Compare commits
No commits in common. "bf4331eed4fb16932f49edeff561c17e1a33b3a5" and "a1ad0b44b1a31ef42ecf485124a9592c9ef757b7" have entirely different histories.
bf4331eed4
...
a1ad0b44b1
|
|
@ -1,449 +0,0 @@
|
|||
name: Build & Package
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- release
|
||||
|
||||
jobs:
|
||||
build:
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: ubuntu-24.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-24.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-24.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
|
||||
# Ninja: build NeuralAudio with it instead of a Visual Studio generator,
|
||||
# so the cmake crate doesn't need to recognize the runner's VS version.
|
||||
choco install ninja -y
|
||||
|
||||
# Activate the MSVC developer environment (runs vcvars) so the cmake crate
|
||||
# building nam-ffi/NeuralAudio can determine the Visual Studio generator.
|
||||
# Without this, cmake-rs panics with "couldn't determine visual studio generator".
|
||||
- name: Set up MSVC developer environment (Windows)
|
||||
if: matrix.platform == 'windows-latest'
|
||||
uses: ilammy/msvc-dev-cmd@v1
|
||||
with:
|
||||
arch: x64
|
||||
|
||||
# ── 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: 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-24.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
|
||||
if: matrix.platform != 'windows-latest'
|
||||
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
|
||||
|
||||
# Windows must build under pwsh, not Git Bash: Git for Windows puts its
|
||||
# usr\bin on PATH ahead of the MSVC tools, so its coreutils `link.exe`
|
||||
# shadows MSVC's linker and rustc fails with "extra operand". pwsh keeps
|
||||
# the MSVC environment (set by msvc-dev-cmd) at the front of PATH.
|
||||
- name: Build release binary (Windows)
|
||||
if: matrix.platform == 'windows-latest'
|
||||
shell: pwsh
|
||||
env:
|
||||
FFMPEG_STATIC: "1"
|
||||
# Force Ninja for the cmake crate (nam-ffi/NeuralAudio): cmake-rs 0.1.54
|
||||
# panics on "unknown VisualStudio version: 18.0" when it auto-detects the
|
||||
# runner's VS generator. Ninja sidesteps that lookup entirely.
|
||||
CMAKE_GENERATOR: Ninja
|
||||
run: |
|
||||
cd lightningbeam-ui
|
||||
cargo build --release --bin lightningbeam-editor
|
||||
|
||||
- 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-24.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-24.04'
|
||||
shell: bash
|
||||
run: |
|
||||
cd lightningbeam-ui
|
||||
RPM_VERSION=$(grep '^version' lightningbeam-editor/Cargo.toml | sed 's/.*"\(.*\)"/\1/' | tr '-' '~')
|
||||
cargo generate-rpm -p lightningbeam-editor --set-metadata="version = \"$RPM_VERSION\""
|
||||
|
||||
- name: Build AppImage
|
||||
if: matrix.platform == 'ubuntu-24.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-24.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-24.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-24.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 lightningbeam-ui/lightningbeam-editor/assets/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-24.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 }}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
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 }}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
# 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
|
||||
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/
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
[submodule "vendor/NeuralAudio"]
|
||||
path = vendor/NeuralAudio
|
||||
url = https://github.com/mikeoliphant/NeuralAudio.git
|
||||
537
ARCHITECTURE.md
537
ARCHITECTURE.md
|
|
@ -1,537 +0,0 @@
|
|||
# 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 `main` branch. Core UI, tools, undo system, and audio integration are implemented.
|
||||
|
||||
## 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/ # Legacy JavaScript frontend (browser-only)
|
||||
├── 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
|
||||
276
CONTRIBUTING.md
276
CONTRIBUTING.md
|
|
@ -1,276 +0,0 @@
|
|||
# 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/ # Legacy JavaScript frontend (browser-only)
|
||||
```
|
||||
|
||||
## Making Changes
|
||||
|
||||
### Branching Strategy
|
||||
|
||||
- `main` - Main development branch
|
||||
- Feature branches - Create from `main` 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 `main` branch:
|
||||
```bash
|
||||
git checkout main
|
||||
git pull origin main
|
||||
```
|
||||
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 `main` 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.
|
||||
103
Changelog.md
103
Changelog.md
|
|
@ -1,106 +1,3 @@
|
|||
# 1.0.5-alpha:
|
||||
Changes:
|
||||
- Add shape tweens (morph vector geometry between keyframes)
|
||||
- Add motion tweens for groups and movie clips
|
||||
- Group geometry and Convert to Movie Clip now work on DCEL vector shapes
|
||||
- Region/lasso select now cuts the shape and feeds the normal selection, so Group, Convert, Delete and Properties all work from a lasso (hold shift to add to the selection)
|
||||
- Clip instances now draw on top of a layer's loose shapes
|
||||
- Add onion skinning for raster and vector layers, with tinted ghosts and settings in the Info Panel
|
||||
- Images can now fill vector shapes (None / Solid / Gradient / Image fill types)
|
||||
- Imported images can be placed on the canvas
|
||||
- Add a raster keyframe timeline UI with explicit keyframe creation; click a keyframe diamond to snap the playhead to it
|
||||
- Stream audio, video and images to and from the project file instead of holding them in memory, supporting arbitrarily long media
|
||||
- Persist (and resume) waveforms and video thumbnails in the project file
|
||||
- Use low-res proxies for fast cold scrubbing of raster frames
|
||||
- Bound memory use for raster pixels, GPU textures, video frames and decoded images on large projects
|
||||
- Video export is roughly 4x faster
|
||||
- Downmix surround video audio to stereo
|
||||
|
||||
Bugfixes:
|
||||
- Fix video export resolution scaling and a post-export UI hang
|
||||
- Fix gamma handling and improve brush canvas performance
|
||||
- Fix a save crash on projects with zero or sparse audio
|
||||
- Fix raster strokes vanishing when committed
|
||||
- Fix image fill mapping (anchor to the fill's bounding box)
|
||||
- Fix video thumbnail strip bugs
|
||||
|
||||
# 1.0.4-alpha:
|
||||
Changes:
|
||||
- Beats are now the canonical time representation (replacing seconds)
|
||||
- Tempo can now be non-constant (variable BPM)
|
||||
- All events now have time references in seconds, measures/beats, and frames
|
||||
- Add piano roll note snapping
|
||||
- Snap to beats in measures mode
|
||||
- Add velocity and modulation editing
|
||||
- Add pitch bend support
|
||||
- Add automation inputs for audio graphs
|
||||
- Add automatable volume and pan controls to default instruments
|
||||
- Add count-in and metronome
|
||||
- Add drawing tablet input support
|
||||
- Set default timeline mode based on activity
|
||||
- Tweaked automation lane appearance
|
||||
- Double CPU rendering performance by switching to tiny-skia
|
||||
|
||||
Bugfixes:
|
||||
- Fix MIDI track recording previews
|
||||
- Fix timeline elements not updating on BPM changes
|
||||
|
||||
# 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
|
||||
|
|
|
|||
674
LICENSE
674
LICENSE
|
|
@ -1,674 +0,0 @@
|
|||
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>.
|
||||
108
README.md
108
README.md
|
|
@ -10,118 +10,42 @@ A free and open-source 2D multimedia editor combining vector animation, audio pr
|
|||
|
||||

|
||||
|
||||
## Features
|
||||
## Current 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
|
||||
- Multi-track audio recording
|
||||
- MIDI sequencing with synthesized and sampled instruments
|
||||
- Integrated DAW functionality
|
||||
|
||||
**Video Editing**
|
||||
- Video timeline and editing with FFmpeg-based decoding
|
||||
- GPU-accelerated waveform rendering with mipmaps
|
||||
- Audio integration from video soundtracks
|
||||
- Basic video timeline and editing (early stage)
|
||||
- FFmpeg-based video decoding
|
||||
|
||||
## 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)
|
||||
- **Frontend:** Vanilla JavaScript
|
||||
- **Backend:** Rust (Tauri framework)
|
||||
- **Audio:** cpal + dasp for audio processing
|
||||
- **Video:** FFmpeg for encode/decode
|
||||
|
||||
## Project Status
|
||||
|
||||
Lightningbeam is developed on the `main` 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.
|
||||
Lightningbeam is under active development. Current focus is on core functionality and architecture. Full project export is not yet fully implemented.
|
||||
|
||||
**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)
|
||||
### Known Architectural Challenge
|
||||
|
||||
## Getting Started
|
||||
The current Tauri implementation hits IPC bandwidth limitations when streaming decoded video frames from Rust to JavaScript. Tauri's IPC layer has significant serialization overhead (~few MB/s), which is insufficient for real-time high-resolution video rendering.
|
||||
|
||||
### 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
|
||||
I'm currently exploring a full Rust rewrite using wgpu/egui to eliminate the IPC bottleneck and handle rendering entirely in native code.
|
||||
|
||||
## 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.
|
||||
Lightningbeam evolved from earlier multimedia editing projects I've worked on since 2010, including the FreeJam DAW. The current JavaScript/Tauri iteration began in November 2023.
|
||||
|
||||
## 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
|
||||
Create a comprehensive FOSS alternative for 2D-focused multimedia work, integrating animation, audio, and video editing in a unified workflow.
|
||||
|
|
@ -1,853 +0,0 @@
|
|||
# Streaming Media To/From Disk — Plan
|
||||
|
||||
**Goal:** Lightningbeam must handle audio and video files (and raster animation, and
|
||||
image assets) of *arbitrary length/size*. Anywhere we touch media we should stream from
|
||||
and to disk when the data is too large to fit comfortably in memory, rather than loading
|
||||
the entire file regardless of size.
|
||||
|
||||
**Scope of this document:** audio, video, raster frames, image-asset paging, **and the
|
||||
`.beam` container format** — these turned out to be one problem, not two. Streaming on load
|
||||
is impossible while the container forces a full decode, so the container decision (below)
|
||||
is now part of this plan.
|
||||
|
||||
## Deferred bugs (do at the end)
|
||||
- [x] **Timeline thumbnail scroll (FIXED):** the strip tiled from the *clamped* visible-left of the
|
||||
clip, so when a clip was scrolled partly off the left it showed the clip's start content at the
|
||||
viewport edge. Now tiled from the clip's **true (unclamped) origin** over its full width, drawing
|
||||
only the tiles intersecting the visible rect (`draw_video_thumbnail_strip` in timeline.rs). Both
|
||||
render sites (collapsed-group + expanded-track) share the helper. *(Compiles; needs in-app check.)*
|
||||
- [x] **Clip thumbnails stop updating (FIXED):** the GPU texture cache was keyed by the *requested*
|
||||
content time, so once a tile cached the first (often far-off) thumbnail it never refreshed as
|
||||
closer ones loaded. `VideoManager::get_thumbnail_at` now also returns the **actual** thumbnail
|
||||
timestamp, and the cache keys on that — so a tile picks up a new texture when a closer thumbnail
|
||||
finishes generating. Existing `retain`-by-visible-clip cleanup keeps it bounded. *(Needs in-app check.)*
|
||||
|
||||
## Raster-keyframe-UI bugs — **[DONE]** (built the raster keyframe timeline UI, 2026-06-20)
|
||||
Both resolved by the raster-keyframe-timeline-UI work: timeline now draws a diamond per
|
||||
`RasterKeyframe` (mirrors vector), `K`/New Keyframe inserts a blank cel via `AddRasterKeyframeAction`
|
||||
(canvas refreshes), paint tools edit the active keyframe instead of lazily creating, diamonds are
|
||||
click-to-seek (pointing-hand cursor), playback prefetches frames, and onion skinning (raster+vector,
|
||||
tinted, Info-Panel settings) is in. (a) canvas-refresh-on-new-keyframe and (b) keyframes-on-timeline
|
||||
are both fixed.
|
||||
|
||||
## Noted enhancements (later, after the phases)
|
||||
- [x] **Surround → stereo downmix (DONE).** Done uniformly in `render_from_file` (`pool.rs`) so it
|
||||
covers every storage type (PCM/InMemory, compressed via symphonia, video-audio via ffmpeg — all
|
||||
flow through this mixer with the source kept multichannel in the read-ahead buffer). New
|
||||
`stereo_downmix_matrix(src_channels)` gives `[L][src]`/`[R][src]` coefficients for the conventional
|
||||
interleave order (FL FR FC LFE BL BR SL SR…) for 3/4/5/5.1/6.1/7.1: full level for the matching
|
||||
front, `1/√2` for centre + each surround, LFE dropped; each row normalized so |coef| sum ≤ 1 to
|
||||
prevent clipping (matches ffmpeg's default). Applied in both the direct-copy and sinc-resample
|
||||
paths (only when `dst==2 && src>2`; unknown layouts fall back to front L/R). Compiles clean.
|
||||
*(Needs in-app check: a 5.1 file now has centre/dialog present and isn't thin; not distorted/clipping.)*
|
||||
Native multichannel support remains a separate, larger project.
|
||||
- **Export speed (audited 2026-06-21):** a 1:14 1080p MP4 took ~9:06 (~7.4x realtime, ~135 ms/frame).
|
||||
Audit **refuted** the per-frame-seek theory — export decodes the source *sequentially*
|
||||
(`video.rs` `need_seek` is false once advancing forward), and readback is already async +
|
||||
triple-buffered. Real hotspots:
|
||||
- **[DONE] #1 — per-frame renderer rebuild.** The export pump built a fresh `vello::Renderer`
|
||||
(full wgpu pipeline init) + empty `ImageCache` *every egui repaint* (`main.rs` ~6218). Now built
|
||||
once per export and reused; also fixed lazy-image export (the throwaway cache had no container
|
||||
path). **Expected the dominant win.**
|
||||
- **[DONE] #2a — encode swscale rebuilt per frame.** `CpuYuvConverter::convert` now caches the
|
||||
RGBA→YUV420p `scaling::Context` + frames in `new()` instead of per call.
|
||||
- **[TODO] #2b — decode swscale + stride-repack** per frame in `video.rs:294-320` (shared with
|
||||
scrubbing; cache the YUV→RGBA scaler on the decoder). Small win, modest risk.
|
||||
- **Result of #1+#2a (measured):** ~7.4x → **~1.74x realtime** (130.7 s for 4488 frames @ 60 fps;
|
||||
34 fps). Per-stage avg: Render(CPU build) 15 ms, **Readback(GPU latency) 42 ms**, Extract 1.3 ms,
|
||||
Convert 5.7 ms.
|
||||
- **Now GPU-bound.** Per ~87 ms poll cycle the CPU does ~66 ms (3× build 45 + convert 17 + extract 4)
|
||||
but the GPU does ~87 ms (3 × ~29 ms composite) → GPU saturated at ~29 ms/frame; "Readback 42 ms" is
|
||||
queue latency, not transfer (8 MB is sub-ms).
|
||||
- **[SKIP] #3 GPU YUV / #5 pacing** — both only trim the CPU side, which is already *under* the GPU.
|
||||
Won't move a GPU-bound throughput.
|
||||
- **[TODO, big] Reduce the GPU composite (~29 ms/frame).** The per-layer HDR pipeline (Vello render →
|
||||
linear → composite, ×layers) is the wall, shared with live rendering. Options: batch composite
|
||||
passes; a fast-path skipping HDR compositing for simple single-layer/no-blend docs; cache unchanged
|
||||
layers' scenes (CPU-side, only helps if it later becomes CPU-bound). Render-architecture project.
|
||||
- Non-issues: per-frame seek, blocking readback, audio. (`video.rs:237` container-reopen-on-seek is
|
||||
a latent cost but doesn't fire on forward export.)
|
||||
- **AAC export NaN guard (done):** `convert_chunk_to_planar_f32` now sanitizes non-finite samples
|
||||
(NaN/Inf → 0, finite clamped to [-1,1]) like the integer paths, with a one-time warning — a stray
|
||||
non-finite render sample no longer fails the whole export. Upstream NaN source (effect/automation/
|
||||
decode) still worth chasing if it recurs.
|
||||
- [x] **Persist video thumbnails (DONE).** Mirrors waveform persistence: each clip's thumbnails are
|
||||
PNG-encoded + packed into one opaque `LBTN` blob (editor owns the format; `encode/decode_thumbnail_blob`
|
||||
in main.rs), stored as a `MediaKind::Thumbnail` row keyed by `thumbnail_media_id(clip_id)` (clip id XOR
|
||||
a fixed sentinel). Save: a cheap Arc-clone snapshot (`VideoManager::snapshot_all_thumbnails`) rides the
|
||||
`FileCommand::Save`, PNG-encoded off the UI thread in the worker, written by `save_beam` (kept in place
|
||||
on re-save). Load: `load_beam_sqlite` reads the packs into `LoadedProject.thumbnail_blobs`; the editor
|
||||
decodes + `insert_thumbnail`s them on a background thread and **gates regeneration** (`register_loaded_videos`
|
||||
skips clips with persisted thumbnails). Bonus: thumbnails show even if the source video file is missing.
|
||||
**Partial sets are persisted and resumed** (not thrown away): the `LBTN` blob (v2) carries a `complete`
|
||||
flag (`VideoManager.thumbnails_complete`, marked when the keyframe pass finishes). On load, complete
|
||||
packs are restored + skip regeneration; *partial* packs are restored AND generation is resumed —
|
||||
`generate_keyframe_thumbnails` takes a `should_skip` predicate (`has_thumbnail_near`) so it only decodes
|
||||
the keyframes not already covered. `insert_thumbnail` is now sorted + idempotent (fixes a latent
|
||||
unsorted-`binary_search` bug and makes concurrent restore + resume race-safe). So a save 50 min into a
|
||||
2 h video keeps that work and continues from there on reload.
|
||||
Container tests still green; all crates compile. *(Needs in-app check: reload = instant thumbnails for
|
||||
complete clips; a mid-generation save resumes from where it left off on reload.)*
|
||||
**Size assessment (done):** thumbnails are 128px wide, height by aspect (72px at 16:9 →
|
||||
128×72×4 ≈ **36 KB raw** each; 4:3 ≈ 49 KB), generated **one per ~5 s** (capped `interval_secs`,
|
||||
at keyframes — so ~12/min). Raw: ~0.5 MB per 1:14 clip, ~26 MB/hour, ~52 MB/2 h. Compressed for
|
||||
on-disk: JPEG ~3–6 KB/thumb → **~6 MB/2 h**; PNG ~8–15 KB → ~14 MB/2 h. So persistence is cheap
|
||||
(≤ the waveform's ~36 MB/2 h), especially as JPEG. Plan: encode each clip's thumbnails (JPEG) +
|
||||
their timestamps into one blob, a new `MediaKind::Thumbnail` row keyed by the clip/media id (mirror
|
||||
the waveform persistence: write on save, restore via `insert_thumbnail` on load, regenerate if
|
||||
absent). The 5 s interval already bounds count; no extra budget needed.
|
||||
- **Progressive waveform on first import:** generation streams the whole file before the
|
||||
waveform appears (several seconds for large files). Since `build_waveform_pyramid` already
|
||||
streams, emit partial floors as it advances (e.g. flush every N seconds of decoded audio via
|
||||
the existing `waveform_result` channel + chunked GPU upload) so the overview fills in across
|
||||
the clip left-to-right instead of appearing all at once. Persistence saves only the final
|
||||
complete pyramid.
|
||||
|
||||
## Guiding principle
|
||||
Three subsystems already have the right streaming primitive; most of the work is wiring,
|
||||
bounding caches, and adding a residency window. The recurring pattern:
|
||||
|
||||
> Keep tiny metadata always-resident, fault the heavy payload in on demand keyed by a
|
||||
> stable ID, and evict everything outside a window around the playhead.
|
||||
|
||||
---
|
||||
|
||||
## Audit summary (where we stand today)
|
||||
|
||||
### Correctly streaming / bounded
|
||||
- Video frame decode/seek/playback (`lightningbeam-core/src/video.rs:191` `get_frame` —
|
||||
keyframe-index seek + decode-until-target, one frame resident).
|
||||
- WAV/AIFF import via mmap (`daw-backend/src/audio/engine.rs:2328`).
|
||||
- Webcam capture encodes directly to disk (`lightningbeam-core/src/webcam.rs`).
|
||||
- `WaveformCache` (100MB cap), decoder `LruCache` (20 frames), export render loop (≤3
|
||||
frames in flight).
|
||||
- The compressed-audio disk reader `daw-backend/src/audio/disk_reader.rs`
|
||||
(`CompressedReader` + 3s `ReadAheadBuffer`) — **correct but never activated** (Phase 1a).
|
||||
|
||||
### Fully-loaded, unbounded by file length (the problems)
|
||||
| Site | Issue |
|
||||
|---|---|
|
||||
| `daw-backend/src/io/audio_file.rs:344` `decode_progressive` | Decodes whole compressed file into a `Vec<f32>`; de-facto playback source. |
|
||||
| `daw-backend/src/audio/pool.rs:1071` `load_file_into_pool` | Every audio file in a saved project fully decoded to `InMemory` on open. |
|
||||
| `lightningbeam-core/src/video.rs:711` `extract_audio_from_video` | Whole video audio track into one `Vec<f32>`. |
|
||||
| `lightningbeam-core/src/video.rs:412` `VideoManager.frame_cache` | Unbounded `HashMap` of full-res RGBA frames; grows while scrubbing. |
|
||||
| `export/mod.rs:388-400` | Mux step buffers all compressed packets into `Vec`s; O(duration). |
|
||||
| `lightningbeam-core/src/raster_layer.rs:115` `RasterKeyframe.raw_pixels` | ~8MB/frame at 1080p; all keyframes decoded from PNG at load (`file_io.rs:611-640`), never evicted. |
|
||||
| `lightningbeam-editor/src/gpu_brush.rs:1051` `raster_layer_cache` | Unbounded GPU texture `HashMap`. |
|
||||
| `lightningbeam-core/src/renderer.rs:25` `ImageCache` | Unbounded decoded image cache (asset textures). |
|
||||
| `Document.image_assets` (`document.rs:206`) | Every image asset's compressed bytes resident for document life. |
|
||||
|
||||
---
|
||||
|
||||
## Container format decision: `.beam` → SQLite *(DECIDED)*
|
||||
|
||||
The `.beam` container moves from a **ZIP archive** to a **SQLite database file** (same
|
||||
`.beam` extension). This is the foundation the rest of the plan builds on.
|
||||
|
||||
### Why
|
||||
ZIP can stream `Stored` entries in place (via `data_start()`), but it has **no in-place
|
||||
mutation** — every save and every raster frame write-back rewrites the whole archive — and
|
||||
embedded PCM is rarely mmap-aligned. The current load path is even worse: it reads each
|
||||
ZIP audio entry fully, decodes FLAC → re-encodes WAV → base64 → base64-decodes → temp file
|
||||
→ full Symphonia decode → resident `Vec<f32>` (`file_io.rs:513-604`, `pool.rs:1071`).
|
||||
|
||||
SQLite dissolves the single-file-vs-performance tension:
|
||||
- **Single file** — beginner-friendly, behaves like a file on every OS (no package-folder
|
||||
confusion; we have no bundle magic on Linux/Windows).
|
||||
- **Streaming reads** — `sqlite3_blob_open` / `blob_read(offset, len)` gives seekable,
|
||||
chunked reads through the pager (mmap mode for the DB). For chunked streaming the
|
||||
pager-copy is negligible vs. decode cost, so the lack of zero-copy mmap doesn't matter.
|
||||
- **Cheap, crash-safe mutation** — raster frame write-back is a transactional `UPDATE`;
|
||||
save is a metadata write + dirty-blob updates. **ACID** means a force-quit / power loss /
|
||||
crash mid-save can't corrupt the project (ZIP and package-dirs both have to hand-roll
|
||||
atomicity).
|
||||
- **Inspectable / scriptable** — `sqlite3` CLI; `beam_inspector.py` can read it directly.
|
||||
|
||||
**Net effect: there is no scratch directory anywhere in this plan.** Media stream via blob
|
||||
reads (or external paths); raster frames live in blob rows and write back transactionally.
|
||||
|
||||
### Large-media policy: packed OR referenced
|
||||
Two storage modes per media item, both supported:
|
||||
- **Packed** — bytes live in the DB. To stay under SQLite's ~2GB per-blob ceiling (and to
|
||||
make reads naturally chunked), large media is split into **multiple blob-chunk rows**
|
||||
(e.g. 64 MB/chunk); streaming reads address `(chunk_index, offset)`.
|
||||
- **Referenced** — the DB stores only a path; bytes stay on disk (useful for shared media
|
||||
on a network drive, or media too large/volatile to pack).
|
||||
|
||||
**Default-mode preference for files over the per-blob limit (~2GB):**
|
||||
- A user preference `large_media_default: Pack | Reference` controls what happens to
|
||||
imports above the threshold.
|
||||
- The **first time** the user imports a media file over the limit, **prompt** them
|
||||
(Pack vs Reference), apply it, and **persist the choice** as the preference for future
|
||||
large imports (changeable later in settings).
|
||||
- Files under the limit are packed by default (chunked only if needed).
|
||||
|
||||
### Schema sketch
|
||||
```
|
||||
media(
|
||||
id BLOB PRIMARY KEY, -- stable Uuid
|
||||
kind INTEGER, -- audio | video | raster | image-asset
|
||||
codec TEXT, -- "flac","mp3","png",... (original, lossless-preserving)
|
||||
storage INTEGER, -- 0 = packed, 1 = referenced
|
||||
ext_path TEXT, -- set when storage = referenced
|
||||
total_len INTEGER, -- bytes (packed) for chunk math
|
||||
channels INTEGER, sample_rate INTEGER, width INTEGER, height INTEGER -- kind-specific meta
|
||||
)
|
||||
media_chunk(
|
||||
media_id BLOB, chunk_index INTEGER, bytes BLOB,
|
||||
PRIMARY KEY (media_id, chunk_index)
|
||||
)
|
||||
project_json(id INTEGER PRIMARY KEY CHECK (id = 0), data TEXT) -- existing project.json, verbatim
|
||||
meta(key TEXT PRIMARY KEY, value TEXT) -- version, created, modified
|
||||
```
|
||||
`project.json` stays the same serialized `BeamProject` for now — only its container and the
|
||||
media storage change. A migration reads a legacy ZIP `.beam` and writes the SQLite form on
|
||||
first open/save.
|
||||
|
||||
### Streaming reads from packed media
|
||||
A `BlobReader` implementing `Read + Seek` over `media_chunk` rows feeds the existing
|
||||
streaming consumers unchanged: `CompressedReader` (audio) decodes from it instead of a
|
||||
`File`; the video decoder seeks within it; raster `UPDATE`s a chunk. Referenced media uses a
|
||||
plain `File` exactly as `do_import_audio` already does for originals today.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Audio: activate what already exists *(highest impact, lowest effort)*
|
||||
|
||||
### 1a. Turn on the compressed-audio disk reader
|
||||
The `CompressedReader` + 3-second `ReadAheadBuffer` in `disk_reader.rs` is complete but
|
||||
never invoked (`DiskReaderCommand::ActivateFile` / `DiskReader::create_buffer` are never
|
||||
called; `AudioClip::read_ahead` at `clip.rs:63` is hard-wired to `None`).
|
||||
- On compressed import (`engine.rs:2381`) and during playback setup, activate the file and
|
||||
assign `AudioClip::read_ahead`.
|
||||
- Change `decode_progressive` (`io/audio_file.rs:344`) to produce only the downsampled
|
||||
waveform overview (min/max peaks) the UI needs, then drop decoded PCM. Playback comes
|
||||
from the ring buffer, not RAM.
|
||||
- Verify `render_from_file` (`pool.rs:449`) reads from `read_ahead` when `data()` is empty.
|
||||
|
||||
**Risk:** the real-time thread must never block on disk. The ring buffer prefetches ~2s
|
||||
ahead; underruns degrade to silence (live) or block-wait (export), which `disk_reader.rs`
|
||||
already distinguishes.
|
||||
|
||||
### 1b. Stream on project load *(depends on the SQLite container)*
|
||||
Three coupled changes (none works alone):
|
||||
1. Replace `load_file_into_pool`'s full decode (`pool.rs:1071`) with the same branching as
|
||||
`do_import_audio`: PCM → mmap (referenced) or in-memory for tiny packed PCM; compressed
|
||||
(incl. FLAC) → `from_compressed` placeholder backed by a `BlobReader` (packed) or `File`
|
||||
(referenced). The claxon FLAC→WAV→base64 round-trip in `file_io.rs:533-591` is deleted.
|
||||
2. **Bulk read-ahead activation:** loaded clips are deserialized directly
|
||||
(`audio_backend.project`), bypassing `AddAudioClip`, so the Phase 1a wiring never fires
|
||||
for them. After the engine installs the project, walk all audio clips and
|
||||
`create_buffer` + `ActivateFile` + set `read_ahead` for every clip referencing a
|
||||
`Compressed` pool entry. (`CompressedReader::open` needs a variant that takes a
|
||||
`BlobReader` instead of a path for packed media.)
|
||||
3. Pool entries carry storage mode (packed-chunks vs referenced path) from the `media`
|
||||
table instead of base64 `embedded_data`.
|
||||
|
||||
### 1c. Video's embedded audio track — stream from the video via ffmpeg
|
||||
|
||||
**Interim stopgap (shipped):** `extract_audio_from_video_to_wav` streams the decoded audio to
|
||||
a temp WAV, imported via `import_audio_sync` (mmap). Fixes the RAM OOM but writes the whole
|
||||
uncompressed track to `/tmp` (fills small temp partitions) and the temp path doesn't survive
|
||||
save/reload. **Superseded by the design below.**
|
||||
|
||||
**Proper design — stream the video's audio track on demand, never materialized.**
|
||||
|
||||
*Enabler:* `daw-backend` already depends on `ffmpeg-next` (used for MP3/AAC encoding), so the
|
||||
ffmpeg audio decoder lives beside `CompressedReader` in `daw-backend/src/audio/`. No
|
||||
cross-crate work (`core → daw-backend` is one-way). `CompressedReader` already has the needed
|
||||
interface.
|
||||
|
||||
1. **`VideoAudioReader` (ffmpeg)** — mirrors `CompressedReader`:
|
||||
`open(path)`, `decode_next(&mut Vec<f32>) -> frames` (resample → interleaved f32 at native
|
||||
rate; reuse the old extraction resampler), `seek(target_frame) -> actual`,
|
||||
`sample_rate`/`channels`/`total_frames`.
|
||||
2. **Source dispatch:** `enum StreamSource { Compressed(CompressedReader), Video(VideoAudioReader) }`
|
||||
(or a small `trait AudioFrameSource`) held by the reader thread; ring buffer / prefetch /
|
||||
export-blocking unchanged. `DiskReaderCommand::ActivateFile` gains a `kind: SourceKind`.
|
||||
3. **Pool model:** `AudioStorage::VideoAudio { video_path, decoded_for_waveform, decoded_frames,
|
||||
total_frames }` (near-copy of `Compressed`); `data()` empty, playback via `read_ahead`. Pool
|
||||
entry `path` = the video file.
|
||||
4. **Engine API:** `EngineController::add_video_audio_sync(video_path) -> usize` — ffmpeg-probe
|
||||
the audio track (rate/channels/frames/duration, no decode), build the pool entry, return index.
|
||||
5. **Clip activation:** extend the Phase 1a `AddAudioClip` wiring — if entry is `VideoAudio`,
|
||||
make the buffer + `ActivateFile{kind:VideoAudio, path:video_path}` + set `clip.read_ahead`.
|
||||
One ffmpeg context + 3 s buffer per active clip instance.
|
||||
6. **Import flow:** `import_video` calls `add_video_audio_sync(video_path)` →
|
||||
`AudioClip::new_sampled`. **Remove** `extract_audio_from_video_to_wav`, the temp-WAV
|
||||
handling, and the now-dead `add_audio_file_sync`. No WAV / `/tmp` / RAM.
|
||||
7. **Save/load:** the `VideoAudio` entry serializes as a path reference to the video (no media
|
||||
bytes — the video is already referenced by its `VideoClip`); reconstruct on load by
|
||||
re-probing. Fixes the stopgap's reload fragility (nothing to persist).
|
||||
8. **Waveform overview:** background ffmpeg pass emitting **downsampled peaks only** (bounded
|
||||
memory) into the existing waveform path — shared with the Phase 1a `decode_progressive`
|
||||
cleanup.
|
||||
|
||||
**Sample accuracy (required — video audio must stay frame-synced with other clips):**
|
||||
Coarse ffmpeg seeks are NOT sufficient. `VideoAudioReader::seek(target_frame)` must:
|
||||
- coarse-seek to a point ≤ target, then **decode-and-discard** to land exactly on
|
||||
`target_frame`, tracking the absolute sample position from decoded-frame PTS (discard whole
|
||||
frames before target; for the frame straddling target, drop its leading samples). After
|
||||
`seek`, `decode_next` yields samples starting at exactly `target_frame`.
|
||||
- This makes frame N of the video-audio pool entry correspond to the exact timeline position,
|
||||
so it mixes sample-aligned with mmap/InMemory clips. Continuous decode advances frame-exact.
|
||||
- *Consistency note:* `CompressedReader` should get the same decode-discard alignment (its
|
||||
current coarse-seek-then-write-at-target can misalign by up to a GOP after a seek). Fold in
|
||||
while here, or at least flag.
|
||||
|
||||
*Model decision (confirmed):* the video's audio stays a **separate, editable `AudioClip`** on
|
||||
an audio track, backed by the `VideoAudio` pool entry — users can move/trim/mute/detach it.
|
||||
|
||||
*Build order:* `VideoAudioReader` + `StreamSource` → pool `VideoAudio` variant →
|
||||
`add_video_audio_sync` + activation → swap `import_video` (remove WAV path) → sample-accurate
|
||||
seek (both readers) → waveform-peaks pass.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Video: bound the caches *(small, isolated)*
|
||||
|
||||
### 2a. Bound `VideoManager.frame_cache`
|
||||
`video.rs:412` — convert the unbounded `HashMap<(Uuid,i64), Arc<VideoFrame>>` to an LRU
|
||||
mirroring the decoder-level cache (`video.rs:34`). Frame-count or byte budget.
|
||||
|
||||
### 2b. Stream the export mux
|
||||
`export/mod.rs:388-400` — interleave-write packets to the output as produced (compare PTS,
|
||||
write the earlier stream) instead of collecting all then writing. O(duration) → O(1).
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Raster: disk-backed keyframe paging *(the heavy one)* **[locked design]**
|
||||
|
||||
Today `load_beam_sqlite` (`file_io.rs:564`) eagerly `decode_png`s **every** raster keyframe's
|
||||
`Raster` media row into `RasterKeyframe.raw_pixels` (`raster_layer.rs:115`, `w·h·4` ≈ 8 MB @
|
||||
1080p, `#[serde(skip)]`), never evicts, has an unbounded GPU texture cache, and holds full-frame
|
||||
undo snapshots. `raw_pixels` is the working rep (edits write it, save reads it, render reads it),
|
||||
`has_pixels()` = `!raw_pixels.is_empty()`, `keyframe_at` is a `partition_point` binary search, and
|
||||
the container is opened only at load/save (no live handle).
|
||||
|
||||
**Design (confirmed with user):** keep `raw_pixels` as the working rep; make residency explicit
|
||||
via a `RasterStore` + an editor-run fault-in/evict pass *before* the immutable render. Async
|
||||
fault-in (no scrub hitch), with a **low-res image proxy** shown until the full frame lands.
|
||||
Decisions: small window (±~2 keyframes); **dirty (edited-unsaved) frames stay fully resident**
|
||||
(spill-to-scratch deferred); fault-in is **async**; proxy is a **per-keyframe low-res RGBA image**
|
||||
(PNG/WebP, correct alpha), NOT a video (VP9-alpha was rejected as finicky for negligible disk win).
|
||||
|
||||
### Drive-by (Arc pixels): DROPPED
|
||||
Investigated and rejected: `raw_pixels` has ~64 access sites, and most `.clone()`s genuinely need
|
||||
an owned `Vec<u8>` (undo buffers, export, GPU readback) so `Arc<Vec<u8>>` would force `(*p).clone()`
|
||||
and still copy. The only beneficiary, the per-frame `renderer.rs:550` Vello clone, is on the
|
||||
**legacy/dead** path — the live HDR canvas renders raster as `RenderedLayerType::Raster` → GPU
|
||||
upload in `stage.rs` which passes a `&[u8]` slice and uploads only on cache-miss (no per-frame
|
||||
clone). Not worth 64 edits. Start at 3a.
|
||||
|
||||
### 3a. Lazy async fault-in + image proxy
|
||||
- **[DONE 3a-1]** Lazy load: full-decode removed; `raw_pixels` empty on load, `needs_fault_in`
|
||||
armed recursively; canvas records misses → App pages in via `RasterStore.load_pixels`.
|
||||
- **[DONE 3a-2]** Async: page-in runs on a background thread (deduped via `raster_loads_inflight`);
|
||||
results applied at top of `update()`. No UI block on cold scrub.
|
||||
- **[DONE 3a-3]** Image proxy: `MediaKind::RasterProxy` (≤192px PNG, derived id), written
|
||||
beside each resident full PNG on save + eager-decoded on load into `RasterKeyframe::proxy`.
|
||||
Separate `proxy_layer_cache` (own LRU, budget 64); the raster render blits the proxy mapped to
|
||||
the keyframe's FULL logical dims (upscales via sampler) when the full texture isn't resident.
|
||||
*(Proxies exist only after a save+reload; eager decode → lazy/paged is a refinement for huge
|
||||
paint projects.)*
|
||||
|
||||
- **`RasterStore`** (core): current `.beam` path + a read-only connection; `load_pixels(kf_id,w,h)`
|
||||
reads the `Raster` row and `decode_png`s it. Set/cleared by the editor on load + save-as.
|
||||
- **Save:** alongside the full PNG, write a low-res RGBA proxy per resident keyframe
|
||||
(`MediaKind::RasterProxy`, ≤~480px long edge, keyed by `kf.id`).
|
||||
- **Load:** stop eager full-decode; decode **proxies** eagerly (cheap → instant scrub everywhere);
|
||||
leave full `raw_pixels` empty.
|
||||
- **Fault-in pass** (editor, `&mut document` + store, each frame before render): for each raster
|
||||
layer ensure the active keyframe ±N is requested; load full PNGs on a **background thread pool**;
|
||||
on arrival, set `raw_pixels` + `texture_dirty`. Render uses full `raw_pixels` if resident, else the
|
||||
upscaled proxy. Reused by the exporter (already frame-by-frame).
|
||||
|
||||
### 3b. Residency window + eviction **[DONE]**
|
||||
- Added `#[serde(skip)] dirty: bool` (edited-since-persist; distinct from `texture_dirty`). Set on
|
||||
stroke/fill/paint-bucket/floating-lift commits + undo/redo; cleared on save (which re-arms the LRU).
|
||||
- Implemented as a fault-in-recency **LRU** (`RASTER_RESIDENT_MAX = 12`), not a strict ±N window:
|
||||
evict the oldest **clean** frame (drop `raw_pixels`, re-arm `needs_fault_in`); the shown frame is
|
||||
always most-recent so it's protected; **dirty frames never evicted**. Save preserves evicted frames'
|
||||
rows via `media_exists` (no data loss) and walks all layers to match load.
|
||||
*(Refinement deferred: count budget → byte budget for 4K resolution-robustness.)*
|
||||
|
||||
### 3c. Bound the GPU cache **[DONE for raster_layer_cache]**
|
||||
`raster_layer_cache` (`gpu_brush.rs`, `HashMap<Uuid,CanvasPair>`, Rgba16Float ping-pong
|
||||
≈ `w·h·16`/entry, was **unbounded**) → recency LRU (`RASTER_LAYER_CACHE_MAX = 12`) in
|
||||
`ensure_layer_texture`: bump-to-most-recent + evict oldest; shown frames protected. F3 overlay
|
||||
now shows tracked VRAM (raster cache MB + count). *(Refinements: count→byte budget; raise/headroom
|
||||
if >12 raster layers are visible at once. Export `raster_cache` lives one export — fine. Vello
|
||||
`ImageCache` is image *assets* → Phase 4.)*
|
||||
|
||||
### 3d. Undo memory **[DONE]**
|
||||
`RasterStrokeAction`/`RasterFillAction` stored `buffer_before`+`buffer_after` full frames.
|
||||
Now store a `RasterDiff` (`actions/raster_diff.rs`) — changed bbox before/after only, computed in
|
||||
`new()`, full buffers dropped. Undo/redo apply onto the keyframe's resident pixels; the editor
|
||||
faults the target frame in first (`Action::raster_resident_hint` + `peek_undo/redo_raster_hint`),
|
||||
correct because a clean evicted frame's container bytes == its logical state. Non-resident base ⇒
|
||||
skip (no corruption). Unit-tested round-trip. *(Refinement: compress full-canvas-fill diffs, whose
|
||||
bbox is the whole frame.)*
|
||||
|
||||
### 3e. Prefetch frames **[DONE for playback]**
|
||||
Implemented for playback: each update during playback, page in the next `PREFETCH_AHEAD=4`
|
||||
upcoming keyframes per raster layer (reusing the async worker + `raster_loads_inflight` dedup), so
|
||||
full frames are resident before the playhead arrives — fixes "proxy on every frame"/flicker during
|
||||
playback. *(Caveat: with many simultaneous raster layers the 12-frame resident budget may evict a
|
||||
prefetched frame before it's shown — raise budget or scale prefetch if that surfaces. Scrub-direction
|
||||
prefetch still TODO.)*
|
||||
|
||||
Original note: *(future, after 3d — pure latency win, no correctness need)*
|
||||
Fault-in is reactive (page in only on a render miss), so a never-visited frame still shows the
|
||||
proxy for a beat before the full lands. **Prefetch the full pixels for frames about to be shown**:
|
||||
on scrub/playback, dispatch background page-ins for the active keyframe ±N in the direction of
|
||||
playhead motion (and during playback, the next K keyframes), reusing the 3a-2 async worker +
|
||||
`raster_loads_inflight` dedup. Keep prefetched frames in the 3b LRU so they're still bounded; cap
|
||||
concurrent prefetch loads so scrubbing fast doesn't thrash the disk. Optional: also prewarm the GPU
|
||||
texture (3c cache) for the immediate next frame. Net effect: cold scrubbing/playback shows full-res
|
||||
frames with no proxy flicker. Proxy stays as the instant fallback when prefetch can't keep up.
|
||||
|
||||
### Build order & tests
|
||||
1. Arc drive-by — COW make_mut test. 2. 3a fault-in + store + proxy — load→empty-until-faulted,
|
||||
PNG round-trip, proxy-then-swap. 3. 3b window/evict/dirty — residency ≤ window while scrubbing,
|
||||
dirty never evicted. 4. 3c GPU bound. 5. 3d undo diffs reproduce pre-stroke buffer exactly.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3.5 — Image textures in vector scenes **[DONE 2026-06-21]** *(prereq for Phase 4; fixed DCEL-broken image import)*
|
||||
|
||||
**Done:** 3.5a — import/drop places an image as a borderless image-filled rectangle
|
||||
(`AddShapeAction::image_rect`), centered (direct import) or at the drop point (library drag);
|
||||
renderer now maps the image brush onto the fill's bounding box (was anchored at world origin →
|
||||
only a corner showed); `SetImageFillAction` + an **Image** fill-type tab (None|Solid|Gradient|Image)
|
||||
with an asset picker in the Info Panel. 3.5b — image bytes persist as `MediaKind::ImageAsset` rows in
|
||||
the `.beam` (kept-in-place; `ImageAsset.data` is `skip_serializing` + container-backed; old base64
|
||||
projects migrate on re-save); eager-read on load. *(ImageCache still unbounded — Phase 4 adds the
|
||||
usage-based LRU/lazy paging.)*
|
||||
|
||||
### (original plan below)
|
||||
## Phase 3.5 — Image textures in vector scenes *(prereq for testing Phase 4; fixes DCEL-broken image import)*
|
||||
|
||||
**Why:** Phase 4 pages *image assets*, but there's currently no way to get an image asset into a
|
||||
vector scene — so nothing to page. This also repairs image import, half-broken since the DCEL switch.
|
||||
|
||||
**Current state (audited 2026-06-21):**
|
||||
- *Works:* `import_image` (`main.rs`) decodes dims + creates an `ImageAsset` (raw bytes embedded in
|
||||
`Document::image_assets`, serialized as **base64 in project JSON**). The renderer's image-fill paths
|
||||
are **complete** — GPU/Vello (`renderer.rs:~1160`, `ImageBrush` via `ImageCache.get_or_decode`) and
|
||||
CPU/tiny-skia (`renderer.rs:~1486`). `Fill::image_fill` (`vector_graph/mod.rs:110`) and
|
||||
`Face::image_fill` (`dcel2/mod.rs:117`) fields exist and render when set.
|
||||
- *Broken/missing (the workflow):*
|
||||
1. **Drop image → canvas is stubbed:** `stage.rs:~11782` and `main.rs:~4924` both just print
|
||||
"Image drag to stage not yet supported with DCEL backend". Nothing is added to the scene.
|
||||
2. **No way to assign an image fill:** no `SetImageFillAction` (only `SetFillPaintAction` for
|
||||
color/gradient); no Info-Panel picker. `Fill`/`Face.image_fill` are never populated.
|
||||
3. **DCEL faces never get `image_fill`** (`dcel2/import.rs:275` always `None`; topology copies from
|
||||
parent which is also `None`).
|
||||
4. **Not in the container:** `MediaKind::ImageAsset` exists but is **dead** — image bytes live only
|
||||
as base64 in project JSON. Not chunked, not pageable (so Phase 4 can't page them).
|
||||
|
||||
**Tasks:**
|
||||
- **3.5a — Place + assign.** Replace the two drop stubs: dropping an image onto a vector layer creates
|
||||
a rectangle face sized to the image at the drop point with `image_fill = asset_id`. Add
|
||||
`SetImageFillAction` (set/clear an image fill on the selected face/shape; mirrors `SetFillPaintAction`)
|
||||
+ an Info-Panel image-asset picker for the selected shape's fill. Populate `Face.image_fill` in DCEL
|
||||
(and keep it through topology ops — already copied from parent).
|
||||
- **3.5b — Persist in the container.** Write image assets as `MediaKind::ImageAsset` rows in the `.beam`
|
||||
SQLite (like raster/audio: write on save kept-in-place on re-save; read on load), keyed by asset id;
|
||||
drop the base64-in-JSON embedding (or keep a tiny ref). This is the storage Phase 4 pages from.
|
||||
- **3.5c — Lazy decode hook.** Image bytes load from the container into `ImageCache` on first render
|
||||
(decode → `ImageBrush`/`Pixmap`). Leave `ImageCache` **unbounded for now**; Phase 4 adds the
|
||||
usage-based LRU/eviction (this phase just makes there *be* real, container-backed image assets to page).
|
||||
- **Tests:** import→drop→render round-trip; save/reload preserves the image fill + reads bytes from the
|
||||
container (not JSON); CPU and GPU render paths both show the image.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Asset paging by usage + LRU *(vector's real cost is assets, not geometry)*
|
||||
|
||||
Vector geometry is compact flat POD (tens of KB/frame, no cached tessellation/DCEL) — leave
|
||||
it resident. The heavy, evictable thing is the **image assets** referenced by fills.
|
||||
|
||||
**Data model.**
|
||||
- `ImageAsset` (`clip.rs:250`): `path: PathBuf` + `data: Option<Vec<u8>>` (whole compressed
|
||||
file bytes) + dims. Imported fully into `data` at `main.rs:3936`.
|
||||
- All assets resident in `Document.image_assets: HashMap<Uuid, ImageAsset>` (`document.rs:206`).
|
||||
- Decoded form in `ImageCache` (`renderer.rs:25`): `HashMap<Uuid, Arc<ImageBrush>>` + CPU
|
||||
`Pixmap` map, keyed by asset id, **unbounded**.
|
||||
- A `Fill` references an asset by `image_fill: Option<Uuid>` (`vector_graph/mod.rs:110`).
|
||||
Same UUID may appear in many fills/keyframes/layers and recursively through clip instances.
|
||||
**No asset→frame or frame→asset index exists today.**
|
||||
|
||||
**Two evictable tiers:** Tier 1 = compressed bytes (`ImageAsset.data`, droppable, reload
|
||||
from blob row or external `path`); Tier 2 = decoded pixels (`ImageCache` + GPU textures —
|
||||
the heavy one).
|
||||
|
||||
**Progress (2026-06-21):**
|
||||
- **[DONE] Tier 2 — bound the decoded `ImageCache`.** 256 MB **usage-LRU**: every
|
||||
`get_or_decode`/`_cpu` bumps the asset's recency; inserts past budget evict the least-recently-used
|
||||
(a miss re-decodes from `asset.data`). Achieves usage-based eviction via render-access recency
|
||||
(simpler than the frame→asset enumeration below; that enumeration is only needed for *prefetch*).
|
||||
- **[DONE] Tier 1 — lazy compressed bytes.** `ImageCache` holds the container path (threaded
|
||||
App.current_file_path → SharedPaneState → VelloRenderContext) and pages bytes on a decode miss via
|
||||
`read_packed_media_readonly`; `load_beam_sqlite` no longer eager-reads → instant load, compressed
|
||||
bytes don't accumulate. `asset.data` is still used when resident (fresh import / old base64 project).
|
||||
*(Refinement: persistent read connection vs open-per-miss.)*
|
||||
- **[DONE] Prefetch.** `assets_needed_at(document, time)` enumerates image ids in the visible vector
|
||||
layers' active keyframes; during playback the stage decodes the ~0.5s-ahead set into the cache.
|
||||
*(Refinements: nested clip-instance recursion; background-thread decode.)*
|
||||
|
||||
**Phase 4 = DONE** (image asset paging by usage + LRU).
|
||||
|
||||
### 4a. Frame→asset enumeration (incl. nested clips — see note below)
|
||||
A function `assets_needed_at(time) -> HashSet<Uuid>`: walk each visible vector layer's active
|
||||
`ShapeKeyframe`, collect `fill.image_fill` across its `VectorGraph.fills`, **recursing into
|
||||
clip instances** with the outer→inner local-time mapping. This is "needed now". Scanning
|
||||
upcoming keyframes (and upcoming nested-clip keyframes) gives "needed soon" for prefetch.
|
||||
|
||||
### 4b. Usage bookkeeping (the multi-frame problem)
|
||||
Maintain a reverse index `asset_id → usage count` (fills referencing it across the whole
|
||||
document), updated incrementally as edits add/remove `image_fill`s (hook the fill-mutation
|
||||
paths in `vector_graph` and the relevant actions).
|
||||
- count 0 → dead, fully evictable / GC candidate.
|
||||
- count > 0 → keep metadata; residency of `data`/decoded pixels driven by **proximity to
|
||||
playhead**, not by count (a high-count asset far from the playhead is still evicted).
|
||||
|
||||
Residency decision: `resident = needed-now ∪ needed-soon`; beyond that, an **LRU with a byte
|
||||
budget** for referenced-but-distant assets (covers scrubbing back without a reload).
|
||||
Eviction never touches an asset in needed-now.
|
||||
|
||||
### 4c. Bound the decoded tier
|
||||
Convert `ImageCache`'s two maps to LRU/byte-budgeted (`renderer.rs:25`) and bound the GPU
|
||||
image-texture cache the same way, keyed to the residency window.
|
||||
|
||||
### Nested-clip prefetch (important)
|
||||
A clip instance placed on an outer frame has its **own internal timeline of keyframes**,
|
||||
each of which can reference its own image assets. Prefetch must therefore:
|
||||
- Recurse through clip instances when computing both needed-now and needed-soon.
|
||||
- Map outer playhead time → each nested clip's local time, and look ahead along the
|
||||
**nested** timeline (not just the outer one) so assets used by an upcoming *inner*
|
||||
keyframe are loaded before the nested clip reaches it.
|
||||
- Deduplicate across the whole recursion (an asset shared by outer and inner frames counts
|
||||
once); the usage index handles refcounting.
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting: a shared residency abstraction
|
||||
|
||||
A generic **`PagedStore<Id, Payload>`** with three consumers — always-resident metadata,
|
||||
disk backing, residency = window/needed-set around playhead + LRU byte budget:
|
||||
|
||||
| Consumer | Metadata kept | Paged payload | Backing | "Needed now" key |
|
||||
|---|---|---|---|---|
|
||||
| Raster keyframes (Ph 3) | id, dims, time | `raw_pixels` + GPU texture | SQLite blob row (`UPDATE` on write-back) | active keyframe per layer |
|
||||
| Image assets (Ph 4) | id, dims, storage | `data` bytes + decoded pixels/texture | SQLite blob row or external path | fills' `image_fill` set at time (recursive) |
|
||||
| Video frames (Ph 2a) | — | RGBA frame | source via ffmpeg seek | requested timestamps |
|
||||
|
||||
Audio stays separate (real-time ring buffer, different constraints). The frame→asset
|
||||
enumeration + usage index is unique to Phase 4.
|
||||
|
||||
---
|
||||
|
||||
## Sequencing
|
||||
1. **Phase 1a** — done; independent of the container, works with the current ZIP loader.
|
||||
2. **Phase 2** — small, isolated, independently shippable; container-independent.
|
||||
3. **Phase 0 (container)** — `.beam` ZIP → SQLite + `BlobReader` + large-media policy +
|
||||
legacy-ZIP migration. Prerequisite for 1b/1c/3/4.
|
||||
4. **Phase 1b** — streaming pool loader + bulk read-ahead activation (on the SQLite store).
|
||||
5. **Phase 1c** — depends on 1b's pool path.
|
||||
6. **Phase 3** — the substantial build; implement `PagedStore` over blob rows.
|
||||
7. **Phase 4** — thin layer on the same abstraction + the frame→asset/usage index.
|
||||
|
||||
Phase 1a and Phase 2 can ship now; everything else waits on Phase 0 (the container).
|
||||
|
||||
---
|
||||
|
||||
## Status
|
||||
- [~] Phase 1a — activate compressed-audio disk reader ← **in progress**
|
||||
- [x] Wire `ActivateFile` + assign `clip.read_ahead` on `AddAudioClip` for compressed
|
||||
pool files (`engine.rs:909`). Per-clip reader keyed by `clip_id`; matches the
|
||||
existing `DeactivateFile` convention in `RemoveAudioClip`. Compiles clean.
|
||||
- [ ] Stop `decode_progressive` (`io/audio_file.rs:344`) from accumulating/streaming the
|
||||
full PCM; emit only the downsampled waveform overview. (Crosses into the UI
|
||||
waveform pipeline — `AudioDecodeProgress` consumer — so handled as its own step.)
|
||||
- [ ] Runtime verification: confirm a compressed clip actually plays from the ring
|
||||
buffer (was effectively silent before, since `read_ahead` was always `None`).
|
||||
- [~] **Phase 0 — container migration `.beam` ZIP → SQLite** ← **in progress**
|
||||
- [x] SQLite schema (`media`, `media_chunk`, `project_json`, `meta`) + `rusqlite` dep
|
||||
(bundled) — `lightningbeam-core/src/beam_archive.rs`
|
||||
- [x] `BlobReader` (`Read + Seek` over `media_chunk`, owns its own read-only connection,
|
||||
opens a blob handle per read with rowids resolved once) — for `CompressedReader` /
|
||||
video decoder in 1b. 5 integration tests pass (`tests/beam_archive.rs`): json
|
||||
round-trip, packed full read, streaming reads + seeks across chunk boundaries,
|
||||
referenced-path, overwrite-replaces-chunks.
|
||||
- [x] Packed (chunked) + referenced media write/read API; `is_sqlite()` format detection;
|
||||
`MediaKind`/`MediaStorage`/`MediaMeta`/`MediaInfo`.
|
||||
- [x] `BeamArchive::transaction()` / `BeamTxn` — in-place transactional save (only
|
||||
changed rows written; unchanged large media never rewritten); orphan cleanup via
|
||||
`retain_media`. 7 archive tests pass (added txn-grouping + rollback). Per user: save
|
||||
must NOT copy+rename for existing SQLite files.
|
||||
- [x] Wire `save_beam` to `BeamArchive` — in-place txn for existing SQLite, temp+rename
|
||||
only for new/migrated files. Audio → packed (or referenced ≥2GB) `media` rows;
|
||||
raster → PNG `media` rows keyed by keyframe id. FLAC→WAV→base64 save round-trip
|
||||
deleted (now packs original bytes with their codec).
|
||||
- [x] Wire `load_beam` — format dispatch: SQLite (`load_beam_sqlite`) vs legacy ZIP
|
||||
(`load_beam_zip_legacy`, kept verbatim). SQLite load reconstitutes packed audio into
|
||||
`embedded_data` so the existing pool loader is unchanged (streaming = Phase 1b).
|
||||
- [x] Legacy ZIP `.beam` → SQLite migration: `is_sqlite()` routes load; saving a
|
||||
ZIP-loaded project writes SQLite (migrates on save). Editor compiles end-to-end.
|
||||
- [x] Large-media policy: packed (chunked) vs referenced — `LargeMediaMode {Ask,Pack,
|
||||
Reference}`; save honors it for files ≥`LARGE_MEDIA_THRESHOLD`. Packing streams from
|
||||
disk via `put_media_packed_from_path` (chunk-by-chunk, never loads the whole file).
|
||||
`Ask` behaves as `Reference` at save time.
|
||||
- [x] `large_media_default` user preference: persisted in `AppConfig`, editable in
|
||||
Preferences → Advanced (incl. resetting to `Ask` to re-trigger the prompt).
|
||||
- [x] First-import-over-threshold prompt: `note_possible_large_media` (hooked into
|
||||
import_audio/video/image) queues a one-time modal; choice persists to config.
|
||||
Threshold shown in the modal is derived from the constant.
|
||||
- [ ] Runtime verification: save a real project, reopen it, confirm audio + raster survive
|
||||
round-trip; confirm an old ZIP `.beam` still opens and migrates on save.
|
||||
- [ ] (Optimization, later) FLAC-compress packed PCM/WAV audio; raster disk-dirty flag to
|
||||
skip unchanged frames on in-place save (Phase 3).
|
||||
|
||||
> Note: the crate's internal `#[cfg(test)]` modules (`clip.rs`, `effect_layer.rs`) have
|
||||
> pre-existing compile breakage (old `Beats`/`TempoMap` API) unrelated to this work; it
|
||||
> blocks `cargo test --lib`, so `beam_archive` tests live in `tests/` (integration) which
|
||||
> build the lib in normal mode. Worth fixing separately.
|
||||
- [x] Phase 1b — stream on project load (PACKED audio path complete & user-verified: streams on load,
|
||||
waveform generates + persists, sample-accurate seeking). Referenced-path streaming + MP3 seek index
|
||||
+ proper video-audio reload remain as noted follow-ups.
|
||||
- **Decision (user):** cross-crate packed streaming via an **inversion-of-control factory** —
|
||||
daw-backend defines the interface, core implements it over `BlobReader`. Keeps the audio
|
||||
engine container-agnostic. (Alternatives rejected: daw-backend owning rusqlite = layering
|
||||
violation; referenced-only-first = leaves packed <2GB in RAM.)
|
||||
- **Current load reality (why this is needed):** *nothing* streams on load today — every entry
|
||||
is fully decoded to a PCM `Vec<f32>`. Packed audio is base64-reconstituted into `embedded_data`
|
||||
(`load_beam_sqlite`) → written to a temp file → `load_file_into_pool` full-decodes; referenced
|
||||
audio also full-decodes via `load_file_into_pool`; and the Phase 1a/1c disk-reader activation
|
||||
never fires for loaded clips (they bypass `AddAudioClip`).
|
||||
- [x] **B1/B2 foundation (DONE, headless-tested):** in `disk_reader.rs` — `trait MediaByteSource:
|
||||
Read+Seek+Send+Sync { byte_len }` + `trait AudioBlobSourceFactory: Send+Sync { open(media_id)
|
||||
-> Box<dyn MediaByteSource> }`; `SymphoniaByteSource` adapter (impl `MediaSource`,
|
||||
is_seekable/byte_len); `CompressedReader::open_source(src, ext)` sharing probe via a
|
||||
refactored `from_mss`; `enum StreamOpen { Path, Source{src,ext} }`; `StreamSource::open` and
|
||||
`DiskReaderCommand::ActivateFile` now take `StreamOpen` (engine site wraps `Path`); re-exported
|
||||
`AudioBlobSourceFactory`/`MediaByteSource` at `daw_backend::audio`. Test
|
||||
`tests/compressed_source_stream.rs` decodes an in-memory WAV through a `Cursor`-backed
|
||||
`MediaByteSource` (proves probe+decode+seek over a byte stream). daw-backend compiles clean.
|
||||
- [x] **B3 (engine, DONE):** `Engine.blob_source_factory: Option<Arc<dyn AudioBlobSourceFactory>>` +
|
||||
`EngineController::set_blob_source_factory` (via `Query::SetBlobSourceFactory`, ordered before
|
||||
`SetProject` on the same queue). `AudioFile.packed_media_id: Option<String>` (Some ⇒ open via
|
||||
factory using `original_format` as the ext hint; None ⇒ `StreamOpen::Path`). Activation factored
|
||||
into `Engine::activate_streaming_for(reader_id, pool_index)`, used by `AddAudioClip` and bulk.
|
||||
- [x] **C (core factory, DONE):** `file_io::blob_source_factory(beam_path)` → `BeamBlobFactory`
|
||||
implementing `AudioBlobSourceFactory` over `BeamArchive::open_blob_reader`. `BlobReader` holds a
|
||||
`!Sync` rusqlite `Connection`, so it's wrapped in `SyncBlobReader` (a `Mutex` used via `get_mut`
|
||||
on the hot path — no runtime locking) to satisfy Symphonia's `MediaSource: Send + Sync`. Installed
|
||||
by the editor between `load_audio_pool` and `set_project`.
|
||||
- [x] **D (load-path, DONE — packed audio):** `load_beam_sqlite` now streams packed audio whose codec
|
||||
is recognized (`is_streamable_audio_codec`) — leaves `embedded_data` empty so the pool builds a
|
||||
Compressed placeholder with `packed_media_id`; no base64, no temp file, no decode. `serialize`
|
||||
round-trips packed entries by media id (so in-place re-save keeps the row). Non-audio codecs
|
||||
(video-container audio tracks) keep the legacy reconstitution path → **no regression**.
|
||||
- [x] **E (bulk activation, DONE):** `SetProject` calls `Engine::activate_all_streaming_clips` —
|
||||
walks every loaded audio clip and `activate_streaming_for` (create_buffer + `ActivateFile` + set
|
||||
`read_ahead`), the loaded-clip equivalent of the Phase 1a wiring.
|
||||
- [x] **Waveform-on-load for streamed audio (DONE):** streaming broke the old waveform path (it came
|
||||
from the full in-RAM decode, which no longer happens). Added
|
||||
`disk_reader::build_waveform_pyramid_from_source(Box<dyn MediaByteSource>, ext, B)` (load-time
|
||||
counterpart of the path-based builder). On load, the editor background-generates a pyramid for any
|
||||
streamed entry lacking a persisted one (opens the packed blob via a local factory), sending the
|
||||
floor through the same `waveform_result` channel `update()` drains; the next save persists it.
|
||||
Verified in-app: packed MP3 **streams + plays** (`Activated reader=0, kind=CompressedAudio`); the
|
||||
overview now fills in shortly after load.
|
||||
- **Headless tests pass** (compressed_source_stream, video_audio_stream, waveform_pyramid); all three
|
||||
crates compile clean. **Needs in-app verification:** the waveform appears after load (background gen),
|
||||
then instantly on subsequent loads once saved; RAM stays flat on a big project.
|
||||
- [x] **Seek alignment fix (DONE):** streamed compressed audio was ~1.2s off *after seeking*
|
||||
(fine from the start). `CompressedReader::seek` used `SeekMode::Coarse`, which for MP3
|
||||
byte-estimates the position and seeds the timestamp from that estimate — wrong for VBR / files
|
||||
whose header padding the estimate ignores, so `actual_ts` (and thus the buffer's frame labels)
|
||||
landed ~1.2s early. Switched to `SeekMode::Accurate`: Symphonia counts frame *headers* (no
|
||||
decode) from a true anchor (current pos, or rewind-to-0 for backward seeks) → exact `actual_ts`;
|
||||
the existing sub-frame `pending_discard` finishes the job. FLAC/OGG seek cheaply (seek tables);
|
||||
a long MP3 backward seek walks headers from 0 (I/O, not decode). Tests still green.
|
||||
- [ ] **Deferred (follow-up):** per-file **seek index** for elementary streams (MP3) — a one-time
|
||||
header scan (ts↔byte map) to make far seeks O(1) instead of an Accurate header-walk from the
|
||||
anchor. Matters for multi-hour MP3s; song-length files are fine as-is.
|
||||
- [x] **Proper video-audio reload (DONE):** a video's audio is now stored as a **path reference** to
|
||||
the video (never packed/embedded as audio media) and **re-probed via FFmpeg** on load into a
|
||||
streaming `VideoAudio` entry — `AudioPoolEntry.is_video_audio` flag drives both `serialize`
|
||||
(reference, not pack), `save_beam` (`reference_it |= is_video_audio`), and `load_from_serialized`
|
||||
(`VideoAudioReader::open` → `from_video_audio`). Fixes 5.1 audio losing its channels on reload
|
||||
(the old Symphonia reconstitution collapsed it); also no more decode-whole-video-to-RAM / temp
|
||||
files on load. Old saves (video mis-packed as audio) self-heal on the next save.
|
||||
- [ ] **Deferred (follow-up):** stream *referenced* (external-path) **audio** on load too — replace
|
||||
`load_file_into_pool`'s full decode with the `do_import_audio` branching (PCM → mmap, compressed
|
||||
→ `from_compressed` placeholder). Higher risk (touches the working referenced path); packed
|
||||
covers the common <2GB case first.
|
||||
- [ ] **Deferred (follow-up): packed video streaming.** Let small videos be packed into the `.beam`
|
||||
(a `MediaKind::Video` blob, `VideoClip` referencing it by id) and stream **both frames and audio**
|
||||
from the DB blob via FFmpeg. ffmpeg-next has no custom-I/O wrapper, so this needs an
|
||||
`AVIOContext`-over-`BlobReader` shim via raw FFI. **Decision (user):** that FFI wrapper lives in
|
||||
its **own crate, version-pinned to the ffmpeg version**, isolating the unsafe + the ABI coupling.
|
||||
- [~] Phase 1c — video embedded-audio track ← **stopgap shipped; proper design next**
|
||||
- [x] Stopgap: `extract_audio_from_video_to_wav` streams to a temp WAV → `import_audio_sync`
|
||||
(mmap). Fixed the ~2.8GB-`Vec<f32>` OOM. But writes the whole WAV to `/tmp` (fills
|
||||
small temp partitions) and the temp path doesn't survive reload.
|
||||
- [~] **Proper design** (see "Phase 1c" body): stream the video's audio on demand via a new
|
||||
ffmpeg `VideoAudioReader` in the disk reader — no extraction, no `/tmp`, no RAM; path
|
||||
reference survives save/load.
|
||||
- [x] **Step 1 (DONE):** `VideoAudioReader` (ffmpeg) + `StreamSource` enum + `SourceKind`
|
||||
in `disk_reader.rs`. Sample-accurate seek (coarse seek + decode-discard to exact
|
||||
frame via PTS). 2 integration tests pass (`daw-backend/tests/video_audio_stream.rs`):
|
||||
in-order decode + sample-exact seek at several targets. (Found: mono frames have an
|
||||
empty channel layout → must `set_channel_layout` before resampling, else swr returns
|
||||
AVERROR_INPUT_CHANGED.) Lib compiles clean; `StreamSource` `#[allow(dead_code)]`
|
||||
until wired. `VideoAudioReader` made `pub` for the integration test.
|
||||
- [x] **Step 2 (DONE):** `AudioStorage::VideoAudio { decoded_for_waveform, decoded_frames,
|
||||
total_frames }` + `AudioFile::from_video_audio` (path = the video file). `data()`
|
||||
empty / `read_samples()` 0 (streamed). `Query::AddVideoAudioSync` +
|
||||
`do_add_video_audio` (probes via `VideoAudioReader::open`, no decode) +
|
||||
`EngineController::add_video_audio_sync`. `GetPoolAudioSamples` surfaces VideoAudio's
|
||||
waveform overview too. daw-backend compiles clean; probe `total_frames` test passes.
|
||||
- [x] **Step 3 (DONE):** reader thread now holds `StreamSource` (opens via
|
||||
`StreamSource::open(path, kind)`, dispatches `sample_rate()/channels()/seek/decode_next`);
|
||||
`ActivateFile` carries `kind: SourceKind`; `#[allow(dead_code)]` removed. `AddAudioClip`
|
||||
activation maps `Compressed`→`CompressedAudio`, `VideoAudio`→`VideoAudio`, creates the
|
||||
read-ahead buffer + `ActivateFile{kind}` + sets `clip.read_ahead`. Compressed path is
|
||||
behaviorally identical (StreamSource::Compressed wraps the same CompressedReader).
|
||||
daw-backend + editor compile clean; VideoAudioReader tests still pass.
|
||||
⚠️ Not runtime-verified — needs in-app check that compressed audio still plays (no
|
||||
regression) and that an activated VideoAudio clip produces sound.
|
||||
- [x] **Step 4 (DONE):** `import_video` now calls `add_video_audio_sync(video_path)` →
|
||||
pool index, fetches channels/sample_rate via `get_pool_file_info`, makes the
|
||||
`AudioClip` with the video's duration. **No WAV / /tmp / RAM.** Removed the stopgap
|
||||
(`extract_audio_from_video_to_wav` + WAV helpers + `ExtractedAudioInfo`), dead
|
||||
`add_audio_file_sync` (+ `Query::AddAudioFileSync` / `QueryResponse::AudioFileAddedSync`
|
||||
/ handler), and the now-unreachable `AudioExtractionResult::NoAudio`. Kept
|
||||
`import_audio_sync` (still used by normal audio import). daw-backend + editor clean.
|
||||
**→ Feature is live end-to-end; ready for in-app testing.**
|
||||
- [x] **Step 5 (DONE):** `CompressedReader` now seeks sample-accurately too — coarse
|
||||
symphonia seek + decode-discard (`pending_discard` set from `seeked.actual_ts` in
|
||||
`seek`, applied in `decode_next`, which continues rather than reporting EOF when a
|
||||
whole packet is discarded). So compressed clips no longer drift vs video audio after
|
||||
a seek. Test `compressed_reader_seek_is_sample_accurate` passes (the WAV coarse seek
|
||||
lands pre-target, exercising the discard). `CompressedReader` made `pub` for the test.
|
||||
- [~] Step 6: **bounded waveform overview** — replaces today's full-resolution
|
||||
`raw_audio_cache`/GPU waveform (which doesn't scale: it stores every sample at mip 0,
|
||||
so a long file is multi-GB on GPU + RAM — the same memory issue, and the Phase 1a
|
||||
`decode_progressive` leftover). Design below. Slices: (1a) streaming pyramid builder
|
||||
+ (1b) persistence + (1c) min/max GPU upload, then (2) LRU tile cache + re-decode floor.
|
||||
- [x] **Slice 1a (DONE):** `daw-backend/src/audio/waveform_pyramid.rs` —
|
||||
`WaveformPyramidBuilder` streams interleaved samples, accumulates the floor, and
|
||||
reduces `BRANCH(4):1` at `finish` into a root-first pyramid (convention B:
|
||||
`levels[0]`=root envelope, `levels.last()`=floor, `.root()`/`.floor()` accessors).
|
||||
Ragged last buckets reduce over available children (no value padding). Bounded
|
||||
(~22 MB/2 h @ B=256). 7 integration tests pass (`tests/waveform_pyramid.rs`):
|
||||
bucket min/max, partial flush, multi-level envelope == global min/max, root-first
|
||||
ordering, stereo channels, size bound, chunk-agnostic.
|
||||
- [~] **Slice 1b (data layer DONE; orchestration folded into 1c):**
|
||||
- [x] Generation bridge `disk_reader::build_waveform_pyramid(path, kind, B)` — streams
|
||||
a decode (`StreamSource` over symphonia/ffmpeg) into the builder; bounded
|
||||
memory (one chunk + the pyramid). Test: envelope matches the signal through
|
||||
both backends.
|
||||
- [x] Serialization `WaveformPyramid::to_bytes`/`from_bytes` (LBWF blob; f32 texels —
|
||||
f16 a later size optimization). Round-trip test + rejects truncated/garbage.
|
||||
- [x] `MediaKind::Waveform` in the SQLite container (keyed by the audio item's id).
|
||||
- [ ] Orchestration (with 1c).
|
||||
- [~] **Slice 1c (in-memory floor overview DONE; persistence next):**
|
||||
- [x] `waveform_gpu`: `PendingUpload.minmax` flag + `pack_texel` helper; `upload_audio`
|
||||
threads `minmax` (frame_stride 4, packs `(Lmin,Lmax,Rmin,Rmax)` directly).
|
||||
The texture is already Rgba16Float and the GPU mipgen builds zoom-out levels, so
|
||||
only the texel-packing differs. Render the floor at **effective rate `sr/B`** (so
|
||||
time→texel maps B samples/texel) and `total_frames = floor_texel_count`.
|
||||
- [x] `AppConfig.waveform_floor_samples_per_texel` (default 256, user-configurable).
|
||||
- [x] App: `waveform_minmax_pools: HashMap<usize, u32>` (pool → `B`, carries the floor rate
|
||||
with full float precision) + a `(pool, packed_floor, sr, channels, B)` results channel;
|
||||
drained in `update()` → `raw_audio_cache.insert(floor)` + flag pool + `waveform_gpu_dirty`.
|
||||
- [x] Generation: on video-audio import Success, the same bg thread streams
|
||||
`disk_reader::build_waveform_pyramid(path, VideoAudio, B)` once and sends the packed
|
||||
`floor()`. (Video-audio has no in-RAM samples, so this is what makes its waveform appear.)
|
||||
- [x] Threaded `waveform_minmax_pools` through the pane-context (`panes/mod.rs` +
|
||||
main.rs construction) → `render_layers` → **both** render sites (collapsed-group
|
||||
~timeline.rs:3048 AND expanded-track ~3613): compute `total_frames = len/4`,
|
||||
`eff_sr = sr/B`, set `minmax`. Compiles clean (editor `cargo check` = 0 errors).
|
||||
- [x] Shader fix: `waveform.wgsl` now reads the **nearest integer LOD via `textureLoad`**
|
||||
instead of sampling a fractional mip. Trilinear blends two levels whose row-major
|
||||
linearizations differ → horizontal shift that flips each 0.5 of `mip_f` (= each 2x
|
||||
zoom step), the "every other zoom level is offset" artifact. **User-confirmed fixed:**
|
||||
features hold position at every zoom and line up with playback.
|
||||
See memory `waveform-shader-fractional-mip-offset`.
|
||||
- [x] **Persistence (done):** the full pyramid is serialized (`to_bytes`) on generation and
|
||||
kept in `App.waveform_pyramid_blobs`. `save_beam` writes it as a `MediaKind::Waveform`
|
||||
row keyed by a **deterministic id derived from the pool index** (`file_io::waveform_media_id`,
|
||||
"LBWF" sentinel in the high 32 bits) — independent of how the audio bytes are stored, so
|
||||
it works for packed/referenced/video-audio alike, and an in-place re-save reuses the row.
|
||||
Carried in/out via a transient `#[serde(skip)] AudioPoolEntry.waveform_blob` and a
|
||||
`waveform_blobs` field on `FileCommand::Save`. `load_beam_sqlite` reads the row back;
|
||||
the editor restores `raw_audio_cache`/`waveform_minmax_pools`/`waveform_pyramid_blobs`
|
||||
+ flags `waveform_gpu_dirty` after the backend loads the pool (using each entry's
|
||||
`sample_rate` for `eff_sr`, the stored `B` for the rate). No re-decode on load.
|
||||
`register_loaded_videos` only loads frames (not audio), so there is no redundant
|
||||
regeneration to suppress. Compiles clean across all three crates.
|
||||
|
||||
### Waveform LOD pyramid design (step 6)
|
||||
A min/max LOD pyramid (tree of zoom-level textures): fully zoomed out → envelope; fully zoomed
|
||||
in → per-sample; seamless between.
|
||||
|
||||
- **One streaming decode pass** builds the whole pyramid down to a configurable **floor**
|
||||
`B` samples/texel (default 256), via a hierarchical reduction (each sample updates a running
|
||||
per-level min/max accumulator; a filled bucket emits a texel and folds into its parent —
|
||||
`branch` 4:1). Bounded memory: holds only the pyramid (~`N/B·4/3` texels ≈ **~14 MB / 2 h
|
||||
stereo @ B=256**), never the full samples. Full-res (B=1 ≈ 2.7 GB) is the only level NOT
|
||||
stored.
|
||||
- **Persist the pyramid** in the `.beam` SQLite container (a `waveform` media kind; session
|
||||
temp before first save). `B` is stored with it (preference is just the default for new gen).
|
||||
Persistence is load-bearing: it makes mid-zoom a cheap **disk read**, not a re-decode.
|
||||
- **Runtime = LRU tile cache** (GPU textures) loaded from the persisted pyramid on demand.
|
||||
Eviction is **ancestor-closed**: only evict an LRU node with no resident children ("a node is
|
||||
cleared only after its children") — so rendering can always walk up to a resident ancestor;
|
||||
detail sharpens in, never blanks. Root is tiny/hot → effectively pinned for free.
|
||||
- **Re-decode only below the floor** (texel < `B` samples): by then the visible window spans a
|
||||
tiny time range, so decoding it (via the sample-accurate seekable readers from steps 1–5 —
|
||||
the payoff) for true per-sample detail is cheap. This removes the large-span re-decode gap:
|
||||
above the floor it's a disk read; below it the span is already small.
|
||||
- **Why a deep floor (not a coarse cutoff):** a coarse-only pinned set would force the first
|
||||
on-demand level to re-reduce a huge time span per tile. Persisting deep makes every level a
|
||||
disk read; `B` is a size-vs-crossover knob (smaller B = bigger pyramid, cheaper re-decode).
|
||||
- `waveform_gpu` needs a **min/max texel upload** (`Lmin,Lmax,Rmin,Rmax` per texel) instead of
|
||||
min=max-per-sample; the existing compute mipgen still builds the mip chain *within* a tile.
|
||||
|
||||
**Decisions (locked):** branch 4:1; floor `B≈256` samples/texel, **user-configurable**
|
||||
(`AppConfig.waveform_floor_samples_per_texel`, stored per-pyramid); 8192-wide tiles; LRU ~4
|
||||
viewports of fine tiles; persist pyramid in `.beam`.
|
||||
- [x] Video decoder concurrency (movie-length lag/freeze): keyframe-index scan now runs
|
||||
holding no VideoManager/decoder lock (brief locks only bracket it) → no more multi-second
|
||||
UI freeze on import/load; thumbnail generation uses a **dedicated** decoder and samples
|
||||
at keyframes (≈1 frame each vs whole-GOP) → no playback contention. Removed dead
|
||||
`VideoManager::build_keyframe_index`, `build_and_set_keyframe_index`, `downsample_rgba*`.
|
||||
- [x] Phase 2a — bound video frame cache. `VideoManager.frame_cache` (was an unbounded
|
||||
`HashMap<(Uuid,i64), Arc<VideoFrame>>` that grew per distinct frame during playback) is now an
|
||||
`LruCache` evicted by a **byte budget** (`FRAME_CACHE_BYTE_BUDGET` = 256 MB) rather than a frame
|
||||
count — robust across resolutions (a 4K frame is ~33 MB vs ~2 MB at 800×600). Byte total tracked
|
||||
on insert/evict/remove; `unload_video` pops per-clip keys (LruCache has no `retain`). Decoder-level
|
||||
cache was already LRU. Editor compiles clean. *(Not yet runtime-verified.)*
|
||||
- [x] Phase 2b — stream export mux. `export/mod.rs::mux_video_and_audio` no longer collects every
|
||||
packet into two `Vec`s before interleaving; it stream-merges the two inputs by PTS with one pending
|
||||
packet per stream (O(1) memory vs O(duration)). Same tie-break (`v_us <= a_us`) and drain-on-EOF
|
||||
behavior; output is byte-identical. Editor compiles clean. *(Not yet runtime-verified — needs an
|
||||
in-app export to confirm A/V sync.)*
|
||||
- [x] Phase 3a — lazy + async raster fault-in (`RasterStore` + background thread + image proxy)
|
||||
- [x] Phase 3b — raster residency LRU + eviction (dirty-flag data-loss safety)
|
||||
- [x] Phase 3c — bound raster GPU texture cache (recency LRU + F3 VRAM readout)
|
||||
- [x] Phase 3d — raster undo dirty-rect diffs (+ fault-in-before-undo)
|
||||
- [x] Phase 3.5 — image textures in vector scenes (fixed DCEL-broken image import; image-fill tab + picker; container-persisted)
|
||||
- [x] Phase 4 — image asset paging: Tier 2 decoded-cache byte-LRU, Tier 1 lazy container bytes, playback prefetch
|
||||
- [x] Phase 5 — fixed the broken `#[cfg(test)]` unit tests; **`cargo test --lib` green again**
|
||||
(daw-backend 17 passed, lightningbeam-core 264 passed). Wrapped stale raw-`f64` time literals
|
||||
in `Beats(...)` / passed `&TempoMap` to changed signatures (automation.rs, clip.rs,
|
||||
effect_layer.rs); fixed stale test setup (register a vector clip so `get_clip_duration` resolves)
|
||||
and a stale default expectation (shape `fill_color` defaults `None`). Surfaced + fixed one **real
|
||||
undo bug**: `DeleteFolderAction(MoveToParent)` reparented child subfolders but never restored them
|
||||
on rollback (orphaned them) — now tracked and restored. Production code otherwise untouched.
|
||||
305
TODO.md
305
TODO.md
|
|
@ -1,305 +0,0 @@
|
|||
# Lightningbeam TODO
|
||||
|
||||
> ⚠️ **Stale entries:** Lightningbeam was rewritten from JavaScript to Rust. Any entry below
|
||||
> that cites `src/*.js` / `main.js` / `animation.js` predates that migration — the *issue* may
|
||||
> or may not still exist in the Rust codebase, but the file/line references are obsolete.
|
||||
> **Re-verify against the current Rust code before acting** (this covers the "Animation System
|
||||
> Refactoring" section and the JS-referencing "Known Issues" entries — node editor, default
|
||||
> interpolation, etc.). Items with no `.js` references are current.
|
||||
|
||||
## Animation System Refactoring *(STALE — JS-era migration notes; superseded by the Rust DCEL/keyframe system)*
|
||||
|
||||
### Completed
|
||||
- ✅ Implement AnimationData curve-based system (Keyframe, AnimationCurve, AnimationData classes)
|
||||
- ✅ Add GraphicsObject.currentTime property
|
||||
- ✅ Migrate shape rendering to use AnimationData curves (exists, zOrder)
|
||||
- ✅ Binary search optimization for keyframe lookups
|
||||
|
||||
### In Progress
|
||||
- Migrating from Frame-based to AnimationData curve-based system throughout codebase
|
||||
|
||||
### Pending Features
|
||||
|
||||
#### Animation Curve Enhancements
|
||||
- [ ] Implement extrapolation modes (separate for start vs end):
|
||||
- "hold" (default) - hold value at first/last keyframe
|
||||
- "extend" - linearly extend the curve beyond keyframes
|
||||
- "repeat" - repeat the animation
|
||||
- "decay" - exponential decay to a target value
|
||||
- [ ] Add position, scale, rotation animation curves for shapes
|
||||
- [ ] Add shape morphing/tweening between keyframes
|
||||
|
||||
#### Keyframing Behavior
|
||||
- [ ] Add user preference for keyframing behavior when editing objects:
|
||||
- Auto-keyframe (current default): create/update keyframe at current time
|
||||
- Edit previous (Flash-style): update most recent keyframe before current time
|
||||
- Ephemeral (Blender-style): changes don't persist without manual keyframe
|
||||
- Optional: Add modifier key (e.g. Shift) to toggle between modes
|
||||
|
||||
#### Shape Ordering
|
||||
- [ ] Add "Bring Forward" menu option (swap zOrder with shape in front)
|
||||
- [ ] Add "Send Backward" menu option (swap zOrder with shape behind)
|
||||
- [ ] Add "Bring to Front" menu option (set zOrder to max + 1)
|
||||
- [ ] Add "Send to Back" menu option (set zOrder to min - 1)
|
||||
|
||||
#### Code Cleanup
|
||||
- [ ] Remove all remaining references to Frame-based system
|
||||
- [ ] Remove legacy Frame class once migration is complete
|
||||
- [ ] Clean up GraphicsObject.shapes[] array (shapes should only live in Layers)
|
||||
|
||||
## Known Issues / Platform Limitations
|
||||
|
||||
### Animation: Tweens are broken (Rust codebase) — LOW PRIORITY
|
||||
- **Issue**: Animation tweening between keyframes (shape/vector interpolation, and the
|
||||
`tween_after` behavior on keyframes) does not work correctly in the current Rust app.
|
||||
Needs investigation + fix. Not urgent — revisit later.
|
||||
- (Older JS-codebase animation entries below reference `src/*.js` and are stale.)
|
||||
|
||||
### Audio: Oscillator Timbre Drift (Phase Accumulation Error)
|
||||
- **Issue**: Oscillators exhibit timbre changes over time due to floating-point phase accumulation errors
|
||||
- **Affected Files**:
|
||||
- `daw-backend/src/effects/synth.rs:117-120` (SimpleSynth)
|
||||
- `daw-backend/src/audio/node_graph/nodes/oscillator.rs:167-170` (OscillatorNode)
|
||||
- **Root Cause**: Current phase wrapping uses conditional subtraction (`if phase >= 1.0 { phase -= 1.0 }`), which accumulates f32 rounding errors over time, especially for long-playing notes
|
||||
- **Current Code**:
|
||||
```rust
|
||||
self.phase += frequency / sample_rate;
|
||||
if self.phase >= 1.0 {
|
||||
self.phase -= 1.0;
|
||||
}
|
||||
```
|
||||
- **Recommended Fix**: Replace with `.fract()` for numerically stable wraparound:
|
||||
```rust
|
||||
self.phase += frequency / sample_rate;
|
||||
self.phase = self.phase.fract();
|
||||
```
|
||||
- **Impact**: Medium - affects audio quality for sustained notes, becomes noticeable after several seconds
|
||||
- **Priority**: Medium - should be addressed before production use
|
||||
|
||||
### UI: Node Connections Render Behind VoiceAllocator Child Nodes
|
||||
- **Issue**: Connection lines (SVG paths) inside expanded VoiceAllocator nodes render behind child nodes due to z-index stacking
|
||||
- **Affected File**: `src/styles.css:1128`
|
||||
- **Root Cause**: Child nodes have `z-index: 10` while connection SVG paths have default/lower z-index
|
||||
- **Current Code**:
|
||||
```css
|
||||
.drawflow .drawflow-node.child-node {
|
||||
opacity: 0.9;
|
||||
border: 1px solid #5a5aaa !important;
|
||||
box-shadow: 0 2px 8px rgba(90, 90, 170, 0.3);
|
||||
z-index: 10;
|
||||
}
|
||||
```
|
||||
- **Recommended Fix**: Either:
|
||||
1. Remove `z-index: 10` from `.child-node` (simplest), or
|
||||
2. Add higher z-index to connection SVG paths, or
|
||||
3. Use CSS `isolation: isolate` on the VoiceAllocator contents area to create a new stacking context
|
||||
- **Impact**: Low - visual issue only, connections still function but appear to go "behind" nodes
|
||||
- **Priority**: Low - cosmetic issue that doesn't affect functionality
|
||||
|
||||
### UI: VoiceAllocator Child Nodes Don't Move with Parent
|
||||
- **Issue**: When a VoiceAllocator node is moved, its child nodes remain in their original positions instead of moving with the parent
|
||||
- **Affected File**: `src/main.js:6202-6207`
|
||||
- **Root Cause**: The `nodeMoved` event handler only handles the case where a child node is moved (resizes parent), but doesn't handle when the VoiceAllocator itself is moved
|
||||
- **Current Code**:
|
||||
```javascript
|
||||
editor.on("nodeMoved", (nodeId) => {
|
||||
const node = editor.getNodeFromId(nodeId);
|
||||
if (node && node.data.parentNodeId) {
|
||||
resizeVoiceAllocatorToFit(node.data.parentNodeId);
|
||||
}
|
||||
});
|
||||
```
|
||||
- **Recommended Fix**: Add logic to detect when a VoiceAllocator is moved and update all child node positions:
|
||||
```javascript
|
||||
editor.on("nodeMoved", (nodeId) => {
|
||||
const node = editor.getNodeFromId(nodeId);
|
||||
|
||||
// Case 1: A child node was moved - resize parent
|
||||
if (node && node.data.parentNodeId) {
|
||||
resizeVoiceAllocatorToFit(node.data.parentNodeId);
|
||||
}
|
||||
|
||||
// Case 2: A VoiceAllocator was moved - move all children
|
||||
if (node && node.data.nodeType === 'VoiceAllocator') {
|
||||
// Calculate delta from previous position (need to track)
|
||||
// Update all child node positions by the delta
|
||||
// Call editor.updateConnectionNodes() for parent and all children
|
||||
}
|
||||
});
|
||||
```
|
||||
- **Impact**: High - child nodes become disconnected from parent visually
|
||||
- **Priority**: High - breaks expected behavior of grouped nodes
|
||||
|
||||
### UI: VoiceAllocator Expansion Doesn't Update Connection Positions
|
||||
- **Issue**: When expanding/collapsing a VoiceAllocator, connection endpoints don't update to match the new port positions
|
||||
- **Affected File**: `src/main.js:6496-6555` (handleNodeDoubleClick function)
|
||||
- **Root Cause**: The expand/collapse logic shows/hides child nodes and resizes the container, but never calls `editor.updateConnectionNodes()` to refresh connection positions
|
||||
- **Current Code**: In `handleNodeDoubleClick()`, after expanding or collapsing:
|
||||
```javascript
|
||||
// Expand
|
||||
expandedNodes.add(nodeId);
|
||||
nodeElement.classList.add('expanded');
|
||||
nodeElement.style.width = '600px';
|
||||
nodeElement.style.height = '400px';
|
||||
// ... shows child nodes ...
|
||||
// Missing: editor.updateConnectionNodes(`node-${nodeId}`)
|
||||
```
|
||||
- **Recommended Fix**: Call `editor.updateConnectionNodes()` after resizing:
|
||||
```javascript
|
||||
// After expanding
|
||||
expandedNodes.add(nodeId);
|
||||
nodeElement.classList.add('expanded');
|
||||
// ... resize and show children ...
|
||||
|
||||
// Update connection positions for VoiceAllocator and all children
|
||||
editor.updateConnectionNodes(`node-${nodeId}`);
|
||||
for (const [childId, parentId] of nodeParents.entries()) {
|
||||
if (parentId === nodeId) {
|
||||
editor.updateConnectionNodes(`node-${childId}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
- **Impact**: Medium - connections appear in wrong positions until manually moved
|
||||
- **Priority**: Medium - visual issue that affects usability
|
||||
|
||||
### UI: Node Editor Allows Editing Without MIDI Layer Selected
|
||||
- **Issue**: The node editor pane allows adding/editing instrument nodes even when no MIDI layer is selected, and always uses hardcoded `trackId: 0`
|
||||
- **Affected File**: `src/main.js:6045-6920` (nodeEditor function)
|
||||
- **Root Cause**: The node editor never checks if `context.activeObject.activeLayer` exists or is a MIDI track, and all backend commands use hardcoded `trackId: 0`
|
||||
- **Current Code**: All graph commands hardcode track 0:
|
||||
```javascript
|
||||
const commandArgs = parentNodeId
|
||||
? {
|
||||
trackId: 0, // HARDCODED!
|
||||
voiceAllocatorId: editor.getNodeFromId(parentNodeId).data.backendId,
|
||||
nodeType: nodeType,
|
||||
x: x,
|
||||
y: y
|
||||
}
|
||||
: {
|
||||
trackId: 0, // HARDCODED!
|
||||
nodeType: nodeType,
|
||||
x: x,
|
||||
y: y
|
||||
};
|
||||
```
|
||||
- **Recommended Fix**:
|
||||
1. Check if activeLayer is a MIDI track before allowing edits:
|
||||
```javascript
|
||||
function getSelectedMidiTrack() {
|
||||
const activeLayer = context.activeObject?.activeLayer;
|
||||
if (!activeLayer || activeLayer.type !== 'midi') {
|
||||
return null;
|
||||
}
|
||||
return activeLayer;
|
||||
}
|
||||
```
|
||||
2. Show placeholder when no MIDI track selected:
|
||||
```javascript
|
||||
function nodeEditor() {
|
||||
const container = document.createElement("div");
|
||||
const midiTrack = getSelectedMidiTrack();
|
||||
|
||||
if (!midiTrack) {
|
||||
container.innerHTML = '<div class="placeholder">Select a MIDI layer to edit instruments</div>';
|
||||
return container;
|
||||
}
|
||||
// ... rest of node editor code ...
|
||||
}
|
||||
```
|
||||
3. Use actual track ID instead of hardcoded 0:
|
||||
```javascript
|
||||
const trackId = midiTrack.audioTrackId || 0;
|
||||
const commandArgs = { trackId, nodeType, x, y };
|
||||
```
|
||||
4. Add listener to refresh node editor when layer selection changes
|
||||
- **Impact**: High - allows editing wrong track's instrument graph, data corruption risk
|
||||
- **Priority**: High - can cause confusion and data loss
|
||||
|
||||
### Animation: Wrong Default Interpolation for Shape and Object Keyframes
|
||||
- **Issue**: Shape index and object transform keyframes default to "linear" interpolation but should default to "hold" (step function), and there's no UI to change interpolation after creation
|
||||
- **Affected Files**:
|
||||
- `src/models/animation.js:124` (Keyframe constructor defaults to "linear")
|
||||
- `src/main.js:2161` (shapeIndex keyframes default to "linear")
|
||||
- `src/main.js:2198` (object position/rotation/scale keyframes default to "linear")
|
||||
- `src/main.js:5910` (Timeline menu - missing tween options)
|
||||
- **Root Cause**:
|
||||
1. The Keyframe constructor defaults interpolation to "linear"
|
||||
2. Shape index keyframes preserve existing interpolation or default to "linear"
|
||||
3. Object transform keyframes explicitly use "linear"
|
||||
4. No menu options exist to change interpolation mode after keyframe creation
|
||||
- **Current Code**:
|
||||
- Keyframe constructor (animation.js:124):
|
||||
```javascript
|
||||
constructor(time, value, interpolation = "linear", uuid = undefined) {
|
||||
```
|
||||
- Shape index keyframes (main.js:2161):
|
||||
```javascript
|
||||
const interpolationType = existingShapeIndexKf ? existingShapeIndexKf.interpolation : 'linear';
|
||||
const shapeIndexKeyframe = new Keyframe(currentTime, newShapeIndex, interpolationType);
|
||||
```
|
||||
- Object keyframes (main.js:2198):
|
||||
```javascript
|
||||
const newKeyframe = new Keyframe(
|
||||
currentTime,
|
||||
currentValue,
|
||||
'linear' // Default to linear interpolation
|
||||
);
|
||||
```
|
||||
- **Expected Behavior**:
|
||||
- Shape index keyframes should default to "hold" (shapes shouldn't morph between versions)
|
||||
- Object transforms should default to "hold" (objects shouldn't move/rotate/scale between keyframes unless explicitly tweened)
|
||||
- Timeline menu should have options to convert between interpolation modes
|
||||
- **Recommended Fix**:
|
||||
1. Change shapeIndex default to "hold" (main.js:2161):
|
||||
```javascript
|
||||
const interpolationType = existingShapeIndexKf ? existingShapeIndexKf.interpolation : 'hold';
|
||||
```
|
||||
2. Change object keyframe default to "hold" (main.js:2198):
|
||||
```javascript
|
||||
const newKeyframe = new Keyframe(currentTime, currentValue, 'hold');
|
||||
```
|
||||
3. Add Timeline menu options (main.js:5910, in timelineSubmenu):
|
||||
```javascript
|
||||
{
|
||||
text: "Add Shape Tween",
|
||||
enabled: /* check if shape is selected and has keyframes */,
|
||||
action: () => {
|
||||
// Find shapeIndex curve for selected shape
|
||||
// Change interpolation between keyframes to "linear"
|
||||
}
|
||||
},
|
||||
{
|
||||
text: "Add Motion Tween",
|
||||
enabled: /* check if object is selected and has transform keyframes */,
|
||||
action: () => {
|
||||
// Find position/rotation/scale curves for selected object
|
||||
// Change interpolation between keyframes to "linear" or "bezier"
|
||||
}
|
||||
}
|
||||
```
|
||||
- **Note**: exists and zOrder keyframes already correctly use "hold" (main.js:2139, 2150)
|
||||
- **Impact**: High - causes unwanted interpolation, shapes morph unexpectedly, objects move when they shouldn't
|
||||
- **Priority**: High - fundamental animation behavior is incorrect
|
||||
|
||||
### Tauri Pinch-Zoom on Linux
|
||||
- **Issue**: Two-finger pinch gestures zoom the entire Tauri window instead of individual canvases
|
||||
- **Status**: Known Tauri limitation on Linux/GTK with no cross-platform solution
|
||||
- **Tracking**: https://github.com/tauri-apps/tauri/discussions/3843
|
||||
- **Workaround attempts**: Tried `zoomHotkeysEnabled: false`, `touch-action: none`, viewport meta tags - none worked
|
||||
- **Resolution**: Monitor Tauri releases for official fix
|
||||
|
||||
## Notes
|
||||
|
||||
### Architecture
|
||||
- **GraphicsObject** contains Layers and has `currentTime` (continuous time)
|
||||
- **Layer** contains `shapes[]` array and `animationData` (AnimationData instance)
|
||||
- **AnimationData** contains curves dictionary, each curve identified by parameter name
|
||||
- Shape curves: `shape.{uuid}.exists`, `shape.{uuid}.zOrder`
|
||||
- Future: `shape.{uuid}.x`, `shape.{uuid}.y`, `shape.{uuid}.rotation`, etc.
|
||||
- **Shapes render based on curves**: Layer.draw checks exists > 0, sorts by zOrder, draws in order
|
||||
|
||||
### Interpolation Types
|
||||
- `linear` - Linear interpolation between keyframes
|
||||
- `bezier` - Cubic Bezier with easing control points
|
||||
- `step`/`hold` - Step function (jumps to next value)
|
||||
|
|
@ -1,34 +1,34 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Ensure the script stops on error
|
||||
set -e
|
||||
|
||||
# Check if a version argument was passed
|
||||
if [ -z "$1" ]; then
|
||||
echo "Usage: ./create_release.sh <version>"
|
||||
echo "Usage: ./create-release.sh <version>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION=$1
|
||||
RELEASE_BRANCH="release"
|
||||
MAIN_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
CARGO_TOML="lightningbeam-ui/lightningbeam-editor/Cargo.toml"
|
||||
MAIN_BRANCH="main"
|
||||
CONFIG_FILE="src-tauri/tauri.conf.json"
|
||||
|
||||
echo "Updating version to $VERSION in $CARGO_TOML..."
|
||||
sed -i "0,/^version = .*/s/^version = .*/version = \"$VERSION\"/" "$CARGO_TOML"
|
||||
echo "Updating version to $VERSION in $CONFIG_FILE..."
|
||||
jq --arg version "$VERSION" '.version = $version' $CONFIG_FILE > tmp.json && mv tmp.json $CONFIG_FILE
|
||||
|
||||
echo "Committing to $MAIN_BRANCH..."
|
||||
git add "$CARGO_TOML" Changelog.md
|
||||
git diff --cached --quiet || git commit -m "Bump version to $VERSION"
|
||||
echo "Committing to main..."
|
||||
git add $CONFIG_FILE
|
||||
git commit -m "Bump version to $VERSION"
|
||||
|
||||
echo "Checking out the release branch..."
|
||||
git checkout $RELEASE_BRANCH
|
||||
|
||||
echo "Merging $MAIN_BRANCH into $RELEASE_BRANCH..."
|
||||
echo "Merging the main branch into $RELEASE_BRANCH..."
|
||||
git merge $MAIN_BRANCH --no-ff -m "Release $VERSION"
|
||||
|
||||
echo "Pushing $RELEASE_BRANCH..."
|
||||
# Push to the 'all' remote so the release branch lands on both GitHub and Gitea.
|
||||
# CI (GitHub Actions) still triggers via the GitHub pushurl.
|
||||
git push all $RELEASE_BRANCH
|
||||
echo "Pushing changes to the release branch..."
|
||||
git push origin $RELEASE_BRANCH
|
||||
|
||||
git checkout $MAIN_BRANCH
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -4,7 +4,7 @@ version = "0.1.0"
|
|||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
cpal = "0.17"
|
||||
cpal = "0.15"
|
||||
symphonia = { version = "0.5", features = ["all"] }
|
||||
rtrb = "0.3"
|
||||
midly = "0.5"
|
||||
|
|
@ -15,14 +15,11 @@ 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
|
||||
# TODO: Add MP3 support with a different crate
|
||||
# mp3lame-encoder API is too complex, need to find a better option
|
||||
|
||||
# Node-based audio graph dependencies
|
||||
dasp_graph = "0.11"
|
||||
|
|
@ -35,13 +32,6 @@ 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]
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -1,6 +1,5 @@
|
|||
/// Automation system for parameter modulation over time
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::time::Beats;
|
||||
|
||||
/// Unique identifier for automation lanes
|
||||
pub type AutomationLaneId = u32;
|
||||
|
|
@ -36,13 +35,17 @@ pub enum CurveType {
|
|||
/// A single automation point
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AutomationPoint {
|
||||
pub time: Beats,
|
||||
/// Time in seconds
|
||||
pub time: f64,
|
||||
/// Parameter value (normalized 0.0 to 1.0, or actual value depending on parameter)
|
||||
pub value: f32,
|
||||
/// Curve type to next point
|
||||
pub curve: CurveType,
|
||||
}
|
||||
|
||||
impl AutomationPoint {
|
||||
pub fn new(time: Beats, value: f32, curve: CurveType) -> Self {
|
||||
/// Create a new automation point
|
||||
pub fn new(time: f64, value: f32, curve: CurveType) -> Self {
|
||||
Self { time, value, curve }
|
||||
}
|
||||
}
|
||||
|
|
@ -90,7 +93,8 @@ impl AutomationLane {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn remove_point_at_time(&mut self, time: Beats, tolerance: Beats) -> bool {
|
||||
/// Remove point at specific time
|
||||
pub fn remove_point_at_time(&mut self, time: f64, tolerance: f64) -> bool {
|
||||
if let Some(idx) = self.points.iter().position(|p| (p.time - time).abs() < tolerance) {
|
||||
self.points.remove(idx);
|
||||
true
|
||||
|
|
@ -109,7 +113,8 @@ impl AutomationLane {
|
|||
&self.points
|
||||
}
|
||||
|
||||
pub fn evaluate(&self, time: Beats) -> Option<f32> {
|
||||
/// Get value at a specific time with interpolation
|
||||
pub fn evaluate(&self, time: f64) -> Option<f32> {
|
||||
if !self.enabled || self.points.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
|
@ -143,12 +148,14 @@ impl AutomationLane {
|
|||
}
|
||||
}
|
||||
|
||||
fn interpolate(p1: &AutomationPoint, p2: &AutomationPoint, time: Beats) -> f32 {
|
||||
/// Interpolate between two automation points based on curve type
|
||||
fn interpolate(p1: &AutomationPoint, p2: &AutomationPoint, time: f64) -> f32 {
|
||||
// Calculate normalized position between points (0.0 to 1.0)
|
||||
let t = if p2.time == p1.time {
|
||||
0.0f64
|
||||
0.0
|
||||
} else {
|
||||
(time - p1.time) / (p2.time - p1.time)
|
||||
} as f32;
|
||||
((time - p1.time) / (p2.time - p1.time)) as f32
|
||||
};
|
||||
|
||||
// Apply curve
|
||||
let curved_t = match p1.curve {
|
||||
|
|
@ -189,22 +196,22 @@ mod tests {
|
|||
fn test_add_points_sorted() {
|
||||
let mut lane = AutomationLane::new(0, ParameterId::TrackVolume);
|
||||
|
||||
lane.add_point(AutomationPoint::new(Beats(2.0), 0.5, CurveType::Linear));
|
||||
lane.add_point(AutomationPoint::new(Beats(1.0), 0.3, CurveType::Linear));
|
||||
lane.add_point(AutomationPoint::new(Beats(3.0), 0.8, CurveType::Linear));
|
||||
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, Beats(1.0));
|
||||
assert_eq!(lane.points()[1].time, Beats(2.0));
|
||||
assert_eq!(lane.points()[2].time, Beats(3.0));
|
||||
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(Beats(1.0), 0.3, CurveType::Linear));
|
||||
lane.add_point(AutomationPoint::new(Beats(1.0), 0.5, CurveType::Linear));
|
||||
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);
|
||||
|
|
@ -214,59 +221,59 @@ mod tests {
|
|||
fn test_linear_interpolation() {
|
||||
let mut lane = AutomationLane::new(0, ParameterId::TrackVolume);
|
||||
|
||||
lane.add_point(AutomationPoint::new(Beats(0.0), 0.0, CurveType::Linear));
|
||||
lane.add_point(AutomationPoint::new(Beats(1.0), 1.0, CurveType::Linear));
|
||||
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(Beats(0.0)), Some(0.0));
|
||||
assert_eq!(lane.evaluate(Beats(0.5)), Some(0.5));
|
||||
assert_eq!(lane.evaluate(Beats(1.0)), Some(1.0));
|
||||
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(Beats(0.0), 0.5, CurveType::Step));
|
||||
lane.add_point(AutomationPoint::new(Beats(1.0), 1.0, CurveType::Step));
|
||||
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(Beats(0.0)), Some(0.5));
|
||||
assert_eq!(lane.evaluate(Beats(0.5)), Some(0.5));
|
||||
assert_eq!(lane.evaluate(Beats(0.99)), Some(0.5));
|
||||
assert_eq!(lane.evaluate(Beats(1.0)), Some(1.0));
|
||||
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(Beats(1.0), 0.5, CurveType::Linear));
|
||||
lane.add_point(AutomationPoint::new(Beats(2.0), 1.0, CurveType::Linear));
|
||||
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(Beats(0.0)), Some(0.5));
|
||||
assert_eq!(lane.evaluate(0.0), Some(0.5));
|
||||
// After last point
|
||||
assert_eq!(lane.evaluate(Beats(3.0)), Some(1.0));
|
||||
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(Beats(0.0), 0.5, CurveType::Linear));
|
||||
lane.add_point(AutomationPoint::new(0.0, 0.5, CurveType::Linear));
|
||||
lane.enabled = false;
|
||||
|
||||
assert_eq!(lane.evaluate(Beats(0.0)), None);
|
||||
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(Beats(1.0), 0.5, CurveType::Linear));
|
||||
lane.add_point(AutomationPoint::new(Beats(2.0), 0.8, CurveType::Linear));
|
||||
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(Beats(1.0), Beats(0.001)));
|
||||
assert!(lane.remove_point_at_time(1.0, 0.001));
|
||||
assert_eq!(lane.points().len(), 1);
|
||||
assert_eq!(lane.points()[0].time, Beats(2.0));
|
||||
assert_eq!(lane.points()[0].time, 2.0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,3 @@
|
|||
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;
|
||||
|
||||
|
|
@ -11,46 +6,47 @@ pub type ClipId = AudioClipInstanceId;
|
|||
|
||||
/// Audio clip instance that references content in the AudioClipPool
|
||||
///
|
||||
/// This represents a placed instance of audio content on the timeline.
|
||||
/// The actual audio data is stored in the AudioClipPool and referenced by `audio_pool_index`.
|
||||
///
|
||||
/// ## 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**)
|
||||
/// - `internal_start` / `internal_end`: Define the region of the source audio to play (trimming)
|
||||
/// - `external_start` / `external_duration`: Define where the clip appears on the timeline and how long
|
||||
///
|
||||
/// ## Looping
|
||||
/// If `external_duration_secs(bpm)` > `internal_end - internal_start`, the clip loops.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
/// If `external_duration` is greater than `internal_end - internal_start`,
|
||||
/// the clip will seamlessly loop back to `internal_start` when it reaches `internal_end`.
|
||||
#[derive(Debug, Clone)]
|
||||
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 within the audio content (seconds)
|
||||
pub internal_start: f64,
|
||||
/// End position within the audio content (seconds)
|
||||
pub internal_end: f64,
|
||||
|
||||
/// Start position on the timeline
|
||||
pub external_start: Beats,
|
||||
/// Duration on the timeline
|
||||
pub external_duration: Beats,
|
||||
/// Start position on the timeline (seconds)
|
||||
pub external_start: f64,
|
||||
/// Duration on the timeline (seconds) - can be longer than internal duration for looping
|
||||
pub external_duration: f64,
|
||||
|
||||
/// 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 {
|
||||
/// Create a new audio clip instance
|
||||
pub fn new(
|
||||
id: AudioClipInstanceId,
|
||||
audio_pool_index: usize,
|
||||
internal_start: Seconds,
|
||||
internal_end: Seconds,
|
||||
external_start: Beats,
|
||||
external_duration: Beats,
|
||||
internal_start: f64,
|
||||
internal_end: f64,
|
||||
external_start: f64,
|
||||
external_duration: f64,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
|
|
@ -60,54 +56,82 @@ impl AudioClipInstance {
|
|||
external_start,
|
||||
external_duration,
|
||||
gain: 1.0,
|
||||
read_ahead: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn external_end(&self) -> Beats {
|
||||
/// Create a clip instance from legacy parameters (for backwards compatibility)
|
||||
/// Maps old start_time/duration/offset to new timing model
|
||||
pub fn from_legacy(
|
||||
id: AudioClipInstanceId,
|
||||
audio_pool_index: usize,
|
||||
start_time: f64,
|
||||
duration: f64,
|
||||
offset: f64,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
audio_pool_index,
|
||||
internal_start: offset,
|
||||
internal_end: offset + duration,
|
||||
external_start: start_time,
|
||||
external_duration: duration,
|
||||
gain: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this clip instance is active at a given timeline position
|
||||
pub fn is_active_at(&self, time_seconds: f64) -> bool {
|
||||
time_seconds >= self.external_start && time_seconds < self.external_end()
|
||||
}
|
||||
|
||||
/// Get the end time of this clip instance on the timeline
|
||||
pub fn external_end(&self) -> f64 {
|
||||
self.external_start + self.external_duration
|
||||
}
|
||||
|
||||
pub fn external_start_secs(&self, tempo_map: &TempoMap) -> Seconds {
|
||||
tempo_map.beats_to_seconds(self.external_start)
|
||||
/// Get the end time of this clip instance on the timeline
|
||||
/// (Alias for external_end(), for backwards compatibility)
|
||||
pub fn end_time(&self) -> f64 {
|
||||
self.external_end()
|
||||
}
|
||||
|
||||
pub fn external_end_secs(&self, tempo_map: &TempoMap) -> Seconds {
|
||||
tempo_map.beats_to_seconds(self.external_end())
|
||||
/// Get the start time on the timeline
|
||||
/// (Alias for external_start, for backwards compatibility)
|
||||
pub fn start_time(&self) -> f64 {
|
||||
self.external_start
|
||||
}
|
||||
|
||||
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 {
|
||||
/// Get the internal (content) duration
|
||||
pub fn internal_duration(&self) -> f64 {
|
||||
self.internal_end - self.internal_start
|
||||
}
|
||||
|
||||
pub fn is_looping(&self, tempo_map: &TempoMap) -> bool {
|
||||
self.external_duration_secs(tempo_map) > self.internal_duration()
|
||||
/// Check if this clip instance loops
|
||||
pub fn is_looping(&self) -> bool {
|
||||
self.external_duration > 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 {
|
||||
/// Get the position within the audio content for a given timeline position
|
||||
/// Returns None if the timeline position is outside this clip instance
|
||||
/// Handles looping automatically
|
||||
pub fn get_content_position(&self, timeline_pos: f64) -> Option<f64> {
|
||||
if timeline_pos < self.external_start || timeline_pos >= self.external_end() {
|
||||
return None;
|
||||
}
|
||||
let relative_pos = timeline_pos - start_secs;
|
||||
|
||||
let relative_pos = timeline_pos - self.external_start;
|
||||
let internal_duration = self.internal_duration();
|
||||
if internal_duration.0 <= 0.0 {
|
||||
|
||||
if internal_duration <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Wrap around for looping
|
||||
let content_offset = relative_pos % internal_duration;
|
||||
Some(self.internal_start + content_offset)
|
||||
}
|
||||
|
||||
/// Set clip gain
|
||||
pub fn set_gain(&mut self, gain: f32) {
|
||||
self.gain = gain.max(0.0);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -1,22 +1,15 @@
|
|||
use super::buffer_pool::BufferPool;
|
||||
use super::midi_pool::MidiClipPool;
|
||||
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,
|
||||
// TODO: Add MP3 support
|
||||
}
|
||||
|
||||
impl ExportFormat {
|
||||
|
|
@ -25,8 +18,6 @@ impl ExportFormat {
|
|||
match self {
|
||||
ExportFormat::Wav => "wav",
|
||||
ExportFormat::Flac => "flac",
|
||||
ExportFormat::Mp3 => "mp3",
|
||||
ExportFormat::Aac => "m4a",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -44,12 +35,10 @@ pub struct ExportSettings {
|
|||
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,
|
||||
/// Start time in seconds
|
||||
pub start_time: f64,
|
||||
/// End time in seconds
|
||||
pub end_time: f64,
|
||||
}
|
||||
|
||||
impl Default for ExportSettings {
|
||||
|
|
@ -60,9 +49,8 @@ impl Default for ExportSettings {
|
|||
channels: 2,
|
||||
bit_depth: 16,
|
||||
mp3_bitrate: 320,
|
||||
start_time: Seconds::ZERO,
|
||||
end_time: Seconds(60.0),
|
||||
tempo_map: TempoMap::constant(120.0),
|
||||
start_time: 0.0,
|
||||
end_time: 60.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -71,95 +59,43 @@ impl Default for ExportSettings {
|
|||
///
|
||||
/// 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,
|
||||
midi_pool: &MidiClipPool,
|
||||
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()
|
||||
));
|
||||
}
|
||||
) -> Result<(), String> {
|
||||
// Render the project to memory
|
||||
let samples = render_to_memory(project, pool, midi_pool, settings)?;
|
||||
|
||||
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);
|
||||
}
|
||||
// Write to file based on format
|
||||
match settings.format {
|
||||
ExportFormat::Wav => write_wav(&samples, settings, &output_path),
|
||||
ExportFormat::Flac => write_flac(&samples, settings, &output_path),
|
||||
_ => unreachable!(),
|
||||
ExportFormat::Wav => write_wav(&samples, settings, output_path)?,
|
||||
ExportFormat::Flac => write_flac(&samples, settings, output_path)?,
|
||||
}
|
||||
}
|
||||
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
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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(
|
||||
fn render_to_memory(
|
||||
project: &mut Project,
|
||||
pool: &AudioPool,
|
||||
midi_pool: &MidiClipPool,
|
||||
settings: &ExportSettings,
|
||||
mut event_tx: Option<&mut rtrb::Producer<AudioEvent>>,
|
||||
) -> Result<Vec<f32>, String>
|
||||
{
|
||||
) -> 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_frames = (duration * 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);
|
||||
duration, total_frames, total_samples, settings.channels);
|
||||
|
||||
let chunk_samples = EXPORT_CHUNK_FRAMES * settings.channels as usize;
|
||||
// Render in chunks to avoid memory issues
|
||||
const CHUNK_FRAMES: usize = 4096;
|
||||
let chunk_samples = CHUNK_FRAMES * settings.channels as usize;
|
||||
|
||||
// Create buffer for rendering
|
||||
let mut render_buffer = vec![0.0f32; chunk_samples];
|
||||
|
|
@ -169,8 +105,7 @@ pub fn render_to_memory(
|
|||
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;
|
||||
let chunk_duration = CHUNK_FRAMES as f64 / settings.sample_rate as f64;
|
||||
|
||||
// Render the entire timeline in chunks
|
||||
while playhead < settings.end_time {
|
||||
|
|
@ -181,19 +116,18 @@ pub fn render_to_memory(
|
|||
project.render(
|
||||
&mut render_buffer,
|
||||
pool,
|
||||
midi_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 {
|
||||
let samples_needed = if remaining_time < 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 frames_needed = (remaining_time * 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)
|
||||
|
|
@ -204,16 +138,7 @@ pub fn render_to_memory(
|
|||
// 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);
|
||||
playhead += chunk_duration;
|
||||
}
|
||||
|
||||
println!("Export: rendered {} samples total", all_samples.len());
|
||||
|
|
@ -318,507 +243,7 @@ fn write_flac<P: AsRef<Path>>(
|
|||
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.
|
||||
///
|
||||
/// Non-finite samples (NaN/±Inf) are replaced with `0.0` and finite samples are
|
||||
/// clamped to `[-1.0, 1.0]`: the float encoders (e.g. AAC, which takes `fltp`)
|
||||
/// reject a frame outright on "(near) NaN/+-Inf", failing the whole export, so we
|
||||
/// sanitize here exactly as the integer paths already clamp.
|
||||
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];
|
||||
|
||||
let mut non_finite = 0u64;
|
||||
for (i, chunk) in interleaved.chunks(channels as usize).enumerate() {
|
||||
for (ch, &sample) in chunk.iter().enumerate() {
|
||||
planar[ch][i] = if sample.is_finite() {
|
||||
sample.clamp(-1.0, 1.0)
|
||||
} else {
|
||||
non_finite += 1;
|
||||
0.0
|
||||
};
|
||||
}
|
||||
}
|
||||
if non_finite > 0 {
|
||||
// One-time warning: we sanitized rather than failed, but a non-finite
|
||||
// sample reaching here means something upstream (an effect, automation,
|
||||
// or a source decode) produced NaN/Inf — worth chasing if audio is wrong.
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
static WARNED: AtomicBool = AtomicBool::new(false);
|
||||
if !WARNED.swap(true, Ordering::Relaxed) {
|
||||
eprintln!(
|
||||
"⚠️ [EXPORT] sanitized {} non-finite (NaN/Inf) audio sample(s) in a chunk — \
|
||||
check effects/automation/source decode",
|
||||
non_finite
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
// TODO: Add MP3 export support with a better library
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
|
|
|||
|
|
@ -90,9 +90,8 @@ impl Metronome {
|
|||
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)
|
||||
// When enabling, don't trigger a click until the next beat
|
||||
self.click_position = usize::MAX; // Set to max to prevent immediate click
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -107,7 +106,7 @@ impl Metronome {
|
|||
pub fn process(
|
||||
&mut self,
|
||||
output: &mut [f32],
|
||||
playhead_samples: i64,
|
||||
playhead_samples: u64,
|
||||
playing: bool,
|
||||
sample_rate: u32,
|
||||
channels: u32,
|
||||
|
|
@ -120,23 +119,31 @@ impl Metronome {
|
|||
let frames = output.len() / channels as usize;
|
||||
|
||||
for frame in 0..frames {
|
||||
let current_sample = playhead_samples + frame as i64;
|
||||
let current_sample = playhead_samples + frame as u64;
|
||||
|
||||
// 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 {
|
||||
// Check if we crossed a beat boundary
|
||||
if current_beat != self.last_beat && current_beat >= 0 {
|
||||
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;
|
||||
// Only trigger a click if we're not in the "just enabled" state
|
||||
if self.click_position != usize::MAX {
|
||||
// Determine which click to play
|
||||
// Beat 1 of each measure gets the accent (high click)
|
||||
let beat_in_measure = (current_beat as u32 % self.time_signature_numerator) as usize;
|
||||
let is_first_beat = beat_in_measure == 0;
|
||||
|
||||
// Start playing the appropriate click
|
||||
self.playing_high_click = is_first_beat;
|
||||
self.click_position = 0; // Start from beginning of click
|
||||
} else {
|
||||
// We just got enabled - reset position but don't play yet
|
||||
self.click_position = self.high_click.len(); // Set past end so no click plays
|
||||
}
|
||||
}
|
||||
|
||||
// Continue playing click sample if we're currently in one
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
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,
|
||||
/// Time position within the clip in seconds (sample-rate independent)
|
||||
pub timestamp: f64,
|
||||
/// MIDI status byte (includes channel)
|
||||
pub status: u8,
|
||||
/// First data byte (note number, CC number, etc.)
|
||||
|
|
@ -14,28 +12,55 @@ pub struct MidiEvent {
|
|||
}
|
||||
|
||||
impl MidiEvent {
|
||||
pub fn new(timestamp: Beats, status: u8, data1: u8, data2: u8) -> Self {
|
||||
Self { timestamp, status, data1, data2 }
|
||||
/// Create a new MIDI event
|
||||
pub fn new(timestamp: f64, 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 }
|
||||
/// Create a note on event
|
||||
pub fn note_on(timestamp: f64, 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 }
|
||||
/// Create a note off event
|
||||
pub fn note_off(timestamp: f64, channel: u8, note: u8, velocity: u8) -> Self {
|
||||
Self {
|
||||
timestamp,
|
||||
status: 0x80 | (channel & 0x0F),
|
||||
data1: note,
|
||||
data2: velocity,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this is a note on event (with non-zero velocity)
|
||||
pub fn is_note_on(&self) -> bool {
|
||||
(self.status & 0xF0) == 0x90 && self.data2 > 0
|
||||
}
|
||||
|
||||
/// Check if this is a note off event (or note on with zero velocity)
|
||||
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 }
|
||||
/// Get the MIDI channel (0-15)
|
||||
pub fn channel(&self) -> u8 {
|
||||
self.status & 0x0F
|
||||
}
|
||||
|
||||
/// Get the message type (upper 4 bits of status)
|
||||
pub fn message_type(&self) -> u8 {
|
||||
self.status & 0xF0
|
||||
}
|
||||
}
|
||||
|
||||
/// MIDI clip ID type (for clips stored in the pool)
|
||||
|
|
@ -44,118 +69,179 @@ 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)]
|
||||
/// MIDI clip content - stores the actual MIDI events
|
||||
///
|
||||
/// This represents the content data stored in the MidiClipPool.
|
||||
/// Events have timestamps relative to the start of the clip (0.0 = clip beginning).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MidiClip {
|
||||
pub id: MidiClipId,
|
||||
pub events: Vec<MidiEvent>,
|
||||
/// Total content duration in beats
|
||||
pub duration: Beats,
|
||||
pub duration: f64, // Total content duration in seconds
|
||||
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 };
|
||||
/// Create a new MIDI clip with content
|
||||
pub fn new(id: MidiClipId, events: Vec<MidiEvent>, duration: f64, name: String) -> Self {
|
||||
let mut clip = Self {
|
||||
id,
|
||||
events,
|
||||
duration,
|
||||
name,
|
||||
};
|
||||
// Sort events by timestamp
|
||||
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 }
|
||||
/// Create an empty MIDI clip
|
||||
pub fn empty(id: MidiClipId, duration: f64, name: String) -> Self {
|
||||
Self {
|
||||
id,
|
||||
events: Vec::new(),
|
||||
duration,
|
||||
name,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a MIDI event to the clip
|
||||
pub fn add_event(&mut self, event: MidiEvent) {
|
||||
self.events.push(event);
|
||||
// Keep events sorted by timestamp
|
||||
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()
|
||||
/// Get events within a time range (relative to clip start)
|
||||
/// This is used by MidiClipInstance to fetch events for a given portion
|
||||
pub fn get_events_in_range(&self, start: f64, end: f64) -> 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.
|
||||
/// MIDI clip instance - a reference to MidiClip content with timeline positioning
|
||||
///
|
||||
/// All timing fields are in beats.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
/// ## Timing Model
|
||||
/// - `internal_start` / `internal_end`: Define the region of the source clip to play (trimming)
|
||||
/// - `external_start` / `external_duration`: Define where the instance appears on the timeline and how long
|
||||
///
|
||||
/// ## Looping
|
||||
/// If `external_duration` is greater than `internal_end - internal_start`,
|
||||
/// the instance will seamlessly loop back to `internal_start` when it reaches `internal_end`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MidiClipInstance {
|
||||
pub id: MidiClipInstanceId,
|
||||
pub clip_id: MidiClipId,
|
||||
pub clip_id: MidiClipId, // Reference to MidiClip in pool
|
||||
|
||||
/// 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 within the clip content (seconds)
|
||||
pub internal_start: f64,
|
||||
/// End position within the clip content (seconds)
|
||||
pub internal_end: f64,
|
||||
|
||||
/// Start position on the timeline (beats)
|
||||
pub external_start: Beats,
|
||||
/// Duration on the timeline (beats); > internal duration = looping
|
||||
pub external_duration: Beats,
|
||||
/// Start position on the timeline (seconds)
|
||||
pub external_start: f64,
|
||||
/// Duration on the timeline (seconds) - can be longer than internal duration for looping
|
||||
pub external_duration: f64,
|
||||
}
|
||||
|
||||
impl MidiClipInstance {
|
||||
/// Create a new MIDI clip instance
|
||||
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,
|
||||
internal_start: f64,
|
||||
internal_end: f64,
|
||||
external_start: f64,
|
||||
external_duration: f64,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
clip_id,
|
||||
internal_start: Beats::ZERO,
|
||||
internal_start,
|
||||
internal_end,
|
||||
external_start,
|
||||
external_duration,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an instance that uses the full clip content (no trimming, no looping)
|
||||
pub fn from_full_clip(
|
||||
id: MidiClipInstanceId,
|
||||
clip_id: MidiClipId,
|
||||
clip_duration: f64,
|
||||
external_start: f64,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
clip_id,
|
||||
internal_start: 0.0,
|
||||
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() }
|
||||
/// Get the internal (content) duration
|
||||
pub fn internal_duration(&self) -> f64 {
|
||||
self.internal_end - self.internal_start
|
||||
}
|
||||
|
||||
/// Check if this instance overlaps with a beat range
|
||||
pub fn overlaps_range(&self, range_start: Beats, range_end: Beats) -> bool {
|
||||
/// Get the end time on the timeline
|
||||
pub fn external_end(&self) -> f64 {
|
||||
self.external_start + self.external_duration
|
||||
}
|
||||
|
||||
/// Check if this instance loops
|
||||
pub fn is_looping(&self) -> bool {
|
||||
self.external_duration > self.internal_duration()
|
||||
}
|
||||
|
||||
/// Get the end time on the timeline (for backwards compatibility)
|
||||
pub fn end_time(&self) -> f64 {
|
||||
self.external_end()
|
||||
}
|
||||
|
||||
/// Get the start time on the timeline (for backwards compatibility)
|
||||
pub fn start_time(&self) -> f64 {
|
||||
self.external_start
|
||||
}
|
||||
|
||||
/// Check if this instance overlaps with a time range
|
||||
pub fn overlaps_range(&self, range_start: f64, range_end: f64) -> 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.
|
||||
/// Get events that should be triggered in a given timeline range
|
||||
///
|
||||
/// This handles:
|
||||
/// - Trimming (internal_start/internal_end)
|
||||
/// - Looping (when external duration > internal duration)
|
||||
/// - Time mapping from timeline to clip content
|
||||
///
|
||||
/// Returns events with timestamps adjusted to timeline time (not clip-relative)
|
||||
pub fn get_events_in_range(
|
||||
&self,
|
||||
clip: &MidiClip,
|
||||
range_start: Beats,
|
||||
range_end: Beats,
|
||||
range_start_seconds: f64,
|
||||
range_end_seconds: f64,
|
||||
) -> Vec<MidiEvent> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
if !self.overlaps_range(range_start, range_end) {
|
||||
// Check if instance overlaps with the range
|
||||
if !self.overlaps_range(range_start_seconds, range_end_seconds) {
|
||||
return result;
|
||||
}
|
||||
|
||||
let internal_duration = self.internal_duration();
|
||||
if internal_duration <= Beats::ZERO {
|
||||
if internal_duration <= 0.0 {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Calculate how many complete loops fit in the external duration
|
||||
let num_loops = if self.external_duration > internal_duration {
|
||||
(self.external_duration / internal_duration).ceil() as usize
|
||||
} else {
|
||||
|
|
@ -165,19 +251,23 @@ impl MidiClipInstance {
|
|||
let external_end = self.external_end();
|
||||
|
||||
for loop_idx in 0..num_loops {
|
||||
let loop_offset = internal_duration * loop_idx as f64;
|
||||
let loop_offset = loop_idx as f64 * internal_duration;
|
||||
|
||||
// Get events from the clip that fall within the internal range
|
||||
for event in &clip.events {
|
||||
if event.timestamp < self.internal_start || event.timestamp > self.internal_end {
|
||||
// Skip events outside the trimmed region
|
||||
if event.timestamp < self.internal_start || event.timestamp >= self.internal_end {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Convert to timeline time
|
||||
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
|
||||
// Check if within current buffer range and instance bounds
|
||||
if timeline_time >= range_start_seconds
|
||||
&& timeline_time < range_end_seconds
|
||||
&& timeline_time < external_end
|
||||
{
|
||||
let mut adjusted_event = *event;
|
||||
adjusted_event.timestamp = timeline_time;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
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,
|
||||
|
|
@ -22,7 +19,7 @@ impl MidiClipPool {
|
|||
|
||||
/// 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 {
|
||||
pub fn add_clip(&mut self, events: Vec<MidiEvent>, duration: f64, name: String) -> MidiClipId {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ 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;
|
||||
|
|
@ -14,21 +13,17 @@ pub mod project;
|
|||
pub mod recording;
|
||||
pub mod sample_loader;
|
||||
pub mod track;
|
||||
pub mod waveform_cache;
|
||||
pub mod waveform_pyramid;
|
||||
|
||||
pub use automation::{AutomationLane, AutomationLaneId, AutomationPoint, CurveType, ParameterId};
|
||||
pub use buffer_pool::BufferPool;
|
||||
pub use clip::{AudioClipInstance, AudioClipInstanceId, Clip, ClipId};
|
||||
pub use disk_reader::{AudioBlobSourceFactory, MediaByteSource};
|
||||
pub use engine::{AudioClipSnapshot, Engine, EngineController};
|
||||
pub use engine::{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 pool::{AudioClipPool, AudioFile as PoolAudioFile, AudioPool};
|
||||
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
|
|
@ -1,192 +0,0 @@
|
|||
/// 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(())
|
||||
}
|
||||
|
|
@ -1,11 +1,10 @@
|
|||
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 node_trait::AudioNode;
|
||||
pub use preset::{GraphPreset, PresetMetadata, SerializedConnection, SerializedNode};
|
||||
pub use types::{ConnectionError, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
|
||||
|
|
|
|||
|
|
@ -77,34 +77,3 @@ pub trait AudioNode: Send {
|
|||
/// 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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default};
|
||||
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
|
||||
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 {
|
||||
|
|
@ -16,19 +15,6 @@ enum EnvelopeStage {
|
|||
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 {
|
||||
|
|
@ -37,15 +23,8 @@ pub struct ADSRNode {
|
|||
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>,
|
||||
|
|
@ -69,7 +48,6 @@ impl ADSRNode {
|
|||
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 {
|
||||
|
|
@ -78,12 +56,8 @@ impl ADSRNode {
|
|||
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,
|
||||
|
|
@ -115,7 +89,6 @@ impl AudioNode for ADSRNode {
|
|||
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),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
@ -126,7 +99,6 @@ impl AudioNode for ADSRNode {
|
|||
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,
|
||||
}
|
||||
}
|
||||
|
|
@ -150,31 +122,20 @@ impl AudioNode for ADSRNode {
|
|||
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;
|
||||
// Read gate input (if available)
|
||||
let gate_high = if !inputs.is_empty() && frame < inputs[0].len() {
|
||||
inputs[0][frame] > 0.5 // Gate is high if CV > 0.5
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
// 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;
|
||||
|
||||
|
|
@ -184,8 +145,7 @@ impl AudioNode for ADSRNode {
|
|||
self.level = 0.0;
|
||||
}
|
||||
EnvelopeStage::Attack => {
|
||||
match self.curve {
|
||||
CurveType::Linear => {
|
||||
// Rise from current level to 1.0
|
||||
let increment = 1.0 / (self.attack * sample_rate_f32);
|
||||
self.level += increment;
|
||||
if self.level >= 1.0 {
|
||||
|
|
@ -193,27 +153,9 @@ impl AudioNode for ADSRNode {
|
|||
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 => {
|
||||
// Fall from 1.0 to sustain level
|
||||
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 {
|
||||
|
|
@ -221,23 +163,12 @@ impl AudioNode for ADSRNode {
|
|||
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 => {
|
||||
// Fall from current level to 0.0
|
||||
let decrement = self.level / (self.release * sample_rate_f32);
|
||||
self.level -= decrement;
|
||||
if self.level <= 0.001 {
|
||||
|
|
@ -245,16 +176,6 @@ impl AudioNode for ADSRNode {
|
|||
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)
|
||||
|
|
@ -265,9 +186,6 @@ impl AudioNode for ADSRNode {
|
|||
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;
|
||||
}
|
||||
|
||||
|
|
@ -286,13 +204,9 @@ impl AudioNode for ADSRNode {
|
|||
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,
|
||||
stage: EnvelopeStage::Idle, // Reset state
|
||||
level: 0.0, // Reset level
|
||||
gate_was_high: false, // Reset gate
|
||||
inputs: self.inputs.clone(),
|
||||
outputs: self.outputs.clone(),
|
||||
parameters: self.parameters.clone(),
|
||||
|
|
|
|||
|
|
@ -1,225 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,412 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,16 @@
|
|||
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, SignalType};
|
||||
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
|
||||
use crate::audio::midi::MidiEvent;
|
||||
|
||||
/// Audio to CV converter
|
||||
/// Directly converts a stereo audio signal to mono CV (averages L+R channels)
|
||||
const PARAM_ATTACK: u32 = 0;
|
||||
const PARAM_RELEASE: u32 = 1;
|
||||
|
||||
/// Audio to CV converter (Envelope Follower)
|
||||
/// Converts audio amplitude to control voltage
|
||||
pub struct AudioToCVNode {
|
||||
name: String,
|
||||
envelope: f32, // Current envelope value
|
||||
attack: f32, // Attack time in seconds
|
||||
release: f32, // Release time in seconds
|
||||
inputs: Vec<NodePort>,
|
||||
outputs: Vec<NodePort>,
|
||||
parameters: Vec<Parameter>,
|
||||
|
|
@ -22,11 +28,19 @@ impl AudioToCVNode {
|
|||
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,
|
||||
envelope: 0.0,
|
||||
attack: 0.01,
|
||||
release: 0.1,
|
||||
inputs,
|
||||
outputs,
|
||||
parameters: Vec::new(),
|
||||
parameters,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -48,10 +62,20 @@ impl AudioNode for AudioToCVNode {
|
|||
&self.parameters
|
||||
}
|
||||
|
||||
fn set_parameter(&mut self, _id: u32, _value: f32) {}
|
||||
fn set_parameter(&mut self, id: u32, value: f32) {
|
||||
match id {
|
||||
PARAM_ATTACK => self.attack = value.clamp(0.001, 1.0),
|
||||
PARAM_RELEASE => self.release = value.clamp(0.001, 1.0),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_parameter(&self, _id: u32) -> f32 {
|
||||
0.0
|
||||
fn get_parameter(&self, id: u32) -> f32 {
|
||||
match id {
|
||||
PARAM_ATTACK => self.attack,
|
||||
PARAM_RELEASE => self.release,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn process(
|
||||
|
|
@ -60,7 +84,7 @@ impl AudioNode for AudioToCVNode {
|
|||
outputs: &mut [&mut [f32]],
|
||||
_midi_inputs: &[&[MidiEvent]],
|
||||
_midi_outputs: &mut [&mut Vec<MidiEvent>],
|
||||
_sample_rate: u32,
|
||||
sample_rate: u32,
|
||||
) {
|
||||
if inputs.is_empty() || outputs.is_empty() {
|
||||
return;
|
||||
|
|
@ -71,16 +95,39 @@ impl AudioNode for AudioToCVNode {
|
|||
|
||||
// Audio input is stereo (interleaved L/R), CV output is mono
|
||||
let audio_frames = input.len() / 2;
|
||||
let frames = audio_frames.min(output.len());
|
||||
let cv_frames = output.len();
|
||||
let frames = audio_frames.min(cv_frames);
|
||||
|
||||
// Calculate attack and release coefficients
|
||||
let sample_rate_f32 = sample_rate as f32;
|
||||
let attack_coeff = (-1.0 / (self.attack * sample_rate_f32)).exp();
|
||||
let release_coeff = (-1.0 / (self.release * sample_rate_f32)).exp();
|
||||
|
||||
for frame in 0..frames {
|
||||
// Get stereo samples
|
||||
let left = input[frame * 2];
|
||||
let right = input[frame * 2 + 1];
|
||||
output[frame] = (left + right) * 0.5;
|
||||
|
||||
// Calculate RMS-like value (average of absolute values for simplicity)
|
||||
let amplitude = (left.abs() + right.abs()) / 2.0;
|
||||
|
||||
// Envelope follower with attack/release
|
||||
if amplitude > self.envelope {
|
||||
// Attack: follow signal up quickly
|
||||
self.envelope = amplitude * (1.0 - attack_coeff) + self.envelope * attack_coeff;
|
||||
} else {
|
||||
// Release: decay slowly
|
||||
self.envelope = amplitude * (1.0 - release_coeff) + self.envelope * release_coeff;
|
||||
}
|
||||
|
||||
// Output CV (mono)
|
||||
output[frame] = self.envelope;
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {}
|
||||
fn reset(&mut self) {
|
||||
self.envelope = 0.0;
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"AudioToCV"
|
||||
|
|
@ -93,6 +140,9 @@ impl AudioNode for AudioToCVNode {
|
|||
fn clone_node(&self) -> Box<dyn AudioNode> {
|
||||
Box::new(Self {
|
||||
name: self.name.clone(),
|
||||
envelope: 0.0, // Reset envelope
|
||||
attack: self.attack,
|
||||
release: self.release,
|
||||
inputs: self.inputs.clone(),
|
||||
outputs: self.outputs.clone(),
|
||||
parameters: self.parameters.clone(),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
|
||||
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, SignalType};
|
||||
use crate::audio::midi::MidiEvent;
|
||||
use crate::time::Beats;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
|
|
@ -17,7 +16,8 @@ pub enum InterpolationType {
|
|||
/// A single keyframe in an automation curve
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AutomationKeyframe {
|
||||
pub time: Beats,
|
||||
/// Time in seconds (absolute project time)
|
||||
pub time: f64,
|
||||
/// CV output value
|
||||
pub value: f32,
|
||||
/// Interpolation type to next keyframe
|
||||
|
|
@ -29,7 +29,7 @@ pub struct AutomationKeyframe {
|
|||
}
|
||||
|
||||
impl AutomationKeyframe {
|
||||
pub fn new(time: Beats, value: f32) -> Self {
|
||||
pub fn new(time: f64, value: f32) -> Self {
|
||||
Self {
|
||||
time,
|
||||
value,
|
||||
|
|
@ -47,14 +47,8 @@ pub struct AutomationInputNode {
|
|||
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,
|
||||
/// Shared playback time (set by the graph before processing)
|
||||
playback_time: Arc<RwLock<f64>>,
|
||||
}
|
||||
|
||||
impl AutomationInputNode {
|
||||
|
|
@ -65,34 +59,23 @@ impl AutomationInputNode {
|
|||
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)],
|
||||
keyframes: Vec::new(),
|
||||
outputs,
|
||||
parameters,
|
||||
playback_time: Arc::new(RwLock::new(Beats::ZERO)),
|
||||
bpm: 120.0,
|
||||
value_min: -1.0,
|
||||
value_max: 1.0,
|
||||
parameters: Vec::new(),
|
||||
playback_time: Arc::new(RwLock::new(0.0)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_playback_time(&mut self, time: Beats) {
|
||||
/// Set the playback time (called by graph before processing)
|
||||
pub fn set_playback_time(&mut self, time: f64) {
|
||||
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
|
||||
|
|
@ -122,7 +105,8 @@ impl AutomationInputNode {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn remove_keyframe_at_time(&mut self, time: Beats, tolerance: Beats) -> bool {
|
||||
/// Remove keyframe at specific time (with tolerance)
|
||||
pub fn remove_keyframe_at_time(&mut self, time: f64, tolerance: f64) -> bool {
|
||||
if let Some(idx) = self.keyframes.iter().position(|kf| (kf.time - time).abs() < tolerance) {
|
||||
self.keyframes.remove(idx);
|
||||
true
|
||||
|
|
@ -131,8 +115,10 @@ impl AutomationInputNode {
|
|||
}
|
||||
}
|
||||
|
||||
/// Update an existing keyframe
|
||||
pub fn update_keyframe(&mut self, keyframe: AutomationKeyframe) {
|
||||
self.remove_keyframe_at_time(keyframe.time, Beats(0.001));
|
||||
// Remove old keyframe at this time, then add new one
|
||||
self.remove_keyframe_at_time(keyframe.time, 0.001);
|
||||
self.add_keyframe(keyframe);
|
||||
}
|
||||
|
||||
|
|
@ -146,20 +132,24 @@ impl AutomationInputNode {
|
|||
self.keyframes.clear();
|
||||
}
|
||||
|
||||
fn evaluate_at_time(&self, time: Beats) -> f32 {
|
||||
/// Evaluate curve at a specific time
|
||||
fn evaluate_at_time(&self, time: f64) -> f32 {
|
||||
if self.keyframes.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Before first keyframe
|
||||
if time <= self.keyframes[0].time {
|
||||
return self.keyframes[0].value;
|
||||
}
|
||||
|
||||
// After last keyframe
|
||||
let last_idx = self.keyframes.len() - 1;
|
||||
if time >= self.keyframes[last_idx].time {
|
||||
return self.keyframes[last_idx].value;
|
||||
}
|
||||
|
||||
// Find bracketing keyframes
|
||||
for i in 0..self.keyframes.len() - 1 {
|
||||
let kf1 = &self.keyframes[i];
|
||||
let kf2 = &self.keyframes[i + 1];
|
||||
|
|
@ -172,12 +162,14 @@ impl AutomationInputNode {
|
|||
0.0
|
||||
}
|
||||
|
||||
fn interpolate(&self, kf1: &AutomationKeyframe, kf2: &AutomationKeyframe, time: Beats) -> f32 {
|
||||
/// Interpolate between two keyframes
|
||||
fn interpolate(&self, kf1: &AutomationKeyframe, kf2: &AutomationKeyframe, time: f64) -> f32 {
|
||||
// Calculate normalized position between keyframes (0.0 to 1.0)
|
||||
let t = if kf2.time == kf1.time {
|
||||
0.0f64
|
||||
0.0
|
||||
} else {
|
||||
(time - kf1.time) / (kf2.time - kf1.time)
|
||||
} as f32;
|
||||
((time - kf1.time) / (kf2.time - kf1.time)) as f32
|
||||
};
|
||||
|
||||
match kf1.interpolation {
|
||||
InterpolationType::Linear => {
|
||||
|
|
@ -223,20 +215,12 @@ impl AudioNode for AutomationInputNode {
|
|||
&self.parameters
|
||||
}
|
||||
|
||||
fn set_parameter(&mut self, id: u32, value: f32) {
|
||||
match id {
|
||||
0 => self.value_min = value,
|
||||
1 => self.value_max = value,
|
||||
_ => {}
|
||||
}
|
||||
fn set_parameter(&mut self, _id: u32, _value: f32) {
|
||||
// No parameters
|
||||
}
|
||||
|
||||
fn get_parameter(&self, id: u32) -> f32 {
|
||||
match id {
|
||||
0 => self.value_min,
|
||||
1 => self.value_max,
|
||||
_ => 0.0,
|
||||
}
|
||||
fn get_parameter(&self, _id: u32) -> f32 {
|
||||
0.0
|
||||
}
|
||||
|
||||
fn process(
|
||||
|
|
@ -254,17 +238,19 @@ impl AudioNode for AutomationInputNode {
|
|||
let output = &mut outputs[0];
|
||||
let length = output.len();
|
||||
|
||||
// Get the starting playback time
|
||||
let playhead = if let Ok(playback) = self.playback_time.read() {
|
||||
*playback
|
||||
} else {
|
||||
Beats::ZERO
|
||||
0.0
|
||||
};
|
||||
|
||||
// Advance per sample in beats: beats_per_sample = bpm / 60 / sample_rate
|
||||
let beats_per_sample = self.bpm / 60.0 / sample_rate as f64;
|
||||
// Calculate time per sample
|
||||
let sample_duration = 1.0 / sample_rate as f64;
|
||||
|
||||
// Evaluate curve for each sample
|
||||
for i in 0..length {
|
||||
let time = playhead + Beats(i as f64 * beats_per_sample);
|
||||
let time = playhead + (i as f64 * sample_duration);
|
||||
output[i] = self.evaluate_at_time(time);
|
||||
}
|
||||
}
|
||||
|
|
@ -288,10 +274,7 @@ impl AudioNode for AutomationInputNode {
|
|||
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,
|
||||
playback_time: Arc::new(RwLock::new(0.0)),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,231 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
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()
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
@ -7,8 +7,8 @@ const PARAM_WET_DRY: u32 = 2;
|
|||
|
||||
const MAX_DELAY_SECONDS: f32 = 2.0;
|
||||
|
||||
/// Stereo echo node with feedback
|
||||
pub struct EchoNode {
|
||||
/// Stereo delay node with feedback
|
||||
pub struct DelayNode {
|
||||
name: String,
|
||||
delay_time: f32, // seconds
|
||||
feedback: f32, // 0.0 to 0.95
|
||||
|
|
@ -26,7 +26,7 @@ pub struct EchoNode {
|
|||
parameters: Vec<Parameter>,
|
||||
}
|
||||
|
||||
impl EchoNode {
|
||||
impl DelayNode {
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
let name = name.into();
|
||||
|
||||
|
|
@ -79,7 +79,7 @@ impl EchoNode {
|
|||
}
|
||||
}
|
||||
|
||||
impl AudioNode for EchoNode {
|
||||
impl AudioNode for DelayNode {
|
||||
fn category(&self) -> NodeCategory {
|
||||
NodeCategory::Effect
|
||||
}
|
||||
|
|
@ -185,7 +185,7 @@ impl AudioNode for EchoNode {
|
|||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"Echo"
|
||||
"Delay"
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default};
|
||||
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
|
||||
use crate::audio::midi::MidiEvent;
|
||||
use crate::dsp::biquad::BiquadFilter;
|
||||
|
||||
|
|
@ -29,8 +29,6 @@ pub struct FilterNode {
|
|||
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>,
|
||||
|
|
@ -64,7 +62,6 @@ impl FilterNode {
|
|||
resonance: 0.707,
|
||||
filter_type: FilterType::Lowpass,
|
||||
sample_rate: 44100,
|
||||
last_applied_cutoff: 1000.0,
|
||||
inputs,
|
||||
outputs,
|
||||
parameters,
|
||||
|
|
@ -153,28 +150,10 @@ impl AudioNode for FilterNode {
|
|||
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);
|
||||
}
|
||||
}
|
||||
if inputs.len() > 1 && !inputs[1].is_empty() {
|
||||
// CV input modulates cutoff frequency
|
||||
// For now, just use the base cutoff - per-sample modulation would be expensive
|
||||
// TODO: Sample CV at frame rate and update filter periodically
|
||||
}
|
||||
|
||||
// Apply filter (processes stereo interleaved)
|
||||
|
|
@ -200,10 +179,10 @@ impl AudioNode for FilterNode {
|
|||
// 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);
|
||||
new_filter.set_lowpass(self.sample_rate as f32, self.cutoff, self.resonance);
|
||||
}
|
||||
FilterType::Highpass => {
|
||||
new_filter.set_highpass(self.cutoff, self.resonance, self.sample_rate as f32);
|
||||
new_filter.set_highpass(self.sample_rate as f32, self.cutoff, self.resonance);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -214,7 +193,6 @@ impl AudioNode for FilterNode {
|
|||
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(),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default};
|
||||
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
|
||||
use crate::audio::midi::MidiEvent;
|
||||
use std::f32::consts::PI;
|
||||
|
||||
|
|
@ -256,11 +256,18 @@ impl AudioNode for FMSynthNode {
|
|||
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);
|
||||
// Read CV inputs
|
||||
let voct = if inputs.len() > 0 && !inputs[0].is_empty() {
|
||||
inputs[0][frame.min(inputs[0].len() / 2 - 1) * 2]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let gate = if inputs.len() > 1 && !inputs[1].is_empty() {
|
||||
inputs[1][frame.min(inputs[1].len() / 2 - 1) * 2]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Update state
|
||||
self.current_frequency = Self::voct_to_freq(voct);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default};
|
||||
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
|
||||
use crate::audio::midi::MidiEvent;
|
||||
|
||||
const PARAM_GAIN: u32 = 0;
|
||||
|
|
@ -90,11 +90,15 @@ impl AudioNode for GainNode {
|
|||
let frames = input.len().min(output.len()) / 2;
|
||||
|
||||
for frame in 0..frames {
|
||||
// Calculate final gain
|
||||
let mut final_gain = self.gain;
|
||||
|
||||
// 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;
|
||||
if inputs.len() > 1 && frame < inputs[1].len() {
|
||||
let cv = inputs[1][frame];
|
||||
final_gain *= cv; // Multiply gain by CV (0.0 = silence, 1.0 = full gain)
|
||||
}
|
||||
|
||||
// Apply gain to both channels
|
||||
output[frame * 2] = input[frame * 2] * final_gain; // Left
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
use crate::audio::midi::MidiEvent;
|
||||
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
|
||||
|
||||
const PARAM_PITCH_BEND_RANGE: u32 = 0;
|
||||
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, SignalType};
|
||||
|
||||
/// MIDI to CV converter
|
||||
/// Converts MIDI note events to control voltage signals
|
||||
|
|
@ -10,10 +8,7 @@ pub struct MidiToCVNode {
|
|||
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
|
||||
pitch_cv: f32, // Pitch CV (V/Oct: 0V = A4, ±1V per octave)
|
||||
inputs: Vec<NodePort>,
|
||||
outputs: Vec<NodePort>,
|
||||
parameters: Vec<Parameter>,
|
||||
|
|
@ -23,41 +18,26 @@ impl MidiToCVNode {
|
|||
pub fn new(name: impl Into<String>) -> Self {
|
||||
let name = name.into();
|
||||
|
||||
// MIDI input port for receiving MIDI through graph connections
|
||||
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("V/Oct", SignalType::CV, 0), // V/Oct: 0V = A4, ±1V per octave
|
||||
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,
|
||||
note: 60, // Middle C
|
||||
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,
|
||||
parameters: vec![], // No user parameters
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -68,37 +48,6 @@ impl MidiToCVNode {
|
|||
// 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 {
|
||||
|
|
@ -118,27 +67,46 @@ impl AudioNode for MidiToCVNode {
|
|||
&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 set_parameter(&mut self, _id: u32, _value: f32) {
|
||||
// No parameters
|
||||
}
|
||||
|
||||
fn get_parameter(&self, id: u32) -> f32 {
|
||||
if id == PARAM_PITCH_BEND_RANGE {
|
||||
self.pitch_bend_range
|
||||
} else {
|
||||
fn get_parameter(&self, _id: u32) -> f32 {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_midi(&mut self, event: &MidiEvent) {
|
||||
self.apply_midi_event(event);
|
||||
let status = event.status & 0xF0;
|
||||
|
||||
match status {
|
||||
0x90 => {
|
||||
// Note on
|
||||
if event.data2 > 0 {
|
||||
// Velocity > 0 means note on
|
||||
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;
|
||||
} else {
|
||||
// Velocity = 0 means note off
|
||||
if event.data1 == self.note {
|
||||
self.gate = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
0x80 => {
|
||||
// Note off
|
||||
if event.data1 == self.note {
|
||||
self.gate = 0.0;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn process(
|
||||
&mut self,
|
||||
inputs: &[&[f32]],
|
||||
_inputs: &[&[f32]],
|
||||
outputs: &mut [&mut [f32]],
|
||||
midi_inputs: &[&[MidiEvent]],
|
||||
_midi_outputs: &mut [&mut Vec<MidiEvent>],
|
||||
|
|
@ -147,56 +115,52 @@ impl AudioNode for MidiToCVNode {
|
|||
// Process MIDI events from input buffer
|
||||
if !midi_inputs.is_empty() {
|
||||
for event in midi_inputs[0] {
|
||||
self.apply_midi_event(event);
|
||||
let status = event.status & 0xF0;
|
||||
match status {
|
||||
0x90 if event.data2 > 0 => {
|
||||
// Note on
|
||||
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;
|
||||
}
|
||||
0x80 | 0x90 => {
|
||||
// Note off (or note on with velocity 0)
|
||||
if event.data1 == self.note {
|
||||
self.gate = 0.0;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if outputs.len() < 5 {
|
||||
if outputs.len() < 3 {
|
||||
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;
|
||||
|
||||
// CV signals are mono
|
||||
// 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_and_rest, rest) = outputs.split_at_mut(1);
|
||||
let (gate_and_rest, velocity_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 pitch_out = &mut pitch_and_rest[0];
|
||||
let gate_out = &mut gate_and_rest[0];
|
||||
let velocity_out = &mut velocity_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;
|
||||
pitch_out[frame] = self.pitch_cv;
|
||||
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 {
|
||||
|
|
@ -210,13 +174,10 @@ impl AudioNode for MidiToCVNode {
|
|||
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,
|
||||
note: 60, // Reset to middle C
|
||||
gate: 0.0, // Reset gate
|
||||
velocity: 0.0, // Reset velocity
|
||||
pitch_cv: Self::midi_note_to_voct(60), // Reset pitch
|
||||
inputs: self.inputs.clone(),
|
||||
outputs: self.outputs.clone(),
|
||||
parameters: self.parameters.clone(),
|
||||
|
|
|
|||
|
|
@ -1,74 +1,48 @@
|
|||
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.
|
||||
const PARAM_GAIN_1: u32 = 0;
|
||||
const PARAM_GAIN_2: u32 = 1;
|
||||
const PARAM_GAIN_3: u32 = 2;
|
||||
const PARAM_GAIN_4: u32 = 3;
|
||||
|
||||
/// Mixer node - combines multiple audio inputs with independent gain controls
|
||||
pub struct MixerNode {
|
||||
name: String,
|
||||
/// Displayed input ports. Length = num_ports (connected + 1 spare).
|
||||
gains: [f32; 4],
|
||||
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
|
||||
}
|
||||
let name = name.into();
|
||||
|
||||
/// Return the current number of input ports (connected + 1 spare).
|
||||
pub fn num_inputs(&self) -> usize {
|
||||
self.inputs.len()
|
||||
}
|
||||
let inputs = vec![
|
||||
NodePort::new("Input 1", SignalType::Audio, 0),
|
||||
NodePort::new("Input 2", SignalType::Audio, 1),
|
||||
NodePort::new("Input 3", SignalType::Audio, 2),
|
||||
NodePort::new("Input 4", SignalType::Audio, 3),
|
||||
];
|
||||
|
||||
/// 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
|
||||
let outputs = vec![
|
||||
NodePort::new("Mixed Out", SignalType::Audio, 0),
|
||||
];
|
||||
|
||||
self.inputs = (0..n)
|
||||
.map(|i| NodePort::new(format!("Input {}", i + 1).as_str(), SignalType::Audio, i))
|
||||
.collect();
|
||||
let parameters = vec![
|
||||
Parameter::new(PARAM_GAIN_1, "Gain 1", 0.0, 2.0, 1.0, ParameterUnit::Generic),
|
||||
Parameter::new(PARAM_GAIN_2, "Gain 2", 0.0, 2.0, 1.0, ParameterUnit::Generic),
|
||||
Parameter::new(PARAM_GAIN_3, "Gain 3", 0.0, 2.0, 1.0, ParameterUnit::Generic),
|
||||
Parameter::new(PARAM_GAIN_4, "Gain 4", 0.0, 2.0, 1.0, ParameterUnit::Generic),
|
||||
];
|
||||
|
||||
// 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);
|
||||
Self {
|
||||
name,
|
||||
gains: [1.0, 1.0, 1.0, 1.0],
|
||||
inputs,
|
||||
outputs,
|
||||
parameters,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -91,17 +65,23 @@ impl AudioNode for MixerNode {
|
|||
}
|
||||
|
||||
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);
|
||||
match id {
|
||||
PARAM_GAIN_1 => self.gains[0] = value.clamp(0.0, 2.0),
|
||||
PARAM_GAIN_2 => self.gains[1] = value.clamp(0.0, 2.0),
|
||||
PARAM_GAIN_3 => self.gains[2] = value.clamp(0.0, 2.0),
|
||||
PARAM_GAIN_4 => self.gains[3] = value.clamp(0.0, 2.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)
|
||||
match id {
|
||||
PARAM_GAIN_1 => self.gains[0],
|
||||
PARAM_GAIN_2 => self.gains[1],
|
||||
PARAM_GAIN_3 => self.gains[2],
|
||||
PARAM_GAIN_4 => self.gains[3],
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn process(
|
||||
|
|
@ -117,11 +97,20 @@ impl AudioNode for MixerNode {
|
|||
}
|
||||
|
||||
let output = &mut outputs[0];
|
||||
|
||||
// Audio signals are stereo (interleaved L/R)
|
||||
let frames = output.len() / 2;
|
||||
|
||||
// Clear output buffer first
|
||||
output.fill(0.0);
|
||||
|
||||
for (input_idx, input) in inputs.iter().enumerate() {
|
||||
let gain = self.gains.get(input_idx).copied().unwrap_or(1.0);
|
||||
// Mix each input with its gain
|
||||
for (input_idx, input) in inputs.iter().enumerate().take(4) {
|
||||
if input_idx >= self.gains.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
let gain = self.gains[input_idx];
|
||||
let input_frames = input.len() / 2;
|
||||
let process_frames = frames.min(input_frames);
|
||||
|
||||
|
|
@ -133,7 +122,7 @@ impl AudioNode for MixerNode {
|
|||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
// No per-frame state
|
||||
// No state to reset
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
|
|
@ -147,9 +136,9 @@ impl AudioNode for MixerNode {
|
|||
fn clone_node(&self) -> Box<dyn AudioNode> {
|
||||
Box::new(Self {
|
||||
name: self.name.clone(),
|
||||
gains: self.gains,
|
||||
inputs: self.inputs.clone(),
|
||||
outputs: self.outputs.clone(),
|
||||
gains: self.gains.clone(),
|
||||
parameters: self.parameters.clone(),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,13 @@
|
|||
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 delay;
|
||||
mod distortion;
|
||||
mod envelope_follower;
|
||||
mod eq;
|
||||
|
|
@ -37,31 +32,24 @@ 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 delay::DelayNode;
|
||||
pub use distortion::DistortionNode;
|
||||
pub use envelope_follower::EnvelopeFollowerNode;
|
||||
pub use eq::EQNode;
|
||||
|
|
@ -86,75 +74,10 @@ 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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ 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)]
|
||||
|
|
@ -202,7 +201,6 @@ struct Voice {
|
|||
layer_index: usize,
|
||||
playhead: f32,
|
||||
note: u8,
|
||||
channel: u8, // MIDI channel this voice was activated on
|
||||
velocity: u8,
|
||||
is_active: bool,
|
||||
|
||||
|
|
@ -223,18 +221,17 @@ enum EnvelopePhase {
|
|||
}
|
||||
|
||||
impl Voice {
|
||||
fn new(layer_index: usize, note: u8, channel: u8, velocity: u8) -> Self {
|
||||
fn new(layer_index: usize, note: 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
|
||||
crossfade_length: 1000, // ~20ms at 48kHz (longer for smoother loops)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -256,11 +253,6 @@ pub struct MultiSamplerNode {
|
|||
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>,
|
||||
|
|
@ -273,8 +265,6 @@ impl MultiSamplerNode {
|
|||
|
||||
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![
|
||||
|
|
@ -286,7 +276,6 @@ impl MultiSamplerNode {
|
|||
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 {
|
||||
|
|
@ -299,9 +288,6 @@ impl MultiSamplerNode {
|
|||
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,
|
||||
|
|
@ -472,16 +458,6 @@ impl MultiSamplerNode {
|
|||
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
|
||||
|
|
@ -492,9 +468,7 @@ impl MultiSamplerNode {
|
|||
}
|
||||
|
||||
/// 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;
|
||||
fn note_on(&mut self, note: u8, velocity: u8) {
|
||||
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) {
|
||||
|
|
@ -512,7 +486,7 @@ impl MultiSamplerNode {
|
|||
}
|
||||
});
|
||||
|
||||
let voice = Voice::new(layer_index, note, channel, velocity);
|
||||
let voice = Voice::new(layer_index, note, velocity);
|
||||
|
||||
if voice_index < self.voices.len() {
|
||||
self.voices[voice_index] = voice;
|
||||
|
|
@ -563,9 +537,6 @@ impl AudioNode for MultiSamplerNode {
|
|||
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);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
@ -576,14 +547,13 @@ impl AudioNode for MultiSamplerNode {
|
|||
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]],
|
||||
_inputs: &[&[f32]],
|
||||
outputs: &mut [&mut [f32]],
|
||||
midi_inputs: &[&[MidiEvent]],
|
||||
_midi_outputs: &mut [&mut Vec<MidiEvent>],
|
||||
|
|
@ -602,32 +572,14 @@ impl AudioNode for MultiSamplerNode {
|
|||
// 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;
|
||||
}
|
||||
_ => {}
|
||||
if event.is_note_on() {
|
||||
self.note_on(event.data1, event.data2);
|
||||
} else if event.is_note_off() {
|
||||
self.note_off(event.data1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
|
@ -645,12 +597,9 @@ impl AudioNode for MultiSamplerNode {
|
|||
|
||||
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;
|
||||
// Calculate playback speed
|
||||
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 = 2.0_f32.powf(semitone_diff as f32 / 12.0);
|
||||
let speed_adjusted = speed * (layer.sample_rate / sample_rate as f32);
|
||||
|
||||
for frame in 0..frames {
|
||||
|
|
@ -806,8 +755,6 @@ impl AudioNode for MultiSamplerNode {
|
|||
|
||||
fn reset(&mut self) {
|
||||
self.voices.clear();
|
||||
self.bend_per_channel = [0.0; 16];
|
||||
self.current_mod = 0.0;
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default};
|
||||
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
|
||||
use crate::audio::midi::MidiEvent;
|
||||
use std::f32::consts::PI;
|
||||
|
||||
|
|
@ -124,28 +124,26 @@ impl AudioNode for OscillatorNode {
|
|||
let frames = output.len() / 2;
|
||||
|
||||
for frame in 0..frames {
|
||||
// Start with base frequency
|
||||
let mut frequency = self.frequency;
|
||||
|
||||
// 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
|
||||
if !inputs.is_empty() && frame < inputs[0].len() {
|
||||
let voct = inputs[0][frame]; // Read V/Oct CV (mono)
|
||||
// Convert V/Oct to frequency: f = 440 * 2^(voct)
|
||||
// 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)
|
||||
};
|
||||
frequency = 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);
|
||||
if inputs.len() > 1 && frame < inputs[1].len() {
|
||||
let fm = inputs[1][frame]; // Read FM CV (mono)
|
||||
frequency *= 1.0 + fm;
|
||||
}
|
||||
|
||||
let freq_mod = frequency;
|
||||
|
||||
// Generate waveform sample based on waveform type
|
||||
let sample = match self.waveform {
|
||||
|
|
|
|||
|
|
@ -87,9 +87,8 @@ pub struct OscilloscopeNode {
|
|||
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)
|
||||
buffer: Arc<Mutex<CircularBuffer>>, // Audio buffer
|
||||
cv_buffer: Arc<Mutex<CircularBuffer>>, // CV buffer
|
||||
mono_buf: Vec<f32>, // Scratch buffer for stereo-to-mono downmix
|
||||
|
||||
inputs: Vec<NodePort>,
|
||||
outputs: Vec<NodePort>,
|
||||
|
|
@ -102,7 +101,8 @@ impl OscilloscopeNode {
|
|||
|
||||
let inputs = vec![
|
||||
NodePort::new("Audio In", SignalType::Audio, 0),
|
||||
NodePort::new("CV In", SignalType::CV, 1),
|
||||
NodePort::new("V/oct", SignalType::CV, 1),
|
||||
NodePort::new("CV In", SignalType::CV, 2),
|
||||
];
|
||||
|
||||
let outputs = vec![
|
||||
|
|
@ -126,7 +126,6 @@ impl OscilloscopeNode {
|
|||
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,
|
||||
|
|
@ -222,50 +221,41 @@ impl AudioNode for OscilloscopeNode {
|
|||
|
||||
let input = inputs[0];
|
||||
let output = &mut outputs[0];
|
||||
let stereo_len = input.len().min(output.len());
|
||||
let frame_count = stereo_len / 2;
|
||||
let len = input.len().min(output.len());
|
||||
|
||||
// Read CV input if available (port 1) — used for both display and V/Oct triggering
|
||||
// Read V/oct input if available and update trigger period
|
||||
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];
|
||||
self.voct_value = inputs[1][0]; // Use first sample of V/oct input
|
||||
let frequency = Self::voct_to_frequency(self.voct_value);
|
||||
// Calculate period in samples, clamped to reasonable range
|
||||
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;
|
||||
self.sample_counter = (self.sample_counter + len) % self.trigger_period;
|
||||
}
|
||||
|
||||
// Pass through audio (copy input to output)
|
||||
output[..stereo_len].copy_from_slice(&input[..stereo_len]);
|
||||
output[..len].copy_from_slice(&input[..len]);
|
||||
|
||||
// Capture audio as mono downmix to match CV time scale
|
||||
// Capture audio samples to buffer
|
||||
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]);
|
||||
buffer.write(&input[..len]);
|
||||
}
|
||||
|
||||
// Update last sample for trigger detection
|
||||
if frame_count > 0 {
|
||||
self.last_sample = (input[0] + input[1]) * 0.5;
|
||||
// Capture CV samples if CV input is connected (input 2)
|
||||
if inputs.len() > 2 && !inputs[2].is_empty() {
|
||||
let cv_input = inputs[2];
|
||||
if let Ok(mut cv_buffer) = self.cv_buffer.lock() {
|
||||
cv_buffer.write(&cv_input[..len.min(cv_input.len())]);
|
||||
}
|
||||
}
|
||||
|
||||
// Update last sample for trigger detection (use left channel, frame 0)
|
||||
if !input.is_empty() {
|
||||
self.last_sample = input[0];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -296,7 +286,6 @@ impl AudioNode for OscilloscopeNode {
|
|||
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(),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default};
|
||||
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
|
||||
use crate::audio::midi::MidiEvent;
|
||||
use std::f32::consts::PI;
|
||||
|
||||
|
|
@ -113,10 +113,18 @@ impl AudioNode for PanNode {
|
|||
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);
|
||||
// Get base pan position
|
||||
let mut pan = self.pan;
|
||||
|
||||
// Calculate gains using constant-power panning law
|
||||
// Add CV modulation if connected
|
||||
if inputs.len() > 1 && frame < inputs[1].len() {
|
||||
let cv = inputs[1][frame]; // CV is mono
|
||||
// CV is 0-1, map to -1 to +1 range
|
||||
pan += cv * 2.0 - 1.0;
|
||||
pan = pan.clamp(-1.0, 1.0);
|
||||
}
|
||||
|
||||
// Update gains if pan changed from CV
|
||||
let angle = (pan + 1.0) * 0.5 * PI / 2.0;
|
||||
let left_gain = angle.cos();
|
||||
let right_gain = angle.sin();
|
||||
|
|
|
|||
|
|
@ -1,229 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,308 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default};
|
||||
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
|
||||
use crate::audio::midi::MidiEvent;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
|
|
@ -25,7 +25,6 @@ pub struct SimpleSamplerNode {
|
|||
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>,
|
||||
|
|
@ -62,7 +61,6 @@ impl SimpleSamplerNode {
|
|||
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,
|
||||
|
|
@ -103,25 +101,13 @@ impl SimpleSamplerNode {
|
|||
}
|
||||
|
||||
/// 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.
|
||||
/// 0V = 1.0 (original speed), +1V = 2.0 (one octave up), -1V = 0.5 (one octave down)
|
||||
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;
|
||||
// Add pitch shift parameter
|
||||
let total_semitones = voct * 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() {
|
||||
|
|
@ -216,11 +202,18 @@ impl AudioNode for SimpleSamplerNode {
|
|||
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);
|
||||
// Read CV inputs
|
||||
let voct = if !inputs.is_empty() && !inputs[0].is_empty() {
|
||||
inputs[0][frame.min(inputs[0].len() / 2 - 1) * 2]
|
||||
} else {
|
||||
0.0 // Default to original pitch
|
||||
};
|
||||
|
||||
let gate = if inputs.len() > 1 && !inputs[1].is_empty() {
|
||||
inputs[1][frame.min(inputs[1].len() / 2 - 1) * 2]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Detect gate trigger (rising edge)
|
||||
let gate_active = gate > 0.5;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default};
|
||||
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
|
||||
use crate::audio::midi::MidiEvent;
|
||||
|
||||
const PARAM_RISE_TIME: u32 = 0;
|
||||
|
|
@ -90,8 +90,9 @@ impl AudioNode for SlewLimiterNode {
|
|||
return;
|
||||
}
|
||||
|
||||
let input = inputs[0];
|
||||
let output = &mut outputs[0];
|
||||
let length = output.len();
|
||||
let length = input.len().min(output.len());
|
||||
|
||||
// Calculate maximum change per sample
|
||||
let sample_duration = 1.0 / sample_rate as f32;
|
||||
|
|
@ -110,9 +111,7 @@ impl AudioNode for SlewLimiterNode {
|
|||
};
|
||||
|
||||
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 target = input[i];
|
||||
let difference = target - self.last_value;
|
||||
|
||||
let max_change = if difference > 0.0 {
|
||||
|
|
|
|||
|
|
@ -1,177 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,199 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,270 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -9,9 +9,7 @@ const DEFAULT_VOICES: usize = 8;
|
|||
#[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
|
||||
}
|
||||
|
|
@ -20,9 +18,7 @@ impl VoiceState {
|
|||
fn new() -> Self {
|
||||
Self {
|
||||
active: false,
|
||||
releasing: false,
|
||||
note: 0,
|
||||
note_channel: 0,
|
||||
age: 0,
|
||||
pending_events: Vec::new(),
|
||||
}
|
||||
|
|
@ -76,19 +72,8 @@ impl VoiceAllocatorNode {
|
|||
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 empty template graph
|
||||
let template_graph = AudioGraph::new(sample_rate, buffer_size);
|
||||
|
||||
// Create voice instances (initially empty clones of template)
|
||||
let voice_instances: Vec<AudioGraph> = (0..MAX_VOICES)
|
||||
|
|
@ -149,9 +134,9 @@ impl VoiceAllocatorNode {
|
|||
}
|
||||
}
|
||||
|
||||
/// Find a free voice, or steal one
|
||||
/// Priority: inactive → oldest releasing → oldest held
|
||||
/// Find a free voice, or steal the oldest one
|
||||
fn find_voice_for_note_on(&mut self) -> usize {
|
||||
// Only search within active voice_count
|
||||
// First, look for an inactive voice
|
||||
for (i, voice) in self.voices[..self.voice_count].iter().enumerate() {
|
||||
if !voice.active {
|
||||
|
|
@ -159,17 +144,7 @@ impl VoiceAllocatorNode {
|
|||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// No free voices, steal the oldest one within voice_count
|
||||
self.voices[..self.voice_count]
|
||||
.iter()
|
||||
.enumerate()
|
||||
|
|
@ -178,42 +153,13 @@ impl VoiceAllocatorNode {
|
|||
.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)
|
||||
/// Find all voices playing a specific note
|
||||
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 {
|
||||
if v.active && v.note == note {
|
||||
Some(i)
|
||||
} else {
|
||||
None
|
||||
|
|
@ -249,7 +195,6 @@ impl AudioNode for VoiceAllocatorNode {
|
|||
// Stop voices beyond the new count
|
||||
for voice in &mut self.voices[new_count..] {
|
||||
voice.active = false;
|
||||
voice.releasing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -273,37 +218,33 @@ impl AudioNode for VoiceAllocatorNode {
|
|||
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
|
||||
// Velocity = 0 means note off - send to ALL voices playing this note
|
||||
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].active = false;
|
||||
self.voices[voice_idx].pending_events.push(*event);
|
||||
}
|
||||
}
|
||||
}
|
||||
0x80 => {
|
||||
// Note off — mark releasing, keep active for ADSR release
|
||||
// Note off - send to ALL voices playing this note
|
||||
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].active = false;
|
||||
self.voices[voice_idx].pending_events.push(*event);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Route to matching-channel voices; channel 0 = global broadcast
|
||||
let event_channel = event.status & 0x0F;
|
||||
// Other MIDI events (CC, pitch bend, etc.) - send to all active voices
|
||||
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);
|
||||
if self.voices[voice_idx].active {
|
||||
self.voices[voice_idx].pending_events.push(*event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -348,17 +289,7 @@ impl AudioNode for VoiceAllocatorNode {
|
|||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
self.voice_instances[voice_idx].process(mix_slice, &midi_events, 0.0);
|
||||
|
||||
// Mix into output (accumulate)
|
||||
for (i, sample) in mix_slice.iter().enumerate() {
|
||||
|
|
@ -366,12 +297,20 @@ impl AudioNode for VoiceAllocatorNode {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply normalization to prevent clipping (divide by active voice count)
|
||||
let active_count = self.voices[..self.voice_count].iter().filter(|v| v.active).count();
|
||||
if active_count > 1 {
|
||||
let scale = 1.0 / (active_count as f32).sqrt(); // Use sqrt for better loudness perception
|
||||
for sample in output.iter_mut() {
|
||||
*sample *= scale;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default};
|
||||
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
|
||||
use crate::audio::midi::MidiEvent;
|
||||
use std::f32::consts::PI;
|
||||
|
||||
|
|
@ -243,11 +243,14 @@ impl AudioNode for WavetableOscillatorNode {
|
|||
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);
|
||||
// Read V/Oct input
|
||||
let voct = if !inputs.is_empty() && !inputs[0].is_empty() {
|
||||
inputs[0][frame.min(inputs[0].len() / 2 - 1) * 2]
|
||||
} else {
|
||||
0.0 // Default to A4 (440 Hz)
|
||||
};
|
||||
|
||||
// Calculate frequency from V/Oct
|
||||
// Calculate frequency
|
||||
let freq = self.voct_to_freq(voct);
|
||||
|
||||
// Read from wavetable
|
||||
|
|
|
|||
|
|
@ -67,10 +67,6 @@ pub struct GraphPreset {
|
|||
|
||||
/// 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
|
||||
|
|
@ -100,16 +96,6 @@ 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 {
|
||||
|
|
@ -133,58 +119,6 @@ pub struct SerializedNode {
|
|||
/// 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
|
||||
|
|
@ -218,7 +152,6 @@ impl GraphPreset {
|
|||
connections: Vec::new(),
|
||||
midi_targets: Vec::new(),
|
||||
output_node: None,
|
||||
groups: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -253,12 +186,6 @@ impl SerializedNode {
|
|||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,12 +1,9 @@
|
|||
use super::buffer_pool::BufferPool;
|
||||
use super::clip::{AudioClipInstanceId, Clip};
|
||||
use super::clip::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
|
||||
|
|
@ -16,7 +13,6 @@ use std::collections::HashMap;
|
|||
///
|
||||
/// 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,
|
||||
|
|
@ -84,7 +80,7 @@ impl Project {
|
|||
/// 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);
|
||||
let group = Metatrack::new(id, name);
|
||||
self.tracks.insert(id, TrackNode::Group(group));
|
||||
|
||||
if let Some(parent) = parent_id {
|
||||
|
|
@ -213,11 +209,6 @@ impl Project {
|
|||
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) {
|
||||
|
|
@ -235,18 +226,6 @@ impl Project {
|
|||
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
|
||||
|
|
@ -263,11 +242,10 @@ impl Project {
|
|||
}
|
||||
|
||||
/// Add a clip to an audio track
|
||||
pub fn add_clip(&mut self, track_id: TrackId, clip: Clip) -> Result<AudioClipInstanceId, &'static str> {
|
||||
pub fn add_clip(&mut self, track_id: TrackId, clip: Clip) -> Result<(), &'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)
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Track not found or is not an audio track")
|
||||
}
|
||||
|
|
@ -290,9 +268,9 @@ impl Project {
|
|||
&mut self,
|
||||
track_id: TrackId,
|
||||
events: Vec<MidiEvent>,
|
||||
duration: Beats,
|
||||
duration: f64,
|
||||
name: String,
|
||||
external_start: Beats,
|
||||
external_start: f64,
|
||||
) -> Result<(MidiClipId, MidiClipInstanceId), &'static str> {
|
||||
// Verify track exists and is a MIDI track
|
||||
if !matches!(self.tracks.get(&track_id), Some(TrackNode::Midi(_))) {
|
||||
|
|
@ -324,12 +302,12 @@ impl Project {
|
|||
}
|
||||
|
||||
/// 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)
|
||||
pub fn add_midi_clip(&mut self, track_id: TrackId, clip: MidiClip) -> Result<(), &'static str> {
|
||||
self.add_midi_clip_at(track_id, clip, 0.0)
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
pub fn add_midi_clip_at(&mut self, track_id: TrackId, clip: MidiClip, start_time: f64) -> Result<(), &'static str> {
|
||||
// Add the clip to the pool (it already has events and duration)
|
||||
let duration = clip.duration;
|
||||
let clip_id = clip.id;
|
||||
|
|
@ -339,63 +317,39 @@ impl Project {
|
|||
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)
|
||||
self.add_midi_clip_instance(track_id, instance)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Render all root tracks into the output buffer
|
||||
pub fn render(
|
||||
&mut self,
|
||||
output: &mut [f32],
|
||||
audio_pool: &AudioClipPool,
|
||||
midi_pool: &MidiClipPool,
|
||||
buffer_pool: &mut BufferPool,
|
||||
playhead_seconds: Seconds,
|
||||
tempo_map: &TempoMap,
|
||||
playhead_seconds: f64,
|
||||
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())
|
||||
};
|
||||
let ctx = RenderContext::new(
|
||||
playhead_seconds,
|
||||
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];
|
||||
// Render each root track
|
||||
for &track_id in &self.root_tracks.clone() {
|
||||
self.render_track(
|
||||
track_id,
|
||||
output,
|
||||
audio_pool,
|
||||
midi_pool,
|
||||
buffer_pool,
|
||||
ctx,
|
||||
any_solo,
|
||||
|
|
@ -410,6 +364,7 @@ impl Project {
|
|||
track_id: TrackId,
|
||||
output: &mut [f32],
|
||||
audio_pool: &AudioClipPool,
|
||||
midi_pool: &MidiClipPool,
|
||||
buffer_pool: &mut BufferPool,
|
||||
ctx: RenderContext,
|
||||
any_solo: bool,
|
||||
|
|
@ -452,146 +407,54 @@ impl Project {
|
|||
// 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);
|
||||
// Render audio track directly into output
|
||||
track.render(output, audio_pool, ctx.playhead_seconds, ctx.sample_rate, ctx.channels);
|
||||
}
|
||||
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);
|
||||
// Render MIDI track directly into output
|
||||
track.render(output, midi_pool, ctx.playhead_seconds, ctx.sample_rate, ctx.channels);
|
||||
}
|
||||
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();
|
||||
// Get children IDs, check if this group is soloed, and transform context
|
||||
let children: Vec<TrackId> = group.children.clone();
|
||||
let this_group_is_soloed = group.solo;
|
||||
let child_ctx = group.transform_context(ctx);
|
||||
|
||||
// Acquire a temporary buffer for the group mix
|
||||
let mut group_buffer = buffer_pool.acquire();
|
||||
group_buffer.resize(output.len(), 0.0);
|
||||
group_buffer.fill(0.0);
|
||||
|
||||
// Recursively render all children into the group buffer
|
||||
// If this group is soloed (or parent was soloed), children inherit that state
|
||||
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);
|
||||
|
||||
for &child_id in &children {
|
||||
self.render_track(
|
||||
child_id,
|
||||
&mut child_buffer,
|
||||
&mut group_buffer,
|
||||
audio_pool,
|
||||
midi_pool,
|
||||
buffer_pool,
|
||||
child_ctx,
|
||||
any_solo,
|
||||
children_parent_soloed,
|
||||
);
|
||||
}
|
||||
|
||||
// Inject into the SubtrackInputsNode slot for this child
|
||||
// Apply group volume and mix into output
|
||||
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;
|
||||
}
|
||||
}
|
||||
for (out_sample, group_sample) in output.iter_mut().zip(group_buffer.iter()) {
|
||||
*out_sample += group_sample * group.volume;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
// Release buffer back to pool
|
||||
buffer_pool.release(group_buffer);
|
||||
}
|
||||
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() {
|
||||
|
|
@ -601,39 +464,13 @@ impl Project {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
/// Process live MIDI input from all MIDI tracks (called even when not playing)
|
||||
pub fn process_live_midi(&mut self, output: &mut [f32], sample_rate: u32, channels: u32) {
|
||||
// Process all MIDI tracks to handle queued live input events
|
||||
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),
|
||||
if let TrackNode::Midi(midi_track) = track {
|
||||
// Process only queued live events, not clips
|
||||
midi_track.process_live_input(output, sample_rate, channels);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -643,7 +480,7 @@ impl Project {
|
|||
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);
|
||||
let event = MidiEvent::note_on(0.0, 0, note, velocity);
|
||||
track.queue_live_midi(event);
|
||||
}
|
||||
}
|
||||
|
|
@ -652,51 +489,10 @@ impl Project {
|
|||
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);
|
||||
let event = MidiEvent::note_off(0.0, 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 {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
/// 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;
|
||||
|
||||
|
|
@ -13,16 +12,20 @@ pub struct RecordingState {
|
|||
pub clip_id: ClipId,
|
||||
/// Path to temporary WAV file
|
||||
pub temp_file_path: PathBuf,
|
||||
/// WAV file writer (only used at finalization, not during recording)
|
||||
/// WAV file writer
|
||||
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
|
||||
/// Timeline start position in seconds
|
||||
pub start_time: f64,
|
||||
/// Total frames written to disk
|
||||
pub frames_written: usize,
|
||||
/// Accumulation buffer for next flush
|
||||
pub buffer: Vec<f32>,
|
||||
/// Number of frames to accumulate before flushing
|
||||
pub flush_interval_frames: usize,
|
||||
/// Whether recording is currently paused
|
||||
pub paused: bool,
|
||||
/// Number of samples remaining to skip (to discard stale buffer data)
|
||||
|
|
@ -33,7 +36,7 @@ pub struct RecordingState {
|
|||
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)
|
||||
/// All recorded audio data accumulated in memory (for fast finalization)
|
||||
pub audio_data: Vec<f32>,
|
||||
}
|
||||
|
||||
|
|
@ -46,9 +49,11 @@ impl RecordingState {
|
|||
writer: WavWriter,
|
||||
sample_rate: u32,
|
||||
channels: u32,
|
||||
start_time: Beats,
|
||||
_flush_interval_seconds: f64, // No longer used - kept for API compatibility
|
||||
start_time: f64,
|
||||
flush_interval_seconds: f64,
|
||||
) -> Self {
|
||||
let flush_interval_frames = (sample_rate as f64 * flush_interval_seconds) as usize;
|
||||
|
||||
// Calculate frames per waveform peak
|
||||
// Target ~300 peaks per second with minimum 1000 samples per peak
|
||||
let target_peaks_per_second = 300;
|
||||
|
|
@ -63,6 +68,8 @@ impl RecordingState {
|
|||
channels,
|
||||
start_time,
|
||||
frames_written: 0,
|
||||
buffer: Vec::new(),
|
||||
flush_interval_frames,
|
||||
paused: false,
|
||||
samples_to_skip: 0, // Will be set by engine when it knows buffer size
|
||||
waveform: Vec::new(),
|
||||
|
|
@ -95,16 +102,22 @@ impl RecordingState {
|
|||
samples
|
||||
};
|
||||
|
||||
// Add to audio data (accumulate in memory - disk write happens at finalization only)
|
||||
// Add to disk buffer
|
||||
self.buffer.extend_from_slice(samples_to_process);
|
||||
|
||||
// Add to audio data (accumulate in memory for fast finalization)
|
||||
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;
|
||||
// Check if we should flush to disk
|
||||
let frames_in_buffer = self.buffer.len() / self.channels as usize;
|
||||
if frames_in_buffer >= self.flush_interval_frames {
|
||||
self.flush()?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
|
@ -131,17 +144,37 @@ impl RecordingState {
|
|||
}
|
||||
}
|
||||
|
||||
/// Get current recording duration
|
||||
pub fn duration(&self) -> Seconds {
|
||||
Seconds(self.frames_written as f64 / self.sample_rate as f64)
|
||||
/// Flush accumulated samples to disk
|
||||
pub fn flush(&mut self) -> Result<(), std::io::Error> {
|
||||
if self.buffer.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Write to WAV file
|
||||
self.writer.write_samples(&self.buffer)?;
|
||||
|
||||
// Update frames written
|
||||
let frames_flushed = self.buffer.len() / self.channels as usize;
|
||||
self.frames_written += frames_flushed;
|
||||
|
||||
// Clear buffer
|
||||
self.buffer.clear();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get current recording duration in seconds
|
||||
/// Includes both flushed frames and buffered frames
|
||||
pub fn duration(&self) -> f64 {
|
||||
let buffered_frames = self.buffer.len() / self.channels as usize;
|
||||
let total_frames = self.frames_written + buffered_frames;
|
||||
total_frames 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)?;
|
||||
}
|
||||
// Flush any remaining samples to disk
|
||||
self.flush()?;
|
||||
|
||||
// Generate final waveform peak from any remaining samples
|
||||
if !self.waveform_buffer.is_empty() {
|
||||
|
|
@ -176,23 +209,33 @@ impl RecordingState {
|
|||
/// Active MIDI note waiting for its noteOff event
|
||||
#[derive(Debug, Clone)]
|
||||
struct ActiveMidiNote {
|
||||
/// MIDI note number (0-127)
|
||||
note: u8,
|
||||
/// Velocity (0-127)
|
||||
velocity: u8,
|
||||
start_time: Beats,
|
||||
/// Absolute time when note started (seconds)
|
||||
start_time: f64,
|
||||
}
|
||||
|
||||
/// State of an active MIDI recording session.
|
||||
/// State of an active MIDI recording session
|
||||
pub struct MidiRecordingState {
|
||||
/// Track being recorded to
|
||||
pub track_id: TrackId,
|
||||
/// MIDI clip ID
|
||||
pub clip_id: MidiClipId,
|
||||
pub start_time: Beats,
|
||||
/// Timeline start position in seconds
|
||||
pub start_time: f64,
|
||||
/// Currently active notes (noteOn without matching noteOff)
|
||||
/// Maps note number to ActiveMidiNote
|
||||
active_notes: HashMap<u8, ActiveMidiNote>,
|
||||
/// Completed notes: (time_offset, note, velocity, duration) — all times in beats
|
||||
pub completed_notes: Vec<(Beats, u8, u8, Beats)>,
|
||||
/// Completed notes ready to be added to clip
|
||||
/// Format: (time_offset, note, velocity, duration)
|
||||
pub completed_notes: Vec<(f64, u8, u8, f64)>,
|
||||
}
|
||||
|
||||
impl MidiRecordingState {
|
||||
pub fn new(track_id: TrackId, clip_id: MidiClipId, start_time: Beats) -> Self {
|
||||
/// Create a new MIDI recording state
|
||||
pub fn new(track_id: TrackId, clip_id: MidiClipId, start_time: f64) -> Self {
|
||||
Self {
|
||||
track_id,
|
||||
clip_id,
|
||||
|
|
@ -202,62 +245,65 @@ impl MidiRecordingState {
|
|||
}
|
||||
}
|
||||
|
||||
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 });
|
||||
/// Handle a MIDI note on event
|
||||
pub fn note_on(&mut self, note: u8, velocity: u8, absolute_time: f64) {
|
||||
// Store this note as active
|
||||
self.active_notes.insert(note, ActiveMidiNote {
|
||||
note,
|
||||
velocity,
|
||||
start_time: absolute_time,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn note_off(&mut self, note: u8, absolute_time: Beats) {
|
||||
/// Handle a MIDI note off event
|
||||
pub fn note_off(&mut self, note: u8, absolute_time: f64) {
|
||||
// Find the matching noteOn
|
||||
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);
|
||||
// Calculate relative time offset and duration
|
||||
let time_offset = active_note.start_time - self.start_time;
|
||||
let duration = absolute_time - active_note.start_time;
|
||||
|
||||
eprintln!("[MIDI_RECORDING_STATE] Completing note {}: note_start={:.3}s, note_end={:.3}s, recording_start={:.3}s, time_offset={:.3}s, duration={:.3}s",
|
||||
note, active_note.start_time, absolute_time, self.start_time, time_offset, duration);
|
||||
|
||||
// Add to completed notes
|
||||
self.completed_notes.push((
|
||||
note_start - self.start_time,
|
||||
time_offset,
|
||||
active_note.note,
|
||||
active_note.velocity,
|
||||
absolute_time - note_start,
|
||||
duration,
|
||||
));
|
||||
}
|
||||
// If no matching noteOn found, ignore the noteOff
|
||||
}
|
||||
|
||||
pub fn get_notes(&self) -> &[(Beats, u8, u8, Beats)] {
|
||||
/// Get all completed notes
|
||||
pub fn get_notes(&self) -> &[(f64, u8, u8, f64)] {
|
||||
&self.completed_notes
|
||||
}
|
||||
|
||||
/// Get the number of 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) {
|
||||
/// Close out all active notes at the given time
|
||||
/// This should be called when stopping recording to end any held notes
|
||||
pub fn close_active_notes(&mut self, end_time: f64) {
|
||||
// Collect all active notes and close them
|
||||
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);
|
||||
// Calculate relative time offset and duration
|
||||
let time_offset = active_note.start_time - self.start_time;
|
||||
let duration = end_time - active_note.start_time;
|
||||
|
||||
// Add to completed notes
|
||||
self.completed_notes.push((
|
||||
note_start - self.start_time,
|
||||
time_offset,
|
||||
active_note.note,
|
||||
active_note.velocity,
|
||||
end_time - note_start,
|
||||
duration,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ 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
|
||||
|
|
@ -21,36 +20,33 @@ pub struct SampleData {
|
|||
/// 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))?;
|
||||
|
||||
// Open the file
|
||||
let file = File::open(path)
|
||||
.map_err(|e| format!("Failed to open file: {}", e))?;
|
||||
|
||||
// Create a media source stream
|
||||
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());
|
||||
// Create a hint to help the format registry guess the format
|
||||
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);
|
||||
if let Some(extension) = path.extension() {
|
||||
if let Some(ext_str) = extension.to_str() {
|
||||
hint.with_extension(ext_str);
|
||||
}
|
||||
}
|
||||
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> {
|
||||
// Probe the media source for a format
|
||||
let format_opts = FormatOptions::default();
|
||||
let metadata_opts = MetadataOptions::default();
|
||||
|
||||
let probed = symphonia::default::get_probe()
|
||||
.format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default())
|
||||
.format(&hint, mss, &format_opts, &metadata_opts)
|
||||
.map_err(|e| format!("Failed to probe format: {}", e))?;
|
||||
|
||||
let mut format = probed.format;
|
||||
|
||||
// Find the first audio track
|
||||
let track = format
|
||||
.tracks()
|
||||
.iter()
|
||||
|
|
@ -60,33 +56,47 @@ fn decode_mss(mss: MediaSourceStream, hint: Hint) -> Result<SampleData, String>
|
|||
let track_id = track.id;
|
||||
let sample_rate = track.codec_params.sample_rate.unwrap_or(48000);
|
||||
|
||||
// Create a decoder for the track
|
||||
let dec_opts = DecoderOptions::default();
|
||||
let mut decoder = symphonia::default::get_codecs()
|
||||
.make(&track.codec_params, &DecoderOptions::default())
|
||||
.make(&track.codec_params, &dec_opts)
|
||||
.map_err(|e| format!("Failed to create decoder: {}", e))?;
|
||||
|
||||
// Decode all packets
|
||||
let mut all_samples = Vec::new();
|
||||
|
||||
loop {
|
||||
// Get the next packet
|
||||
let packet = match format.next_packet() {
|
||||
Ok(packet) => packet,
|
||||
Err(SymphoniaError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
|
||||
// End of stream
|
||||
break;
|
||||
}
|
||||
Err(e) => return Err(format!("Error reading packet: {}", e)),
|
||||
Err(e) => {
|
||||
return Err(format!("Error reading packet: {}", e));
|
||||
}
|
||||
};
|
||||
|
||||
// Skip packets that don't belong to the selected track
|
||||
if packet.track_id() != track_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Decode the packet
|
||||
let decoded = decoder
|
||||
.decode(&packet)
|
||||
.map_err(|e| format!("Failed to decode packet: {}", e))?;
|
||||
|
||||
all_samples.extend_from_slice(&convert_to_mono_f32(&decoded));
|
||||
// Convert to f32 samples and mix to mono
|
||||
let samples = convert_to_mono_f32(&decoded);
|
||||
all_samples.extend_from_slice(&samples);
|
||||
}
|
||||
|
||||
Ok(SampleData { samples: all_samples, sample_rate })
|
||||
Ok(SampleData {
|
||||
samples: all_samples,
|
||||
sample_rate,
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert an audio buffer to mono f32 samples
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,290 +0,0 @@
|
|||
//! 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,292 +0,0 @@
|
|||
//! Streaming min/max waveform LOD pyramid.
|
||||
//!
|
||||
//! A waveform pyramid is a tree of zoom levels. **Index = tree depth:**
|
||||
//! `levels[0]` is the **root** (a single texel — the min/max envelope of the
|
||||
//! whole file, lowest resolution); each deeper level is `BRANCH`× finer, and
|
||||
//! `levels.last()` is the **floor** (one texel per `floor_samples_per_texel`
|
||||
//! source frames — the finest *persisted* level). A node's children live at
|
||||
//! `index + 1`, so the residency invariant ("a node is cleared only after its
|
||||
//! children") reads straight off the index.
|
||||
//!
|
||||
//! Below the floor (finer than the floor bucket) is *not* stored; the caller
|
||||
//! re-decodes the source window on demand for true per-sample detail.
|
||||
//!
|
||||
//! The builder is **streaming**: samples are pushed once, in order, and only the
|
||||
//! finest level is accumulated (~`total_frames / floor` texels); the coarser
|
||||
//! levels are derived by repeated `BRANCH:1` min/max reduction in [`finish`].
|
||||
//! This yields the identical pyramid to an in-stream cascade (each parent = the
|
||||
//! min/max of its children) without ever holding the full sample buffer.
|
||||
//!
|
||||
//! **Ragged edges are handled by reducing over available children:** a bucket
|
||||
//! whose group is partial (1..BRANCH children, or `< floor` samples at the floor)
|
||||
//! simply takes the min/max of what's there — no value padding. Padding to a
|
||||
//! regular shape, if ever needed, is a GPU-texture/tile concern, not the data's.
|
||||
//!
|
||||
//! Each texel carries per-channel min/max for up to two channels
|
||||
//! (`Lmin,Lmax,Rmin,Rmax`), matching the GPU waveform texture; mono mirrors the
|
||||
//! left channel into the right.
|
||||
//!
|
||||
//! [`finish`]: WaveformPyramidBuilder::finish
|
||||
|
||||
/// Reduction factor between adjacent pyramid levels.
|
||||
pub const BRANCH: u32 = 4;
|
||||
|
||||
/// Default finest-level resolution (source frames per floor texel). Trades
|
||||
/// on-disk pyramid size against how soon zoom-in must re-decode the source.
|
||||
pub const DEFAULT_FLOOR_SAMPLES_PER_TEXEL: u32 = 256;
|
||||
|
||||
/// One waveform texel: per-channel min/max (stereo; mono duplicates left→right).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Texel {
|
||||
pub l_min: f32,
|
||||
pub l_max: f32,
|
||||
pub r_min: f32,
|
||||
pub r_max: f32,
|
||||
}
|
||||
|
||||
impl Texel {
|
||||
const EMPTY: Texel = Texel {
|
||||
l_min: f32::INFINITY,
|
||||
l_max: f32::NEG_INFINITY,
|
||||
r_min: f32::INFINITY,
|
||||
r_max: f32::NEG_INFINITY,
|
||||
};
|
||||
|
||||
#[inline]
|
||||
fn include_sample(&mut self, l: f32, r: f32) {
|
||||
self.l_min = self.l_min.min(l);
|
||||
self.l_max = self.l_max.max(l);
|
||||
self.r_min = self.r_min.min(r);
|
||||
self.r_max = self.r_max.max(r);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn include_texel(&mut self, c: &Texel) {
|
||||
self.l_min = self.l_min.min(c.l_min);
|
||||
self.l_max = self.l_max.max(c.l_max);
|
||||
self.r_min = self.r_min.min(c.r_min);
|
||||
self.r_max = self.r_max.max(c.r_max);
|
||||
}
|
||||
}
|
||||
|
||||
/// A built min/max LOD pyramid, **root-first**: `levels[0]` is the coarsest
|
||||
/// (whole-file envelope), `levels.last()` is the finest persisted (floor).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WaveformPyramid {
|
||||
pub floor_samples_per_texel: u32,
|
||||
pub branch: u32,
|
||||
pub channels: u32,
|
||||
pub total_frames: u64,
|
||||
pub levels: Vec<Vec<Texel>>,
|
||||
}
|
||||
|
||||
impl WaveformPyramid {
|
||||
/// Coarsest level — a single texel (whole-file envelope), or empty if no
|
||||
/// samples were pushed.
|
||||
pub fn root(&self) -> &[Texel] {
|
||||
self.levels.first().map_or(&[][..], |v| v)
|
||||
}
|
||||
|
||||
/// Finest persisted level (`floor_samples_per_texel` frames per texel).
|
||||
pub fn floor(&self) -> &[Texel] {
|
||||
self.levels.last().map_or(&[][..], |v| v)
|
||||
}
|
||||
|
||||
/// Number of levels (tree depth + 1).
|
||||
pub fn depth(&self) -> usize {
|
||||
self.levels.len()
|
||||
}
|
||||
|
||||
/// Serialize to a compact binary blob (for persisting in the `.beam`
|
||||
/// container). Header carries `B`/branch/channels/total_frames + per-level
|
||||
/// lengths, then root-first texel data (`f32` min/max).
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
let total_texels: usize = self.levels.iter().map(|l| l.len()).sum();
|
||||
let mut out = Vec::with_capacity(32 + self.levels.len() * 4 + total_texels * 16);
|
||||
out.extend_from_slice(b"LBWF");
|
||||
out.extend_from_slice(&1u32.to_le_bytes()); // format version
|
||||
out.extend_from_slice(&self.floor_samples_per_texel.to_le_bytes());
|
||||
out.extend_from_slice(&self.branch.to_le_bytes());
|
||||
out.extend_from_slice(&self.channels.to_le_bytes());
|
||||
out.extend_from_slice(&self.total_frames.to_le_bytes());
|
||||
out.extend_from_slice(&(self.levels.len() as u32).to_le_bytes());
|
||||
for level in &self.levels {
|
||||
out.extend_from_slice(&(level.len() as u32).to_le_bytes());
|
||||
}
|
||||
for level in &self.levels {
|
||||
for t in level {
|
||||
out.extend_from_slice(&t.l_min.to_le_bytes());
|
||||
out.extend_from_slice(&t.l_max.to_le_bytes());
|
||||
out.extend_from_slice(&t.r_min.to_le_bytes());
|
||||
out.extend_from_slice(&t.r_max.to_le_bytes());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Reconstruct from [`WaveformPyramid::to_bytes`].
|
||||
pub fn from_bytes(data: &[u8]) -> Result<WaveformPyramid, String> {
|
||||
let mut r = ByteReader::new(data);
|
||||
if r.take(4)? != b"LBWF" {
|
||||
return Err("Not a waveform pyramid blob".to_string());
|
||||
}
|
||||
let version = r.u32()?;
|
||||
if version != 1 {
|
||||
return Err(format!("Unsupported waveform pyramid version {}", version));
|
||||
}
|
||||
let floor_samples_per_texel = r.u32()?;
|
||||
let branch = r.u32()?;
|
||||
let channels = r.u32()?;
|
||||
let total_frames = r.u64()?;
|
||||
let num_levels = r.u32()? as usize;
|
||||
let mut level_lens = Vec::with_capacity(num_levels);
|
||||
for _ in 0..num_levels {
|
||||
level_lens.push(r.u32()? as usize);
|
||||
}
|
||||
let mut levels = Vec::with_capacity(num_levels);
|
||||
for &len in &level_lens {
|
||||
let mut level = Vec::with_capacity(len);
|
||||
for _ in 0..len {
|
||||
level.push(Texel {
|
||||
l_min: r.f32()?,
|
||||
l_max: r.f32()?,
|
||||
r_min: r.f32()?,
|
||||
r_max: r.f32()?,
|
||||
});
|
||||
}
|
||||
levels.push(level);
|
||||
}
|
||||
Ok(WaveformPyramid {
|
||||
floor_samples_per_texel,
|
||||
branch,
|
||||
channels,
|
||||
total_frames,
|
||||
levels,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal little-endian byte cursor for [`WaveformPyramid::from_bytes`].
|
||||
struct ByteReader<'a> {
|
||||
data: &'a [u8],
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> ByteReader<'a> {
|
||||
fn new(data: &'a [u8]) -> Self {
|
||||
Self { data, pos: 0 }
|
||||
}
|
||||
fn take(&mut self, n: usize) -> Result<&'a [u8], String> {
|
||||
let end = self.pos.checked_add(n).ok_or("overflow")?;
|
||||
if end > self.data.len() {
|
||||
return Err("Truncated waveform pyramid blob".to_string());
|
||||
}
|
||||
let s = &self.data[self.pos..end];
|
||||
self.pos = end;
|
||||
Ok(s)
|
||||
}
|
||||
fn u32(&mut self) -> Result<u32, String> {
|
||||
Ok(u32::from_le_bytes(self.take(4)?.try_into().unwrap()))
|
||||
}
|
||||
fn u64(&mut self) -> Result<u64, String> {
|
||||
Ok(u64::from_le_bytes(self.take(8)?.try_into().unwrap()))
|
||||
}
|
||||
fn f32(&mut self) -> Result<f32, String> {
|
||||
Ok(f32::from_le_bytes(self.take(4)?.try_into().unwrap()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Streaming builder for a [`WaveformPyramid`]. See the module docs.
|
||||
pub struct WaveformPyramidBuilder {
|
||||
floor: u32,
|
||||
branch: u32,
|
||||
channels: u32,
|
||||
total_frames: u64,
|
||||
floor_level: Vec<Texel>,
|
||||
acc: Texel,
|
||||
acc_count: u32,
|
||||
}
|
||||
|
||||
impl WaveformPyramidBuilder {
|
||||
pub fn new(channels: u32, floor_samples_per_texel: u32) -> Self {
|
||||
Self {
|
||||
floor: floor_samples_per_texel.max(1),
|
||||
branch: BRANCH,
|
||||
channels: channels.max(1),
|
||||
total_frames: 0,
|
||||
floor_level: Vec::new(),
|
||||
acc: Texel::EMPTY,
|
||||
acc_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pre-reserve the floor `Vec` from an estimated total frame count (e.g. the
|
||||
/// probe's `total_frames`), to avoid reallocations during streaming. Purely a
|
||||
/// hint — the final size is set by the actual number of frames pushed.
|
||||
pub fn reserve_for_frames(&mut self, estimated_frames: u64) {
|
||||
let est_texels = (estimated_frames / self.floor as u64).saturating_add(1);
|
||||
self.floor_level.reserve(est_texels.min(usize::MAX as u64) as usize);
|
||||
}
|
||||
|
||||
/// Push a block of interleaved samples (`channels` per frame). Partial
|
||||
/// trailing frames (fewer than `channels`) are ignored.
|
||||
pub fn push_interleaved(&mut self, samples: &[f32]) {
|
||||
let ch = self.channels as usize;
|
||||
for frame in samples.chunks_exact(ch) {
|
||||
let l = frame[0];
|
||||
let r = if ch >= 2 { frame[1] } else { l };
|
||||
self.push_frame(l, r);
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn push_frame(&mut self, l: f32, r: f32) {
|
||||
self.total_frames += 1;
|
||||
self.acc.include_sample(l, r);
|
||||
self.acc_count += 1;
|
||||
if self.acc_count >= self.floor {
|
||||
self.floor_level.push(std::mem::replace(&mut self.acc, Texel::EMPTY));
|
||||
self.acc_count = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush the trailing partial bucket and reduce up to the root.
|
||||
pub fn finish(mut self) -> WaveformPyramid {
|
||||
if self.acc_count > 0 {
|
||||
self.floor_level.push(self.acc);
|
||||
}
|
||||
|
||||
// Build finest-first by repeated BRANCH:1 reduction until one texel.
|
||||
// The shape is fully determined by the floor texel count; the last group
|
||||
// at each level may be ragged (1..BRANCH children) and reduces over what
|
||||
// it has.
|
||||
let mut levels = vec![std::mem::take(&mut self.floor_level)];
|
||||
let branch = self.branch as usize;
|
||||
while levels.last().map_or(0, |l| l.len()) > 1 {
|
||||
let prev = levels.last().unwrap();
|
||||
let mut next = Vec::with_capacity(prev.len().div_ceil(branch));
|
||||
for chunk in prev.chunks(branch) {
|
||||
let mut t = Texel::EMPTY;
|
||||
for c in chunk {
|
||||
t.include_texel(c);
|
||||
}
|
||||
next.push(t);
|
||||
}
|
||||
levels.push(next);
|
||||
}
|
||||
// Output is root-first (convention B): levels[0] = root, last = floor.
|
||||
levels.reverse();
|
||||
|
||||
WaveformPyramid {
|
||||
floor_samples_per_texel: self.floor,
|
||||
branch: self.branch,
|
||||
channels: self.channels,
|
||||
total_frames: self.total_frames,
|
||||
levels,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tests live in `daw-backend/tests/waveform_pyramid.rs` (integration tests) so
|
||||
// they build the lib in normal mode, independent of the crate's pre-existing
|
||||
// broken `#[cfg(test)]` unit tests (automation.rs).
|
||||
|
|
@ -1,12 +1,10 @@
|
|||
use crate::audio::{
|
||||
AudioClipInstanceId, AutomationLaneId, ClipId, CurveType, MidiClip, MidiClipId,
|
||||
MidiClipInstanceId, ParameterId, TrackId,
|
||||
AutomationLaneId, ClipId, CurveType, MidiClip, MidiClipId, 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)]
|
||||
|
|
@ -40,8 +38,8 @@ pub enum Command {
|
|||
ExtendClip(TrackId, ClipId, f64),
|
||||
|
||||
// Metatrack management commands
|
||||
/// Create a new metatrack with a name and optional parent group
|
||||
CreateMetatrack(String, Option<TrackId>),
|
||||
/// Create a new metatrack with a name
|
||||
CreateMetatrack(String),
|
||||
/// Add a track to a metatrack (track_id, metatrack_id)
|
||||
AddToMetatrack(TrackId, TrackId),
|
||||
/// Remove a track from its parent metatrack
|
||||
|
|
@ -56,28 +54,19 @@ pub enum Command {
|
|||
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>),
|
||||
/// Create a new audio track with a name
|
||||
CreateAudioTrack(String),
|
||||
/// 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),
|
||||
/// Add a clip to an audio track (track_id, pool_index, start_time, duration, offset)
|
||||
AddAudioClip(TrackId, 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 track with a name
|
||||
CreateMidiTrack(String),
|
||||
/// 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)
|
||||
|
|
@ -87,12 +76,6 @@ pub enum Command {
|
|||
/// 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
|
||||
|
|
@ -114,7 +97,7 @@ pub enum Command {
|
|||
|
||||
// Recording commands
|
||||
/// Start recording on a track (track_id, start_time)
|
||||
StartRecording(TrackId, Beats),
|
||||
StartRecording(TrackId, f64),
|
||||
/// Stop the current recording
|
||||
StopRecording,
|
||||
/// Pause the current recording
|
||||
|
|
@ -124,7 +107,7 @@ pub enum Command {
|
|||
|
||||
// MIDI Recording commands
|
||||
/// Start MIDI recording on a track (track_id, clip_id, start_time)
|
||||
StartMidiRecording(TrackId, MidiClipId, Beats),
|
||||
StartMidiRecording(TrackId, MidiClipId, f64),
|
||||
/// Stop the current MIDI recording
|
||||
StopMidiRecording,
|
||||
|
||||
|
|
@ -143,10 +126,7 @@ pub enum Command {
|
|||
// 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),
|
||||
|
|
@ -160,79 +140,28 @@ pub enum Command {
|
|||
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)
|
||||
|
|
@ -241,29 +170,6 @@ pub enum Command {
|
|||
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
|
||||
|
|
@ -285,10 +191,10 @@ pub enum AudioEvent {
|
|||
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 started (track_id, clip_id)
|
||||
RecordingStarted(TrackId, ClipId),
|
||||
/// Recording progress update (clip_id, current_duration)
|
||||
RecordingProgress(ClipId, Seconds),
|
||||
RecordingProgress(ClipId, f64),
|
||||
/// Recording stopped (clip_id, pool_index, waveform)
|
||||
RecordingStopped(ClipId, usize, Vec<WaveformPeak>),
|
||||
/// Recording error (error_message)
|
||||
|
|
@ -296,8 +202,8 @@ pub enum AudioEvent {
|
|||
/// 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)>),
|
||||
/// Notes format: (start_time, note, velocity, duration)
|
||||
MidiRecordingProgress(TrackId, MidiClipId, f64, Vec<(f64, u8, u8, f64)>),
|
||||
/// Project has been reset
|
||||
ProjectReset,
|
||||
/// MIDI note started playing (note, velocity)
|
||||
|
|
@ -312,75 +218,10 @@ pub enum AudioEvent {
|
|||
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 fully loaded (track_id) - emitted after all nodes and samples are loaded
|
||||
GraphPresetLoaded(TrackId),
|
||||
/// 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
|
||||
|
|
@ -392,17 +233,12 @@ pub enum Query {
|
|||
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)
|
||||
|
|
@ -413,52 +249,16 @@ pub enum Query {
|
|||
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>),
|
||||
/// Create a new audio track (name) - returns track ID synchronously
|
||||
CreateAudioTrackSync(String),
|
||||
/// Create a new MIDI track (name) - returns track ID synchronously
|
||||
CreateMidiTrackSync(String),
|
||||
/// 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),
|
||||
/// 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),
|
||||
/// Add the audio track of a video file as a streaming pool entry (FFmpeg,
|
||||
/// decoded on demand — no extraction). Probes the audio track and returns
|
||||
/// the pool index. Response: `AudioImportedSync`.
|
||||
AddVideoAudioSync(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>),
|
||||
/// Install the host's packed-media byte-source factory (for streaming
|
||||
/// container-packed audio on load). Sent before `SetProject` so bulk
|
||||
/// activation can open packed sources.
|
||||
SetBlobSourceFactory(std::sync::Arc<dyn crate::audio::disk_reader::AudioBlobSourceFactory>),
|
||||
/// 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
|
||||
|
|
@ -500,8 +300,6 @@ pub enum QueryResponse {
|
|||
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)
|
||||
|
|
@ -520,22 +318,4 @@ pub enum QueryResponse {
|
|||
PoolFileInfo(Result<(f64, u32, u32), String>),
|
||||
/// Audio exported
|
||||
AudioExported(Result<(), String>),
|
||||
/// MIDI clip instance added (returns instance ID)
|
||||
MidiClipInstanceAdded(Result<MidiClipInstanceId, String>),
|
||||
/// Audio file imported to pool (returns pool index)
|
||||
AudioImportedSync(Result<usize, String>),
|
||||
/// Packed-media byte-source factory installed
|
||||
BlobSourceFactorySet(Result<(), 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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
pub mod biquad;
|
||||
pub mod svf;
|
||||
|
||||
pub use biquad::BiquadFilter;
|
||||
pub use svf::SvfFilter;
|
||||
|
|
|
|||
|
|
@ -1,135 +0,0 @@
|
|||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -13,43 +13,6 @@ pub struct WaveformPeak {
|
|||
pub max: f32,
|
||||
}
|
||||
|
||||
/// Uniquely identifies a waveform chunk
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct WaveformChunkKey {
|
||||
pub pool_index: usize,
|
||||
pub detail_level: u8, // 0-4
|
||||
pub chunk_index: u32, // Sequential chunk number
|
||||
}
|
||||
|
||||
/// A chunk of waveform data at a specific detail level
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WaveformChunk {
|
||||
pub audio_pool_index: usize,
|
||||
pub detail_level: u8, // 0-4 (overview to max detail)
|
||||
pub chunk_index: u32, // Sequential chunk number
|
||||
pub time_range: (f64, f64), // Start and end time in seconds
|
||||
pub peaks: Vec<WaveformPeak>, // Variable length based on level
|
||||
}
|
||||
|
||||
/// Whether an audio file is uncompressed (WAV/AIFF — can be memory-mapped) or compressed
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AudioFormat {
|
||||
/// Uncompressed PCM (WAV, AIFF) — suitable for memory mapping
|
||||
Pcm,
|
||||
/// Compressed (MP3, FLAC, OGG, AAC, etc.) — requires decoding
|
||||
Compressed,
|
||||
}
|
||||
|
||||
/// Audio file metadata obtained without decoding
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AudioMetadata {
|
||||
pub channels: u32,
|
||||
pub sample_rate: u32,
|
||||
pub duration: f64,
|
||||
pub n_frames: Option<u64>,
|
||||
pub format: AudioFormat,
|
||||
}
|
||||
|
||||
pub struct AudioFile {
|
||||
pub data: Vec<f32>,
|
||||
pub channels: u32,
|
||||
|
|
@ -57,179 +20,6 @@ pub struct AudioFile {
|
|||
pub frames: u64,
|
||||
}
|
||||
|
||||
/// Read only metadata from an audio file without decoding any audio packets.
|
||||
/// This is fast (sub-millisecond) and suitable for calling on the UI thread.
|
||||
pub fn read_metadata<P: AsRef<Path>>(path: P) -> Result<AudioMetadata, String> {
|
||||
let path = path.as_ref();
|
||||
|
||||
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();
|
||||
let ext = path.extension().and_then(|e| e.to_str()).map(|s| s.to_lowercase());
|
||||
if let Some(ref ext_str) = ext {
|
||||
hint.with_extension(ext_str);
|
||||
}
|
||||
|
||||
let probed = symphonia::default::get_probe()
|
||||
.format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default())
|
||||
.map_err(|e| format!("Failed to probe file: {}", e))?;
|
||||
|
||||
let format = probed.format;
|
||||
|
||||
let track = format
|
||||
.tracks()
|
||||
.iter()
|
||||
.find(|t| t.codec_params.codec != symphonia::core::codecs::CODEC_TYPE_NULL)
|
||||
.ok_or_else(|| "No audio tracks found".to_string())?;
|
||||
|
||||
let codec_params = &track.codec_params;
|
||||
let channels = codec_params.channels
|
||||
.ok_or_else(|| "Channel count not specified".to_string())?
|
||||
.count() as u32;
|
||||
let sample_rate = codec_params.sample_rate
|
||||
.ok_or_else(|| "Sample rate not specified".to_string())?;
|
||||
let n_frames = codec_params.n_frames;
|
||||
|
||||
// Determine duration from frame count or time base
|
||||
let duration = if let Some(frames) = n_frames {
|
||||
frames as f64 / sample_rate as f64
|
||||
} else if let Some(tb) = codec_params.time_base {
|
||||
if let Some(dur) = codec_params.n_frames {
|
||||
tb.calc_time(dur).seconds as f64 + tb.calc_time(dur).frac
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Determine if this is a PCM format (WAV/AIFF) or compressed
|
||||
let audio_format = match ext.as_deref() {
|
||||
Some("wav") | Some("wave") | Some("aiff") | Some("aif") => AudioFormat::Pcm,
|
||||
_ => AudioFormat::Compressed,
|
||||
};
|
||||
|
||||
Ok(AudioMetadata {
|
||||
channels,
|
||||
sample_rate,
|
||||
duration,
|
||||
n_frames,
|
||||
format: audio_format,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parsed WAV header info needed for memory-mapping.
|
||||
pub struct WavHeaderInfo {
|
||||
pub data_offset: usize,
|
||||
pub data_size: usize,
|
||||
pub sample_format: crate::audio::pool::PcmSampleFormat,
|
||||
pub channels: u32,
|
||||
pub sample_rate: u32,
|
||||
pub total_frames: u64,
|
||||
}
|
||||
|
||||
/// Parse a WAV file header from a byte slice (e.g. from an mmap).
|
||||
/// Returns the byte offset to PCM data and format details.
|
||||
pub fn parse_wav_header(data: &[u8]) -> Result<WavHeaderInfo, String> {
|
||||
if data.len() < 44 {
|
||||
return Err("File too small to be a valid WAV".to_string());
|
||||
}
|
||||
|
||||
// RIFF header
|
||||
if &data[0..4] != b"RIFF" || &data[8..12] != b"WAVE" {
|
||||
return Err("Not a valid RIFF/WAVE file".to_string());
|
||||
}
|
||||
|
||||
// Walk chunks to find "fmt " and "data"
|
||||
let mut pos = 12;
|
||||
let mut fmt_found = false;
|
||||
let mut channels: u32 = 0;
|
||||
let mut sample_rate: u32 = 0;
|
||||
let mut bits_per_sample: u16 = 0;
|
||||
let mut format_code: u16 = 0;
|
||||
|
||||
let mut data_offset: usize = 0;
|
||||
let mut data_size: usize = 0;
|
||||
|
||||
while pos + 8 <= data.len() {
|
||||
let chunk_id = &data[pos..pos + 4];
|
||||
let chunk_size = u32::from_le_bytes([
|
||||
data[pos + 4],
|
||||
data[pos + 5],
|
||||
data[pos + 6],
|
||||
data[pos + 7],
|
||||
]) as usize;
|
||||
|
||||
if chunk_id == b"fmt " {
|
||||
if pos + 8 + 16 > data.len() {
|
||||
return Err("fmt chunk too small".to_string());
|
||||
}
|
||||
let base = pos + 8;
|
||||
format_code = u16::from_le_bytes([data[base], data[base + 1]]);
|
||||
channels = u16::from_le_bytes([data[base + 2], data[base + 3]]) as u32;
|
||||
sample_rate = u32::from_le_bytes([
|
||||
data[base + 4],
|
||||
data[base + 5],
|
||||
data[base + 6],
|
||||
data[base + 7],
|
||||
]);
|
||||
bits_per_sample = u16::from_le_bytes([data[base + 14], data[base + 15]]);
|
||||
fmt_found = true;
|
||||
} else if chunk_id == b"data" {
|
||||
data_offset = pos + 8;
|
||||
data_size = chunk_size;
|
||||
break;
|
||||
}
|
||||
|
||||
// Advance to next chunk (chunks are 2-byte aligned)
|
||||
pos += 8 + chunk_size;
|
||||
if chunk_size % 2 != 0 {
|
||||
pos += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if !fmt_found {
|
||||
return Err("No fmt chunk found".to_string());
|
||||
}
|
||||
if data_offset == 0 {
|
||||
return Err("No data chunk found".to_string());
|
||||
}
|
||||
|
||||
// Determine sample format
|
||||
let sample_format = match (format_code, bits_per_sample) {
|
||||
(1, 16) => crate::audio::pool::PcmSampleFormat::I16,
|
||||
(1, 24) => crate::audio::pool::PcmSampleFormat::I24,
|
||||
(3, 32) => crate::audio::pool::PcmSampleFormat::F32,
|
||||
(1, 32) => crate::audio::pool::PcmSampleFormat::F32, // 32-bit PCM treated as float
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"Unsupported WAV format: code={}, bits={}",
|
||||
format_code, bits_per_sample
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let bytes_per_sample = (bits_per_sample / 8) as usize;
|
||||
let bytes_per_frame = bytes_per_sample * channels as usize;
|
||||
let total_frames = if bytes_per_frame > 0 {
|
||||
(data_size / bytes_per_frame) as u64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
Ok(WavHeaderInfo {
|
||||
data_offset,
|
||||
data_size,
|
||||
sample_format,
|
||||
channels,
|
||||
sample_rate,
|
||||
total_frames,
|
||||
})
|
||||
}
|
||||
|
||||
impl AudioFile {
|
||||
/// Load an audio file from disk and decode it to interleaved f32 samples
|
||||
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, String> {
|
||||
|
|
@ -338,123 +128,6 @@ impl AudioFile {
|
|||
})
|
||||
}
|
||||
|
||||
/// Decode a compressed audio file progressively, calling `on_progress` with
|
||||
/// partial data snapshots so the UI can display waveforms as they decode.
|
||||
/// Sends updates roughly every 2 seconds of decoded audio.
|
||||
pub fn decode_progressive<P: AsRef<Path>, F>(path: P, total_frames: u64, on_progress: F)
|
||||
where
|
||||
F: Fn(&[f32], u64, u64),
|
||||
{
|
||||
let path = path.as_ref();
|
||||
|
||||
let file = match std::fs::File::open(path) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
eprintln!("[WAVEFORM DECODE] Failed to open {:?}: {}", path, e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
||||
|
||||
let mut hint = Hint::new();
|
||||
if let Some(extension) = path.extension() {
|
||||
if let Some(ext_str) = extension.to_str() {
|
||||
hint.with_extension(ext_str);
|
||||
}
|
||||
}
|
||||
|
||||
let probed = match symphonia::default::get_probe()
|
||||
.format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default())
|
||||
{
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
eprintln!("[WAVEFORM DECODE] Failed to probe {:?}: {}", path, e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut format = probed.format;
|
||||
|
||||
let track = match format.tracks().iter()
|
||||
.find(|t| t.codec_params.codec != symphonia::core::codecs::CODEC_TYPE_NULL)
|
||||
{
|
||||
Some(t) => t,
|
||||
None => {
|
||||
eprintln!("[WAVEFORM DECODE] No audio tracks in {:?}", path);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let track_id = track.id;
|
||||
let channels = track.codec_params.channels
|
||||
.map(|c| c.count() as u32)
|
||||
.unwrap_or(2);
|
||||
let sample_rate = track.codec_params.sample_rate.unwrap_or(44100);
|
||||
|
||||
let mut decoder = match symphonia::default::get_codecs()
|
||||
.make(&track.codec_params, &DecoderOptions::default())
|
||||
{
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
eprintln!("[WAVEFORM DECODE] Failed to create decoder for {:?}: {}", path, e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut audio_data = Vec::new();
|
||||
let mut sample_buf = None;
|
||||
// Send a progress update roughly every 2 seconds of audio
|
||||
// Send first update quickly (0.25s), then every 2s of audio
|
||||
let initial_interval = (sample_rate as usize * channels as usize) / 4;
|
||||
let steady_interval = (sample_rate as usize * channels as usize) * 2;
|
||||
let mut sent_first = false;
|
||||
let mut last_update_len = 0usize;
|
||||
|
||||
loop {
|
||||
let packet = match format.next_packet() {
|
||||
Ok(packet) => packet,
|
||||
Err(Error::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
|
||||
Err(Error::ResetRequired) => break,
|
||||
Err(_) => break,
|
||||
};
|
||||
|
||||
if packet.track_id() != track_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
match decoder.decode(&packet) {
|
||||
Ok(decoded) => {
|
||||
if sample_buf.is_none() {
|
||||
let spec = *decoded.spec();
|
||||
let duration = decoded.capacity() as u64;
|
||||
sample_buf = Some(SampleBuffer::<f32>::new(duration, spec));
|
||||
}
|
||||
if let Some(ref mut buf) = sample_buf {
|
||||
buf.copy_interleaved_ref(decoded);
|
||||
audio_data.extend_from_slice(buf.samples());
|
||||
}
|
||||
|
||||
// Send progressive update (fast initial, then periodic)
|
||||
// Only send NEW samples since last update (delta) to avoid large copies
|
||||
let interval = if sent_first { steady_interval } else { initial_interval };
|
||||
if audio_data.len() - last_update_len >= interval {
|
||||
let decoded_frames = audio_data.len() as u64 / channels as u64;
|
||||
on_progress(&audio_data[last_update_len..], decoded_frames, total_frames);
|
||||
last_update_len = audio_data.len();
|
||||
sent_first = true;
|
||||
}
|
||||
}
|
||||
Err(Error::DecodeError(_)) => continue,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
// Final update with remaining data (delta since last update)
|
||||
let decoded_frames = audio_data.len() as u64 / channels as u64;
|
||||
on_progress(&audio_data[last_update_len..], decoded_frames, decoded_frames.max(total_frames));
|
||||
}
|
||||
|
||||
/// Calculate the duration of the audio file in seconds
|
||||
pub fn duration(&self) -> f64 {
|
||||
self.frames as f64 / self.sample_rate as f64
|
||||
|
|
@ -463,48 +136,25 @@ impl AudioFile {
|
|||
/// Generate a waveform overview with the specified number of peaks
|
||||
/// This creates a downsampled representation suitable for timeline visualization
|
||||
pub fn generate_waveform_overview(&self, target_peaks: usize) -> Vec<WaveformPeak> {
|
||||
self.generate_waveform_overview_range(0, self.frames as usize, target_peaks)
|
||||
}
|
||||
|
||||
/// Generate a waveform overview for a specific range of frames
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `start_frame` - Starting frame index (0-based)
|
||||
/// * `end_frame` - Ending frame index (exclusive)
|
||||
/// * `target_peaks` - Desired number of peaks to generate
|
||||
pub fn generate_waveform_overview_range(
|
||||
&self,
|
||||
start_frame: usize,
|
||||
end_frame: usize,
|
||||
target_peaks: usize,
|
||||
) -> Vec<WaveformPeak> {
|
||||
if self.frames == 0 || target_peaks == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let total_frames = self.frames as usize;
|
||||
let start_frame = start_frame.min(total_frames);
|
||||
let end_frame = end_frame.min(total_frames);
|
||||
|
||||
if start_frame >= end_frame {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let range_frames = end_frame - start_frame;
|
||||
let frames_per_peak = (range_frames / target_peaks).max(1);
|
||||
let actual_peaks = (range_frames + frames_per_peak - 1) / frames_per_peak;
|
||||
let frames_per_peak = (total_frames / target_peaks).max(1);
|
||||
let actual_peaks = (total_frames + frames_per_peak - 1) / frames_per_peak;
|
||||
|
||||
let mut peaks = Vec::with_capacity(actual_peaks);
|
||||
|
||||
for peak_idx in 0..actual_peaks {
|
||||
let peak_start = start_frame + peak_idx * frames_per_peak;
|
||||
let peak_end = (start_frame + (peak_idx + 1) * frames_per_peak).min(end_frame);
|
||||
let start_frame = peak_idx * frames_per_peak;
|
||||
let end_frame = ((peak_idx + 1) * frames_per_peak).min(total_frames);
|
||||
|
||||
let mut min = 0.0f32;
|
||||
let mut max = 0.0f32;
|
||||
|
||||
// Scan all samples in this window
|
||||
for frame_idx in peak_start..peak_end {
|
||||
for frame_idx in start_frame..end_frame {
|
||||
// For multi-channel audio, combine all channels
|
||||
for ch in 0..self.channels as usize {
|
||||
let sample_idx = frame_idx * self.channels as usize + ch;
|
||||
|
|
|
|||
|
|
@ -1,69 +1,164 @@
|
|||
use crate::audio::midi::{MidiClip, MidiClipId, MidiEvent};
|
||||
use crate::time::Beats;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// Load a MIDI file and convert it to a MidiClip.
|
||||
///
|
||||
/// Event timestamps are stored as beat positions: `tick / ticks_per_beat`.
|
||||
/// Tempo events in the MIDI file only affect wall-clock playback speed — not the
|
||||
/// beat grid — so they are ignored here.
|
||||
/// Load a MIDI file and convert it to a MidiClip
|
||||
pub fn load_midi_file<P: AsRef<Path>>(
|
||||
path: P,
|
||||
clip_id: MidiClipId,
|
||||
_sample_rate: u32,
|
||||
) -> Result<MidiClip, String> {
|
||||
// Read the MIDI file
|
||||
let data = fs::read(path.as_ref()).map_err(|e| format!("Failed to read MIDI file: {}", e))?;
|
||||
|
||||
// Parse with midly
|
||||
let smf = midly::Smf::parse(&data).map_err(|e| format!("Failed to parse MIDI file: {}", e))?;
|
||||
|
||||
// Convert timing to ticks per second
|
||||
let ticks_per_beat = match smf.header.timing {
|
||||
midly::Timing::Metrical(tpb) => tpb.as_int() as f64,
|
||||
midly::Timing::Timecode(fps, subframe) => {
|
||||
// Timecode-based MIDI: treat subframes as ticks per beat
|
||||
// For timecode, calculate equivalent ticks per second
|
||||
(fps.as_f32() * subframe as f32) as f64
|
||||
}
|
||||
};
|
||||
|
||||
let mut events = Vec::new();
|
||||
let mut max_tick = 0u64;
|
||||
// First pass: collect all events with their tick positions and tempo changes
|
||||
#[derive(Debug)]
|
||||
enum RawEvent {
|
||||
Midi {
|
||||
tick: u64,
|
||||
channel: u8,
|
||||
message: midly::MidiMessage,
|
||||
},
|
||||
Tempo {
|
||||
tick: u64,
|
||||
microseconds_per_beat: f64,
|
||||
},
|
||||
}
|
||||
|
||||
let mut raw_events = Vec::new();
|
||||
let mut max_time_ticks = 0u64;
|
||||
|
||||
// Collect all events from all tracks with their absolute tick positions
|
||||
for track in &smf.tracks {
|
||||
let mut current_tick = 0u64;
|
||||
|
||||
for event in track {
|
||||
current_tick += event.delta.as_int() as u64;
|
||||
max_tick = max_tick.max(current_tick);
|
||||
|
||||
let timestamp = Beats(current_tick as f64 / ticks_per_beat);
|
||||
max_time_ticks = max_time_ticks.max(current_tick);
|
||||
|
||||
match event.kind {
|
||||
midly::TrackEventKind::Midi { channel, message } => {
|
||||
let ch = channel.as_int();
|
||||
raw_events.push(RawEvent::Midi {
|
||||
tick: current_tick,
|
||||
channel: channel.as_int(),
|
||||
message,
|
||||
});
|
||||
}
|
||||
midly::TrackEventKind::Meta(midly::MetaMessage::Tempo(tempo)) => {
|
||||
raw_events.push(RawEvent::Tempo {
|
||||
tick: current_tick,
|
||||
microseconds_per_beat: tempo.as_int() as f64,
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
// Ignore other meta events
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort all events by tick position
|
||||
raw_events.sort_by_key(|e| match e {
|
||||
RawEvent::Midi { tick, .. } => *tick,
|
||||
RawEvent::Tempo { tick, .. } => *tick,
|
||||
});
|
||||
|
||||
// Second pass: convert ticks to timestamps with proper tempo tracking
|
||||
let mut events = Vec::new();
|
||||
let mut microseconds_per_beat = 500000.0; // Default: 120 BPM
|
||||
let mut last_tick = 0u64;
|
||||
let mut accumulated_time = 0.0; // Time in seconds
|
||||
|
||||
for raw_event in raw_events {
|
||||
match raw_event {
|
||||
RawEvent::Tempo {
|
||||
tick,
|
||||
microseconds_per_beat: new_tempo,
|
||||
} => {
|
||||
// Update accumulated time up to this tempo change
|
||||
let delta_ticks = tick - last_tick;
|
||||
let delta_time = (delta_ticks as f64 / ticks_per_beat)
|
||||
* (microseconds_per_beat / 1_000_000.0);
|
||||
accumulated_time += delta_time;
|
||||
last_tick = tick;
|
||||
|
||||
// Update tempo for future events
|
||||
microseconds_per_beat = new_tempo;
|
||||
}
|
||||
RawEvent::Midi {
|
||||
tick,
|
||||
channel,
|
||||
message,
|
||||
} => {
|
||||
// Calculate time for this event
|
||||
let delta_ticks = tick - last_tick;
|
||||
let delta_time = (delta_ticks as f64 / ticks_per_beat)
|
||||
* (microseconds_per_beat / 1_000_000.0);
|
||||
accumulated_time += delta_time;
|
||||
last_tick = tick;
|
||||
|
||||
// Store timestamp in seconds (sample-rate independent)
|
||||
let timestamp = accumulated_time;
|
||||
|
||||
match message {
|
||||
midly::MidiMessage::NoteOn { key, vel } => {
|
||||
let velocity = vel.as_int();
|
||||
if velocity > 0 {
|
||||
events.push(MidiEvent::note_on(timestamp, ch, key.as_int(), velocity));
|
||||
events.push(MidiEvent::note_on(
|
||||
timestamp,
|
||||
channel,
|
||||
key.as_int(),
|
||||
velocity,
|
||||
));
|
||||
} else {
|
||||
events.push(MidiEvent::note_off(timestamp, ch, key.as_int(), 64));
|
||||
events.push(MidiEvent::note_off(timestamp, channel, key.as_int(), 64));
|
||||
}
|
||||
}
|
||||
midly::MidiMessage::NoteOff { key, vel } => {
|
||||
events.push(MidiEvent::note_off(timestamp, ch, key.as_int(), vel.as_int()));
|
||||
events.push(MidiEvent::note_off(
|
||||
timestamp,
|
||||
channel,
|
||||
key.as_int(),
|
||||
vel.as_int(),
|
||||
));
|
||||
}
|
||||
midly::MidiMessage::Controller { controller, value } => {
|
||||
let status = 0xB0 | ch;
|
||||
events.push(MidiEvent::new(timestamp, status, controller.as_int(), value.as_int()));
|
||||
let status = 0xB0 | channel;
|
||||
events.push(MidiEvent::new(
|
||||
timestamp,
|
||||
status,
|
||||
controller.as_int(),
|
||||
value.as_int(),
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
_ => {
|
||||
// Ignore other MIDI messages
|
||||
}
|
||||
}
|
||||
_ => {} // Tempo and other meta events don't affect beat positions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let duration = Beats(max_tick as f64 / ticks_per_beat);
|
||||
let clip = MidiClip::new(clip_id, events, duration, "Imported MIDI".to_string());
|
||||
// Calculate final clip duration
|
||||
let final_delta_ticks = max_time_ticks - last_tick;
|
||||
let final_delta_time =
|
||||
(final_delta_ticks as f64 / ticks_per_beat) * (microseconds_per_beat / 1_000_000.0);
|
||||
let duration_seconds = accumulated_time + final_delta_time;
|
||||
|
||||
// Create the MIDI clip (content only, positioning happens when creating instance)
|
||||
let clip = MidiClip::new(clip_id, events, duration_seconds, "Imported MIDI".to_string());
|
||||
|
||||
Ok(clip)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ pub mod midi_file;
|
|||
pub mod midi_input;
|
||||
pub mod wav_writer;
|
||||
|
||||
pub use audio_file::{AudioFile, AudioFormat, AudioMetadata, WavHeaderInfo, WaveformChunk, WaveformChunkKey, WaveformPeak, parse_wav_header, read_metadata};
|
||||
pub use audio_file::{AudioFile, WaveformPeak};
|
||||
pub use midi_file::load_midi_file;
|
||||
pub use midi_input::MidiInputManager;
|
||||
pub use wav_writer::WavWriter;
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@
|
|||
|
||||
pub mod audio;
|
||||
pub mod command;
|
||||
pub mod time;
|
||||
pub mod tempo_map;
|
||||
pub mod dsp;
|
||||
pub mod effects;
|
||||
pub mod io;
|
||||
|
|
@ -15,16 +13,14 @@ pub mod tui;
|
|||
|
||||
// Re-export commonly used types
|
||||
pub use audio::{
|
||||
AudioClipInstanceId, AudioClipSnapshot, AudioPool, AudioTrack, AutomationLane, AutomationLaneId, AutomationPoint, BufferPool, Clip, ClipId, CurveType, Engine, EngineController,
|
||||
Metatrack, MidiClip, MidiClipId, MidiClipInstance, MidiClipInstanceId, MidiEvent, MidiTrack, ParameterId, PoolAudioFile, Project, RecordingState, RenderContext, Track, TrackId,
|
||||
AudioPool, AudioTrack, AutomationLane, AutomationLaneId, AutomationPoint, BufferPool, Clip, ClipId, CurveType, Engine, EngineController,
|
||||
Metatrack, MidiClip, MidiClipId, MidiEvent, MidiTrack, ParameterId, PoolAudioFile, Project, RecordingState, RenderContext, Track, TrackId,
|
||||
TrackNode,
|
||||
};
|
||||
pub use audio::node_graph::{GraphPreset, AudioGraph, PresetMetadata, SerializedConnection, SerializedNode};
|
||||
pub use time::{Beats, Seconds};
|
||||
pub use tempo_map::{TempoEntry, TempoInterpolation, TempoMap, beats_to_seconds_stack, seconds_to_beats_stack};
|
||||
pub use command::{AudioEvent, Command, OscilloscopeData};
|
||||
pub use command::types::AutomationKeyframeData;
|
||||
pub use io::{load_midi_file, AudioFile, WaveformChunk, WaveformChunkKey, WaveformPeak, WavWriter};
|
||||
pub use io::{load_midi_file, AudioFile, WaveformPeak, WavWriter};
|
||||
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
|
||||
|
|
@ -41,15 +37,6 @@ pub struct AudioSystem {
|
|||
pub stream: cpal::Stream,
|
||||
pub sample_rate: u32,
|
||||
pub channels: u32,
|
||||
/// Event receiver for polling audio events (only present when no EventEmitter is provided)
|
||||
pub event_rx: Option<rtrb::Consumer<AudioEvent>>,
|
||||
/// Consumer for recording audio mirror (streams recorded samples to UI for live waveform)
|
||||
recording_mirror_rx: Option<rtrb::Consumer<f32>>,
|
||||
/// Producer end of the input ring-buffer. Taken into the closure when the
|
||||
/// input stream is opened; `None` after `open_input_stream()` has been called.
|
||||
input_tx: Option<rtrb::Producer<f32>>,
|
||||
/// The live microphone/line-in stream. `None` until `open_input_stream()` is called.
|
||||
input_stream: Option<cpal::Stream>,
|
||||
}
|
||||
|
||||
impl AudioSystem {
|
||||
|
|
@ -59,13 +46,6 @@ impl AudioSystem {
|
|||
/// * `event_emitter` - Optional event emitter for pushing events to external systems
|
||||
/// * `buffer_size` - Audio buffer size in frames (128, 256, 512, 1024, etc.)
|
||||
/// Smaller = lower latency but higher CPU usage. Default: 256
|
||||
///
|
||||
/// # Environment Variables
|
||||
/// * `DAW_AUDIO_DEBUG=1` - Enable audio callback timing diagnostics. Logs:
|
||||
/// - Device and config info at startup
|
||||
/// - First 10 callback buffer sizes (to detect ALSA buffer variance)
|
||||
/// - Per-overrun timing breakdown (command vs render time)
|
||||
/// - Periodic (~5s) timing summaries (avg/worst/overrun rate)
|
||||
pub fn new(
|
||||
event_emitter: Option<std::sync::Arc<dyn EventEmitter>>,
|
||||
buffer_size: u32,
|
||||
|
|
@ -78,12 +58,8 @@ impl AudioSystem {
|
|||
.ok_or("No output device available")?;
|
||||
|
||||
let default_output_config = output_device.default_output_config().map_err(|e| e.to_string())?;
|
||||
let sample_rate = default_output_config.sample_rate();
|
||||
let sample_rate = default_output_config.sample_rate().0;
|
||||
let channels = default_output_config.channels() as u32;
|
||||
let _debug_audio = std::env::var("DAW_AUDIO_DEBUG").map_or(false, |v| v == "1");
|
||||
|
||||
eprintln!("[AUDIO] Device: {:?}, format={:?}, rate={}, channels={}",
|
||||
output_device.description().map(|d| d.name().to_string()).unwrap_or_default(), default_output_config.sample_format(), sample_rate, channels);
|
||||
|
||||
// Create queues
|
||||
let (command_tx, command_rx) = rtrb::RingBuffer::new(512); // Larger buffer for MIDI + UI commands
|
||||
|
|
@ -94,15 +70,11 @@ impl AudioSystem {
|
|||
// Create input ringbuffer for recording (large buffer for audio samples)
|
||||
// Buffer size: 10 seconds of audio at 48kHz stereo = 48000 * 2 * 10 = 960000 samples
|
||||
let input_buffer_size = (sample_rate * channels * 10) as usize;
|
||||
let (input_tx, input_rx) = rtrb::RingBuffer::new(input_buffer_size);
|
||||
|
||||
// Create mirror ringbuffer for streaming recorded audio to UI (live waveform)
|
||||
let (mirror_tx, mirror_rx) = rtrb::RingBuffer::new(input_buffer_size);
|
||||
let (mut input_tx, input_rx) = rtrb::RingBuffer::new(input_buffer_size);
|
||||
|
||||
// Create engine
|
||||
let mut engine = Engine::new(sample_rate, channels, command_rx, event_tx, query_rx, query_response_tx);
|
||||
engine.set_input_rx(input_rx);
|
||||
engine.set_recording_mirror_tx(mirror_tx);
|
||||
let controller = engine.get_controller(command_tx, query_tx, query_response_rx);
|
||||
|
||||
// Initialize MIDI input manager for external MIDI devices
|
||||
|
|
@ -120,23 +92,38 @@ impl AudioSystem {
|
|||
}
|
||||
}
|
||||
|
||||
// Build output stream
|
||||
let mut output_config: cpal::StreamConfig = default_output_config.into();
|
||||
// Build output stream with configurable buffer size
|
||||
let mut output_config: cpal::StreamConfig = default_output_config.clone().into();
|
||||
|
||||
// WASAPI shared mode on Windows does not support fixed buffer sizes.
|
||||
// Use the device default on Windows; honor the requested size on other platforms.
|
||||
if cfg!(target_os = "windows") {
|
||||
output_config.buffer_size = cpal::BufferSize::Default;
|
||||
} else {
|
||||
// Set the requested buffer size
|
||||
output_config.buffer_size = cpal::BufferSize::Fixed(buffer_size);
|
||||
}
|
||||
|
||||
let mut output_buffer = vec![0.0f32; 16384];
|
||||
|
||||
// Log audio configuration
|
||||
println!("Audio Output Configuration:");
|
||||
println!(" Sample Rate: {} Hz", output_config.sample_rate.0);
|
||||
println!(" Channels: {}", output_config.channels);
|
||||
println!(" Buffer Size: {:?}", output_config.buffer_size);
|
||||
|
||||
// Calculate expected latency
|
||||
if let cpal::BufferSize::Fixed(size) = output_config.buffer_size {
|
||||
let latency_ms = (size as f64 / output_config.sample_rate.0 as f64) * 1000.0;
|
||||
println!(" Expected Latency: {:.2} ms", latency_ms);
|
||||
}
|
||||
|
||||
let mut first_callback = true;
|
||||
let output_stream = output_device
|
||||
.build_output_stream(
|
||||
&output_config,
|
||||
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
|
||||
if first_callback {
|
||||
let frames = data.len() / output_config.channels as usize;
|
||||
let latency_ms = (frames as f64 / output_config.sample_rate.0 as f64) * 1000.0;
|
||||
println!("Audio callback buffer size: {} samples ({} frames, {:.2} ms latency)",
|
||||
data.len(), frames, latency_ms);
|
||||
first_callback = false;
|
||||
}
|
||||
let buf = &mut output_buffer[..data.len()];
|
||||
buf.fill(0.0);
|
||||
engine.process(buf);
|
||||
|
|
@ -145,128 +132,89 @@ impl AudioSystem {
|
|||
|err| eprintln!("Output stream error: {}", err),
|
||||
None,
|
||||
)
|
||||
.map_err(|e| format!("Failed to build output stream: {e:?}"))?;
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Start output stream
|
||||
// Get input device
|
||||
let input_device = match host.default_input_device() {
|
||||
Some(device) => device,
|
||||
None => {
|
||||
eprintln!("Warning: No input device available, recording will be disabled");
|
||||
// Start output stream and return without input
|
||||
output_stream.play().map_err(|e| e.to_string())?;
|
||||
|
||||
// Spawn emitter thread if provided, or store event_rx for manual polling
|
||||
let event_rx_option = if let Some(emitter) = event_emitter {
|
||||
// Spawn emitter thread if provided
|
||||
if let Some(emitter) = event_emitter {
|
||||
Self::spawn_emitter_thread(event_rx, emitter);
|
||||
None
|
||||
} else {
|
||||
Some(event_rx)
|
||||
}
|
||||
|
||||
return Ok(Self {
|
||||
controller,
|
||||
stream: output_stream,
|
||||
sample_rate,
|
||||
channels,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Input stream is NOT opened here — call open_input_stream() when an
|
||||
// audio input track is actually selected, to avoid constant ALSA wakeups.
|
||||
// Get input config matching output sample rate and channels if possible
|
||||
let input_config = match input_device.default_input_config() {
|
||||
Ok(config) => {
|
||||
let mut cfg: cpal::StreamConfig = config.into();
|
||||
// Try to match output sample rate and channels
|
||||
cfg.sample_rate = cpal::SampleRate(sample_rate);
|
||||
cfg.channels = channels as u16;
|
||||
cfg
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Warning: Could not get input config: {}, recording will be disabled", e);
|
||||
output_stream.play().map_err(|e| e.to_string())?;
|
||||
|
||||
// Spawn emitter thread if provided
|
||||
if let Some(emitter) = event_emitter {
|
||||
Self::spawn_emitter_thread(event_rx, emitter);
|
||||
}
|
||||
|
||||
return Ok(Self {
|
||||
controller,
|
||||
stream: output_stream,
|
||||
sample_rate,
|
||||
channels,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Build input stream that feeds into the ringbuffer
|
||||
let input_stream = input_device
|
||||
.build_input_stream(
|
||||
&input_config,
|
||||
move |data: &[f32], _: &cpal::InputCallbackInfo| {
|
||||
// Push input samples to ringbuffer for recording
|
||||
for &sample in data {
|
||||
let _ = input_tx.push(sample);
|
||||
}
|
||||
},
|
||||
|err| eprintln!("Input stream error: {}", err),
|
||||
None,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Start both streams
|
||||
output_stream.play().map_err(|e| e.to_string())?;
|
||||
input_stream.play().map_err(|e| e.to_string())?;
|
||||
|
||||
// Leak the input stream to keep it alive
|
||||
Box::leak(Box::new(input_stream));
|
||||
|
||||
// Spawn emitter thread if provided
|
||||
if let Some(emitter) = event_emitter {
|
||||
Self::spawn_emitter_thread(event_rx, emitter);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
controller,
|
||||
stream: output_stream,
|
||||
sample_rate,
|
||||
channels,
|
||||
event_rx: event_rx_option,
|
||||
recording_mirror_rx: Some(mirror_rx),
|
||||
input_tx: Some(input_tx),
|
||||
input_stream: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Take the recording mirror consumer for streaming recorded audio to UI
|
||||
pub fn take_recording_mirror_rx(&mut self) -> Option<rtrb::Consumer<f32>> {
|
||||
self.recording_mirror_rx.take()
|
||||
}
|
||||
|
||||
/// Open the microphone/line-in input stream.
|
||||
///
|
||||
/// Call this as soon as an audio input track is selected so the stream is
|
||||
/// ready before recording starts. The stream is opened with the same fixed
|
||||
/// buffer size as the output stream to avoid ALSA spinning at high callback
|
||||
/// rates with its tiny default buffer.
|
||||
///
|
||||
/// No-ops if the stream is already open.
|
||||
pub fn open_input_stream(&mut self, buffer_size: u32) -> Result<(), String> {
|
||||
if self.input_stream.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut input_tx = match self.input_tx.take() {
|
||||
Some(tx) => tx,
|
||||
None => return Err("Input ring-buffer already consumed".into()),
|
||||
};
|
||||
|
||||
let host = cpal::default_host();
|
||||
let input_device = host.default_input_device()
|
||||
.ok_or("No input device available")?;
|
||||
|
||||
let default_cfg = input_device.default_input_config()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut input_config: cpal::StreamConfig = default_cfg.into();
|
||||
// Match the output buffer size so ALSA wakes up at the same rate as
|
||||
// the output thread — prevents the ~750 wakeups/sec that the default
|
||||
// 64-frame buffer causes.
|
||||
if !cfg!(target_os = "windows") {
|
||||
input_config.buffer_size = cpal::BufferSize::Fixed(buffer_size);
|
||||
}
|
||||
|
||||
let input_sample_rate = input_config.sample_rate;
|
||||
let input_channels = input_config.channels as u32;
|
||||
let output_sample_rate = self.sample_rate;
|
||||
let output_channels = self.channels;
|
||||
let needs_resample = input_sample_rate != output_sample_rate
|
||||
|| input_channels != output_channels;
|
||||
|
||||
if needs_resample {
|
||||
eprintln!("[AUDIO] Input: {}Hz {}ch → resampling to {}Hz {}ch",
|
||||
input_sample_rate, input_channels, output_sample_rate, output_channels);
|
||||
}
|
||||
|
||||
let stream = input_device.build_input_stream(
|
||||
&input_config,
|
||||
move |data: &[f32], _: &cpal::InputCallbackInfo| {
|
||||
if !needs_resample {
|
||||
for &s in data { let _ = input_tx.push(s); }
|
||||
} else {
|
||||
let in_ch = input_channels as usize;
|
||||
let out_ch = output_channels as usize;
|
||||
let ratio = output_sample_rate as f64 / input_sample_rate as f64;
|
||||
let in_frames = data.len() / in_ch;
|
||||
let out_frames = (in_frames as f64 * ratio) as usize;
|
||||
for i in 0..out_frames {
|
||||
let src_pos = i as f64 / ratio;
|
||||
let src_idx = src_pos as usize;
|
||||
let frac = (src_pos - src_idx as f64) as f32;
|
||||
for ch in 0..out_ch {
|
||||
let ic = ch.min(in_ch - 1);
|
||||
let s0 = data.get(src_idx * in_ch + ic).copied().unwrap_or(0.0);
|
||||
let s1 = data.get((src_idx + 1) * in_ch + ic).copied().unwrap_or(s0);
|
||||
let _ = input_tx.push(s0 + frac * (s1 - s0));
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|err| eprintln!("Input stream error: {err}"),
|
||||
None,
|
||||
).map_err(|e| format!("Failed to build input stream: {e}"))?;
|
||||
|
||||
stream.play().map_err(|e| e.to_string())?;
|
||||
self.input_stream = Some(stream);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Close the input stream (e.g. when the last audio input track is removed).
|
||||
pub fn close_input_stream(&mut self) {
|
||||
self.input_stream = None; // Drop stops the stream
|
||||
}
|
||||
|
||||
/// Extract an [`InputStreamOpener`] that can be stored independently and
|
||||
/// used to open the microphone/line-in stream on demand.
|
||||
/// Returns `None` if called a second time.
|
||||
pub fn take_input_opener(&mut self) -> Option<InputStreamOpener> {
|
||||
self.input_tx.take().map(|tx| InputStreamOpener {
|
||||
input_tx: tx,
|
||||
sample_rate: self.sample_rate,
|
||||
channels: self.channels,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -285,77 +233,3 @@ impl AudioSystem {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Self-contained handle for opening the microphone/line-in stream on demand.
|
||||
///
|
||||
/// Obtained via [`AudioSystem::take_input_opener`]. Call [`open`](Self::open)
|
||||
/// when the user selects an audio input track; store the returned
|
||||
/// `cpal::Stream` to keep it alive (dropping it stops the stream).
|
||||
pub struct InputStreamOpener {
|
||||
input_tx: rtrb::Producer<f32>,
|
||||
sample_rate: u32,
|
||||
channels: u32,
|
||||
}
|
||||
|
||||
impl InputStreamOpener {
|
||||
/// Open and start the input stream with the given buffer size.
|
||||
///
|
||||
/// Uses the same `buffer_size` as the output stream so ALSA wakes up at
|
||||
/// the same rate (~187/s at 256 frames) rather than the ~750/s it defaults
|
||||
/// to with 64-frame buffers.
|
||||
pub fn open(mut self, buffer_size: u32) -> Result<cpal::Stream, String> {
|
||||
let host = cpal::default_host();
|
||||
let device = host.default_input_device()
|
||||
.ok_or("No input device available")?;
|
||||
|
||||
let default_cfg = device.default_input_config()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut cfg: cpal::StreamConfig = default_cfg.into();
|
||||
if !cfg!(target_os = "windows") {
|
||||
cfg.buffer_size = cpal::BufferSize::Fixed(buffer_size);
|
||||
}
|
||||
|
||||
let in_rate = cfg.sample_rate;
|
||||
let in_ch = cfg.channels as u32;
|
||||
let out_rate = self.sample_rate;
|
||||
let out_ch = self.channels;
|
||||
let needs_resample = in_rate != out_rate || in_ch != out_ch;
|
||||
|
||||
if needs_resample {
|
||||
eprintln!("[AUDIO] Input: {}Hz {}ch → resampling to {}Hz {}ch",
|
||||
in_rate, in_ch, out_rate, out_ch);
|
||||
}
|
||||
|
||||
let stream = device.build_input_stream(
|
||||
&cfg,
|
||||
move |data: &[f32], _: &cpal::InputCallbackInfo| {
|
||||
if !needs_resample {
|
||||
for &s in data { let _ = self.input_tx.push(s); }
|
||||
} else {
|
||||
let ic = in_ch as usize;
|
||||
let oc = out_ch as usize;
|
||||
let ratio = out_rate as f64 / in_rate as f64;
|
||||
let in_frames = data.len() / ic;
|
||||
let out_frames = (in_frames as f64 * ratio) as usize;
|
||||
for i in 0..out_frames {
|
||||
let src = i as f64 / ratio;
|
||||
let si = src as usize;
|
||||
let f = (src - si as f64) as f32;
|
||||
for ch in 0..oc {
|
||||
let ich = ch.min(ic - 1);
|
||||
let s0 = data.get(si * ic + ich).copied().unwrap_or(0.0);
|
||||
let s1 = data.get((si + 1) * ic + ich).copied().unwrap_or(s0);
|
||||
let _ = self.input_tx.push(s0 + f * (s1 - s0));
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|err| eprintln!("Input stream error: {err}"),
|
||||
None,
|
||||
).map_err(|e| format!("Failed to build input stream: {e}"))?;
|
||||
|
||||
stream.play().map_err(|e| e.to_string())?;
|
||||
Ok(stream)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,332 +0,0 @@
|
|||
//! TempoMap — beats ↔ seconds conversion with variable tempo support.
|
||||
//!
|
||||
//! Positions are stored in **beats** throughout the project; `TempoMap` converts
|
||||
//! between beats and seconds at render / scheduling time.
|
||||
//!
|
||||
//! # Interpolation
|
||||
//! Each `TempoEntry` has an `interpolation` field that controls how the BPM
|
||||
//! changes between that entry and the next:
|
||||
//! - `Step`: BPM is constant from this entry's beat until the next entry. Instant change.
|
||||
//! - `Linear`: BPM linearly interpolates from this entry's BPM to the next entry's BPM
|
||||
//! over the beat range. The seconds calculation uses the exact integral:
|
||||
//! `Δt = (60 / slope) * ln(bpm_end / bpm_start)` where slope = (bpm_end - bpm_start) / span_beats.
|
||||
//!
|
||||
//! # Format
|
||||
//! `entries` is a sorted `Vec<TempoEntry>` where the first entry must always
|
||||
//! have `beat == 0.0`.
|
||||
//!
|
||||
//! # Sequential-access optimisation
|
||||
//! An `AtomicUsize` caches the index of the last segment visited by
|
||||
//! `beats_to_seconds`. When calls are in ascending order (the common case when
|
||||
//! walking events in order) the scan starts from the cached index instead of
|
||||
//! the beginning, giving amortised O(1) behaviour.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use crate::time::{Beats, Seconds};
|
||||
|
||||
/// How the BPM transitions from one `TempoEntry` to the next.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub enum TempoInterpolation {
|
||||
/// BPM stays constant from this entry's beat until the next entry (instant change).
|
||||
#[default]
|
||||
Step,
|
||||
/// BPM linearly interpolates from this entry's BPM to the next entry's BPM
|
||||
/// over the beat span between the two entries.
|
||||
Linear,
|
||||
}
|
||||
|
||||
/// A single tempo segment: from `beat` onwards the tempo changes according to `interpolation`.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct TempoEntry {
|
||||
/// Start of this tempo segment in beats (quarter-note beats).
|
||||
pub beat: f64,
|
||||
/// Tempo at the start of this segment in beats per minute.
|
||||
pub bpm: f64,
|
||||
/// Cumulative seconds elapsed at the start of this segment.
|
||||
/// **Derived** — not serialised; call [`TempoMap::rebuild_seconds`] after any
|
||||
/// mutation or after deserialization.
|
||||
#[serde(skip, default)]
|
||||
pub seconds: f64,
|
||||
/// How the BPM transitions from this entry to the next.
|
||||
#[serde(default)]
|
||||
pub interpolation: TempoInterpolation,
|
||||
}
|
||||
|
||||
/// A piecewise tempo map used to convert between beats and seconds.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct TempoMap {
|
||||
/// Sorted list of tempo segments. Must always have at least one entry at beat 0.
|
||||
pub entries: Vec<TempoEntry>,
|
||||
/// Sequential-access cache: index of the last segment used by `beats_to_seconds`.
|
||||
#[serde(skip, default)]
|
||||
last_index: AtomicUsize,
|
||||
}
|
||||
|
||||
impl Clone for TempoMap {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
entries: self.entries.clone(),
|
||||
last_index: AtomicUsize::new(self.last_index.load(Ordering::Relaxed)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TempoMap {
|
||||
fn default() -> Self {
|
||||
Self::constant(120.0)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Seconds elapsed traversing `span_beats` starting at `bpm_start`.
|
||||
/// If `bpm_end` is `Some` (linear segment) and differs from `bpm_start`,
|
||||
/// uses the exact logarithmic integral.
|
||||
#[inline]
|
||||
fn segment_duration(span_beats: f64, bpm_start: f64, bpm_end: Option<f64>) -> f64 {
|
||||
match bpm_end {
|
||||
None => span_beats * 60.0 / bpm_start,
|
||||
Some(b1) if (b1 - bpm_start).abs() < 1e-9 => span_beats * 60.0 / bpm_start,
|
||||
Some(b1) => {
|
||||
// Linear BPM: BPM(b) = bpm_start + slope * (b - b_start)
|
||||
// Δt = ∫₀^span 60 / BPM(b) db = (60/slope) * ln(b1/bpm_start)
|
||||
let slope = (b1 - bpm_start) / span_beats;
|
||||
(60.0 / slope) * (b1 / bpm_start).ln()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Beats elapsed given `delta_seconds` starting at `bpm_start`.
|
||||
/// If `bpm_end` is `Some` (linear segment) and differs from `bpm_start`,
|
||||
/// uses the exact exponential inverse.
|
||||
#[inline]
|
||||
fn segment_beats(delta_seconds: f64, span_beats: f64, bpm_start: f64, bpm_end: Option<f64>) -> f64 {
|
||||
match bpm_end {
|
||||
None => delta_seconds * bpm_start / 60.0,
|
||||
Some(b1) if (b1 - bpm_start).abs() < 1e-9 => delta_seconds * bpm_start / 60.0,
|
||||
Some(b1) => {
|
||||
// Inverse of the logarithmic integral:
|
||||
// b = b_start + (bpm_start / slope) * (exp(delta_t * slope / 60) - 1)
|
||||
let slope = (b1 - bpm_start) / span_beats;
|
||||
(bpm_start / slope) * ((delta_seconds * slope / 60.0).exp() - 1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TempoMap {
|
||||
/// Create a constant-tempo map.
|
||||
pub fn constant(bpm: f64) -> Self {
|
||||
Self {
|
||||
entries: vec![TempoEntry { beat: 0.0, bpm, seconds: 0.0, interpolation: TempoInterpolation::Step }],
|
||||
last_index: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the `seconds` field on every entry from scratch.
|
||||
/// **Must be called** after any mutation (add/remove/reorder entry) and
|
||||
/// after deserialization.
|
||||
pub fn rebuild_seconds(&mut self) {
|
||||
let mut cumulative = 0.0_f64;
|
||||
let n = self.entries.len();
|
||||
for i in 0..n {
|
||||
self.entries[i].seconds = cumulative;
|
||||
if i + 1 < n {
|
||||
let span = self.entries[i + 1].beat - self.entries[i].beat;
|
||||
let bpm_end = if self.entries[i].interpolation == TempoInterpolation::Linear {
|
||||
Some(self.entries[i + 1].bpm)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
cumulative += segment_duration(span, self.entries[i].bpm, bpm_end);
|
||||
}
|
||||
}
|
||||
self.last_index.store(0, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Return the instantaneous BPM active at `beat`.
|
||||
/// For linear segments, returns the interpolated value at that beat.
|
||||
pub fn bpm_at(&self, beat: Beats) -> f64 {
|
||||
let n = self.entries.len();
|
||||
let idx = self.entries.partition_point(|e| e.beat <= beat.0).saturating_sub(1);
|
||||
let idx = idx.min(n - 1);
|
||||
let entry = &self.entries[idx];
|
||||
if entry.interpolation == TempoInterpolation::Linear && idx + 1 < n {
|
||||
let next = &self.entries[idx + 1];
|
||||
let t = (beat.0 - entry.beat) / (next.beat - entry.beat);
|
||||
entry.bpm + (next.bpm - entry.bpm) * t
|
||||
} else {
|
||||
entry.bpm
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert beats to seconds using the tempo map.
|
||||
///
|
||||
/// Uses the sequential cache: if `beat` is at or after the last cached
|
||||
/// segment, the scan starts there instead of from the beginning.
|
||||
pub fn beats_to_seconds(&self, beat: Beats) -> Seconds {
|
||||
Seconds(self.transform(beat.0))
|
||||
}
|
||||
|
||||
/// Convert seconds to beats using binary search on the cached `seconds` offsets.
|
||||
pub fn seconds_to_beats(&self, seconds: Seconds) -> Beats {
|
||||
Beats(self.inverse_transform(seconds.0))
|
||||
}
|
||||
|
||||
/// Global BPM — the BPM of the first entry (at beat 0).
|
||||
pub fn global_bpm(&self) -> f64 {
|
||||
self.entries[0].bpm
|
||||
}
|
||||
|
||||
/// Set the global BPM (first entry). Rebuilds seconds.
|
||||
pub fn set_global_bpm(&mut self, bpm: f64) {
|
||||
self.entries[0].bpm = bpm;
|
||||
self.rebuild_seconds();
|
||||
}
|
||||
|
||||
/// Convert local beats to parent time units (raw `f64`).
|
||||
///
|
||||
/// At the root level the result is absolute seconds. Inside a nested
|
||||
/// group the result is the *parent group's* beats.
|
||||
pub fn transform(&self, beat: f64) -> f64 {
|
||||
if beat <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let n = self.entries.len();
|
||||
let cached = self.last_index.load(Ordering::Relaxed).min(n.saturating_sub(1));
|
||||
let start = if beat >= self.entries[cached].beat { cached } else { 0 };
|
||||
let mut idx = start;
|
||||
while idx + 1 < n && self.entries[idx + 1].beat <= beat {
|
||||
idx += 1;
|
||||
}
|
||||
self.last_index.store(idx, Ordering::Relaxed);
|
||||
|
||||
let entry = &self.entries[idx];
|
||||
let beat_in_seg = beat - entry.beat;
|
||||
if entry.interpolation == TempoInterpolation::Linear && idx + 1 < n {
|
||||
let next = &self.entries[idx + 1];
|
||||
let span = next.beat - entry.beat;
|
||||
entry.seconds + segment_duration(beat_in_seg, entry.bpm, Some(entry.bpm + (next.bpm - entry.bpm) * beat_in_seg / span))
|
||||
} else {
|
||||
entry.seconds + beat_in_seg * 60.0 / entry.bpm
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverse of [`transform`]: convert parent time units back to local beats.
|
||||
pub fn inverse_transform(&self, parent_time: f64) -> f64 {
|
||||
if parent_time <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let n = self.entries.len();
|
||||
let idx = self.entries.partition_point(|e| e.seconds <= parent_time).saturating_sub(1);
|
||||
let idx = idx.min(n - 1);
|
||||
let entry = &self.entries[idx];
|
||||
let delta_t = parent_time - entry.seconds;
|
||||
if entry.interpolation == TempoInterpolation::Linear && idx + 1 < n {
|
||||
let next = &self.entries[idx + 1];
|
||||
let span = next.beat - entry.beat;
|
||||
entry.beat + segment_beats(delta_t, span, entry.bpm, Some(next.bpm))
|
||||
} else {
|
||||
entry.beat + delta_t * entry.bpm / 60.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a `TempoMap` from a list of `(beat, bpm)` keyframes (step interpolation).
|
||||
/// Always inserts a beat-0 entry using the first keyframe's BPM (or 120.0 if empty).
|
||||
pub fn from_keyframes(keyframes: &[(f64, f64)]) -> Self {
|
||||
if keyframes.is_empty() {
|
||||
return Self::constant(120.0);
|
||||
}
|
||||
let mut entries: Vec<TempoEntry> = keyframes
|
||||
.iter()
|
||||
.map(|&(beat, bpm)| TempoEntry { beat, bpm, seconds: 0.0, interpolation: TempoInterpolation::Step })
|
||||
.collect();
|
||||
entries.sort_by(|a, b| a.beat.partial_cmp(&b.beat).unwrap());
|
||||
if entries[0].beat > 0.0 {
|
||||
entries.insert(0, TempoEntry { beat: 0.0, bpm: entries[0].bpm, seconds: 0.0, interpolation: TempoInterpolation::Step });
|
||||
}
|
||||
let mut map = Self { entries, last_index: AtomicUsize::new(0) };
|
||||
map.rebuild_seconds();
|
||||
map
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert local beats through a stack of tempo maps to absolute seconds.
|
||||
pub fn beats_to_seconds_stack(beat: f64, stack: &[&TempoMap]) -> f64 {
|
||||
let mut t = beat;
|
||||
for tm in stack.iter().rev() {
|
||||
t = tm.transform(t);
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// Inverse of [`beats_to_seconds_stack`]: absolute seconds → local beats.
|
||||
pub fn seconds_to_beats_stack(seconds: f64, stack: &[&TempoMap]) -> f64 {
|
||||
let mut t = seconds;
|
||||
for tm in stack.iter() {
|
||||
t = tm.inverse_transform(t);
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn constant_bpm_round_trip() {
|
||||
let m = TempoMap::constant(120.0);
|
||||
assert!((m.beats_to_seconds(Beats(2.0)).0 - 1.0).abs() < 1e-9);
|
||||
assert!((m.seconds_to_beats(Seconds(1.0)).0 - 2.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_tempo_step() {
|
||||
let m = TempoMap::from_keyframes(&[(0.0, 120.0), (4.0, 60.0)]);
|
||||
// Beat 0-4: 120 BPM → 4 beats = 2 seconds
|
||||
assert!((m.beats_to_seconds(Beats(4.0)).0 - 2.0).abs() < 1e-9, "got {}", m.beats_to_seconds(Beats(4.0)).0);
|
||||
// Beat 4-5: 60 BPM → 1 beat = 1 second
|
||||
assert!((m.beats_to_seconds(Beats(5.0)).0 - 3.0).abs() < 1e-9);
|
||||
assert!((m.seconds_to_beats(Seconds(3.0)).0 - 5.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_interpolation_round_trip() {
|
||||
// 120→240 BPM over 4 beats: slope = (240-120)/4 = 30 BPM/beat
|
||||
// Δt = (60/30) * ln(240/120) = 2 * ln(2) ≈ 1.386s for beats 0-4
|
||||
let mut m = TempoMap::constant(120.0);
|
||||
m.entries.push(TempoEntry { beat: 4.0, bpm: 240.0, seconds: 0.0, interpolation: TempoInterpolation::Step });
|
||||
m.entries[0].interpolation = TempoInterpolation::Linear;
|
||||
m.rebuild_seconds();
|
||||
|
||||
let expected = 2.0 * std::f64::consts::LN_2;
|
||||
let got = m.beats_to_seconds(Beats(4.0)).0;
|
||||
assert!((got - expected).abs() < 1e-9, "got {got}, expected {expected}");
|
||||
|
||||
// Round-trip
|
||||
let beats_back = m.seconds_to_beats(Seconds(expected)).0;
|
||||
assert!((beats_back - 4.0).abs() < 1e-9, "round-trip got {beats_back}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stack_composition() {
|
||||
let root = TempoMap::constant(120.0);
|
||||
let group = TempoMap::constant(60.0);
|
||||
let stack: Vec<&TempoMap> = vec![&root, &group];
|
||||
let secs = beats_to_seconds_stack(2.0, &stack);
|
||||
assert!((secs - 1.0).abs() < 1e-9, "got {secs}");
|
||||
let beats = seconds_to_beats_stack(1.0, &stack);
|
||||
assert!((beats - 2.0).abs() < 1e-9, "got {beats}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequential_cache() {
|
||||
let m = TempoMap::constant(120.0);
|
||||
for i in 0..10 {
|
||||
let secs = m.beats_to_seconds(Beats(i as f64));
|
||||
assert!((secs.0 - i as f64 * 0.5).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
/// Strongly-typed time units to prevent accidental beats/seconds confusion.
|
||||
///
|
||||
/// Convert between the two using `TempoMap::beats_to_seconds` / `TempoMap::seconds_to_beats`.
|
||||
/// All internal scheduling and clip positions use `Beats`; only audio rendering
|
||||
/// (sample offsets, file seeks) uses `Seconds`.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::ops::{Add, AddAssign, Div, Mul, Neg, Rem, Sub, SubAssign};
|
||||
|
||||
/// A time position or duration expressed in **beats** (quarter-note beats).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Beats(pub f64);
|
||||
|
||||
/// A time position or duration expressed in **seconds**.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Seconds(pub f64);
|
||||
|
||||
impl Beats {
|
||||
pub const ZERO: Self = Self(0.0);
|
||||
|
||||
pub fn max(self, other: Self) -> Self { Self(self.0.max(other.0)) }
|
||||
pub fn min(self, other: Self) -> Self { Self(self.0.min(other.0)) }
|
||||
pub fn abs(self) -> Self { Self(self.0.abs()) }
|
||||
pub fn ceil(self) -> Self { Self(self.0.ceil()) }
|
||||
pub fn floor(self) -> Self { Self(self.0.floor()) }
|
||||
pub fn beats_to_f64(self) -> f64 { self.0 }
|
||||
}
|
||||
|
||||
impl Seconds {
|
||||
pub const ZERO: Self = Self(0.0);
|
||||
|
||||
pub fn max(self, other: Self) -> Self { Self(self.0.max(other.0)) }
|
||||
pub fn min(self, other: Self) -> Self { Self(self.0.min(other.0)) }
|
||||
pub fn abs(self) -> Self { Self(self.0.abs()) }
|
||||
pub fn seconds_to_f64(self) -> f64 { self.0 }
|
||||
}
|
||||
|
||||
// --- Beats arithmetic ---
|
||||
|
||||
impl Add for Beats {
|
||||
type Output = Self;
|
||||
fn add(self, rhs: Self) -> Self { Self(self.0 + rhs.0) }
|
||||
}
|
||||
impl Sub for Beats {
|
||||
type Output = Self;
|
||||
fn sub(self, rhs: Self) -> Self { Self(self.0 - rhs.0) }
|
||||
}
|
||||
impl Mul<f64> for Beats {
|
||||
type Output = Self;
|
||||
fn mul(self, rhs: f64) -> Self { Self(self.0 * rhs) }
|
||||
}
|
||||
impl Div<f64> for Beats {
|
||||
type Output = Self;
|
||||
fn div(self, rhs: f64) -> Self { Self(self.0 / rhs) }
|
||||
}
|
||||
/// Beats / Beats = dimensionless ratio (f64)
|
||||
impl Div<Beats> for Beats {
|
||||
type Output = f64;
|
||||
fn div(self, rhs: Beats) -> f64 { self.0 / rhs.0 }
|
||||
}
|
||||
impl Rem for Beats {
|
||||
type Output = Self;
|
||||
fn rem(self, rhs: Self) -> Self { Self(self.0 % rhs.0) }
|
||||
}
|
||||
impl Neg for Beats {
|
||||
type Output = Self;
|
||||
fn neg(self) -> Self { Self(-self.0) }
|
||||
}
|
||||
impl AddAssign for Beats {
|
||||
fn add_assign(&mut self, rhs: Self) { self.0 += rhs.0; }
|
||||
}
|
||||
impl SubAssign for Beats {
|
||||
fn sub_assign(&mut self, rhs: Self) { self.0 -= rhs.0; }
|
||||
}
|
||||
|
||||
// --- Seconds arithmetic ---
|
||||
|
||||
impl Add for Seconds {
|
||||
type Output = Self;
|
||||
fn add(self, rhs: Self) -> Self { Self(self.0 + rhs.0) }
|
||||
}
|
||||
impl Sub for Seconds {
|
||||
type Output = Self;
|
||||
fn sub(self, rhs: Self) -> Self { Self(self.0 - rhs.0) }
|
||||
}
|
||||
impl Mul<f64> for Seconds {
|
||||
type Output = Self;
|
||||
fn mul(self, rhs: f64) -> Self { Self(self.0 * rhs) }
|
||||
}
|
||||
impl Div<f64> for Seconds {
|
||||
type Output = Self;
|
||||
fn div(self, rhs: f64) -> Self { Self(self.0 / rhs) }
|
||||
}
|
||||
/// Seconds / Seconds = dimensionless ratio (f64)
|
||||
impl Div<Seconds> for Seconds {
|
||||
type Output = f64;
|
||||
fn div(self, rhs: Seconds) -> f64 { self.0 / rhs.0 }
|
||||
}
|
||||
impl Rem for Seconds {
|
||||
type Output = Self;
|
||||
fn rem(self, rhs: Self) -> Self { Self(self.0 % rhs.0) }
|
||||
}
|
||||
impl Neg for Seconds {
|
||||
type Output = Self;
|
||||
fn neg(self) -> Self { Self(-self.0) }
|
||||
}
|
||||
impl AddAssign for Seconds {
|
||||
fn add_assign(&mut self, rhs: Self) { self.0 += rhs.0; }
|
||||
}
|
||||
impl SubAssign for Seconds {
|
||||
fn sub_assign(&mut self, rhs: Self) { self.0 -= rhs.0; }
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Beats {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Seconds {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
|
@ -859,7 +859,7 @@ fn execute_command(
|
|||
|
||||
for event in &midi_clip.events {
|
||||
let status = event.status & 0xF0;
|
||||
let time_seconds = event.timestamp.beats_to_f64() / sample_rate;
|
||||
let time_seconds = event.timestamp as f64 / sample_rate;
|
||||
|
||||
match status {
|
||||
0x90 if event.data2 > 0 => {
|
||||
|
|
@ -878,7 +878,7 @@ fn execute_command(
|
|||
}
|
||||
|
||||
// Add to local UI state with note data
|
||||
app.add_clip(track_id, clip_id, start_time, duration.beats_to_f64(), file_path.to_string(), notes);
|
||||
app.add_clip(track_id, clip_id, start_time, duration, file_path.to_string(), notes);
|
||||
app.next_clip_id += 1;
|
||||
|
||||
// Send to audio engine with the start_time (clip content is separate from timeline position)
|
||||
|
|
|
|||
|
|
@ -1,89 +0,0 @@
|
|||
//! Integration test for `CompressedReader::open_source` — decoding a streaming
|
||||
//! audio source from an in-memory byte stream (the packed-in-container path)
|
||||
//! rather than a filesystem path. Proves the `MediaByteSource` adapter feeds
|
||||
//! Symphonia correctly (probe + decode + seekable byte length).
|
||||
|
||||
use std::io::{Cursor, Read, Seek, SeekFrom};
|
||||
|
||||
use daw_backend::audio::disk_reader::{CompressedReader, MediaByteSource};
|
||||
|
||||
/// A `MediaByteSource` over an in-memory buffer (stands in for core's BlobReader).
|
||||
struct VecSource(Cursor<Vec<u8>>);
|
||||
|
||||
impl Read for VecSource {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
self.0.read(buf)
|
||||
}
|
||||
}
|
||||
impl Seek for VecSource {
|
||||
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
|
||||
self.0.seek(pos)
|
||||
}
|
||||
}
|
||||
impl MediaByteSource for VecSource {
|
||||
fn byte_len(&self) -> u64 {
|
||||
self.0.get_ref().len() as u64
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a minimal PCM16 mono WAV byte buffer holding `frames` samples of a ramp.
|
||||
fn make_wav(sample_rate: u32, frames: u32) -> Vec<u8> {
|
||||
let channels: u16 = 1;
|
||||
let bits: u16 = 16;
|
||||
let block_align: u16 = channels * bits / 8;
|
||||
let byte_rate: u32 = sample_rate * block_align as u32;
|
||||
let data_len: u32 = frames * block_align as u32;
|
||||
|
||||
let mut v = Vec::new();
|
||||
v.extend_from_slice(b"RIFF");
|
||||
v.extend_from_slice(&(36 + data_len).to_le_bytes());
|
||||
v.extend_from_slice(b"WAVE");
|
||||
v.extend_from_slice(b"fmt ");
|
||||
v.extend_from_slice(&16u32.to_le_bytes());
|
||||
v.extend_from_slice(&1u16.to_le_bytes()); // PCM
|
||||
v.extend_from_slice(&channels.to_le_bytes());
|
||||
v.extend_from_slice(&sample_rate.to_le_bytes());
|
||||
v.extend_from_slice(&byte_rate.to_le_bytes());
|
||||
v.extend_from_slice(&block_align.to_le_bytes());
|
||||
v.extend_from_slice(&bits.to_le_bytes());
|
||||
v.extend_from_slice(b"data");
|
||||
v.extend_from_slice(&data_len.to_le_bytes());
|
||||
for i in 0..frames {
|
||||
// A ramp from -16000..16000 so values are recognizable.
|
||||
let s = (((i % 1000) as i32 - 500) * 32) as i16;
|
||||
v.extend_from_slice(&s.to_le_bytes());
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_source_decodes_in_memory_wav() {
|
||||
let sample_rate = 8000;
|
||||
let frames = 4096;
|
||||
let bytes = make_wav(sample_rate, frames);
|
||||
|
||||
let src = Box::new(VecSource(Cursor::new(bytes)));
|
||||
let mut reader = CompressedReader::open_source(src, Some("wav"))
|
||||
.expect("open_source should probe the in-memory WAV");
|
||||
|
||||
assert_eq!(reader.sample_rate(), sample_rate);
|
||||
assert_eq!(reader.channels(), 1);
|
||||
|
||||
// Decode the whole stream and count emitted frames.
|
||||
let mut buf = Vec::new();
|
||||
let mut decoded = 0usize;
|
||||
loop {
|
||||
let n = reader.decode_next(&mut buf).expect("decode_next");
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
decoded += n;
|
||||
}
|
||||
// Should recover (approximately) all frames — codec frame counts can round.
|
||||
assert!(
|
||||
(decoded as i64 - frames as i64).abs() < 64,
|
||||
"decoded {} vs expected {}",
|
||||
decoded,
|
||||
frames
|
||||
);
|
||||
}
|
||||
|
|
@ -1,253 +0,0 @@
|
|||
//! Integration tests for `VideoAudioReader` (FFmpeg streaming audio source).
|
||||
//!
|
||||
//! These build the daw-backend lib in normal mode, so they're independent of
|
||||
//! the crate's pre-existing broken `#[cfg(test)]` unit tests (automation.rs).
|
||||
//! They synthesize a mono 32-bit-float WAV whose sample `i` has value `i/n`, so
|
||||
//! a decoded sample's value identifies its frame index — letting us assert both
|
||||
//! in-order decoding and **sample-accurate seeking** (the property video audio
|
||||
//! needs to stay synced with other clips).
|
||||
|
||||
use daw_backend::audio::disk_reader::{
|
||||
build_waveform_pyramid, CompressedReader, SourceKind, VideoAudioReader,
|
||||
};
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
fn write_ramp_wav(path: &Path, n: u32, sample_rate: u32) {
|
||||
let channels = 1u16;
|
||||
let bytes_per_sample = 4u32;
|
||||
let data_size = n * bytes_per_sample;
|
||||
let mut buf: Vec<u8> = Vec::with_capacity(44 + data_size as usize);
|
||||
buf.extend_from_slice(b"RIFF");
|
||||
buf.extend_from_slice(&(36 + data_size).to_le_bytes());
|
||||
buf.extend_from_slice(b"WAVE");
|
||||
buf.extend_from_slice(b"fmt ");
|
||||
buf.extend_from_slice(&16u32.to_le_bytes());
|
||||
buf.extend_from_slice(&3u16.to_le_bytes()); // IEEE float
|
||||
buf.extend_from_slice(&channels.to_le_bytes());
|
||||
buf.extend_from_slice(&sample_rate.to_le_bytes());
|
||||
buf.extend_from_slice(&(sample_rate * channels as u32 * bytes_per_sample).to_le_bytes());
|
||||
buf.extend_from_slice(&((channels as u32 * bytes_per_sample) as u16).to_le_bytes());
|
||||
buf.extend_from_slice(&32u16.to_le_bytes());
|
||||
buf.extend_from_slice(b"data");
|
||||
buf.extend_from_slice(&data_size.to_le_bytes());
|
||||
for i in 0..n {
|
||||
buf.extend_from_slice(&((i as f32) / (n as f32)).to_le_bytes());
|
||||
}
|
||||
let mut f = std::fs::File::create(path).unwrap();
|
||||
f.write_all(&buf).unwrap();
|
||||
}
|
||||
|
||||
/// Stereo ramp: frame `i` has left = `i/n`, right = `0.5 - i/n` (distinct per
|
||||
/// channel), interleaved `[L0,R0,L1,R1,…]`. Exercises the channels>1 path.
|
||||
fn write_stereo_ramp_wav(path: &Path, n: u32, sample_rate: u32) {
|
||||
let channels = 2u16;
|
||||
let bytes_per_sample = 4u32;
|
||||
let data_size = n * channels as u32 * bytes_per_sample;
|
||||
let mut buf: Vec<u8> = Vec::with_capacity(44 + data_size as usize);
|
||||
buf.extend_from_slice(b"RIFF");
|
||||
buf.extend_from_slice(&(36 + data_size).to_le_bytes());
|
||||
buf.extend_from_slice(b"WAVE");
|
||||
buf.extend_from_slice(b"fmt ");
|
||||
buf.extend_from_slice(&16u32.to_le_bytes());
|
||||
buf.extend_from_slice(&3u16.to_le_bytes()); // IEEE float
|
||||
buf.extend_from_slice(&channels.to_le_bytes());
|
||||
buf.extend_from_slice(&sample_rate.to_le_bytes());
|
||||
buf.extend_from_slice(&(sample_rate * channels as u32 * bytes_per_sample).to_le_bytes());
|
||||
buf.extend_from_slice(&((channels as u32 * bytes_per_sample) as u16).to_le_bytes());
|
||||
buf.extend_from_slice(&32u16.to_le_bytes());
|
||||
buf.extend_from_slice(b"data");
|
||||
buf.extend_from_slice(&data_size.to_le_bytes());
|
||||
for i in 0..n {
|
||||
let l = i as f32 / n as f32;
|
||||
let r = 0.5 - i as f32 / n as f32;
|
||||
buf.extend_from_slice(&l.to_le_bytes());
|
||||
buf.extend_from_slice(&r.to_le_bytes());
|
||||
}
|
||||
let mut f = std::fs::File::create(path).unwrap();
|
||||
f.write_all(&buf).unwrap();
|
||||
}
|
||||
|
||||
fn temp_path(tag: &str) -> std::path::PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!("lb_videoaudio_test_{}_{}.wav", std::process::id(), tag));
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_samples_in_order() {
|
||||
let n = 4000u32;
|
||||
let sr = 8000u32;
|
||||
let path = temp_path("seq");
|
||||
write_ramp_wav(&path, n, sr);
|
||||
|
||||
let mut reader = VideoAudioReader::open(&path).unwrap();
|
||||
assert_eq!(reader.channels(), 1);
|
||||
assert_eq!(reader.sample_rate(), sr);
|
||||
// Probe estimate (used by add_video_audio_sync) should be ~n frames.
|
||||
let tf = reader.total_frames() as f64;
|
||||
assert!(
|
||||
(tf - n as f64).abs() < n as f64 * 0.1,
|
||||
"total_frames {} not ~{}",
|
||||
tf, n
|
||||
);
|
||||
|
||||
let mut all = Vec::new();
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
let frames = reader.decode_next(&mut buf).unwrap();
|
||||
if frames == 0 {
|
||||
break;
|
||||
}
|
||||
all.extend_from_slice(&buf);
|
||||
}
|
||||
|
||||
// Allow a couple of priming/flush samples of slack at the very end.
|
||||
assert!(all.len() + 4 >= n as usize, "decoded too few samples: {}", all.len());
|
||||
for (i, &v) in all.iter().enumerate().take(n as usize) {
|
||||
let expected = i as f32 / n as f32;
|
||||
assert!((v - expected).abs() < 1e-3, "sample {} = {}, expected {}", i, v, expected);
|
||||
}
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// CompressedReader (symphonia) must seek **sample-accurately** too, so compressed
|
||||
/// audio stays frame-synced with video audio. Symphonia decodes WAV via the same
|
||||
/// path; its coarse seek lands on packet boundaries, exercising the decode-discard.
|
||||
#[test]
|
||||
fn compressed_reader_seek_is_sample_accurate() {
|
||||
let n = 4000u32;
|
||||
let sr = 8000u32;
|
||||
let path = temp_path("comp_seek");
|
||||
write_ramp_wav(&path, n, sr);
|
||||
|
||||
let mut reader = CompressedReader::open(&path).unwrap();
|
||||
assert_eq!(reader.channels(), 1);
|
||||
assert_eq!(reader.sample_rate(), sr);
|
||||
|
||||
for &target in &[2000u64, 137, 3500, 0] {
|
||||
let actual = reader.seek(target).unwrap();
|
||||
assert_eq!(actual, target, "seek should report the exact target");
|
||||
|
||||
let mut buf = Vec::new();
|
||||
let mut frames = 0;
|
||||
for _ in 0..128 {
|
||||
frames = reader.decode_next(&mut buf).unwrap();
|
||||
if frames > 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(frames > 0, "no samples after seek to {}", target);
|
||||
let expected = target as f32 / n as f32;
|
||||
assert!(
|
||||
(buf[0] - expected).abs() < 1e-3,
|
||||
"compressed seek to {}: first sample = {}, expected {}",
|
||||
target, buf[0], expected
|
||||
);
|
||||
}
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// The decode→pyramid bridge should produce an envelope matching the signal,
|
||||
/// through both reader backends (symphonia + ffmpeg), with bounded memory.
|
||||
#[test]
|
||||
fn waveform_pyramid_from_decode_matches_signal() {
|
||||
let n = 5000u32;
|
||||
let sr = 8000u32;
|
||||
let path = temp_path("pyr");
|
||||
write_ramp_wav(&path, n, sr); // ramp 0 .. (n-1)/n, all positive
|
||||
|
||||
for kind in [SourceKind::CompressedAudio, SourceKind::VideoAudio] {
|
||||
let p = build_waveform_pyramid(&path, kind, 256).unwrap();
|
||||
assert_eq!(p.channels, 1);
|
||||
assert_eq!(p.root().len(), 1, "{:?}: root should be one texel", kind);
|
||||
let root = p.root()[0];
|
||||
assert!(root.l_min.abs() < 1e-2, "{:?}: root min {} ~ 0", kind, root.l_min);
|
||||
let expected_max = (n - 1) as f32 / n as f32;
|
||||
assert!(
|
||||
(root.l_max - expected_max).abs() < 1e-2,
|
||||
"{:?}: root max {} ~ {}", kind, root.l_max, expected_max
|
||||
);
|
||||
// Frame count is approximate across decoders (priming/resampler overhead);
|
||||
// the envelope above is the real check. Just confirm it's about right.
|
||||
assert!((p.total_frames as i64 - n as i64).abs() < 128, "{:?}: frames {}", kind, p.total_frames);
|
||||
}
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_stereo_interleaved() {
|
||||
let n = 2000u32;
|
||||
let sr = 8000u32;
|
||||
let path = temp_path("stereo");
|
||||
write_stereo_ramp_wav(&path, n, sr);
|
||||
|
||||
let mut reader = VideoAudioReader::open(&path).unwrap();
|
||||
assert_eq!(reader.channels(), 2);
|
||||
|
||||
let mut all = Vec::new();
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
let frames = reader.decode_next(&mut buf).unwrap();
|
||||
if frames == 0 {
|
||||
break;
|
||||
}
|
||||
// Each decode_next returns whole interleaved frames.
|
||||
assert_eq!(buf.len() % 2, 0, "stereo decode returned a partial frame");
|
||||
all.extend_from_slice(&buf);
|
||||
}
|
||||
|
||||
// Interleaved L/R, ~n frames.
|
||||
assert!(all.len() + 8 >= (n * 2) as usize, "decoded too few samples: {}", all.len());
|
||||
for i in 0..n as usize {
|
||||
let l = all[2 * i];
|
||||
let r = all[2 * i + 1];
|
||||
assert!((l - i as f32 / n as f32).abs() < 1e-3, "L[{}]={} expected {}", i, l, i as f32 / n as f32);
|
||||
assert!(
|
||||
(r - (0.5 - i as f32 / n as f32)).abs() < 1e-3,
|
||||
"R[{}]={} expected {}", i, r, 0.5 - i as f32 / n as f32
|
||||
);
|
||||
}
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seek_is_sample_accurate() {
|
||||
let n = 4000u32;
|
||||
let sr = 8000u32;
|
||||
let path = temp_path("seek");
|
||||
write_ramp_wav(&path, n, sr);
|
||||
|
||||
let mut reader = VideoAudioReader::open(&path).unwrap();
|
||||
|
||||
for &target in &[2000u64, 137, 3500, 0] {
|
||||
let actual = reader.seek(target).unwrap();
|
||||
assert_eq!(actual, target);
|
||||
|
||||
// Pull the first non-empty decode after the seek.
|
||||
let mut buf = Vec::new();
|
||||
let mut frames = 0;
|
||||
for _ in 0..64 {
|
||||
frames = reader.decode_next(&mut buf).unwrap();
|
||||
if frames > 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(frames > 0, "no samples after seek to {}", target);
|
||||
|
||||
let expected = target as f32 / n as f32;
|
||||
assert!(
|
||||
(buf[0] - expected).abs() < 1e-3,
|
||||
"after seek to {}: first sample = {}, expected {}",
|
||||
target,
|
||||
buf[0],
|
||||
expected
|
||||
);
|
||||
// And the next few advance in order.
|
||||
for k in 0..frames.min(8) {
|
||||
let exp = (target as usize + k) as f32 / n as f32;
|
||||
assert!((buf[k] - exp).abs() < 1e-3, "seek {}+{}: {} vs {}", target, k, buf[k], exp);
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
//! Integration tests for the streaming waveform LOD pyramid builder.
|
||||
//!
|
||||
//! Convention B: `levels[0]` is the root (coarsest), `levels.last()` the floor
|
||||
//! (finest). Tests use the `.root()` / `.floor()` accessors so they don't depend
|
||||
//! on the raw index ordering.
|
||||
|
||||
use daw_backend::audio::waveform_pyramid::{Texel, WaveformPyramid, WaveformPyramidBuilder};
|
||||
|
||||
fn build_mono(samples: &[f32], floor: u32) -> WaveformPyramid {
|
||||
let mut b = WaveformPyramidBuilder::new(1, floor);
|
||||
b.push_interleaved(samples);
|
||||
b.finish()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floor_level_min_max_per_bucket() {
|
||||
// 8 samples, floor 4 → two floor texels covering [0..4) and [4..8).
|
||||
let s: Vec<f32> = (0..8).map(|i| i as f32).collect();
|
||||
let p = build_mono(&s, 4);
|
||||
assert_eq!(p.floor().len(), 2);
|
||||
assert_eq!(p.floor()[0], Texel { l_min: 0.0, l_max: 3.0, r_min: 0.0, r_max: 3.0 });
|
||||
assert_eq!(p.floor()[1], Texel { l_min: 4.0, l_max: 7.0, r_min: 4.0, r_max: 7.0 });
|
||||
// Root reduces the two floor texels into the envelope [0..8).
|
||||
assert_eq!(p.root().len(), 1);
|
||||
assert_eq!(p.root()[0], Texel { l_min: 0.0, l_max: 7.0, r_min: 0.0, r_max: 7.0 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_trailing_bucket_is_flushed() {
|
||||
// 6 samples, floor 4 → texels [0..4) and a ragged [4..6).
|
||||
let s: Vec<f32> = (0..6).map(|i| i as f32).collect();
|
||||
let p = build_mono(&s, 4);
|
||||
assert_eq!(p.floor().len(), 2);
|
||||
assert_eq!(p.floor()[1], Texel { l_min: 4.0, l_max: 5.0, r_min: 4.0, r_max: 5.0 });
|
||||
assert_eq!(p.total_frames, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_level_envelope_matches_global_min_max() {
|
||||
let s: Vec<f32> = (0..1000).map(|i| ((i as f32) * 0.01).sin()).collect();
|
||||
let g_min = s.iter().cloned().fold(f32::INFINITY, f32::min);
|
||||
let g_max = s.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
|
||||
let p = build_mono(&s, 16);
|
||||
assert_eq!(p.root().len(), 1);
|
||||
assert!((p.root()[0].l_min - g_min).abs() < 1e-6);
|
||||
assert!((p.root()[0].l_max - g_max).abs() < 1e-6);
|
||||
// Every level's overall min/max equals the global (extremes are lossless).
|
||||
for level in &p.levels {
|
||||
let lmin = level.iter().map(|t| t.l_min).fold(f32::INFINITY, f32::min);
|
||||
let lmax = level.iter().map(|t| t.l_max).fold(f32::NEG_INFINITY, f32::max);
|
||||
assert!((lmin - g_min).abs() < 1e-6);
|
||||
assert!((lmax - g_max).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_are_root_first_and_get_finer() {
|
||||
let s: Vec<f32> = (0..1000).map(|i| i as f32).collect();
|
||||
let p = build_mono(&s, 16);
|
||||
// Root first, floor last; strictly finer (more texels) as depth increases.
|
||||
assert_eq!(p.root().len(), 1);
|
||||
assert!(p.depth() >= 3);
|
||||
for w in p.levels.windows(2) {
|
||||
assert!(w[1].len() >= w[0].len(), "deeper level should be finer");
|
||||
}
|
||||
// Floor has ceil(1000/16) = 63 texels.
|
||||
assert_eq!(p.floor().len(), 63);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stereo_channels_tracked_separately() {
|
||||
// L ramps up, R ramps down; interleaved.
|
||||
let n = 64;
|
||||
let mut s = Vec::new();
|
||||
for i in 0..n {
|
||||
s.push(i as f32); // L
|
||||
s.push(-(i as f32)); // R
|
||||
}
|
||||
let mut b = WaveformPyramidBuilder::new(2, 16);
|
||||
b.push_interleaved(&s);
|
||||
let p = b.finish();
|
||||
assert_eq!(p.root().len(), 1);
|
||||
assert_eq!(p.root()[0].l_min, 0.0);
|
||||
assert_eq!(p.root()[0].l_max, (n - 1) as f32);
|
||||
assert_eq!(p.root()[0].r_min, -((n - 1) as f32));
|
||||
assert_eq!(p.root()[0].r_max, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pyramid_size_is_bounded() {
|
||||
let n = 100_000usize;
|
||||
let s: Vec<f32> = (0..n).map(|i| (i % 7) as f32).collect();
|
||||
let floor = 256u32;
|
||||
let p = build_mono(&s, floor);
|
||||
let total: usize = p.levels.iter().map(|l| l.len()).sum();
|
||||
let floor_texels = (n as u32).div_ceil(floor) as usize;
|
||||
// Geometric bound: < floor_texels * branch/(branch-1) + small per-level slack.
|
||||
let bound = floor_texels * 4 / 3 + p.depth() + 2;
|
||||
assert!(total <= bound, "pyramid too big: {} > {}", total, bound);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bytes_round_trip() {
|
||||
let s: Vec<f32> = (0..3333).map(|i| ((i as f32) * 0.013).sin()).collect();
|
||||
let p = build_mono(&s, 64);
|
||||
let bytes = p.to_bytes();
|
||||
let q = WaveformPyramid::from_bytes(&bytes).unwrap();
|
||||
assert_eq!(p.floor_samples_per_texel, q.floor_samples_per_texel);
|
||||
assert_eq!(p.branch, q.branch);
|
||||
assert_eq!(p.channels, q.channels);
|
||||
assert_eq!(p.total_frames, q.total_frames);
|
||||
assert_eq!(p.levels, q.levels);
|
||||
// Truncated/garbage input is rejected, not panicking.
|
||||
assert!(WaveformPyramid::from_bytes(&bytes[..bytes.len() - 4]).is_err());
|
||||
assert!(WaveformPyramid::from_bytes(b"nope").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pushing_in_arbitrary_chunks_matches() {
|
||||
// The streaming builder must be agnostic to how samples are chunked.
|
||||
let s: Vec<f32> = (0..5000).map(|i| ((i * 13) % 97) as f32 - 48.0).collect();
|
||||
let whole = build_mono(&s, 32);
|
||||
|
||||
let mut b = WaveformPyramidBuilder::new(1, 32);
|
||||
b.reserve_for_frames(5000);
|
||||
for chunk in s.chunks(37) {
|
||||
b.push_interleaved(chunk);
|
||||
}
|
||||
let chunked = b.finish();
|
||||
|
||||
assert_eq!(whole.depth(), chunked.depth());
|
||||
for (a, c) in whole.levels.iter().zip(chunked.levels.iter()) {
|
||||
assert_eq!(a, c);
|
||||
}
|
||||
}
|
||||
1092
docs/AUDIO_SYSTEM.md
1092
docs/AUDIO_SYSTEM.md
File diff suppressed because it is too large
Load Diff
545
docs/BUILDING.md
545
docs/BUILDING.md
|
|
@ -1,545 +0,0 @@
|
|||
# Building Lightningbeam
|
||||
|
||||
This guide provides detailed instructions for building Lightningbeam on different platforms, including dependency installation, troubleshooting, and advanced build configurations.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Quick Start](#quick-start)
|
||||
- [Platform-Specific Instructions](#platform-specific-instructions)
|
||||
- [Dependencies](#dependencies)
|
||||
- [Build Configurations](#build-configurations)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Development Builds](#development-builds)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/skykooler/lightningbeam.git
|
||||
cd lightningbeam
|
||||
|
||||
# Initialize submodules (including nested ones required by nam-ffi)
|
||||
git submodule update --init --recursive
|
||||
|
||||
cd lightningbeam-ui
|
||||
|
||||
# Build and run
|
||||
cargo build
|
||||
cargo run
|
||||
```
|
||||
|
||||
## Platform-Specific Instructions
|
||||
|
||||
### Linux
|
||||
|
||||
#### Ubuntu/Debian
|
||||
|
||||
**Important**: Lightningbeam requires FFmpeg 8, which may not be in the default repositories.
|
||||
|
||||
```bash
|
||||
# Install basic dependencies
|
||||
sudo apt update
|
||||
sudo apt install -y \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libasound2-dev \
|
||||
clang \
|
||||
libclang-dev
|
||||
|
||||
# Install FFmpeg 8 from PPA (Ubuntu)
|
||||
sudo add-apt-repository ppa:ubuntuhandbook1/ffmpeg7
|
||||
sudo apt update
|
||||
sudo apt install -y \
|
||||
ffmpeg \
|
||||
libavcodec-dev \
|
||||
libavformat-dev \
|
||||
libavutil-dev \
|
||||
libswscale-dev \
|
||||
libswresample-dev
|
||||
|
||||
# Verify FFmpeg version (should be 8.x)
|
||||
ffmpeg -version
|
||||
|
||||
# Install Rust if needed
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
|
||||
# Build
|
||||
cd lightningbeam-ui
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
**Note**: If the PPA doesn't provide FFmpeg 8, you may need to compile FFmpeg from source or find an alternative PPA. See [FFmpeg Issues](#ffmpeg-issues) for details.
|
||||
|
||||
#### Arch Linux/Manjaro
|
||||
|
||||
```bash
|
||||
# Install system dependencies
|
||||
sudo pacman -S --needed \
|
||||
base-devel \
|
||||
rust \
|
||||
alsa-lib \
|
||||
ffmpeg \
|
||||
clang
|
||||
|
||||
# Build
|
||||
cd lightningbeam-ui
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
#### Fedora/RHEL
|
||||
|
||||
```bash
|
||||
# Install system dependencies
|
||||
sudo dnf install -y \
|
||||
gcc \
|
||||
gcc-c++ \
|
||||
make \
|
||||
pkg-config \
|
||||
alsa-lib-devel \
|
||||
ffmpeg \
|
||||
ffmpeg-devel \
|
||||
clang \
|
||||
clang-devel
|
||||
|
||||
# Install Rust if needed
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
|
||||
# Build
|
||||
cd lightningbeam-ui
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
### macOS
|
||||
|
||||
```bash
|
||||
# Install Homebrew if needed
|
||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
|
||||
# Install dependencies
|
||||
brew install rust ffmpeg pkg-config
|
||||
|
||||
# Build
|
||||
cd lightningbeam-ui
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
**Note**: macOS uses CoreAudio for audio I/O (via cpal), so no additional audio libraries are needed.
|
||||
|
||||
### Windows
|
||||
|
||||
#### Using Visual Studio
|
||||
|
||||
1. Install [Visual Studio 2022](https://visualstudio.microsoft.com/) with "Desktop development with C++" workload
|
||||
2. Install [Rust](https://rustup.rs/)
|
||||
3. Install [FFmpeg](https://ffmpeg.org/download.html#build-windows):
|
||||
- Download a shared build from https://www.gyan.dev/ffmpeg/builds/
|
||||
- Extract to `C:\ffmpeg`
|
||||
- Add `C:\ffmpeg\bin` to PATH
|
||||
- Set environment variables:
|
||||
```cmd
|
||||
set FFMPEG_DIR=C:\ffmpeg
|
||||
set PKG_CONFIG_PATH=C:\ffmpeg\lib\pkgconfig
|
||||
```
|
||||
|
||||
4. Build:
|
||||
```cmd
|
||||
cd lightningbeam-ui
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
#### Using MSYS2/MinGW
|
||||
|
||||
```bash
|
||||
# In MSYS2 shell
|
||||
pacman -S mingw-w64-x86_64-rust \
|
||||
mingw-w64-x86_64-ffmpeg \
|
||||
mingw-w64-x86_64-pkg-config
|
||||
|
||||
cd lightningbeam-ui
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
**Note**: Windows uses WASAPI for audio I/O (via cpal), which is built into Windows.
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Required Dependencies
|
||||
|
||||
#### Rust Toolchain
|
||||
- **Version**: Stable (1.70+)
|
||||
- **Install**: https://rustup.rs/
|
||||
- **Components**: Default installation includes everything needed
|
||||
|
||||
#### Audio I/O (ALSA on Linux)
|
||||
- **Ubuntu/Debian**: `libasound2-dev`
|
||||
- **Arch**: `alsa-lib`
|
||||
- **Fedora**: `alsa-lib-devel`
|
||||
- **macOS**: CoreAudio (built-in)
|
||||
- **Windows**: WASAPI (built-in)
|
||||
|
||||
#### FFmpeg
|
||||
**Version Required**: FFmpeg 8.x
|
||||
|
||||
Required for video encoding/decoding. Note that many distribution repositories may have older versions.
|
||||
|
||||
- **Ubuntu/Debian**: Use PPA for FFmpeg 8 (see [Ubuntu/Debian instructions](#ubuntudebian))
|
||||
- **Arch**: `ffmpeg` (usually up-to-date)
|
||||
- **Fedora**: `ffmpeg ffmpeg-devel` (check version with `ffmpeg -version`)
|
||||
- **macOS**: `brew install ffmpeg` (Homebrew usually has latest)
|
||||
- **Windows**: Download FFmpeg 8 from https://ffmpeg.org/download.html
|
||||
|
||||
#### Build Tools
|
||||
- **Linux**: `build-essential` (Ubuntu), `base-devel` (Arch)
|
||||
- **macOS**: Xcode Command Line Tools (`xcode-select --install`)
|
||||
- **Windows**: Visual Studio with C++ tools or MinGW
|
||||
|
||||
#### pkg-config
|
||||
Required for finding system libraries.
|
||||
|
||||
- **Linux**: Usually included with build tools
|
||||
- **macOS**: `brew install pkg-config`
|
||||
- **Windows**: Included with MSYS2/MinGW, or use vcpkg
|
||||
|
||||
### Optional Dependencies
|
||||
|
||||
#### GPU Drivers
|
||||
Vello requires a GPU with Vulkan (Linux/Windows) or Metal (macOS) support:
|
||||
|
||||
- **Linux Vulkan**:
|
||||
- NVIDIA: Install proprietary drivers
|
||||
- AMD: `mesa-vulkan-drivers` (Ubuntu) or `vulkan-radeon` (Arch)
|
||||
- Intel: `mesa-vulkan-drivers` (Ubuntu) or `vulkan-intel` (Arch)
|
||||
|
||||
- **macOS Metal**: Built-in (macOS 10.13+)
|
||||
|
||||
- **Windows Vulkan**:
|
||||
- Usually included with GPU drivers
|
||||
- Manual install: https://vulkan.lunarg.com/
|
||||
|
||||
## Build Configurations
|
||||
|
||||
### Release Build (Optimized)
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
- Optimizations: Level 3
|
||||
- LTO: Enabled
|
||||
- Debug info: None
|
||||
- Build time: Slower (~5-10 minutes)
|
||||
- Runtime: Fast
|
||||
|
||||
Binary location: `target/release/lightningbeam-editor`
|
||||
|
||||
### Debug Build (Default)
|
||||
|
||||
```bash
|
||||
cargo build
|
||||
```
|
||||
|
||||
- Optimizations: Level 1 (Level 2 for audio code)
|
||||
- LTO: Disabled
|
||||
- Debug info: Full
|
||||
- Build time: Faster (~2-5 minutes)
|
||||
- Runtime: Slower (but audio is still optimized)
|
||||
|
||||
Binary location: `target/debug/lightningbeam-editor`
|
||||
|
||||
**Note**: Audio code is always compiled with `opt-level = 2` even in debug builds to meet real-time deadlines. This is configured in `lightningbeam-ui/Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[profile.dev.package.daw-backend]
|
||||
opt-level = 2
|
||||
```
|
||||
|
||||
### Check Without Building
|
||||
|
||||
Quickly check for compilation errors without producing binaries:
|
||||
|
||||
```bash
|
||||
cargo check
|
||||
```
|
||||
|
||||
Useful for rapid feedback during development.
|
||||
|
||||
### Build Specific Package
|
||||
|
||||
```bash
|
||||
# Check only the audio backend
|
||||
cargo check -p daw-backend
|
||||
|
||||
# Build only the core library
|
||||
cargo build -p lightningbeam-core
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Submodule / CMake Issues
|
||||
|
||||
#### "does not contain a CMakeLists.txt file" (RTNeural or math_approx)
|
||||
|
||||
**Cause**: The `vendor/NeuralAudio` submodule has its own nested submodules (`deps/RTNeural`, `deps/math_approx`) that weren't initialized. A plain `git submodule update --init` only initializes top-level submodules.
|
||||
|
||||
**Solution**: Use `--recursive` to initialize all nested submodules:
|
||||
```bash
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
Or, if the top-level submodule is already checked out:
|
||||
```bash
|
||||
cd vendor/NeuralAudio
|
||||
git submodule update --init
|
||||
```
|
||||
|
||||
### Audio Issues
|
||||
|
||||
#### "ALSA lib cannot find card" or similar errors
|
||||
|
||||
**Solution**: Install ALSA development files:
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt install libasound2-dev
|
||||
|
||||
# Arch
|
||||
sudo pacman -S alsa-lib
|
||||
```
|
||||
|
||||
#### Audio dropouts or crackling
|
||||
|
||||
**Symptoms**: Console shows "Audio overrun" or timing warnings.
|
||||
|
||||
**Solutions**:
|
||||
1. Increase buffer size in `daw-backend/src/lib.rs` (default: 256 frames)
|
||||
2. Enable audio debug logging:
|
||||
```bash
|
||||
DAW_AUDIO_DEBUG=1 cargo run
|
||||
```
|
||||
3. Make sure audio code is optimized (check `Cargo.toml` profile settings)
|
||||
4. Close other audio applications
|
||||
|
||||
#### "PulseAudio" or "JACK" errors in container
|
||||
|
||||
**Note**: This is expected in containerized environments without audio support. These errors don't occur on native systems.
|
||||
|
||||
### FFmpeg Issues
|
||||
|
||||
#### "Could not find FFmpeg libraries" or linking errors
|
||||
|
||||
**Version Check First**:
|
||||
```bash
|
||||
ffmpeg -version
|
||||
# Should show version 8.x
|
||||
```
|
||||
|
||||
**Linux**:
|
||||
```bash
|
||||
# Ubuntu/Debian - requires FFmpeg 8 from PPA
|
||||
sudo add-apt-repository ppa:ubuntuhandbook1/ffmpeg7
|
||||
sudo apt update
|
||||
sudo apt install libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libswresample-dev
|
||||
|
||||
# Arch (usually has latest)
|
||||
sudo pacman -S ffmpeg
|
||||
|
||||
# Check installation
|
||||
pkg-config --modversion libavcodec
|
||||
# Should show 61.x or higher (FFmpeg 8)
|
||||
```
|
||||
|
||||
If the PPA doesn't work or doesn't have FFmpeg 8, you may need to compile from source:
|
||||
```bash
|
||||
# Download and compile FFmpeg 8
|
||||
wget https://ffmpeg.org/releases/ffmpeg-8.0.tar.xz
|
||||
tar xf ffmpeg-8.0.tar.xz
|
||||
cd ffmpeg-8.0
|
||||
./configure --enable-shared --disable-static
|
||||
make -j$(nproc)
|
||||
sudo make install
|
||||
sudo ldconfig
|
||||
```
|
||||
|
||||
**macOS**:
|
||||
```bash
|
||||
brew install ffmpeg
|
||||
export PKG_CONFIG_PATH="/opt/homebrew/opt/ffmpeg/lib/pkgconfig:$PKG_CONFIG_PATH"
|
||||
```
|
||||
|
||||
**Windows**:
|
||||
Set environment variables:
|
||||
```cmd
|
||||
set FFMPEG_DIR=C:\path\to\ffmpeg
|
||||
set PKG_CONFIG_PATH=C:\path\to\ffmpeg\lib\pkgconfig
|
||||
```
|
||||
|
||||
#### "Unsupported codec" or video not playing
|
||||
|
||||
Make sure FFmpeg was compiled with the necessary codecs:
|
||||
```bash
|
||||
ffmpeg -codecs | grep h264 # Check for H.264
|
||||
ffmpeg -codecs | grep vp9 # Check for VP9
|
||||
```
|
||||
|
||||
### GPU/Rendering Issues
|
||||
|
||||
#### Black screen or no rendering
|
||||
|
||||
**Check GPU support**:
|
||||
```bash
|
||||
# Linux - check Vulkan
|
||||
vulkaninfo | grep deviceName
|
||||
|
||||
# macOS - Metal is built-in on 10.13+
|
||||
system_profiler SPDisplaysDataType
|
||||
```
|
||||
|
||||
**Solutions**:
|
||||
1. Update GPU drivers
|
||||
2. Install Vulkan runtime (Linux)
|
||||
3. Check console for wgpu errors
|
||||
|
||||
#### "No suitable GPU adapter found"
|
||||
|
||||
This usually means missing Vulkan/Metal support.
|
||||
|
||||
**Linux**: Install Vulkan drivers (see [Optional Dependencies](#optional-dependencies))
|
||||
|
||||
**macOS**: Requires macOS 10.13+ (Metal support)
|
||||
|
||||
**Windows**: Update GPU drivers
|
||||
|
||||
### Build Performance
|
||||
|
||||
#### Slow compilation times
|
||||
|
||||
**Solutions**:
|
||||
1. Use `cargo check` instead of `cargo build` during development
|
||||
2. Enable incremental compilation (enabled by default)
|
||||
3. Use `mold` linker (Linux):
|
||||
```bash
|
||||
# Install mold
|
||||
sudo apt install mold # Ubuntu 22.04+
|
||||
|
||||
# Use mold
|
||||
mold -run cargo build
|
||||
```
|
||||
4. Increase parallel jobs:
|
||||
```bash
|
||||
cargo build -j 8 # Use 8 parallel jobs
|
||||
```
|
||||
|
||||
#### Out of memory during compilation
|
||||
|
||||
**Solution**: Reduce parallel jobs:
|
||||
```bash
|
||||
cargo build -j 2 # Use only 2 parallel jobs
|
||||
```
|
||||
|
||||
### Linker Errors
|
||||
|
||||
#### "undefined reference to..." or "cannot find -l..."
|
||||
|
||||
**Cause**: Missing system libraries.
|
||||
|
||||
**Solution**: Install all dependencies listed in [Platform-Specific Instructions](#platform-specific-instructions).
|
||||
|
||||
#### Windows: "LNK1181: cannot open input file"
|
||||
|
||||
**Cause**: FFmpeg libraries not found.
|
||||
|
||||
**Solution**:
|
||||
1. Download FFmpeg shared build
|
||||
2. Set `FFMPEG_DIR` environment variable
|
||||
3. Add FFmpeg bin directory to PATH
|
||||
|
||||
## Development Builds
|
||||
|
||||
### Enable Audio Debug Logging
|
||||
|
||||
```bash
|
||||
DAW_AUDIO_DEBUG=1 cargo run
|
||||
```
|
||||
|
||||
Output includes:
|
||||
- Buffer sizes
|
||||
- Average/worst-case processing times
|
||||
- Audio overruns/underruns
|
||||
- Playhead position updates
|
||||
|
||||
### Disable Optimizations for Specific Crates
|
||||
|
||||
Edit `lightningbeam-ui/Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[profile.dev.package.my-crate]
|
||||
opt-level = 0 # No optimizations
|
||||
```
|
||||
|
||||
**Warning**: Do not disable optimizations for `daw-backend` or audio-related crates, as this will cause audio dropouts.
|
||||
|
||||
### Build with Specific Features
|
||||
|
||||
```bash
|
||||
# Build with all features
|
||||
cargo build --all-features
|
||||
|
||||
# Build with no default features
|
||||
cargo build --no-default-features
|
||||
```
|
||||
|
||||
### Clean Build
|
||||
|
||||
Remove all build artifacts and start fresh:
|
||||
|
||||
```bash
|
||||
cargo clean
|
||||
cargo build
|
||||
```
|
||||
|
||||
Useful when dependencies change or build cache becomes corrupted.
|
||||
|
||||
### Cross-Compilation
|
||||
|
||||
Cross-compiling is not currently documented but should be possible using `cross`:
|
||||
|
||||
```bash
|
||||
cargo install cross
|
||||
cross build --target x86_64-unknown-linux-gnu
|
||||
```
|
||||
|
||||
See [cross documentation](https://github.com/cross-rs/cross) for details.
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
cargo test
|
||||
|
||||
# Run tests for specific package
|
||||
cargo test -p lightningbeam-core
|
||||
|
||||
# Run with output
|
||||
cargo test -- --nocapture
|
||||
|
||||
# Run specific test
|
||||
cargo test test_name
|
||||
```
|
||||
|
||||
## Building Documentation
|
||||
|
||||
Generate and open Rust API documentation:
|
||||
|
||||
```bash
|
||||
cargo doc --open
|
||||
```
|
||||
|
||||
This generates HTML documentation from code comments and opens it in your browser.
|
||||
|
||||
## Next Steps
|
||||
|
||||
After building successfully:
|
||||
|
||||
- See [CONTRIBUTING.md](../CONTRIBUTING.md) for development workflow
|
||||
- See [ARCHITECTURE.md](../ARCHITECTURE.md) for system architecture
|
||||
- See [docs/AUDIO_SYSTEM.md](AUDIO_SYSTEM.md) for audio engine details
|
||||
- See [docs/UI_SYSTEM.md](UI_SYSTEM.md) for UI development
|
||||
|
|
@ -1,812 +0,0 @@
|
|||
# GPU Rendering Architecture
|
||||
|
||||
This document describes Lightningbeam's GPU rendering pipeline, including Vello integration for vector graphics, custom WGSL shaders for waveforms, and wgpu integration patterns.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Rendering Pipeline](#rendering-pipeline)
|
||||
- [Vello Integration](#vello-integration)
|
||||
- [Waveform Rendering](#waveform-rendering)
|
||||
- [WGSL Shaders](#wgsl-shaders)
|
||||
- [Uniform Buffer Alignment](#uniform-buffer-alignment)
|
||||
- [Custom wgpu Integration](#custom-wgpu-integration)
|
||||
- [Performance Optimization](#performance-optimization)
|
||||
- [Debugging Rendering Issues](#debugging-rendering-issues)
|
||||
|
||||
## Overview
|
||||
|
||||
Lightningbeam uses GPU-accelerated rendering for high-performance 2D graphics:
|
||||
|
||||
- **Vello**: Compute shader-based 2D vector rendering
|
||||
- **wgpu 27**: Cross-platform GPU API (Vulkan, Metal, D3D12)
|
||||
- **egui-wgpu**: Integration layer between egui and wgpu
|
||||
- **Custom WGSL shaders**: For specialized rendering (waveforms, effects)
|
||||
|
||||
### Supported Backends
|
||||
|
||||
- **Linux**: Vulkan (primary), OpenGL (fallback)
|
||||
- **macOS**: Metal
|
||||
- **Windows**: Vulkan, DirectX 12
|
||||
|
||||
## Rendering Pipeline
|
||||
|
||||
### High-Level Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Application Frame │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 1. egui Layout Phase │
|
||||
│ - Build UI tree │
|
||||
│ - Collect paint primitives │
|
||||
│ - Register wgpu callbacks │
|
||||
│ │
|
||||
│ 2. Custom GPU Rendering (via egui_wgpu::Callback) │
|
||||
│ ┌────────────────────────────────────────────────┐ │
|
||||
│ │ prepare(): │ │
|
||||
│ │ - Build Vello scene from document │ │
|
||||
│ │ - Update uniform buffers │ │
|
||||
│ │ - Generate waveform mipmaps (if needed) │ │
|
||||
│ └────────────────────────────────────────────────┘ │
|
||||
│ ┌────────────────────────────────────────────────┐ │
|
||||
│ │ paint(): │ │
|
||||
│ │ - Render Vello scene to texture │ │
|
||||
│ │ - Render waveforms │ │
|
||||
│ │ - Composite layers │ │
|
||||
│ └────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 3. egui Paint │
|
||||
│ - Render egui UI elements │
|
||||
│ - Composite with custom rendering │
|
||||
│ │
|
||||
│ 4. Present to Screen │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Render Pass Structure
|
||||
|
||||
```
|
||||
Main Render Pass
|
||||
├─> Clear screen
|
||||
├─> Custom wgpu callbacks (Stage pane, etc.)
|
||||
│ ├─> Vello vector rendering
|
||||
│ └─> Waveform rendering
|
||||
└─> egui UI rendering (text, widgets, overlays)
|
||||
```
|
||||
|
||||
## Vello Integration
|
||||
|
||||
Vello is a GPU-accelerated 2D rendering engine that uses compute shaders for high-performance vector graphics.
|
||||
|
||||
### Vello Architecture
|
||||
|
||||
```
|
||||
Document Shapes
|
||||
↓
|
||||
Convert to kurbo paths
|
||||
↓
|
||||
Build Vello Scene
|
||||
↓
|
||||
Vello Renderer (compute shaders)
|
||||
↓
|
||||
Render to GPU texture
|
||||
↓
|
||||
Composite with UI
|
||||
```
|
||||
|
||||
### Building a Vello Scene
|
||||
|
||||
```rust
|
||||
use vello::{Scene, SceneBuilder, kurbo::{Affine, BezPath}};
|
||||
use peniko::{Color, Fill, Brush};
|
||||
|
||||
fn build_vello_scene(document: &Document) -> Scene {
|
||||
let mut scene = Scene::new();
|
||||
let mut builder = SceneBuilder::for_scene(&mut scene);
|
||||
|
||||
for layer in &document.layers {
|
||||
if let Layer::VectorLayer { clips, visible, .. } = layer {
|
||||
if !visible {
|
||||
continue;
|
||||
}
|
||||
|
||||
for clip in clips {
|
||||
for shape_instance in &clip.shapes {
|
||||
// Get transform for this shape
|
||||
let transform = shape_instance.compute_world_transform();
|
||||
let affine = to_vello_affine(transform);
|
||||
|
||||
// Convert shape to kurbo path
|
||||
let path = shape_to_kurbo_path(&shape_instance.shape);
|
||||
|
||||
// Fill
|
||||
if let Some(fill_color) = shape_instance.shape.fill {
|
||||
let brush = Brush::Solid(to_peniko_color(fill_color));
|
||||
builder.fill(
|
||||
Fill::NonZero,
|
||||
affine,
|
||||
&brush,
|
||||
None,
|
||||
&path,
|
||||
);
|
||||
}
|
||||
|
||||
// Stroke
|
||||
if let Some(stroke) = &shape_instance.shape.stroke {
|
||||
let brush = Brush::Solid(to_peniko_color(stroke.color));
|
||||
let stroke_style = vello::kurbo::Stroke::new(stroke.width);
|
||||
builder.stroke(
|
||||
&stroke_style,
|
||||
affine,
|
||||
&brush,
|
||||
None,
|
||||
&path,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scene
|
||||
}
|
||||
```
|
||||
|
||||
### Shape to Kurbo Path Conversion
|
||||
|
||||
```rust
|
||||
use kurbo::{BezPath, PathEl, Point};
|
||||
|
||||
fn shape_to_kurbo_path(shape: &Shape) -> BezPath {
|
||||
let mut path = BezPath::new();
|
||||
|
||||
if shape.curves.is_empty() {
|
||||
return path;
|
||||
}
|
||||
|
||||
// Start at first point
|
||||
path.move_to(Point::new(
|
||||
shape.curves[0].start.x as f64,
|
||||
shape.curves[0].start.y as f64,
|
||||
));
|
||||
|
||||
// Add curves
|
||||
for curve in &shape.curves {
|
||||
match curve.curve_type {
|
||||
CurveType::Linear => {
|
||||
path.line_to(Point::new(
|
||||
curve.end.x as f64,
|
||||
curve.end.y as f64,
|
||||
));
|
||||
}
|
||||
CurveType::Quadratic => {
|
||||
path.quad_to(
|
||||
Point::new(curve.control1.x as f64, curve.control1.y as f64),
|
||||
Point::new(curve.end.x as f64, curve.end.y as f64),
|
||||
);
|
||||
}
|
||||
CurveType::Cubic => {
|
||||
path.curve_to(
|
||||
Point::new(curve.control1.x as f64, curve.control1.y as f64),
|
||||
Point::new(curve.control2.x as f64, curve.control2.y as f64),
|
||||
Point::new(curve.end.x as f64, curve.end.y as f64),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close path if needed
|
||||
if shape.closed {
|
||||
path.close_path();
|
||||
}
|
||||
|
||||
path
|
||||
}
|
||||
```
|
||||
|
||||
### Vello Renderer Setup
|
||||
|
||||
```rust
|
||||
use vello::{Renderer, RendererOptions, RenderParams};
|
||||
use wgpu;
|
||||
|
||||
pub struct VelloRenderer {
|
||||
renderer: Renderer,
|
||||
surface_format: wgpu::TextureFormat,
|
||||
}
|
||||
|
||||
impl VelloRenderer {
|
||||
pub fn new(device: &wgpu::Device, surface_format: wgpu::TextureFormat) -> Self {
|
||||
let renderer = Renderer::new(
|
||||
device,
|
||||
RendererOptions {
|
||||
surface_format: Some(surface_format),
|
||||
use_cpu: false,
|
||||
antialiasing_support: vello::AaSupport::all(),
|
||||
num_init_threads: None,
|
||||
},
|
||||
).expect("Failed to create Vello renderer");
|
||||
|
||||
Self {
|
||||
renderer,
|
||||
surface_format,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
scene: &Scene,
|
||||
texture: &wgpu::TextureView,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) {
|
||||
let params = RenderParams {
|
||||
base_color: peniko::Color::TRANSPARENT,
|
||||
width,
|
||||
height,
|
||||
antialiasing_method: vello::AaConfig::Msaa16,
|
||||
};
|
||||
|
||||
self.renderer
|
||||
.render_to_texture(device, queue, scene, texture, ¶ms)
|
||||
.expect("Failed to render Vello scene");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Waveform Rendering
|
||||
|
||||
Audio waveforms are rendered on the GPU using custom WGSL shaders with mipmapping for efficient zooming.
|
||||
|
||||
### Waveform GPU Resources
|
||||
|
||||
```rust
|
||||
pub struct WaveformGPU {
|
||||
// Waveform data texture (min/max per sample)
|
||||
texture: wgpu::Texture,
|
||||
texture_view: wgpu::TextureView,
|
||||
|
||||
// Mipmap chain for level-of-detail
|
||||
mip_levels: Vec<wgpu::TextureView>,
|
||||
|
||||
// Render pipeline
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
|
||||
// Uniform buffer for view parameters
|
||||
uniform_buffer: wgpu::Buffer,
|
||||
bind_group: wgpu::BindGroup,
|
||||
}
|
||||
```
|
||||
|
||||
### Waveform Texture Format
|
||||
|
||||
Each texel stores min/max amplitude for a sample range:
|
||||
|
||||
```
|
||||
Texture Format: Rgba16Float (4 channels, 16-bit float each)
|
||||
- R channel: Left channel minimum amplitude in range [-1, 1]
|
||||
- G channel: Left channel maximum amplitude in range [-1, 1]
|
||||
- B channel: Right channel minimum amplitude in range [-1, 1]
|
||||
- A channel: Right channel maximum amplitude in range [-1, 1]
|
||||
|
||||
Mip level 0: Per-sample min/max (1x)
|
||||
Mip level 1: Per-4-sample min/max (1/4x)
|
||||
Mip level 2: Per-16-sample min/max (1/16x)
|
||||
Mip level 3: Per-64-sample min/max (1/64x)
|
||||
...
|
||||
|
||||
Each mip level reduces by 4x, not 2x, for efficient zooming.
|
||||
```
|
||||
|
||||
### Generating Waveform Texture
|
||||
|
||||
```rust
|
||||
fn generate_waveform_texture(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
audio_samples: &[f32],
|
||||
) -> wgpu::Texture {
|
||||
// Calculate mip levels
|
||||
let width = audio_samples.len() as u32;
|
||||
let mip_levels = (width as f32).log2().floor() as u32 + 1;
|
||||
|
||||
// Create texture
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Waveform Texture"),
|
||||
size: wgpu::Extent3d {
|
||||
width,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: mip_levels,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D1,
|
||||
format: wgpu::TextureFormat::Rg32Float,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
|
||||
// Upload base level (per-sample min/max)
|
||||
let mut data: Vec<f32> = Vec::with_capacity(width as usize * 2);
|
||||
for &sample in audio_samples {
|
||||
data.push(sample); // min
|
||||
data.push(sample); // max
|
||||
}
|
||||
|
||||
queue.write_texture(
|
||||
wgpu::ImageCopyTexture {
|
||||
texture: &texture,
|
||||
mip_level: 0,
|
||||
origin: wgpu::Origin3d::ZERO,
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
bytemuck::cast_slice(&data),
|
||||
wgpu::ImageDataLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(width * 8), // 2 floats * 4 bytes
|
||||
rows_per_image: None,
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
|
||||
texture
|
||||
}
|
||||
```
|
||||
|
||||
### Mipmap Generation (Compute Shader)
|
||||
|
||||
```rust
|
||||
// Compute shader generates mipmaps by taking min/max of 4 parent samples
|
||||
// Each mip level is 4x smaller than the previous level
|
||||
fn generate_mipmaps(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
texture: &wgpu::Texture,
|
||||
base_width: u32,
|
||||
base_height: u32,
|
||||
mip_count: u32,
|
||||
base_sample_count: u32,
|
||||
) -> Vec<wgpu::CommandBuffer> {
|
||||
if mip_count <= 1 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
|
||||
let mut src_width = base_width;
|
||||
let mut src_height = base_height;
|
||||
let mut src_sample_count = base_sample_count;
|
||||
|
||||
for level in 1..mip_count {
|
||||
// Dimensions halve (2x2 texels -> 1 texel)
|
||||
let dst_width = (src_width / 2).max(1);
|
||||
let dst_height = (src_height / 2).max(1);
|
||||
// But sample count reduces by 4x (4 samples -> 1)
|
||||
let dst_sample_count = (src_sample_count + 3) / 4;
|
||||
|
||||
let src_view = texture.create_view(&wgpu::TextureViewDescriptor {
|
||||
base_mip_level: level - 1,
|
||||
mip_level_count: Some(1),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let dst_view = texture.create_view(&wgpu::TextureViewDescriptor {
|
||||
base_mip_level: level,
|
||||
mip_level_count: Some(1),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let params = MipgenParams {
|
||||
src_width,
|
||||
dst_width,
|
||||
src_sample_count,
|
||||
_pad: 0,
|
||||
};
|
||||
let params_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
contents: bytemuck::cast_slice(&[params]),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
|
||||
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
layout: &mipgen_bind_group_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&src_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(&dst_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: params_buffer.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Dispatch compute shader
|
||||
let total_dst_texels = dst_width * dst_height;
|
||||
let workgroup_count = (total_dst_texels + 63) / 64;
|
||||
|
||||
let mut pass = encoder.begin_compute_pass(&Default::default());
|
||||
pass.set_pipeline(&mipgen_pipeline);
|
||||
pass.set_bind_group(0, &bind_group, &[]);
|
||||
pass.dispatch_workgroups(workgroup_count, 1, 1);
|
||||
drop(pass);
|
||||
|
||||
src_width = dst_width;
|
||||
src_height = dst_height;
|
||||
src_sample_count = dst_sample_count;
|
||||
}
|
||||
|
||||
vec![encoder.finish()]
|
||||
}
|
||||
```
|
||||
|
||||
## WGSL Shaders
|
||||
|
||||
### Waveform Render Shader
|
||||
|
||||
```wgsl
|
||||
// waveform.wgsl
|
||||
|
||||
struct WaveformParams {
|
||||
view_matrix: mat4x4<f32>, // 64 bytes
|
||||
viewport_size: vec2<f32>, // 8 bytes
|
||||
zoom: f32, // 4 bytes
|
||||
_pad1: f32, // 4 bytes (padding)
|
||||
tint_color: vec4<f32>, // 16 bytes (requires 16-byte alignment)
|
||||
// Total: 96 bytes
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var<uniform> params: WaveformParams;
|
||||
@group(0) @binding(1) var waveform_texture: texture_1d<f32>;
|
||||
@group(0) @binding(2) var waveform_sampler: sampler;
|
||||
|
||||
struct VertexOutput {
|
||||
@builtin(position) position: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
}
|
||||
|
||||
@vertex
|
||||
fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
|
||||
// Generate fullscreen quad
|
||||
var positions = array<vec2<f32>, 6>(
|
||||
vec2(-1.0, -1.0),
|
||||
vec2( 1.0, -1.0),
|
||||
vec2( 1.0, 1.0),
|
||||
vec2(-1.0, -1.0),
|
||||
vec2( 1.0, 1.0),
|
||||
vec2(-1.0, 1.0),
|
||||
);
|
||||
|
||||
var output: VertexOutput;
|
||||
output.position = vec4(positions[vertex_index], 0.0, 1.0);
|
||||
output.uv = (positions[vertex_index] + 1.0) * 0.5;
|
||||
return output;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
|
||||
// Sample waveform texture
|
||||
let sample_pos = input.uv.x;
|
||||
let waveform = textureSample(waveform_texture, waveform_sampler, sample_pos);
|
||||
|
||||
// waveform.r = min amplitude, waveform.g = max amplitude
|
||||
let min_amp = waveform.r;
|
||||
let max_amp = waveform.g;
|
||||
|
||||
// Map amplitude to vertical position
|
||||
let center_y = 0.5;
|
||||
let min_y = center_y - min_amp * 0.5;
|
||||
let max_y = center_y + max_amp * 0.5;
|
||||
|
||||
// Check if pixel is within waveform range
|
||||
if (input.uv.y >= min_y && input.uv.y <= max_y) {
|
||||
return params.tint_color;
|
||||
} else {
|
||||
return vec4(0.0, 0.0, 0.0, 0.0); // Transparent
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Mipmap Generation Shader
|
||||
|
||||
```wgsl
|
||||
// waveform_mipgen.wgsl
|
||||
|
||||
struct MipgenParams {
|
||||
src_width: u32,
|
||||
dst_width: u32,
|
||||
src_sample_count: u32,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var src_texture: texture_2d<f32>;
|
||||
@group(0) @binding(1) var dst_texture: texture_storage_2d<rgba16float, write>;
|
||||
@group(0) @binding(2) var<uniform> params: MipgenParams;
|
||||
|
||||
@compute @workgroup_size(64)
|
||||
fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {
|
||||
let linear_index = global_id.x;
|
||||
|
||||
// Convert linear index to 2D coordinates
|
||||
let dst_x = linear_index % params.dst_width;
|
||||
let dst_y = linear_index / params.dst_width;
|
||||
|
||||
// Each dst texel corresponds to 4 src samples (not 4 src texels)
|
||||
// But 2D texture layout halves in each dimension
|
||||
let src_x = dst_x * 2u;
|
||||
let src_y = dst_y * 2u;
|
||||
|
||||
// Sample 4 texels from parent level (2x2 block)
|
||||
let s00 = textureLoad(src_texture, vec2<i32>(i32(src_x), i32(src_y)), 0);
|
||||
let s10 = textureLoad(src_texture, vec2<i32>(i32(src_x + 1u), i32(src_y)), 0);
|
||||
let s01 = textureLoad(src_texture, vec2<i32>(i32(src_x), i32(src_y + 1u)), 0);
|
||||
let s11 = textureLoad(src_texture, vec2<i32>(i32(src_x + 1u), i32(src_y + 1u)), 0);
|
||||
|
||||
// Compute min/max across all 4 samples for each channel
|
||||
let left_min = min(min(s00.r, s10.r), min(s01.r, s11.r));
|
||||
let left_max = max(max(s00.g, s10.g), max(s01.g, s11.g));
|
||||
let right_min = min(min(s00.b, s10.b), min(s01.b, s11.b));
|
||||
let right_max = max(max(s00.a, s10.a), max(s01.a, s11.a));
|
||||
|
||||
// Write to destination mip level
|
||||
textureStore(dst_texture, vec2<i32>(i32(dst_x), i32(dst_y)),
|
||||
vec4(left_min, left_max, right_min, right_max));
|
||||
}
|
||||
```
|
||||
|
||||
## Uniform Buffer Alignment
|
||||
|
||||
WGSL has strict alignment requirements. The most common issue is `vec4<f32>` requiring 16-byte alignment.
|
||||
|
||||
### Alignment Rules
|
||||
|
||||
```rust
|
||||
// ❌ Bad: tint_color not aligned to 16 bytes
|
||||
#[repr(C)]
|
||||
struct WaveformParams {
|
||||
view_matrix: [f32; 16], // 64 bytes (offset 0)
|
||||
viewport_size: [f32; 2], // 8 bytes (offset 64)
|
||||
zoom: f32, // 4 bytes (offset 72)
|
||||
tint_color: [f32; 4], // 16 bytes (offset 76) ❌ Not 16-byte aligned!
|
||||
}
|
||||
|
||||
// ✅ Good: explicit padding for alignment
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct WaveformParams {
|
||||
view_matrix: [f32; 16], // 64 bytes (offset 0)
|
||||
viewport_size: [f32; 2], // 8 bytes (offset 64)
|
||||
zoom: f32, // 4 bytes (offset 72)
|
||||
_pad1: f32, // 4 bytes (offset 76) - padding
|
||||
tint_color: [f32; 4], // 16 bytes (offset 80) ✅ 16-byte aligned!
|
||||
}
|
||||
// Total size: 96 bytes
|
||||
```
|
||||
|
||||
### Common Alignment Requirements
|
||||
|
||||
| WGSL Type | Size | Alignment |
|
||||
|-----------|------|-----------|
|
||||
| `f32` | 4 bytes | 4 bytes |
|
||||
| `vec2<f32>` | 8 bytes | 8 bytes |
|
||||
| `vec3<f32>` | 12 bytes | 16 bytes ⚠️ |
|
||||
| `vec4<f32>` | 16 bytes | 16 bytes |
|
||||
| `mat4x4<f32>` | 64 bytes | 16 bytes |
|
||||
| Struct | Sum of members | 16 bytes (uniform buffers) |
|
||||
|
||||
### Debug Alignment Issues
|
||||
|
||||
```rust
|
||||
// Use static_assertions to catch alignment bugs at compile time
|
||||
use static_assertions::const_assert_eq;
|
||||
|
||||
const_assert_eq!(std::mem::size_of::<WaveformParams>(), 96);
|
||||
const_assert_eq!(std::mem::align_of::<WaveformParams>(), 16);
|
||||
|
||||
// Runtime validation
|
||||
fn validate_uniform_buffer<T: bytemuck::Pod>(data: &T) {
|
||||
let size = std::mem::size_of::<T>();
|
||||
let align = std::mem::align_of::<T>();
|
||||
|
||||
assert!(size % 16 == 0, "Uniform buffer size must be multiple of 16");
|
||||
assert!(align >= 16, "Uniform buffer must be 16-byte aligned");
|
||||
}
|
||||
```
|
||||
|
||||
## Custom wgpu Integration
|
||||
|
||||
### egui-wgpu Callback Pattern
|
||||
|
||||
```rust
|
||||
use egui_wgpu::CallbackTrait;
|
||||
|
||||
struct CustomRenderCallback {
|
||||
// Data needed for rendering
|
||||
scene: Scene,
|
||||
params: UniformData,
|
||||
}
|
||||
|
||||
impl CallbackTrait for CustomRenderCallback {
|
||||
fn prepare(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
_screen_descriptor: &egui_wgpu::ScreenDescriptor,
|
||||
_encoder: &mut wgpu::CommandEncoder,
|
||||
resources: &mut egui_wgpu::CallbackResources,
|
||||
) -> Vec<wgpu::CommandBuffer> {
|
||||
// Update GPU resources (buffers, textures, etc.)
|
||||
// This runs before rendering
|
||||
|
||||
// Get or create renderer
|
||||
let renderer: &mut MyRenderer = resources.get_or_insert_with(|| {
|
||||
MyRenderer::new(device)
|
||||
});
|
||||
|
||||
// Update uniform buffer
|
||||
queue.write_buffer(&renderer.uniform_buffer, 0, bytemuck::bytes_of(&self.params));
|
||||
|
||||
vec![] // Return additional command buffers if needed
|
||||
}
|
||||
|
||||
fn paint<'a>(
|
||||
&'a self,
|
||||
_info: egui::PaintCallbackInfo,
|
||||
render_pass: &mut wgpu::RenderPass<'a>,
|
||||
resources: &'a egui_wgpu::CallbackResources,
|
||||
) {
|
||||
// Actual rendering
|
||||
let renderer: &MyRenderer = resources.get().unwrap();
|
||||
|
||||
render_pass.set_pipeline(&renderer.pipeline);
|
||||
render_pass.set_bind_group(0, &renderer.bind_group, &[]);
|
||||
render_pass.draw(0..6, 0..1); // Draw fullscreen quad
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Registering Callback in egui
|
||||
|
||||
```rust
|
||||
// In Stage pane render method
|
||||
let callback = egui_wgpu::Callback::new_paint_callback(
|
||||
rect,
|
||||
CustomRenderCallback {
|
||||
scene: self.build_scene(document),
|
||||
params: self.compute_params(),
|
||||
},
|
||||
);
|
||||
|
||||
ui.painter().add(callback);
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Minimize GPU↔CPU Transfer
|
||||
|
||||
```rust
|
||||
// ❌ Bad: Update uniform buffer every frame
|
||||
for frame in frames {
|
||||
queue.write_buffer(&uniform_buffer, 0, ¶ms);
|
||||
render();
|
||||
}
|
||||
|
||||
// ✅ Good: Only update when changed
|
||||
if params_changed {
|
||||
queue.write_buffer(&uniform_buffer, 0, ¶ms);
|
||||
}
|
||||
render();
|
||||
```
|
||||
|
||||
### Reuse GPU Resources
|
||||
|
||||
```rust
|
||||
// ✅ Good: Reuse textures and buffers
|
||||
struct WaveformCache {
|
||||
textures: HashMap<Uuid, wgpu::Texture>,
|
||||
}
|
||||
|
||||
impl WaveformCache {
|
||||
fn get_or_create(&mut self, clip_id: Uuid, audio_data: &[f32]) -> &wgpu::Texture {
|
||||
self.textures.entry(clip_id).or_insert_with(|| {
|
||||
generate_waveform_texture(device, queue, audio_data)
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Batch Draw Calls
|
||||
|
||||
```rust
|
||||
// ❌ Bad: One draw call per shape
|
||||
for shape in shapes {
|
||||
render_pass.set_bind_group(0, &shape.bind_group, &[]);
|
||||
render_pass.draw(0..shape.vertex_count, 0..1);
|
||||
}
|
||||
|
||||
// ✅ Good: Batch into single draw call
|
||||
let batched_vertices = batch_shapes(shapes);
|
||||
render_pass.set_bind_group(0, &batched_bind_group, &[]);
|
||||
render_pass.draw(0..batched_vertices.len(), 0..1);
|
||||
```
|
||||
|
||||
### Use Mipmaps for Zooming
|
||||
|
||||
```rust
|
||||
// ✅ Good: Select appropriate mip level based on zoom
|
||||
let mip_level = ((1.0 / zoom).log2().floor() as u32).min(max_mip_level);
|
||||
let texture_view = texture.create_view(&wgpu::TextureViewDescriptor {
|
||||
base_mip_level: mip_level,
|
||||
mip_level_count: Some(1),
|
||||
..Default::default()
|
||||
});
|
||||
```
|
||||
|
||||
## Debugging Rendering Issues
|
||||
|
||||
### Enable wgpu Validation
|
||||
|
||||
```rust
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
|
||||
backends: wgpu::Backends::all(),
|
||||
dx12_shader_compiler: Default::default(),
|
||||
flags: wgpu::InstanceFlags::validation(), // Enable validation
|
||||
gles_minor_version: wgpu::Gles3MinorVersion::Automatic,
|
||||
});
|
||||
```
|
||||
|
||||
### Check for Errors
|
||||
|
||||
```rust
|
||||
// Set error handler
|
||||
device.on_uncaptured_error(Box::new(|error| {
|
||||
eprintln!("wgpu error: {:?}", error);
|
||||
}));
|
||||
```
|
||||
|
||||
### Capture GPU Frame
|
||||
|
||||
**Linux** (RenderDoc):
|
||||
```bash
|
||||
renderdoccmd capture ./lightningbeam-editor
|
||||
```
|
||||
|
||||
**macOS** (Xcode):
|
||||
- Run with GPU Frame Capture enabled
|
||||
- Trigger capture with Cmd+Option+G
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### Black Screen
|
||||
- Check that vertex shader outputs correct clip-space coordinates
|
||||
- Verify texture bindings are correct
|
||||
- Check that render pipeline format matches surface format
|
||||
|
||||
#### Validation Errors
|
||||
- Check uniform buffer alignment (see [Uniform Buffer Alignment](#uniform-buffer-alignment))
|
||||
- Verify texture formats match shader expectations
|
||||
- Ensure bind groups match pipeline layout
|
||||
|
||||
#### Performance Issues
|
||||
- Use GPU profiler (RenderDoc, Xcode)
|
||||
- Check for redundant buffer uploads
|
||||
- Profile shader performance
|
||||
- Reduce draw call count via batching
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [ARCHITECTURE.md](../ARCHITECTURE.md) - Overall system architecture
|
||||
- [docs/UI_SYSTEM.md](UI_SYSTEM.md) - UI and pane integration
|
||||
- [CONTRIBUTING.md](../CONTRIBUTING.md) - Development workflow
|
||||
|
|
@ -1,848 +0,0 @@
|
|||
# UI System Architecture
|
||||
|
||||
This document describes Lightningbeam's UI architecture, including the pane system, tool system, GPU integration, and patterns for extending the UI with new features.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Pane System](#pane-system)
|
||||
- [Shared State](#shared-state)
|
||||
- [Two-Phase Dispatch](#two-phase-dispatch)
|
||||
- [ID Collision Avoidance](#id-collision-avoidance)
|
||||
- [Tool System](#tool-system)
|
||||
- [GPU Integration](#gpu-integration)
|
||||
- [Adding New Panes](#adding-new-panes)
|
||||
- [Adding New Tools](#adding-new-tools)
|
||||
- [Event Handling](#event-handling)
|
||||
- [Best Practices](#best-practices)
|
||||
|
||||
## Overview
|
||||
|
||||
Lightningbeam's UI is built with **egui**, an immediate-mode GUI framework. Unlike retained-mode frameworks (Qt, GTK), immediate-mode rebuilds the UI every frame by running code that describes what should be displayed.
|
||||
|
||||
### Key Technologies
|
||||
|
||||
- **egui 0.33.3**: Immediate-mode GUI framework
|
||||
- **eframe**: Application framework wrapping egui
|
||||
- **winit**: Cross-platform windowing
|
||||
- **Vello**: GPU-accelerated 2D vector rendering
|
||||
- **wgpu**: Low-level GPU API
|
||||
- **egui-wgpu**: Integration layer between egui and wgpu
|
||||
|
||||
### Immediate Mode Overview
|
||||
|
||||
```rust
|
||||
// Immediate mode: UI is described every frame
|
||||
fn render(&mut self, ui: &mut egui::Ui) {
|
||||
if ui.button("Click me").clicked() {
|
||||
self.counter += 1;
|
||||
}
|
||||
ui.label(format!("Count: {}", self.counter));
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- Simple mental model (just describe what you see)
|
||||
- No manual synchronization between state and UI
|
||||
- Easy to compose and reuse components
|
||||
|
||||
**Considerations**:
|
||||
- Must avoid expensive operations in render code
|
||||
- IDs needed for stateful widgets (handled automatically in most cases)
|
||||
|
||||
## Pane System
|
||||
|
||||
Lightningbeam uses a flexible pane system where the UI is composed of independent, reusable panes (Stage, Timeline, Asset Library, etc.).
|
||||
|
||||
### Pane Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Main Application │
|
||||
│ (LightningbeamApp) │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────┐ │
|
||||
│ │ Pane Tree (egui_tiles) │ │
|
||||
│ │ │ │
|
||||
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
|
||||
│ │ │ Stage │ │ Timeline │ │ Asset │ │ │
|
||||
│ │ │ Pane │ │ Pane │ │ Library │ │ │
|
||||
│ │ └──────────┘ └──────────┘ └──────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ Each pane: │ │
|
||||
│ │ - Renders its UI │ │
|
||||
│ │ - Registers actions with SharedPaneState │ │
|
||||
│ │ - Accesses shared document state │ │
|
||||
│ └────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────┐ │
|
||||
│ │ SharedPaneState │ │
|
||||
│ │ - Document │ │
|
||||
│ │ - Selected tool │ │
|
||||
│ │ - Pending actions │ │
|
||||
│ │ - Audio system │ │
|
||||
│ └────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ After all panes render: │
|
||||
│ - Execute pending actions │
|
||||
│ - Update undo/redo stacks │
|
||||
│ - Synchronize with audio engine │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### PaneInstance Enum
|
||||
|
||||
All panes are variants of the `PaneInstance` enum:
|
||||
|
||||
```rust
|
||||
// In lightningbeam-editor/src/panes/mod.rs
|
||||
pub enum PaneInstance {
|
||||
Stage(Stage),
|
||||
Timeline(Timeline),
|
||||
AssetLibrary(AssetLibrary),
|
||||
InfoPanel(InfoPanel),
|
||||
VirtualPiano(VirtualPiano),
|
||||
Toolbar(Toolbar),
|
||||
NodeEditor(NodeEditor),
|
||||
PianoRoll(PianoRoll),
|
||||
Outliner(Outliner),
|
||||
PresetBrowser(PresetBrowser),
|
||||
}
|
||||
|
||||
impl PaneInstance {
|
||||
pub fn render(&mut self, ui: &mut Ui, shared_state: &mut SharedPaneState) {
|
||||
match self {
|
||||
PaneInstance::Stage(stage) => stage.render(ui, shared_state),
|
||||
PaneInstance::Timeline(timeline) => timeline.render(ui, shared_state),
|
||||
PaneInstance::AssetLibrary(lib) => lib.render(ui, shared_state),
|
||||
// ... dispatch to specific pane
|
||||
}
|
||||
}
|
||||
|
||||
pub fn title(&self) -> &str {
|
||||
match self {
|
||||
PaneInstance::Stage(_) => "Stage",
|
||||
PaneInstance::Timeline(_) => "Timeline",
|
||||
// ...
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Individual Pane Structure
|
||||
|
||||
Each pane is a struct with its own state and a `render` method:
|
||||
|
||||
```rust
|
||||
pub struct MyPane {
|
||||
// Pane-specific state
|
||||
scroll_offset: f32,
|
||||
selected_item: Option<usize>,
|
||||
// ... other state
|
||||
}
|
||||
|
||||
impl MyPane {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
scroll_offset: 0.0,
|
||||
selected_item: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(&mut self, ui: &mut Ui, shared_state: &mut SharedPaneState) {
|
||||
// Render pane UI
|
||||
ui.heading("My Pane");
|
||||
|
||||
// Access shared state
|
||||
let document = &shared_state.document;
|
||||
|
||||
// Create actions
|
||||
if ui.button("Do something").clicked() {
|
||||
let action = Box::new(MyAction { /* ... */ });
|
||||
shared_state.pending_actions.push(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Key Panes
|
||||
|
||||
Located in `lightningbeam-editor/src/panes/`:
|
||||
|
||||
- **stage.rs** (214KB): Main canvas for drawing and transform tools
|
||||
- **timeline.rs** (84KB): Multi-track timeline with clip editing
|
||||
- **asset_library.rs** (70KB): Asset browser with drag-to-timeline
|
||||
- **infopanel.rs** (31KB): Context-sensitive property editor
|
||||
- **virtual_piano.rs** (31KB): On-screen MIDI keyboard
|
||||
- **toolbar.rs** (9KB): Tool palette
|
||||
|
||||
## Shared State
|
||||
|
||||
`SharedPaneState` is passed to all panes during rendering to share data and coordinate actions.
|
||||
|
||||
### SharedPaneState Structure
|
||||
|
||||
```rust
|
||||
pub struct SharedPaneState {
|
||||
// Document state
|
||||
pub document: Document,
|
||||
pub undo_stack: Vec<Box<dyn Action>>,
|
||||
pub redo_stack: Vec<Box<dyn Action>>,
|
||||
|
||||
// Tool state
|
||||
pub selected_tool: Tool,
|
||||
pub tool_state: ToolState,
|
||||
|
||||
// Actions to execute after rendering
|
||||
pub pending_actions: Vec<Box<dyn Action>>,
|
||||
|
||||
// Audio engine
|
||||
pub audio_system: AudioSystem,
|
||||
pub playhead_position: f64,
|
||||
pub is_playing: bool,
|
||||
|
||||
// Selection state
|
||||
pub selected_clips: HashSet<Uuid>,
|
||||
pub selected_shapes: HashSet<Uuid>,
|
||||
|
||||
// Clipboard
|
||||
pub clipboard: Option<ClipboardData>,
|
||||
|
||||
// UI state
|
||||
pub show_grid: bool,
|
||||
pub snap_to_grid: bool,
|
||||
pub grid_size: f32,
|
||||
}
|
||||
```
|
||||
|
||||
### Accessing Shared State
|
||||
|
||||
```rust
|
||||
impl MyPane {
|
||||
pub fn render(&mut self, ui: &mut Ui, shared_state: &mut SharedPaneState) {
|
||||
// Read from document
|
||||
let layer_count = shared_state.document.layers.len();
|
||||
ui.label(format!("Layers: {}", layer_count));
|
||||
|
||||
// Check tool state
|
||||
if shared_state.selected_tool == Tool::Select {
|
||||
// ... render selection-specific UI
|
||||
}
|
||||
|
||||
// Check playback state
|
||||
if shared_state.is_playing {
|
||||
ui.label("▶ Playing");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Two-Phase Dispatch
|
||||
|
||||
Panes cannot directly mutate shared state during rendering due to Rust's borrowing rules. Instead, they register actions to be executed after all panes have rendered.
|
||||
|
||||
### Why Two-Phase?
|
||||
|
||||
```rust
|
||||
// This doesn't work: can't borrow shared_state as mutable twice
|
||||
pub fn render(&mut self, ui: &mut Ui, shared_state: &mut SharedPaneState) {
|
||||
if ui.button("Add layer").clicked() {
|
||||
// ❌ Can't mutate document while borrowed by render
|
||||
shared_state.document.layers.push(Layer::new());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Solution: Pending Actions
|
||||
|
||||
```rust
|
||||
// Phase 1: Register action during render
|
||||
pub fn render(&mut self, ui: &mut Ui, shared_state: &mut SharedPaneState) {
|
||||
if ui.button("Add layer").clicked() {
|
||||
let action = Box::new(AddLayerAction::new());
|
||||
shared_state.pending_actions.push(action);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Execute after all panes rendered (in main app)
|
||||
for action in shared_state.pending_actions.drain(..) {
|
||||
action.apply(&mut shared_state.document);
|
||||
shared_state.undo_stack.push(action);
|
||||
}
|
||||
```
|
||||
|
||||
### Action Trait
|
||||
|
||||
All actions implement the `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);
|
||||
}
|
||||
```
|
||||
|
||||
Example action:
|
||||
|
||||
```rust
|
||||
pub struct AddLayerAction {
|
||||
layer_id: Uuid,
|
||||
layer_type: LayerType,
|
||||
}
|
||||
|
||||
impl Action for AddLayerAction {
|
||||
fn apply(&mut self, document: &mut Document) {
|
||||
let layer = Layer::new(self.layer_id, self.layer_type);
|
||||
document.layers.push(layer);
|
||||
}
|
||||
|
||||
fn undo(&mut self, document: &mut Document) {
|
||||
document.layers.retain(|l| l.id != self.layer_id);
|
||||
}
|
||||
|
||||
fn redo(&mut self, document: &mut Document) {
|
||||
self.apply(document);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## ID Collision Avoidance
|
||||
|
||||
egui uses IDs to track widget state across frames (e.g., scroll position, collapse state). When multiple instances of the same pane exist, IDs can collide.
|
||||
|
||||
### The Problem
|
||||
|
||||
```rust
|
||||
// If two Timeline panes exist, they'll share the same ID
|
||||
ui.collapsing("Track 1", |ui| {
|
||||
// ... content
|
||||
}); // ID is derived from label "Track 1"
|
||||
```
|
||||
|
||||
Both timeline instances would have the same "Track 1" ID, causing state conflicts.
|
||||
|
||||
### Solution: Salt IDs with Node Path
|
||||
|
||||
Each pane has a unique node path (e.g., `"root/0/1/2"`). Salt all IDs with this path:
|
||||
|
||||
```rust
|
||||
pub struct Timeline {
|
||||
node_path: String, // Unique path for this pane instance
|
||||
}
|
||||
|
||||
impl Timeline {
|
||||
pub fn render(&mut self, ui: &mut Ui, shared_state: &mut SharedPaneState) {
|
||||
// Salt IDs with node path
|
||||
ui.push_id(&self.node_path, |ui| {
|
||||
// Now all IDs within this closure are unique to this instance
|
||||
ui.collapsing("Track 1", |ui| {
|
||||
// ... content
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Alternative: Per-Widget Salting
|
||||
|
||||
For individual widgets:
|
||||
|
||||
```rust
|
||||
ui.collapsing("Track 1", |ui| {
|
||||
// ... content
|
||||
}).id.with(&self.node_path); // Salt this specific ID
|
||||
```
|
||||
|
||||
### Best Practice
|
||||
|
||||
**Always salt IDs in new panes** to support multiple instances:
|
||||
|
||||
```rust
|
||||
impl NewPane {
|
||||
pub fn render(&mut self, ui: &mut Ui, shared_state: &mut SharedPaneState) {
|
||||
ui.push_id(&self.node_path, |ui| {
|
||||
// All rendering code goes here
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tool System
|
||||
|
||||
Tools handle user input on the Stage pane (drawing, selection, transforms, etc.).
|
||||
|
||||
### Tool Enum
|
||||
|
||||
```rust
|
||||
pub enum Tool {
|
||||
Select,
|
||||
Draw,
|
||||
Rectangle,
|
||||
Ellipse,
|
||||
Line,
|
||||
PaintBucket,
|
||||
Transform,
|
||||
Eyedropper,
|
||||
}
|
||||
```
|
||||
|
||||
### Tool State
|
||||
|
||||
```rust
|
||||
pub struct ToolState {
|
||||
// Generic tool state
|
||||
pub mouse_pos: Pos2,
|
||||
pub mouse_down: bool,
|
||||
pub drag_start: Option<Pos2>,
|
||||
|
||||
// Tool-specific state
|
||||
pub draw_points: Vec<Pos2>,
|
||||
pub transform_mode: TransformMode,
|
||||
pub paint_bucket_tolerance: f32,
|
||||
}
|
||||
```
|
||||
|
||||
### Tool Implementation
|
||||
|
||||
Tools implement the `ToolBehavior` trait:
|
||||
|
||||
```rust
|
||||
pub trait ToolBehavior {
|
||||
fn on_mouse_down(&mut self, pos: Pos2, shared_state: &mut SharedPaneState);
|
||||
fn on_mouse_move(&mut self, pos: Pos2, shared_state: &mut SharedPaneState);
|
||||
fn on_mouse_up(&mut self, pos: Pos2, shared_state: &mut SharedPaneState);
|
||||
fn on_key(&mut self, key: Key, shared_state: &mut SharedPaneState);
|
||||
fn render_overlay(&self, painter: &Painter);
|
||||
}
|
||||
```
|
||||
|
||||
Example: Rectangle tool:
|
||||
|
||||
```rust
|
||||
pub struct RectangleTool {
|
||||
start_pos: Option<Pos2>,
|
||||
}
|
||||
|
||||
impl ToolBehavior for RectangleTool {
|
||||
fn on_mouse_down(&mut self, pos: Pos2, _shared_state: &mut SharedPaneState) {
|
||||
self.start_pos = Some(pos);
|
||||
}
|
||||
|
||||
fn on_mouse_move(&mut self, pos: Pos2, _shared_state: &mut SharedPaneState) {
|
||||
// Visual feedback handled in render_overlay
|
||||
}
|
||||
|
||||
fn on_mouse_up(&mut self, pos: Pos2, shared_state: &mut SharedPaneState) {
|
||||
if let Some(start) = self.start_pos.take() {
|
||||
// Create rectangle shape
|
||||
let rect = Rect::from_two_pos(start, pos);
|
||||
let action = Box::new(AddShapeAction::rectangle(rect));
|
||||
shared_state.pending_actions.push(action);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_overlay(&self, painter: &Painter) {
|
||||
if let Some(start) = self.start_pos {
|
||||
let current = painter.mouse_pos();
|
||||
let rect = Rect::from_two_pos(start, current);
|
||||
painter.rect_stroke(rect, 0.0, Stroke::new(2.0, Color32::WHITE));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Tool Selection
|
||||
|
||||
```rust
|
||||
// In Toolbar pane
|
||||
if ui.button("✏ Draw").clicked() {
|
||||
shared_state.selected_tool = Tool::Draw;
|
||||
}
|
||||
|
||||
// In Stage pane
|
||||
match shared_state.selected_tool {
|
||||
Tool::Draw => self.draw_tool.on_mouse_move(pos, shared_state),
|
||||
Tool::Select => self.select_tool.on_mouse_move(pos, shared_state),
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## GPU Integration
|
||||
|
||||
The Stage pane uses custom wgpu rendering for vector graphics and waveforms.
|
||||
|
||||
### egui-wgpu Callbacks
|
||||
|
||||
```rust
|
||||
// In Stage::render()
|
||||
ui.painter().add(egui_wgpu::Callback::new_paint_callback(
|
||||
rect,
|
||||
StageCallback {
|
||||
document: shared_state.document.clone(),
|
||||
vello_renderer: self.vello_renderer.clone(),
|
||||
waveform_renderer: self.waveform_renderer.clone(),
|
||||
},
|
||||
));
|
||||
```
|
||||
|
||||
### Callback Implementation
|
||||
|
||||
```rust
|
||||
struct StageCallback {
|
||||
document: Document,
|
||||
vello_renderer: Arc<Mutex<VelloRenderer>>,
|
||||
waveform_renderer: Arc<Mutex<WaveformRenderer>>,
|
||||
}
|
||||
|
||||
impl egui_wgpu::CallbackTrait for StageCallback {
|
||||
fn prepare(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
resources: &egui_wgpu::CallbackResources,
|
||||
) -> Vec<wgpu::CommandBuffer> {
|
||||
// Prepare GPU resources
|
||||
let mut vello = self.vello_renderer.lock().unwrap();
|
||||
vello.prepare_scene(&self.document);
|
||||
|
||||
vec![]
|
||||
}
|
||||
|
||||
fn paint<'a>(
|
||||
&'a self,
|
||||
info: egui::PaintCallbackInfo,
|
||||
render_pass: &mut wgpu::RenderPass<'a>,
|
||||
resources: &'a egui_wgpu::CallbackResources,
|
||||
) {
|
||||
// Render vector graphics
|
||||
let vello = self.vello_renderer.lock().unwrap();
|
||||
vello.render(render_pass);
|
||||
|
||||
// Render waveforms
|
||||
let waveforms = self.waveform_renderer.lock().unwrap();
|
||||
waveforms.render(render_pass);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Vello Integration
|
||||
|
||||
Vello renders 2D vector graphics using GPU compute shaders:
|
||||
|
||||
```rust
|
||||
use vello::{Scene, SceneBuilder, kurbo};
|
||||
|
||||
fn build_vello_scene(document: &Document) -> Scene {
|
||||
let mut scene = Scene::new();
|
||||
let mut builder = SceneBuilder::for_scene(&mut scene);
|
||||
|
||||
for layer in &document.layers {
|
||||
if let Layer::VectorLayer { clips, .. } = layer {
|
||||
for clip in clips {
|
||||
for shape in &clip.shapes {
|
||||
// Convert shape to kurbo path
|
||||
let path = shape.to_kurbo_path();
|
||||
|
||||
// Add to scene with fill/stroke
|
||||
builder.fill(
|
||||
Fill::NonZero,
|
||||
Affine::IDENTITY,
|
||||
&shape.fill_color,
|
||||
None,
|
||||
&path,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scene
|
||||
}
|
||||
```
|
||||
|
||||
## Adding New Panes
|
||||
|
||||
### Step 1: Create Pane Struct
|
||||
|
||||
```rust
|
||||
// In lightningbeam-editor/src/panes/my_pane.rs
|
||||
pub struct MyPane {
|
||||
node_path: String,
|
||||
// Pane-specific state
|
||||
selected_index: usize,
|
||||
scroll_offset: f32,
|
||||
}
|
||||
|
||||
impl MyPane {
|
||||
pub fn new(node_path: String) -> Self {
|
||||
Self {
|
||||
node_path,
|
||||
selected_index: 0,
|
||||
scroll_offset: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(&mut self, ui: &mut Ui, shared_state: &mut SharedPaneState) {
|
||||
// IMPORTANT: Salt IDs with node path
|
||||
ui.push_id(&self.node_path, |ui| {
|
||||
ui.heading("My Pane");
|
||||
|
||||
// Render pane content
|
||||
// ...
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Add to PaneInstance Enum
|
||||
|
||||
```rust
|
||||
// In lightningbeam-editor/src/panes/mod.rs
|
||||
pub enum PaneInstance {
|
||||
// ... existing variants
|
||||
MyPane(MyPane),
|
||||
}
|
||||
|
||||
impl PaneInstance {
|
||||
pub fn render(&mut self, ui: &mut Ui, shared_state: &mut SharedPaneState) {
|
||||
match self {
|
||||
// ... existing cases
|
||||
PaneInstance::MyPane(pane) => pane.render(ui, shared_state),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn title(&self) -> &str {
|
||||
match self {
|
||||
// ... existing cases
|
||||
PaneInstance::MyPane(_) => "My Pane",
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Add to Menu
|
||||
|
||||
```rust
|
||||
// In main application
|
||||
if ui.button("My Pane").clicked() {
|
||||
let pane = PaneInstance::MyPane(MyPane::new(generate_node_path()));
|
||||
app.add_pane(pane);
|
||||
}
|
||||
```
|
||||
|
||||
## Adding New Tools
|
||||
|
||||
### Step 1: Add to Tool Enum
|
||||
|
||||
```rust
|
||||
pub enum Tool {
|
||||
// ... existing tools
|
||||
MyTool,
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Implement Tool Behavior
|
||||
|
||||
```rust
|
||||
pub struct MyToolState {
|
||||
// Tool-specific state
|
||||
start_pos: Option<Pos2>,
|
||||
}
|
||||
|
||||
impl MyToolState {
|
||||
pub fn handle_input(
|
||||
&mut self,
|
||||
response: &Response,
|
||||
shared_state: &mut SharedPaneState,
|
||||
) {
|
||||
if response.clicked() {
|
||||
self.start_pos = response.interact_pointer_pos();
|
||||
}
|
||||
|
||||
if response.drag_released() {
|
||||
if let Some(start) = self.start_pos.take() {
|
||||
// Create action
|
||||
let action = Box::new(MyAction { /* ... */ });
|
||||
shared_state.pending_actions.push(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_overlay(&self, painter: &Painter) {
|
||||
// Draw tool-specific overlay
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Add to Toolbar
|
||||
|
||||
```rust
|
||||
// In Toolbar pane
|
||||
if ui.button("🔧 My Tool").clicked() {
|
||||
shared_state.selected_tool = Tool::MyTool;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Handle in Stage Pane
|
||||
|
||||
```rust
|
||||
// In Stage pane
|
||||
match shared_state.selected_tool {
|
||||
// ... existing tools
|
||||
Tool::MyTool => self.my_tool_state.handle_input(&response, shared_state),
|
||||
}
|
||||
|
||||
// Render overlay
|
||||
match shared_state.selected_tool {
|
||||
// ... existing tools
|
||||
Tool::MyTool => self.my_tool_state.render_overlay(&painter),
|
||||
}
|
||||
```
|
||||
|
||||
## Event Handling
|
||||
|
||||
### Mouse Events
|
||||
|
||||
```rust
|
||||
let response = ui.allocate_rect(rect, Sense::click_and_drag());
|
||||
|
||||
if response.clicked() {
|
||||
let pos = response.interact_pointer_pos().unwrap();
|
||||
// Handle click at pos
|
||||
}
|
||||
|
||||
if response.dragged() {
|
||||
let delta = response.drag_delta();
|
||||
// Handle drag by delta
|
||||
}
|
||||
|
||||
if response.drag_released() {
|
||||
// Handle drag end
|
||||
}
|
||||
```
|
||||
|
||||
### Keyboard Events
|
||||
|
||||
```rust
|
||||
ui.input(|i| {
|
||||
if i.key_pressed(Key::Delete) {
|
||||
// Delete selected items
|
||||
}
|
||||
|
||||
if i.modifiers.ctrl && i.key_pressed(Key::Z) {
|
||||
// Undo
|
||||
}
|
||||
|
||||
if i.modifiers.ctrl && i.key_pressed(Key::Y) {
|
||||
// Redo
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Drag and Drop
|
||||
|
||||
```rust
|
||||
// Source (Asset Library)
|
||||
let response = ui.label("Audio Clip");
|
||||
if response.dragged() {
|
||||
let payload = DragPayload::AudioClip(clip_id);
|
||||
ui.memory_mut(|mem| {
|
||||
mem.data.insert_temp(Id::new("drag_payload"), payload);
|
||||
});
|
||||
}
|
||||
|
||||
// Target (Timeline)
|
||||
let response = ui.allocate_rect(rect, Sense::hover());
|
||||
if response.hovered() {
|
||||
if let Some(payload) = ui.memory(|mem| mem.data.get_temp::<DragPayload>(Id::new("drag_payload"))) {
|
||||
// Handle drop
|
||||
let action = Box::new(AddClipAction { clip_id: payload.clip_id(), position });
|
||||
shared_state.pending_actions.push(action);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Always Salt IDs
|
||||
|
||||
```rust
|
||||
// ✅ Good
|
||||
ui.push_id(&self.node_path, |ui| {
|
||||
// All rendering here
|
||||
});
|
||||
|
||||
// ❌ Bad (ID collisions if multiple instances)
|
||||
ui.collapsing("Settings", |ui| {
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Use Pending Actions
|
||||
|
||||
```rust
|
||||
// ✅ Good
|
||||
shared_state.pending_actions.push(Box::new(action));
|
||||
|
||||
// ❌ Bad (borrowing conflicts)
|
||||
shared_state.document.layers.push(layer);
|
||||
```
|
||||
|
||||
### 3. Split Borrows with std::mem::take
|
||||
|
||||
```rust
|
||||
// ✅ Good
|
||||
let mut clips = std::mem::take(&mut self.clips);
|
||||
for clip in &mut clips {
|
||||
self.render_clip(ui, clip); // Can borrow self immutably
|
||||
}
|
||||
self.clips = clips;
|
||||
|
||||
// ❌ Bad (can't borrow self while iterating clips)
|
||||
for clip in &mut self.clips {
|
||||
self.render_clip(ui, clip); // Error!
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Avoid Expensive Operations in Render
|
||||
|
||||
```rust
|
||||
// ❌ Bad (heavy computation every frame)
|
||||
pub fn render(&mut self, ui: &mut Ui, shared_state: &mut SharedPaneState) {
|
||||
let thumbnail = self.generate_thumbnail(); // Expensive!
|
||||
ui.image(thumbnail);
|
||||
}
|
||||
|
||||
// ✅ Good (cache result)
|
||||
pub fn render(&mut self, ui: &mut Ui, shared_state: &mut SharedPaneState) {
|
||||
if self.thumbnail_cache.is_none() {
|
||||
self.thumbnail_cache = Some(self.generate_thumbnail());
|
||||
}
|
||||
ui.image(self.thumbnail_cache.as_ref().unwrap());
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Handle Missing State Gracefully
|
||||
|
||||
```rust
|
||||
// ✅ Good
|
||||
if let Some(layer) = document.layers.get(layer_index) {
|
||||
// Render layer
|
||||
} else {
|
||||
ui.label("Layer not found");
|
||||
}
|
||||
|
||||
// ❌ Bad (panics if layer missing)
|
||||
let layer = &document.layers[layer_index]; // May panic!
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [ARCHITECTURE.md](../ARCHITECTURE.md) - Overall system architecture
|
||||
- [docs/AUDIO_SYSTEM.md](AUDIO_SYSTEM.md) - Audio engine integration
|
||||
- [docs/RENDERING.md](RENDERING.md) - GPU rendering details
|
||||
- [CONTRIBUTING.md](../CONTRIBUTING.md) - Development workflow
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
# Rust build artifacts
|
||||
/target/
|
||||
**/target/
|
||||
|
||||
# Cargo.lock for applications (keep for libraries)
|
||||
# We'll keep it since this is an application
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"lightningbeam-editor",
|
||||
"lightningbeam-core",
|
||||
"beamdsp",
|
||||
]
|
||||
|
||||
[workspace.dependencies]
|
||||
# UI Framework (using eframe for simplified integration)
|
||||
# Note: Upgraded from 0.29 to 0.31 to fix Linux IME/keyboard input issues
|
||||
# See: https://github.com/emilk/egui/pull/5198
|
||||
# Upgraded to 0.33 for shader editor (egui_code_editor) and continued bug fixes
|
||||
egui = "0.33.3"
|
||||
eframe = { version = "0.33.3", default-features = true, features = ["wgpu"] }
|
||||
egui_extras = { version = "0.33.3", features = ["image", "svg", "syntect"] }
|
||||
egui-wgpu = "0.33.3"
|
||||
egui_code_editor = "0.2"
|
||||
|
||||
# GPU Rendering
|
||||
# vello from git uses wgpu 27, matching eframe 0.33
|
||||
vello = { git = "https://github.com/linebender/vello", branch = "main" }
|
||||
wgpu = { version = "27", features = ["vulkan", "metal", "gles"] }
|
||||
kurbo = { version = "0.12", features = ["serde"] }
|
||||
peniko = "0.5"
|
||||
|
||||
# Windowing
|
||||
winit = "0.30"
|
||||
|
||||
# Native menus
|
||||
muda = "0.15"
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# Image loading
|
||||
image = "0.25"
|
||||
resvg = "0.42"
|
||||
|
||||
# Utilities
|
||||
pollster = "0.3"
|
||||
|
||||
# Desktop notifications
|
||||
notify-rust = "4.11"
|
||||
|
||||
# Optimize the audio backend even in debug builds — the audio callback
|
||||
# runs on a real-time thread with ~1.5ms deadlines at small buffer sizes,
|
||||
# so it cannot tolerate unoptimized code.
|
||||
[profile.dev.package.daw-backend]
|
||||
opt-level = 2
|
||||
|
||||
[profile.dev.package.nam-ffi]
|
||||
opt-level = 2
|
||||
|
||||
[profile.dev.package.beamdsp]
|
||||
opt-level = 2
|
||||
|
||||
# Also optimize symphonia (audio decoder) and cpal (audio I/O) — these
|
||||
# run in the audio callback path and are heavily numeric.
|
||||
[profile.dev.package.symphonia]
|
||||
opt-level = 2
|
||||
[profile.dev.package.symphonia-core]
|
||||
opt-level = 2
|
||||
[profile.dev.package.symphonia-bundle-mp3]
|
||||
opt-level = 2
|
||||
[profile.dev.package.symphonia-bundle-flac]
|
||||
opt-level = 2
|
||||
[profile.dev.package.symphonia-format-ogg]
|
||||
opt-level = 2
|
||||
[profile.dev.package.symphonia-codec-vorbis]
|
||||
opt-level = 2
|
||||
[profile.dev.package.symphonia-codec-aac]
|
||||
opt-level = 2
|
||||
[profile.dev.package.symphonia-format-isomp4]
|
||||
opt-level = 2
|
||||
[profile.dev.package.cpal]
|
||||
opt-level = 2
|
||||
|
||||
# Use local egui fork with ibus/Wayland text input fix
|
||||
[patch.crates-io]
|
||||
egui = { path = "../../egui-fork/crates/egui" }
|
||||
eframe = { path = "../../egui-fork/crates/eframe" }
|
||||
egui_extras = { path = "../../egui-fork/crates/egui_extras" }
|
||||
egui-wgpu = { path = "../../egui-fork/crates/egui-wgpu" }
|
||||
egui-winit = { path = "../../egui-fork/crates/egui-winit" }
|
||||
epaint = { path = "../../egui-fork/crates/epaint" }
|
||||
ecolor = { path = "../../egui-fork/crates/ecolor" }
|
||||
emath = { path = "../../egui-fork/crates/emath" }
|
||||
|
|
@ -1,613 +0,0 @@
|
|||
# BeamDSP Language Reference
|
||||
|
||||
BeamDSP is a domain-specific language for writing audio processing scripts in Lightningbeam. Scripts are compiled to bytecode and run on the real-time audio thread with guaranteed bounded execution time and constant memory usage.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```
|
||||
name "Simple Gain"
|
||||
category effect
|
||||
|
||||
inputs {
|
||||
audio_in: audio
|
||||
}
|
||||
|
||||
outputs {
|
||||
audio_out: audio
|
||||
}
|
||||
|
||||
params {
|
||||
gain: 1.0 [0.0, 2.0] ""
|
||||
}
|
||||
|
||||
process {
|
||||
for i in 0..buffer_size {
|
||||
audio_out[i * 2] = audio_in[i * 2] * gain;
|
||||
audio_out[i * 2 + 1] = audio_in[i * 2 + 1] * gain;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Save this as a `.bdsp` file or create it directly in the Script Editor pane.
|
||||
|
||||
## Script Structure
|
||||
|
||||
A BeamDSP script is composed of **header blocks** followed by a **process block**. All blocks are optional except `name`, `category`, and `process`.
|
||||
|
||||
```
|
||||
name "Display Name"
|
||||
category effect|generator|utility
|
||||
|
||||
inputs { ... }
|
||||
outputs { ... }
|
||||
params { ... }
|
||||
state { ... }
|
||||
ui { ... }
|
||||
process { ... }
|
||||
```
|
||||
|
||||
### name
|
||||
|
||||
```
|
||||
name "My Effect"
|
||||
```
|
||||
|
||||
Sets the display name shown in the node graph.
|
||||
|
||||
### category
|
||||
|
||||
```
|
||||
category effect
|
||||
```
|
||||
|
||||
One of:
|
||||
- **`effect`** — Processes audio (has inputs and outputs)
|
||||
- **`generator`** — Produces audio or CV (outputs only, no audio inputs)
|
||||
- **`utility`** — Signal routing, mixing, or other utility functions
|
||||
|
||||
### inputs
|
||||
|
||||
Declares input ports. Each input has a name and signal type.
|
||||
|
||||
```
|
||||
inputs {
|
||||
audio_in: audio
|
||||
mod_signal: cv
|
||||
}
|
||||
```
|
||||
|
||||
Signal types:
|
||||
- **`audio`** — Stereo interleaved audio (2 samples per frame: left, right)
|
||||
- **`cv`** — Mono control voltage (1 sample per frame, NaN when unconnected)
|
||||
|
||||
### outputs
|
||||
|
||||
Declares output ports. Same syntax as inputs.
|
||||
|
||||
```
|
||||
outputs {
|
||||
audio_out: audio
|
||||
env_out: cv
|
||||
}
|
||||
```
|
||||
|
||||
### params
|
||||
|
||||
Declares user-adjustable parameters. Each parameter has a default value, range, and unit string.
|
||||
|
||||
```
|
||||
params {
|
||||
frequency: 440.0 [20.0, 20000.0] "Hz"
|
||||
gain: 1.0 [0.0, 2.0] ""
|
||||
mix: 0.5 [0.0, 1.0] ""
|
||||
}
|
||||
```
|
||||
|
||||
Format: `name: default [min, max] "unit"`
|
||||
|
||||
Parameters appear as sliders in the node's UI. They are read-only inside the `process` block.
|
||||
|
||||
### state
|
||||
|
||||
Declares persistent variables that survive across process calls. State is zero-initialized and can be reset.
|
||||
|
||||
```
|
||||
state {
|
||||
phase: f32
|
||||
counter: int
|
||||
active: bool
|
||||
buffer: [44100]f32
|
||||
indices: [16]int
|
||||
clip: sample
|
||||
}
|
||||
```
|
||||
|
||||
Types:
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `f32` | 32-bit float |
|
||||
| `int` | 32-bit signed integer |
|
||||
| `bool` | Boolean |
|
||||
| `[N]f32` | Fixed-size float array (N is a constant) |
|
||||
| `[N]int` | Fixed-size integer array (N is a constant) |
|
||||
| `sample` | Loadable audio sample (stereo interleaved, read-only in process) |
|
||||
|
||||
State arrays are allocated once at compile time and never resized. The `sample` type holds audio data loaded through the node's UI.
|
||||
|
||||
### ui
|
||||
|
||||
Declares the layout of controls rendered below the node in the graph editor. If omitted, a default UI is generated with sliders for all parameters and pickers for all samples.
|
||||
|
||||
```
|
||||
ui {
|
||||
sample clip
|
||||
param frequency
|
||||
param gain
|
||||
group "Mix" {
|
||||
param mix
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Elements:
|
||||
| Element | Description |
|
||||
|---------|-------------|
|
||||
| `param name` | Slider for the named parameter |
|
||||
| `sample name` | Audio clip picker for the named sample state variable |
|
||||
| `group "label" { ... }` | Collapsible section containing child elements |
|
||||
|
||||
### process
|
||||
|
||||
The process block runs once per audio callback, processing all frames in the current buffer.
|
||||
|
||||
```
|
||||
process {
|
||||
for i in 0..buffer_size {
|
||||
audio_out[i * 2] = audio_in[i * 2];
|
||||
audio_out[i * 2 + 1] = audio_in[i * 2 + 1];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Types
|
||||
|
||||
BeamDSP has three scalar types:
|
||||
|
||||
| Type | Description | Literal examples |
|
||||
|------|-------------|-----------------|
|
||||
| `f32` | 32-bit float | `1.0`, `0.5`, `3.14` |
|
||||
| `int` | 32-bit signed integer | `0`, `42`, `256` |
|
||||
| `bool` | Boolean | `true`, `false` |
|
||||
|
||||
Type conversions use cast syntax:
|
||||
- `int(expr)` — Convert float to integer (truncates toward zero)
|
||||
- `float(expr)` — Convert integer to float
|
||||
|
||||
Arithmetic between `int` and `f32` promotes the result to `f32`.
|
||||
|
||||
## Variables
|
||||
|
||||
### Local variables
|
||||
|
||||
```
|
||||
let x = 1.0;
|
||||
let mut counter = 0;
|
||||
```
|
||||
|
||||
Use `let` to declare a local variable. Add `mut` to allow reassignment. Local variables exist only within the current block scope.
|
||||
|
||||
### Built-in variables
|
||||
|
||||
| Variable | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `sample_rate` | `int` | Audio sample rate in Hz (e.g., 44100) |
|
||||
| `buffer_size` | `int` | Number of frames in the current buffer |
|
||||
|
||||
### Inputs and outputs
|
||||
|
||||
Input and output ports are accessed as arrays:
|
||||
|
||||
```
|
||||
// Audio is stereo interleaved: [L0, R0, L1, R1, ...]
|
||||
let left = audio_in[i * 2];
|
||||
let right = audio_in[i * 2 + 1];
|
||||
audio_out[i * 2] = left;
|
||||
audio_out[i * 2 + 1] = right;
|
||||
|
||||
// CV is mono: one sample per frame
|
||||
let mod_value = mod_in[i];
|
||||
cv_out[i] = mod_value;
|
||||
```
|
||||
|
||||
Input arrays are read-only. Output arrays are write-only.
|
||||
|
||||
### Parameters
|
||||
|
||||
Parameters are available as read-only `f32` variables:
|
||||
|
||||
```
|
||||
audio_out[i * 2] = audio_in[i * 2] * gain;
|
||||
```
|
||||
|
||||
### State variables
|
||||
|
||||
State scalars and arrays are mutable and persist across calls:
|
||||
|
||||
```
|
||||
state {
|
||||
phase: f32
|
||||
buffer: [1024]f32
|
||||
}
|
||||
|
||||
process {
|
||||
phase = phase + 0.01;
|
||||
buffer[0] = phase;
|
||||
}
|
||||
```
|
||||
|
||||
## Control Flow
|
||||
|
||||
### if / else
|
||||
|
||||
```
|
||||
if phase >= 1.0 {
|
||||
phase = phase - 1.0;
|
||||
}
|
||||
|
||||
if value > threshold {
|
||||
audio_out[i * 2] = 1.0;
|
||||
} else {
|
||||
audio_out[i * 2] = 0.0;
|
||||
}
|
||||
```
|
||||
|
||||
### for loops
|
||||
|
||||
For loops iterate from 0 to an upper bound (exclusive). The loop variable is an immutable `int`.
|
||||
|
||||
```
|
||||
for i in 0..buffer_size {
|
||||
// i goes from 0 to buffer_size - 1
|
||||
}
|
||||
|
||||
for j in 0..len(buffer) {
|
||||
buffer[j] = 0.0;
|
||||
}
|
||||
```
|
||||
|
||||
The upper bound must be an integer expression. Typically `buffer_size`, `len(array)`, or a constant.
|
||||
|
||||
There are no `while` loops, no recursion, and no user-defined functions. This is by design — it guarantees bounded execution time on the audio thread.
|
||||
|
||||
## Operators
|
||||
|
||||
### Arithmetic
|
||||
| Operator | Description |
|
||||
|----------|-------------|
|
||||
| `+` | Addition |
|
||||
| `-` | Subtraction (binary) or negation (unary) |
|
||||
| `*` | Multiplication |
|
||||
| `/` | Division |
|
||||
| `%` | Modulo |
|
||||
|
||||
### Comparison
|
||||
| Operator | Description |
|
||||
|----------|-------------|
|
||||
| `==` | Equal |
|
||||
| `!=` | Not equal |
|
||||
| `<` | Less than |
|
||||
| `>` | Greater than |
|
||||
| `<=` | Less than or equal |
|
||||
| `>=` | Greater than or equal |
|
||||
|
||||
### Logical
|
||||
| Operator | Description |
|
||||
|----------|-------------|
|
||||
| `&&` | Logical AND |
|
||||
| `\|\|` | Logical OR |
|
||||
| `!` | Logical NOT (unary) |
|
||||
|
||||
## Built-in Functions
|
||||
|
||||
### Trigonometric
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `sin(x)` | Sine |
|
||||
| `cos(x)` | Cosine |
|
||||
| `tan(x)` | Tangent |
|
||||
| `asin(x)` | Arc sine |
|
||||
| `acos(x)` | Arc cosine |
|
||||
| `atan(x)` | Arc tangent |
|
||||
| `atan2(y, x)` | Two-argument arc tangent |
|
||||
|
||||
### Exponential
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `exp(x)` | e^x |
|
||||
| `log(x)` | Natural logarithm |
|
||||
| `log2(x)` | Base-2 logarithm |
|
||||
| `pow(x, y)` | x raised to power y |
|
||||
| `sqrt(x)` | Square root |
|
||||
|
||||
### Rounding
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `floor(x)` | Round toward negative infinity |
|
||||
| `ceil(x)` | Round toward positive infinity |
|
||||
| `round(x)` | Round to nearest integer |
|
||||
| `trunc(x)` | Round toward zero |
|
||||
| `fract(x)` | Fractional part (x - floor(x)) |
|
||||
|
||||
### Clamping and interpolation
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `abs(x)` | Absolute value |
|
||||
| `sign(x)` | Sign (-1.0, 0.0, or 1.0) |
|
||||
| `min(x, y)` | Minimum of two values |
|
||||
| `max(x, y)` | Maximum of two values |
|
||||
| `clamp(x, lo, hi)` | Clamp x to [lo, hi] |
|
||||
| `mix(a, b, t)` | Linear interpolation: a*(1-t) + b*t |
|
||||
| `smoothstep(edge0, edge1, x)` | Hermite interpolation between 0 and 1 |
|
||||
|
||||
### Array
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `len(array)` | Length of a state array (returns `int`) |
|
||||
|
||||
### CV
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `cv_or(value, default)` | Returns `default` if `value` is NaN (unconnected CV), otherwise returns `value` |
|
||||
|
||||
### Sample
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `sample_len(s)` | Number of frames in sample (0 if unloaded, returns `int`) |
|
||||
| `sample_read(s, index)` | Read sample data at index (0.0 if out of bounds, returns `f32`) |
|
||||
| `sample_rate_of(s)` | Original sample rate of the loaded audio (returns `int`) |
|
||||
|
||||
Sample data is stereo interleaved, so frame N has left at index `N*2` and right at `N*2+1`.
|
||||
|
||||
## Comments
|
||||
|
||||
```
|
||||
// This is a line comment
|
||||
let x = 1.0; // Inline comment
|
||||
```
|
||||
|
||||
Line comments start with `//` and extend to the end of the line.
|
||||
|
||||
## Semicolons
|
||||
|
||||
Semicolons are **optional** statement terminators. You can use them or omit them.
|
||||
|
||||
```
|
||||
let x = 1.0; // with semicolons
|
||||
let y = 2.0
|
||||
|
||||
audio_out[0] = x + y
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Stereo Delay
|
||||
|
||||
```
|
||||
name "Stereo Delay"
|
||||
category effect
|
||||
|
||||
inputs {
|
||||
audio_in: audio
|
||||
}
|
||||
|
||||
outputs {
|
||||
audio_out: audio
|
||||
}
|
||||
|
||||
params {
|
||||
delay_time: 0.5 [0.01, 2.0] "s"
|
||||
feedback: 0.3 [0.0, 0.95] ""
|
||||
mix: 0.5 [0.0, 1.0] ""
|
||||
}
|
||||
|
||||
state {
|
||||
buffer: [88200]f32
|
||||
write_pos: int
|
||||
}
|
||||
|
||||
ui {
|
||||
param delay_time
|
||||
param feedback
|
||||
param mix
|
||||
}
|
||||
|
||||
process {
|
||||
let delay_samples = int(delay_time * float(sample_rate)) * 2;
|
||||
for i in 0..buffer_size {
|
||||
let l = audio_in[i * 2];
|
||||
let r = audio_in[i * 2 + 1];
|
||||
let read_pos = (write_pos - delay_samples + len(buffer)) % len(buffer);
|
||||
let dl = buffer[read_pos];
|
||||
let dr = buffer[read_pos + 1];
|
||||
buffer[write_pos] = l + dl * feedback;
|
||||
buffer[write_pos + 1] = r + dr * feedback;
|
||||
write_pos = (write_pos + 2) % len(buffer);
|
||||
audio_out[i * 2] = l * (1.0 - mix) + dl * mix;
|
||||
audio_out[i * 2 + 1] = r * (1.0 - mix) + dr * mix;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Sine Oscillator
|
||||
|
||||
```
|
||||
name "Sine Oscillator"
|
||||
category generator
|
||||
|
||||
outputs {
|
||||
audio_out: audio
|
||||
}
|
||||
|
||||
params {
|
||||
frequency: 440.0 [20.0, 20000.0] "Hz"
|
||||
amplitude: 0.5 [0.0, 1.0] ""
|
||||
}
|
||||
|
||||
state {
|
||||
phase: f32
|
||||
}
|
||||
|
||||
ui {
|
||||
param frequency
|
||||
param amplitude
|
||||
}
|
||||
|
||||
process {
|
||||
let inc = frequency / float(sample_rate);
|
||||
for i in 0..buffer_size {
|
||||
let sample = sin(phase * 6.2831853) * amplitude;
|
||||
audio_out[i * 2] = sample;
|
||||
audio_out[i * 2 + 1] = sample;
|
||||
phase = phase + inc;
|
||||
if phase >= 1.0 {
|
||||
phase = phase - 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Sample Player
|
||||
|
||||
```
|
||||
name "One-Shot Player"
|
||||
category generator
|
||||
|
||||
outputs {
|
||||
audio_out: audio
|
||||
}
|
||||
|
||||
params {
|
||||
speed: 1.0 [0.1, 4.0] ""
|
||||
}
|
||||
|
||||
state {
|
||||
clip: sample
|
||||
phase: f32
|
||||
}
|
||||
|
||||
ui {
|
||||
sample clip
|
||||
param speed
|
||||
}
|
||||
|
||||
process {
|
||||
let frames = sample_len(clip);
|
||||
for i in 0..buffer_size {
|
||||
let idx = int(phase) * 2;
|
||||
audio_out[i * 2] = sample_read(clip, idx);
|
||||
audio_out[i * 2 + 1] = sample_read(clip, idx + 1);
|
||||
phase = phase + speed;
|
||||
if phase >= float(frames) {
|
||||
phase = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### CV-Controlled Filter (Tone Control)
|
||||
|
||||
```
|
||||
name "Tone Control"
|
||||
category effect
|
||||
|
||||
inputs {
|
||||
audio_in: audio
|
||||
cutoff_cv: cv
|
||||
}
|
||||
|
||||
outputs {
|
||||
audio_out: audio
|
||||
}
|
||||
|
||||
params {
|
||||
cutoff: 1000.0 [20.0, 20000.0] "Hz"
|
||||
resonance: 0.5 [0.0, 1.0] ""
|
||||
}
|
||||
|
||||
state {
|
||||
lp_l: f32
|
||||
lp_r: f32
|
||||
}
|
||||
|
||||
ui {
|
||||
param cutoff
|
||||
param resonance
|
||||
}
|
||||
|
||||
process {
|
||||
for i in 0..buffer_size {
|
||||
let cv_mod = cv_or(cutoff_cv[i], 0.0);
|
||||
let freq = clamp(cutoff + cv_mod * 5000.0, 20.0, 20000.0);
|
||||
let rc = 1.0 / (6.2831853 * freq);
|
||||
let dt = 1.0 / float(sample_rate);
|
||||
let alpha = dt / (rc + dt);
|
||||
|
||||
let l = audio_in[i * 2];
|
||||
let r = audio_in[i * 2 + 1];
|
||||
|
||||
lp_l = lp_l + alpha * (l - lp_l);
|
||||
lp_r = lp_r + alpha * (r - lp_r);
|
||||
|
||||
audio_out[i * 2] = lp_l;
|
||||
audio_out[i * 2 + 1] = lp_r;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### LFO
|
||||
|
||||
```
|
||||
name "LFO"
|
||||
category generator
|
||||
|
||||
outputs {
|
||||
cv_out: cv
|
||||
}
|
||||
|
||||
params {
|
||||
rate: 1.0 [0.01, 20.0] "Hz"
|
||||
depth: 1.0 [0.0, 1.0] ""
|
||||
}
|
||||
|
||||
state {
|
||||
phase: f32
|
||||
}
|
||||
|
||||
ui {
|
||||
param rate
|
||||
param depth
|
||||
}
|
||||
|
||||
process {
|
||||
let inc = rate / float(sample_rate);
|
||||
for i in 0..buffer_size {
|
||||
cv_out[i] = sin(phase * 6.2831853) * depth;
|
||||
phase = phase + inc;
|
||||
if phase >= 1.0 {
|
||||
phase = phase - 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Safety Model
|
||||
|
||||
BeamDSP scripts run on the real-time audio thread. The language enforces safety through compile-time restrictions:
|
||||
|
||||
- **Bounded time**: Only `for i in 0..N` loops with statically bounded N. No `while` loops, no recursion, no user-defined functions. An instruction counter limit (~10 million) acts as a safety net.
|
||||
- **Constant memory**: All state arrays have compile-time sizes. The VM uses a fixed-size stack (256 slots) and fixed locals (64 slots). No heap allocation occurs during processing.
|
||||
- **Fail-silent**: If the VM encounters a runtime error (stack overflow, instruction limit exceeded), all outputs are zeroed for that buffer. Audio does not glitch — it simply goes silent.
|
||||
|
||||
## File Format
|
||||
|
||||
BeamDSP scripts use the `.bdsp` file extension. Files are plain UTF-8 text. You can export and import `.bdsp` files through the Script Editor pane or the node graph's script picker dropdown.
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
[package]
|
||||
name = "beamdsp"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
use crate::token::Span;
|
||||
use crate::ui_decl::UiElement;
|
||||
|
||||
/// Top-level script AST
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Script {
|
||||
pub name: String,
|
||||
pub category: CategoryKind,
|
||||
pub inputs: Vec<PortDecl>,
|
||||
pub outputs: Vec<PortDecl>,
|
||||
pub params: Vec<ParamDecl>,
|
||||
pub state: Vec<StateDecl>,
|
||||
pub ui: Option<Vec<UiElement>>,
|
||||
pub process: Block,
|
||||
pub draw: Option<Block>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CategoryKind {
|
||||
Generator,
|
||||
Effect,
|
||||
Utility,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SignalKind {
|
||||
Audio,
|
||||
Cv,
|
||||
Midi,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PortDecl {
|
||||
pub name: String,
|
||||
pub signal: SignalKind,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ParamDecl {
|
||||
pub name: String,
|
||||
pub default: f32,
|
||||
pub min: f32,
|
||||
pub max: f32,
|
||||
pub unit: String,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StateDecl {
|
||||
pub name: String,
|
||||
pub ty: StateType,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum StateType {
|
||||
F32,
|
||||
Int,
|
||||
Bool,
|
||||
ArrayF32(usize),
|
||||
ArrayInt(usize),
|
||||
Sample,
|
||||
}
|
||||
|
||||
pub type Block = Vec<Stmt>;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Stmt {
|
||||
Let {
|
||||
name: String,
|
||||
mutable: bool,
|
||||
init: Expr,
|
||||
span: Span,
|
||||
},
|
||||
Assign {
|
||||
target: LValue,
|
||||
value: Expr,
|
||||
span: Span,
|
||||
},
|
||||
If {
|
||||
cond: Expr,
|
||||
then_block: Block,
|
||||
else_block: Option<Block>,
|
||||
span: Span,
|
||||
},
|
||||
For {
|
||||
var: String,
|
||||
end: Expr,
|
||||
body: Block,
|
||||
span: Span,
|
||||
},
|
||||
ExprStmt(Expr),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum LValue {
|
||||
Ident(String, Span),
|
||||
Index(String, Box<Expr>, Span),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Expr {
|
||||
FloatLit(f32, Span),
|
||||
IntLit(i32, Span),
|
||||
BoolLit(bool, Span),
|
||||
Ident(String, Span),
|
||||
BinOp(Box<Expr>, BinOp, Box<Expr>, Span),
|
||||
UnaryOp(UnaryOp, Box<Expr>, Span),
|
||||
Call(String, Vec<Expr>, Span),
|
||||
Index(Box<Expr>, Box<Expr>, Span),
|
||||
Cast(CastKind, Box<Expr>, Span),
|
||||
}
|
||||
|
||||
impl Expr {
|
||||
pub fn span(&self) -> Span {
|
||||
match self {
|
||||
Expr::FloatLit(_, s) => *s,
|
||||
Expr::IntLit(_, s) => *s,
|
||||
Expr::BoolLit(_, s) => *s,
|
||||
Expr::Ident(_, s) => *s,
|
||||
Expr::BinOp(_, _, _, s) => *s,
|
||||
Expr::UnaryOp(_, _, s) => *s,
|
||||
Expr::Call(_, _, s) => *s,
|
||||
Expr::Index(_, _, s) => *s,
|
||||
Expr::Cast(_, _, s) => *s,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BinOp {
|
||||
Add,
|
||||
Sub,
|
||||
Mul,
|
||||
Div,
|
||||
Mod,
|
||||
Eq,
|
||||
Ne,
|
||||
Lt,
|
||||
Gt,
|
||||
Le,
|
||||
Ge,
|
||||
And,
|
||||
Or,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum UnaryOp {
|
||||
Neg,
|
||||
Not,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CastKind {
|
||||
ToInt,
|
||||
ToFloat,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,55 +0,0 @@
|
|||
use crate::token::Span;
|
||||
use std::fmt;
|
||||
|
||||
/// Compile-time error with source location
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompileError {
|
||||
pub message: String,
|
||||
pub span: Span,
|
||||
pub hint: Option<String>,
|
||||
}
|
||||
|
||||
impl CompileError {
|
||||
pub fn new(message: impl Into<String>, span: Span) -> Self {
|
||||
Self {
|
||||
message: message.into(),
|
||||
span,
|
||||
hint: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
|
||||
self.hint = Some(hint.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for CompileError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "Error at line {}, col {}: {}", self.span.line, self.span.col, self.message)?;
|
||||
if let Some(hint) = &self.hint {
|
||||
write!(f, "\n Hint: {}", hint)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Runtime error during VM execution
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ScriptError {
|
||||
ExecutionLimitExceeded,
|
||||
StackOverflow,
|
||||
StackUnderflow,
|
||||
InvalidOpcode(u8),
|
||||
}
|
||||
|
||||
impl fmt::Display for ScriptError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ScriptError::ExecutionLimitExceeded => write!(f, "Execution limit exceeded (possible infinite loop)"),
|
||||
ScriptError::StackOverflow => write!(f, "Stack overflow"),
|
||||
ScriptError::StackUnderflow => write!(f, "Stack underflow"),
|
||||
ScriptError::InvalidOpcode(op) => write!(f, "Invalid opcode: {}", op),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,306 +0,0 @@
|
|||
use crate::error::CompileError;
|
||||
use crate::token::{Span, Token, TokenKind};
|
||||
|
||||
pub struct Lexer<'a> {
|
||||
source: &'a [u8],
|
||||
pos: usize,
|
||||
line: u32,
|
||||
col: u32,
|
||||
}
|
||||
|
||||
impl<'a> Lexer<'a> {
|
||||
pub fn new(source: &'a str) -> Self {
|
||||
Self {
|
||||
source: source.as_bytes(),
|
||||
pos: 0,
|
||||
line: 1,
|
||||
col: 1,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tokenize(&mut self) -> Result<Vec<Token>, CompileError> {
|
||||
let mut tokens = Vec::new();
|
||||
loop {
|
||||
self.skip_whitespace_and_comments();
|
||||
if self.pos >= self.source.len() {
|
||||
tokens.push(Token {
|
||||
kind: TokenKind::Eof,
|
||||
span: self.span(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
tokens.push(self.next_token()?);
|
||||
}
|
||||
Ok(tokens)
|
||||
}
|
||||
|
||||
fn span(&self) -> Span {
|
||||
Span::new(self.line, self.col)
|
||||
}
|
||||
|
||||
fn peek(&self) -> Option<u8> {
|
||||
self.source.get(self.pos).copied()
|
||||
}
|
||||
|
||||
fn peek_next(&self) -> Option<u8> {
|
||||
self.source.get(self.pos + 1).copied()
|
||||
}
|
||||
|
||||
fn advance(&mut self) -> u8 {
|
||||
let ch = self.source[self.pos];
|
||||
self.pos += 1;
|
||||
if ch == b'\n' {
|
||||
self.line += 1;
|
||||
self.col = 1;
|
||||
} else {
|
||||
self.col += 1;
|
||||
}
|
||||
ch
|
||||
}
|
||||
|
||||
fn skip_whitespace_and_comments(&mut self) {
|
||||
loop {
|
||||
// Skip whitespace
|
||||
while self.pos < self.source.len() && self.source[self.pos].is_ascii_whitespace() {
|
||||
self.advance();
|
||||
}
|
||||
// Skip line comments
|
||||
if self.pos + 1 < self.source.len()
|
||||
&& self.source[self.pos] == b'/'
|
||||
&& self.source[self.pos + 1] == b'/'
|
||||
{
|
||||
while self.pos < self.source.len() && self.source[self.pos] != b'\n' {
|
||||
self.advance();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
fn next_token(&mut self) -> Result<Token, CompileError> {
|
||||
let span = self.span();
|
||||
let ch = self.advance();
|
||||
|
||||
match ch {
|
||||
b'{' => Ok(Token { kind: TokenKind::LBrace, span }),
|
||||
b'}' => Ok(Token { kind: TokenKind::RBrace, span }),
|
||||
b'[' => Ok(Token { kind: TokenKind::LBracket, span }),
|
||||
b']' => Ok(Token { kind: TokenKind::RBracket, span }),
|
||||
b'(' => Ok(Token { kind: TokenKind::LParen, span }),
|
||||
b')' => Ok(Token { kind: TokenKind::RParen, span }),
|
||||
b':' => Ok(Token { kind: TokenKind::Colon, span }),
|
||||
b',' => Ok(Token { kind: TokenKind::Comma, span }),
|
||||
b';' => Ok(Token { kind: TokenKind::Semicolon, span }),
|
||||
b'+' => Ok(Token { kind: TokenKind::Plus, span }),
|
||||
b'-' => Ok(Token { kind: TokenKind::Minus, span }),
|
||||
b'*' => Ok(Token { kind: TokenKind::Star, span }),
|
||||
b'/' => Ok(Token { kind: TokenKind::Slash, span }),
|
||||
b'%' => Ok(Token { kind: TokenKind::Percent, span }),
|
||||
|
||||
b'.' if self.peek() == Some(b'.') => {
|
||||
self.advance();
|
||||
Ok(Token { kind: TokenKind::DotDot, span })
|
||||
}
|
||||
|
||||
b'=' if self.peek() == Some(b'=') => {
|
||||
self.advance();
|
||||
Ok(Token { kind: TokenKind::EqEq, span })
|
||||
}
|
||||
b'=' => Ok(Token { kind: TokenKind::Eq, span }),
|
||||
|
||||
b'!' if self.peek() == Some(b'=') => {
|
||||
self.advance();
|
||||
Ok(Token { kind: TokenKind::BangEq, span })
|
||||
}
|
||||
b'!' => Ok(Token { kind: TokenKind::Bang, span }),
|
||||
|
||||
b'<' if self.peek() == Some(b'=') => {
|
||||
self.advance();
|
||||
Ok(Token { kind: TokenKind::LtEq, span })
|
||||
}
|
||||
b'<' => Ok(Token { kind: TokenKind::Lt, span }),
|
||||
|
||||
b'>' if self.peek() == Some(b'=') => {
|
||||
self.advance();
|
||||
Ok(Token { kind: TokenKind::GtEq, span })
|
||||
}
|
||||
b'>' => Ok(Token { kind: TokenKind::Gt, span }),
|
||||
|
||||
b'&' if self.peek() == Some(b'&') => {
|
||||
self.advance();
|
||||
Ok(Token { kind: TokenKind::AmpAmp, span })
|
||||
}
|
||||
|
||||
b'|' if self.peek() == Some(b'|') => {
|
||||
self.advance();
|
||||
Ok(Token { kind: TokenKind::PipePipe, span })
|
||||
}
|
||||
|
||||
b'"' => self.read_string(span),
|
||||
|
||||
ch if ch.is_ascii_digit() => self.read_number(ch, span),
|
||||
|
||||
ch if ch.is_ascii_alphabetic() || ch == b'_' => self.read_ident(ch, span),
|
||||
|
||||
_ => Err(CompileError::new(
|
||||
format!("Unexpected character: '{}'", ch as char),
|
||||
span,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_string(&mut self, span: Span) -> Result<Token, CompileError> {
|
||||
let mut s = String::new();
|
||||
loop {
|
||||
match self.peek() {
|
||||
Some(b'"') => {
|
||||
self.advance();
|
||||
return Ok(Token {
|
||||
kind: TokenKind::StringLit(s),
|
||||
span,
|
||||
});
|
||||
}
|
||||
Some(b'\n') | None => {
|
||||
return Err(CompileError::new("Unterminated string literal", span));
|
||||
}
|
||||
Some(_) => {
|
||||
s.push(self.advance() as char);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_number(&mut self, first: u8, span: Span) -> Result<Token, CompileError> {
|
||||
// Check for hex literal: 0x...
|
||||
if first == b'0' && self.peek() == Some(b'x') {
|
||||
self.advance(); // skip 'x'
|
||||
let mut hex = String::new();
|
||||
while let Some(ch) = self.peek() {
|
||||
if ch.is_ascii_hexdigit() {
|
||||
hex.push(self.advance() as char);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if hex.is_empty() {
|
||||
return Err(CompileError::new("Expected hex digits after 0x", span));
|
||||
}
|
||||
let val = u32::from_str_radix(&hex, 16)
|
||||
.map_err(|_| CompileError::new(format!("Invalid hex literal: 0x{}", hex), span))?;
|
||||
return Ok(Token {
|
||||
kind: TokenKind::IntLit(val as i32),
|
||||
span,
|
||||
});
|
||||
}
|
||||
|
||||
let mut s = String::new();
|
||||
s.push(first as char);
|
||||
let mut is_float = false;
|
||||
|
||||
while let Some(ch) = self.peek() {
|
||||
if ch.is_ascii_digit() {
|
||||
s.push(self.advance() as char);
|
||||
} else if ch == b'.' && self.peek_next() != Some(b'.') && !is_float {
|
||||
is_float = true;
|
||||
s.push(self.advance() as char);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if is_float {
|
||||
let val: f32 = s
|
||||
.parse()
|
||||
.map_err(|_| CompileError::new(format!("Invalid float literal: {}", s), span))?;
|
||||
Ok(Token {
|
||||
kind: TokenKind::FloatLit(val),
|
||||
span,
|
||||
})
|
||||
} else {
|
||||
let val: i32 = s
|
||||
.parse()
|
||||
.map_err(|_| CompileError::new(format!("Invalid integer literal: {}", s), span))?;
|
||||
// Check if this could be a float (e.g. 0 used in float context)
|
||||
// For now, emit as IntLit; parser/validator handles coercion
|
||||
Ok(Token {
|
||||
kind: TokenKind::IntLit(val),
|
||||
span,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn read_ident(&mut self, first: u8, span: Span) -> Result<Token, CompileError> {
|
||||
let mut s = String::new();
|
||||
s.push(first as char);
|
||||
|
||||
while let Some(ch) = self.peek() {
|
||||
if ch.is_ascii_alphanumeric() || ch == b'_' {
|
||||
s.push(self.advance() as char);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Token {
|
||||
kind: TokenKind::from_ident(&s),
|
||||
span,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_simple_tokens() {
|
||||
let mut lexer = Lexer::new("name \"Test\" category effect");
|
||||
let tokens = lexer.tokenize().unwrap();
|
||||
assert_eq!(tokens[0].kind, TokenKind::Name);
|
||||
assert_eq!(tokens[1].kind, TokenKind::StringLit("Test".into()));
|
||||
assert_eq!(tokens[2].kind, TokenKind::Category);
|
||||
assert_eq!(tokens[3].kind, TokenKind::Effect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_numbers() {
|
||||
let mut lexer = Lexer::new("42 3.14 0.5");
|
||||
let tokens = lexer.tokenize().unwrap();
|
||||
assert_eq!(tokens[0].kind, TokenKind::IntLit(42));
|
||||
assert_eq!(tokens[1].kind, TokenKind::FloatLit(3.14));
|
||||
assert_eq!(tokens[2].kind, TokenKind::FloatLit(0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_operators() {
|
||||
let mut lexer = Lexer::new("== != <= >= && || ..");
|
||||
let tokens = lexer.tokenize().unwrap();
|
||||
assert_eq!(tokens[0].kind, TokenKind::EqEq);
|
||||
assert_eq!(tokens[1].kind, TokenKind::BangEq);
|
||||
assert_eq!(tokens[2].kind, TokenKind::LtEq);
|
||||
assert_eq!(tokens[3].kind, TokenKind::GtEq);
|
||||
assert_eq!(tokens[4].kind, TokenKind::AmpAmp);
|
||||
assert_eq!(tokens[5].kind, TokenKind::PipePipe);
|
||||
assert_eq!(tokens[6].kind, TokenKind::DotDot);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_comments() {
|
||||
let mut lexer = Lexer::new("let x = 5; // comment\nlet y = 10;");
|
||||
let tokens = lexer.tokenize().unwrap();
|
||||
// Should skip the comment
|
||||
assert_eq!(tokens[0].kind, TokenKind::Let);
|
||||
assert_eq!(tokens[5].kind, TokenKind::Let);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_range_vs_float() {
|
||||
// "0..10" should parse as IntLit(0), DotDot, IntLit(10), not as a float
|
||||
let mut lexer = Lexer::new("0..10");
|
||||
let tokens = lexer.tokenize().unwrap();
|
||||
assert_eq!(tokens[0].kind, TokenKind::IntLit(0));
|
||||
assert_eq!(tokens[1].kind, TokenKind::DotDot);
|
||||
assert_eq!(tokens[2].kind, TokenKind::IntLit(10));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
pub mod ast;
|
||||
pub mod error;
|
||||
pub mod lexer;
|
||||
pub mod token;
|
||||
pub mod ui_decl;
|
||||
pub mod parser;
|
||||
pub mod validator;
|
||||
pub mod opcodes;
|
||||
pub mod codegen;
|
||||
pub mod vm;
|
||||
|
||||
use error::CompileError;
|
||||
use lexer::Lexer;
|
||||
use parser::Parser;
|
||||
|
||||
pub use error::ScriptError;
|
||||
pub use ui_decl::{UiDeclaration, UiElement};
|
||||
pub use vm::{ScriptVM, SampleSlot, DrawVM, DrawCommand, MouseState};
|
||||
|
||||
/// Compiled script metadata — everything needed to create a ScriptNode
|
||||
pub struct CompiledScript {
|
||||
pub vm: ScriptVM,
|
||||
pub name: String,
|
||||
pub category: ast::CategoryKind,
|
||||
pub input_ports: Vec<PortInfo>,
|
||||
pub output_ports: Vec<PortInfo>,
|
||||
pub parameters: Vec<ParamInfo>,
|
||||
pub sample_slots: Vec<String>,
|
||||
pub ui_declaration: UiDeclaration,
|
||||
pub source: String,
|
||||
pub draw_vm: Option<DrawVM>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PortInfo {
|
||||
pub name: String,
|
||||
pub signal: ast::SignalKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ParamInfo {
|
||||
pub name: String,
|
||||
pub min: f32,
|
||||
pub max: f32,
|
||||
pub default: f32,
|
||||
pub unit: String,
|
||||
}
|
||||
|
||||
/// Compile BeamDSP source code into a ready-to-run script
|
||||
pub fn compile(source: &str) -> Result<CompiledScript, CompileError> {
|
||||
let mut lexer = Lexer::new(source);
|
||||
let tokens = lexer.tokenize()?;
|
||||
|
||||
let mut parser = Parser::new(&tokens);
|
||||
let script = parser.parse()?;
|
||||
|
||||
let validated = validator::validate(&script)?;
|
||||
|
||||
let (vm, ui_decl, draw_vm) = codegen::compile(&validated)?;
|
||||
|
||||
let input_ports = script
|
||||
.inputs
|
||||
.iter()
|
||||
.map(|p| PortInfo {
|
||||
name: p.name.clone(),
|
||||
signal: p.signal,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let output_ports = script
|
||||
.outputs
|
||||
.iter()
|
||||
.map(|p| PortInfo {
|
||||
name: p.name.clone(),
|
||||
signal: p.signal,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let parameters = script
|
||||
.params
|
||||
.iter()
|
||||
.map(|p| ParamInfo {
|
||||
name: p.name.clone(),
|
||||
min: p.min,
|
||||
max: p.max,
|
||||
default: p.default,
|
||||
unit: p.unit.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let sample_slots = script
|
||||
.state
|
||||
.iter()
|
||||
.filter(|s| s.ty == ast::StateType::Sample)
|
||||
.map(|s| s.name.clone())
|
||||
.collect();
|
||||
|
||||
Ok(CompiledScript {
|
||||
vm,
|
||||
name: script.name.clone(),
|
||||
category: script.category,
|
||||
input_ports,
|
||||
output_ports,
|
||||
parameters,
|
||||
sample_slots,
|
||||
ui_declaration: ui_decl,
|
||||
source: source.to_string(),
|
||||
draw_vm,
|
||||
})
|
||||
}
|
||||
|
|
@ -1,223 +0,0 @@
|
|||
/// Bytecode opcodes for the BeamDSP VM
|
||||
#[repr(u8)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OpCode {
|
||||
// Stack operations
|
||||
PushF32 = 0, // next 4 bytes: f32 constant index (u16)
|
||||
PushI32 = 1, // next 2 bytes: i32 constant index (u16)
|
||||
PushBool = 2, // next 1 byte: 0 or 1
|
||||
Pop = 3,
|
||||
|
||||
// Variable access (all use u16 index)
|
||||
LoadLocal = 10,
|
||||
StoreLocal = 11,
|
||||
LoadParam = 12,
|
||||
LoadState = 13,
|
||||
StoreState = 14,
|
||||
|
||||
// Buffer access (u8 port index)
|
||||
// LoadInput: pops index from stack, pushes input[port][index]
|
||||
LoadInput = 20,
|
||||
// StoreOutput: pops value then index, stores output[port][index] = value
|
||||
StoreOutput = 21,
|
||||
// State arrays (u16 array id)
|
||||
LoadStateArray = 22, // pops index, pushes state_array[id][index]
|
||||
StoreStateArray = 23, // pops value then index, stores state_array[id][index]
|
||||
|
||||
// Sample access (u8 slot index)
|
||||
SampleLen = 25, // pushes frame count
|
||||
SampleRead = 26, // pops index, pushes sample data
|
||||
SampleRateOf = 27, // pushes sample rate
|
||||
|
||||
// Float arithmetic
|
||||
AddF = 30,
|
||||
SubF = 31,
|
||||
MulF = 32,
|
||||
DivF = 33,
|
||||
ModF = 34,
|
||||
NegF = 35,
|
||||
|
||||
// Int arithmetic
|
||||
AddI = 40,
|
||||
SubI = 41,
|
||||
MulI = 42,
|
||||
DivI = 43,
|
||||
ModI = 44,
|
||||
NegI = 45,
|
||||
|
||||
// Float comparison (push bool)
|
||||
EqF = 50,
|
||||
NeF = 51,
|
||||
LtF = 52,
|
||||
GtF = 53,
|
||||
LeF = 54,
|
||||
GeF = 55,
|
||||
|
||||
// Int comparison (push bool)
|
||||
EqI = 60,
|
||||
NeI = 61,
|
||||
LtI = 62,
|
||||
GtI = 63,
|
||||
LeI = 64,
|
||||
GeI = 65,
|
||||
|
||||
// Logical
|
||||
And = 70,
|
||||
Or = 71,
|
||||
Not = 72,
|
||||
|
||||
// Type conversion
|
||||
F32ToI32 = 80,
|
||||
I32ToF32 = 81,
|
||||
|
||||
// Control flow (u32 offset)
|
||||
Jump = 90,
|
||||
JumpIfFalse = 91,
|
||||
|
||||
// Built-in math functions (operate on stack)
|
||||
Sin = 100,
|
||||
Cos = 101,
|
||||
Tan = 102,
|
||||
Asin = 103,
|
||||
Acos = 104,
|
||||
Atan = 105,
|
||||
Atan2 = 106,
|
||||
Exp = 107,
|
||||
Log = 108,
|
||||
Log2 = 109,
|
||||
Pow = 110,
|
||||
Sqrt = 111,
|
||||
Floor = 112,
|
||||
Ceil = 113,
|
||||
Round = 114,
|
||||
Trunc = 115,
|
||||
Fract = 116,
|
||||
Abs = 117,
|
||||
Clamp = 118,
|
||||
Min = 119,
|
||||
Max = 120,
|
||||
Sign = 121,
|
||||
Mix = 122,
|
||||
Smoothstep = 123,
|
||||
IsNan = 124,
|
||||
|
||||
// Array/buffer info
|
||||
ArrayLen = 130, // u16 array_id, pushes length as int
|
||||
|
||||
// Built-in constants
|
||||
LoadSampleRate = 140,
|
||||
LoadBufferSize = 141,
|
||||
|
||||
// Draw commands (pop args from stack, push to draw command buffer)
|
||||
DrawFillCircle = 150, // pops: color(i32), r, cy, cx
|
||||
DrawStrokeCircle = 151, // pops: width, color(i32), r, cy, cx
|
||||
DrawStrokeArc = 152, // pops: width, color(i32), end_deg, start_deg, r, cy, cx
|
||||
DrawLine = 153, // pops: width, color(i32), y2, x2, y1, x1
|
||||
DrawFillRect = 154, // pops: color(i32), h, w, y, x
|
||||
DrawStrokeRect = 155, // pops: width, color(i32), h, w, y, x
|
||||
|
||||
// Mouse input (push onto stack)
|
||||
MouseX = 160, // pushes canvas-relative X as f32
|
||||
MouseY = 161, // pushes canvas-relative Y as f32
|
||||
MouseDown = 162, // pushes 1.0 if pressed, 0.0 if not
|
||||
|
||||
// Param write (draw context only)
|
||||
StoreParam = 170, // u16 param index, pops value from stack
|
||||
|
||||
Halt = 255,
|
||||
}
|
||||
|
||||
impl OpCode {
|
||||
pub fn from_u8(v: u8) -> Option<OpCode> {
|
||||
// Safety: we validate the opcode values
|
||||
match v {
|
||||
0 => Some(OpCode::PushF32),
|
||||
1 => Some(OpCode::PushI32),
|
||||
2 => Some(OpCode::PushBool),
|
||||
3 => Some(OpCode::Pop),
|
||||
10 => Some(OpCode::LoadLocal),
|
||||
11 => Some(OpCode::StoreLocal),
|
||||
12 => Some(OpCode::LoadParam),
|
||||
13 => Some(OpCode::LoadState),
|
||||
14 => Some(OpCode::StoreState),
|
||||
20 => Some(OpCode::LoadInput),
|
||||
21 => Some(OpCode::StoreOutput),
|
||||
22 => Some(OpCode::LoadStateArray),
|
||||
23 => Some(OpCode::StoreStateArray),
|
||||
25 => Some(OpCode::SampleLen),
|
||||
26 => Some(OpCode::SampleRead),
|
||||
27 => Some(OpCode::SampleRateOf),
|
||||
30 => Some(OpCode::AddF),
|
||||
31 => Some(OpCode::SubF),
|
||||
32 => Some(OpCode::MulF),
|
||||
33 => Some(OpCode::DivF),
|
||||
34 => Some(OpCode::ModF),
|
||||
35 => Some(OpCode::NegF),
|
||||
40 => Some(OpCode::AddI),
|
||||
41 => Some(OpCode::SubI),
|
||||
42 => Some(OpCode::MulI),
|
||||
43 => Some(OpCode::DivI),
|
||||
44 => Some(OpCode::ModI),
|
||||
45 => Some(OpCode::NegI),
|
||||
50 => Some(OpCode::EqF),
|
||||
51 => Some(OpCode::NeF),
|
||||
52 => Some(OpCode::LtF),
|
||||
53 => Some(OpCode::GtF),
|
||||
54 => Some(OpCode::LeF),
|
||||
55 => Some(OpCode::GeF),
|
||||
60 => Some(OpCode::EqI),
|
||||
61 => Some(OpCode::NeI),
|
||||
62 => Some(OpCode::LtI),
|
||||
63 => Some(OpCode::GtI),
|
||||
64 => Some(OpCode::LeI),
|
||||
65 => Some(OpCode::GeI),
|
||||
70 => Some(OpCode::And),
|
||||
71 => Some(OpCode::Or),
|
||||
72 => Some(OpCode::Not),
|
||||
80 => Some(OpCode::F32ToI32),
|
||||
81 => Some(OpCode::I32ToF32),
|
||||
90 => Some(OpCode::Jump),
|
||||
91 => Some(OpCode::JumpIfFalse),
|
||||
100 => Some(OpCode::Sin),
|
||||
101 => Some(OpCode::Cos),
|
||||
102 => Some(OpCode::Tan),
|
||||
103 => Some(OpCode::Asin),
|
||||
104 => Some(OpCode::Acos),
|
||||
105 => Some(OpCode::Atan),
|
||||
106 => Some(OpCode::Atan2),
|
||||
107 => Some(OpCode::Exp),
|
||||
108 => Some(OpCode::Log),
|
||||
109 => Some(OpCode::Log2),
|
||||
110 => Some(OpCode::Pow),
|
||||
111 => Some(OpCode::Sqrt),
|
||||
112 => Some(OpCode::Floor),
|
||||
113 => Some(OpCode::Ceil),
|
||||
114 => Some(OpCode::Round),
|
||||
115 => Some(OpCode::Trunc),
|
||||
116 => Some(OpCode::Fract),
|
||||
117 => Some(OpCode::Abs),
|
||||
118 => Some(OpCode::Clamp),
|
||||
119 => Some(OpCode::Min),
|
||||
120 => Some(OpCode::Max),
|
||||
121 => Some(OpCode::Sign),
|
||||
122 => Some(OpCode::Mix),
|
||||
123 => Some(OpCode::Smoothstep),
|
||||
124 => Some(OpCode::IsNan),
|
||||
130 => Some(OpCode::ArrayLen),
|
||||
140 => Some(OpCode::LoadSampleRate),
|
||||
141 => Some(OpCode::LoadBufferSize),
|
||||
150 => Some(OpCode::DrawFillCircle),
|
||||
151 => Some(OpCode::DrawStrokeCircle),
|
||||
152 => Some(OpCode::DrawStrokeArc),
|
||||
153 => Some(OpCode::DrawLine),
|
||||
154 => Some(OpCode::DrawFillRect),
|
||||
155 => Some(OpCode::DrawStrokeRect),
|
||||
160 => Some(OpCode::MouseX),
|
||||
161 => Some(OpCode::MouseY),
|
||||
162 => Some(OpCode::MouseDown),
|
||||
170 => Some(OpCode::StoreParam),
|
||||
255 => Some(OpCode::Halt),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,771 +0,0 @@
|
|||
use crate::ast::*;
|
||||
use crate::error::CompileError;
|
||||
use crate::token::{Span, Token, TokenKind};
|
||||
use crate::ui_decl::UiElement;
|
||||
|
||||
pub struct Parser<'a> {
|
||||
tokens: &'a [Token],
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> Parser<'a> {
|
||||
pub fn new(tokens: &'a [Token]) -> Self {
|
||||
Self { tokens, pos: 0 }
|
||||
}
|
||||
|
||||
fn peek(&self) -> &TokenKind {
|
||||
&self.tokens[self.pos].kind
|
||||
}
|
||||
|
||||
fn span(&self) -> Span {
|
||||
self.tokens[self.pos].span
|
||||
}
|
||||
|
||||
fn advance(&mut self) -> &Token {
|
||||
let tok = &self.tokens[self.pos];
|
||||
if self.pos + 1 < self.tokens.len() {
|
||||
self.pos += 1;
|
||||
}
|
||||
tok
|
||||
}
|
||||
|
||||
fn expect(&mut self, expected: &TokenKind) -> Result<&Token, CompileError> {
|
||||
if std::mem::discriminant(self.peek()) == std::mem::discriminant(expected) {
|
||||
Ok(self.advance())
|
||||
} else {
|
||||
Err(CompileError::new(
|
||||
format!("Expected {:?}, found {:?}", expected, self.peek()),
|
||||
self.span(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn expect_ident(&mut self) -> Result<String, CompileError> {
|
||||
match self.peek().clone() {
|
||||
TokenKind::Ident(name) => {
|
||||
let name = name.clone();
|
||||
self.advance();
|
||||
Ok(name)
|
||||
}
|
||||
_ => Err(CompileError::new(
|
||||
format!("Expected identifier, found {:?}", self.peek()),
|
||||
self.span(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn expect_string(&mut self) -> Result<String, CompileError> {
|
||||
match self.peek().clone() {
|
||||
TokenKind::StringLit(s) => {
|
||||
let s = s.clone();
|
||||
self.advance();
|
||||
Ok(s)
|
||||
}
|
||||
_ => Err(CompileError::new(
|
||||
format!("Expected string literal, found {:?}", self.peek()),
|
||||
self.span(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn eat(&mut self, kind: &TokenKind) -> bool {
|
||||
if std::mem::discriminant(self.peek()) == std::mem::discriminant(kind) {
|
||||
self.advance();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(&mut self) -> Result<Script, CompileError> {
|
||||
let mut name = String::new();
|
||||
let mut category = CategoryKind::Utility;
|
||||
let mut inputs = Vec::new();
|
||||
let mut outputs = Vec::new();
|
||||
let mut params = Vec::new();
|
||||
let mut state = Vec::new();
|
||||
let mut ui = None;
|
||||
let mut process = Vec::new();
|
||||
let mut draw = None;
|
||||
|
||||
while *self.peek() != TokenKind::Eof {
|
||||
match self.peek() {
|
||||
TokenKind::Name => {
|
||||
self.advance();
|
||||
name = self.expect_string()?;
|
||||
}
|
||||
TokenKind::Category => {
|
||||
self.advance();
|
||||
category = match self.peek() {
|
||||
TokenKind::Generator => { self.advance(); CategoryKind::Generator }
|
||||
TokenKind::Effect => { self.advance(); CategoryKind::Effect }
|
||||
TokenKind::Utility => { self.advance(); CategoryKind::Utility }
|
||||
_ => {
|
||||
return Err(CompileError::new(
|
||||
"Expected generator, effect, or utility",
|
||||
self.span(),
|
||||
));
|
||||
}
|
||||
};
|
||||
}
|
||||
TokenKind::Inputs => {
|
||||
self.advance();
|
||||
inputs = self.parse_port_block()?;
|
||||
}
|
||||
TokenKind::Outputs => {
|
||||
self.advance();
|
||||
outputs = self.parse_port_block()?;
|
||||
}
|
||||
TokenKind::Params => {
|
||||
self.advance();
|
||||
params = self.parse_params_block()?;
|
||||
}
|
||||
TokenKind::State => {
|
||||
self.advance();
|
||||
state = self.parse_state_block()?;
|
||||
}
|
||||
TokenKind::Ui => {
|
||||
self.advance();
|
||||
ui = Some(self.parse_ui_block()?);
|
||||
}
|
||||
TokenKind::Process => {
|
||||
self.advance();
|
||||
process = self.parse_block()?;
|
||||
}
|
||||
TokenKind::Draw => {
|
||||
self.advance();
|
||||
draw = Some(self.parse_block()?);
|
||||
}
|
||||
_ => {
|
||||
return Err(CompileError::new(
|
||||
format!("Unexpected token {:?} at top level", self.peek()),
|
||||
self.span(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if name.is_empty() {
|
||||
return Err(CompileError::new(
|
||||
"Script must have a name declaration",
|
||||
Span::new(1, 1),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Script {
|
||||
name,
|
||||
category,
|
||||
inputs,
|
||||
outputs,
|
||||
params,
|
||||
state,
|
||||
ui,
|
||||
process,
|
||||
draw,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_port_block(&mut self) -> Result<Vec<PortDecl>, CompileError> {
|
||||
self.expect(&TokenKind::LBrace)?;
|
||||
let mut ports = Vec::new();
|
||||
while *self.peek() != TokenKind::RBrace {
|
||||
let span = self.span();
|
||||
let name = self.expect_ident()?;
|
||||
self.expect(&TokenKind::Colon)?;
|
||||
let signal = match self.peek() {
|
||||
TokenKind::Audio => { self.advance(); SignalKind::Audio }
|
||||
TokenKind::Cv => { self.advance(); SignalKind::Cv }
|
||||
TokenKind::Midi => { self.advance(); SignalKind::Midi }
|
||||
_ => {
|
||||
return Err(CompileError::new(
|
||||
"Expected audio, cv, or midi",
|
||||
self.span(),
|
||||
));
|
||||
}
|
||||
};
|
||||
ports.push(PortDecl { name, signal, span });
|
||||
}
|
||||
self.expect(&TokenKind::RBrace)?;
|
||||
Ok(ports)
|
||||
}
|
||||
|
||||
fn parse_params_block(&mut self) -> Result<Vec<ParamDecl>, CompileError> {
|
||||
self.expect(&TokenKind::LBrace)?;
|
||||
let mut params = Vec::new();
|
||||
while *self.peek() != TokenKind::RBrace {
|
||||
let span = self.span();
|
||||
let name = self.expect_ident()?;
|
||||
self.expect(&TokenKind::Colon)?;
|
||||
let default = self.parse_number()?;
|
||||
self.expect(&TokenKind::LBracket)?;
|
||||
let min = self.parse_number()?;
|
||||
self.expect(&TokenKind::Comma)?;
|
||||
let max = self.parse_number()?;
|
||||
self.expect(&TokenKind::RBracket)?;
|
||||
let unit = self.expect_string()?;
|
||||
params.push(ParamDecl {
|
||||
name,
|
||||
default,
|
||||
min,
|
||||
max,
|
||||
unit,
|
||||
span,
|
||||
});
|
||||
}
|
||||
self.expect(&TokenKind::RBrace)?;
|
||||
Ok(params)
|
||||
}
|
||||
|
||||
fn parse_number(&mut self) -> Result<f32, CompileError> {
|
||||
let negative = self.eat(&TokenKind::Minus);
|
||||
let val = match self.peek() {
|
||||
TokenKind::FloatLit(v) => {
|
||||
let v = *v;
|
||||
self.advance();
|
||||
v
|
||||
}
|
||||
TokenKind::IntLit(v) => {
|
||||
let v = *v as f32;
|
||||
self.advance();
|
||||
v
|
||||
}
|
||||
_ => {
|
||||
return Err(CompileError::new(
|
||||
format!("Expected number, found {:?}", self.peek()),
|
||||
self.span(),
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok(if negative { -val } else { val })
|
||||
}
|
||||
|
||||
fn parse_state_block(&mut self) -> Result<Vec<StateDecl>, CompileError> {
|
||||
self.expect(&TokenKind::LBrace)?;
|
||||
let mut decls = Vec::new();
|
||||
while *self.peek() != TokenKind::RBrace {
|
||||
let span = self.span();
|
||||
let name = self.expect_ident()?;
|
||||
self.expect(&TokenKind::Colon)?;
|
||||
let ty = self.parse_state_type()?;
|
||||
decls.push(StateDecl { name, ty, span });
|
||||
}
|
||||
self.expect(&TokenKind::RBrace)?;
|
||||
Ok(decls)
|
||||
}
|
||||
|
||||
fn parse_state_type(&mut self) -> Result<StateType, CompileError> {
|
||||
match self.peek() {
|
||||
TokenKind::F32 => { self.advance(); Ok(StateType::F32) }
|
||||
TokenKind::Int => { self.advance(); Ok(StateType::Int) }
|
||||
TokenKind::Bool => { self.advance(); Ok(StateType::Bool) }
|
||||
TokenKind::Sample => { self.advance(); Ok(StateType::Sample) }
|
||||
TokenKind::LBracket => {
|
||||
self.advance();
|
||||
let size = match self.peek() {
|
||||
TokenKind::IntLit(n) => {
|
||||
let n = *n as usize;
|
||||
self.advance();
|
||||
n
|
||||
}
|
||||
_ => {
|
||||
return Err(CompileError::new(
|
||||
"Expected integer size for array",
|
||||
self.span(),
|
||||
));
|
||||
}
|
||||
};
|
||||
self.expect(&TokenKind::RBracket)?;
|
||||
match self.peek() {
|
||||
TokenKind::F32 => { self.advance(); Ok(StateType::ArrayF32(size)) }
|
||||
TokenKind::Int => { self.advance(); Ok(StateType::ArrayInt(size)) }
|
||||
_ => Err(CompileError::new("Expected f32 or int after array size", self.span())),
|
||||
}
|
||||
}
|
||||
_ => Err(CompileError::new(
|
||||
format!("Expected type (f32, int, bool, sample, [N]f32, [N]int), found {:?}", self.peek()),
|
||||
self.span(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_ui_block(&mut self) -> Result<Vec<UiElement>, CompileError> {
|
||||
self.expect(&TokenKind::LBrace)?;
|
||||
let mut elements = Vec::new();
|
||||
while *self.peek() != TokenKind::RBrace {
|
||||
elements.push(self.parse_ui_element()?);
|
||||
}
|
||||
self.expect(&TokenKind::RBrace)?;
|
||||
Ok(elements)
|
||||
}
|
||||
|
||||
fn parse_ui_element(&mut self) -> Result<UiElement, CompileError> {
|
||||
match self.peek() {
|
||||
TokenKind::Param => {
|
||||
self.advance();
|
||||
let name = self.expect_ident()?;
|
||||
Ok(UiElement::Param(name))
|
||||
}
|
||||
TokenKind::Sample => {
|
||||
self.advance();
|
||||
let name = self.expect_ident()?;
|
||||
Ok(UiElement::Sample(name))
|
||||
}
|
||||
TokenKind::Group => {
|
||||
self.advance();
|
||||
let label = self.expect_string()?;
|
||||
let children = self.parse_ui_block()?;
|
||||
Ok(UiElement::Group { label, children })
|
||||
}
|
||||
TokenKind::Canvas => {
|
||||
self.advance();
|
||||
self.expect(&TokenKind::LBracket)?;
|
||||
let width = self.parse_number()?;
|
||||
self.expect(&TokenKind::Comma)?;
|
||||
let height = self.parse_number()?;
|
||||
self.expect(&TokenKind::RBracket)?;
|
||||
Ok(UiElement::Canvas { width, height })
|
||||
}
|
||||
TokenKind::Spacer => {
|
||||
self.advance();
|
||||
let px = self.parse_number()?;
|
||||
Ok(UiElement::Spacer(px))
|
||||
}
|
||||
_ => Err(CompileError::new(
|
||||
format!("Expected UI element (param, sample, group, canvas, spacer), found {:?}", self.peek()),
|
||||
self.span(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_block(&mut self) -> Result<Block, CompileError> {
|
||||
self.expect(&TokenKind::LBrace)?;
|
||||
let mut stmts = Vec::new();
|
||||
while *self.peek() != TokenKind::RBrace {
|
||||
stmts.push(self.parse_stmt()?);
|
||||
}
|
||||
self.expect(&TokenKind::RBrace)?;
|
||||
Ok(stmts)
|
||||
}
|
||||
|
||||
fn parse_stmt(&mut self) -> Result<Stmt, CompileError> {
|
||||
match self.peek() {
|
||||
TokenKind::Let => self.parse_let(),
|
||||
TokenKind::If => self.parse_if(),
|
||||
TokenKind::For => self.parse_for(),
|
||||
_ => {
|
||||
// Assignment or expression statement
|
||||
let span = self.span();
|
||||
let expr = self.parse_expr()?;
|
||||
|
||||
if self.eat(&TokenKind::Eq) {
|
||||
// This is an assignment: expr = value
|
||||
let value = self.parse_expr()?;
|
||||
self.eat(&TokenKind::Semicolon);
|
||||
let target = self.expr_to_lvalue(expr, span)?;
|
||||
Ok(Stmt::Assign { target, value, span })
|
||||
} else {
|
||||
self.eat(&TokenKind::Semicolon);
|
||||
Ok(Stmt::ExprStmt(expr))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn expr_to_lvalue(&self, expr: Expr, span: Span) -> Result<LValue, CompileError> {
|
||||
match expr {
|
||||
Expr::Ident(name, s) => Ok(LValue::Ident(name, s)),
|
||||
Expr::Index(base, idx, s) => {
|
||||
if let Expr::Ident(name, _) = *base {
|
||||
Ok(LValue::Index(name, idx, s))
|
||||
} else {
|
||||
Err(CompileError::new("Invalid assignment target", span))
|
||||
}
|
||||
}
|
||||
_ => Err(CompileError::new("Invalid assignment target", span)),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_let(&mut self) -> Result<Stmt, CompileError> {
|
||||
let span = self.span();
|
||||
self.advance(); // consume 'let'
|
||||
let mutable = self.eat(&TokenKind::Mut);
|
||||
let name = self.expect_ident()?;
|
||||
self.expect(&TokenKind::Eq)?;
|
||||
let init = self.parse_expr()?;
|
||||
self.eat(&TokenKind::Semicolon);
|
||||
Ok(Stmt::Let {
|
||||
name,
|
||||
mutable,
|
||||
init,
|
||||
span,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_if(&mut self) -> Result<Stmt, CompileError> {
|
||||
let span = self.span();
|
||||
self.advance(); // consume 'if'
|
||||
let cond = self.parse_expr()?;
|
||||
let then_block = self.parse_block()?;
|
||||
let else_block = if self.eat(&TokenKind::Else) {
|
||||
if *self.peek() == TokenKind::If {
|
||||
// else if -> wrap in a block with single if statement
|
||||
Some(vec![self.parse_if()?])
|
||||
} else {
|
||||
Some(self.parse_block()?)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(Stmt::If {
|
||||
cond,
|
||||
then_block,
|
||||
else_block,
|
||||
span,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_for(&mut self) -> Result<Stmt, CompileError> {
|
||||
let span = self.span();
|
||||
self.advance(); // consume 'for'
|
||||
let var = self.expect_ident()?;
|
||||
self.expect(&TokenKind::In)?;
|
||||
// Expect 0..end
|
||||
let zero_span = self.span();
|
||||
match self.peek() {
|
||||
TokenKind::IntLit(0) => { self.advance(); }
|
||||
_ => {
|
||||
return Err(CompileError::new(
|
||||
"For loop range must start at 0 (e.g. 0..buffer_size)",
|
||||
zero_span,
|
||||
));
|
||||
}
|
||||
}
|
||||
self.expect(&TokenKind::DotDot)?;
|
||||
let end = self.parse_expr()?;
|
||||
let body = self.parse_block()?;
|
||||
Ok(Stmt::For {
|
||||
var,
|
||||
end,
|
||||
body,
|
||||
span,
|
||||
})
|
||||
}
|
||||
|
||||
// Expression parsing with precedence climbing
|
||||
|
||||
fn parse_expr(&mut self) -> Result<Expr, CompileError> {
|
||||
self.parse_or()
|
||||
}
|
||||
|
||||
fn parse_or(&mut self) -> Result<Expr, CompileError> {
|
||||
let mut left = self.parse_and()?;
|
||||
while *self.peek() == TokenKind::PipePipe {
|
||||
let span = self.span();
|
||||
self.advance();
|
||||
let right = self.parse_and()?;
|
||||
left = Expr::BinOp(Box::new(left), BinOp::Or, Box::new(right), span);
|
||||
}
|
||||
Ok(left)
|
||||
}
|
||||
|
||||
fn parse_and(&mut self) -> Result<Expr, CompileError> {
|
||||
let mut left = self.parse_equality()?;
|
||||
while *self.peek() == TokenKind::AmpAmp {
|
||||
let span = self.span();
|
||||
self.advance();
|
||||
let right = self.parse_equality()?;
|
||||
left = Expr::BinOp(Box::new(left), BinOp::And, Box::new(right), span);
|
||||
}
|
||||
Ok(left)
|
||||
}
|
||||
|
||||
fn parse_equality(&mut self) -> Result<Expr, CompileError> {
|
||||
let mut left = self.parse_comparison()?;
|
||||
loop {
|
||||
let op = match self.peek() {
|
||||
TokenKind::EqEq => BinOp::Eq,
|
||||
TokenKind::BangEq => BinOp::Ne,
|
||||
_ => break,
|
||||
};
|
||||
let span = self.span();
|
||||
self.advance();
|
||||
let right = self.parse_comparison()?;
|
||||
left = Expr::BinOp(Box::new(left), op, Box::new(right), span);
|
||||
}
|
||||
Ok(left)
|
||||
}
|
||||
|
||||
fn parse_comparison(&mut self) -> Result<Expr, CompileError> {
|
||||
let mut left = self.parse_additive()?;
|
||||
loop {
|
||||
let op = match self.peek() {
|
||||
TokenKind::Lt => BinOp::Lt,
|
||||
TokenKind::Gt => BinOp::Gt,
|
||||
TokenKind::LtEq => BinOp::Le,
|
||||
TokenKind::GtEq => BinOp::Ge,
|
||||
_ => break,
|
||||
};
|
||||
let span = self.span();
|
||||
self.advance();
|
||||
let right = self.parse_additive()?;
|
||||
left = Expr::BinOp(Box::new(left), op, Box::new(right), span);
|
||||
}
|
||||
Ok(left)
|
||||
}
|
||||
|
||||
fn parse_additive(&mut self) -> Result<Expr, CompileError> {
|
||||
let mut left = self.parse_multiplicative()?;
|
||||
loop {
|
||||
let op = match self.peek() {
|
||||
TokenKind::Plus => BinOp::Add,
|
||||
TokenKind::Minus => BinOp::Sub,
|
||||
_ => break,
|
||||
};
|
||||
let span = self.span();
|
||||
self.advance();
|
||||
let right = self.parse_multiplicative()?;
|
||||
left = Expr::BinOp(Box::new(left), op, Box::new(right), span);
|
||||
}
|
||||
Ok(left)
|
||||
}
|
||||
|
||||
fn parse_multiplicative(&mut self) -> Result<Expr, CompileError> {
|
||||
let mut left = self.parse_unary()?;
|
||||
loop {
|
||||
let op = match self.peek() {
|
||||
TokenKind::Star => BinOp::Mul,
|
||||
TokenKind::Slash => BinOp::Div,
|
||||
TokenKind::Percent => BinOp::Mod,
|
||||
_ => break,
|
||||
};
|
||||
let span = self.span();
|
||||
self.advance();
|
||||
let right = self.parse_unary()?;
|
||||
left = Expr::BinOp(Box::new(left), op, Box::new(right), span);
|
||||
}
|
||||
Ok(left)
|
||||
}
|
||||
|
||||
fn parse_unary(&mut self) -> Result<Expr, CompileError> {
|
||||
match self.peek() {
|
||||
TokenKind::Minus => {
|
||||
let span = self.span();
|
||||
self.advance();
|
||||
let expr = self.parse_unary()?;
|
||||
Ok(Expr::UnaryOp(UnaryOp::Neg, Box::new(expr), span))
|
||||
}
|
||||
TokenKind::Bang => {
|
||||
let span = self.span();
|
||||
self.advance();
|
||||
let expr = self.parse_unary()?;
|
||||
Ok(Expr::UnaryOp(UnaryOp::Not, Box::new(expr), span))
|
||||
}
|
||||
_ => self.parse_postfix(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_postfix(&mut self) -> Result<Expr, CompileError> {
|
||||
let mut expr = self.parse_primary()?;
|
||||
|
||||
// Handle indexing: expr[index]
|
||||
while *self.peek() == TokenKind::LBracket {
|
||||
let span = self.span();
|
||||
self.advance();
|
||||
let index = self.parse_expr()?;
|
||||
self.expect(&TokenKind::RBracket)?;
|
||||
expr = Expr::Index(Box::new(expr), Box::new(index), span);
|
||||
}
|
||||
|
||||
Ok(expr)
|
||||
}
|
||||
|
||||
fn parse_primary(&mut self) -> Result<Expr, CompileError> {
|
||||
let span = self.span();
|
||||
match self.peek().clone() {
|
||||
TokenKind::FloatLit(v) => {
|
||||
self.advance();
|
||||
Ok(Expr::FloatLit(v, span))
|
||||
}
|
||||
TokenKind::IntLit(v) => {
|
||||
self.advance();
|
||||
Ok(Expr::IntLit(v, span))
|
||||
}
|
||||
TokenKind::True => {
|
||||
self.advance();
|
||||
Ok(Expr::BoolLit(true, span))
|
||||
}
|
||||
TokenKind::False => {
|
||||
self.advance();
|
||||
Ok(Expr::BoolLit(false, span))
|
||||
}
|
||||
TokenKind::LParen => {
|
||||
self.advance();
|
||||
let expr = self.parse_expr()?;
|
||||
self.expect(&TokenKind::RParen)?;
|
||||
Ok(expr)
|
||||
}
|
||||
// Cast: int(expr) or float(expr)
|
||||
TokenKind::Int => {
|
||||
self.advance();
|
||||
self.expect(&TokenKind::LParen)?;
|
||||
let expr = self.parse_expr()?;
|
||||
self.expect(&TokenKind::RParen)?;
|
||||
Ok(Expr::Cast(CastKind::ToInt, Box::new(expr), span))
|
||||
}
|
||||
TokenKind::F32 => {
|
||||
self.advance();
|
||||
self.expect(&TokenKind::LParen)?;
|
||||
let expr = self.parse_expr()?;
|
||||
self.expect(&TokenKind::RParen)?;
|
||||
Ok(Expr::Cast(CastKind::ToFloat, Box::new(expr), span))
|
||||
}
|
||||
TokenKind::Ident(name) => {
|
||||
let name = name.clone();
|
||||
self.advance();
|
||||
// Check if it's a function call
|
||||
if *self.peek() == TokenKind::LParen {
|
||||
self.advance();
|
||||
let mut args = Vec::new();
|
||||
if *self.peek() != TokenKind::RParen {
|
||||
args.push(self.parse_expr()?);
|
||||
while self.eat(&TokenKind::Comma) {
|
||||
args.push(self.parse_expr()?);
|
||||
}
|
||||
}
|
||||
self.expect(&TokenKind::RParen)?;
|
||||
Ok(Expr::Call(name, args, span))
|
||||
} else {
|
||||
Ok(Expr::Ident(name, span))
|
||||
}
|
||||
}
|
||||
_ => Err(CompileError::new(
|
||||
format!("Expected expression, found {:?}", self.peek()),
|
||||
span,
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::lexer::Lexer;
|
||||
|
||||
fn parse_script(source: &str) -> Result<Script, CompileError> {
|
||||
let mut lexer = Lexer::new(source);
|
||||
let tokens = lexer.tokenize()?;
|
||||
let mut parser = Parser::new(&tokens);
|
||||
parser.parse()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_minimal_script() {
|
||||
let script = parse_script(r#"
|
||||
name "Test"
|
||||
category utility
|
||||
process {}
|
||||
"#).unwrap();
|
||||
assert_eq!(script.name, "Test");
|
||||
assert_eq!(script.category, CategoryKind::Utility);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ports_and_params() {
|
||||
let script = parse_script(r#"
|
||||
name "Gain"
|
||||
category effect
|
||||
inputs {
|
||||
audio_in: audio
|
||||
cv_mod: cv
|
||||
}
|
||||
outputs {
|
||||
audio_out: audio
|
||||
}
|
||||
params {
|
||||
gain: 1.0 [0.0, 2.0] ""
|
||||
}
|
||||
process {}
|
||||
"#).unwrap();
|
||||
assert_eq!(script.inputs.len(), 2);
|
||||
assert_eq!(script.outputs.len(), 1);
|
||||
assert_eq!(script.params.len(), 1);
|
||||
assert_eq!(script.params[0].name, "gain");
|
||||
assert_eq!(script.params[0].default, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_state_with_sample() {
|
||||
let script = parse_script(r#"
|
||||
name "Sampler"
|
||||
category generator
|
||||
state {
|
||||
clip: sample
|
||||
phase: f32
|
||||
buffer: [4096]f32
|
||||
counter: int
|
||||
}
|
||||
process {}
|
||||
"#).unwrap();
|
||||
assert_eq!(script.state.len(), 4);
|
||||
assert_eq!(script.state[0].ty, StateType::Sample);
|
||||
assert_eq!(script.state[1].ty, StateType::F32);
|
||||
assert_eq!(script.state[2].ty, StateType::ArrayF32(4096));
|
||||
assert_eq!(script.state[3].ty, StateType::Int);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_process_with_for_loop() {
|
||||
let script = parse_script(r#"
|
||||
name "Pass"
|
||||
category effect
|
||||
inputs { audio_in: audio }
|
||||
outputs { audio_out: audio }
|
||||
process {
|
||||
for i in 0..buffer_size {
|
||||
audio_out[i * 2] = audio_in[i * 2];
|
||||
audio_out[i * 2 + 1] = audio_in[i * 2 + 1];
|
||||
}
|
||||
}
|
||||
"#).unwrap();
|
||||
assert_eq!(script.process.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expressions() {
|
||||
let script = parse_script(r#"
|
||||
name "Expr"
|
||||
category utility
|
||||
process {
|
||||
let x = 1.0 + 2.0 * 3.0;
|
||||
let y = sin(x) + cos(3.14);
|
||||
let z = int(x * 100.0);
|
||||
}
|
||||
"#).unwrap();
|
||||
assert_eq!(script.process.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ui_block() {
|
||||
let script = parse_script(r#"
|
||||
name "UI Test"
|
||||
category utility
|
||||
params {
|
||||
gain: 1.0 [0.0, 2.0] ""
|
||||
mix: 0.5 [0.0, 1.0] ""
|
||||
}
|
||||
state {
|
||||
clip: sample
|
||||
}
|
||||
ui {
|
||||
sample clip
|
||||
param gain
|
||||
group "Advanced" {
|
||||
param mix
|
||||
}
|
||||
}
|
||||
process {}
|
||||
"#).unwrap();
|
||||
let ui = script.ui.unwrap();
|
||||
assert_eq!(ui.len(), 3);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
/// Source location
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Span {
|
||||
pub line: u32,
|
||||
pub col: u32,
|
||||
}
|
||||
|
||||
impl Span {
|
||||
pub fn new(line: u32, col: u32) -> Self {
|
||||
Self { line, col }
|
||||
}
|
||||
}
|
||||
|
||||
/// Token with source location
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Token {
|
||||
pub kind: TokenKind,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum TokenKind {
|
||||
// Header keywords
|
||||
Name,
|
||||
Category,
|
||||
Inputs,
|
||||
Outputs,
|
||||
Params,
|
||||
State,
|
||||
Ui,
|
||||
Process,
|
||||
|
||||
// Type keywords
|
||||
Audio,
|
||||
Cv,
|
||||
Midi,
|
||||
F32,
|
||||
Int,
|
||||
Bool,
|
||||
Sample,
|
||||
|
||||
// Category values
|
||||
Generator,
|
||||
Effect,
|
||||
Utility,
|
||||
|
||||
// Statement keywords
|
||||
Let,
|
||||
Mut,
|
||||
If,
|
||||
Else,
|
||||
For,
|
||||
In,
|
||||
|
||||
// UI keywords
|
||||
Group,
|
||||
Param,
|
||||
Canvas,
|
||||
Spacer,
|
||||
|
||||
// Draw block
|
||||
Draw,
|
||||
|
||||
// Literals
|
||||
FloatLit(f32),
|
||||
IntLit(i32),
|
||||
StringLit(String),
|
||||
True,
|
||||
False,
|
||||
|
||||
// Identifiers
|
||||
Ident(String),
|
||||
|
||||
// Operators
|
||||
Plus,
|
||||
Minus,
|
||||
Star,
|
||||
Slash,
|
||||
Percent,
|
||||
Eq, // =
|
||||
EqEq, // ==
|
||||
BangEq, // !=
|
||||
Lt, // <
|
||||
Gt, // >
|
||||
LtEq, // <=
|
||||
GtEq, // >=
|
||||
AmpAmp, // &&
|
||||
PipePipe, // ||
|
||||
Bang, // !
|
||||
|
||||
// Delimiters
|
||||
LBrace, // {
|
||||
RBrace, // }
|
||||
LBracket, // [
|
||||
RBracket, // ]
|
||||
LParen, // (
|
||||
RParen, // )
|
||||
Colon, // :
|
||||
Comma, // ,
|
||||
Semicolon, // ;
|
||||
DotDot, // ..
|
||||
|
||||
// End of file
|
||||
Eof,
|
||||
}
|
||||
|
||||
impl TokenKind {
|
||||
/// Try to match an identifier string to a keyword
|
||||
pub fn from_ident(s: &str) -> TokenKind {
|
||||
match s {
|
||||
"name" => TokenKind::Name,
|
||||
"category" => TokenKind::Category,
|
||||
"inputs" => TokenKind::Inputs,
|
||||
"outputs" => TokenKind::Outputs,
|
||||
"params" => TokenKind::Params,
|
||||
"state" => TokenKind::State,
|
||||
"ui" => TokenKind::Ui,
|
||||
"process" => TokenKind::Process,
|
||||
"audio" => TokenKind::Audio,
|
||||
"cv" => TokenKind::Cv,
|
||||
"midi" => TokenKind::Midi,
|
||||
"f32" => TokenKind::F32,
|
||||
"int" => TokenKind::Int,
|
||||
"bool" => TokenKind::Bool,
|
||||
"sample" => TokenKind::Sample,
|
||||
"generator" => TokenKind::Generator,
|
||||
"effect" => TokenKind::Effect,
|
||||
"utility" => TokenKind::Utility,
|
||||
"let" => TokenKind::Let,
|
||||
"mut" => TokenKind::Mut,
|
||||
"if" => TokenKind::If,
|
||||
"else" => TokenKind::Else,
|
||||
"for" => TokenKind::For,
|
||||
"in" => TokenKind::In,
|
||||
"group" => TokenKind::Group,
|
||||
"param" => TokenKind::Param,
|
||||
"canvas" => TokenKind::Canvas,
|
||||
"spacer" => TokenKind::Spacer,
|
||||
"draw" => TokenKind::Draw,
|
||||
"true" => TokenKind::True,
|
||||
"false" => TokenKind::False,
|
||||
_ => TokenKind::Ident(s.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Declarative UI layout for a script node, rendered in bottom_ui()
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct UiDeclaration {
|
||||
pub elements: Vec<UiElement>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum UiElement {
|
||||
/// Render a parameter slider/knob
|
||||
Param(String),
|
||||
/// Render a sample picker dropdown
|
||||
Sample(String),
|
||||
/// Collapsible group with label
|
||||
Group {
|
||||
label: String,
|
||||
children: Vec<UiElement>,
|
||||
},
|
||||
/// Drawable canvas area (phase 2)
|
||||
Canvas {
|
||||
width: f32,
|
||||
height: f32,
|
||||
},
|
||||
/// Vertical spacer
|
||||
Spacer(f32),
|
||||
}
|
||||
|
|
@ -1,388 +0,0 @@
|
|||
use crate::ast::*;
|
||||
use crate::error::CompileError;
|
||||
use crate::token::Span;
|
||||
use crate::ui_decl::UiElement;
|
||||
|
||||
/// Type used during validation
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum VType {
|
||||
F32,
|
||||
Int,
|
||||
Bool,
|
||||
/// Array of f32 (state array or input/output buffer)
|
||||
ArrayF32,
|
||||
/// Array of int
|
||||
ArrayInt,
|
||||
/// Sample slot (accessed via sample_read/sample_len)
|
||||
Sample,
|
||||
}
|
||||
|
||||
struct VarInfo {
|
||||
ty: VType,
|
||||
mutable: bool,
|
||||
}
|
||||
|
||||
struct Scope {
|
||||
vars: Vec<(String, VarInfo)>,
|
||||
}
|
||||
|
||||
impl Scope {
|
||||
fn new() -> Self {
|
||||
Self { vars: Vec::new() }
|
||||
}
|
||||
|
||||
fn define(&mut self, name: String, ty: VType, mutable: bool) {
|
||||
self.vars.push((name, VarInfo { ty, mutable }));
|
||||
}
|
||||
|
||||
fn lookup(&self, name: &str) -> Option<&VarInfo> {
|
||||
self.vars.iter().rev().find(|(n, _)| n == name).map(|(_, v)| v)
|
||||
}
|
||||
}
|
||||
|
||||
struct Validator<'a> {
|
||||
script: &'a Script,
|
||||
scopes: Vec<Scope>,
|
||||
}
|
||||
|
||||
impl<'a> Validator<'a> {
|
||||
fn new(script: &'a Script) -> Self {
|
||||
Self {
|
||||
script,
|
||||
scopes: vec![Scope::new()],
|
||||
}
|
||||
}
|
||||
|
||||
fn current_scope(&mut self) -> &mut Scope {
|
||||
self.scopes.last_mut().unwrap()
|
||||
}
|
||||
|
||||
fn push_scope(&mut self) {
|
||||
self.scopes.push(Scope::new());
|
||||
}
|
||||
|
||||
fn pop_scope(&mut self) {
|
||||
self.scopes.pop();
|
||||
}
|
||||
|
||||
fn lookup(&self, name: &str) -> Option<&VarInfo> {
|
||||
for scope in self.scopes.iter().rev() {
|
||||
if let Some(info) = scope.lookup(name) {
|
||||
return Some(info);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn define(&mut self, name: String, ty: VType, mutable: bool) {
|
||||
self.current_scope().define(name, ty, mutable);
|
||||
}
|
||||
|
||||
fn validate(&mut self) -> Result<(), CompileError> {
|
||||
// Register built-in variables
|
||||
self.define("sample_rate".into(), VType::Int, false);
|
||||
self.define("buffer_size".into(), VType::Int, false);
|
||||
|
||||
// Register inputs as arrays
|
||||
for input in &self.script.inputs {
|
||||
let ty = match input.signal {
|
||||
SignalKind::Audio | SignalKind::Cv => VType::ArrayF32,
|
||||
SignalKind::Midi => continue, // MIDI not yet supported in process
|
||||
};
|
||||
self.define(input.name.clone(), ty, false);
|
||||
}
|
||||
|
||||
// Register outputs as mutable arrays
|
||||
for output in &self.script.outputs {
|
||||
let ty = match output.signal {
|
||||
SignalKind::Audio | SignalKind::Cv => VType::ArrayF32,
|
||||
SignalKind::Midi => continue,
|
||||
};
|
||||
self.define(output.name.clone(), ty, true);
|
||||
}
|
||||
|
||||
// Register params as f32
|
||||
for param in &self.script.params {
|
||||
self.define(param.name.clone(), VType::F32, false);
|
||||
}
|
||||
|
||||
// Register state vars
|
||||
for state in &self.script.state {
|
||||
let (ty, mutable) = match &state.ty {
|
||||
StateType::F32 => (VType::F32, true),
|
||||
StateType::Int => (VType::Int, true),
|
||||
StateType::Bool => (VType::Bool, true),
|
||||
StateType::ArrayF32(_) => (VType::ArrayF32, true),
|
||||
StateType::ArrayInt(_) => (VType::ArrayInt, true),
|
||||
StateType::Sample => (VType::Sample, false),
|
||||
};
|
||||
self.define(state.name.clone(), ty, mutable);
|
||||
}
|
||||
|
||||
// Validate process block
|
||||
self.validate_block(&self.script.process)?;
|
||||
|
||||
// Validate UI references
|
||||
if let Some(ui) = &self.script.ui {
|
||||
self.validate_ui(ui)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_block(&mut self, block: &[Stmt]) -> Result<(), CompileError> {
|
||||
for stmt in block {
|
||||
self.validate_stmt(stmt)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_stmt(&mut self, stmt: &Stmt) -> Result<(), CompileError> {
|
||||
match stmt {
|
||||
Stmt::Let { name, mutable, init, span: _ } => {
|
||||
let ty = self.infer_type(init)?;
|
||||
self.define(name.clone(), ty, *mutable);
|
||||
Ok(())
|
||||
}
|
||||
Stmt::Assign { target, value, span: _ } => {
|
||||
match target {
|
||||
LValue::Ident(name, s) => {
|
||||
let info = self.lookup(name).ok_or_else(|| {
|
||||
CompileError::new(format!("Undefined variable: {}", name), *s)
|
||||
})?;
|
||||
if !info.mutable {
|
||||
return Err(CompileError::new(
|
||||
format!("Cannot assign to immutable variable: {}", name),
|
||||
*s,
|
||||
));
|
||||
}
|
||||
}
|
||||
LValue::Index(name, idx, s) => {
|
||||
let info = self.lookup(name).ok_or_else(|| {
|
||||
CompileError::new(format!("Undefined variable: {}", name), *s)
|
||||
})?;
|
||||
if !info.mutable {
|
||||
return Err(CompileError::new(
|
||||
format!("Cannot assign to immutable array: {}", name),
|
||||
*s,
|
||||
));
|
||||
}
|
||||
self.infer_type(idx)?;
|
||||
}
|
||||
}
|
||||
self.infer_type(value)?;
|
||||
Ok(())
|
||||
}
|
||||
Stmt::If { cond, then_block, else_block, .. } => {
|
||||
self.infer_type(cond)?;
|
||||
self.push_scope();
|
||||
self.validate_block(then_block)?;
|
||||
self.pop_scope();
|
||||
if let Some(else_b) = else_block {
|
||||
self.push_scope();
|
||||
self.validate_block(else_b)?;
|
||||
self.pop_scope();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Stmt::For { var, end, body, span } => {
|
||||
let end_ty = self.infer_type(end)?;
|
||||
if end_ty != VType::Int {
|
||||
return Err(CompileError::new(
|
||||
"For loop bound must be an integer expression",
|
||||
*span,
|
||||
).with_hint("Use int(...) to convert, or use buffer_size / len(array)"));
|
||||
}
|
||||
self.push_scope();
|
||||
self.define(var.clone(), VType::Int, false);
|
||||
self.validate_block(body)?;
|
||||
self.pop_scope();
|
||||
Ok(())
|
||||
}
|
||||
Stmt::ExprStmt(expr) => {
|
||||
self.infer_type(expr)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn infer_type(&self, expr: &Expr) -> Result<VType, CompileError> {
|
||||
match expr {
|
||||
Expr::FloatLit(_, _) => Ok(VType::F32),
|
||||
Expr::IntLit(_, _) => Ok(VType::Int),
|
||||
Expr::BoolLit(_, _) => Ok(VType::Bool),
|
||||
Expr::Ident(name, span) => {
|
||||
let info = self.lookup(name).ok_or_else(|| {
|
||||
CompileError::new(format!("Undefined variable: {}", name), *span)
|
||||
})?;
|
||||
Ok(info.ty)
|
||||
}
|
||||
Expr::BinOp(left, op, right, span) => {
|
||||
let lt = self.infer_type(left)?;
|
||||
let rt = self.infer_type(right)?;
|
||||
match op {
|
||||
BinOp::And | BinOp::Or => Ok(VType::Bool),
|
||||
BinOp::Eq | BinOp::Ne | BinOp::Lt | BinOp::Gt | BinOp::Le | BinOp::Ge => {
|
||||
Ok(VType::Bool)
|
||||
}
|
||||
_ => {
|
||||
// Arithmetic: both sides should be same numeric type
|
||||
if lt == VType::F32 || rt == VType::F32 {
|
||||
Ok(VType::F32)
|
||||
} else if lt == VType::Int && rt == VType::Int {
|
||||
Ok(VType::Int)
|
||||
} else {
|
||||
Err(CompileError::new(
|
||||
format!("Cannot apply {:?} to {:?} and {:?}", op, lt, rt),
|
||||
*span,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Expr::UnaryOp(op, inner, _) => {
|
||||
let ty = self.infer_type(inner)?;
|
||||
match op {
|
||||
UnaryOp::Neg => Ok(ty),
|
||||
UnaryOp::Not => Ok(VType::Bool),
|
||||
}
|
||||
}
|
||||
Expr::Cast(kind, _, _) => match kind {
|
||||
CastKind::ToInt => Ok(VType::Int),
|
||||
CastKind::ToFloat => Ok(VType::F32),
|
||||
},
|
||||
Expr::Index(base, idx, span) => {
|
||||
let base_ty = self.infer_type(base)?;
|
||||
self.infer_type(idx)?;
|
||||
match base_ty {
|
||||
VType::ArrayF32 => Ok(VType::F32),
|
||||
VType::ArrayInt => Ok(VType::Int),
|
||||
_ => Err(CompileError::new("Cannot index non-array type", *span)),
|
||||
}
|
||||
}
|
||||
Expr::Call(name, args, span) => {
|
||||
self.validate_call(name, args, *span)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_call(&self, name: &str, args: &[Expr], span: Span) -> Result<VType, CompileError> {
|
||||
// Validate argument count and infer return type
|
||||
match name {
|
||||
// 1-arg math functions returning f32
|
||||
"sin" | "cos" | "tan" | "asin" | "acos" | "atan" | "exp" | "log" | "log2"
|
||||
| "sqrt" | "floor" | "ceil" | "round" | "trunc" | "fract" | "abs" | "sign" => {
|
||||
if args.len() != 1 {
|
||||
return Err(CompileError::new(format!("{}() takes 1 argument", name), span));
|
||||
}
|
||||
for arg in args { self.infer_type(arg)?; }
|
||||
Ok(VType::F32)
|
||||
}
|
||||
// 2-arg math functions returning f32
|
||||
"atan2" | "pow" | "min" | "max" => {
|
||||
if args.len() != 2 {
|
||||
return Err(CompileError::new(format!("{}() takes 2 arguments", name), span));
|
||||
}
|
||||
for arg in args { self.infer_type(arg)?; }
|
||||
Ok(VType::F32)
|
||||
}
|
||||
// 3-arg functions
|
||||
"clamp" | "mix" | "smoothstep" => {
|
||||
if args.len() != 3 {
|
||||
return Err(CompileError::new(format!("{}() takes 3 arguments", name), span));
|
||||
}
|
||||
for arg in args { self.infer_type(arg)?; }
|
||||
Ok(VType::F32)
|
||||
}
|
||||
// cv_or(value, default) -> f32
|
||||
"cv_or" => {
|
||||
if args.len() != 2 {
|
||||
return Err(CompileError::new("cv_or() takes 2 arguments", span));
|
||||
}
|
||||
for arg in args { self.infer_type(arg)?; }
|
||||
Ok(VType::F32)
|
||||
}
|
||||
// len(array) -> int
|
||||
"len" => {
|
||||
if args.len() != 1 {
|
||||
return Err(CompileError::new("len() takes 1 argument", span));
|
||||
}
|
||||
let ty = self.infer_type(&args[0])?;
|
||||
if ty != VType::ArrayF32 && ty != VType::ArrayInt {
|
||||
return Err(CompileError::new("len() requires an array argument", span));
|
||||
}
|
||||
Ok(VType::Int)
|
||||
}
|
||||
// sample_len(sample) -> int
|
||||
"sample_len" => {
|
||||
if args.len() != 1 {
|
||||
return Err(CompileError::new("sample_len() takes 1 argument", span));
|
||||
}
|
||||
let ty = self.infer_type(&args[0])?;
|
||||
if ty != VType::Sample {
|
||||
return Err(CompileError::new("sample_len() requires a sample argument", span));
|
||||
}
|
||||
Ok(VType::Int)
|
||||
}
|
||||
// sample_read(sample, index) -> f32
|
||||
"sample_read" => {
|
||||
if args.len() != 2 {
|
||||
return Err(CompileError::new("sample_read() takes 2 arguments", span));
|
||||
}
|
||||
let ty = self.infer_type(&args[0])?;
|
||||
if ty != VType::Sample {
|
||||
return Err(CompileError::new("sample_read() first argument must be a sample", span));
|
||||
}
|
||||
self.infer_type(&args[1])?;
|
||||
Ok(VType::F32)
|
||||
}
|
||||
// sample_rate_of(sample) -> int
|
||||
"sample_rate_of" => {
|
||||
if args.len() != 1 {
|
||||
return Err(CompileError::new("sample_rate_of() takes 1 argument", span));
|
||||
}
|
||||
let ty = self.infer_type(&args[0])?;
|
||||
if ty != VType::Sample {
|
||||
return Err(CompileError::new("sample_rate_of() requires a sample argument", span));
|
||||
}
|
||||
Ok(VType::Int)
|
||||
}
|
||||
_ => Err(CompileError::new(format!("Unknown function: {}", name), span)),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_ui(&self, elements: &[UiElement]) -> Result<(), CompileError> {
|
||||
for element in elements {
|
||||
match element {
|
||||
UiElement::Param(name) => {
|
||||
if !self.script.params.iter().any(|p| p.name == *name) {
|
||||
return Err(CompileError::new(
|
||||
format!("UI references unknown parameter: {}", name),
|
||||
Span::new(0, 0),
|
||||
));
|
||||
}
|
||||
}
|
||||
UiElement::Sample(name) => {
|
||||
if !self.script.state.iter().any(|s| s.name == *name && s.ty == StateType::Sample) {
|
||||
return Err(CompileError::new(
|
||||
format!("UI references unknown sample: {}", name),
|
||||
Span::new(0, 0),
|
||||
));
|
||||
}
|
||||
}
|
||||
UiElement::Group { children, .. } => {
|
||||
self.validate_ui(children)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a parsed script. Returns Ok(()) if valid.
|
||||
pub fn validate(script: &Script) -> Result<&Script, CompileError> {
|
||||
let mut validator = Validator::new(script);
|
||||
validator.validate()?;
|
||||
Ok(script)
|
||||
}
|
||||
|
|
@ -1,674 +0,0 @@
|
|||
use crate::error::ScriptError;
|
||||
use crate::opcodes::OpCode;
|
||||
|
||||
const STACK_SIZE: usize = 256;
|
||||
const MAX_LOCALS: usize = 64;
|
||||
const DEFAULT_INSTRUCTION_LIMIT: u64 = 10_000_000;
|
||||
|
||||
/// A value on the VM stack (tagged union)
|
||||
#[derive(Clone, Copy)]
|
||||
pub union Value {
|
||||
pub f: f32,
|
||||
pub i: i32,
|
||||
pub b: bool,
|
||||
}
|
||||
|
||||
impl Default for Value {
|
||||
fn default() -> Self {
|
||||
Value { i: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// A loaded audio sample slot
|
||||
#[derive(Clone)]
|
||||
pub struct SampleSlot {
|
||||
pub data: Vec<f32>,
|
||||
pub frame_count: usize,
|
||||
pub sample_rate: u32,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl Default for SampleSlot {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
data: Vec::new(),
|
||||
frame_count: 0,
|
||||
sample_rate: 0,
|
||||
name: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a single opcode step in VmCore
|
||||
enum StepResult {
|
||||
/// Opcode was handled, continue execution
|
||||
Continue,
|
||||
/// Hit a Halt instruction
|
||||
Halt,
|
||||
/// Opcode not handled by core — caller must handle it
|
||||
Unhandled(OpCode),
|
||||
}
|
||||
|
||||
/// Shared VM state and opcode dispatch for arithmetic, logic, control flow, and math builtins.
|
||||
#[derive(Clone)]
|
||||
struct VmCore {
|
||||
bytecode: Vec<u8>,
|
||||
constants_f32: Vec<f32>,
|
||||
constants_i32: Vec<i32>,
|
||||
stack: Vec<Value>,
|
||||
sp: usize,
|
||||
locals: Vec<Value>,
|
||||
params: Vec<f32>,
|
||||
state_scalars: Vec<Value>,
|
||||
state_arrays: Vec<Vec<f32>>,
|
||||
instruction_limit: u64,
|
||||
}
|
||||
|
||||
impl VmCore {
|
||||
fn new(
|
||||
bytecode: Vec<u8>,
|
||||
constants_f32: Vec<f32>,
|
||||
constants_i32: Vec<i32>,
|
||||
num_params: usize,
|
||||
param_defaults: &[f32],
|
||||
num_state_scalars: usize,
|
||||
state_array_sizes: &[usize],
|
||||
instruction_limit: u64,
|
||||
) -> Self {
|
||||
let mut params = vec![0.0f32; num_params];
|
||||
for (i, &d) in param_defaults.iter().enumerate() {
|
||||
if i < params.len() {
|
||||
params[i] = d;
|
||||
}
|
||||
}
|
||||
Self {
|
||||
bytecode,
|
||||
constants_f32,
|
||||
constants_i32,
|
||||
stack: vec![Value::default(); STACK_SIZE],
|
||||
sp: 0,
|
||||
locals: vec![Value::default(); MAX_LOCALS],
|
||||
params,
|
||||
state_scalars: vec![Value::default(); num_state_scalars],
|
||||
state_arrays: state_array_sizes.iter().map(|&sz| vec![0.0f32; sz]).collect(),
|
||||
instruction_limit,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset execution state (sp + locals) at the start of each execute() call.
|
||||
fn reset_frame(&mut self) {
|
||||
self.sp = 0;
|
||||
for l in &mut self.locals {
|
||||
*l = Value::default();
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute one opcode at `pc`. Returns the new `pc` and a `StepResult`.
|
||||
/// Handles all opcodes shared between ScriptVM and DrawVM:
|
||||
/// stack ops, locals, params, state, arrays, arithmetic, comparison,
|
||||
/// logic, casts, control flow, and math builtins.
|
||||
fn step(&mut self, pc: &mut usize) -> Result<StepResult, ScriptError> {
|
||||
let op = self.bytecode[*pc];
|
||||
*pc += 1;
|
||||
|
||||
let Some(opcode) = OpCode::from_u8(op) else {
|
||||
return Err(ScriptError::InvalidOpcode(op));
|
||||
};
|
||||
|
||||
match opcode {
|
||||
OpCode::Halt => return Ok(StepResult::Halt),
|
||||
|
||||
// Stack operations
|
||||
OpCode::PushF32 => {
|
||||
let idx = self.read_u16(pc) as usize;
|
||||
self.push_f(self.constants_f32[idx])?;
|
||||
}
|
||||
OpCode::PushI32 => {
|
||||
let idx = self.read_u16(pc) as usize;
|
||||
self.push_i(self.constants_i32[idx])?;
|
||||
}
|
||||
OpCode::PushBool => {
|
||||
let v = self.bytecode[*pc];
|
||||
*pc += 1;
|
||||
self.push_b(v != 0)?;
|
||||
}
|
||||
OpCode::Pop => { self.pop()?; }
|
||||
|
||||
// Locals
|
||||
OpCode::LoadLocal => {
|
||||
let idx = self.read_u16(pc) as usize;
|
||||
self.push(self.locals[idx])?;
|
||||
}
|
||||
OpCode::StoreLocal => {
|
||||
let idx = self.read_u16(pc) as usize;
|
||||
self.locals[idx] = self.pop()?;
|
||||
}
|
||||
|
||||
// Params (read)
|
||||
OpCode::LoadParam => {
|
||||
let idx = self.read_u16(pc) as usize;
|
||||
self.push_f(self.params[idx])?;
|
||||
}
|
||||
|
||||
// State scalars
|
||||
OpCode::LoadState => {
|
||||
let idx = self.read_u16(pc) as usize;
|
||||
self.push(self.state_scalars[idx])?;
|
||||
}
|
||||
OpCode::StoreState => {
|
||||
let idx = self.read_u16(pc) as usize;
|
||||
self.state_scalars[idx] = self.pop()?;
|
||||
}
|
||||
|
||||
// State arrays
|
||||
OpCode::LoadStateArray => {
|
||||
let arr_id = self.read_u16(pc) as usize;
|
||||
let idx = unsafe { self.pop()?.i };
|
||||
let val = if arr_id < self.state_arrays.len() {
|
||||
let arr_len = self.state_arrays[arr_id].len();
|
||||
let idx = ((idx % arr_len as i32) + arr_len as i32) as usize % arr_len;
|
||||
self.state_arrays[arr_id][idx]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.push_f(val)?;
|
||||
}
|
||||
OpCode::StoreStateArray => {
|
||||
let arr_id = self.read_u16(pc) as usize;
|
||||
let val = unsafe { self.pop()?.f };
|
||||
let idx = unsafe { self.pop()?.i };
|
||||
if arr_id < self.state_arrays.len() {
|
||||
let arr_len = self.state_arrays[arr_id].len();
|
||||
let idx = ((idx % arr_len as i32) + arr_len as i32) as usize % arr_len;
|
||||
self.state_arrays[arr_id][idx] = val;
|
||||
}
|
||||
}
|
||||
OpCode::ArrayLen => {
|
||||
let arr_id = self.read_u16(pc) as usize;
|
||||
let len = if arr_id < self.state_arrays.len() {
|
||||
self.state_arrays[arr_id].len() as i32
|
||||
} else {
|
||||
0
|
||||
};
|
||||
self.push_i(len)?;
|
||||
}
|
||||
|
||||
// Float arithmetic
|
||||
OpCode::AddF => { let b = self.pop_f()?; let a = self.pop_f()?; self.push_f(a + b)?; }
|
||||
OpCode::SubF => { let b = self.pop_f()?; let a = self.pop_f()?; self.push_f(a - b)?; }
|
||||
OpCode::MulF => { let b = self.pop_f()?; let a = self.pop_f()?; self.push_f(a * b)?; }
|
||||
OpCode::DivF => { let b = self.pop_f()?; let a = self.pop_f()?; self.push_f(if b.abs() > 1e-30 { a / b } else { 0.0 })?; }
|
||||
OpCode::ModF => { let b = self.pop_f()?; let a = self.pop_f()?; self.push_f(if b.abs() > 1e-30 { a % b } else { 0.0 })?; }
|
||||
OpCode::NegF => { let v = self.pop_f()?; self.push_f(-v)?; }
|
||||
|
||||
// Int arithmetic
|
||||
OpCode::AddI => { let b = self.pop_i()?; let a = self.pop_i()?; self.push_i(a.wrapping_add(b))?; }
|
||||
OpCode::SubI => { let b = self.pop_i()?; let a = self.pop_i()?; self.push_i(a.wrapping_sub(b))?; }
|
||||
OpCode::MulI => { let b = self.pop_i()?; let a = self.pop_i()?; self.push_i(a.wrapping_mul(b))?; }
|
||||
OpCode::DivI => { let b = self.pop_i()?; let a = self.pop_i()?; self.push_i(if b != 0 { a / b } else { 0 })?; }
|
||||
OpCode::ModI => { let b = self.pop_i()?; let a = self.pop_i()?; self.push_i(if b != 0 { a % b } else { 0 })?; }
|
||||
OpCode::NegI => { let v = self.pop_i()?; self.push_i(-v)?; }
|
||||
|
||||
// Float comparison
|
||||
OpCode::EqF => { let b = self.pop_f()?; let a = self.pop_f()?; self.push_b(a == b)?; }
|
||||
OpCode::NeF => { let b = self.pop_f()?; let a = self.pop_f()?; self.push_b(a != b)?; }
|
||||
OpCode::LtF => { let b = self.pop_f()?; let a = self.pop_f()?; self.push_b(a < b)?; }
|
||||
OpCode::GtF => { let b = self.pop_f()?; let a = self.pop_f()?; self.push_b(a > b)?; }
|
||||
OpCode::LeF => { let b = self.pop_f()?; let a = self.pop_f()?; self.push_b(a <= b)?; }
|
||||
OpCode::GeF => { let b = self.pop_f()?; let a = self.pop_f()?; self.push_b(a >= b)?; }
|
||||
|
||||
// Int comparison
|
||||
OpCode::EqI => { let b = self.pop_i()?; let a = self.pop_i()?; self.push_b(a == b)?; }
|
||||
OpCode::NeI => { let b = self.pop_i()?; let a = self.pop_i()?; self.push_b(a != b)?; }
|
||||
OpCode::LtI => { let b = self.pop_i()?; let a = self.pop_i()?; self.push_b(a < b)?; }
|
||||
OpCode::GtI => { let b = self.pop_i()?; let a = self.pop_i()?; self.push_b(a > b)?; }
|
||||
OpCode::LeI => { let b = self.pop_i()?; let a = self.pop_i()?; self.push_b(a <= b)?; }
|
||||
OpCode::GeI => { let b = self.pop_i()?; let a = self.pop_i()?; self.push_b(a >= b)?; }
|
||||
|
||||
// Logical
|
||||
OpCode::And => { let b = self.pop_b()?; let a = self.pop_b()?; self.push_b(a && b)?; }
|
||||
OpCode::Or => { let b = self.pop_b()?; let a = self.pop_b()?; self.push_b(a || b)?; }
|
||||
OpCode::Not => { let v = self.pop_b()?; self.push_b(!v)?; }
|
||||
|
||||
// Casts
|
||||
OpCode::F32ToI32 => { let v = self.pop_f()?; self.push_i(v as i32)?; }
|
||||
OpCode::I32ToF32 => { let v = self.pop_i()?; self.push_f(v as f32)?; }
|
||||
|
||||
// Control flow
|
||||
OpCode::Jump => {
|
||||
*pc = self.read_u32(pc) as usize;
|
||||
}
|
||||
OpCode::JumpIfFalse => {
|
||||
let target = self.read_u32(pc) as usize;
|
||||
let cond = self.pop_b()?;
|
||||
if !cond {
|
||||
*pc = target;
|
||||
}
|
||||
}
|
||||
|
||||
// Math builtins
|
||||
OpCode::Sin => { let v = self.pop_f()?; self.push_f(v.sin())?; }
|
||||
OpCode::Cos => { let v = self.pop_f()?; self.push_f(v.cos())?; }
|
||||
OpCode::Tan => { let v = self.pop_f()?; self.push_f(v.tan())?; }
|
||||
OpCode::Asin => { let v = self.pop_f()?; self.push_f(v.asin())?; }
|
||||
OpCode::Acos => { let v = self.pop_f()?; self.push_f(v.acos())?; }
|
||||
OpCode::Atan => { let v = self.pop_f()?; self.push_f(v.atan())?; }
|
||||
OpCode::Atan2 => { let x = self.pop_f()?; let y = self.pop_f()?; self.push_f(y.atan2(x))?; }
|
||||
OpCode::Exp => { let v = self.pop_f()?; self.push_f(v.exp())?; }
|
||||
OpCode::Log => { let v = self.pop_f()?; self.push_f(v.ln())?; }
|
||||
OpCode::Log2 => { let v = self.pop_f()?; self.push_f(v.log2())?; }
|
||||
OpCode::Pow => { let e = self.pop_f()?; let b = self.pop_f()?; self.push_f(b.powf(e))?; }
|
||||
OpCode::Sqrt => { let v = self.pop_f()?; self.push_f(v.sqrt())?; }
|
||||
OpCode::Floor => { let v = self.pop_f()?; self.push_f(v.floor())?; }
|
||||
OpCode::Ceil => { let v = self.pop_f()?; self.push_f(v.ceil())?; }
|
||||
OpCode::Round => { let v = self.pop_f()?; self.push_f(v.round())?; }
|
||||
OpCode::Trunc => { let v = self.pop_f()?; self.push_f(v.trunc())?; }
|
||||
OpCode::Fract => { let v = self.pop_f()?; self.push_f(v.fract())?; }
|
||||
OpCode::Abs => { let v = self.pop_f()?; self.push_f(v.abs())?; }
|
||||
OpCode::Sign => { let v = self.pop_f()?; self.push_f(v.signum())?; }
|
||||
OpCode::Clamp => {
|
||||
let hi = self.pop_f()?;
|
||||
let lo = self.pop_f()?;
|
||||
let v = self.pop_f()?;
|
||||
self.push_f(v.clamp(lo, hi))?;
|
||||
}
|
||||
OpCode::Min => { let b = self.pop_f()?; let a = self.pop_f()?; self.push_f(a.min(b))?; }
|
||||
OpCode::Max => { let b = self.pop_f()?; let a = self.pop_f()?; self.push_f(a.max(b))?; }
|
||||
OpCode::Mix => {
|
||||
let t = self.pop_f()?;
|
||||
let b = self.pop_f()?;
|
||||
let a = self.pop_f()?;
|
||||
self.push_f(a + (b - a) * t)?;
|
||||
}
|
||||
OpCode::Smoothstep => {
|
||||
let x = self.pop_f()?;
|
||||
let e1 = self.pop_f()?;
|
||||
let e0 = self.pop_f()?;
|
||||
let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0);
|
||||
self.push_f(t * t * (3.0 - 2.0 * t))?;
|
||||
}
|
||||
OpCode::IsNan => { let v = self.pop_f()?; self.push_b(v.is_nan())?; }
|
||||
|
||||
// VM-specific opcodes — caller must handle
|
||||
other => return Ok(StepResult::Unhandled(other)),
|
||||
}
|
||||
|
||||
Ok(StepResult::Continue)
|
||||
}
|
||||
|
||||
// Stack helpers
|
||||
#[inline]
|
||||
fn push(&mut self, v: Value) -> Result<(), ScriptError> {
|
||||
if self.sp >= STACK_SIZE {
|
||||
return Err(ScriptError::StackOverflow);
|
||||
}
|
||||
self.stack[self.sp] = v;
|
||||
self.sp += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn push_f(&mut self, v: f32) -> Result<(), ScriptError> { self.push(Value { f: v }) }
|
||||
#[inline]
|
||||
fn push_i(&mut self, v: i32) -> Result<(), ScriptError> { self.push(Value { i: v }) }
|
||||
#[inline]
|
||||
fn push_b(&mut self, v: bool) -> Result<(), ScriptError> { self.push(Value { b: v }) }
|
||||
|
||||
#[inline]
|
||||
fn pop(&mut self) -> Result<Value, ScriptError> {
|
||||
if self.sp == 0 {
|
||||
return Err(ScriptError::StackUnderflow);
|
||||
}
|
||||
self.sp -= 1;
|
||||
Ok(self.stack[self.sp])
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn pop_f(&mut self) -> Result<f32, ScriptError> { Ok(unsafe { self.pop()?.f }) }
|
||||
#[inline]
|
||||
fn pop_i(&mut self) -> Result<i32, ScriptError> { Ok(unsafe { self.pop()?.i }) }
|
||||
#[inline]
|
||||
fn pop_b(&mut self) -> Result<bool, ScriptError> { Ok(unsafe { self.pop()?.b }) }
|
||||
|
||||
#[inline]
|
||||
fn read_u16(&self, pc: &mut usize) -> u16 {
|
||||
let v = u16::from_le_bytes([self.bytecode[*pc], self.bytecode[*pc + 1]]);
|
||||
*pc += 2;
|
||||
v
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn read_u32(&self, pc: &mut usize) -> u32 {
|
||||
let v = u32::from_le_bytes([
|
||||
self.bytecode[*pc], self.bytecode[*pc + 1],
|
||||
self.bytecode[*pc + 2], self.bytecode[*pc + 3],
|
||||
]);
|
||||
*pc += 4;
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ScriptVM (runs on audio thread) ----
|
||||
|
||||
/// The BeamDSP virtual machine
|
||||
#[derive(Clone)]
|
||||
pub struct ScriptVM {
|
||||
core: VmCore,
|
||||
pub sample_slots: Vec<SampleSlot>,
|
||||
}
|
||||
|
||||
impl ScriptVM {
|
||||
pub fn new(
|
||||
bytecode: Vec<u8>,
|
||||
constants_f32: Vec<f32>,
|
||||
constants_i32: Vec<i32>,
|
||||
num_params: usize,
|
||||
param_defaults: &[f32],
|
||||
num_state_scalars: usize,
|
||||
state_array_sizes: &[usize],
|
||||
num_sample_slots: usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
core: VmCore::new(
|
||||
bytecode, constants_f32, constants_i32,
|
||||
num_params, param_defaults, num_state_scalars, state_array_sizes,
|
||||
DEFAULT_INSTRUCTION_LIMIT,
|
||||
),
|
||||
sample_slots: (0..num_sample_slots).map(|_| SampleSlot::default()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Access params for reading
|
||||
pub fn params(&self) -> &[f32] {
|
||||
&self.core.params
|
||||
}
|
||||
|
||||
/// Access params mutably (backend sets values from parameter changes)
|
||||
pub fn params_mut(&mut self) -> &mut Vec<f32> {
|
||||
&mut self.core.params
|
||||
}
|
||||
|
||||
/// Reset all state (scalars + arrays) to zero. Called on node reset.
|
||||
pub fn reset_state(&mut self) {
|
||||
for s in &mut self.core.state_scalars {
|
||||
*s = Value::default();
|
||||
}
|
||||
for arr in &mut self.core.state_arrays {
|
||||
arr.fill(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute the bytecode with the given I/O buffers
|
||||
pub fn execute(
|
||||
&mut self,
|
||||
inputs: &[&[f32]],
|
||||
outputs: &mut [&mut [f32]],
|
||||
sample_rate: u32,
|
||||
buffer_size: usize,
|
||||
) -> Result<(), ScriptError> {
|
||||
self.core.reset_frame();
|
||||
|
||||
let mut pc: usize = 0;
|
||||
let mut ic: u64 = 0;
|
||||
let limit = self.core.instruction_limit;
|
||||
|
||||
while pc < self.core.bytecode.len() {
|
||||
ic += 1;
|
||||
if ic > limit {
|
||||
return Err(ScriptError::ExecutionLimitExceeded);
|
||||
}
|
||||
|
||||
match self.core.step(&mut pc)? {
|
||||
StepResult::Continue => {}
|
||||
StepResult::Halt => return Ok(()),
|
||||
StepResult::Unhandled(opcode) => {
|
||||
match opcode {
|
||||
// Input buffers
|
||||
OpCode::LoadInput => {
|
||||
let port = self.core.bytecode[pc] as usize;
|
||||
pc += 1;
|
||||
let idx = unsafe { self.core.pop()?.i } as usize;
|
||||
let val = if port < inputs.len() && idx < inputs[port].len() {
|
||||
inputs[port][idx]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.core.push_f(val)?;
|
||||
}
|
||||
|
||||
// Output buffers
|
||||
OpCode::StoreOutput => {
|
||||
let port = self.core.bytecode[pc] as usize;
|
||||
pc += 1;
|
||||
let val = unsafe { self.core.pop()?.f };
|
||||
let idx = unsafe { self.core.pop()?.i } as usize;
|
||||
if port < outputs.len() && idx < outputs[port].len() {
|
||||
outputs[port][idx] = val;
|
||||
}
|
||||
}
|
||||
|
||||
// Sample access
|
||||
OpCode::SampleLen => {
|
||||
let slot = self.core.bytecode[pc] as usize;
|
||||
pc += 1;
|
||||
let len = if slot < self.sample_slots.len() {
|
||||
self.sample_slots[slot].frame_count as i32
|
||||
} else {
|
||||
0
|
||||
};
|
||||
self.core.push_i(len)?;
|
||||
}
|
||||
OpCode::SampleRead => {
|
||||
let slot = self.core.bytecode[pc] as usize;
|
||||
pc += 1;
|
||||
let idx = unsafe { self.core.pop()?.i } as usize;
|
||||
let val = if slot < self.sample_slots.len() && idx < self.sample_slots[slot].data.len() {
|
||||
self.sample_slots[slot].data[idx]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.core.push_f(val)?;
|
||||
}
|
||||
OpCode::SampleRateOf => {
|
||||
let slot = self.core.bytecode[pc] as usize;
|
||||
pc += 1;
|
||||
let sr = if slot < self.sample_slots.len() {
|
||||
self.sample_slots[slot].sample_rate as i32
|
||||
} else {
|
||||
0
|
||||
};
|
||||
self.core.push_i(sr)?;
|
||||
}
|
||||
|
||||
// Built-in constants
|
||||
OpCode::LoadSampleRate => {
|
||||
self.core.push_i(sample_rate as i32)?;
|
||||
}
|
||||
OpCode::LoadBufferSize => {
|
||||
self.core.push_i(buffer_size as i32)?;
|
||||
}
|
||||
|
||||
// Draw/mouse opcodes are not valid in the audio ScriptVM
|
||||
_ => {
|
||||
return Err(ScriptError::InvalidOpcode(opcode as u8));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Draw VM (runs on UI thread, produces draw commands) ----
|
||||
|
||||
/// A draw command produced by the draw block
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DrawCommand {
|
||||
FillCircle { cx: f32, cy: f32, r: f32, color: u32 },
|
||||
StrokeCircle { cx: f32, cy: f32, r: f32, color: u32, width: f32 },
|
||||
StrokeArc { cx: f32, cy: f32, r: f32, start_deg: f32, end_deg: f32, color: u32, width: f32 },
|
||||
Line { x1: f32, y1: f32, x2: f32, y2: f32, color: u32, width: f32 },
|
||||
FillRect { x: f32, y: f32, w: f32, h: f32, color: u32 },
|
||||
StrokeRect { x: f32, y: f32, w: f32, h: f32, color: u32, width: f32 },
|
||||
}
|
||||
|
||||
/// Mouse state passed to the draw VM each frame
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MouseState {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub down: bool,
|
||||
}
|
||||
|
||||
/// Lightweight VM for executing draw bytecode on the UI thread
|
||||
#[derive(Clone)]
|
||||
pub struct DrawVM {
|
||||
core: VmCore,
|
||||
pub draw_commands: Vec<DrawCommand>,
|
||||
pub mouse: MouseState,
|
||||
}
|
||||
|
||||
impl DrawVM {
|
||||
pub fn new(
|
||||
bytecode: Vec<u8>,
|
||||
constants_f32: Vec<f32>,
|
||||
constants_i32: Vec<i32>,
|
||||
num_params: usize,
|
||||
param_defaults: &[f32],
|
||||
num_state_scalars: usize,
|
||||
state_array_sizes: &[usize],
|
||||
) -> Self {
|
||||
Self {
|
||||
core: VmCore::new(
|
||||
bytecode, constants_f32, constants_i32,
|
||||
num_params, param_defaults, num_state_scalars, state_array_sizes,
|
||||
1_000_000, // lower limit for draw (runs per frame)
|
||||
),
|
||||
draw_commands: Vec::new(),
|
||||
mouse: MouseState::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Access params for reading/writing from the editor
|
||||
pub fn params(&self) -> &[f32] {
|
||||
&self.core.params
|
||||
}
|
||||
|
||||
/// Access params mutably (editor sets values from node inputs each frame)
|
||||
pub fn params_mut(&mut self) -> &mut Vec<f32> {
|
||||
&mut self.core.params
|
||||
}
|
||||
|
||||
/// Check if bytecode is non-empty
|
||||
pub fn has_bytecode(&self) -> bool {
|
||||
!self.core.bytecode.is_empty()
|
||||
}
|
||||
|
||||
/// Execute the draw bytecode. Call once per frame.
|
||||
/// Draw commands accumulate in `self.draw_commands` (cleared at start).
|
||||
pub fn execute(&mut self) -> Result<(), ScriptError> {
|
||||
self.core.reset_frame();
|
||||
self.draw_commands.clear();
|
||||
|
||||
let mut pc: usize = 0;
|
||||
let mut ic: u64 = 0;
|
||||
let limit = self.core.instruction_limit;
|
||||
|
||||
while pc < self.core.bytecode.len() {
|
||||
ic += 1;
|
||||
if ic > limit {
|
||||
return Err(ScriptError::ExecutionLimitExceeded);
|
||||
}
|
||||
|
||||
match self.core.step(&mut pc)? {
|
||||
StepResult::Continue => {}
|
||||
StepResult::Halt => return Ok(()),
|
||||
StepResult::Unhandled(opcode) => {
|
||||
match opcode {
|
||||
// Draw commands
|
||||
OpCode::DrawFillCircle => {
|
||||
let color = self.core.pop_i()? as u32;
|
||||
let r = self.core.pop_f()?;
|
||||
let cy = self.core.pop_f()?;
|
||||
let cx = self.core.pop_f()?;
|
||||
self.draw_commands.push(DrawCommand::FillCircle { cx, cy, r, color });
|
||||
}
|
||||
OpCode::DrawStrokeCircle => {
|
||||
let width = self.core.pop_f()?;
|
||||
let color = self.core.pop_i()? as u32;
|
||||
let r = self.core.pop_f()?;
|
||||
let cy = self.core.pop_f()?;
|
||||
let cx = self.core.pop_f()?;
|
||||
self.draw_commands.push(DrawCommand::StrokeCircle { cx, cy, r, color, width });
|
||||
}
|
||||
OpCode::DrawStrokeArc => {
|
||||
let width = self.core.pop_f()?;
|
||||
let color = self.core.pop_i()? as u32;
|
||||
let end_deg = self.core.pop_f()?;
|
||||
let start_deg = self.core.pop_f()?;
|
||||
let r = self.core.pop_f()?;
|
||||
let cy = self.core.pop_f()?;
|
||||
let cx = self.core.pop_f()?;
|
||||
self.draw_commands.push(DrawCommand::StrokeArc { cx, cy, r, start_deg, end_deg, color, width });
|
||||
}
|
||||
OpCode::DrawLine => {
|
||||
let width = self.core.pop_f()?;
|
||||
let color = self.core.pop_i()? as u32;
|
||||
let y2 = self.core.pop_f()?;
|
||||
let x2 = self.core.pop_f()?;
|
||||
let y1 = self.core.pop_f()?;
|
||||
let x1 = self.core.pop_f()?;
|
||||
self.draw_commands.push(DrawCommand::Line { x1, y1, x2, y2, color, width });
|
||||
}
|
||||
OpCode::DrawFillRect => {
|
||||
let color = self.core.pop_i()? as u32;
|
||||
let h = self.core.pop_f()?;
|
||||
let w = self.core.pop_f()?;
|
||||
let y = self.core.pop_f()?;
|
||||
let x = self.core.pop_f()?;
|
||||
self.draw_commands.push(DrawCommand::FillRect { x, y, w, h, color });
|
||||
}
|
||||
OpCode::DrawStrokeRect => {
|
||||
let width = self.core.pop_f()?;
|
||||
let color = self.core.pop_i()? as u32;
|
||||
let h = self.core.pop_f()?;
|
||||
let w = self.core.pop_f()?;
|
||||
let y = self.core.pop_f()?;
|
||||
let x = self.core.pop_f()?;
|
||||
self.draw_commands.push(DrawCommand::StrokeRect { x, y, w, h, color, width });
|
||||
}
|
||||
|
||||
// Mouse input
|
||||
OpCode::MouseX => { self.core.push_f(self.mouse.x)?; }
|
||||
OpCode::MouseY => { self.core.push_f(self.mouse.y)?; }
|
||||
OpCode::MouseDown => { self.core.push_f(if self.mouse.down { 1.0 } else { 0.0 })?; }
|
||||
|
||||
// Param write
|
||||
OpCode::StoreParam => {
|
||||
let idx = self.core.read_u16(&mut pc) as usize;
|
||||
let val = self.core.pop_f()?;
|
||||
if idx < self.core.params.len() {
|
||||
self.core.params[idx] = val;
|
||||
}
|
||||
}
|
||||
|
||||
// Sample access not available in draw context
|
||||
OpCode::SampleLen | OpCode::SampleRead | OpCode::SampleRateOf => {
|
||||
pc += 1; // skip slot byte
|
||||
self.core.push_i(0)?;
|
||||
}
|
||||
|
||||
// Audio I/O not available in draw context
|
||||
_ => {
|
||||
return Err(ScriptError::InvalidOpcode(opcode as u8));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
#!/bin/bash
|
||||
# Build script for static FFmpeg linking
|
||||
|
||||
set -e
|
||||
|
||||
# Point pkg-config to our static FFmpeg build
|
||||
export PKG_CONFIG_PATH="/opt/ffmpeg-static/lib/pkgconfig:${PKG_CONFIG_PATH}"
|
||||
|
||||
# Tell pkg-config to use static linking
|
||||
export PKG_CONFIG_ALL_STATIC=1
|
||||
|
||||
# Force static linking of codec libraries (and link required C++ and NUMA libraries)
|
||||
export RUSTFLAGS="-C prefer-dynamic=no -C link-arg=-L/usr/lib/x86_64-linux-gnu -C link-arg=-Wl,-Bstatic -C link-arg=-lx264 -C link-arg=-lx265 -C link-arg=-lvpx -C link-arg=-lmp3lame -C link-arg=-Wl,-Bdynamic -C link-arg=-lstdc++ -C link-arg=-lnuma"
|
||||
|
||||
# Build with static features
|
||||
echo "Building with static FFmpeg from /opt/ffmpeg-static..."
|
||||
echo "PKG_CONFIG_PATH=$PKG_CONFIG_PATH"
|
||||
echo "PKG_CONFIG_ALL_STATIC=$PKG_CONFIG_ALL_STATIC"
|
||||
|
||||
cargo build --release
|
||||
|
||||
echo ""
|
||||
echo "Build complete! Binary at: target/release/lightningbeam-editor"
|
||||
echo ""
|
||||
echo "To verify static linking, run:"
|
||||
echo " ldd target/release/lightningbeam-editor | grep -E '(ffmpeg|avcodec|avformat|x264|x265|vpx)'"
|
||||
echo "(Should show no ffmpeg or codec libraries if fully static)"
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
@echo off
|
||||
REM Build script for Windows
|
||||
REM Requires: FFmpeg 8.0.0 dev files in C:\ffmpeg, LLVM installed, VS Build Tools
|
||||
|
||||
REM Set up MSVC environment
|
||||
call "C:\Program Files (x86)\Microsoft Visual Studio\18\BuildTools\VC\Auxiliary\Build\vcvarsall.bat" x64
|
||||
|
||||
REM FFmpeg location (headers + libs + DLLs)
|
||||
if not defined FFMPEG_DIR set FFMPEG_DIR=C:\ffmpeg
|
||||
|
||||
REM LLVM/libclang for bindgen (ffmpeg-sys-next)
|
||||
if not defined LIBCLANG_PATH set LIBCLANG_PATH=C:\Program Files\LLVM\bin
|
||||
|
||||
REM Validate prerequisites
|
||||
if not exist "%FFMPEG_DIR%\include\libavcodec\avcodec.h" (
|
||||
echo ERROR: FFmpeg dev files not found at %FFMPEG_DIR%
|
||||
echo Download FFmpeg 8.0.0 shared+dev from https://github.com/GyanD/codexffmpeg/releases
|
||||
echo and extract to %FFMPEG_DIR%
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if not exist "%LIBCLANG_PATH%\libclang.dll" (
|
||||
echo ERROR: LLVM/libclang not found at %LIBCLANG_PATH%
|
||||
echo Install with: winget install LLVM.LLVM
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo Building Lightningbeam Editor...
|
||||
echo FFMPEG_DIR=%FFMPEG_DIR%
|
||||
echo LIBCLANG_PATH=%LIBCLANG_PATH%
|
||||
|
||||
cargo build --package lightningbeam-editor %*
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
[package]
|
||||
name = "egui_node_graph2"
|
||||
description = "A helper library to create interactive node graphs using egui"
|
||||
homepage = "https://github.com/trevyn/egui_node_graph2"
|
||||
repository = "https://github.com/trevyn/egui_node_graph2"
|
||||
license = "MIT"
|
||||
version = "0.7.0"
|
||||
keywords = ["egui_node_graph", "ui", "egui", "graph", "node"]
|
||||
edition = "2021"
|
||||
readme = "../README.md"
|
||||
workspace = ".."
|
||||
|
||||
[features]
|
||||
persistence = ["serde", "slotmap/serde", "smallvec/serde", "egui/persistence"]
|
||||
|
||||
[dependencies]
|
||||
egui = "0.33.3"
|
||||
slotmap = { version = "1.0" }
|
||||
smallvec = { version = "1.10.0" }
|
||||
serde = { version = "1.0", optional = true, features = ["derive"] }
|
||||
thiserror = "1.0"
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
use egui::Color32;
|
||||
|
||||
/// Converts a hex string with a leading '#' into a egui::Color32.
|
||||
/// - The first three channels are interpreted as R, G, B.
|
||||
/// - The fourth channel, if present, is used as the alpha value.
|
||||
/// - Both upper and lowercase characters can be used for the hex values.
|
||||
///
|
||||
/// *Adapted from: https://docs.rs/raster/0.1.0/src/raster/lib.rs.html#425-725.
|
||||
/// Credit goes to original authors.*
|
||||
pub fn color_from_hex(hex: &str) -> Result<Color32, String> {
|
||||
// Convert a hex string to decimal. Eg. "00" -> 0. "FF" -> 255.
|
||||
fn _hex_dec(hex_string: &str) -> Result<u8, String> {
|
||||
match u8::from_str_radix(hex_string, 16) {
|
||||
Ok(o) => Ok(o),
|
||||
Err(e) => Err(format!("Error parsing hex: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
if hex.len() == 9 && hex.starts_with('#') {
|
||||
// #FFFFFFFF (Red Green Blue Alpha)
|
||||
return Ok(Color32::from_rgba_premultiplied(
|
||||
_hex_dec(&hex[1..3])?,
|
||||
_hex_dec(&hex[3..5])?,
|
||||
_hex_dec(&hex[5..7])?,
|
||||
_hex_dec(&hex[7..9])?,
|
||||
));
|
||||
} else if hex.len() == 7 && hex.starts_with('#') {
|
||||
// #FFFFFF (Red Green Blue)
|
||||
return Ok(Color32::from_rgb(
|
||||
_hex_dec(&hex[1..3])?,
|
||||
_hex_dec(&hex[3..5])?,
|
||||
_hex_dec(&hex[5..7])?,
|
||||
));
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"Error parsing hex: {}. Example of valid formats: #FFFFFF or #ffffffff",
|
||||
hex
|
||||
))
|
||||
}
|
||||
|
||||
/// Converts a Color32 into its canonical hexadecimal representation.
|
||||
/// - The color string will be preceded by '#'.
|
||||
/// - If the alpha channel is completely opaque, it will be ommitted.
|
||||
/// - Characters from 'a' to 'f' will be written in lowercase.
|
||||
#[allow(dead_code)]
|
||||
pub fn color_to_hex(color: Color32) -> String {
|
||||
if color.a() < 255 {
|
||||
format!(
|
||||
"#{:02x?}{:02x?}{:02x?}{:02x?}",
|
||||
color.r(),
|
||||
color.g(),
|
||||
color.b(),
|
||||
color.a()
|
||||
)
|
||||
} else {
|
||||
format!("#{:02x?}{:02x?}{:02x?}", color.r(), color.g(), color.b())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
pub fn test_color_from_and_to_hex() {
|
||||
assert_eq!(
|
||||
color_from_hex("#00ff00").unwrap(),
|
||||
Color32::from_rgb(0, 255, 0)
|
||||
);
|
||||
assert_eq!(
|
||||
color_from_hex("#5577AA").unwrap(),
|
||||
Color32::from_rgb(85, 119, 170)
|
||||
);
|
||||
assert_eq!(
|
||||
color_from_hex("#E2e2e277").unwrap(),
|
||||
Color32::from_rgba_premultiplied(226, 226, 226, 119)
|
||||
);
|
||||
assert!(color_from_hex("abcdefgh").is_err());
|
||||
|
||||
assert_eq!(
|
||||
color_to_hex(Color32::from_rgb(0, 255, 0)),
|
||||
"#00ff00".to_string()
|
||||
);
|
||||
assert_eq!(
|
||||
color_to_hex(Color32::from_rgb(85, 119, 170)),
|
||||
"#5577aa".to_string()
|
||||
);
|
||||
assert_eq!(
|
||||
color_to_hex(Color32::from_rgba_premultiplied(226, 226, 226, 119)),
|
||||
"#e2e2e277".to_string()
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue