diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..57dc572 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,425 @@ +name: Build & Package + +on: + workflow_dispatch: + push: + branches: + - release + +jobs: + build: + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - platform: ubuntu-22.04 + target: '' + artifact-name: linux-x86_64 + - platform: macos-latest + target: '' + artifact-name: macos-arm64 + - platform: macos-latest + target: x86_64-apple-darwin + artifact-name: macos-x86_64 + - platform: windows-latest + target: '' + artifact-name: windows-x86_64 + + runs-on: ${{ matrix.platform }} + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Verify submodules + shell: bash + run: | + echo "=== Submodule status ===" + git submodule status --recursive + echo "=== NeuralAudio deps ===" + ls -la vendor/NeuralAudio/deps/ + ls vendor/NeuralAudio/deps/RTNeural/CMakeLists.txt + ls vendor/NeuralAudio/deps/math_approx/CMakeLists.txt + + - name: Clone egui fork + run: git clone --depth 1 -b ibus-wayland-fix https://git.skyler.io/skyler/egui.git ../egui-fork + env: + GIT_LFS_SKIP_SMUDGE: "1" + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target != '' && matrix.target || '' }} + + - name: Rust cache + uses: swatinem/rust-cache@v2 + with: + workspaces: './lightningbeam-ui -> target' + key: ${{ matrix.artifact-name }}-v2 + + # ── Linux dependencies ── + - name: Install dependencies (Linux) + if: matrix.platform == 'ubuntu-22.04' + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential pkg-config clang nasm cmake \ + libasound2-dev libwayland-dev libwayland-cursor0 \ + libx11-dev libxkbcommon-dev libxcb-shape0-dev libxcb-xfixes0-dev \ + libxdo-dev libglib2.0-dev libgtk-3-dev libvulkan-dev \ + yasm libx264-dev libx265-dev libvpx-dev libmp3lame-dev libopus-dev \ + libpulse-dev squashfs-tools dpkg rpm + + - name: Install cargo packaging tools (Linux) + if: matrix.platform == 'ubuntu-22.04' + uses: taiki-e/install-action@v2 + with: + tool: cargo-deb,cargo-generate-rpm + + # ── macOS dependencies ── + - name: Install dependencies (macOS) + if: startsWith(matrix.platform, 'macos') + run: brew install nasm cmake create-dmg + + # ── Windows dependencies ── + - name: Install dependencies (Windows) + if: matrix.platform == 'windows-latest' + shell: pwsh + run: choco install cmake llvm --installargs 'ADD_CMAKE_TO_PATH=System' -y + + # ── Common build steps ── + - name: Extract version + id: version + shell: bash + run: | + VERSION=$(grep '^version' lightningbeam-ui/lightningbeam-editor/Cargo.toml | sed 's/.*"\(.*\)"/\1/') + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Enable FFmpeg build from source (Linux/macOS) + if: matrix.platform != 'windows-latest' + shell: bash + run: | + sed -i.bak 's/ffmpeg-next = { version = "8.0", features = \["static"\] }/ffmpeg-next = { version = "8.0", features = ["build", "static"] }/' lightningbeam-ui/lightningbeam-editor/Cargo.toml + + - name: Setup FFmpeg (Windows) + if: matrix.platform == 'windows-latest' + shell: pwsh + run: | + # Download FFmpeg 8.0 shared+dev build (headers + import libs + DLLs) + $url = "https://github.com/GyanD/codexffmpeg/releases/download/8.0/ffmpeg-8.0-full_build-shared.7z" + Invoke-WebRequest -Uri $url -OutFile ffmpeg.7z + 7z x ffmpeg.7z -offmpeg-win + $ffmpegDir = (Get-ChildItem -Path ffmpeg-win -Directory | Select-Object -First 1).FullName + echo "FFMPEG_DIR=$ffmpegDir" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + # LLVM/libclang for bindgen + echo "LIBCLANG_PATH=C:\Program Files\LLVM\bin" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Setup icons + shell: bash + run: | + mkdir -p lightningbeam-ui/lightningbeam-editor/assets/icons + cp -f src-tauri/icons/32x32.png lightningbeam-ui/lightningbeam-editor/assets/icons/ + cp -f src-tauri/icons/128x128.png lightningbeam-ui/lightningbeam-editor/assets/icons/ + cp -f src-tauri/icons/icon.png lightningbeam-ui/lightningbeam-editor/assets/icons/256x256.png + + - name: Stage factory presets + shell: bash + run: | + mkdir -p lightningbeam-ui/lightningbeam-editor/assets/presets + cp -r src/assets/instruments/* lightningbeam-ui/lightningbeam-editor/assets/presets/ + # Remove empty category dirs and README + find lightningbeam-ui/lightningbeam-editor/assets/presets -maxdepth 1 -type d -empty -delete + rm -f lightningbeam-ui/lightningbeam-editor/assets/presets/README.md + + - name: Inject preset entries into RPM metadata (Linux) + if: matrix.platform == 'ubuntu-22.04' + shell: bash + run: | + cd lightningbeam-ui + find lightningbeam-editor/assets/presets -type f | sort | while read -r f; do + rel="${f#lightningbeam-editor/}" + dest="/usr/share/lightningbeam-editor/presets/${f#lightningbeam-editor/assets/presets/}" + printf '\n[[package.metadata.generate-rpm.assets]]\nsource = "%s"\ndest = "%s"\nmode = "644"\n' "$rel" "$dest" >> lightningbeam-editor/Cargo.toml + done + + - name: Fix FFmpeg cross-compile OS detection (macOS x86_64) + if: matrix.target == 'x86_64-apple-darwin' + shell: bash + run: | + # ffmpeg-sys-next passes --target-os=macos to FFmpeg configure, but FFmpeg + # expects --target-os=darwin. Fetch crates, then patch the build script. + cd lightningbeam-ui + cargo fetch + BUILDRS=$(find $HOME/.cargo/registry/src -path '*/ffmpeg-sys-next-*/build.rs' | head -1) + echo "Patching $BUILDRS to fix macos -> darwin mapping" + # Add "macos" => "darwin" to the match in get_ffmpeg_target_os() + sed -i.bak 's/"ios" => "darwin"/"ios" | "macos" => "darwin"/' "$BUILDRS" + grep -A4 'fn get_ffmpeg_target_os' "$BUILDRS" + echo "MACOSX_DEPLOYMENT_TARGET=11.0" >> "$GITHUB_ENV" + + - name: Build release binary + shell: bash + env: + FFMPEG_STATIC: "1" + run: | + cd lightningbeam-ui + TARGET_FLAG="" + if [ -n "${{ matrix.target }}" ]; then + TARGET_FLAG="--target ${{ matrix.target }}" + fi + cargo build --release --bin lightningbeam-editor $TARGET_FLAG + + - name: Copy cross-compiled binary to release dir + if: matrix.target != '' + shell: bash + run: | + mkdir -p lightningbeam-ui/target/release + cp lightningbeam-ui/target/${{ matrix.target }}/release/lightningbeam-editor lightningbeam-ui/target/release/ + + # ── Stage presets next to binary for packaging ── + - name: Stage presets in target dir + shell: bash + run: | + mkdir -p lightningbeam-ui/target/release/presets + cp -r lightningbeam-ui/lightningbeam-editor/assets/presets/* lightningbeam-ui/target/release/presets/ + + # ══════════════════════════════════════════════ + # Linux Packaging + # ══════════════════════════════════════════════ + - name: Build .deb package + if: matrix.platform == 'ubuntu-22.04' + shell: bash + run: | + cd lightningbeam-ui + cargo deb -p lightningbeam-editor --no-build --no-strip + + # Inject factory presets into .deb (cargo-deb doesn't handle recursive dirs well) + DEB=$(ls target/debian/*.deb | head -1) + WORK=$(mktemp -d) + dpkg-deb -R "$DEB" "$WORK" + mkdir -p "$WORK/usr/share/lightningbeam-editor/presets" + cp -r lightningbeam-editor/assets/presets/* "$WORK/usr/share/lightningbeam-editor/presets/" + dpkg-deb -b "$WORK" "$DEB" + rm -rf "$WORK" + + - name: Build .rpm package + if: matrix.platform == 'ubuntu-22.04' + shell: bash + run: | + cd lightningbeam-ui + cargo generate-rpm -p lightningbeam-editor + + - name: Build AppImage + if: matrix.platform == 'ubuntu-22.04' + shell: bash + run: | + cd lightningbeam-ui + VERSION="${{ steps.version.outputs.version }}" + APPDIR=/tmp/AppDir + ASSETS=lightningbeam-editor/assets + + rm -rf "$APPDIR" + mkdir -p "$APPDIR/usr/bin" + mkdir -p "$APPDIR/usr/bin/presets" + mkdir -p "$APPDIR/usr/share/applications" + mkdir -p "$APPDIR/usr/share/metainfo" + mkdir -p "$APPDIR/usr/share/icons/hicolor/32x32/apps" + mkdir -p "$APPDIR/usr/share/icons/hicolor/128x128/apps" + mkdir -p "$APPDIR/usr/share/icons/hicolor/256x256/apps" + + cp target/release/lightningbeam-editor "$APPDIR/usr/bin/" + cp -r lightningbeam-editor/assets/presets/* "$APPDIR/usr/bin/presets/" + + cp "$ASSETS/com.lightningbeam.editor.desktop" "$APPDIR/usr/share/applications/" + cp "$ASSETS/com.lightningbeam.editor.appdata.xml" "$APPDIR/usr/share/metainfo/" + cp "$ASSETS/icons/32x32.png" "$APPDIR/usr/share/icons/hicolor/32x32/apps/lightningbeam-editor.png" + cp "$ASSETS/icons/128x128.png" "$APPDIR/usr/share/icons/hicolor/128x128/apps/lightningbeam-editor.png" + cp "$ASSETS/icons/256x256.png" "$APPDIR/usr/share/icons/hicolor/256x256/apps/lightningbeam-editor.png" + + ln -sf usr/share/icons/hicolor/256x256/apps/lightningbeam-editor.png "$APPDIR/lightningbeam-editor.png" + ln -sf usr/share/applications/com.lightningbeam.editor.desktop "$APPDIR/lightningbeam-editor.desktop" + + printf '#!/bin/bash\nSELF=$(readlink -f "$0")\nHERE=${SELF%%/*}\nexport XDG_DATA_DIRS="${HERE}/usr/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}"\nexec "${HERE}/usr/bin/lightningbeam-editor" "$@"\n' > "$APPDIR/AppRun" + chmod +x "$APPDIR/AppRun" + + # Download AppImage runtime + wget -q "https://github.com/AppImage/AppImageKit/releases/download/continuous/runtime-x86_64" \ + -O /tmp/appimage-runtime + chmod +x /tmp/appimage-runtime + + # Build squashfs and concatenate + mksquashfs "$APPDIR" /tmp/appimage.squashfs \ + -root-owned -noappend -no-exports -no-xattrs \ + -comp gzip -b 131072 + cat /tmp/appimage-runtime /tmp/appimage.squashfs \ + > "Lightningbeam_Editor-${VERSION}-x86_64.AppImage" + chmod +x "Lightningbeam_Editor-${VERSION}-x86_64.AppImage" + + - name: Upload .deb + if: matrix.platform == 'ubuntu-22.04' + uses: actions/upload-artifact@v4 + with: + name: deb-package + path: lightningbeam-ui/target/debian/*.deb + if-no-files-found: error + + - name: Upload .rpm + if: matrix.platform == 'ubuntu-22.04' + uses: actions/upload-artifact@v4 + with: + name: rpm-package + path: lightningbeam-ui/target/generate-rpm/*.rpm + if-no-files-found: error + + - name: Upload AppImage + if: matrix.platform == 'ubuntu-22.04' + uses: actions/upload-artifact@v4 + with: + name: appimage + path: lightningbeam-ui/Lightningbeam_Editor-*.AppImage + if-no-files-found: error + + # ══════════════════════════════════════════════ + # macOS Packaging + # ══════════════════════════════════════════════ + - name: Create macOS .app bundle + if: startsWith(matrix.platform, 'macos') + shell: bash + run: | + VERSION="${{ steps.version.outputs.version }}" + APP="Lightningbeam Editor.app" + mkdir -p "$APP/Contents/MacOS" + mkdir -p "$APP/Contents/Resources/presets" + + cp lightningbeam-ui/target/release/lightningbeam-editor "$APP/Contents/MacOS/" + cp src-tauri/icons/icon.icns "$APP/Contents/Resources/lightningbeam-editor.icns" + cp -r lightningbeam-ui/lightningbeam-editor/assets/presets/* "$APP/Contents/Resources/presets/" + + cat > "$APP/Contents/Info.plist" << EOF + + + + + CFBundleName + Lightningbeam Editor + CFBundleDisplayName + Lightningbeam Editor + CFBundleIdentifier + com.lightningbeam.editor + CFBundleVersion + ${VERSION} + CFBundleShortVersionString + ${VERSION} + CFBundlePackageType + APPL + CFBundleExecutable + lightningbeam-editor + CFBundleIconFile + lightningbeam-editor + LSMinimumSystemVersion + 11.0 + NSHighResolutionCapable + + + + EOF + + - name: Create macOS .dmg + if: startsWith(matrix.platform, 'macos') + shell: bash + run: | + VERSION="${{ steps.version.outputs.version }}" + ARCH="${{ matrix.target == 'x86_64-apple-darwin' && 'x86_64' || 'arm64' }}" + DMG_NAME="Lightningbeam_Editor-${VERSION}-macOS-${ARCH}.dmg" + + create-dmg \ + --volname "Lightningbeam Editor" \ + --window-pos 200 120 \ + --window-size 600 400 \ + --icon-size 100 \ + --icon "Lightningbeam Editor.app" 175 190 \ + --app-drop-link 425 190 \ + "$DMG_NAME" \ + "Lightningbeam Editor.app" || true + # create-dmg returns non-zero if codesigning is skipped, but the .dmg is still valid + + - name: Upload .dmg + if: startsWith(matrix.platform, 'macos') + uses: actions/upload-artifact@v4 + with: + name: dmg-${{ matrix.artifact-name }} + path: Lightningbeam_Editor-*.dmg + if-no-files-found: error + + # ══════════════════════════════════════════════ + # Windows Packaging + # ══════════════════════════════════════════════ + - name: Create Windows .zip + if: matrix.platform == 'windows-latest' + shell: pwsh + run: | + $VERSION = "${{ steps.version.outputs.version }}" + $DIST = "Lightningbeam_Editor-${VERSION}-Windows-x86_64" + New-Item -ItemType Directory -Force -Path $DIST + Copy-Item "lightningbeam-ui/target/release/lightningbeam-editor.exe" "$DIST/" + Copy-Item -Recurse "lightningbeam-ui/target/release/presets" "$DIST/presets" + # Bundle FFmpeg DLLs (shared build) + Copy-Item "$env:FFMPEG_DIR\bin\*.dll" "$DIST/" + Compress-Archive -Path $DIST -DestinationPath "${DIST}.zip" + + - name: Upload .zip + if: matrix.platform == 'windows-latest' + uses: actions/upload-artifact@v4 + with: + name: windows-zip + path: Lightningbeam_Editor-*.zip + if-no-files-found: error + + release: + needs: build + runs-on: ubuntu-22.04 + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: | + lightningbeam-ui/lightningbeam-editor/Cargo.toml + Changelog.md + + - name: Extract version + id: version + run: | + VERSION=$(grep '^version' lightningbeam-ui/lightningbeam-editor/Cargo.toml | sed 's/.*"\(.*\)"/\1/') + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Extract release notes + id: notes + uses: sean0x42/markdown-extract@v2.1.0 + with: + pattern: "${{ steps.version.outputs.version }}:" + file: Changelog.md + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + + - name: List artifacts + run: ls -lhR dist/ + + - name: Create draft release + uses: softprops/action-gh-release@v2 + with: + tag_name: "v${{ steps.version.outputs.version }}" + name: "Lightningbeam v${{ steps.version.outputs.version }}" + body: ${{ steps.notes.outputs.markdown }} + draft: true + prerelease: true + files: dist/* + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index 657ee38..0000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,150 +0,0 @@ -name: 'publish' - -on: - workflow_dispatch: - push: - branches: - - release - -jobs: - extract-changelog: - runs-on: ubuntu-22.04 - steps: - - uses: actions/checkout@v4 - - - name: Set version for changelog extraction - shell: bash - run: | - # Read the version from src-tauri/tauri.conf.json - VERSION=$(jq -r '.version' src-tauri/tauri.conf.json) - # Set the version in the environment variable - echo "VERSION=$VERSION" >> $GITHUB_ENV - - - name: Extract release notes from Changelog.md - id: changelog - uses: sean0x42/markdown-extract@v2.1.0 - with: - pattern: "${{ env.VERSION }}:" # Look for the version header (e.g., # 0.6.15-alpha:) - file: Changelog.md - - - name: Set markdown output - id: set-markdown-output - run: | - echo 'RELEASE_NOTES<> $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 }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..35df03e --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/ +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Git +.gitignore + +# Build +src-tauri/gen +src-tauri/target +lightningbeam-core/target +daw-backend/target +target/ + +# Packaging build artifacts +packaging/output/ +packaging/AppDir/ +packaging/squashfs-root/ + +# Wrapper script (generated, not needed with static FFmpeg) +lightningbeam-ui/lightningbeam-editor/debian/ \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..e627048 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "vendor/NeuralAudio"] + path = vendor/NeuralAudio + url = https://github.com/mikeoliphant/NeuralAudio.git diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..7da90d8 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,538 @@ +# Lightningbeam Architecture + +This document provides a comprehensive overview of Lightningbeam's architecture, design decisions, and component interactions. + +## Table of Contents + +- [System Overview](#system-overview) +- [Technology Stack](#technology-stack) +- [Component Architecture](#component-architecture) +- [Data Flow](#data-flow) +- [Rendering Pipeline](#rendering-pipeline) +- [Audio Architecture](#audio-architecture) +- [Key Design Decisions](#key-design-decisions) +- [Directory Structure](#directory-structure) + +## System Overview + +Lightningbeam is a 2D multimedia editor combining vector animation, audio production, and video editing. The application is built as a pure Rust desktop application using immediate-mode GUI (egui) with GPU-accelerated vector rendering (Vello). + +### High-Level Architecture + +``` +┌────────────────────────────────────────────────────────────┐ +│ Lightningbeam Editor │ +│ (egui UI) │ +├────────────────────────────────────────────────────────────┤ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ Stage │ │ Timeline │ │ Asset │ │ Info │ │ +│ │ Pane │ │ Pane │ │ Library │ │ Panel │ │ +│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ Lightningbeam Core (Data Model) │ │ +│ │ Document, Layers, Clips, Actions, Undo/Redo │ │ +│ └──────────────────────────────────────────────────────┘ │ +├────────────────────────────────────────────────────────────┤ +│ Rendering & Audio │ +│ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ Vello + wgpu │ │ daw-backend │ │ +│ │ (GPU Rendering) │ │ (Audio Engine) │ │ +│ └──────────────────┘ └──────────────────┘ │ +└────────────────────────────────────────────────────────────┘ + ↓ ↓ + ┌─────────┐ ┌─────────┐ + │ GPU │ │ cpal │ + │ (Vulkan │ │ (Audio │ + │ /Metal) │ │ I/O) │ + └─────────┘ └─────────┘ +``` + +### Migration from Tauri/JavaScript + +Lightningbeam is undergoing a rewrite from a Tauri/JavaScript prototype to pure Rust. The original architecture hit IPC bandwidth limitations when streaming decoded video frames. The new Rust UI eliminates this bottleneck by handling all rendering natively. + +**Current Status**: Active development on the `rust-ui` branch. Core UI, tools, and undo system are implemented. Audio integration in progress. + +## Technology Stack + +### UI Framework +- **egui 0.33.3**: Immediate-mode GUI framework +- **eframe 0.33.3**: Application framework wrapping egui +- **winit 0.30**: Cross-platform windowing + +### GPU Rendering +- **Vello (git main)**: GPU-accelerated 2D vector graphics using compute shaders +- **wgpu 27**: Low-level GPU API (Vulkan/Metal backend) +- **kurbo 0.12**: 2D curve and shape primitives +- **peniko 0.5**: Color and brush definitions + +### Audio Engine +- **daw-backend**: Custom real-time audio engine +- **cpal 0.15**: Cross-platform audio I/O +- **symphonia 0.5**: Audio decoding (MP3, FLAC, WAV, Ogg, etc.) +- **rtrb 0.3**: Lock-free ringbuffers for audio thread communication +- **dasp**: Audio graph processing + +### Video +- **FFmpeg**: Video encoding/decoding (via ffmpeg-next) + +### Serialization +- **serde**: Document serialization +- **serde_json**: JSON format + +## Component Architecture + +### 1. Lightningbeam Core (`lightningbeam-core/`) + +The core crate contains the data model and business logic, independent of UI framework. + +**Key Types:** + +```rust +Document { + canvas_size: (u32, u32), + layers: Vec, + undo_stack: Vec>, + redo_stack: Vec>, +} + +Layer (enum) { + VectorLayer { clips: Vec, ... }, + AudioLayer { clips: Vec, ... }, + VideoLayer { clips: Vec, ... }, +} + +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>, + 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` requires 16-byte alignment: + +```rust +#[repr(C)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +struct WaveformParams { + view_matrix: [f32; 16], // 64 bytes + viewport_size: [f32; 2], // 8 bytes + zoom: f32, // 4 bytes + _pad1: f32, // 4 bytes padding + tint_color: [f32; 4], // 16 bytes (requires 16-byte alignment) +} +// Total: 96 bytes +``` + +## Audio Architecture + +### Real-Time Constraints + +Audio callbacks run on a dedicated real-time thread with strict timing requirements: +- Buffer size: 256 frames default (~5.8ms at 44.1kHz) +- ALSA may provide smaller buffers (64-75 frames, ~1.5ms) +- **No blocking operations allowed**: No locks, no allocations, no syscalls + +### Lock-Free Communication + +UI and audio thread communicate via lock-free ringbuffers (rtrb): + +```rust +// UI Thread +command_sender.push(AudioCommand::Play).ok(); + +// Audio Thread (in process callback) +while let Ok(command) = command_receiver.pop() { + match command { + AudioCommand::Play => self.playing = true, + // ... handle other commands + } +} +``` + +### Audio Processing Pipeline + +``` +Audio Callback Invoked (every ~5ms) + ↓ +Process queued commands + ↓ +For each track: + - Read audio samples at playhead position + - Apply effects chain + - Mix to master output + ↓ +Write samples to output buffer + ↓ +Return from callback (must complete in <5ms) +``` + +### Optimized Debug Builds + +Audio code is optimized even in debug builds to meet real-time deadlines: + +```toml +[profile.dev.package.daw-backend] +opt-level = 2 + +[profile.dev.package.symphonia] +opt-level = 2 +# ... other audio libraries +``` + +## Key Design Decisions + +### Layer & Clip System + +**Type-Specific Layers:** +Each layer type supports only its matching clip type: +- `VectorLayer` → `VectorClip` +- `AudioLayer` → `AudioClip` +- `VideoLayer` → `VideoClip` + +**Recursive Nesting:** +Vector clips can contain internal layers of any type, enabling complex nested compositions. + +**Clip vs ClipInstance:** +- **Clip**: Template/definition in asset library (the "master") +- **ClipInstance**: Placed on timeline with instance-specific properties (position, duration, trim points) +- Multiple instances can reference the same clip +- "Make Unique" operation duplicates the underlying clip + +### Undo/Redo System + +**Action Trait:** +```rust +pub trait Action: Send { + fn apply(&mut self, document: &mut Document); + fn undo(&mut self, document: &mut Document); + fn redo(&mut self, document: &mut Document); +} +``` + +All operations (drawing, editing, clip manipulation) implement this trait. + +**Continuous Operations:** +Dragging sliders or scrubbing creates only one undo action when complete, not one per frame. + +### Two-Phase Dispatch Pattern + +Panes cannot directly mutate shared state during rendering (borrowing rules). Instead: + +1. **Phase 1 (Render)**: Panes register actions + ```rust + shared_state.register_action(Box::new(MyAction { ... })); + ``` + +2. **Phase 2 (Execute)**: After all panes rendered, execute actions + ```rust + for action in shared_state.pending_actions.drain(..) { + action.apply(&mut document); + undo_stack.push(action); + } + ``` + +### Pane ID Salting + +egui uses IDs to track widget state. Multiple instances of the same pane would collide without unique IDs. + +**Solution**: Salt all IDs with the pane's node path: +```rust +ui.horizontal(|ui| { + ui.label("My Widget"); +}).id.with(&node_path); +``` + +### Selection & Clipboard + +- **Selection scope**: Limited to current clip/layer +- **Type-aware paste**: Content must match target type +- **Clip instance copying**: Creates reference to same underlying clip +- **Make unique**: Duplicates underlying clip for independent editing + +## Directory Structure + +``` +lightningbeam-2/ +├── lightningbeam-ui/ # Rust UI workspace +│ ├── Cargo.toml # Workspace manifest +│ ├── lightningbeam-editor/ # Main application crate +│ │ ├── Cargo.toml +│ │ └── src/ +│ │ ├── main.rs # Entry point, event loop +│ │ ├── app.rs # Application state +│ │ ├── panes/ +│ │ │ ├── mod.rs # Pane system dispatch +│ │ │ ├── stage.rs # Main canvas +│ │ │ ├── timeline.rs # Timeline editor +│ │ │ ├── asset_library.rs +│ │ │ └── ... +│ │ ├── tools/ # Drawing and editing tools +│ │ ├── rendering/ +│ │ │ ├── vello_integration.rs +│ │ │ ├── waveform_gpu.rs +│ │ │ └── shaders/ +│ │ │ ├── waveform.wgsl +│ │ │ └── waveform_mipgen.wgsl +│ │ └── export/ # Export functionality +│ └── lightningbeam-core/ # Core data model crate +│ ├── Cargo.toml +│ └── src/ +│ ├── lib.rs +│ ├── document.rs # Document structure +│ ├── layer.rs # Layer types +│ ├── clip.rs # Clip types and instances +│ ├── shape.rs # Shape definitions +│ ├── action.rs # Action trait and undo/redo +│ ├── animation.rs # Keyframe animation +│ └── tools.rs # Tool definitions +│ +├── daw-backend/ # Audio engine (standalone) +│ ├── Cargo.toml +│ └── src/ +│ ├── lib.rs # Audio system initialization +│ ├── audio/ +│ │ ├── engine.rs # Main audio callback +│ │ ├── track.rs # Track management +│ │ ├── project.rs # Project state +│ │ └── buffer.rs # Audio buffer utilities +│ ├── effects/ # Audio effects +│ │ ├── reverb.rs +│ │ ├── delay.rs +│ │ └── ... +│ ├── synth/ # Synthesizers +│ └── midi/ # MIDI handling +│ +├── src-tauri/ # Legacy Tauri backend +├── src/ # Legacy JavaScript frontend +├── CONTRIBUTING.md # Contributor guide +├── ARCHITECTURE.md # This file +├── README.md # Project overview +└── docs/ # Additional documentation + ├── AUDIO_SYSTEM.md + ├── UI_SYSTEM.md + └── ... +``` + +## Performance Considerations + +### GPU Rendering +- Vello uses compute shaders for efficient 2D rendering +- Waveforms pre-rendered on GPU with mipmaps for smooth zooming +- Custom wgpu integration minimizes CPU↔GPU data transfer + +### Audio Processing +- Lock-free design: No blocking in audio thread +- Optimized even in debug builds (`opt-level = 2`) +- Memory-mapped file I/O for large audio files +- Zero-copy audio buffers where possible + +### Memory Management +- Audio buffers pre-allocated, no allocations in audio thread +- Vello manages GPU memory automatically +- Document structure uses `Rc`/`Arc` for shared clip references + +## Future Considerations + +### Video Integration +Video decoding has been ported from the legacy Tauri backend. Video soundtracks become audio tracks in daw-backend, enabling full effects processing. + +### File Format +The .beam file format is not yet finalized. Considerations: +- Single JSON file vs container format (e.g., ZIP) +- Embedded media vs external references +- Forward/backward compatibility strategy + +### Node Editor +Primary use: Audio effects chains and modular synthesizers. Future expansion to visual effects and procedural generation is possible. + +## Related Documentation + +- [CONTRIBUTING.md](CONTRIBUTING.md) - Development setup and workflow +- [docs/AUDIO_SYSTEM.md](docs/AUDIO_SYSTEM.md) - Detailed audio engine documentation +- [docs/UI_SYSTEM.md](docs/UI_SYSTEM.md) - UI pane system details +- [docs/RENDERING.md](docs/RENDERING.md) - GPU rendering pipeline +- [Claude.md](Claude.md) - Comprehensive architectural reference for AI assistants diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..12a96ec --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,278 @@ +# Contributing to Lightningbeam + +Thank you for your interest in contributing to Lightningbeam! This document provides guidelines and instructions for setting up your development environment and contributing to the project. + +## Table of Contents + +- [Development Setup](#development-setup) +- [Building the Project](#building-the-project) +- [Project Structure](#project-structure) +- [Making Changes](#making-changes) +- [Code Style](#code-style) +- [Testing](#testing) +- [Submitting Changes](#submitting-changes) +- [Getting Help](#getting-help) + +## Development Setup + +### Prerequisites + +- **Rust**: Install via [rustup](https://rustup.rs/) (stable toolchain) +- **System dependencies** (Linux): + - ALSA development files: `libasound2-dev` + - For Ubuntu/Debian: `sudo apt install libasound2-dev pkg-config` + - For Arch/Manjaro: `sudo pacman -S alsa-lib` +- **FFmpeg**: Required for video encoding/decoding + - Ubuntu/Debian: `sudo apt install ffmpeg libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libswresample-dev pkg-config clang` + - Arch/Manjaro: `sudo pacman -S ffmpeg` + +### Clone and Build + +```bash +# Clone the repository (GitHub) +git clone https://github.com/skykooler/lightningbeam.git +# Or from Gitea +git clone https://git.skyler.io/skyler/lightningbeam.git + +cd lightningbeam + +# Build the Rust UI editor (current focus) +cd lightningbeam-ui +cargo build + +# Run the editor +cargo run +``` + +**Note**: The project is hosted on both GitHub and Gitea (git.skyler.io). You can use either for cloning and submitting pull requests. + +## Building the Project + +### Workspace Structure + +The project consists of multiple Rust workspaces: + +1. **lightningbeam-ui** (current focus) - Pure Rust UI application + - `lightningbeam-editor/` - Main editor application + - `lightningbeam-core/` - Core data models and business logic + +2. **daw-backend** - Audio engine (standalone crate) + +3. **Root workspace** (legacy) - Contains Tauri backend and benchmarks + +### Build Commands + +```bash +# Build the editor (from lightningbeam-ui/) +cargo build + +# Build with optimizations (faster runtime) +cargo build --release + +# Check just the audio backend +cargo check -p daw-backend + +# Build the audio backend separately +cd ../daw-backend +cargo build +``` + +### Debug Builds and Audio Performance + +The audio engine runs on a real-time thread with strict timing constraints (~5.8ms at 44.1kHz). To maintain performance in debug builds, the audio backend is compiled with optimizations even in debug mode: + +```toml +# In lightningbeam-ui/Cargo.toml +[profile.dev.package.daw-backend] +opt-level = 2 +``` + +This is already configured—no action needed. + +### Debug Flags + +Enable audio diagnostics with: +```bash +DAW_AUDIO_DEBUG=1 cargo run +``` + +This prints timing information, buffer sizes, and overrun warnings to help debug audio issues. + +## Project Structure + +``` +lightningbeam-2/ +├── lightningbeam-ui/ # Rust UI workspace (current) +│ ├── lightningbeam-editor/ # Main application +│ │ └── src/ +│ │ ├── main.rs # Entry point +│ │ ├── panes/ # UI panes (stage, timeline, etc.) +│ │ └── tools/ # Drawing and editing tools +│ └── lightningbeam-core/ # Core data model +│ └── src/ +│ ├── document.rs # Document structure +│ ├── clip.rs # Clips and instances +│ ├── action.rs # Undo/redo system +│ └── tools.rs # Tool system +├── daw-backend/ # Audio engine +│ └── src/ +│ ├── lib.rs # Audio system setup +│ ├── audio/ +│ │ ├── engine.rs # Audio callback +│ │ ├── track.rs # Track management +│ │ └── project.rs # Project state +│ └── effects/ # Audio effects +├── src-tauri/ # Legacy Tauri backend +└── src/ # Legacy JavaScript frontend +``` + +## Making Changes + +### Branching Strategy + +- `main` - Stable branch +- `rust-ui` - Active development branch for Rust UI rewrite +- Feature branches - Create from `rust-ui` for new features + +### Before You Start + +1. Check existing issues or create a new one to discuss your change +2. Make sure you're on the latest `rust-ui` branch: + ```bash + git checkout rust-ui + git pull origin rust-ui + ``` +3. Create a feature branch: + ```bash + git checkout -b feature/your-feature-name + ``` + +## Code Style + +### Rust Style + +- Follow standard Rust formatting: `cargo fmt` +- Check for common issues: `cargo clippy` +- Use meaningful variable names +- Add comments for non-obvious code +- Keep functions focused and reasonably sized + +### Key Patterns + +#### Pane ID Salting +When implementing new panes, **always salt egui IDs** with the node path to avoid collisions when users add multiple instances of the same pane: + +```rust +ui.horizontal(|ui| { + ui.label("My Widget"); +}).id.with(&node_path); // Salt with node path +``` + +#### Splitting Borrows with `std::mem::take` +When you need to split borrows from a struct, use `std::mem::take`: + +```rust +let mut clips = std::mem::take(&mut self.clips); +// Now you can borrow other fields while processing clips +``` + +#### Two-Phase Dispatch +Panes register handlers during render, execution happens after: + +```rust +// During render +shared_state.register_action(Box::new(MyAction { ... })); + +// After all panes rendered +for action in shared_state.pending_actions.drain(..) { + action.execute(&mut document); +} +``` + +## Testing + +### Running Tests + +```bash +# Run all tests +cargo test + +# Test specific package +cargo test -p lightningbeam-core +cargo test -p daw-backend + +# Run with output +cargo test -- --nocapture +``` + +### Audio Testing + +Test audio functionality: +```bash +# Run with audio debug output +DAW_AUDIO_DEBUG=1 cargo run + +# Check for audio dropouts or timing issues in the console output +``` + +## Submitting Changes + +### Before Submitting + +1. **Format your code**: `cargo fmt --all` +2. **Run clippy**: `cargo clippy --all-targets --all-features` +3. **Run tests**: `cargo test --all` +4. **Test manually**: Build and run the application to verify your changes work +5. **Write clear commit messages**: Describe what and why, not just what + +### Commit Message Format + +``` +Short summary (50 chars or less) + +More detailed explanation if needed. Wrap at 72 characters. +Explain the problem this commit solves and why you chose +this solution. + +- Bullet points are fine +- Use present tense: "Add feature" not "Added feature" +``` + +### Pull Request Process + +1. Push your branch to GitHub or Gitea +2. Open a pull request against `rust-ui` branch + - GitHub: https://github.com/skykooler/lightningbeam + - Gitea: https://git.skyler.io/skyler/lightningbeam +3. Provide a clear description of: + - What problem does this solve? + - How does it work? + - Any testing you've done + - Screenshots/videos if applicable (especially for UI changes) +4. Address review feedback +5. Once approved, a maintainer will merge your PR + +### PR Checklist + +- [ ] Code follows project style (`cargo fmt`, `cargo clippy`) +- [ ] Tests pass (`cargo test`) +- [ ] New code has appropriate tests (if applicable) +- [ ] Documentation updated (if needed) +- [ ] Commit messages are clear +- [ ] PR description explains the change + +## Getting Help + +- **Issues**: Check issues on [GitHub](https://github.com/skykooler/lightningbeam/issues) or [Gitea](https://git.skyler.io/skyler/lightningbeam/issues) for existing discussions +- **Documentation**: See `ARCHITECTURE.md` and `docs/` folder for technical details +- **Questions**: Open a discussion or issue with the "question" label on either platform + +## Additional Resources + +- [ARCHITECTURE.md](ARCHITECTURE.md) - System architecture overview +- [docs/AUDIO_SYSTEM.md](docs/AUDIO_SYSTEM.md) - Audio engine details +- [docs/UI_SYSTEM.md](docs/UI_SYSTEM.md) - UI and pane system + +## License + +By contributing, you agree that your contributions will be licensed under the same license as the project. diff --git a/Changelog.md b/Changelog.md index af0e374..6e89aad 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,3 +1,73 @@ +# 1.0.3-alpha: +Changes: +- Add gradient support to vector graphics +- Add "frames" timeline mode +- Reduce CPU usage at idle +- Allow group tracks' audio node graphs to be edited + +Bugfixes: +- Support Vello CPU fallback on systems with older GPUs + +# 1.0.2-alpha: +Changes: +- All vector shapes on a layer go into a unified shape rather than separate shapes +- Keyboard shortcuts are now user-configurable +- Added webcam support in video editor +- Background can now be transparent +- Video thumbnails are now displayed on the clip +- Virtual keyboard, piano roll and node editor now have a quick switcher +- Add electric guitar preset +- Layers can now be grouped +- Layers can be reordered by dragging +- Added VU meters to audio layers and mix +- Added raster image editing +- Added brush, airbrush, dodge/burn, sponge, pattern stamp, healing brush, clone stamp, blur/sharpen, magic wand and quick select tools +- Added support for MyPaint .myb brushes +- UI now uses CSS styling to support future user styles +- Added image export + +Bugfixes: +- Toolbar now only shows tools that can be used on the current layer +- Fix NAM model loading +- Fix menu width and mouse following +- Export dialog now remembers the previous export filename + +# 1.0.1-alpha: +Changes: +- Added real-time amp simulation via NAM +- Added beat mode to the timeline +- Changed shape drawing from making separate shapes to making shapes in the layer using a DCEL graph +- Licensed under GPLv3 +- Added snapping for vector editing +- Added organ instrument and vibrato node + +Bugfixes: +- Fix preset loading not updating node graph editor +- Fix stroke intersections not splitting strokes +- Fix paint bucket fill not attaching to existing strokes + +# 1.0.0-alpha: +Changes: +- New native GUI built with egui + wgpu (replaces Tauri/web frontend) +- GPU-accelerated canvas with vello rendering +- MIDI input and node-based audio graph improvements +- Factory instrument presets +- Video import and high performance playback + +# 0.8.1-alpha: +Changes: +- Rewrite timeline UI +- Add start screen +- Move audio engine to backend +- Add node editor for audio synthesis +- Add factory presets for instruments +- Add MIDI input support +- Add BPM handling and time signature +- Add metronome +- Add preset layouts for different tasks +- Add video import +- Add animation curves for object properties + # 0.7.14-alpha: Changes: - Moving frames can now be undone diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + 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. + + + Copyright (C) + + 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 . + +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: + + Copyright (C) + 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 +. + + 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 +. diff --git a/README.md b/README.md index 36d8496..cf9afb3 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,127 @@ -# Lightningbeam 2 +# Lightningbeam -This README needs content. This is Lightningbeam rewritten with Tauri. +A free and open-source 2D multimedia editor combining vector animation, audio production, and video editing in a single application. -To test: +## Screenshots -`pnpm tauri dev` \ No newline at end of file +![Animation View](screenshots/animation.png) + +![Music Editing View](screenshots/music.png) + +![Video Editing View](screenshots/video.png) + +## Features + +**Vector Animation** +- GPU-accelerated vector rendering with Vello +- Draw and animate vector shapes with keyframe-based timeline +- Non-destructive editing workflow +- Paint bucket tool for automatic fill detection + +**Audio Production** +- Real-time multi-track audio recording and playback +- Node graph-based effects processing +- MIDI sequencing with synthesizers and samplers +- Comprehensive effects library (reverb, delay, EQ, compression, distortion, etc.) +- Custom audio engine with lock-free design for glitch-free playback + +**Video Editing** +- Video timeline and editing with FFmpeg-based decoding +- GPU-accelerated waveform rendering with mipmaps +- Audio integration from video soundtracks + +## Technical Stack + +**Current Implementation (Rust UI)** +- **UI Framework:** egui (immediate-mode GUI) +- **GPU Rendering:** Vello + wgpu (Vulkan/Metal/DirectX 12) +- **Audio Engine:** Custom real-time engine (`daw-backend`) + - cpal for cross-platform audio I/O + - symphonia for audio decoding + - dasp for node graph processing +- **Video:** FFmpeg 8 for encode/decode +- **Platform:** Cross-platform (Linux, macOS, Windows) + +**Legacy Implementation (Deprecated)** +- Frontend: Vanilla JavaScript +- Backend: Rust (Tauri framework) + +## Project Status + +Lightningbeam is under active development on the `rust-ui` branch. The project has been rewritten from a Tauri/JavaScript prototype to a pure Rust application to eliminate IPC bottlenecks and achieve better performance for real-time video and audio processing. + +**Current Status:** +- ✅ Core UI panes (Stage, Timeline, Asset Library, Info Panel, Toolbar) +- ✅ Drawing tools (Select, Draw, Rectangle, Ellipse, Paint Bucket, Transform) +- ✅ Undo/redo system +- ✅ GPU-accelerated vector rendering +- ✅ Audio engine with node graph processing +- ✅ GPU waveform rendering with mipmaps +- ✅ Video decoding integration +- 🚧 Export system (in progress) +- 🚧 Node editor UI (planned) +- 🚧 Piano roll editor (planned) + +## Getting Started + +### Prerequisites + +- Rust (stable toolchain via [rustup](https://rustup.rs/)) +- System dependencies: + - **Linux:** ALSA development files, FFmpeg 8 + - **macOS:** FFmpeg (via Homebrew) + - **Windows:** FFmpeg 8, Visual Studio with C++ tools + +See [docs/BUILDING.md](docs/BUILDING.md) for detailed setup instructions. + +### Building and Running + +```bash +# Clone the repository +git clone https://github.com/skykooler/lightningbeam.git +# Or from Gitea +git clone https://git.skyler.io/skyler/lightningbeam.git + +cd lightningbeam/lightningbeam-ui + +# Build and run +cargo run + +# Or build optimized release version +cargo build --release +``` + +### Documentation + +- **[CONTRIBUTING.md](CONTRIBUTING.md)** - Development setup and contribution guidelines +- **[ARCHITECTURE.md](ARCHITECTURE.md)** - System architecture overview +- **[docs/BUILDING.md](docs/BUILDING.md)** - Detailed build instructions and troubleshooting +- **[docs/AUDIO_SYSTEM.md](docs/AUDIO_SYSTEM.md)** - Audio engine architecture and development +- **[docs/UI_SYSTEM.md](docs/UI_SYSTEM.md)** - UI pane system and tool development +- **[docs/RENDERING.md](docs/RENDERING.md)** - GPU rendering pipeline and shaders + +## Project History + +Lightningbeam evolved from earlier multimedia editing projects I've worked on since 2010, including the FreeJam DAW. The JavaScript/Tauri prototype began in November 2023, and the Rust UI rewrite started in late 2024 to eliminate performance bottlenecks and provide a more integrated native experience. + +## Goals + +Create a comprehensive FOSS alternative for 2D-focused multimedia work, integrating animation, audio, and video editing in a unified workflow. Lightningbeam aims to be: + +- **Fast:** GPU-accelerated rendering and real-time audio processing +- **Flexible:** Node graph-based audio routing and modular synthesis +- **Integrated:** Seamless workflow across animation, audio, and video +- **Open:** Free and open-source, built on open standards + +## Contributing + +Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. + +## License + +[License information to be added] + +## Links + +- **GitHub:** https://github.com/skykooler/lightningbeam +- **Gitea:** https://git.skyler.io/skyler/lightningbeam diff --git a/daw-backend/C2.mp3 b/daw-backend/C2.mp3 new file mode 100644 index 0000000..3fca8d0 Binary files /dev/null and b/daw-backend/C2.mp3 differ diff --git a/daw-backend/Cargo.lock b/daw-backend/Cargo.lock new file mode 100644 index 0000000..6399b8d --- /dev/null +++ b/daw-backend/Cargo.lock @@ -0,0 +1,2484 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "alsa" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2562ad8dcf0f789f65c6fdaad8a8a9708ed6b488e649da28c01656ad66b8b47" +dependencies = [ + "alsa-sys", + "bitflags 1.3.2", + "libc", + "nix", +] + +[[package]] +name = "alsa" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c88dbbce13b232b26250e1e2e6ac18b6a891a646b8148285036ebce260ac5c3" +dependencies = [ + "alsa-sys", + "bitflags 2.9.4", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "beamdsp" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.9.4", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" +dependencies = [ + "bzip2-sys", + "libc", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9fe6cdbb24b6ade63616c0a0688e45bb56732262c158df3c0c4bea4ca47cb7" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "cmake" +version = "0.1.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compact_str" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "ryu", + "static_assertions", +] + +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "coreaudio-rs" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aae284fbaf7d27aa0e292f7677dfbe26503b0d555026f702940805a630eac17" +dependencies = [ + "bitflags 1.3.2", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", +] + +[[package]] +name = "coremidi" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a7847ca018a67204508b77cb9e6de670125075f7464fff5f673023378fa34f5" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "coremidi-sys", +] + +[[package]] +name = "coremidi-sys" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc9504310988d938e49fff1b5f1e56e3dafe39bb1bae580c19660b58b83a191e" +dependencies = [ + "core-foundation-sys", +] + +[[package]] +name = "cpal" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b1f9c7312f19fc2fa12fd7acaf38de54e8320ba10d1a02dcbe21038def51ccb" +dependencies = [ + "alsa 0.10.0", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "objc2", + "objc2-audio-toolbox", + "objc2-avf-audio", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.62.2", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crossterm" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +dependencies = [ + "bitflags 2.9.4", + "crossterm_winapi", + "libc", + "mio", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "dasp_envelope" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ec617ce7016f101a87fe85ed44180839744265fae73bb4aa43e7ece1b7668b6" +dependencies = [ + "dasp_frame", + "dasp_peak", + "dasp_ring_buffer", + "dasp_rms", + "dasp_sample", +] + +[[package]] +name = "dasp_frame" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a3937f5fe2135702897535c8d4a5553f8b116f76c1529088797f2eee7c5cd6" +dependencies = [ + "dasp_sample", +] + +[[package]] +name = "dasp_graph" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39b17b071a1fa4c78054730085620c3bb22dc5fded00483312557a3fdf26d7c4" +dependencies = [ + "dasp_frame", + "dasp_ring_buffer", + "dasp_signal", + "dasp_slice", + "petgraph 0.5.1", +] + +[[package]] +name = "dasp_interpolate" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fc975a6563bb7ca7ec0a6c784ead49983a21c24835b0bc96eea11ee407c7486" +dependencies = [ + "dasp_frame", + "dasp_ring_buffer", + "dasp_sample", +] + +[[package]] +name = "dasp_peak" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cf88559d79c21f3d8523d91250c397f9a15b5fc72fbb3f87fdb0a37b79915bf" +dependencies = [ + "dasp_frame", + "dasp_sample", +] + +[[package]] +name = "dasp_ring_buffer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07d79e19b89618a543c4adec9c5a347fe378a19041699b3278e616e387511ea1" + +[[package]] +name = "dasp_rms" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6c5dcb30b7e5014486e2822537ea2beae50b19722ffe2ed7549ab03774575aa" +dependencies = [ + "dasp_frame", + "dasp_ring_buffer", + "dasp_sample", +] + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "dasp_signal" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa1ab7d01689c6ed4eae3d38fe1cea08cba761573fbd2d592528d55b421077e7" +dependencies = [ + "dasp_envelope", + "dasp_frame", + "dasp_interpolate", + "dasp_peak", + "dasp_ring_buffer", + "dasp_rms", + "dasp_sample", + "dasp_window", +] + +[[package]] +name = "dasp_slice" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e1c7335d58e7baedafa516cb361360ff38d6f4d3f9d9d5ee2a2fc8e27178fa1" +dependencies = [ + "dasp_frame", + "dasp_sample", +] + +[[package]] +name = "dasp_window" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99ded7b88821d2ce4e8b842c9f1c86ac911891ab89443cc1de750cae764c5076" +dependencies = [ + "dasp_sample", +] + +[[package]] +name = "daw-backend" +version = "0.1.0" +dependencies = [ + "base64", + "beamdsp", + "cpal", + "crossterm", + "dasp_envelope", + "dasp_graph", + "dasp_interpolate", + "dasp_peak", + "dasp_ring_buffer", + "dasp_rms", + "dasp_sample", + "dasp_signal", + "ffmpeg-next", + "hound", + "memmap2", + "midir", + "midly", + "nam-ffi", + "pathdiff", + "petgraph 0.6.5", + "rand", + "ratatui", + "rayon", + "rtrb", + "serde", + "serde_json", + "symphonia", + "zip", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.9.4", + "objc2", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + +[[package]] +name = "ffmpeg-next" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d658424d233cbd993a972dd73a66ca733acd12a494c68995c9ac32ae1fe65b40" +dependencies = [ + "bitflags 2.9.4", + "ffmpeg-sys-next", + "libc", +] + +[[package]] +name = "ffmpeg-sys-next" +version = "8.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bca20aa4ee774fe384c2490096c122b0b23cf524a9910add0686691003d797b" +dependencies = [ + "bindgen", + "cc", + "libc", + "num_cpus", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" + +[[package]] +name = "fixedbitset" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37ab347416e802de484e4d03c7316c48f1ecb56574dfd4a46a80f173ce1de04d" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "hound" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + +[[package]] +name = "indexmap" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" +dependencies = [ + "equivalent", + "hashbrown 0.16.0", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "mach2" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "midir" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a456444d83e7ead06ae6a5c0a215ed70282947ff3897fb45fcb052b757284731" +dependencies = [ + "alsa 0.7.1", + "bitflags 1.3.2", + "coremidi", + "js-sys", + "libc", + "wasm-bindgen", + "web-sys", + "windows 0.43.0", +] + +[[package]] +name = "midly" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "207d755f4cb882d20c4da58d707ca9130a0c9bc5061f657a4f299b8e36362b7a" +dependencies = [ + "rayon", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + +[[package]] +name = "nam-ffi" +version = "0.1.0" +dependencies = [ + "cmake", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.9.4", + "jni-sys", + "log", + "ndk-sys", + "num_enum", + "thiserror", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "nix" +version = "0.24.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa52e972a9a719cecb6864fb88568781eb706bac2cd1d4f04a648542dbf78069" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_enum" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a973b4e44ce6cad84ce69d797acf9a044532e4184c4f267913d1b546a0727b7a" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77e878c846a8abae00dd069496dbe8751b16ac1c3d6bd2a7283a938e8228f90d" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-audio-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" +dependencies = [ + "bitflags 2.9.4", + "libc", + "objc2", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-avf-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags 2.9.4", + "objc2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.9.4", + "block2", + "dispatch2", + "libc", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.9.4", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest", + "hmac", + "password-hash", + "sha2", +] + +[[package]] +name = "petgraph" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "467d164a6de56270bd7c4d070df81d07beace25012d5103ced4e9ff08d6afdb7" +dependencies = [ + "fixedbitset 0.2.0", + "indexmap 1.9.3", +] + +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset 0.4.2", + "indexmap 2.11.4", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "ratatui" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f44c9e68fd46eda15c646fbb85e1040b657a58cdc8c98db1d97a55930d991eef" +dependencies = [ + "bitflags 2.9.4", + "cassowary", + "compact_str", + "crossterm", + "itertools 0.12.1", + "lru", + "paste", + "stability", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.9.4", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "rtrb" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8388ea1a9e0ea807e442e8263a699e7edcb320ecbcd21b4fa8ff859acce3ba" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "stability" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d904e7009df136af5297832a3ace3370cd14ff1546a232f4f185036c2736fcac" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-adpcm", + "symphonia-codec-alac", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-caf", + "symphonia-format-isomp4", + "symphonia-format-mkv", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-adpcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-alac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-caf" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8faf379316b6b6e6bbc274d00e7a592e0d63ff1a7e182ce8ba25e24edd3d096" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-format-isomp4" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" +dependencies = [ + "encoding_rs", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-mkv" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "122d786d2c43a49beb6f397551b4a050d8229eaa54c7ddf9ee4b98899b8742d0" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "syn" +version = "2.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "toml_datetime" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.23.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +dependencies = [ + "indexmap 2.11.4", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +dependencies = [ + "winnow", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04662ed0e3e5630dfa9b26e4cb823b817f1a9addda855d973a9458c236556244" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "aes", + "byteorder", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "flate2", + "hmac", + "pbkdf2", + "sha1", + "time", + "zstd", +] + +[[package]] +name = "zstd" +version = "0.11.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "5.0.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" +dependencies = [ + "libc", + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/daw-backend/Cargo.toml b/daw-backend/Cargo.toml new file mode 100644 index 0000000..1878591 --- /dev/null +++ b/daw-backend/Cargo.toml @@ -0,0 +1,53 @@ +[package] +name = "daw-backend" +version = "0.1.0" +edition = "2021" + +[dependencies] +cpal = "0.17" +symphonia = { version = "0.5", features = ["all"] } +rtrb = "0.3" +midly = "0.5" +midir = "0.9" +serde = { version = "1.0", features = ["derive"] } +ratatui = "0.26" +crossterm = "0.27" +rand = "0.8" +base64 = "0.22" +pathdiff = "0.2" +rayon = "1.10" + +# Memory-mapped I/O for audio files +memmap2 = "0.9" + +# Audio export +hound = "3.5" +ffmpeg-next = "8.0" # For MP3/AAC encoding + +# Node-based audio graph dependencies +dasp_graph = "0.11" +dasp_signal = "0.11" +dasp_sample = "0.11" +dasp_interpolate = "0.11" +dasp_envelope = "0.11" +dasp_ring_buffer = "0.11" +dasp_peak = "0.11" +dasp_rms = "0.11" +petgraph = "0.6" +serde_json = "1.0" +zip = "0.6" + +# BeamDSP scripting engine +beamdsp = { path = "../lightningbeam-ui/beamdsp" } + +# Neural Amp Modeler FFI +nam-ffi = { path = "../nam-ffi" } + +[dev-dependencies] + +[profile.release] +opt-level = 3 +lto = true + +[profile.dev] +opt-level = 1 # Faster compile times while still reasonable performance diff --git a/daw-backend/darude-sandstorm.mid b/daw-backend/darude-sandstorm.mid new file mode 100644 index 0000000..f9a99b0 Binary files /dev/null and b/daw-backend/darude-sandstorm.mid differ diff --git a/daw-backend/daw_architecture_doc.md b/daw-backend/daw_architecture_doc.md new file mode 100644 index 0000000..f921f12 --- /dev/null +++ b/daw-backend/daw_architecture_doc.md @@ -0,0 +1,1807 @@ +# DAW Backend Architecture & Implementation Roadmap + +**Version:** 1.0 +**Date:** October 2025 +**Language:** Rust +**Audio I/O:** cpal + +--- + +## Table of Contents + +1. [Architecture Overview](#architecture-overview) +2. [Core Components](#core-components) +3. [Metatracks Architecture](#metatracks-architecture) +4. [Implementation Roadmap](#implementation-roadmap) +5. [Technical Specifications](#technical-specifications) +6. [Testing Strategy](#testing-strategy) + +--- + +## Architecture Overview + +### High-Level Design + +The DAW follows a **multi-threaded, message-passing architecture** that separates real-time audio processing from UI and control logic: + +``` +┌─────────────┐ Commands ┌─────────────┐ Commands ┌─────────────┐ +│ UI Thread │ ←──────────────→ │Control Thread│ ←──────────────→ │ Audio Thread│ +└─────────────┘ Events └─────────────┘ Lock-free └─────────────┘ + │ Queues │ + ↓ │ + ┌─────────────┐ │ + │Project State│←────────────────────────┘ + │(Triple-buf) │ Atomic reads + └─────────────┘ +``` + +### Design Principles + +1. **Real-time Safety**: Audio thread is lock-free and allocation-free +2. **Hierarchical Composition**: Tracks can contain other tracks (metatracks) +3. **Message-Based Communication**: All cross-thread communication via lock-free queues +4. **Incremental Complexity**: Architecture supports simple flat tracks initially, scales to nested metatracks +5. **Separation of Concerns**: Audio processing, state management, and UI are decoupled + +--- + +## Core Components + +### 1. Audio Engine (Real-time Thread) + +**Responsibilities:** +- Process audio graph in response to cpal callbacks +- Execute commands from control thread +- Maintain playback position and transport state +- Send events back to control thread + +**Constraints:** +- No memory allocations +- No blocking operations (mutex, I/O) +- No unbounded loops +- Pre-allocated buffers only + +**Key Structures:** + +```rust +struct AudioEngine { + tracks: Vec, + playhead: u64, // Sample position + playing: bool, + sample_rate: u32, + + // Communication + command_rx: rtrb::Consumer, + event_tx: rtrb::Producer, + + // Pre-allocated resources + mix_buffer: Vec, + buffer_pool: BufferPool, +} + +enum Command { + Play, + Stop, + Seek(f64), + SetTempo(f32), + UpdateTrackVolume(TrackId, f32), + UpdateTrackMute(TrackId, bool), + AddEffect(TrackId, EffectType), + // ... more commands +} + +enum AudioEvent { + PlaybackPosition(f64), + PeakLevel(TrackId, f32), + BufferUnderrun, + // ... more events +} +``` + +### 2. Track Hierarchy + +**Track Node Types:** + +```rust +enum TrackNode { + Audio(AudioTrack), + Midi(MidiTrack), + Metatrack(Metatrack), + Bus(BusTrack), +} + +struct AudioTrack { + id: TrackId, + name: String, + clips: Vec, + effects: Vec>, + volume: f32, + pan: f32, + muted: bool, + solo: bool, + parent: Option, +} + +struct MidiTrack { + id: TrackId, + name: String, + clips: Vec, + instrument: Box, // Virtual instrument + effects: Vec>, + volume: f32, + pan: f32, + muted: bool, + solo: bool, + parent: Option, +} + +struct Metatrack { + id: TrackId, + name: String, + children: Vec, + effects: Vec>, + + // Metatrack-specific features + time_stretch: f32, // Speed multiplier (0.5 = half speed) + pitch_shift: f32, // Semitones + offset: f64, // Time offset in seconds + + volume: f32, + pan: f32, + muted: bool, + solo: bool, + parent: Option, + + // UI hints + collapsed: bool, + color: Color, +} + +struct BusTrack { + id: TrackId, + name: String, + inputs: Vec, // Which tracks send to this bus + effects: Vec>, + volume: f32, + pan: f32, +} +``` + +### 3. Clips and Regions + +```rust +struct Clip { + id: ClipId, + content: ClipContent, + start_time: f64, // Position in parent track (seconds) + duration: f64, // Clip duration (seconds) + offset: f64, // Offset into content (seconds) + + // Clip-level processing + gain: f32, + fade_in: f64, + fade_out: f64, + reversed: bool, +} + +enum ClipContent { + AudioFile { + pool_index: usize, // Index into AudioPool + }, + MidiData { + events: Vec, + }, + MetatrackReference { + track_id: TrackId, + }, +} + +struct MidiEvent { + timestamp: u64, // Sample offset within clip + status: u8, + data1: u8, + data2: u8, +} +``` + +### 4. Audio Pool + +Shared audio file storage: + +```rust +struct AudioPool { + files: Vec, + cache: LruCache>, +} + +struct AudioFile { + id: FileId, + path: PathBuf, + data: Vec, // Interleaved samples + channels: u32, + sample_rate: u32, + frames: u64, +} +``` + +### 5. Effect System + +```rust +trait Effect: Send { + fn process(&mut self, buffer: &mut [f32], channels: usize, sample_rate: u32); + fn set_parameter(&mut self, id: u32, value: f32); + fn get_parameter(&self, id: u32) -> f32; + fn reset(&mut self); +} + +// Example implementations +struct GainEffect { + gain_db: f32, +} + +struct SimpleEQ { + low_gain: f32, + mid_gain: f32, + high_gain: f32, + filters: [BiquadFilter; 3], +} + +struct SimpleSynth { + oscillators: Vec, + adsr: AdsrEnvelope, +} +``` + +### 6. Render Context + +Carries time and tempo information through the track hierarchy: + +```rust +struct RenderContext { + global_position: u64, // Absolute sample position + local_position: u64, // Position within current scope + sample_rate: u32, + tempo: f32, + time_signature: (u32, u32), + time_stretch: f32, // Accumulated stretch factor +} + +impl Metatrack { + fn transform_context(&self, ctx: RenderContext) -> RenderContext { + let offset_samples = (self.offset * ctx.sample_rate as f64) as u64; + let local_pos = ((ctx.local_position.saturating_sub(offset_samples)) as f64 + / self.time_stretch as f64) as u64; + + RenderContext { + global_position: ctx.global_position, + local_position: local_pos, + sample_rate: ctx.sample_rate, + tempo: ctx.tempo * self.time_stretch, + time_signature: ctx.time_signature, + time_stretch: ctx.time_stretch * self.time_stretch, + } + } +} +``` + +### 7. Project State + +```rust +struct Project { + tracks: HashMap, + root_tracks: Vec, + audio_pool: AudioPool, + + // Global settings + sample_rate: u32, + tempo: f32, + time_signature: (u32, u32), + + // Metadata + name: String, + created: SystemTime, + modified: SystemTime, +} + +impl Project { + fn get_processing_order(&self) -> Vec { + // Depth-first traversal for correct rendering order + let mut order = Vec::new(); + for root_id in &self.root_tracks { + self.collect_depth_first(*root_id, &mut order); + } + order + } + + fn collect_depth_first(&self, id: TrackId, order: &mut Vec) { + if let Some(TrackNode::Metatrack(meta)) = self.tracks.get(&id) { + for child_id in &meta.children { + self.collect_depth_first(*child_id, order); + } + } + order.push(id); + } +} +``` + +### 8. Buffer Management + +```rust +struct BufferPool { + buffers: Vec>, + available: Vec, + buffer_size: usize, +} + +impl BufferPool { + fn acquire(&mut self) -> Vec { + if let Some(idx) = self.available.pop() { + let mut buf = std::mem::take(&mut self.buffers[idx]); + buf.fill(0.0); + buf + } else { + vec![0.0; self.buffer_size] + } + } + + fn release(&mut self, buffer: Vec) { + let idx = self.buffers.len(); + self.buffers.push(buffer); + self.available.push(idx); + } +} +``` + +--- + +## Metatracks Architecture + +### Processing Model + +Metatracks use a **pre-mix** model: + +1. Mix all children into a temporary buffer +2. Apply metatrack's effects to the mixed buffer +3. Mix result into parent's output + +```rust +fn process_metatrack( + meta: &Metatrack, + project: &Project, + output: &mut [f32], + context: RenderContext, + buffer_pool: &mut BufferPool, +) { + // Transform context for children + let child_context = meta.transform_context(context); + + // Acquire scratch buffer + let mut submix = buffer_pool.acquire(); + submix.resize(output.len(), 0.0); + + // Process all children into submix + for child_id in &meta.children { + if let Some(child) = project.tracks.get(child_id) { + process_track_node( + child, + project, + &mut submix, + child_context, + buffer_pool + ); + } + } + + // Apply metatrack's effects + for effect in &mut meta.effects { + effect.process(&mut submix, 2, context.sample_rate); + } + + // Mix into output with volume + for (out, sub) in output.iter_mut().zip(submix.iter()) { + *out += sub * meta.volume; + } + + // Return buffer to pool + buffer_pool.release(submix); +} +``` + +### Time Transformation + +Metatracks can manipulate time for all children: + +- **Time Stretch**: Speed up or slow down playback +- **Offset**: Shift content in time +- **Pitch Shift**: Transpose content (future feature, requires pitch-preserving time stretch) + +### Metatrack Operations + +```rust +enum MetatrackOperation { + // Creation + CreateFromSelection(Vec), + CreateEmpty, + + // Hierarchy manipulation + AddToMetatrack(TrackId, Vec), + RemoveFromMetatrack(TrackId, Vec), + MoveToMetatrack { track: TrackId, new_parent: TrackId }, + Ungroup(TrackId), + Flatten(TrackId), + + // Transformation + SetTimeStretch(TrackId, f32), + SetOffset(TrackId, f64), + + // Rendering + BounceToAudio(TrackId), + Freeze(TrackId), + Unfreeze(TrackId), +} +``` + +### Nesting Limits + +- **Recommended maximum depth**: 10 levels +- **Reason**: Performance and UI complexity +- **Implementation**: Check depth during metatrack creation + +--- + +## Implementation Roadmap + +### Phase 1: Single Audio File Playback (Week 1-2) + +**Goal**: Play one audio file through speakers + +**Deliverables:** +- Basic cpal integration +- Load audio file with symphonia +- Simple playback loop +- Press spacebar to play/pause + +**Core Implementation:** + +```rust +struct SimpleEngine { + audio_data: Vec, + playhead: usize, + sample_rate: u32, + playing: Arc, +} + +// Main audio callback +fn audio_callback(data: &mut [f32], engine: &mut SimpleEngine) { + if engine.playing.load(Ordering::Relaxed) { + let end = (engine.playhead + data.len()).min(engine.audio_data.len()); + let available = end - engine.playhead; + + data[..available].copy_from_slice( + &engine.audio_data[engine.playhead..end] + ); + + engine.playhead = end; + } else { + data.fill(0.0); + } +} +``` + +**Dependencies:** +- `cpal = "0.15"` +- `symphonia = "0.5"` + +**Success Criteria:** +- Audio plays without clicks or pops +- Can start/stop playback +- No audio thread panics + +--- + +### Phase 2: Transport Control + UI Communication (Week 2-3) + +**Goal**: Start/stop/seek from a basic UI + +**Deliverables:** +- Lock-free command queue +- Atomic playhead position +- Basic UI (terminal or simple window) +- Play/pause/seek controls + +**Core Implementation:** + +```rust +enum Command { + Play, + Stop, + Seek(f64), +} + +struct Engine { + audio_data: Vec, + playhead: Arc, + command_rx: rtrb::Consumer, + playing: bool, + sample_rate: u32, +} + +fn audio_callback(data: &mut [f32], engine: &mut Engine) { + // Process all pending commands + while let Ok(cmd) = engine.command_rx.pop() { + match cmd { + Command::Play => engine.playing = true, + Command::Stop => { + engine.playing = false; + engine.playhead.store(0, Ordering::Relaxed); + } + Command::Seek(seconds) => { + let samples = (seconds * engine.sample_rate as f64) as u64; + engine.playhead.store(samples, Ordering::Relaxed); + } + } + } + + // Render audio... +} +``` + +**New Dependencies:** +- `rtrb = "0.3"` (lock-free ringbuffer) + +**Success Criteria:** +- Commands execute within 1 buffer period +- No audio glitches during seek +- Playhead position updates smoothly + +--- + +### Phase 3: Multiple Audio Tracks (Week 3-4) + +**Goal**: Play multiple audio files simultaneously + +**Deliverables:** +- Track data structure +- Per-track volume control +- Mute/solo functionality +- Mix multiple tracks + +**Core Implementation:** + +```rust +struct Track { + id: u32, + audio_data: Vec, + volume: f32, + muted: bool, + solo: bool, +} + +struct Engine { + tracks: Vec, + playhead: Arc, + command_rx: rtrb::Consumer, + sample_rate: u32, + mix_buffer: Vec, +} + +enum Command { + Play, + Stop, + Seek(f64), + SetTrackVolume(u32, f32), + SetTrackMute(u32, bool), + SetTrackSolo(u32, bool), +} + +fn audio_callback(data: &mut [f32], engine: &mut Engine) { + // Process commands... + + if engine.playing { + // Clear mix buffer + engine.mix_buffer.fill(0.0); + + // Check if any track is soloed + let any_solo = engine.tracks.iter().any(|t| t.solo); + + // Mix all active tracks + for track in &engine.tracks { + let active = !track.muted && (!any_solo || track.solo); + if active { + mix_track(track, &mut engine.mix_buffer, engine.playhead, data.len()); + } + } + + // Copy mix to output + data.copy_from_slice(&engine.mix_buffer[..data.len()]); + } +} + +fn mix_track(track: &Track, output: &mut [f32], playhead: u64, frames: usize) { + let start = playhead as usize; + let end = (start + frames).min(track.audio_data.len()); + + for (i, sample) in track.audio_data[start..end].iter().enumerate() { + output[i] += sample * track.volume; + } +} +``` + +**Success Criteria:** +- 4+ tracks play simultaneously without distortion +- Volume changes are smooth (no clicks) +- Solo/mute work correctly +- CPU usage remains reasonable + +--- + +### Phase 4: Clips & Timeline (Week 4-5) + +**Goal**: Place audio regions at different positions on timeline + +**Deliverables:** +- Clip data structure +- Timeline-based playback +- Audio pool for shared audio data +- Multiple clips per track + +**Core Implementation:** + +```rust +struct Clip { + id: u32, + audio_pool_index: usize, + start_time: f64, // Seconds + duration: f64, + offset: f64, // Offset into audio file + gain: f32, +} + +struct Track { + id: u32, + clips: Vec, + volume: f32, + muted: bool, + solo: bool, +} + +struct AudioPool { + files: Vec>, +} + +fn render_track( + track: &Track, + output: &mut [f32], + pool: &AudioPool, + playhead_seconds: f64, + sample_rate: u32, + frames: usize, +) { + for clip in &track.clips { + let clip_start = clip.start_time; + let clip_end = clip.start_time + clip.duration; + + // Check if clip is active in this time range + if playhead_seconds < clip_end && + playhead_seconds + (frames as f64 / sample_rate as f64) > clip_start { + + render_clip(clip, output, pool, playhead_seconds, sample_rate, frames); + } + } +} + +fn render_clip( + clip: &Clip, + output: &mut [f32], + pool: &AudioPool, + playhead_seconds: f64, + sample_rate: u32, + frames: usize, +) { + let audio = &pool.files[clip.audio_pool_index]; + + // Calculate position within clip + let clip_position = playhead_seconds - clip.start_time + clip.offset; + let start_sample = (clip_position * sample_rate as f64) as usize; + + // Calculate how many samples to copy + let samples_available = audio.len().saturating_sub(start_sample); + let samples_to_copy = samples_available.min(output.len()); + + // Mix into output + for i in 0..samples_to_copy { + output[i] += audio[start_sample + i] * clip.gain * clip.volume; + } +} +``` + +**Success Criteria:** +- Clips play at correct timeline positions +- Multiple clips per track work correctly +- Clips can overlap +- Audio pool prevents duplication + +**UI Requirements:** +- Basic timeline view +- Drag clips to position them +- Visual representation of waveforms + +--- + +### Phase 5: Effect Processing (Week 5-6) + +**Goal**: Add gain/pan/simple effects to tracks + +**Deliverables:** +- Effect trait +- Basic effects (gain, pan, simple EQ) +- Per-track effect chain +- Effect parameter control + +**Core Implementation:** + +```rust +trait Effect: Send { + fn process(&mut self, buffer: &mut [f32], channels: usize, sample_rate: u32); + fn set_parameter(&mut self, id: u32, value: f32); + fn get_parameter(&self, id: u32) -> f32; + fn reset(&mut self); +} + +struct GainEffect { + gain_linear: f32, +} + +impl Effect for GainEffect { + fn process(&mut self, buffer: &mut [f32], _channels: usize, _sample_rate: u32) { + for sample in buffer.iter_mut() { + *sample *= self.gain_linear; + } + } + + fn set_parameter(&mut self, id: u32, value: f32) { + if id == 0 { // Gain in dB + self.gain_linear = 10.0_f32.powf(value / 20.0); + } + } + + fn get_parameter(&self, id: u32) -> f32 { + if id == 0 { + 20.0 * self.gain_linear.log10() + } else { + 0.0 + } + } + + fn reset(&mut self) {} +} + +struct Track { + id: u32, + clips: Vec, + effects: Vec>, + volume: f32, + muted: bool, +} + +fn render_track( + track: &mut Track, + output: &mut [f32], + // ... other params +) { + // Render all clips... + + // Apply effect chain + for effect in &mut track.effects { + effect.process(output, 2, sample_rate); + } + + // Apply track volume + for sample in output.iter_mut() { + *sample *= track.volume; + } +} +``` + +**Additional Effects to Implement:** + +```rust +struct PanEffect { + pan: f32, // -1.0 (left) to 1.0 (right) +} + +struct SimpleEQ { + low_gain: f32, + mid_gain: f32, + high_gain: f32, + low_filter: BiquadFilter, + high_filter: BiquadFilter, +} + +struct BiquadFilter { + b0: f32, b1: f32, b2: f32, + a1: f32, a2: f32, + x1: f32, x2: f32, + y1: f32, y2: f32, +} +``` + +**Success Criteria:** +- Effects process without distortion +- Multiple effects can chain +- Parameter changes are smooth +- No performance degradation + +**Begin DSP Library:** +- Basic filters (lowpass, highpass, bandpass) +- Utilities (db to linear, frequency to coefficients) + +--- + +### Phase 6: Hierarchical Tracks - Foundation (Week 6-7) + +**Goal**: Introduce track hierarchy (groups) without full metatracks + +**Deliverables:** +- TrackNode enum +- Group tracks +- Recursive rendering +- Parent-child relationships + +**Core Implementation:** + +```rust +enum TrackNode { + Audio(AudioTrack), + Group(GroupTrack), +} + +struct AudioTrack { + id: u32, + clips: Vec, + effects: Vec>, + volume: f32, + parent: Option, +} + +struct GroupTrack { + id: u32, + children: Vec, + effects: Vec>, + volume: f32, +} + +struct Project { + tracks: HashMap, + root_tracks: Vec, + audio_pool: AudioPool, +} + +fn render_track_node( + node_id: u32, + project: &Project, + output: &mut [f32], + context: &RenderContext, + buffer_pool: &mut BufferPool, +) { + match &project.tracks[&node_id] { + TrackNode::Audio(track) => { + render_audio_track(track, output, &project.audio_pool, context); + } + TrackNode::Group(group) => { + // Get temp buffer from pool + let mut group_buffer = buffer_pool.acquire(); + group_buffer.resize(output.len(), 0.0); + + // Render all children + for child_id in &group.children { + render_track_node( + *child_id, + project, + &mut group_buffer, + context, + buffer_pool + ); + } + + // Apply group effects + for effect in &mut group.effects { + effect.process(&mut group_buffer, 2, context.sample_rate); + } + + // Mix into output + for (out, group) in output.iter_mut().zip(group_buffer.iter()) { + *out += group * group.volume; + } + + buffer_pool.release(group_buffer); + } + } +} + +struct RenderContext { + playhead_seconds: f64, + sample_rate: u32, + tempo: f32, +} +``` + +**Success Criteria:** +- Can create groups of tracks +- Groups can nest (test 3-4 levels) +- Effects on groups affect all children +- No audio glitches from recursion + +**Refactoring Required:** +- Migrate from `Vec` to `HashMap` +- Update all track access code +- Add parent tracking + +--- + +### Phase 7: MIDI Support (Week 7-8) + +**Goal**: Play MIDI through virtual instruments + +**Deliverables:** +- MIDI data structures +- MIDI clip rendering +- Simple virtual instrument +- MIDI track type + +**Core Implementation:** + +```rust +struct MidiEvent { + timestamp: u64, // Sample position + status: u8, + data1: u8, // Note/CC number + data2: u8, // Velocity/value +} + +struct MidiClip { + id: u32, + events: Vec, + start_time: f64, + duration: f64, +} + +struct MidiTrack { + id: u32, + clips: Vec, + instrument: Box, // Synth as effect + effects: Vec>, + volume: f32, + parent: Option, +} + +enum TrackNode { + Audio(AudioTrack), + Midi(MidiTrack), + Group(GroupTrack), +} + +// Simple sine wave synth for testing +struct SimpleSynth { + voices: Vec, + sample_rate: f32, +} + +struct SynthVoice { + active: bool, + note: u8, + velocity: u8, + phase: f32, + frequency: f32, +} + +impl Effect for SimpleSynth { + fn process(&mut self, buffer: &mut [f32], channels: usize, sample_rate: u32) { + // Process active voices + for voice in &mut self.voices { + if voice.active { + for frame in buffer.chunks_mut(channels) { + let sample = (voice.phase * 2.0 * PI).sin() + * (voice.velocity as f32 / 127.0) * 0.3; + + for channel in frame.iter_mut() { + *channel += sample; + } + + voice.phase += voice.frequency / sample_rate as f32; + if voice.phase >= 1.0 { + voice.phase -= 1.0; + } + } + } + } + } + + // Handle MIDI events via parameters + fn set_parameter(&mut self, id: u32, value: f32) { + match id { + 0 => self.note_on(value as u8, 100), // Note on + 1 => self.note_off(value as u8), // Note off + _ => {} + } + } +} + +fn render_midi_track( + track: &mut MidiTrack, + output: &mut [f32], + context: &RenderContext, + frames: usize, +) { + // Collect MIDI events for this render period + let mut events_to_process = Vec::new(); + + for clip in &track.clips { + collect_events_in_range( + clip, + context.playhead_seconds, + frames, + context.sample_rate, + &mut events_to_process + ); + } + + // Sort by timestamp + events_to_process.sort_by_key(|e| e.timestamp); + + // Process events through instrument + for event in events_to_process { + handle_midi_event(&mut track.instrument, event); + } + + // Generate audio + track.instrument.process(output, 2, context.sample_rate); + + // Apply effect chain + for effect in &mut track.effects { + effect.process(output, 2, context.sample_rate); + } +} +``` + +**Success Criteria:** +- Can load and play MIDI files +- Notes trigger at correct times +- Polyphony works (4+ notes) +- Timing is sample-accurate + +**Dependencies:** +- `midly = "0.5"` (for MIDI file parsing) + +--- + +### Phase 8: Full Metatracks (Week 8-9) + +**Goal**: Add time transformation and metatrack-specific features + +**Deliverables:** +- Metatrack type with transformations +- Time stretch functionality +- Offset capability +- Transform context propagation + +**Core Implementation:** + +```rust +struct Metatrack { + id: u32, + children: Vec, + effects: Vec>, + + // Transformation parameters + time_stretch: f32, // 0.5 = half speed, 2.0 = double speed + pitch_shift: f32, // Semitones (future feature) + offset: f64, // Time offset in seconds + + volume: f32, + parent: Option, +} + +enum TrackNode { + Audio(AudioTrack), + Midi(MidiTrack), + Metatrack(Metatrack), + Group(GroupTrack), +} + +struct RenderContext { + global_position: u64, // Absolute sample position + local_position: u64, // Position within current scope + sample_rate: u32, + tempo: f32, + time_signature: (u32, u32), + time_stretch: f32, // Accumulated stretch +} + +impl Metatrack { + fn transform_context(&self, ctx: RenderContext) -> RenderContext { + let offset_samples = (self.offset * ctx.sample_rate as f64) as u64; + + let adjusted_position = ctx.local_position.saturating_sub(offset_samples); + let stretched_position = (adjusted_position as f64 / self.time_stretch as f64) as u64; + + RenderContext { + global_position: ctx.global_position, + local_position: stretched_position, + sample_rate: ctx.sample_rate, + tempo: ctx.tempo * self.time_stretch, + time_signature: ctx.time_signature, + time_stretch: ctx.time_stretch * self.time_stretch, + } + } +} + +fn render_metatrack( + meta: &Metatrack, + project: &Project, + output: &mut [f32], + context: RenderContext, + buffer_pool: &mut BufferPool, +) { + // Transform context for children + let child_context = meta.transform_context(context); + + // Acquire buffer for submix + let mut submix = buffer_pool.acquire(); + submix.resize(output.len(), 0.0); + + // Render all children with transformed context + for child_id in &meta.children { + if let Some(child) = project.tracks.get(child_id) { + render_track_node( + *child_id, + project, + &mut submix, + child_context, + buffer_pool + ); + } + } + + // Apply metatrack effects + for effect in &mut meta.effects { + effect.process(&mut submix, 2, context.sample_rate); + } + + // Mix into output + for (out, sub) in output.iter_mut().zip(submix.iter()) { + *out += sub * meta.volume; + } + + buffer_pool.release(submix); +} +``` + +**Metatrack Operations:** + +```rust +impl Project { + fn create_metatrack_from_selection(&mut self, track_ids: Vec) -> u32 { + let metatrack_id = self.next_id(); + + let metatrack = Metatrack { + id: metatrack_id, + children: track_ids.clone(), + effects: Vec::new(), + time_stretch: 1.0, + pitch_shift: 0.0, + offset: 0.0, + volume: 1.0, + parent: None, + }; + + // Update parent references + for track_id in track_ids { + if let Some(track) = self.tracks.get_mut(&track_id) { + set_parent(track, Some(metatrack_id)); + } + } + + self.tracks.insert(metatrack_id, TrackNode::Metatrack(metatrack)); + self.root_tracks.push(metatrack_id); + + metatrack_id + } + + fn ungroup_metatrack(&mut self, metatrack_id: u32) { + if let Some(TrackNode::Metatrack(meta)) = self.tracks.get(&metatrack_id) { + let children = meta.children.clone(); + + // Remove parent from children + for child_id in children { + if let Some(track) = self.tracks.get_mut(&child_id) { + set_parent(track, None); + } + self.root_tracks.push(child_id); + } + + // Remove metatrack + self.tracks.remove(&metatrack_id); + self.root_tracks.retain(|&id| id != metatrack_id); + } + } +} +``` + +**Success Criteria:** +- Can create metatracks from track selection +- Time stretch affects all children +- Offset shifts children in time +- Can nest metatracks 5+ levels deep +- Performance remains acceptable + +--- + +### Phase 9: Polish & Optimization (Week 9-11) + +#### 9a. Buffer Pool Optimization (Week 9) + +**Goal**: Eliminate allocations in audio thread + +```rust +struct BufferPool { + buffers: Vec>, + available: Vec, + buffer_size: usize, + total_allocations: AtomicUsize, +} + +impl BufferPool { + fn new(count: usize, size: usize) -> Self { + let mut buffers = Vec::with_capacity(count); + let mut available = Vec::with_capacity(count); + + for i in 0..count { + buffers.push(vec![0.0; size]); + available.push(i); + } + + BufferPool { + buffers, + available, + buffer_size: size, + total_allocations: AtomicUsize::new(0), + } + } + + fn acquire(&mut self) -> Vec { + if let Some(idx) = self.available.pop() { + let mut buf = std::mem::take(&mut self.buffers[idx]); + buf.fill(0.0); + buf + } else { + self.total_allocations.fetch_add(1, Ordering::Relaxed); + vec![0.0; self.buffer_size] + } + } + + fn release(&mut self, buffer: Vec) { + if buffer.len() == self.buffer_size { + let idx = self.buffers.len(); + self.buffers.push(buffer); + self.available.push(idx); + } + } +} +``` + +**Success Criteria:** +- Zero allocations during steady-state playback +- Pool size auto-adjusts to actual usage +- Metrics show allocation count + +#### 9b. Lock-Free State Updates (Week 10) + +**Goal**: Replace Mutex with triple-buffering for project state + +```rust +struct TripleBuffer { + buffers: [T; 3], + write_idx: AtomicUsize, + read_idx: AtomicUsize, +} + +impl TripleBuffer { + fn new(initial: T) -> Self { + TripleBuffer { + buffers: [initial.clone(), initial.clone(), initial], + write_idx: AtomicUsize::new(0), + read_idx: AtomicUsize::new(0), + } + } + + // Called from control thread + fn write(&mut self, value: T) { + let write_idx = self.write_idx.load(Ordering::Acquire); + let next_idx = (write_idx + 1) % 3; + + self.buffers[next_idx] = value; + self.write_idx.store(next_idx, Ordering::Release); + } + + // Called from audio thread + fn read(&self) -> &T { + let read_idx = self.read_idx.load(Ordering::Acquire); + let write_idx = self.write_idx.load(Ordering::Acquire); + + if read_idx != write_idx { + self.read_idx.store(write_idx, Ordering::Release); + } + + &self.buffers[read_idx] + } +} +``` + +**Success Criteria:** +- No locks in audio thread +- State updates propagate within 1-2 buffers +- No audio glitches during updates + +#### 9c. Disk Streaming (Week 11) + +**Goal**: Stream large audio files that don't fit in RAM + +```rust +struct StreamingFile { + id: FileId, + path: PathBuf, + channels: u32, + sample_rate: u32, + total_frames: u64, + + // Streaming state + buffer_rx: rtrb::Consumer, + current_chunk: Option, + chunk_offset: usize, +} + +struct AudioChunk { + start_frame: u64, + data: Vec, +} + +// Background streaming thread +fn streaming_thread( + file_path: PathBuf, + request_rx: Receiver, + chunk_tx: rtrb::Producer, +) { + let mut file = File::open(file_path).unwrap(); + let mut decoder = /* create decoder */; + + loop { + if let Ok(request) = request_rx.try_recv() { + // Seek to requested position + decoder.seek(request.frame); + } + + // Read chunk + let chunk = decoder.read_frames(CHUNK_SIZE); + + // Send to audio thread + let _ = chunk_tx.push(AudioChunk { + start_frame: current_frame, + data: chunk, + }); + + current_frame += CHUNK_SIZE; + } +} +``` + +**Success Criteria:** +- Can play files larger than RAM +- No dropouts during streaming +- Seek works smoothly +- Multiple streaming files simultaneously + +--- + +### Phase 10: Advanced Features (Week 11+) + +#### Automation + +```rust +struct AutomationLane { + parameter_id: u32, + points: Vec, +} + +struct AutomationPoint { + time: f64, + value: f32, + curve: CurveType, +} + +enum CurveType { + Linear, + Exponential, + SCurve, +} +``` + +#### Plugin Hosting (VST/CLAP) + +```rust +struct PluginHost { + scanner: PluginScanner, + instances: HashMap, +} + +struct PluginInstance { + id: PluginId, + plugin_type: PluginType, + handle: *mut c_void, + parameters: Vec, +} + +enum PluginType { + VST3, + CLAP, +} +``` + +#### Project Save/Load + +```rust +#[derive(Serialize, Deserialize)] +struct ProjectFile { + version: String, + metadata: ProjectMetadata, + tracks: Vec, + audio_files: Vec, + tempo_map: TempoMap, +} +``` + +#### Undo/Redo System + +```rust +trait Command { + fn execute(&mut self, project: &mut Project); + fn undo(&mut self, project: &mut Project); +} + +struct CommandHistory { + commands: Vec>, + position: usize, +} +``` + +--- + +## Technical Specifications + +### Performance Targets + +- **Latency**: < 10ms (varies by buffer size and sample rate) +- **CPU Usage**: < 50% for 32 tracks with effects at 44.1kHz +- **Track Count**: Support 64+ tracks without performance degradation +- **Nesting Depth**: 10 levels of metatrack nesting +- **Plugin Count**: 8+ plugins per track + +### Buffer Sizes + +- **Audio Callback**: 128-512 frames (adjustable) +- **Streaming Chunk**: 8192 frames +- **Ring Buffer**: 8192 samples (UI → Audio commands) + +### Sample Rates + +- **Supported**: 44.1kHz, 48kHz, 88.2kHz, 96kHz +- **Default**: 48kHz +- **Internal Processing**: Always at project sample rate + +### Memory Budget + +- **Audio Pool Cache**: 1GB default, configurable +- **Buffer Pool**: 50 buffers × 4096 samples × 4 bytes = 800KB +- **Per-Track Overhead**: < 1KB + +### Thread Model + +1. **UI Thread**: User interaction, visualization +2. **Control Thread**: Project state management, file I/O +3. **Audio Thread**: Real-time processing (cpal callback) +4. **Streaming Thread(s)**: Disk I/O for large files + +### Data Format + +- **Internal Audio**: 32-bit float, interleaved +- **File Support**: WAV, FLAC, MP3, OGG via symphonia +- **MIDI**: Standard MIDI File Format +- **Project Files**: JSON or MessagePack + +--- + +## Testing Strategy + +### Unit Tests + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_clip_rendering_at_position() { + let clip = Clip { + id: 0, + audio_pool_index: 0, + start_time: 1.0, // Starts at 1 second + duration: 2.0, + offset: 0.0, + gain: 1.0, + }; + + let pool = AudioPool { + files: vec![vec![1.0; 96000]], // 2 seconds at 48kHz + }; + + let mut output = vec![0.0; 4800]; // 0.1 seconds + + render_clip(&clip, &mut output, &pool, 1.5, 48000, 4800); + + assert!(output.iter().any(|&s| s != 0.0)); + } + + #[test] + fn test_metatrack_time_stretch() { + let mut context = RenderContext { + global_position: 48000, + local_position: 48000, + sample_rate: 48000, + tempo: 120.0, + time_signature: (4, 4), + time_stretch: 1.0, + }; + + let metatrack = Metatrack { + id: 0, + children: vec![], + effects: vec![], + time_stretch: 0.5, // Half speed + pitch_shift: 0.0, + offset: 0.0, + volume: 1.0, + parent: None, + }; + + let child_context = metatrack.transform_context(context); + + assert_eq!(child_context.time_stretch, 0.5); + assert_eq!(child_context.tempo, 60.0); + } + + #[test] + fn test_buffer_pool_no_allocations() { + let mut pool = BufferPool::new(10, 1024); + + let buf1 = pool.acquire(); + let buf2 = pool.acquire(); + + pool.release(buf1); + pool.release(buf2); + + let buf3 = pool.acquire(); + let buf4 = pool.acquire(); + + // Should reuse buffers, no new allocations + assert_eq!(pool.total_allocations.load(Ordering::Relaxed), 0); + } +} +``` + +### Integration Tests + +```rust +#[test] +fn test_full_playback_pipeline() { + // Setup + let (cmd_tx, cmd_rx) = rtrb::RingBuffer::new(256); + let (evt_tx, evt_rx) = rtrb::RingBuffer::new(256); + + let mut engine = AudioEngine::new(48000, cmd_rx, evt_tx); + engine.load_audio_file("test.wav"); + + // Start playback + cmd_tx.push(Command::Play).unwrap(); + + // Render some audio + let mut output = vec![0.0; 4800]; + engine.process(&mut output); + + // Verify audio was rendered + assert!(output.iter().any(|&s| s.abs() > 0.001)); + + // Check events + if let Ok(AudioEvent::PlaybackPosition(pos)) = evt_rx.pop() { + assert!(pos > 0.0); + } +} + +#[test] +fn test_nested_metatrack_rendering() { + let mut project = Project::new(48000); + + // Create structure: Metatrack1 -> Metatrack2 -> AudioTrack + let audio_id = project.add_audio_track(); + let meta2_id = project.create_metatrack_from_selection(vec![audio_id]); + let meta1_id = project.create_metatrack_from_selection(vec![meta2_id]); + + // Apply transformations + project.set_metatrack_time_stretch(meta1_id, 0.5); + project.set_metatrack_time_stretch(meta2_id, 2.0); + + // Render + let mut output = vec![0.0; 4800]; + project.render(&mut output, 0.0); + + // Effective stretch should be 0.5 * 2.0 = 1.0 (normal speed) + assert!(output.iter().any(|&s| s != 0.0)); +} +``` + +### Performance Tests + +```rust +#[bench] +fn bench_render_32_tracks(b: &mut Bencher) { + let mut engine = create_engine_with_tracks(32); + let mut output = vec![0.0; 512 * 2]; + + b.iter(|| { + engine.process(&mut output); + }); +} + +#[bench] +fn bench_metatrack_nesting_10_levels(b: &mut Bencher) { + let mut project = create_nested_metatracks(10); + let mut output = vec![0.0; 512 * 2]; + + b.iter(|| { + project.render(&mut output, 0.0); + }); +} +``` + +### Audio Quality Tests + +- **THD+N**: Total Harmonic Distortion + Noise < 0.01% +- **Frequency Response**: Flat ±0.1dB 20Hz-20kHz +- **Click Detection**: No clicks during parameter changes +- **Timing Accuracy**: MIDI events within ±1 sample + +### Stress Tests + +- **Long Sessions**: 8+ hours continuous playback +- **Many Tracks**: 128 tracks, 8 effects each +- **Deep Nesting**: 20 levels of metatrack nesting +- **Rapid Commands**: 1000 commands/second +- **Large Files**: 1GB+ audio files streaming + +--- + +## Recommended Crates + +### Core Audio +- `cpal = "0.15"` - Audio I/O +- `symphonia = "0.5"` - Audio decoding +- `rubato = "0.14"` - Sample rate conversion + +### Concurrency +- `rtrb = "0.3"` - Lock-free ring buffers +- `crossbeam = "0.8"` - Additional concurrency tools +- `parking_lot = "0.12"` - Better mutexes (non-realtime) + +### DSP +- `realfft = "3.3"` - FFT for spectral processing +- `biquad = "0.4"` - IIR filters + +### Serialization +- `serde = { version = "1.0", features = ["derive"] }` +- `serde_json = "1.0"` or `rmp-serde = "1.1"` (MessagePack) + +### File I/O +- `midly = "0.5"` - MIDI file parsing +- `hound = "3.5"` - WAV file writing + +### Future +- `vst3-sys` or `clack` - Plugin hosting +- `egui = "0.24"` - Immediate mode GUI (if building UI in Rust) + +--- + +## Project Structure + +``` +daw-backend/ +├── Cargo.toml +├── src/ +│ ├── main.rs +│ ├── lib.rs +│ │ +│ ├── audio/ +│ │ ├── mod.rs +│ │ ├── engine.rs # Audio engine, main processing loop +│ │ ├── track.rs # Track types (Audio, MIDI, Metatrack) +│ │ ├── clip.rs # Clip management +│ │ ├── pool.rs # Audio pool +│ │ ├── buffer_pool.rs # Buffer allocation pool +│ │ └── render.rs # Rendering functions +│ │ +│ ├── project/ +│ │ ├── mod.rs +│ │ ├── project.rs # Project state +│ │ ├── hierarchy.rs # Track hierarchy management +│ │ ├── operations.rs # Project operations (add track, etc.) +│ │ └── serialization.rs # Save/load +│ │ +│ ├── effects/ +│ │ ├── mod.rs +│ │ ├── trait.rs # Effect trait +│ │ ├── gain.rs +│ │ ├── pan.rs +│ │ ├── eq.rs +│ │ └── synth.rs # Simple synth for MIDI +│ │ +│ ├── dsp/ +│ │ ├── mod.rs +│ │ ├── filters.rs # Biquad, etc. +│ │ ├── envelope.rs # ADSR +│ │ ├── oscillator.rs +│ │ └── utils.rs # DB conversion, etc. +│ │ +│ ├── io/ +│ │ ├── mod.rs +│ │ ├── audio_file.rs # Audio file loading +│ │ ├── midi_file.rs # MIDI file loading +│ │ └── streaming.rs # Disk streaming +│ │ +│ ├── command/ +│ │ ├── mod.rs +│ │ ├── types.rs # Command/Event enums +│ │ └── queue.rs # Command queue management +│ │ +│ └── ui/ +│ ├── mod.rs +│ └── bridge.rs # UI-Audio communication +│ +├── tests/ +│ ├── integration_tests.rs +│ └── audio_quality_tests.rs +│ +└── benches/ + └── performance.rs +``` + +--- + +## Conclusion + +This architecture provides: + +1. **Incremental Development**: Each phase builds on the last without requiring rewrites +2. **Real-time Safety**: Lock-free, allocation-free audio thread from the start +3. **Flexibility**: Hierarchical tracks support simple projects and complex arrangements +4. **Scalability**: Architecture handles 64+ tracks with deep nesting +5. **Extensibility**: Effect trait makes plugin hosting straightforward + +The roadmap gets you from "hello audio" to a full-featured DAW in 11 weeks, with each phase delivering working, testable functionality. + +**Next Steps:** +1. Set up Rust project with cpal +2. Implement Phase 1 (single file playback) +3. Add comprehensive tests +4. Profile and optimize as needed +5. Continue through phases sequentially + +Good luck building your DAW! \ No newline at end of file diff --git a/daw-backend/examples/midi_debug.rs b/daw-backend/examples/midi_debug.rs new file mode 100644 index 0000000..208a5d5 --- /dev/null +++ b/daw-backend/examples/midi_debug.rs @@ -0,0 +1,72 @@ +use daw_backend::load_midi_file; + +fn main() { + let clip = load_midi_file("darude-sandstorm.mid", 0, 44100).unwrap(); + + println!("Clip duration: {:.2}s", clip.duration); + println!("Total events: {}", clip.events.len()); + println!("\nEvent summary:"); + + let mut note_on_count = 0; + let mut note_off_count = 0; + let mut other_count = 0; + + for event in &clip.events { + if event.is_note_on() { + note_on_count += 1; + } else if event.is_note_off() { + note_off_count += 1; + } else { + other_count += 1; + } + } + + println!(" Note On events: {}", note_on_count); + println!(" Note Off events: {}", note_off_count); + println!(" Other events: {}", other_count); + + // Show events around 28 seconds + println!("\nEvents around 28 seconds (27-29s):"); + let sample_rate = 44100.0; + let start_sample = (27.0 * sample_rate) as u64; + let end_sample = (29.0 * sample_rate) as u64; + + for (i, event) in clip.events.iter().enumerate() { + if event.timestamp >= start_sample && event.timestamp <= end_sample { + let time_sec = event.timestamp as f64 / sample_rate; + let event_type = if event.is_note_on() { + "NoteOn" + } else if event.is_note_off() { + "NoteOff" + } else { + "Other" + }; + println!(" [{:4}] {:.3}s: {} ch={} note={} vel={}", + i, time_sec, event_type, event.channel(), event.data1, event.data2); + } + } + + // Check for stuck notes - note ons without corresponding note offs + println!("\nChecking for unmatched notes..."); + let mut active_notes = std::collections::HashMap::new(); + + for (i, event) in clip.events.iter().enumerate() { + if event.is_note_on() { + let key = (event.channel(), event.data1); + active_notes.insert(key, i); + } else if event.is_note_off() { + let key = (event.channel(), event.data1); + active_notes.remove(&key); + } + } + + if !active_notes.is_empty() { + println!("Found {} notes that never got note-off events:", active_notes.len()); + for ((ch, note), event_idx) in active_notes.iter().take(10) { + let time_sec = clip.events[*event_idx].timestamp as f64 / sample_rate; + println!(" Note {} on channel {} at {:.2}s (event #{})", note, ch, time_sec, event_idx); + } + } else { + println!("All notes have matching note-off events!"); + } +} diff --git a/daw-backend/examples/midi_end_debug.rs b/daw-backend/examples/midi_end_debug.rs new file mode 100644 index 0000000..2d072f9 --- /dev/null +++ b/daw-backend/examples/midi_end_debug.rs @@ -0,0 +1,74 @@ +use daw_backend::load_midi_file; + +fn main() { + let clip = load_midi_file("darude-sandstorm.mid", 0, 44100).unwrap(); + + println!("Clip duration: {:.3}s", clip.duration); + println!("Total events: {}", clip.events.len()); + + // Show the last 30 events + println!("\nLast 30 events:"); + let sample_rate = 44100.0; + let start_idx = clip.events.len().saturating_sub(30); + + for (i, event) in clip.events.iter().enumerate().skip(start_idx) { + let time_sec = event.timestamp as f64 / sample_rate; + let event_type = if event.is_note_on() { + "NoteOn " + } else if event.is_note_off() { + "NoteOff" + } else { + "Other " + }; + println!(" [{:4}] {:.3}s: {} ch={} note={:3} vel={:3}", + i, time_sec, event_type, event.channel(), event.data1, event.data2); + } + + // Find notes that are still active at the end of the clip + println!("\nNotes active at end of clip ({:.3}s):", clip.duration); + let mut active_notes = std::collections::HashMap::new(); + + for event in &clip.events { + let time_sec = event.timestamp as f64 / sample_rate; + + if event.is_note_on() { + let key = (event.channel(), event.data1); + active_notes.insert(key, time_sec); + } else if event.is_note_off() { + let key = (event.channel(), event.data1); + active_notes.remove(&key); + } + } + + if !active_notes.is_empty() { + println!("Found {} notes still active after all events:", active_notes.len()); + for ((ch, note), start_time) in &active_notes { + println!(" Channel {} Note {} started at {:.3}s (no note-off before clip end)", + ch, note, start_time); + } + } else { + println!("All notes are turned off by the end!"); + } + + // Check maximum polyphony + println!("\nAnalyzing polyphony..."); + let mut max_polyphony = 0; + let mut current_notes = std::collections::HashSet::new(); + + for event in &clip.events { + if event.is_note_on() { + let key = (event.channel(), event.data1); + current_notes.insert(key); + max_polyphony = max_polyphony.max(current_notes.len()); + } else if event.is_note_off() { + let key = (event.channel(), event.data1); + current_notes.remove(&key); + } + } + + println!("Maximum simultaneous notes: {}", max_polyphony); + println!("Available synth voices: 16"); + if max_polyphony > 16 { + println!("WARNING: Polyphony exceeds available voices! Voice stealing will occur."); + } +} diff --git a/daw-backend/src/audio/automation.rs b/daw-backend/src/audio/automation.rs new file mode 100644 index 0000000..e592905 --- /dev/null +++ b/daw-backend/src/audio/automation.rs @@ -0,0 +1,272 @@ +/// Automation system for parameter modulation over time +use serde::{Deserialize, Serialize}; +use crate::time::Beats; + +/// Unique identifier for automation lanes +pub type AutomationLaneId = u32; + +/// Unique identifier for parameters that can be automated +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ParameterId { + /// Track volume + TrackVolume, + /// Track pan + TrackPan, + /// Effect parameter (effect_index, param_id) + EffectParameter(usize, u32), + /// Metatrack time stretch + TimeStretch, + /// Metatrack offset + TimeOffset, +} + +/// Type of interpolation curve between automation points +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub enum CurveType { + /// Linear interpolation (straight line) + Linear, + /// Exponential curve (smooth acceleration) + Exponential, + /// S-curve (ease in/out) + SCurve, + /// Step (no interpolation, jump to next value) + Step, +} + +/// A single automation point +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct AutomationPoint { + pub time: Beats, + pub value: f32, + pub curve: CurveType, +} + +impl AutomationPoint { + pub fn new(time: Beats, value: f32, curve: CurveType) -> Self { + Self { time, value, curve } + } +} + +/// An automation lane for a specific parameter +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AutomationLane { + /// Unique identifier for this lane + pub id: AutomationLaneId, + /// Which parameter this lane controls + pub parameter_id: ParameterId, + /// Sorted list of automation points + points: Vec, + /// Whether this lane is enabled + pub enabled: bool, +} + +impl AutomationLane { + /// Create a new automation lane + pub fn new(id: AutomationLaneId, parameter_id: ParameterId) -> Self { + Self { + id, + parameter_id, + points: Vec::new(), + enabled: true, + } + } + + /// Add an automation point, maintaining sorted order + pub fn add_point(&mut self, point: AutomationPoint) { + // Find insertion position to maintain sorted order + let pos = self.points.binary_search_by(|p| { + p.time.partial_cmp(&point.time).unwrap_or(std::cmp::Ordering::Equal) + }); + + match pos { + Ok(idx) => { + // Replace existing point at same time + self.points[idx] = point; + } + Err(idx) => { + // Insert at correct position + self.points.insert(idx, point); + } + } + } + + pub fn remove_point_at_time(&mut self, time: Beats, tolerance: Beats) -> bool { + if let Some(idx) = self.points.iter().position(|p| (p.time - time).abs() < tolerance) { + self.points.remove(idx); + true + } else { + false + } + } + + /// Remove all points + pub fn clear(&mut self) { + self.points.clear(); + } + + /// Get all points + pub fn points(&self) -> &[AutomationPoint] { + &self.points + } + + pub fn evaluate(&self, time: Beats) -> Option { + if !self.enabled || self.points.is_empty() { + return None; + } + + // Before first point + if time <= self.points[0].time { + return Some(self.points[0].value); + } + + // After last point + if time >= self.points[self.points.len() - 1].time { + return Some(self.points[self.points.len() - 1].value); + } + + // Find surrounding points + for i in 0..self.points.len() - 1 { + let p1 = &self.points[i]; + let p2 = &self.points[i + 1]; + + if time >= p1.time && time <= p2.time { + return Some(interpolate(p1, p2, time)); + } + } + + None + } + + /// Get number of points + pub fn point_count(&self) -> usize { + self.points.len() + } +} + +fn interpolate(p1: &AutomationPoint, p2: &AutomationPoint, time: Beats) -> f32 { + let t = if p2.time == p1.time { + 0.0f64 + } else { + (time - p1.time) / (p2.time - p1.time) + } as f32; + + // Apply curve + let curved_t = match p1.curve { + CurveType::Linear => t, + CurveType::Exponential => { + // Exponential curve: y = x^2 + t * t + } + CurveType::SCurve => { + // Smooth S-curve using smoothstep + smoothstep(t) + } + CurveType::Step => { + // Step: hold value until next point + return p1.value; + } + }; + + // Linear interpolation with curved t + p1.value + (p2.value - p1.value) * curved_t +} + +/// Smoothstep function for S-curve interpolation +/// Returns a smooth curve from 0 to 1 +#[inline] +fn smoothstep(t: f32) -> f32 { + // Clamp to [0, 1] + let t = t.clamp(0.0, 1.0); + // 3t^2 - 2t^3 + t * t * (3.0 - 2.0 * t) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_add_points_sorted() { + let mut lane = AutomationLane::new(0, ParameterId::TrackVolume); + + lane.add_point(AutomationPoint::new(2.0, 0.5, CurveType::Linear)); + lane.add_point(AutomationPoint::new(1.0, 0.3, CurveType::Linear)); + lane.add_point(AutomationPoint::new(3.0, 0.8, CurveType::Linear)); + + assert_eq!(lane.points().len(), 3); + assert_eq!(lane.points()[0].time, 1.0); + assert_eq!(lane.points()[1].time, 2.0); + assert_eq!(lane.points()[2].time, 3.0); + } + + #[test] + fn test_replace_point_at_same_time() { + let mut lane = AutomationLane::new(0, ParameterId::TrackVolume); + + lane.add_point(AutomationPoint::new(1.0, 0.3, CurveType::Linear)); + lane.add_point(AutomationPoint::new(1.0, 0.5, CurveType::Linear)); + + assert_eq!(lane.points().len(), 1); + assert_eq!(lane.points()[0].value, 0.5); + } + + #[test] + fn test_linear_interpolation() { + let mut lane = AutomationLane::new(0, ParameterId::TrackVolume); + + lane.add_point(AutomationPoint::new(0.0, 0.0, CurveType::Linear)); + lane.add_point(AutomationPoint::new(1.0, 1.0, CurveType::Linear)); + + assert_eq!(lane.evaluate(0.0), Some(0.0)); + assert_eq!(lane.evaluate(0.5), Some(0.5)); + assert_eq!(lane.evaluate(1.0), Some(1.0)); + } + + #[test] + fn test_step_interpolation() { + let mut lane = AutomationLane::new(0, ParameterId::TrackVolume); + + lane.add_point(AutomationPoint::new(0.0, 0.5, CurveType::Step)); + lane.add_point(AutomationPoint::new(1.0, 1.0, CurveType::Step)); + + assert_eq!(lane.evaluate(0.0), Some(0.5)); + assert_eq!(lane.evaluate(0.5), Some(0.5)); + assert_eq!(lane.evaluate(0.99), Some(0.5)); + assert_eq!(lane.evaluate(1.0), Some(1.0)); + } + + #[test] + fn test_evaluate_outside_range() { + let mut lane = AutomationLane::new(0, ParameterId::TrackVolume); + + lane.add_point(AutomationPoint::new(1.0, 0.5, CurveType::Linear)); + lane.add_point(AutomationPoint::new(2.0, 1.0, CurveType::Linear)); + + // Before first point + assert_eq!(lane.evaluate(0.0), Some(0.5)); + // After last point + assert_eq!(lane.evaluate(3.0), Some(1.0)); + } + + #[test] + fn test_disabled_lane() { + let mut lane = AutomationLane::new(0, ParameterId::TrackVolume); + + lane.add_point(AutomationPoint::new(0.0, 0.5, CurveType::Linear)); + lane.enabled = false; + + assert_eq!(lane.evaluate(0.0), None); + } + + #[test] + fn test_remove_point() { + let mut lane = AutomationLane::new(0, ParameterId::TrackVolume); + + lane.add_point(AutomationPoint::new(1.0, 0.5, CurveType::Linear)); + lane.add_point(AutomationPoint::new(2.0, 0.8, CurveType::Linear)); + + assert!(lane.remove_point_at_time(1.0, 0.001)); + assert_eq!(lane.points().len(), 1); + assert_eq!(lane.points()[0].time, 2.0); + } +} diff --git a/daw-backend/src/audio/bpm_detector.rs b/daw-backend/src/audio/bpm_detector.rs new file mode 100644 index 0000000..7c68b19 --- /dev/null +++ b/daw-backend/src/audio/bpm_detector.rs @@ -0,0 +1,310 @@ +/// BPM Detection using autocorrelation and onset detection +/// +/// This module provides both offline analysis (for audio import) +/// and real-time streaming analysis (for the BPM detector node) + +use std::collections::VecDeque; + +/// Detects BPM from a complete audio buffer (offline analysis) +pub fn detect_bpm_offline(audio: &[f32], sample_rate: u32) -> Option { + if audio.is_empty() { + return None; + } + + // Convert to mono if needed (already mono in our case) + // Downsample for efficiency (analyze every 4th sample for faster processing) + let downsampled: Vec = audio.iter().step_by(4).copied().collect(); + let effective_sample_rate = sample_rate / 4; + + // Detect onsets using energy-based method + let onsets = detect_onsets(&downsampled, effective_sample_rate); + + if onsets.len() < 4 { + return None; + } + + // Calculate onset strength function for autocorrelation + let onset_envelope = calculate_onset_envelope(&onsets, downsampled.len(), effective_sample_rate); + + // Further downsample onset envelope for BPM analysis + // For 60-200 BPM (1-3.33 Hz), we only need ~10 Hz sample rate by Nyquist + // Use 100 Hz for good margin (100 samples per second) + let tempo_sample_rate = 100.0; + let downsample_factor = (effective_sample_rate as f32 / tempo_sample_rate) as usize; + let downsampled_envelope: Vec = onset_envelope + .iter() + .step_by(downsample_factor.max(1)) + .copied() + .collect(); + + // Use autocorrelation to find the fundamental period + let bpm = detect_bpm_autocorrelation(&downsampled_envelope, tempo_sample_rate as u32); + + bpm +} + +/// Calculate an onset envelope from detected onsets +fn calculate_onset_envelope(onsets: &[usize], total_length: usize, sample_rate: u32) -> Vec { + // Create a sparse representation of onsets with exponential decay + let mut envelope = vec![0.0; total_length]; + let decay_samples = (sample_rate as f32 * 0.05) as usize; // 50ms decay + + for &onset in onsets { + if onset < total_length { + envelope[onset] = 1.0; + // Add exponential decay after onset + for i in 1..decay_samples.min(total_length - onset) { + let decay_value = (-3.0 * i as f32 / decay_samples as f32).exp(); + envelope[onset + i] = f32::max(envelope[onset + i], decay_value); + } + } + } + + envelope +} + +/// Detect BPM using autocorrelation on onset envelope +fn detect_bpm_autocorrelation(onset_envelope: &[f32], sample_rate: u32) -> Option { + // BPM range: 60-200 BPM + let min_bpm = 60.0; + let max_bpm = 200.0; + + let min_lag = (60.0 * sample_rate as f32 / max_bpm) as usize; + let max_lag = (60.0 * sample_rate as f32 / min_bpm) as usize; + + if max_lag >= onset_envelope.len() / 2 { + return None; + } + + // Calculate autocorrelation for tempo range + let mut best_lag = min_lag; + let mut best_correlation = 0.0; + + for lag in min_lag..=max_lag { + let mut correlation = 0.0; + let mut count = 0; + + for i in 0..(onset_envelope.len() - lag) { + correlation += onset_envelope[i] * onset_envelope[i + lag]; + count += 1; + } + + if count > 0 { + correlation /= count as f32; + + // Bias toward faster tempos slightly (common in EDM) + let bias = 1.0 + (lag as f32 - min_lag as f32) / (max_lag - min_lag) as f32 * 0.1; + correlation /= bias; + + if correlation > best_correlation { + best_correlation = correlation; + best_lag = lag; + } + } + } + + // Convert best lag to BPM + let bpm = 60.0 * sample_rate as f32 / best_lag as f32; + + // Check for octave errors by testing multiples + // Common ranges: 60-90 (slow), 90-140 (medium), 140-200 (fast) + let half_bpm = bpm / 2.0; + let double_bpm = bpm * 2.0; + let quad_bpm = bpm * 4.0; + + // Choose the octave that falls in the most common range (100-180 BPM for EDM/pop) + let final_bpm = if quad_bpm >= 100.0 && quad_bpm <= 200.0 { + // Very slow detection, multiply by 4 + quad_bpm + } else if double_bpm >= 100.0 && double_bpm <= 200.0 { + // Slow detection, multiply by 2 + double_bpm + } else if bpm >= 100.0 && bpm <= 200.0 { + // Already in good range + bpm + } else if half_bpm >= 100.0 && half_bpm <= 200.0 { + // Too fast detection, divide by 2 + half_bpm + } else { + // Outside ideal range, use as-is + bpm + }; + + // Round to nearest 0.5 BPM for cleaner values + Some((final_bpm * 2.0).round() / 2.0) +} + +/// Detect onsets (beat events) in audio using energy-based method +fn detect_onsets(audio: &[f32], sample_rate: u32) -> Vec { + let mut onsets = Vec::new(); + + // Window size for energy calculation (~20ms) + let window_size = ((sample_rate as f32 * 0.02) as usize).max(1); + let hop_size = window_size / 2; + + if audio.len() < window_size { + return onsets; + } + + // Calculate energy for each window + let mut energies = Vec::new(); + let mut pos = 0; + while pos + window_size <= audio.len() { + let window = &audio[pos..pos + window_size]; + let energy: f32 = window.iter().map(|&s| s * s).sum(); + energies.push(energy / window_size as f32); // Normalize + pos += hop_size; + } + + if energies.len() < 3 { + return onsets; + } + + // Calculate energy differences (onset strength) + let mut onset_strengths = Vec::new(); + for i in 1..energies.len() { + let diff = (energies[i] - energies[i - 1]).max(0.0); // Only positive changes + onset_strengths.push(diff); + } + + // Find threshold (adaptive) + let mean_strength: f32 = onset_strengths.iter().sum::() / onset_strengths.len() as f32; + let threshold = mean_strength * 1.5; // 1.5x mean + + // Peak picking with minimum distance + let min_distance = sample_rate as usize / 10; // Minimum 100ms between onsets + let mut last_onset = 0; + + for (i, &strength) in onset_strengths.iter().enumerate() { + if strength > threshold { + let sample_pos = (i + 1) * hop_size; + + // Check if it's a local maximum and far enough from last onset + let is_local_max = (i == 0 || onset_strengths[i - 1] <= strength) && + (i == onset_strengths.len() - 1 || onset_strengths[i + 1] < strength); + + if is_local_max && (onsets.is_empty() || sample_pos - last_onset >= min_distance) { + onsets.push(sample_pos); + last_onset = sample_pos; + } + } + } + + onsets +} + +/// Real-time BPM detector for streaming audio +pub struct BpmDetectorRealtime { + sample_rate: u32, + + // Circular buffer for recent audio (e.g., 10 seconds) + audio_buffer: VecDeque, + max_buffer_samples: usize, + + // Current BPM estimate + current_bpm: f32, + + // Update interval (samples) + samples_since_update: usize, + update_interval: usize, + + // Smoothing + bpm_history: VecDeque, + history_size: usize, +} + +impl BpmDetectorRealtime { + pub fn new(sample_rate: u32, buffer_duration_seconds: f32) -> Self { + let max_buffer_samples = (sample_rate as f32 * buffer_duration_seconds) as usize; + let update_interval = sample_rate as usize; // Update every 1 second + + Self { + sample_rate, + audio_buffer: VecDeque::with_capacity(max_buffer_samples), + max_buffer_samples, + current_bpm: 120.0, // Default BPM + samples_since_update: 0, + update_interval, + bpm_history: VecDeque::with_capacity(8), + history_size: 8, + } + } + + /// Process a chunk of audio and return current BPM estimate + pub fn process(&mut self, audio: &[f32]) -> f32 { + // Add samples to buffer + for &sample in audio { + if self.audio_buffer.len() >= self.max_buffer_samples { + self.audio_buffer.pop_front(); + } + self.audio_buffer.push_back(sample); + } + + self.samples_since_update += audio.len(); + + // Periodically re-analyze + if self.samples_since_update >= self.update_interval && self.audio_buffer.len() > self.sample_rate as usize { + self.samples_since_update = 0; + + // Convert buffer to slice for analysis + let buffer_vec: Vec = self.audio_buffer.iter().copied().collect(); + + if let Some(detected_bpm) = detect_bpm_offline(&buffer_vec, self.sample_rate) { + // Add to history for smoothing + if self.bpm_history.len() >= self.history_size { + self.bpm_history.pop_front(); + } + self.bpm_history.push_back(detected_bpm); + + // Use median of recent detections for stability + let mut sorted_history: Vec = self.bpm_history.iter().copied().collect(); + sorted_history.sort_by(|a, b| a.partial_cmp(b).unwrap()); + self.current_bpm = sorted_history[sorted_history.len() / 2]; + } + } + + self.current_bpm + } + + pub fn get_bpm(&self) -> f32 { + self.current_bpm + } + + pub fn reset(&mut self) { + self.audio_buffer.clear(); + self.bpm_history.clear(); + self.samples_since_update = 0; + self.current_bpm = 120.0; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_120_bpm_detection() { + let sample_rate = 48000; + let bpm = 120.0; + let beat_interval = 60.0 / bpm; + let beat_samples = (sample_rate as f32 * beat_interval) as usize; + + // Generate 8 beats + let mut audio = vec![0.0; beat_samples * 8]; + for beat in 0..8 { + let pos = beat * beat_samples; + // Add a sharp transient at each beat + for i in 0..100 { + audio[pos + i] = (1.0 - i as f32 / 100.0) * 0.8; + } + } + + let detected = detect_bpm_offline(&audio, sample_rate); + assert!(detected.is_some()); + let detected_bpm = detected.unwrap(); + + // Allow 5% tolerance + assert!((detected_bpm - bpm).abs() / bpm < 0.05, + "Expected ~{} BPM, got {}", bpm, detected_bpm); + } +} diff --git a/daw-backend/src/audio/buffer_pool.rs b/daw-backend/src/audio/buffer_pool.rs new file mode 100644 index 0000000..cd1f7e3 --- /dev/null +++ b/daw-backend/src/audio/buffer_pool.rs @@ -0,0 +1,149 @@ +use std::sync::atomic::{AtomicUsize, Ordering}; + +/// Pool of reusable audio buffers for recursive group rendering +/// +/// This pool allows groups to acquire temporary buffers for submixing +/// child tracks without allocating memory in the audio thread. +pub struct BufferPool { + buffers: Vec>, + available: Vec, + buffer_size: usize, + /// Tracks the number of times a buffer had to be allocated (not reused) + /// This should be zero during steady-state playback + total_allocations: AtomicUsize, + /// Peak number of buffers simultaneously in use + peak_usage: AtomicUsize, +} + +impl BufferPool { + /// Create a new buffer pool + /// + /// # Arguments + /// * `initial_capacity` - Number of buffers to pre-allocate + /// * `buffer_size` - Size of each buffer in samples + pub fn new(initial_capacity: usize, buffer_size: usize) -> Self { + let mut buffers = Vec::with_capacity(initial_capacity); + let mut available = Vec::with_capacity(initial_capacity); + + // Pre-allocate buffers + for i in 0..initial_capacity { + buffers.push(vec![0.0; buffer_size]); + available.push(i); + } + + Self { + buffers, + available, + buffer_size, + total_allocations: AtomicUsize::new(0), + peak_usage: AtomicUsize::new(0), + } + } + + /// Acquire a buffer from the pool + /// + /// Returns a zeroed buffer ready for use. If no buffers are available, + /// allocates a new one (though this should be avoided in the audio thread). + pub fn acquire(&mut self) -> Vec { + // Track peak usage + let current_in_use = self.buffers.len() - self.available.len(); + let peak = self.peak_usage.load(Ordering::Relaxed); + if current_in_use > peak { + self.peak_usage.store(current_in_use, Ordering::Relaxed); + } + + if let Some(idx) = self.available.pop() { + // Reuse an existing buffer + let mut buf = std::mem::take(&mut self.buffers[idx]); + buf.fill(0.0); + buf + } else { + // No buffers available, allocate a new one + // This should be rare if the pool is sized correctly + self.total_allocations.fetch_add(1, Ordering::Relaxed); + vec![0.0; self.buffer_size] + } + } + + /// Release a buffer back to the pool + /// + /// # Arguments + /// * `buffer` - The buffer to return to the pool + pub fn release(&mut self, buffer: Vec) { + // Only add to pool if it's the correct size + if buffer.len() == self.buffer_size { + let idx = self.buffers.len(); + self.buffers.push(buffer); + self.available.push(idx); + } + // Otherwise, drop the buffer (wrong size, shouldn't happen normally) + } + + /// Get the configured buffer size + pub fn buffer_size(&self) -> usize { + self.buffer_size + } + + /// Get the number of available buffers + pub fn available_count(&self) -> usize { + self.available.len() + } + + /// Get the total number of buffers in the pool + pub fn total_count(&self) -> usize { + self.buffers.len() + } + + /// Get the total number of allocations that occurred (excluding pre-allocated buffers) + /// + /// This should be zero during steady-state playback. If non-zero, the pool + /// should be resized to avoid allocations in the audio thread. + pub fn allocation_count(&self) -> usize { + self.total_allocations.load(Ordering::Relaxed) + } + + /// Get the peak number of buffers simultaneously in use + /// + /// Use this to determine the optimal initial_capacity for your workload. + pub fn peak_usage(&self) -> usize { + self.peak_usage.load(Ordering::Relaxed) + } + + /// Reset allocation statistics + /// + /// Useful for benchmarking steady-state performance after warmup. + pub fn reset_stats(&mut self) { + self.total_allocations.store(0, Ordering::Relaxed); + self.peak_usage.store(0, Ordering::Relaxed); + } + + /// Get comprehensive pool statistics + pub fn stats(&self) -> BufferPoolStats { + BufferPoolStats { + total_buffers: self.total_count(), + available_buffers: self.available_count(), + in_use_buffers: self.total_count() - self.available_count(), + peak_usage: self.peak_usage(), + total_allocations: self.allocation_count(), + buffer_size: self.buffer_size, + } + } +} + +/// Statistics about buffer pool usage +#[derive(Debug, Clone, Copy)] +pub struct BufferPoolStats { + pub total_buffers: usize, + pub available_buffers: usize, + pub in_use_buffers: usize, + pub peak_usage: usize, + pub total_allocations: usize, + pub buffer_size: usize, +} + +impl Default for BufferPool { + fn default() -> Self { + // Default: 8 buffers of 4096 samples (enough for 85ms at 48kHz stereo) + Self::new(8, 4096) + } +} diff --git a/daw-backend/src/audio/clip.rs b/daw-backend/src/audio/clip.rs new file mode 100644 index 0000000..c3f7f79 --- /dev/null +++ b/daw-backend/src/audio/clip.rs @@ -0,0 +1,114 @@ +use std::sync::Arc; +use serde::{Serialize, Deserialize}; +use crate::time::{Beats, Seconds}; +use crate::tempo_map::TempoMap; + +/// Audio clip instance ID type +pub type AudioClipInstanceId = u32; + +/// Type alias for backwards compatibility +pub type ClipId = AudioClipInstanceId; + +/// Audio clip instance that references content in the AudioClipPool +/// +/// ## Timing Model +/// - `internal_start` / `internal_end`: Region of the source audio to play (seconds — audio file seek positions) +/// - `external_start` / `external_duration`: Where the clip appears on the timeline (**beats**) +/// +/// ## Looping +/// If `external_duration_secs(bpm)` > `internal_end - internal_start`, the clip loops. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AudioClipInstance { + pub id: AudioClipInstanceId, + pub audio_pool_index: usize, + + /// Start position within the audio content + pub internal_start: Seconds, + /// End position within the audio content + pub internal_end: Seconds, + + /// Start position on the timeline + pub external_start: Beats, + /// Duration on the timeline + pub external_duration: Beats, + + /// Clip-level gain + pub gain: f32, + + /// Per-instance read-ahead buffer for compressed audio streaming. + #[serde(skip)] + pub read_ahead: Option>, +} + +/// Type alias for backwards compatibility +pub type Clip = AudioClipInstance; + +impl AudioClipInstance { + pub fn new( + id: AudioClipInstanceId, + audio_pool_index: usize, + internal_start: Seconds, + internal_end: Seconds, + external_start: Beats, + external_duration: Beats, + ) -> Self { + Self { + id, + audio_pool_index, + internal_start, + internal_end, + external_start, + external_duration, + gain: 1.0, + read_ahead: None, + } + } + + pub fn external_end(&self) -> Beats { + self.external_start + self.external_duration + } + + pub fn external_start_secs(&self, tempo_map: &TempoMap) -> Seconds { + tempo_map.beats_to_seconds(self.external_start) + } + + pub fn external_end_secs(&self, tempo_map: &TempoMap) -> Seconds { + tempo_map.beats_to_seconds(self.external_end()) + } + + pub fn external_duration_secs(&self, tempo_map: &TempoMap) -> Seconds { + tempo_map.beats_to_seconds(self.external_end()) - tempo_map.beats_to_seconds(self.external_start) + } + + pub fn is_active_at(&self, time: Seconds, tempo_map: &TempoMap) -> bool { + time >= self.external_start_secs(tempo_map) && time < self.external_end_secs(tempo_map) + } + + pub fn internal_duration(&self) -> Seconds { + self.internal_end - self.internal_start + } + + pub fn is_looping(&self, tempo_map: &TempoMap) -> bool { + self.external_duration_secs(tempo_map) > self.internal_duration() + } + + /// Get the audio content position for a given timeline position. Handles looping. + pub fn get_content_position(&self, timeline_pos: Seconds, tempo_map: &TempoMap) -> Option { + let start_secs = self.external_start_secs(tempo_map); + let end_secs = self.external_end_secs(tempo_map); + if timeline_pos < start_secs || timeline_pos >= end_secs { + return None; + } + let relative_pos = timeline_pos - start_secs; + let internal_duration = self.internal_duration(); + if internal_duration.0 <= 0.0 { + return None; + } + let content_offset = relative_pos % internal_duration; + Some(self.internal_start + content_offset) + } + + pub fn set_gain(&mut self, gain: f32) { + self.gain = gain.max(0.0); + } +} diff --git a/daw-backend/src/audio/disk_reader.rs b/daw-backend/src/audio/disk_reader.rs new file mode 100644 index 0000000..377f4f0 --- /dev/null +++ b/daw-backend/src/audio/disk_reader.rs @@ -0,0 +1,651 @@ +//! Disk reader for streaming audio playback. +//! +//! Provides lock-free read-ahead buffers for audio files that cannot be kept +//! fully decoded in memory. A background thread fills these buffers ahead of +//! the playhead so the audio callback never blocks on I/O or decoding. +//! +//! **InMemory** files bypass the disk reader entirely — their data is already +//! available as `&[f32]`. **Mapped** files (mmap'd WAV/AIFF) also bypass the +//! disk reader for now (OS page cache handles paging). **Compressed** files +//! (MP3, FLAC, OGG, etc.) use a `CompressedReader` that stream-decodes on +//! demand via Symphonia into a `ReadAheadBuffer`. + +use std::cell::UnsafeCell; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; + +use symphonia::core::audio::SampleBuffer; +use symphonia::core::codecs::DecoderOptions; +use symphonia::core::formats::{FormatOptions, SeekMode, SeekTo}; +use symphonia::core::io::MediaSourceStream; +use symphonia::core::meta::MetadataOptions; +use symphonia::core::probe::Hint; + +/// Read-ahead distance in seconds. +const PREFETCH_SECONDS: f64 = 2.0; + +/// How often the disk reader thread wakes up to check for work (ms). +const POLL_INTERVAL_MS: u64 = 5; + +// --------------------------------------------------------------------------- +// ReadAheadBuffer +// --------------------------------------------------------------------------- + +/// Lock-free read-ahead buffer shared between the disk reader (writer) and the +/// audio callback (reader). +/// +/// # Thread safety +/// +/// This is a **single-producer single-consumer** (SPSC) structure: +/// - **Producer** (disk reader thread): calls `write_samples()` and +/// `advance_start()` to fill and reclaim buffer space. +/// - **Consumer** (audio callback): calls `read_sample()` and `has_range()` +/// to access decoded audio. +/// +/// The producer only writes to indices **beyond** `valid_frames`, while the +/// consumer only reads indices **within** `[start_frame, start_frame + +/// valid_frames)`. Because the two threads always operate on disjoint regions, +/// the sample data itself requires no locking. Atomics with Acquire/Release +/// ordering on `start_frame` and `valid_frames` provide the happens-before +/// relationship that guarantees the consumer sees completed writes. +/// +/// The `UnsafeCell` wrapping the buffer data allows the producer to mutate it +/// through a shared `&self` reference. This is sound because only one thread +/// (the producer) ever writes, and it writes to a region that the consumer +/// cannot yet see (gated by the `valid_frames` atomic). +pub struct ReadAheadBuffer { + /// Interleaved f32 samples stored as a circular buffer. + /// Wrapped in `UnsafeCell` to allow the producer to write through `&self`. + buffer: UnsafeCell>, + /// The absolute frame number of the oldest valid frame in the ring. + start_frame: AtomicU64, + /// Number of valid frames starting from `start_frame`. + valid_frames: AtomicU64, + /// Total capacity in frames. + capacity_frames: usize, + /// Number of audio channels. + channels: u32, + /// Source file sample rate. + sample_rate: u32, + /// Last file-local frame requested by the audio callback. + /// Written by the consumer (render_from_file), read by the disk reader. + /// The disk reader uses this instead of the global playhead to know + /// where in the file to buffer around. + target_frame: AtomicU64, + /// When true, `render_from_file` will block-wait for frames instead of + /// returning silence on buffer miss. Used during offline export. + export_mode: AtomicBool, +} + +// SAFETY: See the doc comment on ReadAheadBuffer for the full safety argument. +// In short: SPSC access pattern with atomic coordination means no data races. +// The circular design means advance_start never moves data — it only bumps +// the start pointer, so the consumer never sees partially-shifted memory. +unsafe impl Send for ReadAheadBuffer {} +unsafe impl Sync for ReadAheadBuffer {} + +impl std::fmt::Debug for ReadAheadBuffer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ReadAheadBuffer") + .field("capacity_frames", &self.capacity_frames) + .field("channels", &self.channels) + .field("sample_rate", &self.sample_rate) + .field("start_frame", &self.start_frame.load(Ordering::Relaxed)) + .field("valid_frames", &self.valid_frames.load(Ordering::Relaxed)) + .finish() + } +} + +impl ReadAheadBuffer { + /// Create a new read-ahead buffer with the given capacity (in seconds). + pub fn new(capacity_seconds: f64, sample_rate: u32, channels: u32) -> Self { + let capacity_frames = (capacity_seconds * sample_rate as f64) as usize; + let buffer_len = capacity_frames * channels as usize; + Self { + buffer: UnsafeCell::new(vec![0.0f32; buffer_len].into_boxed_slice()), + start_frame: AtomicU64::new(0), + valid_frames: AtomicU64::new(0), + capacity_frames, + channels, + sample_rate, + target_frame: AtomicU64::new(0), + export_mode: AtomicBool::new(false), + } + } + + /// Map an absolute frame number to a ring-buffer sample index. + #[inline(always)] + fn ring_index(&self, frame: u64, channel: usize) -> usize { + let ring_frame = (frame as usize) % self.capacity_frames; + ring_frame * self.channels as usize + channel + } + + /// Snapshot the current valid range. Call once per audio callback, then + /// pass the returned `(start, end)` to `read_sample` for consistent reads. + #[inline] + pub fn snapshot(&self) -> (u64, u64) { + let start = self.start_frame.load(Ordering::Acquire); + let valid = self.valid_frames.load(Ordering::Acquire); + (start, start + valid) + } + + /// Read a single interleaved sample using a pre-loaded range snapshot. + /// Returns `0.0` if the frame is outside `[snap_start, snap_end)`. + /// Called from the **audio callback** (consumer). + #[inline] + pub fn read_sample(&self, frame: u64, channel: usize, snap_start: u64, snap_end: u64) -> f32 { + if frame < snap_start || frame >= snap_end { + return 0.0; + } + + let idx = self.ring_index(frame, channel); + // SAFETY: We only read indices that the producer has already written + // and published via valid_frames. The circular layout means + // advance_start never moves data, so no torn reads are possible. + let buffer = unsafe { &*self.buffer.get() }; + buffer[idx] + } + + /// Check whether a contiguous range of frames is fully available. + #[inline] + pub fn has_range(&self, start: u64, count: u64) -> bool { + let buf_start = self.start_frame.load(Ordering::Acquire); + let valid = self.valid_frames.load(Ordering::Acquire); + start >= buf_start && start + count <= buf_start + valid + } + + /// Current start frame of the buffer. + #[inline] + pub fn start_frame(&self) -> u64 { + self.start_frame.load(Ordering::Acquire) + } + + /// Number of valid frames currently in the buffer. + #[inline] + pub fn valid_frames_count(&self) -> u64 { + self.valid_frames.load(Ordering::Acquire) + } + + /// Update the target frame — the file-local frame the audio callback + /// is currently reading from. Called by `render_from_file` (consumer). + /// Each clip instance has its own buffer, so a plain store is sufficient. + #[inline] + pub fn set_target_frame(&self, frame: u64) { + self.target_frame.store(frame, Ordering::Relaxed); + } + + /// Reset the target frame to MAX before a new render cycle. + /// If no clip calls `set_target_frame` this cycle, `has_active_target()` + /// returns false, telling the disk reader to skip this buffer. + #[inline] + pub fn reset_target_frame(&self) { + self.target_frame.store(u64::MAX, Ordering::Relaxed); + } + + /// Force-set the target frame to an exact value. + /// Used by the disk reader's seek command where we need an absolute position. + #[inline] + pub fn force_target_frame(&self, frame: u64) { + self.target_frame.store(frame, Ordering::Relaxed); + } + + /// Get the target frame set by the audio callback. + /// Called by the disk reader thread (producer). + #[inline] + pub fn target_frame(&self) -> u64 { + self.target_frame.load(Ordering::Relaxed) + } + + /// Check if any clip set a target this cycle (vs still at reset value). + #[inline] + pub fn has_active_target(&self) -> bool { + self.target_frame.load(Ordering::Relaxed) != u64::MAX + } + + /// Enable or disable export (blocking) mode. When enabled, + /// `render_from_file` will spin-wait for frames instead of returning + /// silence on buffer miss. + pub fn set_export_mode(&self, export: bool) { + self.export_mode.store(export, Ordering::Release); + } + + /// Check if export (blocking) mode is active. + pub fn is_export_mode(&self) -> bool { + self.export_mode.load(Ordering::Acquire) + } + + /// Reset the buffer to start at `new_start` with zero valid frames. + /// Called by the **disk reader thread** (producer) after a seek. + pub fn reset(&self, new_start: u64) { + self.valid_frames.store(0, Ordering::Release); + self.start_frame.store(new_start, Ordering::Release); + } + + /// Write interleaved samples into the buffer, extending the valid range. + /// Called by the **disk reader thread** (producer only). + /// Returns the number of frames actually written (may be less than `frames` + /// if the buffer is full). + /// + /// # Safety + /// Must only be called from the single producer thread. + pub fn write_samples(&self, samples: &[f32], frames: usize) -> usize { + let valid = self.valid_frames.load(Ordering::Acquire) as usize; + let remaining_capacity = self.capacity_frames - valid; + let write_frames = frames.min(remaining_capacity); + if write_frames == 0 { + return 0; + } + + let ch = self.channels as usize; + let start = self.start_frame.load(Ordering::Acquire); + let write_start_frame = start as usize + valid; + + // SAFETY: We only write to ring positions beyond the current valid + // range, which the consumer cannot access. Only one producer calls this. + let buffer = unsafe { &mut *self.buffer.get() }; + + // Write with wrap-around: the ring position may cross the buffer end. + let ring_start = (write_start_frame % self.capacity_frames) * ch; + let total_samples = write_frames * ch; + + let buffer_sample_len = self.capacity_frames * ch; + let first_chunk = total_samples.min(buffer_sample_len - ring_start); + + buffer[ring_start..ring_start + first_chunk] + .copy_from_slice(&samples[..first_chunk]); + + if first_chunk < total_samples { + // Wrap around to the beginning of the buffer. + let second_chunk = total_samples - first_chunk; + buffer[..second_chunk] + .copy_from_slice(&samples[first_chunk..first_chunk + second_chunk]); + } + + // Make the new samples visible to the consumer. + self.valid_frames + .store((valid + write_frames) as u64, Ordering::Release); + + write_frames + } + + /// Advance the buffer start, discarding frames behind the playhead. + /// Called by the **disk reader thread** (producer only) to reclaim space. + /// + /// Because this is a circular buffer, advancing the start only updates + /// atomic counters — no data is moved, so the consumer never sees + /// partially-shifted memory. + pub fn advance_start(&self, new_start: u64) { + let old_start = self.start_frame.load(Ordering::Acquire); + if new_start <= old_start { + return; + } + + let advance_frames = (new_start - old_start) as usize; + let valid = self.valid_frames.load(Ordering::Acquire) as usize; + + if advance_frames >= valid { + // All data is stale — just reset. + self.valid_frames.store(0, Ordering::Release); + self.start_frame.store(new_start, Ordering::Release); + return; + } + + let new_valid = valid - advance_frames; + // Store valid_frames first (shrinking the visible range), then + // advance start_frame. The consumer always sees a consistent + // sub-range of valid data. + self.valid_frames + .store(new_valid as u64, Ordering::Release); + self.start_frame.store(new_start, Ordering::Release); + } +} + +// --------------------------------------------------------------------------- +// CompressedReader +// --------------------------------------------------------------------------- + +/// Wraps a Symphonia decoder for streaming a single compressed audio file. +struct CompressedReader { + format_reader: Box, + decoder: Box, + track_id: u32, + /// Current decoder position in frames. + current_frame: u64, + sample_rate: u32, + channels: u32, + #[allow(dead_code)] + total_frames: u64, + /// Temporary decode buffer. + sample_buf: Option>, +} + +impl CompressedReader { + /// Open a compressed audio file and prepare for streaming decode. + fn open(path: &Path) -> Result { + let file = + std::fs::File::open(path).map_err(|e| format!("Failed to open file: {}", e))?; + let mss = MediaSourceStream::new(Box::new(file), Default::default()); + + let mut hint = Hint::new(); + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + hint.with_extension(ext); + } + + let probed = symphonia::default::get_probe() + .format( + &hint, + mss, + &FormatOptions::default(), + &MetadataOptions::default(), + ) + .map_err(|e| format!("Failed to probe file: {}", e))?; + + let format_reader = probed.format; + + let track = format_reader + .tracks() + .iter() + .find(|t| t.codec_params.codec != symphonia::core::codecs::CODEC_TYPE_NULL) + .ok_or_else(|| "No audio tracks found".to_string())?; + + let track_id = track.id; + let codec_params = &track.codec_params; + let sample_rate = codec_params.sample_rate.unwrap_or(44100); + let channels = codec_params + .channels + .map(|c| c.count()) + .unwrap_or(2) as u32; + let total_frames = codec_params.n_frames.unwrap_or(0); + + let decoder = symphonia::default::get_codecs() + .make(codec_params, &DecoderOptions::default()) + .map_err(|e| format!("Failed to create decoder: {}", e))?; + + Ok(Self { + format_reader, + decoder, + track_id, + current_frame: 0, + sample_rate, + channels, + total_frames, + sample_buf: None, + }) + } + + /// Seek to a specific frame. Returns the actual frame reached (may differ + /// for compressed formats that can only seek to keyframes). + fn seek(&mut self, target_frame: u64) -> Result { + let seek_to = SeekTo::TimeStamp { + ts: target_frame, + track_id: self.track_id, + }; + + let seeked = self + .format_reader + .seek(SeekMode::Coarse, seek_to) + .map_err(|e| format!("Seek failed: {}", e))?; + + let actual_frame = seeked.actual_ts; + self.current_frame = actual_frame; + + // Reset the decoder after seeking. + self.decoder.reset(); + + Ok(actual_frame) + } + + /// Decode the next chunk of audio into `out`. Returns the number of frames + /// decoded. Returns `Ok(0)` at end-of-file. + fn decode_next(&mut self, out: &mut Vec) -> Result { + out.clear(); + + loop { + let packet = match self.format_reader.next_packet() { + Ok(p) => p, + Err(symphonia::core::errors::Error::IoError(ref e)) + if e.kind() == std::io::ErrorKind::UnexpectedEof => + { + return Ok(0); // EOF + } + Err(e) => return Err(format!("Read packet error: {}", e)), + }; + + if packet.track_id() != self.track_id { + continue; + } + + match self.decoder.decode(&packet) { + Ok(decoded) => { + if self.sample_buf.is_none() { + let spec = *decoded.spec(); + let duration = decoded.capacity() as u64; + self.sample_buf = Some(SampleBuffer::new(duration, spec)); + } + + if let Some(ref mut buf) = self.sample_buf { + buf.copy_interleaved_ref(decoded); + let samples = buf.samples(); + out.extend_from_slice(samples); + let frames = samples.len() / self.channels as usize; + self.current_frame += frames as u64; + return Ok(frames); + } + + return Ok(0); + } + Err(symphonia::core::errors::Error::DecodeError(_)) => { + continue; // Skip corrupt packets. + } + Err(e) => return Err(format!("Decode error: {}", e)), + } + } + } +} + +// --------------------------------------------------------------------------- +// DiskReaderCommand +// --------------------------------------------------------------------------- + +/// Commands sent from the engine to the disk reader thread. +pub enum DiskReaderCommand { + /// Start streaming a compressed file for a clip instance. + ActivateFile { + reader_id: u64, + path: PathBuf, + buffer: Arc, + }, + /// Stop streaming for a clip instance. + DeactivateFile { reader_id: u64 }, + /// The playhead has jumped — refill buffers from the new position. + Seek { frame: u64 }, + /// Shut down the disk reader thread. + Shutdown, +} + +// --------------------------------------------------------------------------- +// DiskReader +// --------------------------------------------------------------------------- + +/// Manages background read-ahead for compressed audio files. +/// +/// The engine creates a `DiskReader` at startup. When a compressed file is +/// imported, it sends an `ActivateFile` command. The disk reader opens a +/// Symphonia decoder and starts filling the file's `ReadAheadBuffer` ahead +/// of the shared playhead. +pub struct DiskReader { + /// Channel to send commands to the background thread. + command_tx: rtrb::Producer, + /// Shared playhead position (frames). The engine updates this atomically. + #[allow(dead_code)] + playhead_frame: Arc, + /// Whether the reader thread is running. + running: Arc, + /// Background thread handle. + thread_handle: Option>, +} + +impl DiskReader { + /// Create a new disk reader with a background thread. + pub fn new(playhead_frame: Arc, _sample_rate: u32) -> Self { + let (command_tx, command_rx) = rtrb::RingBuffer::new(64); + let running = Arc::new(AtomicBool::new(true)); + + let thread_running = running.clone(); + + let thread_handle = std::thread::Builder::new() + .name("disk-reader".into()) + .spawn(move || { + Self::reader_thread(command_rx, thread_running); + }) + .expect("Failed to spawn disk reader thread"); + + Self { + command_tx, + playhead_frame, + running, + thread_handle: Some(thread_handle), + } + } + + /// Send a command to the disk reader thread. + pub fn send(&mut self, cmd: DiskReaderCommand) { + let _ = self.command_tx.push(cmd); + } + + /// Create a `ReadAheadBuffer` for a compressed file. + pub fn create_buffer(sample_rate: u32, channels: u32) -> Arc { + Arc::new(ReadAheadBuffer::new( + PREFETCH_SECONDS + 1.0, // extra headroom + sample_rate, + channels, + )) + } + + /// The disk reader background thread. + fn reader_thread( + mut command_rx: rtrb::Consumer, + running: Arc, + ) { + let mut active_files: HashMap)> = + HashMap::new(); + let mut decode_buf = Vec::with_capacity(8192); + + while running.load(Ordering::Relaxed) { + // Process commands. + while let Ok(cmd) = command_rx.pop() { + match cmd { + DiskReaderCommand::ActivateFile { + reader_id, + path, + buffer, + } => match CompressedReader::open(&path) { + Ok(reader) => { + eprintln!("[DiskReader] Activated reader={}, ch={}, sr={}, path={:?}", + reader_id, reader.channels, reader.sample_rate, path); + active_files.insert(reader_id, (reader, buffer)); + } + Err(e) => { + eprintln!( + "[DiskReader] Failed to open compressed file {:?}: {}", + path, e + ); + } + }, + DiskReaderCommand::DeactivateFile { reader_id } => { + active_files.remove(&reader_id); + } + DiskReaderCommand::Seek { frame } => { + for (_, (reader, buffer)) in active_files.iter_mut() { + buffer.force_target_frame(frame); + buffer.reset(frame); + if let Err(e) = reader.seek(frame) { + eprintln!("[DiskReader] Seek error: {}", e); + } + } + } + DiskReaderCommand::Shutdown => { + return; + } + } + } + + // Fill each active reader's buffer ahead of its target frame. + // Each clip instance has its own buffer and target_frame, set by + // render_from_file during the audio callback. + for (_reader_id, (reader, buffer)) in active_files.iter_mut() { + // Skip files where no clip is currently playing + if !buffer.has_active_target() { + continue; + } + + let target = buffer.target_frame(); + let buf_start = buffer.start_frame(); + let buf_valid = buffer.valid_frames_count(); + let buf_end = buf_start + buf_valid; + + // If the target has jumped behind or far ahead of the buffer, + // seek the decoder and reset. + if target < buf_start || target > buf_end + reader.sample_rate as u64 { + buffer.reset(target); + let _ = reader.seek(target); + continue; + } + + // Advance the buffer start to reclaim space behind the target. + // Keep a small lookback for sinc interpolation (~32 frames). + let lookback = 64u64; + let advance_to = target.saturating_sub(lookback); + if advance_to > buf_start { + buffer.advance_start(advance_to); + } + + // Calculate how far ahead we need to fill. + let buf_start = buffer.start_frame(); + let buf_valid = buffer.valid_frames_count(); + let buf_end = buf_start + buf_valid; + let prefetch_target = + target + (PREFETCH_SECONDS * reader.sample_rate as f64) as u64; + + if buf_end >= prefetch_target { + continue; // Already filled far enough ahead. + } + + // Decode more data into the buffer. + match reader.decode_next(&mut decode_buf) { + Ok(0) => {} // EOF + Ok(frames) => { + let was_empty = buffer.valid_frames_count() == 0; + buffer.write_samples(&decode_buf, frames); + if was_empty { + eprintln!("[DiskReader] reader={}: first fill, {} frames, buf_start={}, valid={}", + _reader_id, frames, buffer.start_frame(), buffer.valid_frames_count()); + } + } + Err(e) => { + eprintln!("[DiskReader] Decode error: {}", e); + } + } + } + + // In export mode, skip the sleep so decoding runs at full speed. + // Otherwise sleep briefly to avoid busy-spinning. + let any_exporting = active_files.values().any(|(_, buf)| buf.is_export_mode()); + if !any_exporting { + std::thread::sleep(std::time::Duration::from_millis(POLL_INTERVAL_MS)); + } + } + } +} + +impl Drop for DiskReader { + fn drop(&mut self) { + self.running.store(false, Ordering::Release); + let _ = self.command_tx.push(DiskReaderCommand::Shutdown); + if let Some(handle) = self.thread_handle.take() { + let _ = handle.join(); + } + } +} diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs new file mode 100644 index 0000000..8d59698 --- /dev/null +++ b/daw-backend/src/audio/engine.rs @@ -0,0 +1,4432 @@ +use crate::audio::buffer_pool::BufferPool; +use crate::audio::clip::{AudioClipInstance, AudioClipInstanceId, ClipId}; +use crate::audio::metronome::Metronome; +use crate::audio::midi::{MidiClip, MidiClipId, MidiClipInstance, MidiClipInstanceId, MidiEvent}; +use crate::audio::node_graph::{nodes::*, AudioGraph}; +use crate::audio::pool::AudioClipPool; +use crate::audio::project::Project; +use crate::audio::recording::{MidiRecordingState, RecordingState}; +use crate::audio::track::{Track, TrackId, TrackNode}; +use crate::command::{AudioEvent, Command, Query, QueryResponse}; +use crate::io::MidiInputManager; +use crate::time::{Beats, Seconds}; +use petgraph::stable_graph::NodeIndex; +use std::collections::HashMap; +use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; +use std::sync::{Arc, RwLock}; + +/// Read-only snapshot of all clip instances, updated after every clip mutation. +/// Shared between the audio thread (writer) and the UI thread (reader). +#[derive(Default, Clone)] +pub struct AudioClipSnapshot { + pub audio: HashMap>, + pub midi: HashMap>, +} + +/// Audio engine for Phase 6: hierarchical tracks with groups +pub struct Engine { + project: Project, + audio_pool: AudioClipPool, + buffer_pool: BufferPool, + playhead: i64, // Playhead position in samples (may be negative during count-in pre-roll) + sample_rate: u32, + playing: bool, + channels: u32, + + // Lock-free communication + command_rx: rtrb::Consumer, + midi_command_rx: Option>, + event_tx: rtrb::Producer, + query_rx: rtrb::Consumer, + query_response_tx: rtrb::Producer, + + // Background chunk generation channel + chunk_generation_rx: std::sync::mpsc::Receiver, + chunk_generation_tx: std::sync::mpsc::Sender, + + // Shared clip snapshot for UI reads + clip_snapshot: Arc>, + + // Shared playhead for UI reads + playhead_atomic: Arc, + + // Shared MIDI clip ID counter for synchronous access + next_midi_clip_id_atomic: Arc, + + // Shared audio clip ID counter (shared with EngineController for pre-assigned IDs) + next_audio_clip_id_atomic: Arc, + + // Event counter for periodic position updates + frames_since_last_event: usize, + event_interval_frames: usize, + + // Mix buffer for output + mix_buffer: Vec, + + // ID counters (legacy, unused — kept for potential future use) + // Audio clip IDs are now generated via next_audio_clip_id_atomic + + // Recording state + recording_state: Option, + input_rx: Option>, + recording_mirror_tx: Option>, + recording_progress_counter: usize, + + // MIDI recording state + midi_recording_state: Option, + + // Currently held MIDI notes per track (note -> velocity), updated on NoteOn/NoteOff + // Used to inject held notes when recording starts mid-press (e.g. after count-in) + midi_held_notes: HashMap>, + + // MIDI input manager for external MIDI devices + midi_input_manager: Option, + + // Metronome for click track + metronome: Metronome, + + // Pre-allocated buffer for recording input samples (avoids allocation per callback) + recording_sample_buffer: Vec, + + // Disk reader for streaming playback of compressed files + disk_reader: Option, + + // Input monitoring and metering + input_monitoring: bool, + input_gain: f32, + input_level_peak: f32, + input_level_counter: usize, + output_level_peak_l: f32, + output_level_peak_r: f32, + output_level_counter: usize, + track_level_counter: usize, + + // Callback timing diagnostics (enabled by DAW_AUDIO_DEBUG=1) + debug_audio: bool, + callback_count: u64, + timing_worst_total_us: u64, + timing_worst_commands_us: u64, + timing_worst_render_us: u64, + timing_sum_total_us: u64, + timing_overrun_count: u64, + + // Current tempo map — kept in sync with SetTempo/SetTempoMap commands. + tempo_map: crate::TempoMap, + current_fps: f64, + // Current time signature — updated by SetTempo, used when SetTempoMap fires. + time_sig: (u32, u32), +} + +impl Engine { + /// Create a new Engine with communication channels + pub fn new( + sample_rate: u32, + channels: u32, + command_rx: rtrb::Consumer, + event_tx: rtrb::Producer, + query_rx: rtrb::Consumer, + query_response_tx: rtrb::Producer, + ) -> Self { + let event_interval_frames = (sample_rate as usize * channels as usize) / 60; // Update 60 times per second + + // Calculate a reasonable buffer size for the pool (typical audio callback size * channels) + let buffer_size = 512 * channels as usize; + + // Create channel for background chunk generation + let (chunk_generation_tx, chunk_generation_rx) = std::sync::mpsc::channel(); + + // Shared atomic playhead for UI reads and disk reader + let playhead_atomic = Arc::new(AtomicU64::new(0)); + + // Initialize disk reader with shared playhead + let disk_reader = crate::audio::disk_reader::DiskReader::new( + Arc::clone(&playhead_atomic), + sample_rate, + ); + + Self { + project: Project::new(sample_rate), + audio_pool: AudioClipPool::new(), + buffer_pool: BufferPool::new(8, buffer_size), // 8 buffers should handle deep nesting + playhead: 0, + sample_rate, + playing: false, + channels, + command_rx, + midi_command_rx: None, + event_tx, + query_rx, + query_response_tx, + chunk_generation_rx, + chunk_generation_tx, + clip_snapshot: Arc::new(RwLock::new(AudioClipSnapshot::default())), + playhead_atomic, + next_midi_clip_id_atomic: Arc::new(AtomicU32::new(0)), + next_audio_clip_id_atomic: Arc::new(AtomicU32::new(0)), + frames_since_last_event: 0, + event_interval_frames, + mix_buffer: Vec::new(), + recording_state: None, + input_rx: None, + recording_mirror_tx: None, + recording_progress_counter: 0, + midi_recording_state: None, + midi_held_notes: HashMap::new(), + midi_input_manager: None, + metronome: Metronome::new(sample_rate), + recording_sample_buffer: Vec::with_capacity(4096), + disk_reader: Some(disk_reader), + input_monitoring: false, + input_gain: 1.0, + input_level_peak: 0.0, + input_level_counter: 0, + output_level_peak_l: 0.0, + output_level_peak_r: 0.0, + output_level_counter: 0, + track_level_counter: 0, + debug_audio: std::env::var("DAW_AUDIO_DEBUG").map_or(false, |v| v == "1"), + callback_count: 0, + timing_worst_total_us: 0, + timing_worst_commands_us: 0, + timing_worst_render_us: 0, + timing_sum_total_us: 0, + timing_overrun_count: 0, + tempo_map: crate::TempoMap::constant(120.0), + current_fps: 30.0, + time_sig: (4, 4), + } + } + + /// Set the input ringbuffer consumer for recording + pub fn set_input_rx(&mut self, input_rx: rtrb::Consumer) { + self.input_rx = Some(input_rx); + } + + /// Set the recording mirror producer for streaming audio to UI during recording + pub fn set_recording_mirror_tx(&mut self, tx: rtrb::Producer) { + self.recording_mirror_tx = Some(tx); + } + + /// Set the MIDI input manager for external MIDI devices + pub fn set_midi_input_manager(&mut self, manager: MidiInputManager) { + self.midi_input_manager = Some(manager); + } + + /// Set the MIDI command receiver for external MIDI input + pub fn set_midi_command_rx(&mut self, midi_command_rx: rtrb::Consumer) { + self.midi_command_rx = Some(midi_command_rx); + } + + /// Add an audio track to the engine + pub fn add_track(&mut self, track: Track) -> TrackId { + // For backwards compatibility, we'll extract the track data and add it to the project + let name = track.name.clone(); + let id = self.project.add_audio_track(name, None); + + // Copy over the track properties + if let Some(node) = self.project.get_track_mut(id) { + if let crate::audio::track::TrackNode::Audio(audio_track) = node { + audio_track.clips = track.clips; + audio_track.volume = track.volume; + audio_track.muted = track.muted; + audio_track.solo = track.solo; + } + } + + id + } + + /// Add an audio track by name + pub fn add_audio_track(&mut self, name: String) -> TrackId { + self.project.add_audio_track(name, None) + } + + /// Add a group track by name + pub fn add_group_track(&mut self, name: String) -> TrackId { + self.project.add_group_track(name, None) + } + + /// Add a MIDI track by name + pub fn add_midi_track(&mut self, name: String) -> TrackId { + self.project.add_midi_track(name, None) + } + + /// Get access to the project + pub fn project(&self) -> &Project { + &self.project + } + + /// Get mutable access to the project + pub fn project_mut(&mut self) -> &mut Project { + &mut self.project + } + + /// Get mutable reference to audio pool + pub fn audio_pool_mut(&mut self) -> &mut AudioClipPool { + &mut self.audio_pool + } + + /// Get reference to audio pool + pub fn audio_pool(&self) -> &AudioClipPool { + &self.audio_pool + } + + /// Rebuild the clip snapshot from the current project state. + /// Call this after any command that adds, removes, or modifies clip instances. + fn refresh_clip_snapshot(&self) { + let mut snap = self.clip_snapshot.write().unwrap(); + snap.audio.clear(); + snap.midi.clear(); + for (track_id, node) in self.project.track_iter() { + match node { + crate::audio::track::TrackNode::Audio(t) => { + snap.audio.insert(track_id, t.clips.clone()); + } + crate::audio::track::TrackNode::Midi(t) => { + snap.midi.insert(track_id, t.clip_instances.clone()); + } + crate::audio::track::TrackNode::Group(_) => {} + } + } + } + + /// Get a handle for controlling playback from the UI thread + pub fn get_controller( + &self, + command_tx: rtrb::Producer, + query_tx: rtrb::Producer, + query_response_rx: rtrb::Consumer, + ) -> EngineController { + EngineController { + command_tx, + query_tx, + query_response_rx, + playhead: Arc::clone(&self.playhead_atomic), + next_midi_clip_id: Arc::clone(&self.next_midi_clip_id_atomic), + next_audio_clip_id: Arc::clone(&self.next_audio_clip_id_atomic), + clip_snapshot: Arc::clone(&self.clip_snapshot), + sample_rate: self.sample_rate, + channels: self.channels, + cached_export_response: None, + } + } + + /// Process audio callback - called from the audio thread + pub fn process(&mut self, output: &mut [f32]) { + let t_start = if self.debug_audio { Some(std::time::Instant::now()) } else { None }; + + // Process all pending commands + while let Ok(cmd) = self.command_rx.pop() { + self.handle_command(cmd); + } + + // Process all pending MIDI commands + loop { + let midi_cmd = if let Some(ref mut midi_rx) = self.midi_command_rx { + midi_rx.pop().ok() + } else { + None + }; + + if let Some(cmd) = midi_cmd { + self.handle_command(cmd); + } else { + break; + } + } + + // Process all pending queries + while let Ok(query) = self.query_rx.pop() { + self.handle_query(query); + } + + // Forward chunk generation events from background threads + while let Ok(event) = self.chunk_generation_rx.try_recv() { + match event { + AudioEvent::WaveformDecodeComplete { pool_index, samples, decoded_frames: _df, total_frames: _tf } => { + // Forward samples directly to UI — no clone, just move + if let Some(file) = self.audio_pool.get_file(pool_index) { + let sr = file.sample_rate; + let ch = file.channels; + let _ = self.event_tx.push(AudioEvent::AudioDecodeProgress { + pool_index, + samples, + sample_rate: sr, + channels: ch, + }); + } + } + other => { + if self.debug_audio { + if let AudioEvent::WaveformChunksReady { pool_index, detail_level, ref chunks } = other { + eprintln!("[AUDIO THREAD] Received {} chunks for pool {} level {}, forwarding to UI", chunks.len(), pool_index, detail_level); + } + } + let _ = self.event_tx.push(other); + } + } + } + + let t_commands = if self.debug_audio { Some(std::time::Instant::now()) } else { None }; + + if self.playing { + // Ensure mix buffer is sized correctly + if self.mix_buffer.len() != output.len() { + self.mix_buffer.resize(output.len(), 0.0); + } + + // Ensure buffer pool has the correct buffer size + if self.buffer_pool.buffer_size() != output.len() { + // Reallocate buffer pool with correct size if needed + self.buffer_pool = BufferPool::new(8, output.len()); + } + + // Convert playhead from frames to seconds for timeline-based rendering + let playhead_seconds = Seconds(self.playhead as f64 / self.sample_rate as f64); + + // Reset per-clip read-ahead targets before rendering. + self.project.reset_read_ahead_targets(); + + // Render the entire project hierarchy into the mix buffer + self.project.render( + &mut self.mix_buffer, + &self.audio_pool, + &mut self.buffer_pool, + playhead_seconds, + &self.tempo_map, + self.sample_rate, + self.channels, + false, + ); + + // Copy mix to output + output.copy_from_slice(&self.mix_buffer); + + // Mix in metronome clicks + self.metronome.process( + output, + self.playhead, + self.playing, + self.sample_rate, + self.channels, + ); + + // Update playhead (convert total samples to frames) + self.playhead += (output.len() / self.channels as usize) as i64; + + // Update atomic playhead for UI reads (clamped to 0; negative = count-in pre-roll) + self.playhead_atomic + .store(self.playhead.max(0) as u64, Ordering::Relaxed); + + // Send periodic position updates + self.frames_since_last_event += output.len() / self.channels as usize; + if self.frames_since_last_event >= self.event_interval_frames / self.channels as usize + { + // Clamp to 0 during count-in pre-roll (negative playhead = before project start) + let position_seconds = self.playhead.max(0) as f64 / self.sample_rate as f64; + let _ = self + .event_tx + .push(AudioEvent::PlaybackPosition(position_seconds)); + self.frames_since_last_event = 0; + + // Send MIDI recording progress if active + if let Some(recording) = &self.midi_recording_state { + let current_time_secs = Seconds(self.playhead as f64 / self.sample_rate as f64); + let current_time = self.tempo_map.seconds_to_beats(current_time_secs); + let duration = current_time - recording.start_time; + let notes = recording.get_notes_with_active(current_time); + let _ = self.event_tx.push(AudioEvent::MidiRecordingProgress( + recording.track_id, + recording.clip_id, + duration, + notes, + )); + // Keep the snapshot up to date so the UI can display a growing clip bar. + let track_id = recording.track_id; + let clip_id = recording.clip_id; + if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { + if let Some(instance) = track.clip_instances.iter_mut().find(|i| i.clip_id == clip_id) { + instance.internal_end = duration; + instance.external_duration = duration; + } + } + self.refresh_clip_snapshot(); + } + } + } else { + // Not playing: render live MIDI (keyboard input + note-off tails) through the + // normal group hierarchy so mixer gain is correctly applied. + let playhead_seconds = Seconds(self.playhead as f64 / self.sample_rate as f64); + if self.mix_buffer.len() != output.len() { + self.mix_buffer.resize(output.len(), 0.0); + } + if self.buffer_pool.buffer_size() != output.len() { + self.buffer_pool = BufferPool::new(8, output.len()); + } + self.project.render( + &mut self.mix_buffer, + &self.audio_pool, + &mut self.buffer_pool, + playhead_seconds, + &self.tempo_map, + self.sample_rate, + self.channels, + true, // live_only + ); + output.copy_from_slice(&self.mix_buffer); + } + + // Compute stereo output peaks for master VU meter (independent of playback state) + { + let channels = self.channels as usize; + for frame in output.chunks(channels) { + if channels >= 2 { + self.output_level_peak_l = self.output_level_peak_l.max(frame[0].abs()); + self.output_level_peak_r = self.output_level_peak_r.max(frame[1].abs()); + } else { + let v = frame[0].abs(); + self.output_level_peak_l = self.output_level_peak_l.max(v); + self.output_level_peak_r = self.output_level_peak_r.max(v); + } + } + self.output_level_counter += output.len(); + let meter_interval = self.sample_rate as usize / 20; // ~50ms + if self.output_level_counter >= meter_interval { + let _ = self.event_tx.push(AudioEvent::OutputLevel(self.output_level_peak_l, self.output_level_peak_r)); + self.output_level_peak_l = 0.0; + self.output_level_peak_r = 0.0; + self.output_level_counter = 0; + } + + // Send per-track peak levels periodically + self.track_level_counter += output.len(); + if self.track_level_counter >= meter_interval { + let levels = self.project.collect_track_peaks(); + let _ = self.event_tx.push(AudioEvent::TrackLevels(levels)); + self.track_level_counter = 0; + } + } + + // Process input monitoring and/or recording (independent of playback state) + let is_recording = self.recording_state.is_some(); + if is_recording || self.input_monitoring { + if let Some(input_rx) = &mut self.input_rx { + // Phase 1: Discard stale samples during recording skip phase + if let Some(recording) = &mut self.recording_state { + while recording.samples_to_skip > 0 { + match input_rx.pop() { + Ok(_) => recording.samples_to_skip -= 1, + Err(_) => break, + } + } + } + + // Phase 2: Pull fresh samples + self.recording_sample_buffer.clear(); + while let Ok(sample) = input_rx.pop() { + // Apply input gain + self.recording_sample_buffer.push(sample * self.input_gain); + } + + if !self.recording_sample_buffer.is_empty() { + // Compute input peak for VU metering + let input_peak = self.recording_sample_buffer.iter().map(|s| s.abs()).fold(0.0f32, f32::max); + self.input_level_peak = self.input_level_peak.max(input_peak); + self.input_level_counter += self.recording_sample_buffer.len(); + let meter_interval = self.sample_rate as usize / 20; // ~50ms + if self.input_level_counter >= meter_interval { + let _ = self.event_tx.push(AudioEvent::InputLevel(self.input_level_peak)); + self.input_level_peak = 0.0; + self.input_level_counter = 0; + } + + // Feed samples to recording if active + if let Some(recording) = &mut self.recording_state { + let skip = if recording.paused { + self.recording_sample_buffer.len() + } else { + recording.samples_to_skip.min(self.recording_sample_buffer.len()) + }; + + match recording.add_samples(&self.recording_sample_buffer) { + Ok(_flushed) => { + // Mirror non-skipped samples to UI for live waveform display + if skip < self.recording_sample_buffer.len() { + if let Some(ref mut mirror_tx) = self.recording_mirror_tx { + for &sample in &self.recording_sample_buffer[skip..] { + let _ = mirror_tx.push(sample); + } + } + } + + // Update clip duration every callback for sample-accurate timing + let duration = recording.duration(); + let clip_id = recording.clip_id; + let track_id = recording.track_id; + + // Update clip duration in project as recording progresses + if let Some(crate::audio::track::TrackNode::Audio(track)) = self.project.get_track_mut(track_id) { + if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) { + clip.internal_end = clip.internal_start + duration; + let duration_beats = self.tempo_map.seconds_to_beats(duration); + clip.external_duration = duration_beats; + } + } + + // Send progress event periodically (every ~0.1 seconds) + self.recording_progress_counter += self.recording_sample_buffer.len(); + if self.recording_progress_counter >= (self.sample_rate as usize / 10) { + let _ = self.event_tx.push(AudioEvent::RecordingProgress(clip_id, duration)); + self.recording_progress_counter = 0; + } + } + Err(e) => { + let _ = self.event_tx.push(AudioEvent::RecordingError( + format!("Recording write error: {}", e) + )); + self.recording_state = None; + } + } + } + } + } + } + + // Timing diagnostics (DAW_AUDIO_DEBUG=1) + if let (true, Some(t_start), Some(t_commands)) = (self.debug_audio, t_start, t_commands) { + let t_end = std::time::Instant::now(); + let total_us = t_end.duration_since(t_start).as_micros() as u64; + let commands_us = t_commands.duration_since(t_start).as_micros() as u64; + let render_us = total_us.saturating_sub(commands_us); + + self.callback_count += 1; + self.timing_sum_total_us += total_us; + if total_us > self.timing_worst_total_us { self.timing_worst_total_us = total_us; } + if commands_us > self.timing_worst_commands_us { self.timing_worst_commands_us = commands_us; } + if render_us > self.timing_worst_render_us { self.timing_worst_render_us = render_us; } + + let frames = output.len() as u64 / self.channels as u64; + let deadline_us = frames * 1_000_000 / self.sample_rate as u64; + + if total_us > deadline_us { + self.timing_overrun_count += 1; + eprintln!( + "[AUDIO TIMING] OVERRUN #{}: total={} us (deadline={} us) | cmds={} us, render={} us | buf={} frames", + self.timing_overrun_count, total_us, deadline_us, commands_us, render_us, frames + ); + } + + if self.callback_count % 860 == 0 { + let avg_us = self.timing_sum_total_us / self.callback_count; + eprintln!( + "[AUDIO TIMING] avg={} us, worst: total={} us, cmds={} us, render={} us | overruns={}/{} ({:.1}%) | deadline={} us", + avg_us, self.timing_worst_total_us, self.timing_worst_commands_us, self.timing_worst_render_us, + self.timing_overrun_count, self.callback_count, + self.timing_overrun_count as f64 / self.callback_count as f64 * 100.0, + deadline_us + ); + } + } + } + + /// Read audio from pool as mono f32 samples. + /// Handles all storage types: InMemory/Mapped use read_samples(), + /// Compressed falls back to decoding from the file path. + fn read_mono_from_pool(pool: &crate::audio::pool::AudioClipPool, pool_index: usize) -> Option<(Vec, f32)> { + let audio_file = pool.get_file(pool_index)?; + let channels = audio_file.channels as usize; + let frames = audio_file.frames as usize; + let sample_rate = audio_file.sample_rate as f32; + + // Try read_samples first (works for InMemory and Mapped) + let mut mono_samples = vec![0.0f32; frames]; + let read_count = if channels == 1 { + audio_file.read_samples(0, frames, 0, &mut mono_samples) + } else { + let mut channel_buf = vec![0.0f32; frames]; + let mut count = 0; + for ch in 0..channels { + count = audio_file.read_samples(0, frames, ch, &mut channel_buf); + for (i, &s) in channel_buf.iter().enumerate() { + mono_samples[i] += s; + } + } + let scale = 1.0 / channels as f32; + for s in &mut mono_samples { + *s *= scale; + } + count + }; + + if read_count > 0 { + return Some((mono_samples, sample_rate)); + } + + // Compressed storage: decode from file path using sample_loader + let path = audio_file.path.to_string_lossy(); + if !path.starts_with(" 0 { + let actual_frames = data.len() / channels; + let mut mono = vec![0.0f32; actual_frames]; + for frame in 0..actual_frames { + let mut sum = 0.0f32; + for ch in 0..channels { + sum += data[frame * channels + ch]; + } + mono[frame] = sum / channels as f32; + } + return Some((mono, sample_rate)); + } + + eprintln!("[read_mono_from_pool] Failed to read audio from pool_index={}", pool_index); + None + } + + /// Handle a command from the UI thread + fn handle_command(&mut self, cmd: Command) { + match cmd { + Command::Play => { + self.playing = true; + } + Command::Stop => { + self.playing = false; + self.playhead = 0; + self.playhead_atomic.store(0, Ordering::Relaxed); + // Stop all MIDI notes when stopping playback + self.project.stop_all_notes(); + // Reset disk reader buffers to the new playhead position + if let Some(ref mut dr) = self.disk_reader { + dr.send(crate::audio::disk_reader::DiskReaderCommand::Seek { frame: 0 }); + } + } + Command::Pause => { + self.playing = false; + // Stop all MIDI notes when pausing playback + self.project.stop_all_notes(); + } + Command::Seek(seconds) => { + self.playhead = (seconds * self.sample_rate as f64) as i64; + // Clamp to 0 for atomic/disk-reader; negative = count-in pre-roll (no disk reads needed) + let clamped = self.playhead.max(0) as u64; + self.playhead_atomic.store(clamped, Ordering::Relaxed); + // Stop all MIDI notes when seeking to prevent stuck notes + self.project.stop_all_notes(); + // Reset all node graphs to clear effect buffers (echo, reverb, etc.) + self.project.reset_all_graphs(); + // Notify disk reader to refill buffers from new position + if let Some(ref mut dr) = self.disk_reader { + dr.send(crate::audio::disk_reader::DiskReaderCommand::Seek { frame: clamped }); + } + } + Command::SetTrackVolume(track_id, volume) => { + if let Some(track) = self.project.get_track_mut(track_id) { + track.set_volume(volume); + } + } + Command::SetTrackMute(track_id, muted) => { + if let Some(track) = self.project.get_track_mut(track_id) { + track.set_muted(muted); + } + } + Command::SetTrackSolo(track_id, solo) => { + if let Some(track) = self.project.get_track_mut(track_id) { + track.set_solo(solo); + } + } + Command::MoveClip(track_id, clip_id, new_start_time) => { + // Moving just changes external_start (beats), external_duration stays the same + match self.project.get_track_mut(track_id) { + Some(crate::audio::track::TrackNode::Audio(track)) => { + if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) { + clip.external_start = Beats(new_start_time); + } + } + Some(crate::audio::track::TrackNode::Midi(track)) => { + if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.id == clip_id) { + instance.external_start = Beats(new_start_time); + } + } + _ => {} + } + self.refresh_clip_snapshot(); + } + Command::TrimClip(track_id, clip_id, new_internal_start, new_internal_end) => { + // Trim changes which portion of the source content is used + // Also updates external_duration to match internal duration (no looping after trim) + match self.project.get_track_mut(track_id) { + Some(crate::audio::track::TrackNode::Audio(track)) => { + if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) { + clip.internal_start = Seconds(new_internal_start); + clip.internal_end = Seconds(new_internal_end); + clip.external_duration = Beats(new_internal_end - new_internal_start); + } + } + Some(crate::audio::track::TrackNode::Midi(track)) => { + if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.clip_id == clip_id) { + instance.internal_start = Beats(new_internal_start); + instance.internal_end = Beats(new_internal_end); + instance.external_duration = Beats(new_internal_end - new_internal_start); + } + } + _ => {} + } + self.refresh_clip_snapshot(); + } + Command::ExtendClip(track_id, clip_id, new_external_duration) => { + // Extend changes the external duration (beats; enables looping if > internal duration) + match self.project.get_track_mut(track_id) { + Some(crate::audio::track::TrackNode::Audio(track)) => { + if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) { + clip.external_duration = Beats(new_external_duration); + } + } + Some(crate::audio::track::TrackNode::Midi(track)) => { + if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.clip_id == clip_id) { + instance.external_duration = Beats(new_external_duration); + } + } + _ => {} + } + self.refresh_clip_snapshot(); + } + Command::CreateMetatrack(name, parent_id) => { + let track_id = self.project.add_group_track(name.clone(), parent_id); + // Notify UI about the new metatrack + let _ = self.event_tx.push(AudioEvent::TrackCreated(track_id, true, name)); + } + Command::AddToMetatrack(track_id, metatrack_id) => { + // Move the track to the new metatrack (Project handles removing from old parent) + self.project.move_to_group(track_id, metatrack_id); + } + Command::RemoveFromMetatrack(track_id) => { + // Move to root level (None as parent) + self.project.move_to_root(track_id); + } + Command::SetTimeStretch(track_id, stretch) => { + if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) { + metatrack.time_stretch = stretch.max(0.01); // Prevent zero or negative stretch + } + } + Command::SetOffset(track_id, offset) => { + if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) { + metatrack.offset = Seconds(offset); + } + } + Command::SetPitchShift(track_id, semitones) => { + if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) { + metatrack.pitch_shift = semitones; + } + } + Command::SetTrimStart(track_id, trim_start) => { + if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) { + metatrack.trim_start = Seconds(trim_start.max(0.0)); + } + } + Command::SetTrimEnd(track_id, trim_end) => { + if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) { + metatrack.trim_end = trim_end.map(|t| Seconds(t.max(0.0))); + } + } + Command::CreateAudioTrack(name, parent_id) => { + let track_id = self.project.add_audio_track(name.clone(), parent_id); + // Notify UI about the new audio track + let _ = self.event_tx.push(AudioEvent::TrackCreated(track_id, false, name)); + } + Command::AddAudioFile(path, data, channels, sample_rate) => { + println!("🎵 [ENGINE] Received AddAudioFile command for: {}", path); + // Detect original format from file extension + let path_buf = std::path::PathBuf::from(path.clone()); + let original_format = path_buf.extension() + .and_then(|ext| ext.to_str()) + .map(|s| s.to_lowercase()); + + // Create AudioFile and add to pool + let audio_file = crate::audio::pool::AudioFile::with_format( + path_buf.clone(), + data.clone(), // Clone data for background thread + channels, + sample_rate, + original_format, + ); + let pool_index = self.audio_pool.add_file(audio_file); + println!("📦 [ENGINE] Added to pool at index {}", pool_index); + + // Generate Level 0 (overview) waveform chunks asynchronously in background thread + let chunk_tx = self.chunk_generation_tx.clone(); + let duration = data.len() as f64 / (sample_rate as f64 * channels as f64); + println!("🔄 [ENGINE] Spawning background thread to generate Level 0 chunks for pool {}", pool_index); + std::thread::spawn(move || { + // Create temporary AudioFile for chunk generation + let temp_audio_file = crate::audio::pool::AudioFile::with_format( + path_buf, + data, + channels, + sample_rate, + None, + ); + + // Generate Level 0 chunks + let chunk_count = crate::audio::waveform_cache::WaveformCache::calculate_chunk_count(duration, 0); + println!("🔄 [BACKGROUND] Generating {} Level 0 chunks for pool {}", chunk_count, pool_index); + let chunks = crate::audio::waveform_cache::WaveformCache::generate_chunks( + &temp_audio_file, + pool_index, + 0, // Level 0 (overview) + &(0..chunk_count).collect::>(), + ); + + // Send chunks via MPSC channel (will be forwarded by audio thread) + if !chunks.is_empty() { + println!("📤 [BACKGROUND] Generated {} chunks, sending to audio thread (pool {})", chunks.len(), pool_index); + let event_chunks: Vec<(u32, (f64, f64), Vec)> = chunks + .into_iter() + .map(|chunk| (chunk.chunk_index, chunk.time_range, chunk.peaks)) + .collect(); + + match chunk_tx.send(AudioEvent::WaveformChunksReady { + pool_index, + detail_level: 0, + chunks: event_chunks, + }) { + Ok(_) => println!("✅ [BACKGROUND] Chunks sent successfully for pool {}", pool_index), + Err(e) => eprintln!("❌ [BACKGROUND] Failed to send chunks: {}", e), + } + } else { + eprintln!("⚠️ [BACKGROUND] No chunks generated for pool {}", pool_index); + } + }); + + // Notify UI about the new audio file + let _ = self.event_tx.push(AudioEvent::AudioFileAdded(pool_index, path)); + } + Command::AddAudioClip(track_id, clip_id, pool_index, start_time, duration, offset) => { + // Create a new clip instance with the pre-assigned clip_id + // start_time and duration are in beats; offset (internal_start) is seconds + let start_beats = Beats(start_time); + let end_beats = Beats(start_time + duration); + let start_secs = self.tempo_map.beats_to_seconds(start_beats); + let end_secs = self.tempo_map.beats_to_seconds(end_beats); + let content_dur_secs = (end_secs - start_secs).seconds_to_f64(); + let clip = AudioClipInstance::new( + clip_id, + pool_index, + Seconds(offset), + Seconds(offset + content_dur_secs), + start_beats, + Beats(duration), + ); + + // Add clip to track + if let Some(crate::audio::track::TrackNode::Audio(track)) = self.project.get_track_mut(track_id) { + track.clips.push(clip); + let _ = self.event_tx.push(AudioEvent::ClipAdded(track_id, clip_id)); + } + self.refresh_clip_snapshot(); + } + Command::CreateMidiTrack(name, parent_id) => { + let track_id = self.project.add_midi_track(name.clone(), parent_id); + // Notify UI about the new MIDI track + let _ = self.event_tx.push(AudioEvent::TrackCreated(track_id, false, name)); + } + Command::AddMidiClipToPool(clip) => { + // Add the clip to the pool without placing it on any track + self.project.midi_clip_pool.add_existing_clip(clip); + } + Command::CreateMidiClip(track_id, start_time, duration) => { + // Get the next MIDI clip ID from the atomic counter + let clip_id = self.next_midi_clip_id_atomic.fetch_add(1, Ordering::Relaxed); + + // Create clip content in the pool + let clip = MidiClip::empty(clip_id, Beats(duration), format!("MIDI Clip {}", clip_id)); + self.project.midi_clip_pool.add_existing_clip(clip); + + // Create an instance for this clip on the track + let instance_id = self.project.next_midi_clip_instance_id(); + let instance = MidiClipInstance::from_full_clip(instance_id, clip_id, Beats(duration), Beats(start_time)); + + if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { + track.clip_instances.push(instance); + } + + // Notify UI about the new clip with its ID (using clip_id for now) + let _ = self.event_tx.push(AudioEvent::ClipAdded(track_id, clip_id)); + self.refresh_clip_snapshot(); + } + Command::AddMidiNote(track_id, clip_id, time_offset, note, velocity, duration) => { + // Add a MIDI note event to the specified clip in the pool + // Note: clip_id here refers to the clip in the pool, not the instance + if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(clip_id) { + // Timestamp is in beats (canonical) + let note_on = MidiEvent::note_on(Beats(time_offset), 0, note, velocity); + clip.add_event(note_on); + + // Add note off event + let note_off_time = Beats(time_offset + duration); + let note_off = MidiEvent::note_off(note_off_time, 0, note, 64); + clip.add_event(note_off); + } else { + // Try legacy behavior: look for instance on track and find its clip + if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { + if let Some(instance) = track.clip_instances.iter().find(|c| c.clip_id == clip_id) { + let actual_clip_id = instance.clip_id; + if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(actual_clip_id) { + let note_on = MidiEvent::note_on(Beats(time_offset), 0, note, velocity); + clip.add_event(note_on); + let note_off_time = Beats(time_offset + duration); + let note_off = MidiEvent::note_off(note_off_time, 0, note, 64); + clip.add_event(note_off); + } + } + } + } + } + Command::AddLoadedMidiClip(track_id, clip, start_time) => { + // Add a pre-loaded MIDI clip to the track with the given start time + if let Ok(_instance_id) = self.project.add_midi_clip_at(track_id, clip, crate::time::Beats(start_time)) { + // instance positions are already in beats; nothing to sync + } + self.refresh_clip_snapshot(); + } + Command::UpdateMidiClipNotes(_track_id, clip_id, notes) => { + // Update all notes in a MIDI clip (directly in the pool) + if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(clip_id) { + // Clear existing events + clip.events.clear(); + + // Add new events from the notes array + // Timestamps are in beats (canonical) + for (start_time, note, velocity, duration) in notes { + let note_on = MidiEvent::note_on(Beats(start_time), 0, note, velocity); + clip.events.push(note_on); + + // Add note off event + let note_off_time = Beats(start_time + duration); + let note_off = MidiEvent::note_off(note_off_time, 0, note, 64); + clip.events.push(note_off); + } + + // Sort events by timestamp (using partial_cmp for f64) + clip.events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap()); + } + } + Command::UpdateMidiClipEvents(_track_id, clip_id, events) => { + // Replace all events in a MIDI clip (used for CC/pitch bend editing) + if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(clip_id) { + clip.events = events; + clip.events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap()); + } + } + Command::RemoveMidiClip(track_id, instance_id) => { + // Remove a MIDI clip instance from a track (for undo/redo support) + let _ = self.project.remove_midi_clip(track_id, instance_id); + self.refresh_clip_snapshot(); + } + Command::RemoveAudioClip(track_id, instance_id) => { + // Deactivate the per-clip disk reader before removing + if let Some(ref mut dr) = self.disk_reader { + dr.send(crate::audio::disk_reader::DiskReaderCommand::DeactivateFile { + reader_id: instance_id as u64, + }); + } + // Remove an audio clip instance from a track (for undo/redo support) + let _ = self.project.remove_audio_clip(track_id, instance_id); + self.refresh_clip_snapshot(); + } + Command::RequestBufferPoolStats => { + // Send buffer pool statistics back to UI + let stats = self.buffer_pool.stats(); + let _ = self.event_tx.push(AudioEvent::BufferPoolStats(stats)); + } + Command::CreateAutomationLane(track_id, parameter_id) => { + // Create a new automation lane on the specified track + let lane_id = match self.project.get_track_mut(track_id) { + Some(crate::audio::track::TrackNode::Audio(track)) => { + Some(track.add_automation_lane(parameter_id)) + } + Some(crate::audio::track::TrackNode::Midi(track)) => { + Some(track.add_automation_lane(parameter_id)) + } + Some(crate::audio::track::TrackNode::Group(group)) => { + Some(group.add_automation_lane(parameter_id)) + } + None => None, + }; + + if let Some(lane_id) = lane_id { + let _ = self.event_tx.push(AudioEvent::AutomationLaneCreated( + track_id, + lane_id, + parameter_id, + )); + } + } + Command::AddAutomationPoint(track_id, lane_id, time, value, curve) => { + // Add an automation point to the specified lane + let point = crate::audio::AutomationPoint::new(Beats(time), value, curve); + + match self.project.get_track_mut(track_id) { + Some(crate::audio::track::TrackNode::Audio(track)) => { + if let Some(lane) = track.get_automation_lane_mut(lane_id) { + lane.add_point(point); + } + } + Some(crate::audio::track::TrackNode::Midi(track)) => { + if let Some(lane) = track.get_automation_lane_mut(lane_id) { + lane.add_point(point); + } + } + Some(crate::audio::track::TrackNode::Group(group)) => { + if let Some(lane) = group.get_automation_lane_mut(lane_id) { + lane.add_point(point); + } + } + None => {} + } + } + Command::RemoveAutomationPoint(track_id, lane_id, time, tolerance) => { + // Remove automation point at specified time + match self.project.get_track_mut(track_id) { + Some(crate::audio::track::TrackNode::Audio(track)) => { + if let Some(lane) = track.get_automation_lane_mut(lane_id) { + lane.remove_point_at_time(Beats(time), Beats(tolerance)); + } + } + Some(crate::audio::track::TrackNode::Midi(track)) => { + if let Some(lane) = track.get_automation_lane_mut(lane_id) { + lane.remove_point_at_time(Beats(time), Beats(tolerance)); + } + } + Some(crate::audio::track::TrackNode::Group(group)) => { + if let Some(lane) = group.get_automation_lane_mut(lane_id) { + lane.remove_point_at_time(Beats(time), Beats(tolerance)); + } + } + None => {} + } + } + Command::ClearAutomationLane(track_id, lane_id) => { + // Clear all points from the lane + match self.project.get_track_mut(track_id) { + Some(crate::audio::track::TrackNode::Audio(track)) => { + if let Some(lane) = track.get_automation_lane_mut(lane_id) { + lane.clear(); + } + } + Some(crate::audio::track::TrackNode::Midi(track)) => { + if let Some(lane) = track.get_automation_lane_mut(lane_id) { + lane.clear(); + } + } + Some(crate::audio::track::TrackNode::Group(group)) => { + if let Some(lane) = group.get_automation_lane_mut(lane_id) { + lane.clear(); + } + } + None => {} + } + } + Command::RemoveAutomationLane(track_id, lane_id) => { + // Remove the automation lane entirely + match self.project.get_track_mut(track_id) { + Some(crate::audio::track::TrackNode::Audio(track)) => { + track.remove_automation_lane(lane_id); + } + Some(crate::audio::track::TrackNode::Midi(track)) => { + track.remove_automation_lane(lane_id); + } + Some(crate::audio::track::TrackNode::Group(group)) => { + group.remove_automation_lane(lane_id); + } + None => {} + } + } + Command::SetAutomationLaneEnabled(track_id, lane_id, enabled) => { + // Enable/disable the automation lane + match self.project.get_track_mut(track_id) { + Some(crate::audio::track::TrackNode::Audio(track)) => { + if let Some(lane) = track.get_automation_lane_mut(lane_id) { + lane.enabled = enabled; + } + } + Some(crate::audio::track::TrackNode::Midi(track)) => { + if let Some(lane) = track.get_automation_lane_mut(lane_id) { + lane.enabled = enabled; + } + } + Some(crate::audio::track::TrackNode::Group(group)) => { + if let Some(lane) = group.get_automation_lane_mut(lane_id) { + lane.enabled = enabled; + } + } + None => {} + } + } + Command::StartRecording(track_id, start_time) => { + // Start recording on the specified track + self.handle_start_recording(track_id, start_time); + } + Command::StopRecording => { + // Stop the current recording + self.handle_stop_recording(); + } + Command::PauseRecording => { + // Pause the current recording + if let Some(recording) = &mut self.recording_state { + recording.pause(); + } + } + Command::ResumeRecording => { + // Resume the current recording + if let Some(recording) = &mut self.recording_state { + recording.resume(); + } + } + Command::StartMidiRecording(track_id, clip_id, start_time) => { + // Start MIDI recording on the specified track + self.handle_start_midi_recording(track_id, clip_id, start_time); + } + Command::StopMidiRecording => { + eprintln!("[ENGINE] Received StopMidiRecording command"); + // Stop the current MIDI recording + self.handle_stop_midi_recording(); + eprintln!("[ENGINE] handle_stop_midi_recording() completed"); + } + Command::Reset => { + // Reset the entire project to initial state + // Stop playback + self.playing = false; + self.playhead = 0; + self.playhead_atomic.store(0, Ordering::Relaxed); + + // Stop any active recording + self.recording_state = None; + + // Clear all project data + self.project = Project::new(self.sample_rate); + + // Clear audio pool + self.audio_pool = AudioClipPool::new(); + + // Reset buffer pool (recreate with same settings) + let buffer_size = 512 * self.channels as usize; + self.buffer_pool = BufferPool::new(8, buffer_size); + + // Reset ID counters + self.next_midi_clip_id_atomic.store(0, Ordering::Relaxed); + self.next_audio_clip_id_atomic.store(0, Ordering::Relaxed); + + // Clear mix buffer + self.mix_buffer.clear(); + + // Notify UI that reset is complete + let _ = self.event_tx.push(AudioEvent::ProjectReset); + } + + Command::SendMidiNoteOn(track_id, note, velocity) => { + // Send a live MIDI note on event to the specified track's instrument + self.project.send_midi_note_on(track_id, note, velocity); + + // Emit event to UI for visual feedback + let _ = self.event_tx.push(AudioEvent::NoteOn(note, velocity)); + + // Track held notes so count-in recording can inject them at start_time + self.midi_held_notes.entry(track_id).or_default().insert(note, velocity); + + // If MIDI recording is active on this track, capture the event + if let Some(recording) = &mut self.midi_recording_state { + if recording.track_id == track_id { + let absolute_time = self.tempo_map.seconds_to_beats(Seconds(self.playhead as f64 / self.sample_rate as f64)); + eprintln!("[MIDI_RECORDING] NoteOn captured: note={}, velocity={}, absolute_time={:.3} beats, playhead={}, sample_rate={}", + note, velocity, absolute_time.0, self.playhead, self.sample_rate); + recording.note_on(note, velocity, absolute_time); + } + } + } + + Command::SendMidiNoteOff(track_id, note) => { + // Send a live MIDI note off event to the specified track's instrument + self.project.send_midi_note_off(track_id, note); + + // Emit event to UI for visual feedback + let _ = self.event_tx.push(AudioEvent::NoteOff(note)); + + // Remove from held notes tracking + if let Some(track_notes) = self.midi_held_notes.get_mut(&track_id) { + track_notes.remove(¬e); + } + + // If MIDI recording is active on this track, capture the event + if let Some(recording) = &mut self.midi_recording_state { + if recording.track_id == track_id { + let absolute_time = self.tempo_map.seconds_to_beats(Seconds(self.playhead as f64 / self.sample_rate as f64)); + eprintln!("[MIDI_RECORDING] NoteOff captured: note={}, absolute_time={:.3} beats, playhead={}, sample_rate={}", + note, absolute_time.0, self.playhead, self.sample_rate); + recording.note_off(note, absolute_time); + } + } + } + + Command::SetActiveMidiTrack(track_id) => { + // Update the active MIDI track for external MIDI input routing + if let Some(ref midi_manager) = self.midi_input_manager { + midi_manager.set_active_track(track_id); + } + } + + Command::SetMetronomeEnabled(enabled) => { + self.metronome.set_enabled(enabled); + } + + Command::SetInputMonitoring(enabled) => { + self.input_monitoring = enabled; + } + + Command::SetInputGain(gain) => { + self.input_gain = gain; + } + + Command::SetTempo(bpm, time_sig) => { + self.time_sig = time_sig; + self.metronome.update_timing(bpm, time_sig); + self.project.set_tempo(bpm, time_sig.0); + self.tempo_map.set_global_bpm(bpm as f64); + } + + Command::SetTempoMap(map) => { + let bpm0 = map.global_bpm() as f32; + self.metronome.update_timing(bpm0, self.time_sig); + self.project.set_tempo(bpm0, self.time_sig.0); + self.tempo_map = map; + } + + // Node graph commands + Command::GraphAddNode(track_id, node_type, x, y) => { + eprintln!("[DEBUG] GraphAddNode received: track_id={}, node_type={}, x={}, y={}", track_id, node_type, x, y); + + // Get the track's graph (works for both MIDI and Audio tracks) + let graph = match self.project.get_track_mut(track_id) { + Some(TrackNode::Midi(track)) => { + eprintln!("[DEBUG] Found MIDI track, using instrument_graph"); + Some(&mut track.instrument_graph) + }, + Some(TrackNode::Audio(track)) => { + eprintln!("[DEBUG] Found Audio track, using effects_graph"); + Some(&mut track.effects_graph) + }, + Some(TrackNode::Group(track)) => { + Some(&mut track.audio_graph) + }, + _ => { + eprintln!("[DEBUG] Track not found or invalid type!"); + None + } + }; + + if let Some(graph) = graph { + // Create the node based on type + let node = match crate::audio::node_graph::nodes::create_node(&node_type, self.sample_rate, 8192) { + Some(n) => n, + None => { + let _ = self.event_tx.push(AudioEvent::GraphConnectionError( + track_id, + format!("Unknown node type: {}", node_type) + )); + return; + } + }; + + // Add node to graph + let node_idx = graph.add_node(node); + let node_id = node_idx.index() as u32; + eprintln!("[DEBUG] Node added with index: {:?}, converted to u32 id: {}", node_idx, node_id); + + // Save position + graph.set_node_position(node_idx, x, y); + + // Automatically set MIDI source nodes as MIDI targets + // VoiceAllocator receives MIDI through its input port via connections, + // not directly — it needs a MidiInput node connected to its MIDI In + if node_type == "MidiInput" { + graph.set_midi_target(node_idx, true); + } + + // Automatically set AudioOutput nodes as the graph output + if node_type == "AudioOutput" { + graph.set_output_node(Some(node_idx)); + } + + eprintln!("[DEBUG] Emitting GraphNodeAdded event: track_id={}, node_id={}, node_type={}", track_id, node_id, node_type); + // Emit success event + let _ = self.event_tx.push(AudioEvent::GraphNodeAdded(track_id, node_id, node_type.clone())); + self.set_track_graph_is_default(track_id, false); + } else { + eprintln!("[DEBUG] Graph was None, node not added!"); + } + } + + Command::GraphAddNodeToTemplate(track_id, voice_allocator_id, node_type, x, y) => { + if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { + let graph = &mut track.instrument_graph; + { + let va_idx = NodeIndex::new(voice_allocator_id as usize); + + // Create the node + let node = match crate::audio::node_graph::nodes::create_node(&node_type, self.sample_rate, 8192) { + Some(n) => n, + None => { + let _ = self.event_tx.push(AudioEvent::GraphConnectionError( + track_id, + format!("Unknown node type: {}", node_type) + )); + return; + } + }; + + // Add node to VoiceAllocator's template graph + match graph.add_node_to_voice_allocator_template(va_idx, node) { + Ok(node_id) => { + // Set node position in the template graph + graph.set_position_in_voice_allocator_template(va_idx, node_id, x, y); + println!("Added node {} (ID: {}) to VoiceAllocator {} template at ({}, {})", node_type, node_id, voice_allocator_id, x, y); + let _ = self.event_tx.push(AudioEvent::GraphNodeAdded(track_id, node_id, node_type.clone())); + } + Err(e) => { + let _ = self.event_tx.push(AudioEvent::GraphConnectionError( + track_id, + format!("Failed to add node to template: {}", e) + )); + } + } + } + } + } + + Command::GraphRemoveNode(track_id, node_index) => { + let graph = match self.project.get_track_mut(track_id) { + Some(TrackNode::Midi(track)) => Some(&mut track.instrument_graph), + Some(TrackNode::Audio(track)) => Some(&mut track.effects_graph), + Some(TrackNode::Group(track)) => Some(&mut track.audio_graph), + _ => None, + }; + if let Some(graph) = graph { + let node_idx = NodeIndex::new(node_index as usize); + graph.remove_node(node_idx); + let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id)); + } + self.set_track_graph_is_default(track_id, false); + } + + Command::GraphConnect(track_id, from, from_port, to, to_port) => { + eprintln!("[DEBUG] GraphConnect received: track_id={}, from={}, from_port={}, to={}, to_port={}", track_id, from, from_port, to, to_port); + + let graph = match self.project.get_track_mut(track_id) { + Some(TrackNode::Midi(track)) => { + eprintln!("[DEBUG] Found MIDI track for connection"); + Some(&mut track.instrument_graph) + }, + Some(TrackNode::Audio(track)) => { + eprintln!("[DEBUG] Found Audio track for connection"); + Some(&mut track.effects_graph) + }, + Some(TrackNode::Group(track)) => { + Some(&mut track.audio_graph) + }, + _ => { + eprintln!("[DEBUG] Track not found for connection!"); + None + } + }; + if let Some(graph) = graph { + let from_idx = NodeIndex::new(from as usize); + let to_idx = NodeIndex::new(to as usize); + eprintln!("[DEBUG] Attempting to connect nodes: {:?} port {} -> {:?} port {}", from_idx, from_port, to_idx, to_port); + + match graph.connect(from_idx, from_port, to_idx, to_port) { + Ok(()) => { + eprintln!("[DEBUG] Connection successful!"); + let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id)); + self.set_track_graph_is_default(track_id, false); + } + Err(e) => { + eprintln!("[DEBUG] Connection failed: {:?}", e); + let _ = self.event_tx.push(AudioEvent::GraphConnectionError( + track_id, + format!("{:?}", e) + )); + } + } + } else { + eprintln!("[DEBUG] No graph found, connection not made"); + } + } + + Command::GraphConnectInTemplate(track_id, voice_allocator_id, from, from_port, to, to_port) => { + if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { + let graph = &mut track.instrument_graph; + { + let va_idx = NodeIndex::new(voice_allocator_id as usize); + + match graph.connect_in_voice_allocator_template(va_idx, from, from_port, to, to_port) { + Ok(()) => { + println!("Connected nodes in VoiceAllocator {} template: {} -> {}", voice_allocator_id, from, to); + let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id)); + } + Err(e) => { + let _ = self.event_tx.push(AudioEvent::GraphConnectionError( + track_id, + format!("Failed to connect in template: {}", e) + )); + } + } + } + } + } + + Command::GraphDisconnectInTemplate(track_id, voice_allocator_id, from, from_port, to, to_port) => { + if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { + let graph = &mut track.instrument_graph; + let va_idx = NodeIndex::new(voice_allocator_id as usize); + + match graph.disconnect_in_voice_allocator_template(va_idx, from, from_port, to, to_port) { + Ok(()) => { + let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id)); + } + Err(e) => { + let _ = self.event_tx.push(AudioEvent::GraphConnectionError( + track_id, + format!("Failed to disconnect in template: {}", e) + )); + } + } + } + } + + Command::GraphRemoveNodeFromTemplate(track_id, voice_allocator_id, node_index) => { + if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { + let graph = &mut track.instrument_graph; + let va_idx = NodeIndex::new(voice_allocator_id as usize); + + match graph.remove_node_from_voice_allocator_template(va_idx, node_index) { + Ok(()) => { + let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id)); + } + Err(e) => { + let _ = self.event_tx.push(AudioEvent::GraphConnectionError( + track_id, + format!("Failed to remove node from template: {}", e) + )); + } + } + } + } + + Command::GraphSetParameterInTemplate(track_id, voice_allocator_id, node_index, param_id, value) => { + if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { + let graph = &mut track.instrument_graph; + let va_idx = NodeIndex::new(voice_allocator_id as usize); + + if let Err(e) = graph.set_parameter_in_voice_allocator_template(va_idx, node_index, param_id, value) { + let _ = self.event_tx.push(AudioEvent::GraphConnectionError( + track_id, + format!("Failed to set parameter in template: {}", e) + )); + } + } + } + + Command::GraphDisconnect(track_id, from, from_port, to, to_port) => { + eprintln!("[AUDIO ENGINE] GraphDisconnect: track={}, from={}, from_port={}, to={}, to_port={}", track_id, from, from_port, to, to_port); + let graph = match self.project.get_track_mut(track_id) { + Some(TrackNode::Midi(track)) => Some(&mut track.instrument_graph), + Some(TrackNode::Audio(track)) => { + eprintln!("[AUDIO ENGINE] Found audio track, disconnecting in effects_graph"); + Some(&mut track.effects_graph) + } + Some(TrackNode::Group(track)) => Some(&mut track.audio_graph), + _ => { + eprintln!("[AUDIO ENGINE] Track not found!"); + None + } + }; + if let Some(graph) = graph { + let from_idx = NodeIndex::new(from as usize); + let to_idx = NodeIndex::new(to as usize); + graph.disconnect(from_idx, from_port, to_idx, to_port); + eprintln!("[AUDIO ENGINE] Disconnect completed"); + let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id)); + } + self.set_track_graph_is_default(track_id, false); + } + + Command::GraphSetParameter(track_id, node_index, param_id, value) => { + let graph = match self.project.get_track_mut(track_id) { + Some(TrackNode::Midi(track)) => Some(&mut track.instrument_graph), + Some(TrackNode::Audio(track)) => Some(&mut track.effects_graph), + Some(TrackNode::Group(track)) => Some(&mut track.audio_graph), + _ => None, + }; + if let Some(graph) = graph { + let node_idx = NodeIndex::new(node_index as usize); + if let Some(graph_node) = graph.get_graph_node_mut(node_idx) { + graph_node.node.set_parameter(param_id, value); + } + } + self.set_track_graph_is_default(track_id, false); + } + + Command::GraphSetNodePosition(track_id, node_index, x, y) => { + let graph = match self.project.get_track_mut(track_id) { + Some(TrackNode::Midi(track)) => Some(&mut track.instrument_graph), + Some(TrackNode::Audio(track)) => Some(&mut track.effects_graph), + Some(TrackNode::Group(track)) => Some(&mut track.audio_graph), + _ => None, + }; + if let Some(graph) = graph { + let node_idx = NodeIndex::new(node_index as usize); + graph.set_node_position(node_idx, x, y); + } + } + + Command::GraphSetNodePositionInTemplate(track_id, voice_allocator_id, node_index, x, y) => { + if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { + let graph = &mut track.instrument_graph; + let va_idx = NodeIndex::new(voice_allocator_id as usize); + graph.set_position_in_voice_allocator_template(va_idx, node_index, x, y); + } + } + + Command::GraphSetMidiTarget(track_id, node_index, enabled) => { + if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { + let graph = &mut track.instrument_graph; + { + let node_idx = NodeIndex::new(node_index as usize); + graph.set_midi_target(node_idx, enabled); + } + } + } + + Command::GraphSetOutputNode(track_id, node_index) => { + let graph = match self.project.get_track_mut(track_id) { + Some(TrackNode::Midi(track)) => Some(&mut track.instrument_graph), + Some(TrackNode::Audio(track)) => Some(&mut track.effects_graph), + Some(TrackNode::Group(track)) => Some(&mut track.audio_graph), + _ => None, + }; + if let Some(graph) = graph { + let node_idx = NodeIndex::new(node_index as usize); + graph.set_output_node(Some(node_idx)); + } + } + + Command::GraphSetGroups(track_id, groups) => { + let graph = match self.project.get_track_mut(track_id) { + Some(TrackNode::Midi(track)) => Some(&mut track.instrument_graph), + Some(TrackNode::Audio(track)) => Some(&mut track.effects_graph), + Some(TrackNode::Group(track)) => Some(&mut track.audio_graph), + _ => None, + }; + if let Some(graph) = graph { + graph.set_frontend_groups(groups); + } + } + + Command::GraphSetGroupsInTemplate(track_id, voice_allocator_id, groups) => { + use crate::audio::node_graph::nodes::VoiceAllocatorNode; + let graph = match self.project.get_track_mut(track_id) { + Some(TrackNode::Midi(track)) => Some(&mut track.instrument_graph), + Some(TrackNode::Audio(track)) => Some(&mut track.effects_graph), + _ => None, + }; + if let Some(graph) = graph { + let node_idx = NodeIndex::new(voice_allocator_id as usize); + if let Some(graph_node) = graph.get_node_mut(node_idx) { + if let Some(va_node) = graph_node.as_any_mut().downcast_mut::() { + va_node.template_graph_mut().set_frontend_groups(groups); + } + } + } + } + + Command::GraphSavePreset(track_id, preset_path, preset_name, description, tags) => { + let graph = match self.project.get_track(track_id) { + Some(TrackNode::Midi(track)) => Some(&track.instrument_graph), + Some(TrackNode::Audio(track)) => Some(&track.effects_graph), + Some(TrackNode::Group(track)) => Some(&track.audio_graph), + _ => None, + }; + if let Some(graph) = graph { + // Serialize the graph to a preset + let mut preset = graph.to_preset(&preset_name); + preset.metadata.description = description; + preset.metadata.tags = tags; + preset.metadata.author = String::from("User"); + + // Write to file + if let Ok(json) = preset.to_json() { + match std::fs::write(&preset_path, json) { + Ok(_) => { + // Emit success event with path + let _ = self.event_tx.push(AudioEvent::GraphPresetSaved( + track_id, + preset_path.clone() + )); + } + Err(e) => { + let _ = self.event_tx.push(AudioEvent::GraphConnectionError( + track_id, + format!("Failed to save preset: {}", e) + )); + } + } + } else { + let _ = self.event_tx.push(AudioEvent::GraphConnectionError( + track_id, + "Failed to serialize preset".to_string() + )); + } + } + } + + Command::GraphLoadPreset(track_id, preset_path) => { + // Read and deserialize the preset + match std::fs::read_to_string(&preset_path) { + Ok(json) => { + match crate::audio::node_graph::preset::GraphPreset::from_json(&json) { + Ok(preset) => { + let preset_name = preset.metadata.name.clone(); + // Extract the directory path from the preset path for resolving relative sample paths + let preset_base_path = std::path::Path::new(&preset_path).parent(); + + match AudioGraph::from_preset(&preset, self.sample_rate, 8192, preset_base_path, None) { + Ok(graph) => { + // Replace the track's graph + match self.project.get_track_mut(track_id) { + Some(TrackNode::Midi(track)) => { + track.instrument_graph = graph; + track.graph_is_default = true; + let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id)); + let _ = self.event_tx.push(AudioEvent::GraphPresetLoaded(track_id, preset_name)); + } + Some(TrackNode::Audio(track)) => { + track.effects_graph = graph; + track.graph_is_default = true; + let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id)); + let _ = self.event_tx.push(AudioEvent::GraphPresetLoaded(track_id, preset_name)); + } + Some(TrackNode::Group(track)) => { + track.audio_graph = graph; + track.graph_is_default = true; + let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id)); + let _ = self.event_tx.push(AudioEvent::GraphPresetLoaded(track_id, preset_name)); + } + _ => {} + } + } + Err(e) => { + let _ = self.event_tx.push(AudioEvent::GraphConnectionError( + track_id, + format!("Failed to create graph from preset: {}", e) + )); + } + } + } + Err(e) => { + let _ = self.event_tx.push(AudioEvent::GraphConnectionError( + track_id, + format!("Failed to parse preset: {}", e) + )); + } + } + } + Err(e) => { + let _ = self.event_tx.push(AudioEvent::GraphConnectionError( + track_id, + format!("Failed to read preset file: {}", e) + )); + } + } + } + + Command::GraphLoadLbins(track_id, path) => { + match crate::audio::node_graph::lbins::load_lbins(&path) { + Ok((preset, assets)) => { + let preset_name = preset.metadata.name.clone(); + match AudioGraph::from_preset(&preset, self.sample_rate, 8192, None, Some(&assets)) { + Ok(graph) => { + match self.project.get_track_mut(track_id) { + Some(TrackNode::Midi(track)) => { + track.instrument_graph = graph; + track.graph_is_default = true; + let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id)); + let _ = self.event_tx.push(AudioEvent::GraphPresetLoaded(track_id, preset_name)); + } + Some(TrackNode::Audio(track)) => { + track.effects_graph = graph; + track.graph_is_default = true; + let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id)); + let _ = self.event_tx.push(AudioEvent::GraphPresetLoaded(track_id, preset_name)); + } + Some(TrackNode::Group(track)) => { + track.audio_graph = graph; + track.graph_is_default = true; + let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id)); + let _ = self.event_tx.push(AudioEvent::GraphPresetLoaded(track_id, preset_name)); + } + _ => {} + } + } + Err(e) => { + let _ = self.event_tx.push(AudioEvent::GraphConnectionError( + track_id, + format!("Failed to load .lbins graph: {}", e), + )); + } + } + } + Err(e) => { + let _ = self.event_tx.push(AudioEvent::GraphConnectionError( + track_id, + format!("Failed to open .lbins file: {}", e), + )); + } + } + } + + Command::GraphSaveLbins(track_id, path, preset_name, description, tags) => { + let graph = match self.project.get_track(track_id) { + Some(TrackNode::Midi(track)) => Some(&track.instrument_graph), + Some(TrackNode::Audio(track)) => Some(&track.effects_graph), + Some(TrackNode::Group(track)) => Some(&track.audio_graph), + _ => None, + }; + if let Some(graph) = graph { + let mut preset = graph.to_preset(&preset_name); + preset.metadata.description = description; + preset.metadata.tags = tags; + preset.metadata.author = String::from("User"); + + match crate::audio::node_graph::lbins::save_lbins(&path, &preset, None) { + Ok(()) => { + let _ = self.event_tx.push(AudioEvent::GraphPresetSaved( + track_id, + path.to_string_lossy().to_string(), + )); + } + Err(e) => { + let _ = self.event_tx.push(AudioEvent::GraphConnectionError( + track_id, + format!("Failed to save .lbins: {}", e), + )); + } + } + } + } + + Command::GraphSaveTemplatePreset(track_id, voice_allocator_id, preset_path, preset_name) => { + use crate::audio::node_graph::nodes::VoiceAllocatorNode; + + if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { + let graph = &track.instrument_graph; + let va_idx = NodeIndex::new(voice_allocator_id as usize); + + // Get the VoiceAllocator node and serialize its template + if let Some(node) = graph.get_node(va_idx) { + // Downcast to VoiceAllocatorNode using safe Any trait + if let Some(va_node) = node.as_any().downcast_ref::() { + let template_preset = va_node.template_graph().to_preset(&preset_name); + + // Write to file + if let Ok(json) = template_preset.to_json() { + if let Err(e) = std::fs::write(&preset_path, json) { + eprintln!("Failed to save template preset: {}", e); + } + } + } + } + } + } + + Command::SetMetatrackSubtrackGraph(track_id, subtracks) => { + let buffer_size = self.buffer_pool.buffer_size(); + if let Some(TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) { + let current = metatrack.current_subtracks(); + let has_subtrack_node = metatrack.audio_graph.node_indices().any(|idx| { + metatrack.audio_graph.get_graph_node(idx) + .map(|n| n.node.node_type() == "SubtrackInputs") + .unwrap_or(false) + }); + + // No-op if subtrack list is unchanged AND the graph is already initialised. + // Without the `has_subtrack_node` guard a freshly-created metatrack + // (empty graph, no SubtrackInputs) with zero subtracks would never get + // its default Volume automation node. + if current == subtracks && has_subtrack_node { + return; + } + + if metatrack.graph_is_default { + // Default graph: full rebuild with new subtrack layout + metatrack.set_subtrack_graph(subtracks.clone(), self.sample_rate, buffer_size); + } else { + // User-modified graph: incremental port changes only + let current_ids: std::collections::HashSet = + current.iter().map(|&(id, _)| id).collect(); + let new_ids: std::collections::HashSet = + subtracks.iter().map(|&(id, _)| id).collect(); + for (id, name) in &subtracks { + if !current_ids.contains(id) { + metatrack.add_subtrack_to_graph(*id, name.clone(), buffer_size); + } + } + for &(id, _) in ¤t { + if !new_ids.contains(&id) { + metatrack.remove_subtrack_from_graph(id, buffer_size); + } + } + } + // Sync the group's children list so they render through the mixer graph. + // `move_to_group` removes each child from root_tracks (or another parent) + // and registers it under this group — idempotent if already there. + let new_child_ids: Vec = subtracks.iter().map(|&(id, _)| id).collect(); + for &child_id in &new_child_ids { + // Only move if not already a child of this group + let already_child = self.project.get_track(track_id) + .and_then(|t| if let TrackNode::Group(g) = t { Some(g) } else { None }) + .map(|g| g.children.contains(&child_id)) + .unwrap_or(false); + if !already_child { + self.project.move_to_group(child_id, track_id); + } + } + + let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id)); + } + } + + Command::AddMetatrackSubtrack(track_id, subtrack_id, name) => { + let buffer_size = self.buffer_pool.buffer_size(); + if let Some(TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) { + metatrack.add_subtrack_to_graph(subtrack_id, name, buffer_size); + let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id)); + } + } + + Command::RemoveMetatrackSubtrack(track_id, subtrack_id) => { + let buffer_size = self.buffer_pool.buffer_size(); + if let Some(TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) { + metatrack.remove_subtrack_from_graph(subtrack_id, buffer_size); + let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id)); + } + } + + Command::UpdateMetatrackSubtrackIds(track_id, subtracks) => { + let buffer_size = self.buffer_pool.buffer_size(); + if let Some(TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) { + metatrack.update_subtrack_ids(subtracks, buffer_size); + } + } + + Command::SetGraphIsDefault(track_id, value) => { + self.set_track_graph_is_default(track_id, value); + } + + Command::GraphSetScript(track_id, node_id, source) => { + use crate::audio::node_graph::nodes::ScriptNode; + + if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { + let graph = &mut track.instrument_graph; + let node_idx = NodeIndex::new(node_id as usize); + + if let Some(graph_node) = graph.get_graph_node_mut(node_idx) { + if let Some(script_node) = graph_node.node.as_any_mut().downcast_mut::() { + match script_node.set_script(&source) { + Ok(ui_decl) => { + // Send compile success event back to frontend + let _ = self.event_tx.push(AudioEvent::ScriptCompiled { + track_id, + node_id, + success: true, + error: None, + ui_declaration: Some(ui_decl), + source: source.clone(), + }); + } + Err(e) => { + let _ = self.event_tx.push(AudioEvent::ScriptCompiled { + track_id, + node_id, + success: false, + error: Some(e), + ui_declaration: None, + source, + }); + } + } + } + } + } + } + + Command::GraphSetScriptSample(track_id, node_id, slot_index, data, sample_rate, name) => { + use crate::audio::node_graph::nodes::ScriptNode; + + if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { + let graph = &mut track.instrument_graph; + let node_idx = NodeIndex::new(node_id as usize); + + if let Some(graph_node) = graph.get_graph_node_mut(node_idx) { + if let Some(script_node) = graph_node.node.as_any_mut().downcast_mut::() { + script_node.set_sample(slot_index, data, sample_rate, name); + } + } + } + } + + Command::AmpSimLoadModel(track_id, node_id, model_path) => { + use crate::audio::node_graph::nodes::AmpSimNode; + + eprintln!("[AmpSim] Loading model: {:?} for track {:?} node {}", model_path, track_id, node_id); + let graph = match self.project.get_track_mut(track_id) { + Some(TrackNode::Midi(track)) => Some(&mut track.instrument_graph), + Some(TrackNode::Audio(track)) => Some(&mut track.effects_graph), + _ => None, + }; + if let Some(graph) = graph { + let node_idx = NodeIndex::new(node_id as usize); + if let Some(graph_node) = graph.get_graph_node_mut(node_idx) { + if let Some(amp_sim) = graph_node.node.as_any_mut().downcast_mut::() { + let result = if let Some(bundled_name) = model_path.strip_prefix("bundled:") { + eprintln!("[AmpSim] Loading bundled model: {}", bundled_name); + amp_sim.load_bundled_model(bundled_name) + } else { + eprintln!("[AmpSim] Loading model from file: {}", model_path); + amp_sim.load_model(&model_path) + }; + match &result { + Ok(()) => eprintln!("[AmpSim] Model loaded successfully"), + Err(e) => eprintln!("[AmpSim] Failed to load NAM model: {}", e), + } + } + } + } + } + + Command::SamplerLoadSample(track_id, node_id, file_path) => { + use crate::audio::node_graph::nodes::SimpleSamplerNode; + + if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { + let graph = &mut track.instrument_graph; + let node_idx = NodeIndex::new(node_id as usize); + + if let Some(graph_node) = graph.get_graph_node_mut(node_idx) { + // Downcast to SimpleSamplerNode using safe Any trait + if let Some(sampler_node) = graph_node.node.as_any_mut().downcast_mut::() { + if let Err(e) = sampler_node.load_sample_from_file(&file_path) { + eprintln!("Failed to load sample: {}", e); + } + } + } + } + } + + Command::SamplerLoadFromPool(track_id, node_id, pool_index) => { + use crate::audio::node_graph::nodes::SimpleSamplerNode; + + let sample_result = Self::read_mono_from_pool(&self.audio_pool, pool_index); + + if let Some((mono_samples, sample_rate)) = sample_result { + if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { + let graph = &mut track.instrument_graph; + let node_idx = NodeIndex::new(node_id as usize); + if let Some(graph_node) = graph.get_graph_node_mut(node_idx) { + if let Some(sampler_node) = graph_node.node.as_any_mut().downcast_mut::() { + sampler_node.set_sample(mono_samples, sample_rate); + } + } + } + } + } + + Command::SamplerSetRootNote(track_id, node_id, root_note) => { + use crate::audio::node_graph::nodes::SimpleSamplerNode; + + if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { + let graph = &mut track.instrument_graph; + let node_idx = NodeIndex::new(node_id as usize); + if let Some(graph_node) = graph.get_graph_node_mut(node_idx) { + if let Some(sampler_node) = graph_node.node.as_any_mut().downcast_mut::() { + sampler_node.set_root_note(root_note); + } + } + } + } + + Command::MultiSamplerAddLayer(track_id, node_id, file_path, key_min, key_max, root_key, velocity_min, velocity_max, loop_start, loop_end, loop_mode) => { + use crate::audio::node_graph::nodes::MultiSamplerNode; + + if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { + let graph = &mut track.instrument_graph; + let node_idx = NodeIndex::new(node_id as usize); + + if let Some(graph_node) = graph.get_graph_node_mut(node_idx) { + // Downcast to MultiSamplerNode using safe Any trait + if let Some(multi_sampler_node) = graph_node.node.as_any_mut().downcast_mut::() { + if let Err(e) = multi_sampler_node.load_layer_from_file(&file_path, key_min, key_max, root_key, velocity_min, velocity_max, loop_start, loop_end, loop_mode) { + eprintln!("Failed to add sample layer: {}", e); + } + } + } + } + } + + Command::MultiSamplerAddLayerFromPool(track_id, node_id, pool_index, key_min, key_max, root_key) => { + use crate::audio::node_graph::nodes::MultiSamplerNode; + use crate::audio::node_graph::nodes::LoopMode; + + let sample_result = Self::read_mono_from_pool(&self.audio_pool, pool_index); + + if let Some((mono_samples, sample_rate)) = sample_result { + if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { + let graph = &mut track.instrument_graph; + let node_idx = NodeIndex::new(node_id as usize); + if let Some(graph_node) = graph.get_graph_node_mut(node_idx) { + if let Some(multi_node) = graph_node.node.as_any_mut().downcast_mut::() { + multi_node.add_layer( + mono_samples, sample_rate, + key_min, key_max, root_key, + 0, 127, None, None, LoopMode::OneShot, + ); + } + } + } + } + } + + Command::MultiSamplerUpdateLayer(track_id, node_id, layer_index, key_min, key_max, root_key, velocity_min, velocity_max, loop_start, loop_end, loop_mode) => { + use crate::audio::node_graph::nodes::MultiSamplerNode; + + if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { + let graph = &mut track.instrument_graph; + let node_idx = NodeIndex::new(node_id as usize); + + if let Some(graph_node) = graph.get_graph_node_mut(node_idx) { + // Downcast to MultiSamplerNode using safe Any trait + if let Some(multi_sampler_node) = graph_node.node.as_any_mut().downcast_mut::() { + if let Err(e) = multi_sampler_node.update_layer(layer_index, key_min, key_max, root_key, velocity_min, velocity_max, loop_start, loop_end, loop_mode) { + eprintln!("Failed to update sample layer: {}", e); + } + } + } + } + } + + Command::MultiSamplerRemoveLayer(track_id, node_id, layer_index) => { + use crate::audio::node_graph::nodes::MultiSamplerNode; + + if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { + let graph = &mut track.instrument_graph; + let node_idx = NodeIndex::new(node_id as usize); + + if let Some(graph_node) = graph.get_graph_node_mut(node_idx) { + // Downcast to MultiSamplerNode using safe Any trait + if let Some(multi_sampler_node) = graph_node.node.as_any_mut().downcast_mut::() { + if let Err(e) = multi_sampler_node.remove_layer(layer_index) { + eprintln!("Failed to remove sample layer: {}", e); + } + } + } + } + } + + Command::MultiSamplerClearLayers(track_id, node_id) => { + use crate::audio::node_graph::nodes::MultiSamplerNode; + + if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { + let graph = &mut track.instrument_graph; + let node_idx = NodeIndex::new(node_id as usize); + + if let Some(graph_node) = graph.get_graph_node_mut(node_idx) { + if let Some(multi_sampler_node) = graph_node.node.as_any_mut().downcast_mut::() { + multi_sampler_node.clear_layers(); + } + } + } + } + + Command::AutomationAddKeyframe(track_id, node_id, time, value, interpolation_str, ease_out, ease_in) => { + use crate::audio::node_graph::nodes::{AutomationInputNode, AutomationKeyframe, InterpolationType}; + + // Parse interpolation type + let interpolation = match interpolation_str.to_lowercase().as_str() { + "linear" => InterpolationType::Linear, + "bezier" => InterpolationType::Bezier, + "step" => InterpolationType::Step, + "hold" => InterpolationType::Hold, + _ => { + eprintln!("Unknown interpolation type: {}, defaulting to Linear", interpolation_str); + InterpolationType::Linear + } + }; + + let graph = match self.project.get_track_mut(track_id) { + Some(TrackNode::Midi(t)) => Some(&mut t.instrument_graph), + Some(TrackNode::Audio(t)) => Some(&mut t.effects_graph), + Some(TrackNode::Group(t)) => Some(&mut t.audio_graph), + _ => None, + }; + if let Some(graph) = graph { + let node_idx = NodeIndex::new(node_id as usize); + + if let Some(graph_node) = graph.get_graph_node_mut(node_idx) { + // Downcast to AutomationInputNode using as_any_mut + if let Some(auto_node) = graph_node.node.as_any_mut().downcast_mut::() { + let keyframe = AutomationKeyframe { + time: Beats(time), + value, + interpolation, + ease_out, + ease_in, + }; + auto_node.add_keyframe(keyframe); + } else { + eprintln!("Node {} is not an AutomationInputNode", node_id); + } + } + } + } + + Command::AutomationRemoveKeyframe(track_id, node_id, time) => { + use crate::audio::node_graph::nodes::AutomationInputNode; + + let graph = match self.project.get_track_mut(track_id) { + Some(TrackNode::Midi(t)) => Some(&mut t.instrument_graph), + Some(TrackNode::Audio(t)) => Some(&mut t.effects_graph), + Some(TrackNode::Group(t)) => Some(&mut t.audio_graph), + _ => None, + }; + if let Some(graph) = graph { + let node_idx = NodeIndex::new(node_id as usize); + + if let Some(graph_node) = graph.get_graph_node_mut(node_idx) { + if let Some(auto_node) = graph_node.node.as_any_mut().downcast_mut::() { + auto_node.remove_keyframe_at_time(Beats(time), Beats(0.001)); // 1ms tolerance + } else { + eprintln!("Node {} is not an AutomationInputNode", node_id); + } + } + } + } + + Command::AutomationSetName(track_id, node_id, name) => { + use crate::audio::node_graph::nodes::AutomationInputNode; + + let graph = match self.project.get_track_mut(track_id) { + Some(TrackNode::Midi(t)) => Some(&mut t.instrument_graph), + Some(TrackNode::Audio(t)) => Some(&mut t.effects_graph), + Some(TrackNode::Group(t)) => Some(&mut t.audio_graph), + _ => None, + }; + if let Some(graph) = graph { + let node_idx = NodeIndex::new(node_id as usize); + + if let Some(graph_node) = graph.get_graph_node_mut(node_idx) { + if let Some(auto_node) = graph_node.node.as_any_mut().downcast_mut::() { + auto_node.set_display_name(name); + } else { + eprintln!("Node {} is not an AutomationInputNode", node_id); + } + } + } + } + + Command::GenerateWaveformChunks { + pool_index, + detail_level, + chunk_indices, + priority: _priority, // TODO: Use priority for scheduling + } => { + println!("🔧 [ENGINE] Received GenerateWaveformChunks command: pool={}, level={}, chunks={:?}", + pool_index, detail_level, chunk_indices); + // Get audio file data from pool + if let Some(audio_file) = self.audio_pool.get_file(pool_index) { + println!("✅ [ENGINE] Found audio file in pool, queuing work in thread pool"); + // Clone necessary data for background thread + let data = audio_file.data().to_vec(); + let channels = audio_file.channels; + let sample_rate = audio_file.sample_rate; + let path = audio_file.path.clone(); + let chunk_tx = self.chunk_generation_tx.clone(); + + // Generate chunks using rayon's thread pool to avoid spawning thousands of threads + rayon::spawn(move || { + // Create temporary AudioFile for chunk generation + let temp_audio_file = crate::audio::pool::AudioFile::with_format( + path, + data, + channels, + sample_rate, + None, + ); + + // Generate requested chunks + let chunks = crate::audio::waveform_cache::WaveformCache::generate_chunks( + &temp_audio_file, + pool_index, + detail_level, + &chunk_indices, + ); + + // Send chunks via MPSC channel (will be forwarded by audio thread) + if !chunks.is_empty() { + let event_chunks: Vec<(u32, (f64, f64), Vec)> = chunks + .into_iter() + .map(|chunk| (chunk.chunk_index, chunk.time_range, chunk.peaks)) + .collect(); + + let _ = chunk_tx.send(AudioEvent::WaveformChunksReady { + pool_index, + detail_level, + chunks: event_chunks, + }); + } + + // Yield to other threads to reduce CPU contention with video playback + std::thread::sleep(std::time::Duration::from_millis(1)); + }); + } else { + eprintln!("❌ [ENGINE] Pool index {} not found for waveform generation", pool_index); + } + } + + Command::ImportAudio(path) => { + if let Err(e) = self.do_import_audio(&path) { + eprintln!("[ENGINE] ImportAudio failed for {:?}: {}", path, e); + } + } + } + } + + /// Import an audio file into the pool: mmap for PCM, streaming for compressed. + /// Returns the pool index on success. Emits AudioFileReady event. + fn do_import_audio(&mut self, path: &std::path::Path) -> Result { + let path_str = path.to_string_lossy().to_string(); + + let metadata = crate::io::read_metadata(path) + .map_err(|e| format!("Failed to read metadata for {:?}: {}", path, e))?; + + eprintln!("[ENGINE] ImportAudio: format={:?}, ch={}, sr={}, n_frames={:?}, duration={:.2}s, path={}", + metadata.format, metadata.channels, metadata.sample_rate, metadata.n_frames, metadata.duration, path_str); + + let pool_index = match metadata.format { + crate::io::AudioFormat::Pcm => { + let file = std::fs::File::open(path) + .map_err(|e| format!("Failed to open {:?}: {}", path, e))?; + + // SAFETY: The file is opened read-only. The mmap is shared + // immutably. We never write to it. + let mmap = unsafe { memmap2::Mmap::map(&file) } + .map_err(|e| format!("mmap failed for {:?}: {}", path, e))?; + + let header = crate::io::parse_wav_header(&mmap) + .map_err(|e| format!("WAV parse failed for {:?}: {}", path, e))?; + + let audio_file = crate::audio::pool::AudioFile::from_mmap( + path.to_path_buf(), + mmap, + header.data_offset, + header.sample_format, + header.channels, + header.sample_rate, + header.total_frames, + ); + + self.audio_pool.add_file(audio_file) + } + crate::io::AudioFormat::Compressed => { + let sync_decode = std::env::var("DAW_SYNC_DECODE").is_ok(); + + if sync_decode { + eprintln!("[ENGINE] DAW_SYNC_DECODE: doing full decode of {:?}", path); + let loaded = crate::io::AudioFile::load(path) + .map_err(|e| format!("DAW_SYNC_DECODE failed: {}", e))?; + let ext = path.extension() + .and_then(|e| e.to_str()) + .map(|s| s.to_lowercase()); + let audio_file = crate::audio::pool::AudioFile::with_format( + path.to_path_buf(), + loaded.data, + loaded.channels, + loaded.sample_rate, + ext, + ); + let idx = self.audio_pool.add_file(audio_file); + eprintln!("[ENGINE] DAW_SYNC_DECODE: pool_index={}, frames={}", idx, loaded.frames); + idx + } else { + let ext = path.extension() + .and_then(|e| e.to_str()) + .map(|s| s.to_lowercase()); + + let total_frames = metadata.n_frames.unwrap_or_else(|| { + (metadata.duration * metadata.sample_rate as f64).ceil() as u64 + }); + + let audio_file = crate::audio::pool::AudioFile::from_compressed( + path.to_path_buf(), + metadata.channels, + metadata.sample_rate, + total_frames, + ext, + ); + + let idx = self.audio_pool.add_file(audio_file); + + eprintln!("[ENGINE] Compressed: total_frames={}, pool_index={}, has_disk_reader={}", + total_frames, idx, self.disk_reader.is_some()); + + // Spawn background thread to decode file progressively for waveform display + let bg_tx = self.chunk_generation_tx.clone(); + let bg_path = path.to_path_buf(); + let bg_total_frames = total_frames; + let _ = std::thread::Builder::new() + .name(format!("waveform-decode-{}", idx)) + .spawn(move || { + crate::io::AudioFile::decode_progressive( + &bg_path, + bg_total_frames, + |audio_data, decoded_frames, total| { + let _ = bg_tx.send(AudioEvent::WaveformDecodeComplete { + pool_index: idx, + samples: audio_data.to_vec(), + decoded_frames, + total_frames: total, + }); + }, + ); + }); + idx + } + } + }; + + // Emit AudioFileReady event + let _ = self.event_tx.push(AudioEvent::AudioFileReady { + pool_index, + path: path_str, + channels: metadata.channels, + sample_rate: metadata.sample_rate, + duration: metadata.duration, + format: metadata.format, + }); + + // For PCM files, send samples inline so the UI doesn't need to + // do a blocking get_pool_audio_samples() query. + if metadata.format == crate::io::AudioFormat::Pcm { + if let Some(file) = self.audio_pool.get_file(pool_index) { + let samples = file.data().to_vec(); + if !samples.is_empty() { + let _ = self.event_tx.push(AudioEvent::AudioDecodeProgress { + pool_index, + samples, + sample_rate: metadata.sample_rate, + channels: metadata.channels, + }); + } + } + } + + Ok(pool_index) + } + + /// Handle synchronous queries from the UI thread + fn handle_query(&mut self, query: Query) { + let response = match query { + Query::GetGraphState(track_id) => { + match self.project.get_track(track_id) { + Some(TrackNode::Midi(track)) => { + let graph = &track.instrument_graph; + let preset = graph.to_preset("temp"); + match preset.to_json() { + Ok(json) => QueryResponse::GraphState(Ok(json)), + Err(e) => QueryResponse::GraphState(Err(format!("Failed to serialize graph: {:?}", e))), + } + } + Some(TrackNode::Audio(track)) => { + let graph = &track.effects_graph; + let preset = graph.to_preset("temp"); + match preset.to_json() { + Ok(json) => QueryResponse::GraphState(Ok(json)), + Err(e) => QueryResponse::GraphState(Err(format!("Failed to serialize graph: {:?}", e))), + } + } + Some(TrackNode::Group(track)) => { + let graph = &track.audio_graph; + let preset = graph.to_preset("temp"); + match preset.to_json() { + Ok(json) => QueryResponse::GraphState(Ok(json)), + Err(e) => QueryResponse::GraphState(Err(format!("Failed to serialize graph: {:?}", e))), + } + } + _ => { + QueryResponse::GraphState(Err(format!("Track {} not found", track_id))) + } + } + } + Query::GetTemplateState(track_id, voice_allocator_id) => { + if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { + let graph = &mut track.instrument_graph; + let node_idx = NodeIndex::new(voice_allocator_id as usize); + if let Some(graph_node) = graph.get_graph_node_mut(node_idx) { + // Downcast to VoiceAllocatorNode using safe Any trait + if let Some(va_node) = graph_node.node.as_any().downcast_ref::() { + let template_preset = va_node.template_graph().to_preset("template"); + match template_preset.to_json() { + Ok(json) => QueryResponse::GraphState(Ok(json)), + Err(e) => QueryResponse::GraphState(Err(format!("Failed to serialize template: {:?}", e))), + } + } else { + QueryResponse::GraphState(Err("Node is not a VoiceAllocatorNode".to_string())) + } + } else { + QueryResponse::GraphState(Err("Voice allocator node not found".to_string())) + } + } else { + QueryResponse::GraphState(Err(format!("Track {} not found or is not a MIDI track", track_id))) + } + } + Query::GetOscilloscopeData(track_id, node_id, sample_count) => { + match self.project.get_oscilloscope_data(track_id, node_id, sample_count) { + Some((audio, cv)) => { + use crate::command::OscilloscopeData; + QueryResponse::OscilloscopeData(Ok(OscilloscopeData { audio, cv })) + } + None => QueryResponse::OscilloscopeData(Err(format!( + "Failed to get oscilloscope data from track {} node {}", + track_id, node_id + ))), + } + } + Query::GetVoiceOscilloscopeData(track_id, va_node_id, inner_node_id, sample_count) => { + match self.project.get_voice_oscilloscope_data(track_id, va_node_id, inner_node_id, sample_count) { + Some((audio, cv)) => { + use crate::command::OscilloscopeData; + QueryResponse::OscilloscopeData(Ok(OscilloscopeData { audio, cv })) + } + None => QueryResponse::OscilloscopeData(Err(format!( + "Failed to get voice oscilloscope data from track {} VA {} node {}", + track_id, va_node_id, inner_node_id + ))), + } + } + Query::GetMidiClip(_track_id, clip_id) => { + // Get MIDI clip data from the pool + if let Some(clip) = self.project.midi_clip_pool.get_clip(clip_id) { + use crate::command::MidiClipData; + QueryResponse::MidiClipData(Ok(MidiClipData { + duration: clip.duration.0, + events: clip.events.clone(), + })) + } else { + QueryResponse::MidiClipData(Err(format!("Clip {} not found in pool", clip_id))) + } + } + + Query::GetAutomationKeyframes(track_id, node_id) => { + use crate::audio::node_graph::nodes::{AutomationInputNode, InterpolationType}; + use crate::command::types::AutomationKeyframeData; + + let graph = match self.project.get_track(track_id) { + Some(TrackNode::Midi(t)) => Some(&t.instrument_graph), + Some(TrackNode::Audio(t)) => Some(&t.effects_graph), + Some(TrackNode::Group(t)) => Some(&t.audio_graph), + _ => None, + }; + if let Some(graph) = graph { + let node_idx = NodeIndex::new(node_id as usize); + if let Some(graph_node) = graph.get_graph_node(node_idx) { + if let Some(auto_node) = graph_node.node.as_any().downcast_ref::() { + let keyframes: Vec = auto_node.keyframes() + .iter() + .map(|kf| { + let interpolation_str = match kf.interpolation { + InterpolationType::Linear => "linear", + InterpolationType::Bezier => "bezier", + InterpolationType::Step => "step", + InterpolationType::Hold => "hold", + }.to_string(); + AutomationKeyframeData { + time: kf.time.0, + value: kf.value, + interpolation: interpolation_str, + ease_out: kf.ease_out, + ease_in: kf.ease_in, + } + }) + .collect(); + QueryResponse::AutomationKeyframes(Ok(keyframes)) + } else { + QueryResponse::AutomationKeyframes(Err(format!("Node {} is not an AutomationInputNode", node_id))) + } + } else { + QueryResponse::AutomationKeyframes(Err(format!("Node {} not found in track {}", node_id, track_id))) + } + } else { + QueryResponse::AutomationKeyframes(Err(format!("Track {} not found", track_id))) + } + } + + Query::GetAutomationName(track_id, node_id) => { + use crate::audio::node_graph::nodes::AutomationInputNode; + + let graph = match self.project.get_track(track_id) { + Some(TrackNode::Midi(t)) => Some(&t.instrument_graph), + Some(TrackNode::Audio(t)) => Some(&t.effects_graph), + Some(TrackNode::Group(t)) => Some(&t.audio_graph), + _ => None, + }; + if let Some(graph) = graph { + let node_idx = NodeIndex::new(node_id as usize); + if let Some(graph_node) = graph.get_graph_node(node_idx) { + if let Some(auto_node) = graph_node.node.as_any().downcast_ref::() { + QueryResponse::AutomationName(Ok(auto_node.display_name().to_string())) + } else { + QueryResponse::AutomationName(Err(format!("Node {} is not an AutomationInputNode", node_id))) + } + } else { + QueryResponse::AutomationName(Err(format!("Node {} not found in track {}", node_id, track_id))) + } + } else { + QueryResponse::AutomationName(Err(format!("Track {} not found", track_id))) + } + } + + Query::GetAutomationRange(track_id, node_id) => { + use crate::audio::node_graph::nodes::AutomationInputNode; + + let graph = match self.project.get_track(track_id) { + Some(TrackNode::Midi(t)) => Some(&t.instrument_graph), + Some(TrackNode::Audio(t)) => Some(&t.effects_graph), + Some(TrackNode::Group(t)) => Some(&t.audio_graph), + _ => None, + }; + if let Some(graph) = graph { + let node_idx = NodeIndex::new(node_id as usize); + if let Some(graph_node) = graph.get_graph_node(node_idx) { + if let Some(auto_node) = graph_node.node.as_any().downcast_ref::() { + QueryResponse::AutomationRange(Ok((auto_node.value_min, auto_node.value_max))) + } else { + QueryResponse::AutomationRange(Err(format!("Node {} is not an AutomationInputNode", node_id))) + } + } else { + QueryResponse::AutomationRange(Err(format!("Node {} not found", node_id))) + } + } else { + QueryResponse::AutomationRange(Err(format!("Track {} not found", track_id))) + } + } + + Query::SerializeAudioPool(project_path) => { + QueryResponse::AudioPoolSerialized(self.audio_pool.serialize(&project_path)) + } + + Query::LoadAudioPool(entries, project_path) => { + QueryResponse::AudioPoolLoaded(self.audio_pool.load_from_serialized(entries, &project_path)) + } + + Query::ResolveMissingAudioFile(pool_index, new_path) => { + QueryResponse::AudioFileResolved(self.audio_pool.resolve_missing_file(pool_index, &new_path)) + } + + Query::SerializeTrackGraph(track_id, _project_path) => { + // Get the track and serialize its graph + if let Some(track_node) = self.project.get_track(track_id) { + let preset_json = match track_node { + TrackNode::Audio(track) => { + // Serialize effects graph + let preset = track.effects_graph.to_preset(format!("track_{}_effects", track_id)); + serde_json::to_string_pretty(&preset) + .map_err(|e| format!("Failed to serialize effects graph: {}", e)) + } + TrackNode::Midi(track) => { + // Serialize instrument graph + let preset = track.instrument_graph.to_preset(format!("track_{}_instrument", track_id)); + serde_json::to_string_pretty(&preset) + .map_err(|e| format!("Failed to serialize instrument graph: {}", e)) + } + TrackNode::Group(_) => { + // TODO: Add graph serialization when we add graphs to group tracks + Err("Group tracks don't have graphs to serialize yet".to_string()) + } + }; + QueryResponse::TrackGraphSerialized(preset_json) + } else { + QueryResponse::TrackGraphSerialized(Err(format!("Track {} not found", track_id))) + } + } + + Query::LoadTrackGraph(track_id, preset_json, project_path) => { + // Parse preset and load into track's graph + use crate::audio::node_graph::preset::GraphPreset; + + let result = (|| -> Result<(), String> { + let preset: GraphPreset = serde_json::from_str(&preset_json) + .map_err(|e| format!("Failed to parse preset JSON: {}", e))?; + + let preset_base_path = project_path.parent(); + + if let Some(track_node) = self.project.get_track_mut(track_id) { + match track_node { + TrackNode::Audio(track) => { + // Load into effects graph with proper buffer size (8192 to handle any callback size) + track.effects_graph = AudioGraph::from_preset(&preset, self.sample_rate, 8192, preset_base_path, None)?; + Ok(()) + } + TrackNode::Midi(track) => { + // Load into instrument graph with proper buffer size (8192 to handle any callback size) + track.instrument_graph = AudioGraph::from_preset(&preset, self.sample_rate, 8192, preset_base_path, None)?; + Ok(()) + } + TrackNode::Group(_) => { + // TODO: Add graph loading when we add graphs to group tracks + Err("Group tracks don't have graphs to load yet".to_string()) + } + } + } else { + Err(format!("Track {} not found", track_id)) + } + })(); + + QueryResponse::TrackGraphLoaded(result) + } + Query::CreateAudioTrackSync(name, parent_id) => { + let track_id = self.project.add_audio_track(name.clone(), parent_id); + eprintln!("[Engine] Created audio track '{}' with ID {} (parent: {:?})", name, track_id, parent_id); + let _ = self.event_tx.push(AudioEvent::TrackCreated(track_id, false, name)); + QueryResponse::TrackCreated(Ok(track_id)) + } + Query::CreateMidiTrackSync(name, parent_id) => { + let track_id = self.project.add_midi_track(name.clone(), parent_id); + eprintln!("[Engine] Created MIDI track '{}' with ID {} (parent: {:?})", name, track_id, parent_id); + let _ = self.event_tx.push(AudioEvent::TrackCreated(track_id, false, name)); + QueryResponse::TrackCreated(Ok(track_id)) + } + Query::CreateMetatrackSync(name, parent_id) => { + let track_id = self.project.add_group_track(name.clone(), parent_id); + eprintln!("[Engine] Created metatrack '{}' with ID {} (parent: {:?})", name, track_id, parent_id); + let _ = self.event_tx.push(AudioEvent::TrackCreated(track_id, true, name)); + QueryResponse::TrackCreated(Ok(track_id)) + } + Query::GetPoolWaveform(pool_index, target_peaks) => { + match self.audio_pool.generate_waveform(pool_index, target_peaks) { + Some(waveform) => QueryResponse::PoolWaveform(Ok(waveform)), + None => QueryResponse::PoolWaveform(Err(format!("Pool index {} not found", pool_index))), + } + } + Query::GetPoolFileInfo(pool_index) => { + match self.audio_pool.get_file_info(pool_index) { + Some(info) => QueryResponse::PoolFileInfo(Ok(info)), + None => QueryResponse::PoolFileInfo(Err(format!("Pool index {} not found", pool_index))), + } + } + Query::GetPoolAudioSamples(pool_index) => { + match self.audio_pool.get_file(pool_index) { + Some(file) => { + // For Compressed storage, return decoded_for_waveform if available + let samples = match &file.storage { + crate::audio::pool::AudioStorage::Compressed { + decoded_for_waveform, decoded_frames, .. + } if *decoded_frames > 0 => { + decoded_for_waveform.clone() + } + _ => file.data().to_vec(), + }; + QueryResponse::PoolAudioSamples(Ok(( + samples, + file.sample_rate, + file.channels, + ))) + } + None => QueryResponse::PoolAudioSamples(Err(format!("Pool index {} not found", pool_index))), + } + } + Query::ExportAudio(settings, output_path) => { + // Perform export directly - this will block the audio thread but that's okay + // since we're exporting and not playing back anyway + + // Pass event_tx directly - Rust allows borrowing different fields simultaneously + match crate::audio::export_audio( + &mut self.project, + &self.audio_pool, + &settings, + &output_path, + Some(&mut self.event_tx), + ) { + Ok(()) => QueryResponse::AudioExported(Ok(())), + Err(e) => QueryResponse::AudioExported(Err(e)), + } + } + Query::AddMidiClipSync(track_id, clip, start_time) => { + // Add MIDI clip to track and return the instance ID (positions already in beats) + let result = match self.project.add_midi_clip_at(track_id, clip, crate::time::Beats(start_time)) { + Ok(instance_id) => QueryResponse::MidiClipInstanceAdded(Ok(instance_id)), + Err(e) => QueryResponse::MidiClipInstanceAdded(Err(e.to_string())), + }; + self.refresh_clip_snapshot(); + result + } + Query::AddMidiClipInstanceSync(track_id, mut instance) => { + // Add MIDI clip instance to track (clip must already be in pool) + // Assign instance ID; positions are already in beats + let instance_id = self.project.next_midi_clip_instance_id(); + instance.id = instance_id; + + let result = match self.project.add_midi_clip_instance(track_id, instance) { + Ok(_) => QueryResponse::MidiClipInstanceAdded(Ok(instance_id)), + Err(e) => QueryResponse::MidiClipInstanceAdded(Err(e.to_string())), + }; + self.refresh_clip_snapshot(); + result + } + Query::AddAudioFileSync(path, data, channels, sample_rate) => { + // Add audio file to pool and return the pool index + // Detect original format from file extension + let path_buf = std::path::PathBuf::from(&path); + let original_format = path_buf.extension() + .and_then(|ext| ext.to_str()) + .map(|s| s.to_lowercase()); + + // Create AudioFile and add to pool + let audio_file = crate::audio::pool::AudioFile::with_format( + path_buf.clone(), + data.clone(), // Clone data for background thread + channels, + sample_rate, + original_format, + ); + let pool_index = self.audio_pool.add_file(audio_file); + + // Generate Level 0 (overview) waveform chunks asynchronously in background thread + let chunk_tx = self.chunk_generation_tx.clone(); + let duration = data.len() as f64 / (sample_rate as f64 * channels as f64); + println!("🔄 [ENGINE] Spawning background thread to generate Level 0 chunks for pool {}", pool_index); + std::thread::spawn(move || { + // Create temporary AudioFile for chunk generation + let temp_audio_file = crate::audio::pool::AudioFile::with_format( + path_buf, + data, + channels, + sample_rate, + None, + ); + + // Generate Level 0 chunks + let chunk_count = crate::audio::waveform_cache::WaveformCache::calculate_chunk_count(duration, 0); + println!("🔄 [BACKGROUND] Generating {} Level 0 chunks for pool {}", chunk_count, pool_index); + let chunks = crate::audio::waveform_cache::WaveformCache::generate_chunks( + &temp_audio_file, + pool_index, + 0, // Level 0 (overview) + &(0..chunk_count).collect::>(), + ); + + // Send chunks via MPSC channel (will be forwarded by audio thread) + if !chunks.is_empty() { + println!("📤 [BACKGROUND] Generated {} chunks, sending to audio thread (pool {})", chunks.len(), pool_index); + let event_chunks: Vec<(u32, (f64, f64), Vec)> = chunks + .into_iter() + .map(|chunk| (chunk.chunk_index, chunk.time_range, chunk.peaks)) + .collect(); + + match chunk_tx.send(AudioEvent::WaveformChunksReady { + pool_index, + detail_level: 0, + chunks: event_chunks, + }) { + Ok(_) => println!("✅ [BACKGROUND] Chunks sent successfully for pool {}", pool_index), + Err(e) => eprintln!("❌ [BACKGROUND] Failed to send chunks: {}", e), + } + } else { + eprintln!("⚠️ [BACKGROUND] No chunks generated for pool {}", pool_index); + } + }); + + // Notify UI about the new audio file (for event listeners) + let _ = self.event_tx.push(AudioEvent::AudioFileAdded(pool_index, path)); + + QueryResponse::AudioFileAddedSync(Ok(pool_index)) + } + Query::ImportAudioSync(path) => { + QueryResponse::AudioImportedSync(self.do_import_audio(&path)) + } + Query::GetProject => { + // Save graph presets before cloning — AudioTrack::clone() creates + // a fresh default graph (not a copy), so the preset must be populated + // first so the clone carries the serialized graph data. + self.project.prepare_for_save(); + QueryResponse::ProjectRetrieved(Ok(Box::new(self.project.clone()))) + } + Query::SetProject(new_project) => { + // Replace the current project with the new one + // Need to rebuild audio graphs with current sample_rate and buffer_size + let mut project = *new_project; + match project.rebuild_audio_graphs(self.buffer_pool.buffer_size()) { + Ok(()) => { + self.project = project; + QueryResponse::ProjectSet(Ok(())) + } + Err(e) => QueryResponse::ProjectSet(Err(format!("Failed to rebuild audio graphs: {}", e))), + } + } + Query::DuplicateMidiClipSync(clip_id) => { + match self.project.midi_clip_pool.duplicate_clip(clip_id) { + Some(new_id) => QueryResponse::MidiClipDuplicated(Ok(new_id)), + None => QueryResponse::MidiClipDuplicated(Err(format!("MIDI clip {} not found", clip_id))), + } + } + Query::GetGraphIsDefault(track_id) => { + let is_default = match self.project.get_track(track_id) { + Some(TrackNode::Midi(track)) => track.graph_is_default, + Some(TrackNode::Audio(track)) => track.graph_is_default, + Some(TrackNode::Group(track)) => track.graph_is_default, + _ => false, + }; + QueryResponse::GraphIsDefault(is_default) + } + + Query::GetPitchBendRange(track_id) => { + use crate::audio::node_graph::nodes::{MidiToCVNode, MultiSamplerNode, VoiceAllocatorNode}; + use crate::audio::node_graph::AudioNode; + let range = if let Some(TrackNode::Midi(track)) = self.project.get_track(track_id) { + let graph = &track.instrument_graph; + let mut found = None; + for idx in graph.node_indices() { + if let Some(gn) = graph.get_graph_node(idx) { + if let Some(ms) = gn.node.as_any().downcast_ref::() { + found = Some(ms.get_parameter(4)); // PARAM_PITCH_BEND_RANGE + break; + } + // Search inside VoiceAllocator template for MidiToCV + if let Some(va) = gn.node.as_any().downcast_ref::() { + let tg = va.template_graph(); + for tidx in tg.node_indices() { + if let Some(tgn) = tg.get_graph_node(tidx) { + if let Some(mc) = tgn.node.as_any().downcast_ref::() { + found = Some(mc.get_parameter(0)); // PARAM_PITCH_BEND_RANGE + break; + } + } + } + if found.is_some() { break; } + } + } + } + found.unwrap_or(2.0) + } else { + 2.0 + }; + QueryResponse::PitchBendRange(range) + } + }; + + // Send response back + match self.query_response_tx.push(response) { + Ok(_) => {}, + Err(_) => eprintln!("❌ [ENGINE] FAILED to send query response - queue full!"), + } + } + + /// Set graph_is_default on any track type. + fn set_track_graph_is_default(&mut self, track_id: TrackId, value: bool) { + match self.project.get_track_mut(track_id) { + Some(TrackNode::Midi(track)) => track.graph_is_default = value, + Some(TrackNode::Audio(track)) => track.graph_is_default = value, + Some(TrackNode::Group(track)) => track.graph_is_default = value, + _ => {} + } + } + + /// Handle starting a recording + fn handle_start_recording(&mut self, track_id: TrackId, start_time: Beats) { + use crate::io::WavWriter; + use std::env; + + // Check if track exists and is an audio track + if let Some(crate::audio::track::TrackNode::Audio(_)) = self.project.get_track_mut(track_id) { + // Generate a unique temp file path + let temp_dir = env::temp_dir(); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let temp_file_path = temp_dir.join(format!("daw_recording_{}.wav", timestamp)); + + // Create WAV writer + match WavWriter::create(&temp_file_path, self.sample_rate, self.channels) { + Ok(writer) => { + // Create intermediate clip with a unique ID + let clip_id = self.next_audio_clip_id_atomic.fetch_add(1, Ordering::Relaxed); + + let clip = crate::audio::clip::Clip::new( + clip_id, + 0, // Temporary pool index, will be updated on finalization + Seconds(0.0), // internal_start + Seconds(0.0), // internal_end - Duration starts at 0, will be updated during recording + start_time, // external_start (timeline position, already Beats) + Beats(0.0), // external_duration - will be updated during recording + ); + + // Add clip to track + if let Some(crate::audio::track::TrackNode::Audio(track)) = self.project.get_track_mut(track_id) { + track.clips.push(clip); + self.refresh_clip_snapshot(); + } + + // Create recording state + let flush_interval_seconds = 1.0; // Flush every 1 second (safer than 5 seconds) + let recording_state = RecordingState::new( + track_id, + clip_id, + temp_file_path, + writer, + self.sample_rate, + self.channels, + start_time, + flush_interval_seconds, + ); + + // Count stale samples so we can skip them incrementally + let samples_in_buffer = if let Some(input_rx) = &self.input_rx { + input_rx.slots() + } else { + 0 + }; + + self.recording_state = Some(recording_state); + self.recording_progress_counter = 0; // Reset progress counter + + // Set samples to skip (drained incrementally across callbacks) + if let Some(recording) = &mut self.recording_state { + recording.samples_to_skip = samples_in_buffer; + if self.debug_audio && samples_in_buffer > 0 { + eprintln!("[AUDIO DEBUG] Will skip {} stale samples from input buffer", samples_in_buffer); + } + } + + // Notify UI that recording has started + let _ = self.event_tx.push(AudioEvent::RecordingStarted(track_id, clip_id, self.sample_rate, self.channels)); + } + Err(e) => { + // Send error event to UI + let _ = self.event_tx.push(AudioEvent::RecordingError( + format!("Failed to create temp file: {}", e) + )); + } + } + } else { + // Send error event if track not found or not an audio track + let _ = self.event_tx.push(AudioEvent::RecordingError( + format!("Track {} not found or is not an audio track", track_id) + )); + } + } + + /// Handle stopping a recording + fn handle_stop_recording(&mut self) { + eprintln!("[STOP_RECORDING] handle_stop_recording called"); + + // Check if we have an active MIDI recording first + if self.midi_recording_state.is_some() { + eprintln!("[STOP_RECORDING] Detected active MIDI recording, delegating to handle_stop_midi_recording"); + self.handle_stop_midi_recording(); + return; + } + + // Handle audio recording + if let Some(recording) = self.recording_state.take() { + let clip_id = recording.clip_id; + let track_id = recording.track_id; + let sample_rate = recording.sample_rate; + let channels = recording.channels; + + eprintln!("[STOP_RECORDING] Stopping recording for clip_id={}, track_id={}", clip_id, track_id); + + // Finalize the recording (flush buffers, close file, get waveform and audio data) + let frames_recorded = recording.frames_written; + eprintln!("[STOP_RECORDING] Calling finalize() - frames_recorded={}", frames_recorded); + match recording.finalize() { + Ok((temp_file_path, waveform, audio_data)) => { + eprintln!("[STOP_RECORDING] Finalize succeeded: {} frames written to {:?}, {} waveform peaks generated, {} samples in memory", + frames_recorded, temp_file_path, waveform.len(), audio_data.len()); + + // Add to pool using the in-memory audio data (no file loading needed!) + // Recorded audio is always WAV format + let pool_file = crate::audio::pool::AudioFile::with_format( + temp_file_path.clone(), + audio_data, + channels, + sample_rate, + Some("wav".to_string()), + ); + let pool_index = self.audio_pool.add_file(pool_file); + eprintln!("[STOP_RECORDING] Added to pool at index {}", pool_index); + + // Update the clip to reference the pool + if let Some(crate::audio::track::TrackNode::Audio(track)) = self.project.get_track_mut(track_id) { + if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) { + clip.audio_pool_index = pool_index; + eprintln!("[STOP_RECORDING] Updated clip {} with pool_index {}", clip_id, pool_index); + } + } + self.refresh_clip_snapshot(); + + // Delete temp file + let _ = std::fs::remove_file(&temp_file_path); + + // Send event with the incrementally-generated waveform + eprintln!("[STOP_RECORDING] Pushing RecordingStopped event for clip_id={}, pool_index={}, waveform_peaks={}", + clip_id, pool_index, waveform.len()); + let _ = self.event_tx.push(AudioEvent::RecordingStopped(clip_id, pool_index, waveform)); + eprintln!("[STOP_RECORDING] RecordingStopped event pushed successfully"); + } + Err(e) => { + eprintln!("[STOP_RECORDING] Finalize failed: {}", e); + let _ = self.event_tx.push(AudioEvent::RecordingError( + format!("Failed to finalize recording: {}", e) + )); + } + } + } else { + eprintln!("[STOP_RECORDING] No active recording to stop"); + } + } + + /// Handle starting MIDI recording + fn handle_start_midi_recording(&mut self, track_id: TrackId, clip_id: MidiClipId, start_time: Beats) { + // Check if track exists and is a MIDI track + if let Some(crate::audio::track::TrackNode::Midi(_)) = self.project.get_track_mut(track_id) { + // Create MIDI recording state + let mut recording_state = MidiRecordingState::new(track_id, clip_id, start_time); + + // Inject any notes currently held on this track (pressed during count-in pre-roll) + // so they start at t=0 of the recording rather than being lost + if let Some(held) = self.midi_held_notes.get(&track_id) { + for (¬e, &velocity) in held { + eprintln!("[MIDI_RECORDING] Injecting held note {} vel {} at start_time {:.3}", note, velocity, start_time.0); + recording_state.note_on(note, velocity, start_time); + } + } + + self.midi_recording_state = Some(recording_state); + + eprintln!("[MIDI_RECORDING] Started MIDI recording on track {} for clip {}", track_id, clip_id); + } else { + // Send error event if track not found or not a MIDI track + let _ = self.event_tx.push(AudioEvent::RecordingError( + format!("Track {} not found or is not a MIDI track", track_id) + )); + } + } + + /// Handle stopping MIDI recording + fn handle_stop_midi_recording(&mut self) { + eprintln!("[MIDI_RECORDING] handle_stop_midi_recording called"); + if let Some(mut recording) = self.midi_recording_state.take() { + // Send note-off to the synth for any notes still held, so they don't get stuck + let track_id_for_noteoff = recording.track_id; + for note_num in recording.active_note_numbers() { + self.project.send_midi_note_off(track_id_for_noteoff, note_num); + } + + // Close out any active notes at the current playhead position + let end_time = self.tempo_map.seconds_to_beats(Seconds(self.playhead as f64 / self.sample_rate as f64)); + eprintln!("[MIDI_RECORDING] Closing active notes at time {}", end_time.0); + recording.close_active_notes(end_time); + + let clip_id = recording.clip_id; + let track_id = recording.track_id; + let notes = recording.get_notes().to_vec(); + let note_count = notes.len(); + let recording_duration = end_time - recording.start_time; + + eprintln!("[MIDI_RECORDING] Stopping MIDI recording for clip_id={}, track_id={}, captured {} notes, duration={:.3} beats", + clip_id, track_id, note_count, recording_duration.0); + + // Update the MIDI clip in the pool (new model: clips are stored centrally in the pool) + eprintln!("[MIDI_RECORDING] Looking for clip {} in midi_clip_pool", clip_id); + if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(clip_id) { + eprintln!("[MIDI_RECORDING] Found clip in pool, clearing and adding {} notes", note_count); + // Clear existing events + clip.events.clear(); + + // Recording duration is already in beats (canonical) + let duration_beats = recording_duration; + clip.duration = duration_beats; + + // Add new events from the recorded notes. + // Recorded timestamps are already in beats (canonical). + for (start_time, note, velocity, duration) in notes.iter() { + let note_on = MidiEvent::note_on(*start_time, 0, *note, *velocity); + + eprintln!("[MIDI_RECORDING] Note {}: start={:.3} beats, duration={:.3} beats", + note, start_time.0, duration.0); + + clip.events.push(note_on); + + // Add note off event + let note_off_time = *start_time + *duration; + let note_off = MidiEvent::note_off(note_off_time, 0, *note, 64); + clip.events.push(note_off); + } + + // Sort events by timestamp (using partial_cmp for Beats) + clip.events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap()); + eprintln!("[MIDI_RECORDING] Updated clip {} with {} notes ({} events)", clip_id, note_count, clip.events.len()); + + // Also update the clip instance's internal_end and external_duration to match the recording duration + if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { + if let Some(instance) = track.clip_instances.iter_mut().find(|i| i.clip_id == clip_id) { + instance.internal_end = duration_beats; + instance.external_duration = duration_beats; + eprintln!("[MIDI_RECORDING] Updated clip instance timing: internal_end={:.3} beats, external_duration={:.3} beats", + instance.internal_end.0, instance.external_duration.0); + } + } + } else { + eprintln!("[MIDI_RECORDING] ERROR: Clip {} not found in pool!", clip_id); + } + + self.refresh_clip_snapshot(); + + // Send event to UI + eprintln!("[MIDI_RECORDING] Pushing MidiRecordingStopped event to event_tx..."); + match self.event_tx.push(AudioEvent::MidiRecordingStopped(track_id, clip_id, note_count)) { + Ok(_) => eprintln!("[MIDI_RECORDING] MidiRecordingStopped event pushed successfully"), + Err(e) => eprintln!("[MIDI_RECORDING] ERROR: Failed to push event: {:?}", e), + } + } else { + eprintln!("[MIDI_RECORDING] No active MIDI recording to stop"); + } + } + + /// Get current sample rate + pub fn sample_rate(&self) -> u32 { + self.sample_rate + } + + /// Get number of channels + pub fn channels(&self) -> u32 { + self.channels + } + + /// Get number of tracks + pub fn track_count(&self) -> usize { + self.project.track_count() + } +} + +/// Controller for the engine that can be used from the UI thread +pub struct EngineController { + command_tx: rtrb::Producer, + query_tx: rtrb::Producer, + query_response_rx: rtrb::Consumer, + playhead: Arc, + next_midi_clip_id: Arc, + next_audio_clip_id: Arc, + clip_snapshot: Arc>, + sample_rate: u32, + #[allow(dead_code)] // Used in public getter method + channels: u32, + /// Cached export response found by other query methods + cached_export_response: Option>, +} + +// Safety: EngineController is safe to Send across threads because: +// - rtrb::Producer is Send by design (lock-free queue for cross-thread communication) +// - Arc is Send + Sync (atomic types are inherently thread-safe) +// - u32 primitives are Send + Sync (Copy types) +// EngineController is only accessed through Mutex in application state, ensuring no concurrent mutable access. +unsafe impl Send for EngineController {} + +impl EngineController { + /// Start or resume playback + pub fn play(&mut self) { + let _ = self.command_tx.push(Command::Play); + } + + /// Pause playback + pub fn pause(&mut self) { + let _ = self.command_tx.push(Command::Pause); + } + + /// Stop playback and reset to beginning + pub fn stop(&mut self) { + let _ = self.command_tx.push(Command::Stop); + } + + /// Seek to a specific position in seconds + pub fn seek(&mut self, seconds: f64) { + let _ = self.command_tx.push(Command::Seek(seconds)); + } + + /// Set track volume (0.0 = silence, 1.0 = unity gain) + pub fn set_track_volume(&mut self, track_id: TrackId, volume: f32) { + let _ = self + .command_tx + .push(Command::SetTrackVolume(track_id, volume)); + } + + /// Set track mute state + pub fn set_track_mute(&mut self, track_id: TrackId, muted: bool) { + let _ = self.command_tx.push(Command::SetTrackMute(track_id, muted)); + } + + /// Set track solo state + pub fn set_track_solo(&mut self, track_id: TrackId, solo: bool) { + let _ = self.command_tx.push(Command::SetTrackSolo(track_id, solo)); + } + + /// Enable or disable input monitoring (mic level metering) + pub fn set_input_monitoring(&mut self, enabled: bool) { + let _ = self.command_tx.push(Command::SetInputMonitoring(enabled)); + } + + /// Set the input gain multiplier (applied before recording) + pub fn set_input_gain(&mut self, gain: f32) { + let _ = self.command_tx.push(Command::SetInputGain(gain)); + } + + /// Move a clip to a new timeline position (changes external_start) + pub fn move_clip(&mut self, track_id: TrackId, clip_id: ClipId, new_start_time: f64) { + let _ = self.command_tx.push(Command::MoveClip(track_id, clip_id, new_start_time)); + } + + /// Trim a clip's internal boundaries (changes which portion of source content is used) + /// This also resets external_duration to match internal duration (disables looping) + pub fn trim_clip(&mut self, track_id: TrackId, clip_id: ClipId, new_internal_start: f64, new_internal_end: f64) { + let _ = self.command_tx.push(Command::TrimClip(track_id, clip_id, new_internal_start, new_internal_end)); + } + + /// Extend or shrink a clip's external duration (enables looping if > internal duration) + pub fn extend_clip(&mut self, track_id: TrackId, clip_id: ClipId, new_external_duration: f64) { + let _ = self.command_tx.push(Command::ExtendClip(track_id, clip_id, new_external_duration)); + } + + /// Send a generic command to the audio thread + pub fn send_command(&mut self, command: Command) { + let _ = self.command_tx.push(command); + } + + /// Get current playhead position in samples + pub fn get_playhead_samples(&self) -> u64 { + self.playhead.load(Ordering::Relaxed) + } + + /// Get current playhead position in seconds + pub fn get_playhead_seconds(&self) -> f64 { + let frames = self.playhead.load(Ordering::Relaxed); + frames as f64 / self.sample_rate as f64 + } + + /// Get the shared clip snapshot. The UI can read this each frame to display + /// the authoritative clip state from the backend. + pub fn clip_snapshot(&self) -> Arc> { + Arc::clone(&self.clip_snapshot) + } + + /// Create a new metatrack + pub fn create_metatrack(&mut self, name: String) { + let _ = self.command_tx.push(Command::CreateMetatrack(name, None)); + } + + /// Add a track to a metatrack + pub fn add_to_metatrack(&mut self, track_id: TrackId, metatrack_id: TrackId) { + let _ = self.command_tx.push(Command::AddToMetatrack(track_id, metatrack_id)); + } + + /// Remove a track from its parent metatrack + pub fn remove_from_metatrack(&mut self, track_id: TrackId) { + let _ = self.command_tx.push(Command::RemoveFromMetatrack(track_id)); + } + + /// Set metatrack time stretch factor + /// 0.5 = half speed, 1.0 = normal, 2.0 = double speed + pub fn set_time_stretch(&mut self, track_id: TrackId, stretch: f32) { + let _ = self.command_tx.push(Command::SetTimeStretch(track_id, stretch)); + } + + /// Set metatrack time offset in seconds + /// Positive = shift content later, negative = shift earlier + pub fn set_offset(&mut self, track_id: TrackId, offset: f64) { + let _ = self.command_tx.push(Command::SetOffset(track_id, offset)); + } + + /// Set metatrack pitch shift in semitones (for future use) + pub fn set_pitch_shift(&mut self, track_id: TrackId, semitones: f32) { + let _ = self.command_tx.push(Command::SetPitchShift(track_id, semitones)); + } + + /// Set metatrack trim start in seconds + pub fn set_trim_start(&mut self, track_id: TrackId, trim_start: f64) { + let _ = self.command_tx.push(Command::SetTrimStart(track_id, trim_start)); + } + + /// Set metatrack trim end in seconds (None = no end trim) + pub fn set_trim_end(&mut self, track_id: TrackId, trim_end: Option) { + let _ = self.command_tx.push(Command::SetTrimEnd(track_id, trim_end)); + } + + /// Create a new audio track + pub fn create_audio_track(&mut self, name: String) { + let _ = self.command_tx.push(Command::CreateAudioTrack(name, None)); + } + + /// Add an audio file to the pool (must be called from non-audio thread with pre-loaded data) + pub fn add_audio_file(&mut self, path: String, data: Vec, channels: u32, sample_rate: u32) { + match self.command_tx.push(Command::AddAudioFile(path.clone(), data, channels, sample_rate)) { + Ok(_) => println!("✅ [CONTROLLER] AddAudioFile command queued successfully: {}", path), + Err(_) => eprintln!("❌ [CONTROLLER] Failed to queue AddAudioFile command (buffer full): {}", path), + } + } + + /// Add an audio file to the pool synchronously and get the pool index + /// Returns the pool index where the audio file was added + pub fn add_audio_file_sync(&mut self, path: String, data: Vec, channels: u32, sample_rate: u32) -> Result { + let query = Query::AddAudioFileSync(path, data, channels, sample_rate); + match self.send_query(query)? { + QueryResponse::AudioFileAddedSync(result) => result, + _ => Err("Unexpected query response".to_string()), + } + } + + /// Import an audio file asynchronously. The engine will memory-map WAV/AIFF + /// files for instant availability, or set up stream decoding for compressed + /// formats. Listen for `AudioEvent::AudioFileReady` to get the pool index. + pub fn import_audio(&mut self, path: std::path::PathBuf) { + let _ = self.command_tx.push(Command::ImportAudio(path)); + } + + /// Import an audio file synchronously and get the pool index. + /// Does the same work as `import_audio` (mmap for PCM, streaming for + /// compressed) but returns the real pool index directly. + /// 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. + pub fn import_audio_sync(&mut self, path: std::path::PathBuf) -> Result { + let query = Query::ImportAudioSync(path); + match self.send_query(query)? { + QueryResponse::AudioImportedSync(result) => result, + _ => Err("Unexpected query response".to_string()), + } + } + + /// Generate the next unique audio clip instance ID (atomic, thread-safe) + pub fn next_audio_clip_id(&self) -> AudioClipInstanceId { + self.next_audio_clip_id.fetch_add(1, Ordering::Relaxed) + } + + /// Add a clip to an audio track (async, fire-and-forget) + /// Returns the pre-assigned clip instance ID so callers can track the clip without a sync round-trip + pub fn add_audio_clip(&mut self, track_id: TrackId, pool_index: usize, start_time: f64, duration: f64, offset: f64) -> AudioClipInstanceId { + let clip_id = self.next_audio_clip_id.fetch_add(1, Ordering::Relaxed); + let _ = self.command_tx.push(Command::AddAudioClip(track_id, clip_id, pool_index, start_time, duration, offset)); + clip_id + } + + /// Add a clip to an audio track with a pre-assigned ID (for undo/redo, restoring deleted clips) + pub fn add_audio_clip_with_id(&mut self, track_id: TrackId, clip_id: AudioClipInstanceId, pool_index: usize, start_time: f64, duration: f64, offset: f64) { + let _ = self.command_tx.push(Command::AddAudioClip(track_id, clip_id, pool_index, start_time, duration, offset)); + } + + /// Create a new MIDI track + pub fn create_midi_track(&mut self, name: String) { + let _ = self.command_tx.push(Command::CreateMidiTrack(name, None)); + } + + /// Add a MIDI clip to the pool without placing it on any track + /// This is useful for importing MIDI files into a clip library + pub fn add_midi_clip_to_pool(&mut self, clip: MidiClip) { + let _ = self.command_tx.push(Command::AddMidiClipToPool(clip)); + } + + /// Create a new audio track synchronously (waits for creation to complete) + pub fn create_audio_track_sync(&mut self, name: String, parent: Option) -> Result { + if let Err(_) = self.query_tx.push(Query::CreateAudioTrackSync(name, parent)) { + return Err("Failed to send track creation query".to_string()); + } + + // Wait for response (with timeout) + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(2); + + while start.elapsed() < timeout { + if let Ok(QueryResponse::TrackCreated(result)) = self.query_response_rx.pop() { + return result; + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } + + Err("Track creation timeout".to_string()) + } + + /// Create a new MIDI track synchronously (waits for creation to complete) + pub fn create_midi_track_sync(&mut self, name: String, parent: Option) -> Result { + if let Err(_) = self.query_tx.push(Query::CreateMidiTrackSync(name, parent)) { + return Err("Failed to send track creation query".to_string()); + } + + // Wait for response (with timeout) + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(2); + + while start.elapsed() < timeout { + if let Ok(QueryResponse::TrackCreated(result)) = self.query_response_rx.pop() { + return result; + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } + + Err("Track creation timeout".to_string()) + } + + /// Create a new metatrack/group synchronously (waits for creation to complete) + pub fn create_group_track_sync(&mut self, name: String, parent: Option) -> Result { + if let Err(_) = self.query_tx.push(Query::CreateMetatrackSync(name, parent)) { + return Err("Failed to send metatrack creation query".to_string()); + } + + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(2); + + while start.elapsed() < timeout { + if let Ok(QueryResponse::TrackCreated(result)) = self.query_response_rx.pop() { + return result; + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } + + Err("Metatrack creation timeout".to_string()) + } + + /// Create a new MIDI clip on a track + pub fn create_midi_clip(&mut self, track_id: TrackId, start_time: f64, duration: f64) -> MidiClipId { + // Peek at the next clip ID that will be used + let clip_id = self.next_midi_clip_id.load(Ordering::Relaxed); + let _ = self.command_tx.push(Command::CreateMidiClip(track_id, start_time, duration)); + clip_id + } + + /// Add a MIDI note to a clip + pub fn add_midi_note(&mut self, track_id: TrackId, clip_id: MidiClipId, time_offset: f64, note: u8, velocity: u8, duration: f64) { + let _ = self.command_tx.push(Command::AddMidiNote(track_id, clip_id, time_offset, note, velocity, duration)); + } + + /// Add a pre-loaded MIDI clip to a track at the given timeline position + pub fn add_loaded_midi_clip(&mut self, track_id: TrackId, clip: MidiClip, start_time: f64) { + let _ = self.command_tx.push(Command::AddLoadedMidiClip(track_id, clip, start_time)); + } + + /// Update all notes in a MIDI clip + pub fn update_midi_clip_notes(&mut self, track_id: TrackId, clip_id: MidiClipId, notes: Vec<(f64, u8, u8, f64)>) { + let _ = self.command_tx.push(Command::UpdateMidiClipNotes(track_id, clip_id, notes)); + } + + /// Replace all events in a MIDI clip (used for CC/pitch bend editing from the piano roll) + pub fn update_midi_clip_events(&mut self, track_id: TrackId, clip_id: MidiClipId, events: Vec) { + let _ = self.command_tx.push(Command::UpdateMidiClipEvents(track_id, clip_id, events)); + } + + /// 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) { + let _ = self.command_tx.push(Command::RemoveMidiClip(track_id, instance_id)); + } + + /// 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) { + let _ = self.command_tx.push(Command::RemoveAudioClip(track_id, instance_id)); + } + + /// Request buffer pool statistics + /// The statistics will be sent via an AudioEvent::BufferPoolStats event + pub fn request_buffer_pool_stats(&mut self) { + let _ = self.command_tx.push(Command::RequestBufferPoolStats); + } + + /// Create a new automation lane on a track + /// Returns an event AutomationLaneCreated with the lane ID + pub fn create_automation_lane(&mut self, track_id: TrackId, parameter_id: crate::audio::ParameterId) { + let _ = self.command_tx.push(Command::CreateAutomationLane(track_id, parameter_id)); + } + + /// Add an automation point to a lane + pub fn add_automation_point( + &mut self, + track_id: TrackId, + lane_id: crate::audio::AutomationLaneId, + time: f64, + value: f32, + curve: crate::audio::CurveType, + ) { + let _ = self.command_tx.push(Command::AddAutomationPoint( + track_id, lane_id, time, value, curve, + )); + } + + /// Remove an automation point at a specific time + pub fn remove_automation_point( + &mut self, + track_id: TrackId, + lane_id: crate::audio::AutomationLaneId, + time: f64, + tolerance: f64, + ) { + let _ = self.command_tx.push(Command::RemoveAutomationPoint( + track_id, lane_id, time, tolerance, + )); + } + + /// Clear all automation points from a lane + pub fn clear_automation_lane( + &mut self, + track_id: TrackId, + lane_id: crate::audio::AutomationLaneId, + ) { + let _ = self.command_tx.push(Command::ClearAutomationLane(track_id, lane_id)); + } + + /// Remove an automation lane entirely + pub fn remove_automation_lane( + &mut self, + track_id: TrackId, + lane_id: crate::audio::AutomationLaneId, + ) { + let _ = self.command_tx.push(Command::RemoveAutomationLane(track_id, lane_id)); + } + + /// Enable or disable an automation lane + pub fn set_automation_lane_enabled( + &mut self, + track_id: TrackId, + lane_id: crate::audio::AutomationLaneId, + enabled: bool, + ) { + let _ = self.command_tx.push(Command::SetAutomationLaneEnabled( + track_id, lane_id, enabled, + )); + } + + /// Add a keyframe to an AutomationInput node + pub fn automation_add_keyframe(&mut self, track_id: TrackId, node_id: u32, + time: f64, value: f32, interpolation: String, + ease_out: (f32, f32), ease_in: (f32, f32)) { + let _ = self.command_tx.push(Command::AutomationAddKeyframe( + track_id, node_id, time, value, interpolation, ease_out, ease_in)); + } + + /// Remove a keyframe from an AutomationInput node + pub fn automation_remove_keyframe(&mut self, track_id: TrackId, node_id: u32, time: f64) { + let _ = self.command_tx.push(Command::AutomationRemoveKeyframe( + track_id, node_id, time)); + } + + /// Set the display name of an AutomationInput node + pub fn automation_set_name(&mut self, track_id: TrackId, node_id: u32, name: String) { + let _ = self.command_tx.push(Command::AutomationSetName(track_id, node_id, name)); + } + + /// Set the min/max output range of an AutomationInput node (param ids 0 = min, 1 = max) + pub fn automation_set_range(&mut self, track_id: TrackId, node_id: u32, min: f32, max: f32) { + self.graph_set_parameter(track_id, node_id, 0, min); + self.graph_set_parameter(track_id, node_id, 1, max); + } + + /// Start recording on a track + pub fn start_recording(&mut self, track_id: TrackId, start_time: f64) { + let _ = self.command_tx.push(Command::StartRecording(track_id, Beats(start_time))); + } + + /// Stop the current recording + pub fn stop_recording(&mut self) { + let _ = self.command_tx.push(Command::StopRecording); + } + + /// Pause the current recording + pub fn pause_recording(&mut self) { + let _ = self.command_tx.push(Command::PauseRecording); + } + + /// Resume the current recording + pub fn resume_recording(&mut self) { + let _ = self.command_tx.push(Command::ResumeRecording); + } + + /// Start MIDI recording on a track + pub fn start_midi_recording(&mut self, track_id: TrackId, clip_id: MidiClipId, start_time: f64) { + let _ = self.command_tx.push(Command::StartMidiRecording(track_id, clip_id, Beats(start_time))); + } + + /// Stop the current MIDI recording + pub fn stop_midi_recording(&mut self) { + let _ = self.command_tx.push(Command::StopMidiRecording); + } + + /// Reset the entire project (clear all tracks, audio pool, and state) + pub fn reset(&mut self) { + let _ = self.command_tx.push(Command::Reset); + } + + /// Send a live MIDI note on event to a track's instrument + pub fn send_midi_note_on(&mut self, track_id: TrackId, note: u8, velocity: u8) { + let _ = self.command_tx.push(Command::SendMidiNoteOn(track_id, note, velocity)); + } + + /// Send a live MIDI note off event to a track's instrument + pub fn send_midi_note_off(&mut self, track_id: TrackId, note: u8) { + let _ = self.command_tx.push(Command::SendMidiNoteOff(track_id, note)); + } + + /// Set the active MIDI track for external MIDI input routing + pub fn set_active_midi_track(&mut self, track_id: Option) { + let _ = self.command_tx.push(Command::SetActiveMidiTrack(track_id)); + } + + /// Enable or disable the metronome click track + pub fn set_metronome_enabled(&mut self, enabled: bool) { + let _ = self.command_tx.push(Command::SetMetronomeEnabled(enabled)); + } + + /// Set project tempo (BPM) and time signature + pub fn set_tempo(&mut self, bpm: f32, time_signature: (u32, u32)) { + let _ = self.command_tx.push(Command::SetTempo(bpm, time_signature)); + } + + /// Replace the full tempo map (multi-entry variable tempo) + pub fn set_tempo_map(&mut self, map: crate::TempoMap) { + let _ = self.command_tx.push(Command::SetTempoMap(map)); + } + + /// After a BPM change: update MIDI clip durations, sync clip beats/frames, and rescale + /// automation keyframe times to preserve beat positions. + /// `from_bpm` is the BPM before the change; `to_bpm` is the new BPM. + /// Call this after move_clip() has been called for all affected clips. + // Node graph operations + + /// Add a node to a track's instrument graph + pub fn graph_add_node(&mut self, track_id: TrackId, node_type: String, x: f32, y: f32) { + let _ = self.command_tx.push(Command::GraphAddNode(track_id, node_type, x, y)); + } + + pub fn graph_add_node_to_template(&mut self, track_id: TrackId, voice_allocator_id: u32, node_type: String, x: f32, y: f32) { + let _ = self.command_tx.push(Command::GraphAddNodeToTemplate(track_id, voice_allocator_id, node_type, x, y)); + } + + pub fn graph_connect_in_template(&mut self, track_id: TrackId, voice_allocator_id: u32, from_node: u32, from_port: usize, to_node: u32, to_port: usize) { + let _ = self.command_tx.push(Command::GraphConnectInTemplate(track_id, voice_allocator_id, from_node, from_port, to_node, to_port)); + } + + pub fn graph_disconnect_in_template(&mut self, track_id: TrackId, voice_allocator_id: u32, from_node: u32, from_port: usize, to_node: u32, to_port: usize) { + let _ = self.command_tx.push(Command::GraphDisconnectInTemplate(track_id, voice_allocator_id, from_node, from_port, to_node, to_port)); + } + + pub fn graph_remove_node_from_template(&mut self, track_id: TrackId, voice_allocator_id: u32, node_id: u32) { + let _ = self.command_tx.push(Command::GraphRemoveNodeFromTemplate(track_id, voice_allocator_id, node_id)); + } + + pub fn graph_set_parameter_in_template(&mut self, track_id: TrackId, voice_allocator_id: u32, node_id: u32, param_id: u32, value: f32) { + let _ = self.command_tx.push(Command::GraphSetParameterInTemplate(track_id, voice_allocator_id, node_id, param_id, value)); + } + + /// Remove a node from a track's instrument graph + pub fn graph_remove_node(&mut self, track_id: TrackId, node_id: u32) { + let _ = self.command_tx.push(Command::GraphRemoveNode(track_id, node_id)); + } + + /// Connect two nodes in a track's instrument graph + pub fn graph_connect(&mut self, track_id: TrackId, from_node: u32, from_port: usize, to_node: u32, to_port: usize) { + let _ = self.command_tx.push(Command::GraphConnect(track_id, from_node, from_port, to_node, to_port)); + } + + /// Disconnect two nodes in a track's instrument graph + pub fn graph_disconnect(&mut self, track_id: TrackId, from_node: u32, from_port: usize, to_node: u32, to_port: usize) { + let _ = self.command_tx.push(Command::GraphDisconnect(track_id, from_node, from_port, to_node, to_port)); + } + + /// Set a parameter on a node in a track's instrument graph + pub fn graph_set_parameter(&mut self, track_id: TrackId, node_id: u32, param_id: u32, value: f32) { + let _ = self.command_tx.push(Command::GraphSetParameter(track_id, node_id, param_id, value)); + } + + /// Set the UI position of a node in a track's graph + pub fn graph_set_node_position(&mut self, track_id: TrackId, node_id: u32, x: f32, y: f32) { + let _ = self.command_tx.push(Command::GraphSetNodePosition(track_id, node_id, x, y)); + } + + pub fn graph_set_node_position_in_template(&mut self, track_id: TrackId, voice_allocator_id: u32, node_id: u32, x: f32, y: f32) { + let _ = self.command_tx.push(Command::GraphSetNodePositionInTemplate(track_id, voice_allocator_id, node_id, x, y)); + } + + /// Set which node receives MIDI events in a track's instrument graph + pub fn graph_set_midi_target(&mut self, track_id: TrackId, node_id: u32, enabled: bool) { + let _ = self.command_tx.push(Command::GraphSetMidiTarget(track_id, node_id, enabled)); + } + + /// Set which node is the audio output in a track's instrument graph + pub fn graph_set_output_node(&mut self, track_id: TrackId, node_id: u32) { + let _ = self.command_tx.push(Command::GraphSetOutputNode(track_id, node_id)); + } + + /// Set frontend-only group definitions on a track's graph + pub fn graph_set_groups(&mut self, track_id: TrackId, groups: Vec) { + let _ = self.command_tx.push(Command::GraphSetGroups(track_id, groups)); + } + + /// Set frontend-only group definitions on a VA template graph + pub fn graph_set_groups_in_template(&mut self, track_id: TrackId, voice_allocator_id: u32, groups: Vec) { + let _ = self.command_tx.push(Command::GraphSetGroupsInTemplate(track_id, voice_allocator_id, groups)); + } + + /// Save the current graph as a preset + pub fn graph_save_preset(&mut self, track_id: TrackId, preset_path: String, preset_name: String, description: String, tags: Vec) { + let _ = self.command_tx.push(Command::GraphSavePreset(track_id, preset_path, preset_name, description, tags)); + } + + /// Load a preset into a track's graph + pub fn graph_load_preset(&mut self, track_id: TrackId, preset_path: String) { + let _ = self.command_tx.push(Command::GraphLoadPreset(track_id, preset_path)); + } + + /// Load a `.lbins` instrument bundle into a track's graph + pub fn graph_load_lbins(&mut self, track_id: TrackId, path: std::path::PathBuf) { + let _ = self.command_tx.push(Command::GraphLoadLbins(track_id, path)); + } + + /// Save a track's graph as a `.lbins` instrument bundle + pub fn graph_save_lbins(&mut self, track_id: TrackId, path: std::path::PathBuf, preset_name: String, description: String, tags: Vec) { + let _ = self.command_tx.push(Command::GraphSaveLbins(track_id, path, preset_name, description, tags)); + } + + /// Save a VoiceAllocator's template graph as a preset + pub fn graph_save_template_preset(&mut self, track_id: TrackId, voice_allocator_id: u32, preset_path: String, preset_name: String) { + let _ = self.command_tx.push(Command::GraphSaveTemplatePreset(track_id, voice_allocator_id, preset_path, preset_name)); + } + + /// Load a NAM model into an AmpSim node + pub fn amp_sim_load_model(&mut self, track_id: TrackId, node_id: u32, model_path: String) { + let _ = self.command_tx.push(Command::AmpSimLoadModel(track_id, node_id, model_path)); + } + + /// Load a sample into a SimpleSampler node + pub fn sampler_load_sample(&mut self, track_id: TrackId, node_id: u32, file_path: String) { + let _ = self.command_tx.push(Command::SamplerLoadSample(track_id, node_id, file_path)); + } + + /// Load a sample from the audio pool into a SimpleSampler node + pub fn sampler_load_from_pool(&mut self, track_id: TrackId, node_id: u32, pool_index: usize) { + let _ = self.command_tx.push(Command::SamplerLoadFromPool(track_id, node_id, pool_index)); + } + + /// Set the root note for a SimpleSampler node + pub fn sampler_set_root_note(&mut self, track_id: TrackId, node_id: u32, root_note: u8) { + let _ = self.command_tx.push(Command::SamplerSetRootNote(track_id, node_id, root_note)); + } + + /// Add a sample layer to a MultiSampler node + pub fn multi_sampler_add_layer(&mut self, track_id: TrackId, node_id: u32, file_path: String, key_min: u8, key_max: u8, root_key: u8, velocity_min: u8, velocity_max: u8, loop_start: Option, loop_end: Option, loop_mode: crate::audio::node_graph::nodes::LoopMode) { + let _ = self.command_tx.push(Command::MultiSamplerAddLayer(track_id, node_id, file_path, key_min, key_max, root_key, velocity_min, velocity_max, loop_start, loop_end, loop_mode)); + } + + /// Add a sample layer from the audio pool to a MultiSampler node + pub fn multi_sampler_add_layer_from_pool(&mut self, track_id: TrackId, node_id: u32, pool_index: usize, key_min: u8, key_max: u8, root_key: u8) { + let _ = self.command_tx.push(Command::MultiSamplerAddLayerFromPool(track_id, node_id, pool_index, key_min, key_max, root_key)); + } + + /// Update a MultiSampler layer's configuration + pub fn multi_sampler_update_layer(&mut self, track_id: TrackId, node_id: u32, layer_index: usize, key_min: u8, key_max: u8, root_key: u8, velocity_min: u8, velocity_max: u8, loop_start: Option, loop_end: Option, loop_mode: crate::audio::node_graph::nodes::LoopMode) { + let _ = self.command_tx.push(Command::MultiSamplerUpdateLayer(track_id, node_id, layer_index, key_min, key_max, root_key, velocity_min, velocity_max, loop_start, loop_end, loop_mode)); + } + + /// Remove a layer from a MultiSampler node + pub fn multi_sampler_remove_layer(&mut self, track_id: TrackId, node_id: u32, layer_index: usize) { + let _ = self.command_tx.push(Command::MultiSamplerRemoveLayer(track_id, node_id, layer_index)); + } + + /// Clear all layers from a MultiSampler node + pub fn multi_sampler_clear_layers(&mut self, track_id: TrackId, node_id: u32) { + let _ = self.command_tx.push(Command::MultiSamplerClearLayers(track_id, node_id)); + } + + /// Set the full subtrack list for a metatrack's mixing graph (rebuilds the graph) + pub fn set_metatrack_subtrack_graph(&mut self, track_id: TrackId, subtracks: Vec<(TrackId, String)>) { + let _ = self.command_tx.push(Command::SetMetatrackSubtrackGraph(track_id, subtracks)); + } + + /// Add a subtrack port to a metatrack's mixing graph + pub fn add_metatrack_subtrack(&mut self, track_id: TrackId, subtrack_id: TrackId, name: String) { + let _ = self.command_tx.push(Command::AddMetatrackSubtrack(track_id, subtrack_id, name)); + } + + /// Remove a subtrack port from a metatrack's mixing graph + pub fn remove_metatrack_subtrack(&mut self, track_id: TrackId, subtrack_id: TrackId) { + let _ = self.command_tx.push(Command::RemoveMetatrackSubtrack(track_id, subtrack_id)); + } + + /// Re-associate backend TrackIds with SubtrackInputsNode slots (called after project load) + pub fn update_metatrack_subtrack_ids(&mut self, track_id: TrackId, subtracks: Vec<(TrackId, String)>) { + let _ = self.command_tx.push(Command::UpdateMetatrackSubtrackIds(track_id, subtracks)); + } + + /// Set the graph_is_default flag on a track (command, processed async) + pub fn set_graph_is_default(&mut self, track_id: TrackId, value: bool) { + let _ = self.command_tx.push(Command::SetGraphIsDefault(track_id, value)); + } + + /// Query whether a track's graph is the auto-generated default (synchronous) + pub fn get_graph_is_default(&mut self, track_id: TrackId) -> bool { + if let Err(_) = self.query_tx.push(Query::GetGraphIsDefault(track_id)) { + return false; + } + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_millis(500); + while start.elapsed() < timeout { + if let Ok(QueryResponse::GraphIsDefault(v)) = self.query_response_rx.pop() { + return v; + } + std::thread::sleep(std::time::Duration::from_micros(100)); + } + false + } + + /// Send a synchronous query and wait for the response + /// This blocks until the audio thread processes the query + /// Generic method that works with any Query/QueryResponse pair + pub fn send_query(&mut self, query: Query) -> Result { + // Send query + if let Err(_) = self.query_tx.push(query) { + return Err("Failed to send query - queue full".to_string()); + } + + // Wait for response (with timeout) + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_millis(500); + + while start.elapsed() < timeout { + if let Ok(response) = self.query_response_rx.pop() { + return Ok(response); + } + // Small sleep to avoid busy-waiting + std::thread::sleep(std::time::Duration::from_micros(100)); + } + + Err("Query timeout".to_string()) + } + + /// Send a synchronous query and wait for the response + /// This blocks until the audio thread processes the query + pub fn query_graph_state(&mut self, track_id: TrackId) -> Result { + // Send query + if let Err(_) = self.query_tx.push(Query::GetGraphState(track_id)) { + return Err("Failed to send query - queue full".to_string()); + } + + // Wait for response (with timeout) + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_millis(500); + + while start.elapsed() < timeout { + if let Ok(QueryResponse::GraphState(result)) = self.query_response_rx.pop() { + return result; + } + // Small sleep to avoid busy-waiting + std::thread::sleep(std::time::Duration::from_micros(100)); + } + + Err("Query timeout".to_string()) + } + + /// Query a template graph state + pub fn query_template_state(&mut self, track_id: TrackId, voice_allocator_id: u32) -> Result { + // Send query + if let Err(_) = self.query_tx.push(Query::GetTemplateState(track_id, voice_allocator_id)) { + return Err("Failed to send query - queue full".to_string()); + } + + // Wait for response (with timeout) + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_millis(500); + + while start.elapsed() < timeout { + if let Ok(QueryResponse::GraphState(result)) = self.query_response_rx.pop() { + return result; + } + // Small sleep to avoid busy-waiting + std::thread::sleep(std::time::Duration::from_micros(100)); + } + + Err("Query timeout".to_string()) + } + + /// Query MIDI clip data + pub fn query_midi_clip(&mut self, track_id: TrackId, clip_id: MidiClipId) -> Result { + // Send query + if let Err(_) = self.query_tx.push(Query::GetMidiClip(track_id, clip_id)) { + return Err("Failed to send query - queue full".to_string()); + } + + // Wait for response (with timeout) + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_millis(500); + + while start.elapsed() < timeout { + if let Ok(QueryResponse::MidiClipData(result)) = self.query_response_rx.pop() { + return result; + } + // Small sleep to avoid busy-waiting + std::thread::sleep(std::time::Duration::from_micros(100)); + } + + Err("Query timeout".to_string()) + } + + /// Query oscilloscope data from a node + pub fn query_oscilloscope_data(&mut self, track_id: TrackId, node_id: u32, sample_count: usize) -> Result { + // Send query + if let Err(_) = self.query_tx.push(Query::GetOscilloscopeData(track_id, node_id, sample_count)) { + return Err("Failed to send query - queue full".to_string()); + } + + // Wait for response (with timeout) + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_millis(100); + + while start.elapsed() < timeout { + if let Ok(QueryResponse::OscilloscopeData(result)) = self.query_response_rx.pop() { + return result; + } + // Small sleep to avoid busy-waiting + std::thread::sleep(std::time::Duration::from_micros(50)); + } + + Err("Query timeout".to_string()) + } + + /// Query oscilloscope data from a node inside a VoiceAllocator's best voice + pub fn query_voice_oscilloscope_data(&mut self, track_id: TrackId, va_node_id: u32, inner_node_id: u32, sample_count: usize) -> Result { + if let Err(_) = self.query_tx.push(Query::GetVoiceOscilloscopeData(track_id, va_node_id, inner_node_id, sample_count)) { + return Err("Failed to send query - queue full".to_string()); + } + + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_millis(100); + + while start.elapsed() < timeout { + if let Ok(QueryResponse::OscilloscopeData(result)) = self.query_response_rx.pop() { + return result; + } + std::thread::sleep(std::time::Duration::from_micros(50)); + } + + Err("Query timeout".to_string()) + } + + /// Query automation keyframes from an AutomationInput node + pub fn query_automation_keyframes(&mut self, track_id: TrackId, node_id: u32) -> Result, String> { + // Send query + if let Err(_) = self.query_tx.push(Query::GetAutomationKeyframes(track_id, node_id)) { + return Err("Failed to send query - queue full".to_string()); + } + + // Wait for response (with timeout) + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_millis(100); + + while start.elapsed() < timeout { + if let Ok(QueryResponse::AutomationKeyframes(result)) = self.query_response_rx.pop() { + return result; + } + // Small sleep to avoid busy-waiting + std::thread::sleep(std::time::Duration::from_micros(50)); + } + + Err("Query timeout".to_string()) + } + + /// Query automation node display name + pub fn query_automation_name(&mut self, track_id: TrackId, node_id: u32) -> Result { + // Send query + if let Err(_) = self.query_tx.push(Query::GetAutomationName(track_id, node_id)) { + return Err("Failed to send query - queue full".to_string()); + } + + // Wait for response (with timeout) + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_millis(100); + + while start.elapsed() < timeout { + if let Ok(QueryResponse::AutomationName(result)) = self.query_response_rx.pop() { + return result; + } + // Small sleep to avoid busy-waiting + std::thread::sleep(std::time::Duration::from_micros(50)); + } + + Err("Query timeout".to_string()) + } + + /// Query automation node value range (min, max) + pub fn query_automation_range(&mut self, track_id: TrackId, node_id: u32) -> Result<(f32, f32), String> { + if let Err(_) = self.query_tx.push(Query::GetAutomationRange(track_id, node_id)) { + return Err("Failed to send query - queue full".to_string()); + } + + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_millis(100); + + while start.elapsed() < timeout { + if let Ok(QueryResponse::AutomationRange(result)) = self.query_response_rx.pop() { + return result; + } + std::thread::sleep(std::time::Duration::from_micros(50)); + } + + Err("Query timeout".to_string()) + } + + /// Query the pitch bend range (semitones) for the instrument on a MIDI track. + /// Returns 2.0 (default) if the track or instrument cannot be found. + pub fn query_pitch_bend_range(&mut self, track_id: TrackId) -> f32 { + if let Err(_) = self.query_tx.push(Query::GetPitchBendRange(track_id)) { + return 2.0; + } + + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_millis(100); + + while start.elapsed() < timeout { + if let Ok(QueryResponse::PitchBendRange(range)) = self.query_response_rx.pop() { + return range; + } + std::thread::sleep(std::time::Duration::from_micros(50)); + } + + 2.0 // default on timeout + } + + /// Serialize the audio pool for project saving + pub fn serialize_audio_pool(&mut self, project_path: &std::path::Path) -> Result, String> { + // Send query + if let Err(_) = self.query_tx.push(Query::SerializeAudioPool(project_path.to_path_buf())) { + return Err("Failed to send query - queue full".to_string()); + } + + // Wait for response (with timeout) + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(5); // Longer timeout for file operations + + while start.elapsed() < timeout { + if let Ok(QueryResponse::AudioPoolSerialized(result)) = self.query_response_rx.pop() { + return result; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + + Err("Query timeout".to_string()) + } + + /// Get waveform for a pool index + pub fn get_pool_waveform(&mut self, pool_index: usize, target_peaks: usize) -> Result, String> { + // Send query + if let Err(_) = self.query_tx.push(Query::GetPoolWaveform(pool_index, target_peaks)) { + return Err("Failed to send query - queue full".to_string()); + } + + // Wait for response (with shorter timeout to avoid blocking UI during export) + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_millis(50); + + while start.elapsed() < timeout { + if let Ok(response) = self.query_response_rx.pop() { + match response { + QueryResponse::PoolWaveform(result) => return result, + QueryResponse::AudioExported(result) => { + // Cache for poll_export_completion() + println!("💾 [CONTROLLER] Caching AudioExported response from get_pool_waveform"); + self.cached_export_response = Some(result); + } + _ => {} // Discard other responses + } + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } + + Err("Query timeout".to_string()) + } + + /// Get file info from pool (duration, sample_rate, channels) + pub fn get_pool_file_info(&mut self, pool_index: usize) -> Result<(f64, u32, u32), String> { + // Send query + if let Err(_) = self.query_tx.push(Query::GetPoolFileInfo(pool_index)) { + return Err("Failed to send query - queue full".to_string()); + } + + // Wait for response (with shorter timeout to avoid blocking UI during export) + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_millis(50); + + while start.elapsed() < timeout { + if let Ok(response) = self.query_response_rx.pop() { + match response { + QueryResponse::PoolFileInfo(result) => return result, + QueryResponse::AudioExported(result) => { + // Cache for poll_export_completion() + println!("💾 [CONTROLLER] Caching AudioExported response from get_pool_file_info"); + self.cached_export_response = Some(result); + } + _ => {} // Discard other responses + } + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } + + Err("Query timeout".to_string()) + } + + /// Get raw audio samples from pool (samples, sample_rate, channels) + pub fn get_pool_audio_samples(&mut self, pool_index: usize) -> Result<(Vec, u32, u32), String> { + if let Err(_) = self.query_tx.push(Query::GetPoolAudioSamples(pool_index)) { + return Err("Failed to send query - queue full".to_string()); + } + + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(5); // Longer timeout for large audio data + + while start.elapsed() < timeout { + if let Ok(response) = self.query_response_rx.pop() { + match response { + QueryResponse::PoolAudioSamples(result) => return result, + QueryResponse::AudioExported(result) => { + self.cached_export_response = Some(result); + } + _ => {} + } + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } + + Err("Query timeout".to_string()) + } + + /// Request waveform chunks to be generated + /// This is an asynchronous command - chunks will be returned via WaveformChunksReady events + pub fn generate_waveform_chunks( + &mut self, + pool_index: usize, + detail_level: u8, + chunk_indices: Vec, + priority: u8, + ) -> Result<(), String> { + let command = Command::GenerateWaveformChunks { + pool_index, + detail_level, + chunk_indices, + priority, + }; + + if let Err(_) = self.command_tx.push(command) { + return Err("Failed to send command - queue full".to_string()); + } + + Ok(()) + } + + /// Load audio pool from serialized entries + pub fn load_audio_pool(&mut self, entries: Vec, project_path: &std::path::Path) -> Result, String> { + // Send command via query mechanism + if let Err(_) = self.query_tx.push(Query::LoadAudioPool(entries, project_path.to_path_buf())) { + return Err("Failed to send query - queue full".to_string()); + } + + // Wait for response (with timeout) + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(10); // Long timeout for loading multiple files + + while start.elapsed() < timeout { + if let Ok(QueryResponse::AudioPoolLoaded(result)) = self.query_response_rx.pop() { + return result; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + + Err("Query timeout".to_string()) + } + + /// Resolve a missing audio file by loading from a new path + pub fn resolve_missing_audio_file(&mut self, pool_index: usize, new_path: &std::path::Path) -> Result<(), String> { + // Send command via query mechanism + if let Err(_) = self.query_tx.push(Query::ResolveMissingAudioFile(pool_index, new_path.to_path_buf())) { + return Err("Failed to send query - queue full".to_string()); + } + + // Wait for response (with timeout) + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(5); + + while start.elapsed() < timeout { + if let Ok(QueryResponse::AudioFileResolved(result)) = self.query_response_rx.pop() { + return result; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + + Err("Query timeout".to_string()) + } + + /// Serialize a track's effects/instrument graph to JSON + pub fn serialize_track_graph(&mut self, track_id: TrackId, project_path: &std::path::Path) -> Result { + // Send query + if let Err(_) = self.query_tx.push(Query::SerializeTrackGraph(track_id, project_path.to_path_buf())) { + return Err("Failed to send query - queue full".to_string()); + } + + // Wait for response (with timeout) + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(5); + + while start.elapsed() < timeout { + if let Ok(QueryResponse::TrackGraphSerialized(result)) = self.query_response_rx.pop() { + return result; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + + Err("Query timeout".to_string()) + } + + /// Load a track's effects/instrument graph from JSON + pub fn load_track_graph(&mut self, track_id: TrackId, preset_json: &str, project_path: &std::path::Path) -> Result<(), String> { + // Send query + if let Err(_) = self.query_tx.push(Query::LoadTrackGraph(track_id, preset_json.to_string(), project_path.to_path_buf())) { + return Err("Failed to send query - queue full".to_string()); + } + + // Wait for response (with timeout) + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(10); // Longer timeout for loading presets + + while start.elapsed() < timeout { + if let Ok(QueryResponse::TrackGraphLoaded(result)) = self.query_response_rx.pop() { + return result; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + + Err("Query timeout".to_string()) + } + + /// Start an audio export (non-blocking) + /// + /// Sends the export query to the audio thread and returns immediately. + /// Use `poll_export_completion()` to check for completion. + pub fn start_export_audio>(&mut self, settings: &crate::audio::ExportSettings, output_path: P) -> Result<(), String> { + // Send export query + if let Err(_) = self.query_tx.push(Query::ExportAudio(settings.clone(), output_path.as_ref().to_path_buf())) { + return Err("Failed to send export query - queue full".to_string()); + } + Ok(()) + } + + /// Poll for export completion (non-blocking) + /// + /// Returns: + /// - `Ok(Some(result))` if export completed (result may be Ok or Err) + /// - `Ok(None)` if export is still in progress + /// - `Err` should not happen in normal operation + pub fn poll_export_completion(&mut self) -> Result>, String> { + // Check if we have a cached response from another query method + if let Some(result) = self.cached_export_response.take() { + println!("✅ [CONTROLLER] Found cached AudioExported response!"); + return Ok(Some(result)); + } + + // Keep popping responses until we find AudioExported or queue is empty + while let Ok(response) = self.query_response_rx.pop() { + println!("📥 [CONTROLLER] Received response: {:?}", std::mem::discriminant(&response)); + if let QueryResponse::AudioExported(result) = response { + println!("✅ [CONTROLLER] Found AudioExported response!"); + return Ok(Some(result)); + } + // Discard other query responses (they're for synchronous queries) + println!("⏭️ [CONTROLLER] Skipping non-export response"); + } + Ok(None) + } + + /// Export audio to a file (blocking) + /// + /// This is a convenience method that calls start_export_audio and waits for completion. + /// For non-blocking export with progress updates, use start_export_audio() and poll_export_completion(). + pub fn export_audio>(&mut self, settings: &crate::audio::ExportSettings, output_path: P) -> Result<(), String> { + self.start_export_audio(settings, &output_path)?; + + // Wait for response (with longer timeout since export can take a while) + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(300); // 5 minute timeout for export + + while start.elapsed() < timeout { + if let Some(result) = self.poll_export_completion()? { + return result; + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + + Err("Export timeout".to_string()) + } + + /// Get a clone of the current project for serialization + pub fn get_project(&mut self) -> Result { + // Send query + if let Err(_) = self.query_tx.push(Query::GetProject) { + return Err("Failed to send query - queue full".to_string()); + } + + // Wait for response (with timeout) + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(5); + + while start.elapsed() < timeout { + if let Ok(QueryResponse::ProjectRetrieved(result)) = self.query_response_rx.pop() { + return result.map(|boxed| *boxed); + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + + Err("Query timeout".to_string()) + } + + /// Set the project (replaces current project state) + pub fn set_project(&mut self, project: crate::audio::project::Project) -> Result<(), String> { + // Send query + if let Err(_) = self.query_tx.push(Query::SetProject(Box::new(project))) { + return Err("Failed to send query - queue full".to_string()); + } + + // Wait for response (with timeout) + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(10); // Longer timeout for loading project + + while start.elapsed() < timeout { + if let Ok(QueryResponse::ProjectSet(result)) = self.query_response_rx.pop() { + return result; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + + Err("Query timeout".to_string()) + } +} diff --git a/daw-backend/src/audio/export.rs b/daw-backend/src/audio/export.rs new file mode 100644 index 0000000..bdc940b --- /dev/null +++ b/daw-backend/src/audio/export.rs @@ -0,0 +1,816 @@ +use super::buffer_pool::BufferPool; +use super::pool::AudioPool; +use super::project::Project; +use crate::command::AudioEvent; +use crate::tempo_map::TempoMap; +use crate::time::Seconds; +use std::path::Path; + +/// Render chunk size for offline export. Matches the real-time playback buffer size +/// so that MIDI events are processed at the same granularity, avoiding timing jitter. +const EXPORT_CHUNK_FRAMES: usize = 256; + +/// Supported export formats +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExportFormat { + Wav, + Flac, + Mp3, + Aac, +} + +impl ExportFormat { + /// Get the file extension for this format + pub fn extension(&self) -> &'static str { + match self { + ExportFormat::Wav => "wav", + ExportFormat::Flac => "flac", + ExportFormat::Mp3 => "mp3", + ExportFormat::Aac => "m4a", + } + } +} + +/// Export settings for rendering audio +#[derive(Debug, Clone)] +pub struct ExportSettings { + /// Output format + pub format: ExportFormat, + /// Sample rate for export + pub sample_rate: u32, + /// Number of channels (1 = mono, 2 = stereo) + pub channels: u32, + /// Bit depth (16 or 24) - only for WAV/FLAC + pub bit_depth: u16, + /// MP3 bitrate in kbps (128, 192, 256, 320) + pub mp3_bitrate: u32, + /// Start time + pub start_time: Seconds, + /// End time + pub end_time: Seconds, + /// Tempo map for beat-position scheduling + pub tempo_map: TempoMap, +} + +impl Default for ExportSettings { + fn default() -> Self { + Self { + format: ExportFormat::Wav, + sample_rate: 44100, + channels: 2, + bit_depth: 16, + mp3_bitrate: 320, + start_time: Seconds::ZERO, + end_time: Seconds(60.0), + tempo_map: TempoMap::constant(120.0), + } + } +} + +/// Export the project to an audio file +/// +/// This performs offline rendering, processing the entire timeline +/// in chunks to generate the final audio file. +/// +/// If an event producer is provided, progress events will be sent +/// after each chunk with (frames_rendered, total_frames). +pub fn export_audio>( + project: &mut Project, + pool: &AudioPool, + settings: &ExportSettings, + output_path: P, + mut event_tx: Option<&mut rtrb::Producer>, +) -> Result<(), String> +{ + // Validate duration + let duration = settings.end_time - settings.start_time; + if duration <= Seconds::ZERO { + return Err(format!( + "Export duration is zero or negative (start={:.3}s, end={:.3}s). \ + Check that the timeline has content.", + settings.start_time.seconds_to_f64(), settings.end_time.seconds_to_f64() + )); + } + + let total_frames = (duration.seconds_to_f64() * settings.sample_rate as f64).round() as usize; + if total_frames == 0 { + return Err("Export would produce zero audio frames".to_string()); + } + + // Reset all node graphs to clear stale effect buffers (echo, reverb, etc.) + project.reset_all_graphs(); + + // Enable blocking mode on all read-ahead buffers so compressed audio + // streams block until decoded frames are available (instead of returning + // silence when the disk reader hasn't caught up with offline rendering). + project.set_export_mode(true); + + // Route to appropriate export implementation based on format. + // Ensure export mode is disabled even if an error occurs. + let result = match settings.format { + ExportFormat::Wav | ExportFormat::Flac => { + let samples = render_to_memory(project, pool, settings, event_tx.as_mut().map(|tx| &mut **tx))?; + // Signal that rendering is done and we're now writing the file + if let Some(ref mut tx) = event_tx { + let _ = tx.push(AudioEvent::ExportFinalizing); + } + match settings.format { + ExportFormat::Wav => write_wav(&samples, settings, &output_path), + ExportFormat::Flac => write_flac(&samples, settings, &output_path), + _ => unreachable!(), + } + } + ExportFormat::Mp3 => { + export_mp3(project, pool, settings, output_path, event_tx) + } + ExportFormat::Aac => { + export_aac(project, pool, settings, output_path, event_tx) + } + }; + + // Always disable export mode, even on error + project.set_export_mode(false); + + result +} + +/// Render the project to memory +/// +/// This function renders the project's audio to an in-memory buffer +/// of interleaved f32 samples. This is useful for custom export formats +/// or for passing audio to external encoders (e.g., FFmpeg for MP3/AAC). +/// +/// The returned samples are interleaved (L,R,L,R,... for stereo). +/// +/// If an event producer is provided, progress events will be sent +/// after each chunk with (frames_rendered, total_frames). +pub fn render_to_memory( + project: &mut Project, + pool: &AudioPool, + settings: &ExportSettings, + mut event_tx: Option<&mut rtrb::Producer>, +) -> Result, String> +{ + // Calculate total number of frames + let duration = settings.end_time - settings.start_time; + let total_frames = (duration.seconds_to_f64() * settings.sample_rate as f64).round() as usize; + let total_samples = total_frames * settings.channels as usize; + + println!("Export: duration={:.3}s, total_frames={}, total_samples={}, channels={}", + duration.seconds_to_f64(), total_frames, total_samples, settings.channels); + + let chunk_samples = EXPORT_CHUNK_FRAMES * settings.channels as usize; + + // Create buffer for rendering + let mut render_buffer = vec![0.0f32; chunk_samples]; + let mut buffer_pool = BufferPool::new(16, chunk_samples); + + // Collect all rendered samples + let mut all_samples = Vec::with_capacity(total_samples); + + let mut playhead = settings.start_time; + let chunk_duration = EXPORT_CHUNK_FRAMES as f64 / settings.sample_rate as f64; + let mut frames_rendered = 0; + + // Render the entire timeline in chunks + while playhead < settings.end_time { + // Clear the render buffer + render_buffer.fill(0.0); + + // Render this chunk + project.render( + &mut render_buffer, + pool, + &mut buffer_pool, + playhead, + &settings.tempo_map, + settings.sample_rate, + settings.channels, + false, + ); + + // Calculate how many samples we actually need from this chunk + let remaining_time = settings.end_time - playhead; + let samples_needed = if remaining_time.seconds_to_f64() < chunk_duration { + // Calculate frames needed and ensure it's a whole number + let frames_needed = (remaining_time.seconds_to_f64() * settings.sample_rate as f64).round() as usize; + let samples = frames_needed * settings.channels as usize; + // Ensure we don't exceed chunk size + samples.min(chunk_samples) + } else { + chunk_samples + }; + + // Append to output + all_samples.extend_from_slice(&render_buffer[..samples_needed]); + + // Update progress + frames_rendered += samples_needed / settings.channels as usize; + if let Some(event_tx) = event_tx.as_mut() { + let _ = event_tx.push(AudioEvent::ExportProgress { + frames_rendered, + total_frames, + }); + } + + playhead = playhead + Seconds(chunk_duration); + } + + println!("Export: rendered {} samples total", all_samples.len()); + + // Verify the sample count is a multiple of channels + if all_samples.len() % settings.channels as usize != 0 { + return Err(format!( + "Sample count {} is not a multiple of channel count {}", + all_samples.len(), + settings.channels + )); + } + + Ok(all_samples) +} + +/// Write WAV file using hound +fn write_wav>( + samples: &[f32], + settings: &ExportSettings, + output_path: P, +) -> Result<(), String> { + let spec = hound::WavSpec { + channels: settings.channels as u16, + sample_rate: settings.sample_rate, + bits_per_sample: settings.bit_depth, + sample_format: hound::SampleFormat::Int, + }; + + let mut writer = hound::WavWriter::create(output_path, spec) + .map_err(|e| format!("Failed to create WAV file: {}", e))?; + + // Write samples + match settings.bit_depth { + 16 => { + for &sample in samples { + let clamped = sample.max(-1.0).min(1.0); + let pcm_value = (clamped * 32767.0) as i16; + writer.write_sample(pcm_value) + .map_err(|e| format!("Failed to write sample: {}", e))?; + } + } + 24 => { + for &sample in samples { + let clamped = sample.max(-1.0).min(1.0); + let pcm_value = (clamped * 8388607.0) as i32; + writer.write_sample(pcm_value) + .map_err(|e| format!("Failed to write sample: {}", e))?; + } + } + _ => return Err(format!("Unsupported bit depth: {}", settings.bit_depth)), + } + + writer.finalize() + .map_err(|e| format!("Failed to finalize WAV file: {}", e))?; + + Ok(()) +} + +/// Write FLAC file using hound (FLAC is essentially lossless WAV) +fn write_flac>( + samples: &[f32], + settings: &ExportSettings, + output_path: P, +) -> Result<(), String> { + // For now, we'll use hound to write a WAV-like FLAC file + // In the future, we could use a dedicated FLAC encoder + let spec = hound::WavSpec { + channels: settings.channels as u16, + sample_rate: settings.sample_rate, + bits_per_sample: settings.bit_depth, + sample_format: hound::SampleFormat::Int, + }; + + let mut writer = hound::WavWriter::create(output_path, spec) + .map_err(|e| format!("Failed to create FLAC file: {}", e))?; + + // Write samples (same as WAV for now) + match settings.bit_depth { + 16 => { + for &sample in samples { + let clamped = sample.max(-1.0).min(1.0); + let pcm_value = (clamped * 32767.0) as i16; + writer.write_sample(pcm_value) + .map_err(|e| format!("Failed to write sample: {}", e))?; + } + } + 24 => { + for &sample in samples { + let clamped = sample.max(-1.0).min(1.0); + let pcm_value = (clamped * 8388607.0) as i32; + writer.write_sample(pcm_value) + .map_err(|e| format!("Failed to write sample: {}", e))?; + } + } + _ => return Err(format!("Unsupported bit depth: {}", settings.bit_depth)), + } + + writer.finalize() + .map_err(|e| format!("Failed to finalize FLAC file: {}", e))?; + + Ok(()) +} + +/// Export audio as MP3 using FFmpeg (streaming - render and encode simultaneously) +fn export_mp3>( + project: &mut Project, + pool: &AudioPool, + settings: &ExportSettings, + output_path: P, + mut event_tx: Option<&mut rtrb::Producer>, +) -> 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 = 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 = 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>( + project: &mut Project, + pool: &AudioPool, + settings: &ExportSettings, + output_path: P, + mut event_tx: Option<&mut rtrb::Producer>, +) -> 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 = 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 = 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> { + let num_frames = interleaved.len() / channels as usize; + let mut planar = vec![vec![0i16; num_frames]; channels as usize]; + + for (i, chunk) in interleaved.chunks(channels as usize).enumerate() { + for (ch, &sample) in chunk.iter().enumerate() { + let clamped = sample.max(-1.0).min(1.0); + planar[ch][i] = (clamped * 32767.0) as i16; + } + } + + planar +} + +/// Convert a chunk of interleaved f32 samples to planar f32 format +fn convert_chunk_to_planar_f32(interleaved: &[f32], channels: u32) -> Vec> { + let num_frames = interleaved.len() / channels as usize; + let mut planar = vec![vec![0.0f32; num_frames]; channels as usize]; + + for (i, chunk) in interleaved.chunks(channels as usize).enumerate() { + for (ch, &sample) in chunk.iter().enumerate() { + planar[ch][i] = sample; + } + } + + planar +} + +/// Encode a single complete frame of planar i16 samples to MP3 +fn encode_complete_frame_mp3( + encoder: &mut ffmpeg_next::encoder::Audio, + output: &mut ffmpeg_next::format::context::Output, + planar_samples: &[Vec], + 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:: 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::(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], + 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:: 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::(ch); + plane.copy_from_slice(&planar_samples[ch]); + } + + encoder.send_frame(&frame) + .map_err(|e| format!("Failed to send frame: {}", e))?; + + receive_and_write_packets(encoder, output)?; + + Ok(()) +} + +/// Receive encoded packets and write to output +fn receive_and_write_packets( + encoder: &mut ffmpeg_next::encoder::Audio, + output: &mut ffmpeg_next::format::context::Output, +) -> Result<(), String> { + let mut encoded = ffmpeg_next::Packet::empty(); + + while encoder.receive_packet(&mut encoded).is_ok() { + encoded.set_stream(0); + encoded.write_interleaved(output) + .map_err(|e| format!("Failed to write packet: {}", e))?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_export_settings_default() { + let settings = ExportSettings::default(); + assert_eq!(settings.format, ExportFormat::Wav); + assert_eq!(settings.sample_rate, 44100); + assert_eq!(settings.channels, 2); + assert_eq!(settings.bit_depth, 16); + } + + #[test] + fn test_format_extension() { + assert_eq!(ExportFormat::Wav.extension(), "wav"); + assert_eq!(ExportFormat::Flac.extension(), "flac"); + } +} diff --git a/daw-backend/src/audio/metronome.rs b/daw-backend/src/audio/metronome.rs new file mode 100644 index 0000000..f3b8b7c --- /dev/null +++ b/daw-backend/src/audio/metronome.rs @@ -0,0 +1,162 @@ +/// Metronome for providing click track during playback +pub struct Metronome { + enabled: bool, + bpm: f32, + time_signature_numerator: u32, + time_signature_denominator: u32, + last_beat: i64, // Last beat number that was played (-1 = none) + + // Pre-generated click samples (mono) + high_click: Vec, // Accent click for first beat + low_click: Vec, // Normal click for other beats + + // Click playback state + click_position: usize, // Current position in the click sample (0 = not playing) + playing_high_click: bool, // Which click we're currently playing + + #[allow(dead_code)] + sample_rate: u32, +} + +impl Metronome { + /// Create a new metronome with pre-generated click sounds + pub fn new(sample_rate: u32) -> Self { + let (high_click, low_click) = Self::generate_clicks(sample_rate); + + Self { + enabled: false, + bpm: 120.0, + time_signature_numerator: 4, + time_signature_denominator: 4, + last_beat: -1, + high_click, + low_click, + click_position: 0, + playing_high_click: false, + sample_rate, + } + } + + /// Generate woodblock-style click samples + fn generate_clicks(sample_rate: u32) -> (Vec, Vec) { + let click_duration_ms = 10.0; // 10ms click + let click_samples = ((sample_rate as f32 * click_duration_ms) / 1000.0) as usize; + + // High click (accent): 1200 Hz + 2400 Hz (higher pitched woodblock) + let high_freq1 = 1200.0; + let high_freq2 = 2400.0; + let mut high_click = Vec::with_capacity(click_samples); + + for i in 0..click_samples { + let t = i as f32 / sample_rate as f32; + let envelope = 1.0 - (i as f32 / click_samples as f32); // Linear decay + let envelope = envelope * envelope; // Square for faster decay + + // Mix two sine waves for woodblock character + let sample = 0.3 * (2.0 * std::f32::consts::PI * high_freq1 * t).sin() + + 0.2 * (2.0 * std::f32::consts::PI * high_freq2 * t).sin(); + + // Add a bit of noise for attack transient + let noise = (i as f32 * 0.1).sin() * 0.1; + + high_click.push((sample + noise) * envelope * 0.5); // Scale down to avoid clipping + } + + // Low click: 800 Hz + 1600 Hz (lower pitched woodblock) + let low_freq1 = 800.0; + let low_freq2 = 1600.0; + let mut low_click = Vec::with_capacity(click_samples); + + for i in 0..click_samples { + let t = i as f32 / sample_rate as f32; + let envelope = 1.0 - (i as f32 / click_samples as f32); + let envelope = envelope * envelope; + + let sample = 0.3 * (2.0 * std::f32::consts::PI * low_freq1 * t).sin() + + 0.2 * (2.0 * std::f32::consts::PI * low_freq2 * t).sin(); + + let noise = (i as f32 * 0.1).sin() * 0.1; + + low_click.push((sample + noise) * envelope * 0.4); // Slightly quieter than high click + } + + (high_click, low_click) + } + + /// Enable or disable the metronome + pub fn set_enabled(&mut self, enabled: bool) { + self.enabled = enabled; + if !enabled { + self.last_beat = -1; // Reset beat tracking when disabled + self.click_position = 0; // Stop any playing click + } else { + // Reset beat tracking so the next beat boundary (including beat 0) fires a click + self.last_beat = -1; + self.click_position = self.high_click.len(); // Idle (past end, nothing playing) + } + } + + /// Update BPM and time signature + pub fn update_timing(&mut self, bpm: f32, time_signature: (u32, u32)) { + self.bpm = bpm; + self.time_signature_numerator = time_signature.0; + self.time_signature_denominator = time_signature.1; + } + + /// Process audio and mix in metronome clicks + pub fn process( + &mut self, + output: &mut [f32], + playhead_samples: i64, + playing: bool, + sample_rate: u32, + channels: u32, + ) { + if !self.enabled || !playing { + self.click_position = 0; // Reset if not playing + return; + } + + let frames = output.len() / channels as usize; + + for frame in 0..frames { + let current_sample = playhead_samples + frame as i64; + + // Calculate current beat number + let current_time_seconds = current_sample as f64 / sample_rate as f64; + let beats_per_second = self.bpm as f64 / 60.0; + let current_beat = (current_time_seconds * beats_per_second).floor() as i64; + + // Check if we crossed a beat boundary (including negative beats during count-in pre-roll) + if current_beat != self.last_beat { + self.last_beat = current_beat; + + // Determine which click to play. + // Beat 0 of each measure gets the accent (high click). + // Use rem_euclid so negative beat numbers map correctly (e.g. -4 % 4 = 0). + let beat_in_measure = current_beat.rem_euclid(self.time_signature_numerator as i64) as usize; + self.playing_high_click = beat_in_measure == 0; + self.click_position = 0; // Start from beginning of click + } + + // Continue playing click sample if we're currently in one + let click = if self.playing_high_click { + &self.high_click + } else { + &self.low_click + }; + + if self.click_position < click.len() { + let click_sample = click[self.click_position]; + + // Mix into all channels + for ch in 0..channels as usize { + let output_idx = frame * channels as usize + ch; + output[output_idx] += click_sample; + } + + self.click_position += 1; + } + } + } +} diff --git a/daw-backend/src/audio/midi.rs b/daw-backend/src/audio/midi.rs new file mode 100644 index 0000000..a941e7a --- /dev/null +++ b/daw-backend/src/audio/midi.rs @@ -0,0 +1,191 @@ +use crate::time::Beats; + +/// MIDI event representing a single MIDI message +#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)] +pub struct MidiEvent { + /// Time position in beats (quarter-note beats) + pub timestamp: Beats, + /// MIDI status byte (includes channel) + pub status: u8, + /// First data byte (note number, CC number, etc.) + pub data1: u8, + /// Second data byte (velocity, CC value, etc.) + pub data2: u8, +} + +impl MidiEvent { + pub fn new(timestamp: Beats, status: u8, data1: u8, data2: u8) -> Self { + Self { timestamp, status, data1, data2 } + } + + pub fn note_on(timestamp: Beats, channel: u8, note: u8, velocity: u8) -> Self { + Self { timestamp, status: 0x90 | (channel & 0x0F), data1: note, data2: velocity } + } + + pub fn note_off(timestamp: Beats, channel: u8, note: u8, velocity: u8) -> Self { + Self { timestamp, status: 0x80 | (channel & 0x0F), data1: note, data2: velocity } + } + + pub fn is_note_on(&self) -> bool { + (self.status & 0xF0) == 0x90 && self.data2 > 0 + } + + pub fn is_note_off(&self) -> bool { + (self.status & 0xF0) == 0x80 || ((self.status & 0xF0) == 0x90 && self.data2 == 0) + } + + pub fn channel(&self) -> u8 { self.status & 0x0F } + pub fn message_type(&self) -> u8 { self.status & 0xF0 } +} + +/// MIDI clip ID type (for clips stored in the pool) +pub type MidiClipId = u32; + +/// MIDI clip instance ID type (for instances placed on tracks) +pub type MidiClipInstanceId = u32; + +/// MIDI clip content — stores the actual MIDI events. +/// `duration` is in beats. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct MidiClip { + pub id: MidiClipId, + pub events: Vec, + /// Total content duration in beats + pub duration: Beats, + pub name: String, +} + +impl MidiClip { + pub fn new(id: MidiClipId, events: Vec, duration: Beats, name: String) -> Self { + let mut clip = Self { id, events, duration, name }; + clip.events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap()); + clip + } + + pub fn empty(id: MidiClipId, duration: Beats, name: String) -> Self { + Self { id, events: Vec::new(), duration, name } + } + + pub fn add_event(&mut self, event: MidiEvent) { + self.events.push(event); + self.events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap()); + } + + /// Get events within a beat range (relative to clip start) + pub fn get_events_in_range(&self, start: Beats, end: Beats) -> Vec { + self.events.iter() + .filter(|e| e.timestamp >= start && e.timestamp < end) + .copied() + .collect() + } +} + +/// MIDI clip instance — a reference to MidiClip content with timeline positioning. +/// +/// All timing fields are in beats. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct MidiClipInstance { + pub id: MidiClipInstanceId, + pub clip_id: MidiClipId, + + /// Start of the trimmed region within the clip content (beats) + pub internal_start: Beats, + /// End of the trimmed region within the clip content (beats) + pub internal_end: Beats, + + /// Start position on the timeline (beats) + pub external_start: Beats, + /// Duration on the timeline (beats); > internal duration = looping + pub external_duration: Beats, +} + +impl MidiClipInstance { + pub fn new( + id: MidiClipInstanceId, + clip_id: MidiClipId, + internal_start: Beats, + internal_end: Beats, + external_start: Beats, + external_duration: Beats, + ) -> Self { + Self { id, clip_id, internal_start, internal_end, external_start, external_duration } + } + + /// Create an instance covering the full clip with no trim + pub fn from_full_clip( + id: MidiClipInstanceId, + clip_id: MidiClipId, + clip_duration: Beats, + external_start: Beats, + ) -> Self { + Self { + id, + clip_id, + internal_start: Beats::ZERO, + internal_end: clip_duration, + external_start, + external_duration: clip_duration, + } + } + + pub fn internal_duration(&self) -> Beats { self.internal_end - self.internal_start } + pub fn external_end(&self) -> Beats { self.external_start + self.external_duration } + pub fn is_looping(&self) -> bool { self.external_duration > self.internal_duration() } + + /// Check if this instance overlaps with a beat range + pub fn overlaps_range(&self, range_start: Beats, range_end: Beats) -> bool { + self.external_start < range_end && self.external_end() > range_start + } + + /// Get events that should fire in a given beat range on the timeline. + /// Returns events with `timestamp` set to their global timeline beat position. + pub fn get_events_in_range( + &self, + clip: &MidiClip, + range_start: Beats, + range_end: Beats, + ) -> Vec { + let mut result = Vec::new(); + + if !self.overlaps_range(range_start, range_end) { + return result; + } + + let internal_duration = self.internal_duration(); + if internal_duration <= Beats::ZERO { + return result; + } + + let num_loops = if self.external_duration > internal_duration { + (self.external_duration / internal_duration).ceil() as usize + } else { + 1 + }; + + let external_end = self.external_end(); + + for loop_idx in 0..num_loops { + let loop_offset = internal_duration * loop_idx as f64; + + for event in &clip.events { + if event.timestamp < self.internal_start || event.timestamp > self.internal_end { + continue; + } + + let relative_content_time = event.timestamp - self.internal_start; + let timeline_time = self.external_start + loop_offset + relative_content_time; + + if timeline_time >= range_start + && timeline_time < range_end + && timeline_time <= external_end + { + let mut adjusted_event = *event; + adjusted_event.timestamp = timeline_time; + result.push(adjusted_event); + } + } + } + + result + } +} diff --git a/daw-backend/src/audio/midi_pool.rs b/daw-backend/src/audio/midi_pool.rs new file mode 100644 index 0000000..70b7c33 --- /dev/null +++ b/daw-backend/src/audio/midi_pool.rs @@ -0,0 +1,104 @@ +use serde::{Serialize, Deserialize}; +use std::collections::HashMap; +use super::midi::{MidiClip, MidiClipId, MidiEvent}; +use crate::time::Beats; + +/// Pool for storing MIDI clip content +/// Similar to AudioClipPool but for MIDI data +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MidiClipPool { + clips: HashMap, + next_id: MidiClipId, +} + +impl MidiClipPool { + /// Create a new empty MIDI clip pool + pub fn new() -> Self { + Self { + clips: HashMap::new(), + next_id: 1, // Start at 1 so 0 can indicate "no clip" + } + } + + /// Add a new clip to the pool with the given events and duration + /// Returns the ID of the newly created clip + pub fn add_clip(&mut self, events: Vec, duration: Beats, name: String) -> MidiClipId { + let id = self.next_id; + self.next_id += 1; + + let clip = MidiClip::new(id, events, duration, name); + self.clips.insert(id, clip); + id + } + + /// Add an existing clip to the pool (used when loading projects) + /// The clip's ID is preserved + pub fn add_existing_clip(&mut self, clip: MidiClip) { + // Update next_id to avoid collisions + if clip.id >= self.next_id { + self.next_id = clip.id + 1; + } + self.clips.insert(clip.id, clip); + } + + /// Get a clip by ID + pub fn get_clip(&self, id: MidiClipId) -> Option<&MidiClip> { + self.clips.get(&id) + } + + /// Get a mutable clip by ID + pub fn get_clip_mut(&mut self, id: MidiClipId) -> Option<&mut MidiClip> { + self.clips.get_mut(&id) + } + + /// Remove a clip from the pool + pub fn remove_clip(&mut self, id: MidiClipId) -> Option { + self.clips.remove(&id) + } + + /// Duplicate a clip, returning the new clip's ID + pub fn duplicate_clip(&mut self, id: MidiClipId) -> Option { + let clip = self.clips.get(&id)?; + let new_id = self.next_id; + self.next_id += 1; + + let mut new_clip = clip.clone(); + new_clip.id = new_id; + new_clip.name = format!("{} (copy)", clip.name); + + self.clips.insert(new_id, new_clip); + Some(new_id) + } + + /// Get all clip IDs in the pool + pub fn clip_ids(&self) -> Vec { + self.clips.keys().copied().collect() + } + + /// Get the number of clips in the pool + pub fn len(&self) -> usize { + self.clips.len() + } + + /// Check if the pool is empty + pub fn is_empty(&self) -> bool { + self.clips.is_empty() + } + + /// Clear all clips from the pool + pub fn clear(&mut self) { + self.clips.clear(); + self.next_id = 1; + } + + /// Get an iterator over all clips + pub fn iter(&self) -> impl Iterator { + self.clips.iter() + } +} + +impl Default for MidiClipPool { + fn default() -> Self { + Self::new() + } +} diff --git a/daw-backend/src/audio/mod.rs b/daw-backend/src/audio/mod.rs new file mode 100644 index 0000000..6d7d771 --- /dev/null +++ b/daw-backend/src/audio/mod.rs @@ -0,0 +1,32 @@ +pub mod automation; +pub mod bpm_detector; +pub mod buffer_pool; +pub mod clip; +pub mod disk_reader; +pub mod engine; +pub mod export; +pub mod metronome; +pub mod midi; +pub mod midi_pool; +pub mod node_graph; +pub mod pool; +pub mod project; +pub mod recording; +pub mod sample_loader; +pub mod track; +pub mod waveform_cache; + +pub use automation::{AutomationLane, AutomationLaneId, AutomationPoint, CurveType, ParameterId}; +pub use buffer_pool::BufferPool; +pub use clip::{AudioClipInstance, AudioClipInstanceId, Clip, ClipId}; +pub use engine::{AudioClipSnapshot, Engine, EngineController}; +pub use export::{export_audio, ExportFormat, ExportSettings}; +pub use metronome::Metronome; +pub use midi::{MidiClip, MidiClipId, MidiClipInstance, MidiClipInstanceId, MidiEvent}; +pub use midi_pool::MidiClipPool; +pub use pool::{AudioClipPool, AudioFile as PoolAudioFile, AudioPool, AudioStorage, PcmSampleFormat}; +pub use project::Project; +pub use recording::RecordingState; +pub use sample_loader::{load_audio_file, SampleData}; +pub use track::{AudioTrack, Metatrack, MidiTrack, RenderContext, Track, TrackId, TrackNode}; +pub use waveform_cache::{ChunkPriority, DetailLevel, WaveformCache}; diff --git a/daw-backend/src/audio/node_graph/graph.rs b/daw-backend/src/audio/node_graph/graph.rs new file mode 100644 index 0000000..fc77d49 --- /dev/null +++ b/daw-backend/src/audio/node_graph/graph.rs @@ -0,0 +1,1413 @@ +use super::node_trait::AudioNode; +use super::types::{ConnectionError, SignalType}; +use crate::audio::midi::MidiEvent; +use crate::time::Beats; +use petgraph::algo::has_path_connecting; +use petgraph::stable_graph::{NodeIndex, StableGraph}; +use petgraph::visit::{EdgeRef, IntoEdgeReferences}; +use petgraph::Direction; + +/// Connection information between nodes +#[derive(Debug, Clone)] +pub struct Connection { + pub from_port: usize, + pub to_port: usize, +} + +/// Wrapper for audio nodes in the graph +pub struct GraphNode { + pub node: Box, + /// Buffers for each audio/CV output port + pub output_buffers: Vec>, + /// Buffers for each MIDI output port + pub midi_output_buffers: Vec>, +} + +impl std::fmt::Debug for GraphNode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GraphNode") + .field("node", &"") + .field("output_buffers_len", &self.output_buffers.len()) + .field("midi_output_buffers_len", &self.midi_output_buffers.len()) + .finish() + } +} + +impl GraphNode { + pub fn new(node: Box, buffer_size: usize) -> Self { + let outputs = node.outputs(); + + // Allocate buffers based on signal type + // Audio signals are stereo (2 samples per frame), CV is mono (1 sample per frame) + let mut output_buffers = Vec::new(); + let mut midi_output_buffers = Vec::new(); + + for port in outputs.iter() { + match port.signal_type { + SignalType::Audio => { + output_buffers.push(vec![0.0; buffer_size * 2]); // Stereo (interleaved L/R) + } + SignalType::CV => { + output_buffers.push(vec![0.0; buffer_size]); // Mono + } + SignalType::Midi => { + output_buffers.push(vec![]); // Placeholder for indexing alignment + let mut midi_buf = Vec::new(); + midi_buf.reserve(128); // Max 128 MIDI events per cycle + midi_output_buffers.push(midi_buf); + } + } + } + + Self { + node, + output_buffers, + midi_output_buffers, + } + } +} + +/// Audio processing graph for instruments/effects +#[derive(Debug)] +pub struct AudioGraph { + /// The audio graph (StableGraph allows node removal without index invalidation) + graph: StableGraph, + + /// MIDI input mapping (which nodes receive MIDI) + midi_targets: Vec, + + /// Audio output node index (where we read final audio) + output_node: Option, + + /// Sample rate + sample_rate: u32, + + /// Buffer size for internal processing + buffer_size: usize, + + /// Temporary buffers for node audio/CV inputs during processing + input_buffers: Vec>, + + /// Temporary buffers for node MIDI inputs during processing + midi_input_buffers: Vec>, + + /// UI positions for nodes (node_index -> (x, y)) + node_positions: std::collections::HashMap, + + /// Current playback time in beats (for automation nodes) + playback_time: Beats, + + /// Project tempo (synced from Engine via SetTempo) + bpm: f32, + /// Beats per bar (time signature numerator) + beats_per_bar: u32, + + /// Cached topological sort order (invalidated on graph mutation) + topo_cache: Option>, + + /// Frontend-only group definitions (stored opaquely for persistence) + frontend_groups: Vec, +} + +impl AudioGraph { + /// Create a new empty audio graph + pub fn new(sample_rate: u32, buffer_size: usize) -> Self { + Self { + graph: StableGraph::new(), + midi_targets: Vec::new(), + output_node: None, + sample_rate, + buffer_size, + // Pre-allocate input buffers with stereo size (2x) to accommodate Audio signals + // CV signals will only use the first half + input_buffers: vec![vec![0.0; buffer_size * 2]; 16], + // Pre-allocate MIDI input buffers (max 128 events per port) + midi_input_buffers: (0..16).map(|_| Vec::with_capacity(128)).collect(), + node_positions: std::collections::HashMap::new(), + playback_time: Beats::ZERO, + bpm: 120.0, + beats_per_bar: 4, + topo_cache: None, + frontend_groups: Vec::new(), + } + } + + /// Set the project tempo and time signature for BeatNodes + pub fn set_tempo(&mut self, bpm: f32, beats_per_bar: u32) { + self.bpm = bpm; + self.beats_per_bar = beats_per_bar; + } + + /// Add a node to the graph + pub fn add_node(&mut self, node: Box) -> NodeIndex { + let graph_node = GraphNode::new(node, self.buffer_size); + self.topo_cache = None; + self.graph.add_node(graph_node) + } + + /// Get the number of nodes in the graph + pub fn node_count(&self) -> usize { + self.graph.node_count() + } + + /// Set the UI position for a node + pub fn set_node_position(&mut self, node: NodeIndex, x: f32, y: f32) { + self.node_positions.insert(node.index() as u32, (x, y)); + } + + /// Get the UI position for a node + pub fn get_node_position(&self, node: NodeIndex) -> Option<(f32, f32)> { + self.node_positions.get(&(node.index() as u32)).copied() + } + + /// Connect two nodes with type checking + pub fn connect( + &mut self, + from: NodeIndex, + from_port: usize, + to: NodeIndex, + to_port: usize, + ) -> Result<(), ConnectionError> { + // Check if this exact connection already exists + if let Some(edge_idx) = self.graph.find_edge(from, to) { + let existing_conn = &self.graph[edge_idx]; + if existing_conn.from_port == from_port && existing_conn.to_port == to_port { + return Ok(()); // Connection already exists, don't create duplicate + } + } + + // Validate the connection + self.validate_connection(from, from_port, to, to_port)?; + + // Remove any existing connection to the same input port (replace semantics). + // The frontend UI enforces single-connection inputs, so when a new connection + // targets the same port, the old one should be replaced. + let edges_to_remove: Vec<_> = self.graph.edges_directed(to, petgraph::Direction::Incoming) + .filter(|e| e.weight().to_port == to_port) + .map(|e| e.id()) + .collect(); + for edge_id in edges_to_remove { + self.graph.remove_edge(edge_id); + } + + // Add the edge + self.graph.add_edge(from, to, Connection { from_port, to_port }); + self.topo_cache = None; + + // Auto-grow MixerNode: always keep one spare port beyond the connected count + let n_incoming = self.graph.edges_directed(to, petgraph::Direction::Incoming).count(); + if let Some(graph_node) = self.graph.node_weight_mut(to) { + use crate::audio::node_graph::nodes::MixerNode; + if let Some(mixer) = graph_node.node.as_any_mut().downcast_mut::() { + mixer.ensure_min_ports(n_incoming + 1); + } + } + + Ok(()) + } + + /// Disconnect two nodes + pub fn disconnect( + &mut self, + from: NodeIndex, + from_port: usize, + to: NodeIndex, + to_port: usize, + ) { + let mut did_remove = false; + if let Some(edge_idx) = self.graph.find_edge(from, to) { + let conn = &self.graph[edge_idx]; + if conn.from_port == from_port && conn.to_port == to_port { + self.graph.remove_edge(edge_idx); + self.topo_cache = None; + did_remove = true; + } + } + + // Shrink MixerNode back to n_remaining + 1 spare after a disconnect + if did_remove { + let n_remaining = self.graph.edges_directed(to, petgraph::Direction::Incoming).count(); + if let Some(graph_node) = self.graph.node_weight_mut(to) { + use crate::audio::node_graph::nodes::MixerNode; + if let Some(mixer) = graph_node.node.as_any_mut().downcast_mut::() { + mixer.resize(n_remaining + 1); + } + } + } + } + + /// Remove a node from the graph + pub fn remove_node(&mut self, node: NodeIndex) { + self.graph.remove_node(node); + self.topo_cache = None; + + // Update MIDI targets + self.midi_targets.retain(|&idx| idx != node); + + // Update output node + if self.output_node == Some(node) { + self.output_node = None; + } + } + + /// Validate a connection is type-compatible and wouldn't create a cycle + fn validate_connection( + &self, + from: NodeIndex, + from_port: usize, + to: NodeIndex, + to_port: usize, + ) -> Result<(), ConnectionError> { + // Check nodes exist + let from_node = self.graph.node_weight(from).ok_or(ConnectionError::InvalidPort)?; + let to_node = self.graph.node_weight(to).ok_or(ConnectionError::InvalidPort)?; + + // Check ports are valid + let from_outputs = from_node.node.outputs(); + let to_inputs = to_node.node.inputs(); + + if from_port >= from_outputs.len() || to_port >= to_inputs.len() { + return Err(ConnectionError::InvalidPort); + } + + // Check signal types match + let from_type = from_outputs[from_port].signal_type; + let to_type = to_inputs[to_port].signal_type; + + if from_type != to_type { + return Err(ConnectionError::TypeMismatch { + expected: to_type, + got: from_type, + }); + } + + // Check for cycles: if there's already a path from 'to' to 'from', + // then adding 'from' -> 'to' would create a cycle + if has_path_connecting(&self.graph, to, from, None) { + return Err(ConnectionError::WouldCreateCycle); + } + + Ok(()) + } + + /// Set which node receives MIDI events + pub fn set_midi_target(&mut self, node: NodeIndex, enabled: bool) { + if enabled { + if !self.midi_targets.contains(&node) { + self.midi_targets.push(node); + } + } else { + self.midi_targets.retain(|&idx| idx != node); + } + } + + /// Set the output node (where final audio is read from) + pub fn set_output_node(&mut self, node: Option) { + self.output_node = node; + } + + /// Add a node to a VoiceAllocator's template graph + pub fn add_node_to_voice_allocator_template( + &mut self, + voice_allocator_idx: NodeIndex, + node: Box, + ) -> Result { + use crate::audio::node_graph::nodes::VoiceAllocatorNode; + + // Get the VoiceAllocator node + if let Some(graph_node) = self.graph.node_weight_mut(voice_allocator_idx) { + // We need to downcast to VoiceAllocatorNode + // This is tricky with trait objects, so we'll need to use Any + // For now, let's use a different approach - store the node pointer temporarily + + // Downcast to VoiceAllocatorNode using safe Any trait + let va = graph_node.node.as_any_mut() + .downcast_mut::() + .ok_or_else(|| "Node is not a VoiceAllocator".to_string())?; + + // Add node to template graph + let node_idx = va.template_graph_mut().add_node(node); + let node_id = node_idx.index() as u32; + + // Rebuild voice instances from template + va.rebuild_voices(); + + return Ok(node_id); + } + + Err("VoiceAllocator node not found".to_string()) + } + + /// Connect nodes in a VoiceAllocator's template graph + pub fn connect_in_voice_allocator_template( + &mut self, + voice_allocator_idx: NodeIndex, + from_node: u32, + from_port: usize, + to_node: u32, + to_port: usize, + ) -> Result<(), String> { + use crate::audio::node_graph::nodes::VoiceAllocatorNode; + + // Get the VoiceAllocator node + if let Some(graph_node) = self.graph.node_weight_mut(voice_allocator_idx) { + // Downcast to VoiceAllocatorNode using safe Any trait + let va = graph_node.node.as_any_mut() + .downcast_mut::() + .ok_or_else(|| "Node is not a VoiceAllocator".to_string())?; + + // Connect in template graph + let from_idx = NodeIndex::new(from_node as usize); + let to_idx = NodeIndex::new(to_node as usize); + + va.template_graph_mut().connect(from_idx, from_port, to_idx, to_port) + .map_err(|e| format!("{:?}", e))?; + + // Rebuild voice instances from template + va.rebuild_voices(); + + return Ok(()); + } + + Err("VoiceAllocator node not found".to_string()) + } + + /// Disconnect two nodes in a VoiceAllocator's template graph + pub fn disconnect_in_voice_allocator_template( + &mut self, + voice_allocator_idx: NodeIndex, + from_node: u32, + from_port: usize, + to_node: u32, + to_port: usize, + ) -> Result<(), String> { + use crate::audio::node_graph::nodes::VoiceAllocatorNode; + + if let Some(graph_node) = self.graph.node_weight_mut(voice_allocator_idx) { + // Downcast to VoiceAllocatorNode using safe Any trait + let va = graph_node.node.as_any_mut() + .downcast_mut::() + .ok_or_else(|| "Node is not a VoiceAllocator".to_string())?; + + let from_idx = NodeIndex::new(from_node as usize); + let to_idx = NodeIndex::new(to_node as usize); + + va.template_graph_mut().disconnect(from_idx, from_port, to_idx, to_port); + va.rebuild_voices(); + + return Ok(()); + } + + Err("VoiceAllocator node not found".to_string()) + } + + /// Remove a node from a VoiceAllocator's template graph + pub fn remove_node_from_voice_allocator_template( + &mut self, + voice_allocator_idx: NodeIndex, + node_id: u32, + ) -> Result<(), String> { + use crate::audio::node_graph::nodes::VoiceAllocatorNode; + + if let Some(graph_node) = self.graph.node_weight_mut(voice_allocator_idx) { + // Downcast to VoiceAllocatorNode using safe Any trait + let va = graph_node.node.as_any_mut() + .downcast_mut::() + .ok_or_else(|| "Node is not a VoiceAllocator".to_string())?; + + let node_idx = NodeIndex::new(node_id as usize); + va.template_graph_mut().remove_node(node_idx); + va.rebuild_voices(); + + return Ok(()); + } + + Err("VoiceAllocator node not found".to_string()) + } + + /// Set a parameter on a node in a VoiceAllocator's template graph + pub fn set_parameter_in_voice_allocator_template( + &mut self, + voice_allocator_idx: NodeIndex, + node_id: u32, + param_id: u32, + value: f32, + ) -> Result<(), String> { + use crate::audio::node_graph::nodes::VoiceAllocatorNode; + + if let Some(graph_node) = self.graph.node_weight_mut(voice_allocator_idx) { + // Downcast to VoiceAllocatorNode using safe Any trait + let va = graph_node.node.as_any_mut() + .downcast_mut::() + .ok_or_else(|| "Node is not a VoiceAllocator".to_string())?; + + let node_idx = NodeIndex::new(node_id as usize); + if let Some(template_node) = va.template_graph_mut().get_graph_node_mut(node_idx) { + template_node.node.set_parameter(param_id, value); + } else { + return Err("Node not found in template".to_string()); + } + + va.rebuild_voices(); + + return Ok(()); + } + + Err("VoiceAllocator node not found".to_string()) + } + + /// Set the position of a node in a VoiceAllocator's template graph + pub fn set_position_in_voice_allocator_template( + &mut self, + voice_allocator_idx: NodeIndex, + node_id: u32, + x: f32, + y: f32, + ) { + use crate::audio::node_graph::nodes::VoiceAllocatorNode; + + if let Some(graph_node) = self.graph.node_weight_mut(voice_allocator_idx) { + // Downcast to VoiceAllocatorNode using safe Any trait + if let Some(va) = graph_node.node.as_any_mut().downcast_mut::() { + let node_idx = NodeIndex::new(node_id as usize); + va.template_graph_mut().set_node_position(node_idx, x, y); + } + } + } + + /// Process the graph and produce audio output + pub fn process(&mut self, output_buffer: &mut [f32], midi_events: &[MidiEvent], playback_time: Beats) { + // Update playback time + self.playback_time = playback_time; + + // Update playback time for all time-dependent nodes before processing + use super::nodes::{AutomationInputNode, BeatNode}; + for node in self.graph.node_weights_mut() { + if let Some(auto_node) = node.node.as_any_mut().downcast_mut::() { + auto_node.set_playback_time(playback_time); + auto_node.set_bpm(self.bpm as f64); + } else if let Some(beat_node) = node.node.as_any_mut().downcast_mut::() { + beat_node.set_playback_time(playback_time); + beat_node.set_tempo(self.bpm, self.beats_per_bar); + } + } + + // Use the requested output buffer size for processing + // process_size is stereo (interleaved L/R), frame_count is mono + let process_size = output_buffer.len(); + let frame_count = process_size / 2; + + // Clear all output buffers (audio/CV and MIDI) + for node in self.graph.node_weights_mut() { + for buffer in &mut node.output_buffers { + let len = buffer.len(); + buffer[..process_size.min(len)].fill(0.0); + } + for midi_buffer in &mut node.midi_output_buffers { + midi_buffer.clear(); + } + } + + // Distribute incoming MIDI events to target nodes' MIDI output buffers + // This puts MIDI into the graph so it can flow through connections + for &target_idx in &self.midi_targets { + if let Some(node) = self.graph.node_weight_mut(target_idx) { + // Find the first MIDI output port and add events there + if !node.midi_output_buffers.is_empty() { + node.midi_output_buffers[0].extend_from_slice(midi_events); + } + } + } + + // Topological sort for processing order (cached, recomputed only on graph mutation) + if self.topo_cache.is_none() { + self.topo_cache = Some( + petgraph::algo::toposort(&self.graph, None) + .unwrap_or_else(|_| { + // If there's a cycle (shouldn't happen due to validation), just process in index order + self.graph.node_indices().collect() + }) + ); + } + let topo_len = self.topo_cache.as_ref().unwrap().len(); + + // Process nodes in topological order + for topo_i in 0..topo_len { + let node_idx = self.topo_cache.as_ref().unwrap()[topo_i]; + // Get input port information + let inputs = self.graph[node_idx].node.inputs(); + let num_audio_cv_inputs = inputs.iter().filter(|p| p.signal_type != SignalType::Midi).count(); + let num_midi_inputs = inputs.iter().filter(|p| p.signal_type == SignalType::Midi).count(); + // Collect audio/CV input signal types for correct buffer sizing + let audio_cv_input_types: Vec = inputs.iter() + .filter(|p| p.signal_type != SignalType::Midi) + .map(|p| p.signal_type) + .collect(); + + // Clear input buffers + // - Audio inputs: fill with 0.0 (silence) when unconnected + // - CV inputs: fill with NaN to indicate "no connection" (allows nodes to use parameter values) + let mut audio_cv_idx = 0; + for port in inputs.iter().filter(|p| p.signal_type != SignalType::Midi) { + if audio_cv_idx < self.input_buffers.len() { + let fill_value = match port.signal_type { + SignalType::Audio => 0.0, // Silence for audio + SignalType::CV => f32::NAN, // Sentinel for CV + SignalType::Midi => unreachable!(), // Already filtered out + }; + self.input_buffers[audio_cv_idx].fill(fill_value); + audio_cv_idx += 1; + } + } + + // Clear MIDI input buffers + for i in 0..num_midi_inputs { + if i < self.midi_input_buffers.len() { + self.midi_input_buffers[i].clear(); + } + } + + // Collect edge info into stack array to avoid heap allocation + // (need to collect because we borrow graph immutably for source node data) + const MAX_EDGES: usize = 32; + let mut edge_info: [(NodeIndex, usize, usize); MAX_EDGES] = [(NodeIndex::new(0), 0, 0); MAX_EDGES]; + let mut edge_count = 0; + for edge in self.graph.edges_directed(node_idx, Direction::Incoming) { + if edge_count < MAX_EDGES { + edge_info[edge_count] = (edge.source(), edge.weight().from_port, edge.weight().to_port); + edge_count += 1; + } + } + + for ei in 0..edge_count { + let (source_idx, from_port, to_port) = edge_info[ei]; + let source_node = &self.graph[source_idx]; + + // Determine source port type + if from_port < source_node.node.outputs().len() { + let source_port_type = source_node.node.outputs()[from_port].signal_type; + + match source_port_type { + SignalType::Audio | SignalType::CV => { + // Map from global port index to audio/CV-only port index + // (input_buffers only contains audio/CV entries, not MIDI) + let audio_cv_port_idx = inputs.iter() + .take(to_port + 1) + .filter(|p| p.signal_type != SignalType::Midi) + .count().saturating_sub(1); + + // Copy audio/CV data + if audio_cv_port_idx < num_audio_cv_inputs && from_port < source_node.output_buffers.len() { + let source_buffer = &source_node.output_buffers[from_port]; + if audio_cv_port_idx < self.input_buffers.len() { + for (dst, src) in self.input_buffers[audio_cv_port_idx].iter_mut().zip(source_buffer.iter()) { + // If dst is NaN (unconnected), replace it; otherwise add (for mixing) + if dst.is_nan() { + *dst = *src; + } else { + *dst += src; + } + } + } + } + } + SignalType::Midi => { + // Copy MIDI events + // Map from global port index to MIDI-only port index + let midi_port_idx = inputs.iter() + .take(to_port + 1) + .filter(|p| p.signal_type == SignalType::Midi) + .count() - 1; + + let source_midi_idx = source_node.node.outputs().iter() + .take(from_port + 1) + .filter(|p| p.signal_type == SignalType::Midi) + .count() - 1; + + if midi_port_idx < self.midi_input_buffers.len() && + source_midi_idx < source_node.midi_output_buffers.len() { + self.midi_input_buffers[midi_port_idx] + .extend_from_slice(&source_node.midi_output_buffers[source_midi_idx]); + } + } + } + } + } + + // Prepare audio/CV input slices (Audio=stereo process_size, CV=mono frame_count) + let input_slices: Vec<&[f32]> = (0..num_audio_cv_inputs) + .map(|i| { + if i < self.input_buffers.len() { + let slice_size = match audio_cv_input_types.get(i) { + Some(&SignalType::Audio) => process_size, + _ => frame_count, + }; + &self.input_buffers[i][..slice_size.min(self.input_buffers[i].len())] + } else { + &[][..] + } + }) + .collect(); + + // Prepare MIDI input slices + let midi_input_slices: Vec<&[MidiEvent]> = (0..num_midi_inputs) + .map(|i| { + if i < self.midi_input_buffers.len() { + &self.midi_input_buffers[i][..] + } else { + &[][..] + } + }) + .collect(); + + // Get mutable access to output buffers + let node = &mut self.graph[node_idx]; + let outputs = node.node.outputs(); + let num_midi_outputs = outputs.iter().filter(|p| p.signal_type == SignalType::Midi).count(); + // Collect output signal types for correct buffer sizing + let output_signal_types: Vec = outputs.iter().map(|p| p.signal_type).collect(); + + // Create mutable slices for audio/CV outputs (Audio=stereo, CV=mono) + let mut output_slices: Vec<&mut [f32]> = Vec::new(); + for (i, buf) in node.output_buffers.iter_mut().enumerate() { + let signal_type = output_signal_types.get(i).copied().unwrap_or(SignalType::CV); + if signal_type == SignalType::Midi { continue; } + let slice_size = match signal_type { + SignalType::Audio => process_size, + _ => frame_count, + }; + let len = buf.len(); + output_slices.push(&mut buf[..slice_size.min(len)]); + } + + // Create mutable references for MIDI outputs + let mut midi_output_refs: Vec<&mut Vec> = node.midi_output_buffers + .iter_mut() + .take(num_midi_outputs) + .collect(); + + // Process the node with both audio/CV and MIDI + node.node.process(&input_slices, &mut output_slices, &midi_input_slices, &mut midi_output_refs, self.sample_rate); + } + + // Mix output node's first output into the provided buffer + if let Some(output_idx) = self.output_node { + if let Some(output_node) = self.graph.node_weight(output_idx) { + if !output_node.output_buffers.is_empty() { + let len = output_buffer.len().min(output_node.output_buffers[0].len()); + for i in 0..len { + output_buffer[i] += output_node.output_buffers[0][i]; + } + } + } + } + } + + /// Get node by index + pub fn get_node(&self, idx: NodeIndex) -> Option<&dyn AudioNode> { + self.graph.node_weight(idx).map(|n| &*n.node) + } + + pub fn get_node_mut(&mut self, idx: NodeIndex) -> Option<&mut (dyn AudioNode + 'static)> { + self.graph.node_weight_mut(idx).map(|n| &mut *n.node) + } + + /// Get oscilloscope data from a specific node + pub fn get_oscilloscope_data(&self, idx: NodeIndex, sample_count: usize) -> Option> { + self.get_node(idx).and_then(|node| node.get_oscilloscope_data(sample_count)) + } + + /// Get oscilloscope CV data from a specific node + pub fn get_oscilloscope_cv_data(&self, idx: NodeIndex, sample_count: usize) -> Option> { + self.get_node(idx).and_then(|node| node.get_oscilloscope_cv_data(sample_count)) + } + + /// Get node by index (read-only) + pub fn get_graph_node(&self, idx: NodeIndex) -> Option<&GraphNode> { + self.graph.node_weight(idx) + } + + /// Get node mutably by index + /// Note: Due to lifetime constraints with trait objects, this returns a mutable reference + /// to the GraphNode, from which you can access the node + pub fn get_graph_node_mut(&mut self, idx: NodeIndex) -> Option<&mut GraphNode> { + self.graph.node_weight_mut(idx) + } + + /// Get all node indices + pub fn node_indices(&self) -> impl Iterator + '_ { + self.graph.node_indices() + } + + /// Reallocate a node's output buffers to match its current port list. + /// + /// Must be called after `SubtrackInputsNode::update_subtracks` changes the port count, + /// since `GraphNode.output_buffers` was allocated at `add_node` time. + pub fn reallocate_node_output_buffers(&mut self, idx: NodeIndex, buffer_size: usize) { + if let Some(graph_node) = self.graph.node_weight_mut(idx) { + let outputs = graph_node.node.outputs(); + graph_node.output_buffers.clear(); + for port in outputs.iter() { + match port.signal_type { + super::types::SignalType::Audio => graph_node.output_buffers.push(vec![0.0; buffer_size * 2]), + super::types::SignalType::CV => graph_node.output_buffers.push(vec![0.0; buffer_size]), + super::types::SignalType::Midi => graph_node.output_buffers.push(vec![]), + } + } + self.topo_cache = None; + } + } + + /// Remove all edges going OUT of a specific output port of a node. + pub fn disconnect_output_port(&mut self, node: NodeIndex, port: usize) { + let edges: Vec<_> = self.graph + .edges_directed(node, petgraph::Direction::Outgoing) + .filter(|e| e.weight().from_port == port) + .map(|e| e.id()) + .collect(); + for edge_id in edges { + self.graph.remove_edge(edge_id); + } + self.topo_cache = None; + } + + /// Remove all edges going INTO a node (all input connections). + pub fn disconnect_all_inputs(&mut self, node: NodeIndex) { + let edges: Vec<_> = self.graph + .edges_directed(node, petgraph::Direction::Incoming) + .map(|e| e.id()) + .collect(); + for edge_id in edges { + self.graph.remove_edge(edge_id); + } + self.topo_cache = None; + } + + /// Get all connections + pub fn connections(&self) -> impl Iterator + '_ { + self.graph.edge_references().map(|e| (e.source(), e.target(), e.weight())) + } + + /// Reset all nodes in the graph + pub fn reset(&mut self) { + // Collect indices first to avoid borrow checker issues + let indices: Vec<_> = self.graph.node_indices().collect(); + for node_idx in indices { + if let Some(node) = self.graph.node_weight_mut(node_idx) { + node.node.reset(); + } + } + } + + /// Clone the graph structure with all nodes and connections + pub fn clone_graph(&self) -> Self { + let mut new_graph = Self::new(self.sample_rate, self.buffer_size); + + // Map from old NodeIndex to new NodeIndex + let mut index_map = std::collections::HashMap::new(); + + // Clone all nodes + for node_idx in self.graph.node_indices() { + if let Some(graph_node) = self.graph.node_weight(node_idx) { + let cloned_node = graph_node.node.clone_node(); + let new_idx = new_graph.add_node(cloned_node); + index_map.insert(node_idx, new_idx); + } + } + + // Clone all connections + for edge in self.graph.edge_references() { + let source = edge.source(); + let target = edge.target(); + let conn = edge.weight(); + + if let (Some(&new_source), Some(&new_target)) = (index_map.get(&source), index_map.get(&target)) { + let _ = new_graph.connect(new_source, conn.from_port, new_target, conn.to_port); + } + } + + // Clone MIDI targets + for &old_target in &self.midi_targets { + if let Some(&new_target) = index_map.get(&old_target) { + new_graph.set_midi_target(new_target, true); + } + } + + // Clone output node reference + if let Some(old_output) = self.output_node { + if let Some(&new_output) = index_map.get(&old_output) { + new_graph.output_node = Some(new_output); + } + } + + // Clone frontend groups + new_graph.frontend_groups = self.frontend_groups.clone(); + + new_graph + } + + /// Set frontend-only group definitions (stored opaquely for persistence) + pub fn set_frontend_groups(&mut self, groups: Vec) { + self.frontend_groups = groups; + } + + /// Serialize the graph to a preset + pub fn to_preset(&self, name: impl Into) -> crate::audio::node_graph::preset::GraphPreset { + use crate::audio::node_graph::preset::{GraphPreset, SerializedConnection, SerializedNode}; + use crate::audio::node_graph::nodes::{VoiceAllocatorNode, MixerNode, SubtrackInputsNode}; + + let mut preset = GraphPreset::new(name); + + // Serialize all nodes + for node_idx in self.graph.node_indices() { + if let Some(graph_node) = self.graph.node_weight(node_idx) { + let node = &graph_node.node; + let node_id = node_idx.index() as u32; + + let mut serialized = SerializedNode::new(node_id, node.node_type()); + + // Get all parameters + for param in node.parameters() { + let value = node.get_parameter(param.id); + serialized.set_parameter(param.id, value); + } + + // Save port count for dynamic-port nodes so they round-trip correctly + if node.node_type() == "Mixer" { + if let Some(mixer) = node.as_any().downcast_ref::() { + serialized.num_ports = Some(mixer.num_inputs() as u32); + } + } + if node.node_type() == "SubtrackInputs" { + if let Some(si) = node.as_any().downcast_ref::() { + serialized.num_ports = Some(si.num_subtracks() as u32); + serialized.port_names = si.subtracks().iter().map(|(_, name)| name.clone()).collect(); + } + } + + // For VoiceAllocator nodes, serialize the template graph + if node.node_type() == "VoiceAllocator" { + // Downcast using safe Any trait + if let Some(va_node) = node.as_any().downcast_ref::() { + let template_preset = va_node.template_graph().to_preset("template"); + serialized.template_graph = Some(Box::new(template_preset)); + } + } + + // For SimpleSampler nodes, serialize the loaded sample + if node.node_type() == "SimpleSampler" { + use crate::audio::node_graph::nodes::SimpleSamplerNode; + use crate::audio::node_graph::preset::{EmbeddedSampleData, SampleData}; + use base64::{Engine as _, engine::general_purpose}; + + // Downcast using safe Any trait + if let Some(sampler_node) = node.as_any().downcast_ref::() { + if let Some(sample_path) = sampler_node.get_sample_path() { + // Check file size + let should_embed = std::fs::metadata(sample_path) + .map(|m| m.len() < 100_000) // < 100KB + .unwrap_or(false); + + if should_embed { + // Embed the sample data + let (sample_data, sample_rate) = sampler_node.get_sample_data_for_embedding(); + + // Convert f32 samples to bytes + let bytes: Vec = sample_data + .iter() + .flat_map(|&f| f.to_le_bytes()) + .collect(); + + // Encode to base64 + let data_base64 = general_purpose::STANDARD.encode(&bytes); + + serialized.sample_data = Some(SampleData::SimpleSampler { + file_path: Some(sample_path.to_string()), + embedded_data: Some(EmbeddedSampleData { + data_base64, + sample_rate: sample_rate as u32, + }), + }); + } else { + // Just save the file path + serialized.sample_data = Some(SampleData::SimpleSampler { + file_path: Some(sample_path.to_string()), + embedded_data: None, + }); + } + } + } + } + + // For MultiSampler nodes, serialize all loaded layers + if node.node_type() == "MultiSampler" { + use crate::audio::node_graph::nodes::MultiSamplerNode; + use crate::audio::node_graph::preset::{EmbeddedSampleData, LayerData, SampleData}; + use base64::{Engine as _, engine::general_purpose}; + + // Downcast using safe Any trait + if let Some(multi_sampler_node) = node.as_any().downcast_ref::() { + let layers_info = multi_sampler_node.get_layers_info(); + if !layers_info.is_empty() { + let layers: Vec = layers_info + .iter() + .enumerate() + .map(|(layer_index, info)| { + // Check if we should embed this layer + let should_embed = std::fs::metadata(&info.file_path) + .map(|m| m.len() < 100_000) // < 100KB + .unwrap_or(false); + + let embedded_data = if should_embed { + // Get the sample data for this layer + if let Some((sample_data, sample_rate)) = multi_sampler_node.get_layer_data(layer_index) { + // Convert f32 samples to bytes + let bytes: Vec = sample_data + .iter() + .flat_map(|&f| f.to_le_bytes()) + .collect(); + + // Encode to base64 + let data_base64 = general_purpose::STANDARD.encode(&bytes); + + Some(EmbeddedSampleData { + data_base64, + sample_rate: sample_rate as u32, + }) + } else { + None + } + } else { + None + }; + + LayerData { + file_path: Some(info.file_path.clone()), + embedded_data, + key_min: info.key_min, + key_max: info.key_max, + root_key: info.root_key, + velocity_min: info.velocity_min, + velocity_max: info.velocity_max, + loop_start: info.loop_start, + loop_end: info.loop_end, + loop_mode: info.loop_mode, + } + }) + .collect(); + serialized.sample_data = Some(SampleData::MultiSampler { layers }); + } + } + } + + // For Script nodes, serialize the source code + if node.node_type() == "Script" { + use crate::audio::node_graph::nodes::ScriptNode; + if let Some(script_node) = node.as_any().downcast_ref::() { + let source = script_node.source_code(); + if !source.is_empty() { + serialized.script_source = Some(source.to_string()); + } + } + } + + // For AutomationInput nodes, serialize display name and keyframes + if node.node_type() == "AutomationInput" { + use crate::audio::node_graph::nodes::{AutomationInputNode, InterpolationType}; + use crate::audio::node_graph::preset::SerializedKeyframe; + if let Some(auto_node) = node.as_any().downcast_ref::() { + serialized.automation_display_name = Some(auto_node.display_name().to_string()); + serialized.automation_keyframes = auto_node.keyframes().iter().map(|kf| { + SerializedKeyframe { + time: kf.time.0, + value: kf.value, + interpolation: match kf.interpolation { + InterpolationType::Linear => "linear", + InterpolationType::Bezier => "bezier", + InterpolationType::Step => "step", + InterpolationType::Hold => "hold", + }.to_string(), + ease_out: kf.ease_out, + ease_in: kf.ease_in, + } + }).collect(); + } + } + + // For AmpSim nodes, serialize the model path + if node.node_type() == "AmpSim" { + use crate::audio::node_graph::nodes::AmpSimNode; + if let Some(amp_sim) = node.as_any().downcast_ref::() { + serialized.nam_model_path = amp_sim.model_path().map(|s| s.to_string()); + } + } + + // Save position if available + if let Some(pos) = self.get_node_position(node_idx) { + serialized.set_position(pos.0, pos.1); + } + + preset.add_node(serialized); + } + } + + // Serialize connections + for edge in self.graph.edge_references() { + let source = edge.source(); + let target = edge.target(); + let conn = edge.weight(); + + preset.add_connection(SerializedConnection { + from_node: source.index() as u32, + from_port: conn.from_port, + to_node: target.index() as u32, + to_port: conn.to_port, + }); + } + + // MIDI targets + preset.midi_targets = self.midi_targets.iter().map(|idx| idx.index() as u32).collect(); + + // Output node + preset.output_node = self.output_node.map(|idx| idx.index() as u32); + + // Frontend groups (stored opaquely) + preset.groups = self.frontend_groups.clone(); + + preset + } + + /// Deserialize a preset into the graph + pub fn from_preset(preset: &crate::audio::node_graph::preset::GraphPreset, sample_rate: u32, buffer_size: usize, preset_base_path: Option<&std::path::Path>, embedded_assets: Option<&std::collections::HashMap>>) -> Result { + use crate::audio::node_graph::nodes::*; + use petgraph::stable_graph::NodeIndex; + use std::collections::HashMap; + + // Helper function to resolve sample paths relative to preset + let resolve_sample_path = |path: &str| -> String { + let path_obj = std::path::Path::new(path); + + // If path is absolute, use it as-is + if path_obj.is_absolute() { + return path.to_string(); + } + + // If we have a base path and the path is relative, resolve it + if let Some(base) = preset_base_path { + let resolved = base.join(path); + resolved.to_string_lossy().to_string() + } else { + // No base path, use path as-is + path.to_string() + } + }; + + let mut graph = Self::new(sample_rate, buffer_size); + let mut index_map: HashMap = HashMap::new(); + + // Pre-pass: compute required min port count for dynamic-port nodes from the connection list. + // This ensures old presets (without num_ports) still size correctly regardless of + // connection-restoration order. + let mut required_ports: HashMap = HashMap::new(); + for conn in &preset.connections { + let entry = required_ports.entry(conn.to_node).or_insert(0); + *entry = (*entry).max(conn.to_port + 2); // port N + 1 spare + } + + // Create all nodes + for serialized_node in &preset.nodes { + // Create the node based on type + let mut node = crate::audio::node_graph::nodes::create_node(&serialized_node.node_type, sample_rate, buffer_size) + .ok_or_else(|| format!("Unknown node type: {}", serialized_node.node_type))?; + + // Pre-size dynamic-port nodes before graph.add_node() so output buffers are + // allocated at the correct size. num_ports takes priority; fall back to + // connection-count inference so old presets without num_ports still work. + if serialized_node.node_type == "Mixer" { + use crate::audio::node_graph::nodes::MixerNode; + if let Some(mixer) = node.as_any_mut().downcast_mut::() { + let from_conns = required_ports.get(&serialized_node.id).copied().unwrap_or(1); + let target = serialized_node.num_ports.map(|n| n as usize).unwrap_or(0).max(from_conns).max(1); + mixer.resize(target); + } + } + if serialized_node.node_type == "SubtrackInputs" { + use crate::audio::node_graph::nodes::SubtrackInputsNode; + if let Some(si) = node.as_any_mut().downcast_mut::() { + let from_conns = required_ports.get(&serialized_node.id).copied().unwrap_or(0); + let target = serialized_node.num_ports.map(|n| n as usize).unwrap_or(0).max(from_conns); + if target > 0 { + let subtracks = (0..target) + .map(|i| (0u32, format!("Subtrack {}", i + 1))) + .collect(); + si.update_subtracks(subtracks, buffer_size); + } + } + } + + // VoiceAllocator needs its template graph deserialized and set + if serialized_node.node_type == "VoiceAllocator" { + if let Some(ref template_preset) = serialized_node.template_graph { + if let Some(va) = node.as_any_mut().downcast_mut::() { + let template_graph = Self::from_preset(template_preset, sample_rate, buffer_size, preset_base_path, embedded_assets)?; + *va.template_graph_mut() = template_graph; + va.rebuild_voices(); + } + } + } + + let node_idx = graph.add_node(node); + index_map.insert(serialized_node.id, node_idx); + + // Restore script source for Script nodes (must come before parameter setting + // since set_script rebuilds parameters) + if let Some(ref source) = serialized_node.script_source { + if serialized_node.node_type == "Script" { + use crate::audio::node_graph::nodes::ScriptNode; + if let Some(graph_node) = graph.graph.node_weight_mut(node_idx) { + if let Some(script_node) = graph_node.node.as_any_mut().downcast_mut::() { + if let Err(e) = script_node.set_script(source) { + eprintln!("Warning: failed to compile script for node {}: {}", serialized_node.id, e); + } + } + } + } + } + + // Set parameters (after script compilation so param slots exist) + for (¶m_id, &value) in &serialized_node.parameters { + if let Some(graph_node) = graph.graph.node_weight_mut(node_idx) { + graph_node.node.set_parameter(param_id, value); + } + } + + // Restore sample data for sampler nodes + if let Some(ref sample_data) = serialized_node.sample_data { + match sample_data { + crate::audio::node_graph::preset::SampleData::SimpleSampler { file_path, embedded_data } => { + // Load sample into SimpleSampler + if let Some(graph_node) = graph.graph.node_weight_mut(node_idx) { + // Downcast using safe Any trait + if let Some(sampler_node) = graph_node.node.as_any_mut().downcast_mut::() { + + // Try embedded data first, then fall back to file path + if let Some(ref embedded) = embedded_data { + use base64::{Engine as _, engine::general_purpose}; + + // Decode base64 + if let Ok(bytes) = general_purpose::STANDARD.decode(&embedded.data_base64) { + // Convert bytes back to f32 samples + let samples: Vec = bytes + .chunks_exact(4) + .map(|chunk| { + f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]) + }) + .collect(); + + sampler_node.set_sample(samples, embedded.sample_rate as f32); + } + } else if let Some(ref path) = file_path { + // Check embedded assets map first (from .lbins bundle) + let loaded = if let Some(assets) = embedded_assets { + if let Some(bytes) = assets.get(path.as_str()) { + match crate::audio::sample_loader::load_audio_from_bytes(bytes, path) { + Ok(data) => { + sampler_node.set_sample(data.samples, data.sample_rate as f32); + true + } + Err(e) => { + eprintln!("Failed to decode bundled sample {}: {}", path, e); + false + } + } + } else { false } + } else { false }; + + if !loaded { + // Fall back to loading from filesystem + let resolved_path = resolve_sample_path(path); + if let Err(e) = sampler_node.load_sample_from_file(&resolved_path) { + eprintln!("Failed to load sample from {}: {}", resolved_path, e); + } + } + } + } + } + } + crate::audio::node_graph::preset::SampleData::MultiSampler { layers } => { + // Load layers into MultiSampler + if let Some(graph_node) = graph.graph.node_weight_mut(node_idx) { + // Downcast using safe Any trait + if let Some(multi_sampler_node) = graph_node.node.as_any_mut().downcast_mut::() { + for layer in layers { + // Try embedded data first, then fall back to file path + if let Some(ref embedded) = layer.embedded_data { + use base64::{Engine as _, engine::general_purpose}; + + // Decode base64 + if let Ok(bytes) = general_purpose::STANDARD.decode(&embedded.data_base64) { + // Convert bytes back to f32 samples + let samples: Vec = bytes + .chunks_exact(4) + .map(|chunk| { + f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]) + }) + .collect(); + + multi_sampler_node.add_layer( + samples, + embedded.sample_rate as f32, + layer.key_min, + layer.key_max, + layer.root_key, + layer.velocity_min, + layer.velocity_max, + layer.loop_start, + layer.loop_end, + layer.loop_mode, + ); + } + } else if let Some(ref path) = layer.file_path { + // Check embedded assets map first (from .lbins bundle) + let loaded = if let Some(assets) = embedded_assets { + if let Some(bytes) = assets.get(path.as_str()) { + match crate::audio::sample_loader::load_audio_from_bytes(bytes, path) { + Ok(data) => { + multi_sampler_node.add_layer( + data.samples, + data.sample_rate as f32, + layer.key_min, + layer.key_max, + layer.root_key, + layer.velocity_min, + layer.velocity_max, + layer.loop_start, + layer.loop_end, + layer.loop_mode, + ); + true + } + Err(e) => { + eprintln!("Failed to decode bundled sample layer {}: {}", path, e); + false + } + } + } else { false } + } else { false }; + + if !loaded { + // Fall back to loading from filesystem + let resolved_path = resolve_sample_path(path); + if let Err(e) = multi_sampler_node.load_layer_from_file( + &resolved_path, + layer.key_min, + layer.key_max, + layer.root_key, + layer.velocity_min, + layer.velocity_max, + layer.loop_start, + layer.loop_end, + layer.loop_mode, + ) { + eprintln!("Failed to load sample layer from {}: {}", resolved_path, e); + } + } + } + } + } + } + } + } + } + + // Restore NAM model for AmpSim nodes + if let Some(ref model_path) = serialized_node.nam_model_path { + if serialized_node.node_type == "AmpSim" { + use crate::audio::node_graph::nodes::AmpSimNode; + eprintln!("[AmpSim] Preset restore: nam_model_path={:?}", model_path); + if let Some(graph_node) = graph.graph.node_weight_mut(node_idx) { + if let Some(amp_sim) = graph_node.node.as_any_mut().downcast_mut::() { + let result = if let Some(bundled_name) = model_path.strip_prefix("bundled:") { + eprintln!("[AmpSim] Preset: loading bundled model {:?}", bundled_name); + amp_sim.load_bundled_model(bundled_name) + } else if let Some(bytes) = embedded_assets.and_then(|a| a.get(model_path.as_str())) { + eprintln!("[AmpSim] Preset: loading from bundle {:?}", model_path); + amp_sim.load_model_from_bytes(model_path, bytes) + } else { + let resolved_path = resolve_sample_path(model_path); + eprintln!("[AmpSim] Preset: loading from file {:?}", resolved_path); + amp_sim.load_model(&resolved_path) + }; + match &result { + Ok(()) => eprintln!("[AmpSim] Preset: model loaded successfully"), + Err(e) => eprintln!("[AmpSim] Preset: failed to load NAM model: {}", e), + } + } + } + } + } + + // Restore AutomationInput display name and keyframes + if serialized_node.node_type == "AutomationInput" { + use crate::audio::node_graph::nodes::{AutomationInputNode, AutomationKeyframe, InterpolationType}; + if let Some(graph_node) = graph.graph.node_weight_mut(node_idx) { + if let Some(auto_node) = graph_node.node.as_any_mut().downcast_mut::() { + if let Some(ref name) = serialized_node.automation_display_name { + auto_node.set_display_name(name.clone()); + } + if !serialized_node.automation_keyframes.is_empty() { + auto_node.clear_keyframes(); + for kf in &serialized_node.automation_keyframes { + auto_node.add_keyframe(AutomationKeyframe { + time: crate::time::Beats(kf.time), + value: kf.value, + interpolation: match kf.interpolation.as_str() { + "bezier" => InterpolationType::Bezier, + "step" => InterpolationType::Step, + "hold" => InterpolationType::Hold, + _ => InterpolationType::Linear, + }, + ease_out: kf.ease_out, + ease_in: kf.ease_in, + }); + } + } + } + } + } + + // Restore position + graph.set_node_position(node_idx, serialized_node.position.0, serialized_node.position.1); + } + + // Create connections + for conn in &preset.connections { + let from_idx = index_map.get(&conn.from_node) + .ok_or_else(|| format!("Connection from unknown node {}", conn.from_node))?; + let to_idx = index_map.get(&conn.to_node) + .ok_or_else(|| format!("Connection to unknown node {}", conn.to_node))?; + + graph.connect(*from_idx, conn.from_port, *to_idx, conn.to_port) + .map_err(|e| format!("Failed to connect nodes: {:?}", e))?; + } + + // Set MIDI targets + for &target_id in &preset.midi_targets { + if let Some(&target_idx) = index_map.get(&target_id) { + graph.set_midi_target(target_idx, true); + } + } + + // Set output node + if let Some(output_id) = preset.output_node { + if let Some(&output_idx) = index_map.get(&output_id) { + graph.output_node = Some(output_idx); + } + } + + // Restore frontend groups (stored opaquely) + graph.frontend_groups = preset.groups.clone(); + + Ok(graph) + } +} diff --git a/daw-backend/src/audio/node_graph/lbins.rs b/daw-backend/src/audio/node_graph/lbins.rs new file mode 100644 index 0000000..3516301 --- /dev/null +++ b/daw-backend/src/audio/node_graph/lbins.rs @@ -0,0 +1,192 @@ +/// Load and save `.lbins` instrument bundle files. +/// +/// A `.lbins` file is a ZIP archive with the following layout: +/// +/// ``` +/// instrument.lbins (ZIP) +/// ├── instrument.json ← GraphPreset JSON (existing schema) +/// ├── samples/ +/// │ ├── kick.wav +/// │ └── snare.flac +/// └── models/ +/// └── amp.nam +/// ``` +/// +/// All asset paths in `instrument.json` are ZIP-relative +/// (e.g. `"samples/kick.wav"`, `"models/amp.nam"`). + +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::path::Path; + +use crate::audio::node_graph::preset::{GraphPreset, SampleData}; + +/// Load a `.lbins` file. +/// +/// Returns the deserialized `GraphPreset` together with a map of all +/// non-JSON entries keyed by their ZIP-relative path (e.g. `"samples/kick.wav"`). +pub fn load_lbins(path: &Path) -> Result<(GraphPreset, HashMap>), String> { + 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> = 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/` or `models/`). +/// 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)> = 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 { + // 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(()) +} diff --git a/daw-backend/src/audio/node_graph/mod.rs b/daw-backend/src/audio/node_graph/mod.rs new file mode 100644 index 0000000..c42e8a1 --- /dev/null +++ b/daw-backend/src/audio/node_graph/mod.rs @@ -0,0 +1,11 @@ +mod graph; +mod node_trait; +mod types; +pub mod lbins; +pub mod nodes; +pub mod preset; + +pub use graph::{Connection, GraphNode, AudioGraph}; +pub use node_trait::{AudioNode, cv_input_or_default}; +pub use preset::{GraphPreset, PresetMetadata, SerializedConnection, SerializedNode, SerializedGroup, SerializedBoundaryConnection}; +pub use types::{ConnectionError, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; diff --git a/daw-backend/src/audio/node_graph/node_trait.rs b/daw-backend/src/audio/node_graph/node_trait.rs new file mode 100644 index 0000000..38eca19 --- /dev/null +++ b/daw-backend/src/audio/node_graph/node_trait.rs @@ -0,0 +1,110 @@ +use super::types::{NodeCategory, NodePort, Parameter}; +use crate::audio::midi::MidiEvent; + +/// Custom node trait for audio processing nodes +/// +/// All nodes must be Send to be usable in the audio thread. +/// Nodes should be real-time safe: no allocations, no blocking operations. +pub trait AudioNode: Send { + /// Node category for UI organization + fn category(&self) -> NodeCategory; + + /// Input port definitions + fn inputs(&self) -> &[NodePort]; + + /// Output port definitions + fn outputs(&self) -> &[NodePort]; + + /// User-facing parameters + fn parameters(&self) -> &[Parameter]; + + /// Set parameter by ID + fn set_parameter(&mut self, id: u32, value: f32); + + /// Get parameter by ID + fn get_parameter(&self, id: u32) -> f32; + + /// Process audio buffers + /// + /// # Arguments + /// * `inputs` - Audio/CV input buffers for each input port + /// * `outputs` - Audio/CV output buffers for each output port + /// * `midi_inputs` - MIDI event buffers for each MIDI input port + /// * `midi_outputs` - MIDI event buffers for each MIDI output port + /// * `sample_rate` - Current sample rate in Hz + fn process( + &mut self, + inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + midi_inputs: &[&[MidiEvent]], + midi_outputs: &mut [&mut Vec], + sample_rate: u32, + ); + + /// Handle MIDI events (for nodes with MIDI inputs) + fn handle_midi(&mut self, _event: &MidiEvent) { + // Default: do nothing + } + + /// Reset internal state (clear delays, resonances, etc.) + fn reset(&mut self); + + /// Get the node type name (for serialization) + fn node_type(&self) -> &str; + + /// Get a unique identifier for this node instance + fn name(&self) -> &str; + + /// Clone this node into a new boxed instance + /// Required for VoiceAllocator to create multiple instances + fn clone_node(&self) -> Box; + + /// Get oscilloscope data if this is an oscilloscope node + /// Returns None for non-oscilloscope nodes + fn get_oscilloscope_data(&self, _sample_count: usize) -> Option> { + None + } + + /// Get oscilloscope CV data if this is an oscilloscope node + /// Returns None for non-oscilloscope nodes + fn get_oscilloscope_cv_data(&self, _sample_count: usize) -> Option> { + None + } + + /// Downcast to `&mut dyn Any` for type-specific operations + fn as_any_mut(&mut self) -> &mut dyn std::any::Any; + + /// Downcast to `&dyn Any` for type-specific read-only operations + fn as_any(&self) -> &dyn std::any::Any; +} + +/// Helper function for CV inputs with optional connections +/// +/// Returns the input value if connected (not NaN), otherwise returns the default value. +/// This implements "Blender-style" input behavior where parameters are replaced by +/// connected inputs. +/// +/// # Arguments +/// * `inputs` - Input buffer array from process() +/// * `port` - Input port index +/// * `frame` - Current frame index +/// * `default` - Default value to use when input is unconnected +/// +/// # Returns +/// The input value if connected, otherwise the default value +#[inline] +pub fn cv_input_or_default(inputs: &[&[f32]], port: usize, frame: usize, default: f32) -> f32 { + if port < inputs.len() && frame < inputs[port].len() { + let value = inputs[port][frame]; + if value.is_nan() { + // Unconnected: use default parameter value + default + } else { + // Connected: use input signal + value + } + } else { + // No input buffer: use default + default + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/add_as_any.sh b/daw-backend/src/audio/node_graph/nodes/add_as_any.sh new file mode 100755 index 0000000..039eb6b --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/add_as_any.sh @@ -0,0 +1,46 @@ +#!/bin/bash +for file in *.rs; do + if [ "$file" = "mod.rs" ]; then + continue + fi + + echo "Processing $file" + + # Create a backup + cp "$file" "$file.bak" + + # Add as_any() method right after as_any_mut() + awk ' + { + lines[NR] = $0 + if (/fn as_any_mut\(&mut self\)/) { + # Found as_any_mut, look for its closing brace + found_method = NR + } + if (found_method > 0 && /^ }$/ && !inserted) { + closing_brace = NR + inserted = 1 + } + } + END { + for (i = 1; i <= NR; i++) { + print lines[i] + if (i == closing_brace) { + print "" + print " fn as_any(&self) -> &dyn std::any::Any {" + print " self" + print " }" + } + } + } + ' "$file.bak" > "$file" + + # Verify the change was made + if grep -q "fn as_any(&self)" "$file"; then + echo " ✓ Successfully added as_any() to $file" + rm "$file.bak" + else + echo " ✗ Failed to add as_any() to $file - restoring backup" + mv "$file.bak" "$file" + fi +done diff --git a/daw-backend/src/audio/node_graph/nodes/adsr.rs b/daw-backend/src/audio/node_graph/nodes/adsr.rs new file mode 100644 index 0000000..b4bdbe9 --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/adsr.rs @@ -0,0 +1,309 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default}; +use crate::audio::midi::MidiEvent; + +const PARAM_ATTACK: u32 = 0; +const PARAM_DECAY: u32 = 1; +const PARAM_SUSTAIN: u32 = 2; +const PARAM_RELEASE: u32 = 3; +const PARAM_CURVE: u32 = 4; + +#[derive(Debug, Clone, Copy, PartialEq)] +enum EnvelopeStage { + Idle, + Attack, + Decay, + Sustain, + Release, +} + +/// Curve shape for envelope segments +#[derive(Debug, Clone, Copy, PartialEq)] +enum CurveType { + Linear, + Exponential, +} + +impl CurveType { + fn from_f32(v: f32) -> Self { + if v >= 0.5 { CurveType::Exponential } else { CurveType::Linear } + } +} + +/// ADSR Envelope Generator +/// Outputs a CV signal (0.0-1.0) based on gate input and ADSR parameters +pub struct ADSRNode { + name: String, + attack: f32, // seconds + decay: f32, // seconds + sustain: f32, // level (0.0-1.0) + release: f32, // seconds + curve: CurveType, + stage: EnvelopeStage, + level: f32, // current envelope level + /// For exponential curves: the coefficient per sample (computed on stage entry) + exp_coeff: f32, + /// For exponential curves: the base level when the stage started + exp_base: f32, + /// For exponential curves: the target level + exp_target: f32, + gate_was_high: bool, + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl ADSRNode { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + + let inputs = vec![ + NodePort::new("Gate", SignalType::CV, 0), + ]; + + let outputs = vec![ + NodePort::new("Envelope Out", SignalType::CV, 0), + ]; + + let parameters = vec![ + Parameter::new(PARAM_ATTACK, "Attack", 0.001, 5.0, 0.01, ParameterUnit::Time), + Parameter::new(PARAM_DECAY, "Decay", 0.001, 5.0, 0.1, ParameterUnit::Time), + Parameter::new(PARAM_SUSTAIN, "Sustain", 0.0, 1.0, 0.7, ParameterUnit::Generic), + Parameter::new(PARAM_RELEASE, "Release", 0.001, 5.0, 0.2, ParameterUnit::Time), + Parameter::new(PARAM_CURVE, "Curve", 0.0, 1.0, 0.0, ParameterUnit::Generic), + ]; + + Self { + name, + attack: 0.01, + decay: 0.1, + sustain: 0.7, + release: 0.2, + curve: CurveType::Linear, + stage: EnvelopeStage::Idle, + level: 0.0, + exp_coeff: 0.0, + exp_base: 0.0, + exp_target: 0.0, + gate_was_high: false, + inputs, + outputs, + parameters, + } + } +} + +impl AudioNode for ADSRNode { + fn category(&self) -> NodeCategory { + NodeCategory::Utility + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, id: u32, value: f32) { + match id { + PARAM_ATTACK => self.attack = value.clamp(0.001, 5.0), + PARAM_DECAY => self.decay = value.clamp(0.001, 5.0), + PARAM_SUSTAIN => self.sustain = value.clamp(0.0, 1.0), + PARAM_RELEASE => self.release = value.clamp(0.001, 5.0), + PARAM_CURVE => self.curve = CurveType::from_f32(value), + _ => {} + } + } + + fn get_parameter(&self, id: u32) -> f32 { + match id { + PARAM_ATTACK => self.attack, + PARAM_DECAY => self.decay, + PARAM_SUSTAIN => self.sustain, + PARAM_RELEASE => self.release, + PARAM_CURVE => match self.curve { CurveType::Linear => 0.0, CurveType::Exponential => 1.0 }, + _ => 0.0, + } + } + + fn process( + &mut self, + inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + sample_rate: u32, + ) { + if outputs.is_empty() { + return; + } + + let output = &mut outputs[0]; + let sample_rate_f32 = sample_rate as f32; + + // CV signals are mono + let frames = output.len(); + + for frame in 0..frames { + // Gate input: when unconnected, defaults to 0.0 (off) + let gate_cv = cv_input_or_default(inputs, 0, frame, 0.0); + let gate_high = gate_cv > 0.5; + + // Detect gate transitions + if gate_high && !self.gate_was_high { + // Note on: Start attack + self.stage = EnvelopeStage::Attack; + if self.curve == CurveType::Exponential { + // For exponential attack, compute coefficient for ~5 time constants + // We overshoot the target slightly so the curve reaches 1.0 naturally + let samples = self.attack * sample_rate_f32; + self.exp_coeff = (-5.0 / samples).exp(); + self.exp_base = self.level; + self.exp_target = 1.0; + } + } else if !gate_high && self.gate_was_high { + // Note off: Start release + self.stage = EnvelopeStage::Release; + if self.curve == CurveType::Exponential { + let samples = self.release * sample_rate_f32; + self.exp_coeff = (-5.0 / samples).exp(); + self.exp_base = self.level; + self.exp_target = 0.0; + } + } + self.gate_was_high = gate_high; + + // Process envelope stage + match self.stage { + EnvelopeStage::Idle => { + self.level = 0.0; + } + EnvelopeStage::Attack => { + match self.curve { + CurveType::Linear => { + let increment = 1.0 / (self.attack * sample_rate_f32); + self.level += increment; + if self.level >= 1.0 { + self.level = 1.0; + self.stage = EnvelopeStage::Decay; + } + } + CurveType::Exponential => { + // Asymptotic approach: level moves toward overshoot target + // Using target of 1.0 + small overshoot so we actually reach 1.0 + let overshoot_target = 1.0 + (1.0 - self.exp_base) * 0.01; + self.level = overshoot_target - (overshoot_target - self.level) * self.exp_coeff; + if self.level >= 1.0 { + self.level = 1.0; + self.stage = EnvelopeStage::Decay; + // Set up decay exponential + let samples = self.decay * sample_rate_f32; + self.exp_coeff = (-5.0 / samples).exp(); + self.exp_base = 1.0; + self.exp_target = self.sustain; + } + } + } + } + EnvelopeStage::Decay => { + let target = self.sustain; + match self.curve { + CurveType::Linear => { + let decrement = (1.0 - target) / (self.decay * sample_rate_f32); + self.level -= decrement; + if self.level <= target { + self.level = target; + self.stage = EnvelopeStage::Sustain; + } + } + CurveType::Exponential => { + // Exponential decay toward sustain level + self.level = target + (self.level - target) * self.exp_coeff; + if (self.level - target).abs() < 0.001 { + self.level = target; + self.stage = EnvelopeStage::Sustain; + } + } + } + } + EnvelopeStage::Sustain => { + // Hold at sustain level + self.level = self.sustain; + } + EnvelopeStage::Release => { + match self.curve { + CurveType::Linear => { + let decrement = self.level / (self.release * sample_rate_f32); + self.level -= decrement; + if self.level <= 0.001 { + self.level = 0.0; + self.stage = EnvelopeStage::Idle; + } + } + CurveType::Exponential => { + // Exponential decay toward 0 + self.level *= self.exp_coeff; + if self.level <= 0.001 { + self.level = 0.0; + self.stage = EnvelopeStage::Idle; + } + } + } + } + } + + // Write envelope value (CV is mono) + output[frame] = self.level; + } + } + + fn reset(&mut self) { + self.stage = EnvelopeStage::Idle; + self.level = 0.0; + self.exp_coeff = 0.0; + self.exp_base = 0.0; + self.exp_target = 0.0; + self.gate_was_high = false; + } + + fn node_type(&self) -> &str { + "ADSR" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + Box::new(Self { + name: self.name.clone(), + attack: self.attack, + decay: self.decay, + sustain: self.sustain, + release: self.release, + curve: self.curve, + stage: EnvelopeStage::Idle, + level: 0.0, + exp_coeff: 0.0, + exp_base: 0.0, + exp_target: 0.0, + gate_was_high: false, + inputs: self.inputs.clone(), + outputs: self.outputs.clone(), + parameters: self.parameters.clone(), + }) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/amp_sim.rs b/daw-backend/src/audio/node_graph/nodes/amp_sim.rs new file mode 100644 index 0000000..f2a043c --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/amp_sim.rs @@ -0,0 +1,225 @@ +use crate::audio::midi::MidiEvent; +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; +use nam_ffi::NamModel; +use std::path::Path; + +const PARAM_INPUT_GAIN: u32 = 0; +const PARAM_OUTPUT_GAIN: u32 = 1; +const PARAM_MIX: u32 = 2; + +/// Guitar amp simulator node using Neural Amp Modeler (.nam) models. +pub struct AmpSimNode { + name: String, + input_gain: f32, + output_gain: f32, + mix: f32, + + model: Option, + model_path: Option, + + // Mono scratch buffers for NAM processing (NAM is mono-only) + mono_in: Vec, + mono_out: Vec, + + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl AmpSimNode { + pub fn new(name: impl Into) -> 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], + _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 { + // 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 + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/arpeggiator.rs b/daw-backend/src/audio/node_graph/nodes/arpeggiator.rs new file mode 100644 index 0000000..dbe5a8a --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/arpeggiator.rs @@ -0,0 +1,412 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default}; +use crate::audio::midi::MidiEvent; + +const PARAM_MODE: u32 = 0; +const PARAM_DIRECTION: u32 = 1; +const PARAM_OCTAVES: u32 = 2; +const PARAM_RETRIGGER: u32 = 3; + +/// ~1ms gate-off for re-triggering at 48kHz +const RETRIGGER_SAMPLES: u32 = 48; + +#[derive(Debug, Clone, Copy, PartialEq)] +enum ArpMode { + OnePerCycle = 0, + AllPerCycle = 1, +} + +impl ArpMode { + fn from_f32(v: f32) -> Self { + if v.round() as i32 >= 1 { ArpMode::AllPerCycle } else { ArpMode::OnePerCycle } + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +enum ArpDirection { + Up = 0, + Down = 1, + UpDown = 2, + Random = 3, +} + +impl ArpDirection { + fn from_f32(v: f32) -> Self { + match v.round() as i32 { + 1 => ArpDirection::Down, + 2 => ArpDirection::UpDown, + 3 => ArpDirection::Random, + _ => ArpDirection::Up, + } + } +} + +/// Arpeggiator node — takes MIDI input (held chord) and a CV phase input, +/// outputs CV V/Oct + Gate stepping through the held notes. +pub struct ArpeggiatorNode { + name: String, + /// Currently held notes: (note, velocity), kept sorted by pitch + held_notes: Vec<(u8, u8)>, + /// Expanded sequence after applying direction + octaves + sequence: Vec<(u8, u8)>, + /// Current position in the sequence (for OnePerCycle mode) + current_step: usize, + /// Previous phase value for wraparound detection + prev_phase: f32, + /// Countdown for gate re-trigger gap + retrigger_countdown: u32, + /// Current output values + current_voct: f32, + current_gate: f32, + /// Parameters + mode: ArpMode, + direction: ArpDirection, + octaves: u32, + retrigger: bool, + /// For Up/Down direction tracking + going_up: bool, + /// Track whether sequence needs rebuilding + sequence_dirty: bool, + /// Stateful PRNG for random direction + rng_state: u32, + + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl ArpeggiatorNode { + pub fn new(name: impl Into) -> 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], + _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 { + 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 + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/audio_input.rs b/daw-backend/src/audio/node_graph/nodes/audio_input.rs new file mode 100644 index 0000000..13c3e48 --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/audio_input.rs @@ -0,0 +1,127 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, SignalType}; +use crate::audio::midi::MidiEvent; + +/// Audio input node - receives audio from audio track clip playback +/// This node acts as the entry point for audio tracks, injecting clip audio into the effects graph +pub struct AudioInputNode { + name: String, + inputs: Vec, + outputs: Vec, + /// Internal buffer to hold injected audio from clips + /// This is filled externally by AudioTrack::render() before graph processing + audio_buffer: Vec, +} + +impl AudioInputNode { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + + // Audio input node has no inputs - audio is injected externally + let inputs = vec![]; + + // Outputs stereo audio + let outputs = vec![ + NodePort::new("Audio Out", SignalType::Audio, 0), + ]; + + Self { + name, + inputs, + outputs, + audio_buffer: Vec::new(), + } + } + + /// Inject audio from clip playback into this node + /// Should be called by AudioTrack::render() before processing the graph + pub fn inject_audio(&mut self, audio: &[f32]) { + self.audio_buffer.clear(); + self.audio_buffer.extend_from_slice(audio); + } + + /// Clear the internal audio buffer + pub fn clear_buffer(&mut self) { + self.audio_buffer.clear(); + } +} + +impl AudioNode for AudioInputNode { + fn category(&self) -> NodeCategory { + NodeCategory::Input + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &[] // No parameters + } + + fn set_parameter(&mut self, _id: u32, _value: f32) { + // No parameters + } + + fn get_parameter(&self, _id: u32) -> f32 { + 0.0 + } + + fn process( + &mut self, + _inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + _sample_rate: u32, + ) { + if outputs.is_empty() { + return; + } + + let output = &mut outputs[0]; + let len = output.len().min(self.audio_buffer.len()); + + // Copy audio from internal buffer to output + if len > 0 { + output[..len].copy_from_slice(&self.audio_buffer[..len]); + } + + // Clear any remaining samples in output + if output.len() > len { + output[len..].fill(0.0); + } + } + + fn reset(&mut self) { + self.audio_buffer.clear(); + } + + fn node_type(&self) -> &str { + "AudioInput" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + Box::new(Self { + name: self.name.clone(), + inputs: self.inputs.clone(), + outputs: self.outputs.clone(), + audio_buffer: Vec::new(), // Don't clone the buffer, start fresh + }) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/audio_to_cv.rs b/daw-backend/src/audio/node_graph/nodes/audio_to_cv.rs new file mode 100644 index 0000000..a85042f --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/audio_to_cv.rs @@ -0,0 +1,109 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, SignalType}; +use crate::audio::midi::MidiEvent; + +/// Audio to CV converter +/// Directly converts a stereo audio signal to mono CV (averages L+R channels) +pub struct AudioToCVNode { + name: String, + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl AudioToCVNode { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + + let inputs = vec![ + NodePort::new("Audio In", SignalType::Audio, 0), + ]; + + let outputs = vec![ + NodePort::new("CV Out", SignalType::CV, 0), + ]; + + Self { + name, + inputs, + outputs, + parameters: Vec::new(), + } + } +} + +impl AudioNode for AudioToCVNode { + fn category(&self) -> NodeCategory { + NodeCategory::Utility + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, _id: u32, _value: f32) {} + + fn get_parameter(&self, _id: u32) -> f32 { + 0.0 + } + + fn process( + &mut self, + inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + _sample_rate: u32, + ) { + if inputs.is_empty() || outputs.is_empty() { + return; + } + + let input = inputs[0]; + let output = &mut outputs[0]; + + // Audio input is stereo (interleaved L/R), CV output is mono + let audio_frames = input.len() / 2; + let frames = audio_frames.min(output.len()); + + for frame in 0..frames { + let left = input[frame * 2]; + let right = input[frame * 2 + 1]; + output[frame] = (left + right) * 0.5; + } + } + + fn reset(&mut self) {} + + fn node_type(&self) -> &str { + "AudioToCV" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + Box::new(Self { + name: self.name.clone(), + inputs: self.inputs.clone(), + outputs: self.outputs.clone(), + parameters: self.parameters.clone(), + }) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/automation_input.rs b/daw-backend/src/audio/node_graph/nodes/automation_input.rs new file mode 100644 index 0000000..6f28ac8 --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/automation_input.rs @@ -0,0 +1,305 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; +use crate::audio::midi::MidiEvent; +use crate::time::Beats; +use serde::{Deserialize, Serialize}; +use std::sync::{Arc, RwLock}; + +/// Interpolation type for automation curves +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum InterpolationType { + Linear, + Bezier, + Step, + Hold, +} + +/// A single keyframe in an automation curve +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AutomationKeyframe { + pub time: Beats, + /// CV output value + pub value: f32, + /// Interpolation type to next keyframe + pub interpolation: InterpolationType, + /// Bezier ease-out control point (for bezier interpolation) + pub ease_out: (f32, f32), + /// Bezier ease-in control point (for bezier interpolation) + pub ease_in: (f32, f32), +} + +impl AutomationKeyframe { + pub fn new(time: Beats, value: f32) -> Self { + Self { + time, + value, + interpolation: InterpolationType::Linear, + ease_out: (0.58, 1.0), + ease_in: (0.42, 0.0), + } + } +} + +/// Automation Input Node - outputs CV signal controlled by timeline curves +pub struct AutomationInputNode { + name: String, + display_name: String, // User-editable name shown in UI + keyframes: Vec, + outputs: Vec, + parameters: Vec, + /// Shared playback time in beats (set by the graph before processing) + playback_time: Arc>, + /// Current BPM (set by the graph before processing, used for per-sample beat advancement) + bpm: f64, + /// Minimum output value (for UI display range) + pub value_min: f32, + /// Maximum output value (for UI display range) + pub value_max: f32, +} + +impl AutomationInputNode { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + + let outputs = vec![ + NodePort::new("CV Out", SignalType::CV, 0), + ]; + + let parameters = vec![ + Parameter::new(0, "Min", f32::NEG_INFINITY, f32::INFINITY, -1.0, ParameterUnit::Generic), + Parameter::new(1, "Max", f32::NEG_INFINITY, f32::INFINITY, 1.0, ParameterUnit::Generic), + ]; + + Self { + name: name.clone(), + display_name: "Automation".to_string(), + keyframes: vec![AutomationKeyframe::new(Beats::ZERO, 0.0)], + outputs, + parameters, + playback_time: Arc::new(RwLock::new(Beats::ZERO)), + bpm: 120.0, + value_min: -1.0, + value_max: 1.0, + } + } + + pub fn set_playback_time(&mut self, time: Beats) { + if let Ok(mut playback) = self.playback_time.write() { + *playback = time; + } + } + + pub fn set_bpm(&mut self, bpm: f64) { + self.bpm = bpm; + } + + /// Get the display name (shown in UI) + pub fn display_name(&self) -> &str { + &self.display_name + } + + /// Set the display name + pub fn set_display_name(&mut self, name: String) { + self.display_name = name; + } + + /// Add a keyframe to the curve (maintains sorted order by time) + pub fn add_keyframe(&mut self, keyframe: AutomationKeyframe) { + // Find insertion position to maintain sorted order + let pos = self.keyframes.binary_search_by(|kf| { + kf.time.partial_cmp(&keyframe.time).unwrap_or(std::cmp::Ordering::Equal) + }); + + match pos { + Ok(idx) => { + // Replace existing keyframe at same time + self.keyframes[idx] = keyframe; + } + Err(idx) => { + // Insert at correct position + self.keyframes.insert(idx, keyframe); + } + } + } + + pub fn remove_keyframe_at_time(&mut self, time: Beats, tolerance: Beats) -> bool { + if let Some(idx) = self.keyframes.iter().position(|kf| (kf.time - time).abs() < tolerance) { + self.keyframes.remove(idx); + true + } else { + false + } + } + + pub fn update_keyframe(&mut self, keyframe: AutomationKeyframe) { + self.remove_keyframe_at_time(keyframe.time, Beats(0.001)); + self.add_keyframe(keyframe); + } + + /// Get all keyframes + pub fn keyframes(&self) -> &[AutomationKeyframe] { + &self.keyframes + } + + /// Clear all keyframes + pub fn clear_keyframes(&mut self) { + self.keyframes.clear(); + } + + fn evaluate_at_time(&self, time: Beats) -> f32 { + if self.keyframes.is_empty() { + return 0.0; + } + + if time <= self.keyframes[0].time { + return self.keyframes[0].value; + } + + let last_idx = self.keyframes.len() - 1; + if time >= self.keyframes[last_idx].time { + return self.keyframes[last_idx].value; + } + + for i in 0..self.keyframes.len() - 1 { + let kf1 = &self.keyframes[i]; + let kf2 = &self.keyframes[i + 1]; + + if time >= kf1.time && time <= kf2.time { + return self.interpolate(kf1, kf2, time); + } + } + + 0.0 + } + + fn interpolate(&self, kf1: &AutomationKeyframe, kf2: &AutomationKeyframe, time: Beats) -> f32 { + let t = if kf2.time == kf1.time { + 0.0f64 + } else { + (time - kf1.time) / (kf2.time - kf1.time) + } as f32; + + match kf1.interpolation { + InterpolationType::Linear => { + // Simple linear interpolation + kf1.value + (kf2.value - kf1.value) * t + } + InterpolationType::Bezier => { + // Cubic bezier interpolation using control points + let eased_t = self.cubic_bezier_ease(t, kf1.ease_out, kf2.ease_in); + kf1.value + (kf2.value - kf1.value) * eased_t + } + InterpolationType::Step | InterpolationType::Hold => { + // Hold value until next keyframe + kf1.value + } + } + } + + /// Cubic bezier easing function + fn cubic_bezier_ease(&self, t: f32, ease_out: (f32, f32), ease_in: (f32, f32)) -> f32 { + // Simplified cubic bezier for 0,0 -> easeOut -> easeIn -> 1,1 + let u = 1.0 - t; + 3.0 * u * u * t * ease_out.1 + + 3.0 * u * t * t * ease_in.1 + + t * t * t + } +} + +impl AudioNode for AutomationInputNode { + fn category(&self) -> NodeCategory { + NodeCategory::Input + } + + fn inputs(&self) -> &[NodePort] { + &[] // No inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, id: u32, value: f32) { + match id { + 0 => self.value_min = value, + 1 => self.value_max = value, + _ => {} + } + } + + fn get_parameter(&self, id: u32) -> f32 { + match id { + 0 => self.value_min, + 1 => self.value_max, + _ => 0.0, + } + } + + fn process( + &mut self, + _inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + sample_rate: u32, + ) { + if outputs.is_empty() { + return; + } + + let output = &mut outputs[0]; + let length = output.len(); + + let playhead = if let Ok(playback) = self.playback_time.read() { + *playback + } else { + Beats::ZERO + }; + + // Advance per sample in beats: beats_per_sample = bpm / 60 / sample_rate + let beats_per_sample = self.bpm / 60.0 / sample_rate as f64; + + for i in 0..length { + let time = playhead + Beats(i as f64 * beats_per_sample); + output[i] = self.evaluate_at_time(time); + } + } + + fn reset(&mut self) { + // No state to reset + } + + fn node_type(&self) -> &str { + "AutomationInput" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + Box::new(Self { + name: self.name.clone(), + display_name: self.display_name.clone(), + keyframes: self.keyframes.clone(), + outputs: self.outputs.clone(), + parameters: self.parameters.clone(), + playback_time: Arc::new(RwLock::new(Beats::ZERO)), + bpm: self.bpm, + value_min: self.value_min, + value_max: self.value_max, + }) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/beat.rs b/daw-backend/src/audio/node_graph/nodes/beat.rs new file mode 100644 index 0000000..9f14111 --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/beat.rs @@ -0,0 +1,231 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; +use crate::audio::midi::MidiEvent; +use crate::time::Beats; + +const PARAM_RESOLUTION: u32 = 0; + +const DEFAULT_BPM: f32 = 120.0; +const DEFAULT_BEATS_PER_BAR: u32 = 4; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum BeatResolution { + Whole = 0, // 1/1 + Half = 1, // 1/2 + Quarter = 2, // 1/4 + Eighth = 3, // 1/8 + Sixteenth = 4, // 1/16 + QuarterT = 5, // 1/4 triplet + EighthT = 6, // 1/8 triplet +} + +impl BeatResolution { + fn from_f32(value: f32) -> Self { + match value.round() as i32 { + 0 => BeatResolution::Whole, + 1 => BeatResolution::Half, + 2 => BeatResolution::Quarter, + 3 => BeatResolution::Eighth, + 4 => BeatResolution::Sixteenth, + 5 => BeatResolution::QuarterT, + 6 => BeatResolution::EighthT, + _ => BeatResolution::Quarter, + } + } + + /// How many subdivisions per quarter note beat + fn subdivisions_per_beat(&self) -> f64 { + match self { + BeatResolution::Whole => 0.25, // 1 per 4 beats + BeatResolution::Half => 0.5, // 1 per 2 beats + BeatResolution::Quarter => 1.0, // 1 per beat + BeatResolution::Eighth => 2.0, // 2 per beat + BeatResolution::Sixteenth => 4.0, // 4 per beat + BeatResolution::QuarterT => 1.5, // 3 per 2 beats (triplet) + BeatResolution::EighthT => 3.0, // 3 per beat (triplet) + } + } +} + +/// Beat clock node — generates tempo-synced CV signals. +/// +/// BPM and time signature are synced from the project document via SetTempo. +/// When playing: synced to timeline position. +/// When stopped: free-runs continuously at the project BPM. +/// +/// Outputs: +/// - BPM: constant CV proportional to tempo (bpm / 240) +/// - Beat Phase: sawtooth 0→1 per beat subdivision +/// - Bar Phase: sawtooth 0→1 per bar (uses project time signature) +/// - Gate: 1.0 for first half of each subdivision, 0.0 otherwise +pub struct BeatNode { + name: String, + bpm: f32, + beats_per_bar: u32, + resolution: BeatResolution, + /// Playback time in beats, set by the graph before process() + playback_time: Beats, + /// Previous playback_time to detect paused state + prev_playback_time: Beats, + /// Free-running beat accumulator for when playback is stopped + free_run_time: Beats, + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl BeatNode { + pub fn new(name: impl Into) -> 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], + 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 { + 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 + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/bit_crusher.rs b/daw-backend/src/audio/node_graph/nodes/bit_crusher.rs new file mode 100644 index 0000000..c105925 --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/bit_crusher.rs @@ -0,0 +1,195 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; +use crate::audio::midi::MidiEvent; + +const PARAM_BIT_DEPTH: u32 = 0; +const PARAM_SAMPLE_RATE_REDUCTION: u32 = 1; +const PARAM_MIX: u32 = 2; + +/// Bit Crusher effect - reduces bit depth and sample rate for lo-fi sound +pub struct BitCrusherNode { + name: String, + bit_depth: f32, // 1 to 16 bits + sample_rate_reduction: f32, // 1 to 48000 Hz (crushed sample rate) + mix: f32, // 0.0 to 1.0 (dry/wet) + + // State for sample rate reduction + hold_left: f32, + hold_right: f32, + hold_counter: f32, + + sample_rate: u32, + + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl BitCrusherNode { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + + let inputs = vec![ + NodePort::new("Audio In", SignalType::Audio, 0), + ]; + + let outputs = vec![ + NodePort::new("Audio Out", SignalType::Audio, 0), + ]; + + let parameters = vec![ + Parameter::new(PARAM_BIT_DEPTH, "Bit Depth", 1.0, 16.0, 8.0, ParameterUnit::Generic), + Parameter::new(PARAM_SAMPLE_RATE_REDUCTION, "Sample Rate", 100.0, 48000.0, 8000.0, ParameterUnit::Frequency), + Parameter::new(PARAM_MIX, "Mix", 0.0, 1.0, 1.0, ParameterUnit::Generic), + ]; + + Self { + name, + bit_depth: 8.0, + sample_rate_reduction: 8000.0, + mix: 1.0, + hold_left: 0.0, + hold_right: 0.0, + hold_counter: 0.0, + sample_rate: 48000, + inputs, + outputs, + parameters, + } + } + + /// Quantize sample to specified bit depth + fn quantize(&self, sample: f32) -> f32 { + // Calculate number of quantization levels + let levels = 2.0_f32.powf(self.bit_depth); + + // Quantize: scale up, round, scale back down + let scaled = sample * levels / 2.0; + let quantized = scaled.round(); + quantized * 2.0 / levels + } +} + +impl AudioNode for BitCrusherNode { + fn category(&self) -> NodeCategory { + NodeCategory::Effect + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, id: u32, value: f32) { + match id { + PARAM_BIT_DEPTH => self.bit_depth = value.clamp(1.0, 16.0), + PARAM_SAMPLE_RATE_REDUCTION => self.sample_rate_reduction = value.clamp(100.0, 48000.0), + PARAM_MIX => self.mix = value.clamp(0.0, 1.0), + _ => {} + } + } + + fn get_parameter(&self, id: u32) -> f32 { + match id { + PARAM_BIT_DEPTH => self.bit_depth, + PARAM_SAMPLE_RATE_REDUCTION => self.sample_rate_reduction, + PARAM_MIX => self.mix, + _ => 0.0, + } + } + + fn process( + &mut self, + inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + sample_rate: u32, + ) { + if inputs.is_empty() || outputs.is_empty() { + return; + } + + // Update sample rate if changed + if self.sample_rate != sample_rate { + self.sample_rate = sample_rate; + } + + let input = inputs[0]; + let output = &mut outputs[0]; + + // Audio signals are stereo (interleaved L/R) + let frames = input.len() / 2; + let output_frames = output.len() / 2; + let frames_to_process = frames.min(output_frames); + + // Calculate sample hold period + let hold_period = self.sample_rate as f32 / self.sample_rate_reduction; + + for frame in 0..frames_to_process { + let left_in = input[frame * 2]; + let right_in = input[frame * 2 + 1]; + + // Sample rate reduction: hold samples + if self.hold_counter <= 0.0 { + // Time to sample a new value + self.hold_left = self.quantize(left_in); + self.hold_right = self.quantize(right_in); + self.hold_counter = hold_period; + } + + self.hold_counter -= 1.0; + + // Mix dry and wet + let wet_left = self.hold_left; + let wet_right = self.hold_right; + + output[frame * 2] = left_in * (1.0 - self.mix) + wet_left * self.mix; + output[frame * 2 + 1] = right_in * (1.0 - self.mix) + wet_right * self.mix; + } + } + + fn reset(&mut self) { + self.hold_left = 0.0; + self.hold_right = 0.0; + self.hold_counter = 0.0; + } + + fn node_type(&self) -> &str { + "BitCrusher" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + Box::new(Self { + name: self.name.clone(), + bit_depth: self.bit_depth, + sample_rate_reduction: self.sample_rate_reduction, + mix: self.mix, + hold_left: 0.0, + hold_right: 0.0, + hold_counter: 0.0, + sample_rate: self.sample_rate, + inputs: self.inputs.clone(), + outputs: self.outputs.clone(), + parameters: self.parameters.clone(), + }) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/bpm_detector.rs b/daw-backend/src/audio/node_graph/nodes/bpm_detector.rs new file mode 100644 index 0000000..7da4d0a --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/bpm_detector.rs @@ -0,0 +1,165 @@ +use crate::audio::bpm_detector::BpmDetectorRealtime; +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; +use crate::audio::midi::MidiEvent; + +const PARAM_SMOOTHING: u32 = 0; + +/// BPM Detector Node - analyzes audio input and outputs tempo as CV +/// CV output represents BPM (e.g., 0.12 = 120 BPM when scaled appropriately) +pub struct BpmDetectorNode { + name: String, + detector: BpmDetectorRealtime, + smoothing: f32, // Smoothing factor for output (0-1) + last_output: f32, // For smooth transitions + sample_rate: u32, // Current sample rate + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl BpmDetectorNode { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + + let inputs = vec![ + NodePort::new("Audio In", SignalType::Audio, 0), + ]; + + let outputs = vec![ + NodePort::new("BPM CV", SignalType::CV, 0), + ]; + + let parameters = vec![ + Parameter::new(PARAM_SMOOTHING, "Smoothing", 0.0, 1.0, 0.9, ParameterUnit::Percent), + ]; + + // Use 10 second buffer for analysis + let detector = BpmDetectorRealtime::new(48000, 10.0); + + Self { + name, + detector, + smoothing: 0.9, + last_output: 120.0, + sample_rate: 48000, + inputs, + outputs, + parameters, + } + } +} + +impl AudioNode for BpmDetectorNode { + fn category(&self) -> NodeCategory { + NodeCategory::Utility + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, id: u32, value: f32) { + match id { + PARAM_SMOOTHING => self.smoothing = value.clamp(0.0, 1.0), + _ => {} + } + } + + fn get_parameter(&self, id: u32) -> f32 { + match id { + PARAM_SMOOTHING => self.smoothing, + _ => 0.0, + } + } + + fn process( + &mut self, + inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + sample_rate: u32, + ) { + // Recreate detector if sample rate changed + if sample_rate != self.sample_rate { + self.sample_rate = sample_rate; + self.detector = BpmDetectorRealtime::new(sample_rate, 10.0); + } + + if outputs.is_empty() { + return; + } + + let output = &mut outputs[0]; + let length = output.len(); + + let input = if !inputs.is_empty() && !inputs[0].is_empty() { + inputs[0] + } else { + // Fill output with last known BPM + for i in 0..length { + output[i] = self.last_output / 1000.0; // Scale BPM for CV (e.g., 120 BPM -> 0.12) + } + return; + }; + + // Process audio through detector + let detected_bpm = self.detector.process(input); + + // Apply smoothing + let target_bpm = detected_bpm; + let smoothed_bpm = self.last_output * self.smoothing + target_bpm * (1.0 - self.smoothing); + self.last_output = smoothed_bpm; + + // Output BPM as CV (scaled down for typical CV range) + // BPM / 1000 gives us reasonable CV values (60-180 BPM -> 0.06-0.18) + let cv_value = smoothed_bpm / 1000.0; + + // Fill entire output buffer with current BPM value + for i in 0..length { + output[i] = cv_value; + } + } + + fn reset(&mut self) { + self.detector.reset(); + self.last_output = 120.0; + } + + fn node_type(&self) -> &str { + "BpmDetector" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + Box::new(Self { + name: self.name.clone(), + detector: BpmDetectorRealtime::new(self.sample_rate, 10.0), + smoothing: self.smoothing, + last_output: self.last_output, + sample_rate: self.sample_rate, + inputs: self.inputs.clone(), + outputs: self.outputs.clone(), + parameters: self.parameters.clone(), + }) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/bundled_models.rs b/daw-backend/src/audio/node_graph/nodes/bundled_models.rs new file mode 100644 index 0000000..19f2e5d --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/bundled_models.rs @@ -0,0 +1,50 @@ +use nam_ffi::NamModel; + +struct BundledModel { + name: &'static str, + filename: &'static str, + data: &'static [u8], +} + +const BUNDLED_MODELS: &[BundledModel] = &[ + BundledModel { + name: "BossSD1", + filename: "BossSD1-WaveNet.nam", + data: include_bytes!("../../../../../src/assets/nam_models/BossSD1-WaveNet.nam"), + }, + BundledModel { + name: "DeluxeReverb", + filename: "DeluxeReverb.nam", + data: include_bytes!("../../../../../src/assets/nam_models/DeluxeReverb.nam"), + }, + BundledModel { + name: "DingwallBass", + filename: "DingwallBass.nam", + data: include_bytes!("../../../../../src/assets/nam_models/DingwallBass.nam"), + }, + BundledModel { + name: "Rhythm", + filename: "Rhythm.nam", + data: include_bytes!("../../../../../src/assets/nam_models/Rhythm.nam"), + }, +]; + +/// Return display names of all bundled NAM models. +pub fn bundled_model_names() -> Vec<&'static str> { + BUNDLED_MODELS.iter().map(|m| m.name).collect() +} + +/// Load a bundled NAM model by display name. +/// Returns `None` if the name isn't found, `Some(Err(...))` on load failure. +pub fn load_bundled_model(name: &str) -> Option> { + 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() + }), + ) +} diff --git a/daw-backend/src/audio/node_graph/nodes/chorus.rs b/daw-backend/src/audio/node_graph/nodes/chorus.rs new file mode 100644 index 0000000..8ae7859 --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/chorus.rs @@ -0,0 +1,242 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; +use crate::audio::midi::MidiEvent; +use std::f32::consts::PI; + +const PARAM_RATE: u32 = 0; +const PARAM_DEPTH: u32 = 1; +const PARAM_WET_DRY: u32 = 2; + +const MAX_DELAY_MS: f32 = 50.0; +const BASE_DELAY_MS: f32 = 15.0; + +/// Chorus effect using modulated delay lines +pub struct ChorusNode { + name: String, + rate: f32, // LFO rate in Hz (0.1 to 5 Hz) + depth: f32, // Modulation depth 0.0 to 1.0 + wet_dry: f32, // 0.0 = dry only, 1.0 = wet only + + // Delay buffers for left and right channels + delay_buffer_left: Vec, + delay_buffer_right: Vec, + write_position: usize, + max_delay_samples: usize, + sample_rate: u32, + + // LFO state + lfo_phase: f32, + + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl ChorusNode { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + + let inputs = vec![ + NodePort::new("Audio In", SignalType::Audio, 0), + ]; + + let outputs = vec![ + NodePort::new("Audio Out", SignalType::Audio, 0), + ]; + + let parameters = vec![ + Parameter::new(PARAM_RATE, "Rate", 0.1, 5.0, 1.0, ParameterUnit::Frequency), + Parameter::new(PARAM_DEPTH, "Depth", 0.0, 1.0, 0.5, ParameterUnit::Generic), + Parameter::new(PARAM_WET_DRY, "Wet/Dry", 0.0, 1.0, 0.5, ParameterUnit::Generic), + ]; + + // Allocate max delay buffer size + let max_delay_samples = ((MAX_DELAY_MS / 1000.0) * 48000.0) as usize; + + Self { + name, + rate: 1.0, + depth: 0.5, + wet_dry: 0.5, + delay_buffer_left: vec![0.0; max_delay_samples], + delay_buffer_right: vec![0.0; max_delay_samples], + write_position: 0, + max_delay_samples, + sample_rate: 48000, + lfo_phase: 0.0, + inputs, + outputs, + parameters, + } + } + + fn read_interpolated_sample(&self, buffer: &[f32], delay_samples: f32) -> f32 { + // Linear interpolation for smooth delay modulation + let delay_samples = delay_samples.clamp(0.0, (self.max_delay_samples - 1) as f32); + + let read_pos_float = self.write_position as f32 - delay_samples; + let read_pos_float = if read_pos_float < 0.0 { + read_pos_float + self.max_delay_samples as f32 + } else { + read_pos_float + }; + + let read_pos_int = read_pos_float.floor() as usize; + let frac = read_pos_float - read_pos_int as f32; + + let sample1 = buffer[read_pos_int % self.max_delay_samples]; + let sample2 = buffer[(read_pos_int + 1) % self.max_delay_samples]; + + sample1 * (1.0 - frac) + sample2 * frac + } +} + +impl AudioNode for ChorusNode { + fn category(&self) -> NodeCategory { + NodeCategory::Effect + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, id: u32, value: f32) { + match id { + PARAM_RATE => { + self.rate = value.clamp(0.1, 5.0); + } + PARAM_DEPTH => { + self.depth = value.clamp(0.0, 1.0); + } + PARAM_WET_DRY => { + self.wet_dry = value.clamp(0.0, 1.0); + } + _ => {} + } + } + + fn get_parameter(&self, id: u32) -> f32 { + match id { + PARAM_RATE => self.rate, + PARAM_DEPTH => self.depth, + PARAM_WET_DRY => self.wet_dry, + _ => 0.0, + } + } + + fn process( + &mut self, + inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + sample_rate: u32, + ) { + if inputs.is_empty() || outputs.is_empty() { + return; + } + + // Update sample rate if changed + if self.sample_rate != sample_rate { + self.sample_rate = sample_rate; + self.max_delay_samples = ((MAX_DELAY_MS / 1000.0) * sample_rate as f32) as usize; + self.delay_buffer_left.resize(self.max_delay_samples, 0.0); + self.delay_buffer_right.resize(self.max_delay_samples, 0.0); + self.write_position = 0; + } + + let input = inputs[0]; + let output = &mut outputs[0]; + + // Audio signals are stereo (interleaved L/R) + let frames = input.len() / 2; + let output_frames = output.len() / 2; + let frames_to_process = frames.min(output_frames); + + let dry_gain = 1.0 - self.wet_dry; + let wet_gain = self.wet_dry; + + let base_delay_samples = (BASE_DELAY_MS / 1000.0) * self.sample_rate as f32; + let max_modulation_samples = (MAX_DELAY_MS - BASE_DELAY_MS) / 1000.0 * self.sample_rate as f32; + + for frame in 0..frames_to_process { + let left_in = input[frame * 2]; + let right_in = input[frame * 2 + 1]; + + // Generate LFO value (sine wave, 0 to 1) + let lfo_value = ((self.lfo_phase * 2.0 * PI).sin() * 0.5 + 0.5) * self.depth; + + // Calculate modulated delay time + let delay_samples = base_delay_samples + lfo_value * max_modulation_samples; + + // Read delayed samples with interpolation + let left_delayed = self.read_interpolated_sample(&self.delay_buffer_left, delay_samples); + let right_delayed = self.read_interpolated_sample(&self.delay_buffer_right, delay_samples); + + // Mix dry and wet signals + output[frame * 2] = left_in * dry_gain + left_delayed * wet_gain; + output[frame * 2 + 1] = right_in * dry_gain + right_delayed * wet_gain; + + // Write to delay buffer + self.delay_buffer_left[self.write_position] = left_in; + self.delay_buffer_right[self.write_position] = right_in; + + // Advance write position + self.write_position = (self.write_position + 1) % self.max_delay_samples; + + // Advance LFO phase + self.lfo_phase += self.rate / self.sample_rate as f32; + if self.lfo_phase >= 1.0 { + self.lfo_phase -= 1.0; + } + } + } + + fn reset(&mut self) { + self.delay_buffer_left.fill(0.0); + self.delay_buffer_right.fill(0.0); + self.write_position = 0; + self.lfo_phase = 0.0; + } + + fn node_type(&self) -> &str { + "Chorus" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + Box::new(Self { + name: self.name.clone(), + rate: self.rate, + depth: self.depth, + wet_dry: self.wet_dry, + delay_buffer_left: vec![0.0; self.max_delay_samples], + delay_buffer_right: vec![0.0; self.max_delay_samples], + write_position: 0, + max_delay_samples: self.max_delay_samples, + sample_rate: self.sample_rate, + lfo_phase: 0.0, + inputs: self.inputs.clone(), + outputs: self.outputs.clone(), + parameters: self.parameters.clone(), + }) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/compressor.rs b/daw-backend/src/audio/node_graph/nodes/compressor.rs new file mode 100644 index 0000000..742380e --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/compressor.rs @@ -0,0 +1,261 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; +use crate::audio::midi::MidiEvent; + +const PARAM_THRESHOLD: u32 = 0; +const PARAM_RATIO: u32 = 1; +const PARAM_ATTACK: u32 = 2; +const PARAM_RELEASE: u32 = 3; +const PARAM_MAKEUP_GAIN: u32 = 4; +const PARAM_KNEE: u32 = 5; + +/// Compressor node for dynamic range compression +pub struct CompressorNode { + name: String, + threshold_db: f32, + ratio: f32, + attack_ms: f32, + release_ms: f32, + makeup_gain_db: f32, + knee_db: f32, + + // State + envelope: f32, + attack_coeff: f32, + release_coeff: f32, + sample_rate: u32, + + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl CompressorNode { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + + let inputs = vec![ + NodePort::new("Audio In", SignalType::Audio, 0), + ]; + + let outputs = vec![ + NodePort::new("Audio Out", SignalType::Audio, 0), + ]; + + let parameters = vec![ + Parameter::new(PARAM_THRESHOLD, "Threshold", -60.0, 0.0, -20.0, ParameterUnit::Decibels), + Parameter::new(PARAM_RATIO, "Ratio", 1.0, 20.0, 4.0, ParameterUnit::Generic), + Parameter::new(PARAM_ATTACK, "Attack", 0.1, 100.0, 5.0, ParameterUnit::Time), + Parameter::new(PARAM_RELEASE, "Release", 10.0, 1000.0, 50.0, ParameterUnit::Time), + Parameter::new(PARAM_MAKEUP_GAIN, "Makeup", 0.0, 24.0, 0.0, ParameterUnit::Decibels), + Parameter::new(PARAM_KNEE, "Knee", 0.0, 12.0, 3.0, ParameterUnit::Decibels), + ]; + + let sample_rate = 44100; + let attack_coeff = Self::ms_to_coeff(5.0, sample_rate); + let release_coeff = Self::ms_to_coeff(50.0, sample_rate); + + Self { + name, + threshold_db: -20.0, + ratio: 4.0, + attack_ms: 5.0, + release_ms: 50.0, + makeup_gain_db: 0.0, + knee_db: 3.0, + envelope: 0.0, + attack_coeff, + release_coeff, + sample_rate, + inputs, + outputs, + parameters, + } + } + + /// Convert milliseconds to exponential smoothing coefficient + fn ms_to_coeff(time_ms: f32, sample_rate: u32) -> f32 { + let time_seconds = time_ms / 1000.0; + let samples = time_seconds * sample_rate as f32; + (-1.0 / samples).exp() + } + + fn update_coefficients(&mut self) { + self.attack_coeff = Self::ms_to_coeff(self.attack_ms, self.sample_rate); + self.release_coeff = Self::ms_to_coeff(self.release_ms, self.sample_rate); + } + + /// Convert linear amplitude to dB + fn linear_to_db(linear: f32) -> f32 { + if linear > 0.0 { + 20.0 * linear.log10() + } else { + -160.0 + } + } + + /// Convert dB to linear gain + fn db_to_linear(db: f32) -> f32 { + 10.0_f32.powf(db / 20.0) + } + + /// Calculate gain reduction for a given input level + fn calculate_gain_reduction(&self, input_db: f32) -> f32 { + let threshold = self.threshold_db; + let knee = self.knee_db; + let ratio = self.ratio; + + // Soft knee implementation + if input_db < threshold - knee / 2.0 { + // Below threshold - no compression + 0.0 + } else if input_db > threshold + knee / 2.0 { + // Above threshold - full compression + let overshoot = input_db - threshold; + overshoot * (1.0 - 1.0 / ratio) + } else { + // In knee region - gradual compression + let overshoot = input_db - threshold + knee / 2.0; + let knee_factor = overshoot / knee; + overshoot * knee_factor * (1.0 - 1.0 / ratio) / 2.0 + } + } + + fn process_sample(&mut self, input: f32) -> f32 { + // Detect input level (using absolute value as simple peak detector) + let input_level = input.abs(); + + // Convert to dB + let input_db = Self::linear_to_db(input_level); + + // Calculate target gain reduction + let target_gr_db = self.calculate_gain_reduction(input_db); + let target_gr_linear = Self::db_to_linear(-target_gr_db); + + // Smooth envelope with attack/release + let coeff = if target_gr_linear < self.envelope { + self.attack_coeff // Attack (faster response to louder signal) + } else { + self.release_coeff // Release (slower response when signal gets quieter) + }; + + self.envelope = target_gr_linear + coeff * (self.envelope - target_gr_linear); + + // Apply compression and makeup gain + let makeup_linear = Self::db_to_linear(self.makeup_gain_db); + input * self.envelope * makeup_linear + } +} + +impl AudioNode for CompressorNode { + fn category(&self) -> NodeCategory { + NodeCategory::Effect + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, id: u32, value: f32) { + match id { + PARAM_THRESHOLD => self.threshold_db = value, + PARAM_RATIO => self.ratio = value, + PARAM_ATTACK => { + self.attack_ms = value; + self.update_coefficients(); + } + PARAM_RELEASE => { + self.release_ms = value; + self.update_coefficients(); + } + PARAM_MAKEUP_GAIN => self.makeup_gain_db = value, + PARAM_KNEE => self.knee_db = value, + _ => {} + } + } + + fn get_parameter(&self, id: u32) -> f32 { + match id { + PARAM_THRESHOLD => self.threshold_db, + PARAM_RATIO => self.ratio, + PARAM_ATTACK => self.attack_ms, + PARAM_RELEASE => self.release_ms, + PARAM_MAKEUP_GAIN => self.makeup_gain_db, + PARAM_KNEE => self.knee_db, + _ => 0.0, + } + } + + fn process( + &mut self, + inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + sample_rate: u32, + ) { + if inputs.is_empty() || outputs.is_empty() { + return; + } + + // Update sample rate if changed + if self.sample_rate != sample_rate { + self.sample_rate = sample_rate; + self.update_coefficients(); + } + + let input = inputs[0]; + let output = &mut outputs[0]; + let len = input.len().min(output.len()); + + for i in 0..len { + output[i] = self.process_sample(input[i]); + } + } + + fn reset(&mut self) { + self.envelope = 0.0; + } + + fn node_type(&self) -> &str { + "Compressor" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + Box::new(Self { + name: self.name.clone(), + threshold_db: self.threshold_db, + ratio: self.ratio, + attack_ms: self.attack_ms, + release_ms: self.release_ms, + makeup_gain_db: self.makeup_gain_db, + knee_db: self.knee_db, + envelope: 0.0, // Reset state for clone + attack_coeff: self.attack_coeff, + release_coeff: self.release_coeff, + sample_rate: self.sample_rate, + inputs: self.inputs.clone(), + outputs: self.outputs.clone(), + parameters: self.parameters.clone(), + }) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/constant.rs b/daw-backend/src/audio/node_graph/nodes/constant.rs new file mode 100644 index 0000000..4a8ee1c --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/constant.rs @@ -0,0 +1,121 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; +use crate::audio::midi::MidiEvent; + +const PARAM_VALUE: u32 = 0; + +/// Constant CV source - outputs a constant voltage +/// Useful for providing fixed values to CV inputs, offsets, etc. +pub struct ConstantNode { + name: String, + value: f32, + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl ConstantNode { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + + let inputs = vec![]; + + let outputs = vec![ + NodePort::new("CV Out", SignalType::CV, 0), + ]; + + let parameters = vec![ + Parameter::new(PARAM_VALUE, "Value", -10.0, 10.0, 0.0, ParameterUnit::Generic), + ]; + + Self { + name, + value: 0.0, + inputs, + outputs, + parameters, + } + } +} + +impl AudioNode for ConstantNode { + fn category(&self) -> NodeCategory { + NodeCategory::Utility + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, id: u32, value: f32) { + match id { + PARAM_VALUE => self.value = value.clamp(-10.0, 10.0), + _ => {} + } + } + + fn get_parameter(&self, id: u32) -> f32 { + match id { + PARAM_VALUE => self.value, + _ => 0.0, + } + } + + fn process( + &mut self, + _inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + _sample_rate: u32, + ) { + if outputs.is_empty() { + return; + } + + let output = &mut outputs[0]; + let length = output.len(); + + // Fill output with constant value + for i in 0..length { + output[i] = self.value; + } + } + + fn reset(&mut self) { + // No state to reset + } + + fn node_type(&self) -> &str { + "Constant" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + Box::new(Self { + name: self.name.clone(), + value: self.value, + inputs: self.inputs.clone(), + outputs: self.outputs.clone(), + parameters: self.parameters.clone(), + }) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/distortion.rs b/daw-backend/src/audio/node_graph/nodes/distortion.rs new file mode 100644 index 0000000..424b106 --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/distortion.rs @@ -0,0 +1,265 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; +use crate::audio::midi::MidiEvent; + +const PARAM_DRIVE: u32 = 0; +const PARAM_TYPE: u32 = 1; +const PARAM_TONE: u32 = 2; +const PARAM_MIX: u32 = 3; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum DistortionType { + SoftClip = 0, + HardClip = 1, + Tanh = 2, + Asymmetric = 3, +} + +impl DistortionType { + fn from_f32(value: f32) -> Self { + match value.round() as i32 { + 1 => DistortionType::HardClip, + 2 => DistortionType::Tanh, + 3 => DistortionType::Asymmetric, + _ => DistortionType::SoftClip, + } + } +} + +/// Distortion node with multiple waveshaping algorithms +pub struct DistortionNode { + name: String, + drive: f32, // 0.01 to 20.0 (linear gain) + distortion_type: DistortionType, + tone: f32, // 0.0 to 1.0 (low-pass filter cutoff) + mix: f32, // 0.0 to 1.0 (dry/wet) + + // Tone filter state (simple one-pole low-pass) + filter_state_left: f32, + filter_state_right: f32, + sample_rate: u32, + + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl DistortionNode { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + + let inputs = vec![ + NodePort::new("Audio In", SignalType::Audio, 0), + ]; + + let outputs = vec![ + NodePort::new("Audio Out", SignalType::Audio, 0), + ]; + + let parameters = vec![ + Parameter::new(PARAM_DRIVE, "Drive", 0.01, 20.0, 1.0, ParameterUnit::Generic), + Parameter::new(PARAM_TYPE, "Type", 0.0, 3.0, 0.0, ParameterUnit::Generic), + Parameter::new(PARAM_TONE, "Tone", 0.0, 1.0, 0.7, ParameterUnit::Generic), + Parameter::new(PARAM_MIX, "Mix", 0.0, 1.0, 1.0, ParameterUnit::Generic), + ]; + + Self { + name, + drive: 1.0, + distortion_type: DistortionType::SoftClip, + tone: 0.7, + mix: 1.0, + filter_state_left: 0.0, + filter_state_right: 0.0, + sample_rate: 44100, + inputs, + outputs, + parameters, + } + } + + /// Soft clipping using cubic waveshaping + fn soft_clip(&self, x: f32) -> f32 { + let x = x.clamp(-2.0, 2.0); + if x.abs() <= 1.0 { + x + } else { + let sign = x.signum(); + sign * (2.0 - (2.0 - x.abs()).powi(2)) / 2.0 + } + } + + /// Hard clipping + fn hard_clip(&self, x: f32) -> f32 { + x.clamp(-1.0, 1.0) + } + + /// Hyperbolic tangent waveshaping + fn tanh_distortion(&self, x: f32) -> f32 { + x.tanh() + } + + /// Asymmetric waveshaping (different curves for positive/negative) + fn asymmetric(&self, x: f32) -> f32 { + if x >= 0.0 { + // Positive: soft clip + self.soft_clip(x) + } else { + // Negative: harder clip + self.hard_clip(x * 1.5) / 1.5 + } + } + + /// Apply waveshaping based on type + fn apply_waveshaping(&self, x: f32) -> f32 { + match self.distortion_type { + DistortionType::SoftClip => self.soft_clip(x), + DistortionType::HardClip => self.hard_clip(x), + DistortionType::Tanh => self.tanh_distortion(x), + DistortionType::Asymmetric => self.asymmetric(x), + } + } + + /// Simple one-pole low-pass filter for tone control + fn apply_tone_filter(&mut self, input: f32, is_left: bool) -> f32 { + // Tone parameter controls cutoff frequency (0 = dark, 1 = bright) + // Map tone to filter coefficient (0.1 to 0.99) + let coeff = 0.1 + self.tone * 0.89; + + let state = if is_left { + &mut self.filter_state_left + } else { + &mut self.filter_state_right + }; + + *state = *state * coeff + input * (1.0 - coeff); + *state + } + + fn process_sample(&mut self, input: f32, is_left: bool) -> f32 { + // Apply drive (input gain) + let driven = input * self.drive; + + // Apply waveshaping + let distorted = self.apply_waveshaping(driven); + + // Apply tone control (low-pass filter to tame harshness) + let filtered = self.apply_tone_filter(distorted, is_left); + + // Apply output gain compensation and mix + let output_gain = 1.0 / (1.0 + self.drive * 0.2); // Compensate for loudness increase + let wet = filtered * output_gain; + let dry = input; + + // Mix dry and wet + dry * (1.0 - self.mix) + wet * self.mix + } +} + +impl AudioNode for DistortionNode { + fn category(&self) -> NodeCategory { + NodeCategory::Effect + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, id: u32, value: f32) { + match id { + PARAM_DRIVE => self.drive = value.clamp(0.01, 20.0), + PARAM_TYPE => self.distortion_type = DistortionType::from_f32(value), + PARAM_TONE => self.tone = value.clamp(0.0, 1.0), + PARAM_MIX => self.mix = value.clamp(0.0, 1.0), + _ => {} + } + } + + fn get_parameter(&self, id: u32) -> f32 { + match id { + PARAM_DRIVE => self.drive, + PARAM_TYPE => self.distortion_type as i32 as f32, + PARAM_TONE => self.tone, + PARAM_MIX => self.mix, + _ => 0.0, + } + } + + fn process( + &mut self, + inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + sample_rate: u32, + ) { + if inputs.is_empty() || outputs.is_empty() { + return; + } + + // Update sample rate if changed + if self.sample_rate != sample_rate { + self.sample_rate = sample_rate; + } + + let input = inputs[0]; + let output = &mut outputs[0]; + + // Audio signals are stereo (interleaved L/R) + let frames = input.len() / 2; + let output_frames = output.len() / 2; + let frames_to_process = frames.min(output_frames); + + for frame in 0..frames_to_process { + let left_in = input[frame * 2]; + let right_in = input[frame * 2 + 1]; + + output[frame * 2] = self.process_sample(left_in, true); + output[frame * 2 + 1] = self.process_sample(right_in, false); + } + } + + fn reset(&mut self) { + self.filter_state_left = 0.0; + self.filter_state_right = 0.0; + } + + fn node_type(&self) -> &str { + "Distortion" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + Box::new(Self { + name: self.name.clone(), + drive: self.drive, + distortion_type: self.distortion_type, + tone: self.tone, + mix: self.mix, + filter_state_left: 0.0, // Reset state for clone + filter_state_right: 0.0, + sample_rate: self.sample_rate, + inputs: self.inputs.clone(), + outputs: self.outputs.clone(), + parameters: self.parameters.clone(), + }) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/echo.rs b/daw-backend/src/audio/node_graph/nodes/echo.rs new file mode 100644 index 0000000..f2ba870 --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/echo.rs @@ -0,0 +1,219 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; +use crate::audio::midi::MidiEvent; + +const PARAM_DELAY_TIME: u32 = 0; +const PARAM_FEEDBACK: u32 = 1; +const PARAM_WET_DRY: u32 = 2; + +const MAX_DELAY_SECONDS: f32 = 2.0; + +/// Stereo echo node with feedback +pub struct EchoNode { + name: String, + delay_time: f32, // seconds + feedback: f32, // 0.0 to 0.95 + wet_dry: f32, // 0.0 = dry only, 1.0 = wet only + + // Delay buffers for left and right channels + delay_buffer_left: Vec, + delay_buffer_right: Vec, + write_position: usize, + max_delay_samples: usize, + sample_rate: u32, + + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl EchoNode { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + + let inputs = vec![ + NodePort::new("Audio In", SignalType::Audio, 0), + ]; + + let outputs = vec![ + NodePort::new("Audio Out", SignalType::Audio, 0), + ]; + + let parameters = vec![ + Parameter::new(PARAM_DELAY_TIME, "Delay Time", 0.001, MAX_DELAY_SECONDS, 0.5, ParameterUnit::Time), + Parameter::new(PARAM_FEEDBACK, "Feedback", 0.0, 0.95, 0.5, ParameterUnit::Generic), + Parameter::new(PARAM_WET_DRY, "Wet/Dry", 0.0, 1.0, 0.5, ParameterUnit::Generic), + ]; + + // Allocate max delay buffer size (will be initialized properly when we get sample rate) + let max_delay_samples = (MAX_DELAY_SECONDS * 48000.0) as usize; // Assume max 48kHz + + Self { + name, + delay_time: 0.5, + feedback: 0.5, + wet_dry: 0.5, + delay_buffer_left: vec![0.0; max_delay_samples], + delay_buffer_right: vec![0.0; max_delay_samples], + write_position: 0, + max_delay_samples, + sample_rate: 48000, + inputs, + outputs, + parameters, + } + } + + fn get_delay_samples(&self) -> usize { + (self.delay_time * self.sample_rate as f32) as usize + } + + fn read_delayed_sample(&self, buffer: &[f32], delay_samples: usize) -> f32 { + // Calculate read position (wrap around) + let read_pos = if self.write_position >= delay_samples { + self.write_position - delay_samples + } else { + self.max_delay_samples + self.write_position - delay_samples + }; + + buffer[read_pos % self.max_delay_samples] + } +} + +impl AudioNode for EchoNode { + fn category(&self) -> NodeCategory { + NodeCategory::Effect + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, id: u32, value: f32) { + match id { + PARAM_DELAY_TIME => { + self.delay_time = value.clamp(0.001, MAX_DELAY_SECONDS); + } + PARAM_FEEDBACK => { + self.feedback = value.clamp(0.0, 0.95); + } + PARAM_WET_DRY => { + self.wet_dry = value.clamp(0.0, 1.0); + } + _ => {} + } + } + + fn get_parameter(&self, id: u32) -> f32 { + match id { + PARAM_DELAY_TIME => self.delay_time, + PARAM_FEEDBACK => self.feedback, + PARAM_WET_DRY => self.wet_dry, + _ => 0.0, + } + } + + fn process( + &mut self, + inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + sample_rate: u32, + ) { + if inputs.is_empty() || outputs.is_empty() { + return; + } + + // Update sample rate if changed + if self.sample_rate != sample_rate { + self.sample_rate = sample_rate; + self.max_delay_samples = (MAX_DELAY_SECONDS * sample_rate as f32) as usize; + self.delay_buffer_left.resize(self.max_delay_samples, 0.0); + self.delay_buffer_right.resize(self.max_delay_samples, 0.0); + self.write_position = 0; + } + + let input = inputs[0]; + let output = &mut outputs[0]; + + // Audio signals are stereo (interleaved L/R) + let frames = input.len() / 2; + let output_frames = output.len() / 2; + let frames_to_process = frames.min(output_frames); + + let delay_samples = self.get_delay_samples().max(1).min(self.max_delay_samples - 1); + + for frame in 0..frames_to_process { + let left_in = input[frame * 2]; + let right_in = input[frame * 2 + 1]; + + // Read delayed samples + let left_delayed = self.read_delayed_sample(&self.delay_buffer_left, delay_samples); + let right_delayed = self.read_delayed_sample(&self.delay_buffer_right, delay_samples); + + // Mix dry and wet signals + let dry_gain = 1.0 - self.wet_dry; + let wet_gain = self.wet_dry; + + let left_out = left_in * dry_gain + left_delayed * wet_gain; + let right_out = right_in * dry_gain + right_delayed * wet_gain; + + output[frame * 2] = left_out; + output[frame * 2 + 1] = right_out; + + // Write to delay buffer with feedback + self.delay_buffer_left[self.write_position] = left_in + left_delayed * self.feedback; + self.delay_buffer_right[self.write_position] = right_in + right_delayed * self.feedback; + + // Advance write position + self.write_position = (self.write_position + 1) % self.max_delay_samples; + } + } + + fn reset(&mut self) { + self.delay_buffer_left.fill(0.0); + self.delay_buffer_right.fill(0.0); + self.write_position = 0; + } + + fn node_type(&self) -> &str { + "Echo" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + Box::new(Self { + name: self.name.clone(), + delay_time: self.delay_time, + feedback: self.feedback, + wet_dry: self.wet_dry, + delay_buffer_left: vec![0.0; self.max_delay_samples], + delay_buffer_right: vec![0.0; self.max_delay_samples], + write_position: 0, + max_delay_samples: self.max_delay_samples, + sample_rate: self.sample_rate, + inputs: self.inputs.clone(), + outputs: self.outputs.clone(), + parameters: self.parameters.clone(), + }) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/envelope_follower.rs b/daw-backend/src/audio/node_graph/nodes/envelope_follower.rs new file mode 100644 index 0000000..ebe6436 --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/envelope_follower.rs @@ -0,0 +1,166 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; +use crate::audio::midi::MidiEvent; + +const PARAM_ATTACK: u32 = 0; +const PARAM_RELEASE: u32 = 1; + +/// Envelope Follower - extracts amplitude envelope from audio signal +/// Outputs a CV signal that follows the loudness of the input +pub struct EnvelopeFollowerNode { + name: String, + attack_time: f32, // seconds + release_time: f32, // seconds + envelope: f32, // current envelope level + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl EnvelopeFollowerNode { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + + let inputs = vec![ + NodePort::new("Audio In", SignalType::Audio, 0), + ]; + + let outputs = vec![ + NodePort::new("CV Out", SignalType::CV, 0), + ]; + + let parameters = vec![ + Parameter::new(PARAM_ATTACK, "Attack", 0.001, 1.0, 0.01, ParameterUnit::Time), + Parameter::new(PARAM_RELEASE, "Release", 0.001, 1.0, 0.1, ParameterUnit::Time), + ]; + + Self { + name, + attack_time: 0.01, + release_time: 0.1, + envelope: 0.0, + inputs, + outputs, + parameters, + } + } +} + +impl AudioNode for EnvelopeFollowerNode { + fn category(&self) -> NodeCategory { + NodeCategory::Utility + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, id: u32, value: f32) { + match id { + PARAM_ATTACK => self.attack_time = value.clamp(0.001, 1.0), + PARAM_RELEASE => self.release_time = value.clamp(0.001, 1.0), + _ => {} + } + } + + fn get_parameter(&self, id: u32) -> f32 { + match id { + PARAM_ATTACK => self.attack_time, + PARAM_RELEASE => self.release_time, + _ => 0.0, + } + } + + fn process( + &mut self, + inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + sample_rate: u32, + ) { + if outputs.is_empty() { + return; + } + + let output = &mut outputs[0]; + let length = output.len(); + + let input = if !inputs.is_empty() && !inputs[0].is_empty() { + inputs[0] + } else { + &[] + }; + + // Calculate filter coefficients + // One-pole filter: y[n] = y[n-1] + coefficient * (x[n] - y[n-1]) + let sample_duration = 1.0 / sample_rate as f32; + + // Time constant τ = time to reach ~63% of target + // Coefficient = 1 - e^(-1/(τ * sample_rate)) + // Simplified approximation: coefficient ≈ sample_duration / time_constant + let attack_coeff = (sample_duration / self.attack_time).min(1.0); + let release_coeff = (sample_duration / self.release_time).min(1.0); + + // Process each sample + for i in 0..length { + // Get absolute value of input (rectify) + let input_level = if i < input.len() { + input[i].abs() + } else { + 0.0 + }; + + // Apply attack or release + let coeff = if input_level > self.envelope { + attack_coeff // Rising - use attack time + } else { + release_coeff // Falling - use release time + }; + + // One-pole filter + self.envelope += (input_level - self.envelope) * coeff; + + output[i] = self.envelope; + } + } + + fn reset(&mut self) { + self.envelope = 0.0; + } + + fn node_type(&self) -> &str { + "EnvelopeFollower" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + Box::new(Self { + name: self.name.clone(), + attack_time: self.attack_time, + release_time: self.release_time, + envelope: self.envelope, + inputs: self.inputs.clone(), + outputs: self.outputs.clone(), + parameters: self.parameters.clone(), + }) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/eq.rs b/daw-backend/src/audio/node_graph/nodes/eq.rs new file mode 100644 index 0000000..f711e89 --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/eq.rs @@ -0,0 +1,267 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; +use crate::audio::midi::MidiEvent; +use crate::dsp::biquad::BiquadFilter; + +// Low band (shelving) +const PARAM_LOW_FREQ: u32 = 0; +const PARAM_LOW_GAIN: u32 = 1; + +// Mid band (peaking) +const PARAM_MID_FREQ: u32 = 2; +const PARAM_MID_GAIN: u32 = 3; +const PARAM_MID_Q: u32 = 4; + +// High band (shelving) +const PARAM_HIGH_FREQ: u32 = 5; +const PARAM_HIGH_GAIN: u32 = 6; + +/// 3-Band Parametric EQ Node +/// All three bands use peaking filters at different frequencies +pub struct EQNode { + name: String, + + // Parameters + low_freq: f32, + low_gain_db: f32, + low_q: f32, + mid_freq: f32, + mid_gain_db: f32, + mid_q: f32, + high_freq: f32, + high_gain_db: f32, + high_q: f32, + + // Filters (stereo) + low_filter_left: BiquadFilter, + low_filter_right: BiquadFilter, + mid_filter_left: BiquadFilter, + mid_filter_right: BiquadFilter, + high_filter_left: BiquadFilter, + high_filter_right: BiquadFilter, + + sample_rate: u32, + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl EQNode { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + + let inputs = vec![ + NodePort::new("Audio In", SignalType::Audio, 0), + ]; + + let outputs = vec![ + NodePort::new("Audio Out", SignalType::Audio, 0), + ]; + + let parameters = vec![ + Parameter::new(PARAM_LOW_FREQ, "Low Freq", 20.0, 500.0, 100.0, ParameterUnit::Frequency), + Parameter::new(PARAM_LOW_GAIN, "Low Gain", -24.0, 24.0, 0.0, ParameterUnit::Decibels), + Parameter::new(PARAM_MID_FREQ, "Mid Freq", 200.0, 5000.0, 1000.0, ParameterUnit::Frequency), + Parameter::new(PARAM_MID_GAIN, "Mid Gain", -24.0, 24.0, 0.0, ParameterUnit::Decibels), + Parameter::new(PARAM_MID_Q, "Mid Q", 0.1, 10.0, 0.707, ParameterUnit::Generic), + Parameter::new(PARAM_HIGH_FREQ, "High Freq", 2000.0, 20000.0, 8000.0, ParameterUnit::Frequency), + Parameter::new(PARAM_HIGH_GAIN, "High Gain", -24.0, 24.0, 0.0, ParameterUnit::Decibels), + ]; + + let sample_rate = 44100; + + // Initialize filters - all peaking + let low_filter_left = BiquadFilter::peaking(100.0, 1.0, 0.0, sample_rate as f32); + let low_filter_right = BiquadFilter::peaking(100.0, 1.0, 0.0, sample_rate as f32); + let mid_filter_left = BiquadFilter::peaking(1000.0, 0.707, 0.0, sample_rate as f32); + let mid_filter_right = BiquadFilter::peaking(1000.0, 0.707, 0.0, sample_rate as f32); + let high_filter_left = BiquadFilter::peaking(8000.0, 1.0, 0.0, sample_rate as f32); + let high_filter_right = BiquadFilter::peaking(8000.0, 1.0, 0.0, sample_rate as f32); + + Self { + name, + low_freq: 100.0, + low_gain_db: 0.0, + low_q: 1.0, + mid_freq: 1000.0, + mid_gain_db: 0.0, + mid_q: 0.707, + high_freq: 8000.0, + high_gain_db: 0.0, + high_q: 1.0, + low_filter_left, + low_filter_right, + mid_filter_left, + mid_filter_right, + high_filter_left, + high_filter_right, + sample_rate, + inputs, + outputs, + parameters, + } + } + + fn update_filters(&mut self) { + let sr = self.sample_rate as f32; + + // Update low band peaking filter + self.low_filter_left.set_peaking(self.low_freq, self.low_q, self.low_gain_db, sr); + self.low_filter_right.set_peaking(self.low_freq, self.low_q, self.low_gain_db, sr); + + // Update mid band peaking filter + self.mid_filter_left.set_peaking(self.mid_freq, self.mid_q, self.mid_gain_db, sr); + self.mid_filter_right.set_peaking(self.mid_freq, self.mid_q, self.mid_gain_db, sr); + + // Update high band peaking filter + self.high_filter_left.set_peaking(self.high_freq, self.high_q, self.high_gain_db, sr); + self.high_filter_right.set_peaking(self.high_freq, self.high_q, self.high_gain_db, sr); + } +} + +impl AudioNode for EQNode { + fn category(&self) -> NodeCategory { + NodeCategory::Effect + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, id: u32, value: f32) { + match id { + PARAM_LOW_FREQ => { + self.low_freq = value; + self.update_filters(); + } + PARAM_LOW_GAIN => { + self.low_gain_db = value; + self.update_filters(); + } + PARAM_MID_FREQ => { + self.mid_freq = value; + self.update_filters(); + } + PARAM_MID_GAIN => { + self.mid_gain_db = value; + self.update_filters(); + } + PARAM_MID_Q => { + self.mid_q = value; + self.update_filters(); + } + PARAM_HIGH_FREQ => { + self.high_freq = value; + self.update_filters(); + } + PARAM_HIGH_GAIN => { + self.high_gain_db = value; + self.update_filters(); + } + _ => {} + } + } + + fn get_parameter(&self, id: u32) -> f32 { + match id { + PARAM_LOW_FREQ => self.low_freq, + PARAM_LOW_GAIN => self.low_gain_db, + PARAM_MID_FREQ => self.mid_freq, + PARAM_MID_GAIN => self.mid_gain_db, + PARAM_MID_Q => self.mid_q, + PARAM_HIGH_FREQ => self.high_freq, + PARAM_HIGH_GAIN => self.high_gain_db, + _ => 0.0, + } + } + + fn process( + &mut self, + inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + sample_rate: u32, + ) { + if inputs.is_empty() || outputs.is_empty() { + return; + } + + // Update sample rate if changed + if self.sample_rate != sample_rate { + self.sample_rate = sample_rate; + self.update_filters(); + } + + let input = inputs[0]; + let output = &mut outputs[0]; + + // Audio signals are stereo (interleaved L/R) + let frames = input.len() / 2; + let output_frames = output.len() / 2; + let frames_to_process = frames.min(output_frames); + + for frame in 0..frames_to_process { + let mut left = input[frame * 2]; + let mut right = input[frame * 2 + 1]; + + // Process through all three bands + left = self.low_filter_left.process_sample(left, 0); + left = self.mid_filter_left.process_sample(left, 0); + left = self.high_filter_left.process_sample(left, 0); + + right = self.low_filter_right.process_sample(right, 1); + right = self.mid_filter_right.process_sample(right, 1); + right = self.high_filter_right.process_sample(right, 1); + + output[frame * 2] = left; + output[frame * 2 + 1] = right; + } + } + + fn reset(&mut self) { + self.low_filter_left.reset(); + self.low_filter_right.reset(); + self.mid_filter_left.reset(); + self.mid_filter_right.reset(); + self.high_filter_left.reset(); + self.high_filter_right.reset(); + } + + fn node_type(&self) -> &str { + "EQ" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + let mut node = Self::new(self.name.clone()); + node.low_freq = self.low_freq; + node.low_gain_db = self.low_gain_db; + node.mid_freq = self.mid_freq; + node.mid_gain_db = self.mid_gain_db; + node.mid_q = self.mid_q; + node.high_freq = self.high_freq; + node.high_gain_db = self.high_gain_db; + node.sample_rate = self.sample_rate; + node.update_filters(); + Box::new(node) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/filter.rs b/daw-backend/src/audio/node_graph/nodes/filter.rs new file mode 100644 index 0000000..719460c --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/filter.rs @@ -0,0 +1,231 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default}; +use crate::audio::midi::MidiEvent; +use crate::dsp::biquad::BiquadFilter; + +const PARAM_CUTOFF: u32 = 0; +const PARAM_RESONANCE: u32 = 1; +const PARAM_TYPE: u32 = 2; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum FilterType { + Lowpass = 0, + Highpass = 1, +} + +impl FilterType { + fn from_f32(value: f32) -> Self { + match value.round() as i32 { + 1 => FilterType::Highpass, + _ => FilterType::Lowpass, + } + } +} + +/// Filter node using biquad implementation +pub struct FilterNode { + name: String, + filter: BiquadFilter, + cutoff: f32, + resonance: f32, + filter_type: FilterType, + sample_rate: u32, + /// Last cutoff frequency applied to filter coefficients (for change detection with CV modulation) + last_applied_cutoff: f32, + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl FilterNode { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + + let inputs = vec![ + NodePort::new("Audio In", SignalType::Audio, 0), + NodePort::new("Cutoff CV", SignalType::CV, 1), + ]; + + let outputs = vec![ + NodePort::new("Audio Out", SignalType::Audio, 0), + ]; + + let parameters = vec![ + Parameter::new(PARAM_CUTOFF, "Cutoff", 20.0, 20000.0, 1000.0, ParameterUnit::Frequency), + Parameter::new(PARAM_RESONANCE, "Resonance", 0.1, 10.0, 0.707, ParameterUnit::Generic), + Parameter::new(PARAM_TYPE, "Type", 0.0, 1.0, 0.0, ParameterUnit::Generic), + ]; + + let filter = BiquadFilter::lowpass(1000.0, 0.707, 44100.0); + + Self { + name, + filter, + cutoff: 1000.0, + resonance: 0.707, + filter_type: FilterType::Lowpass, + sample_rate: 44100, + last_applied_cutoff: 1000.0, + inputs, + outputs, + parameters, + } + } + + fn update_filter(&mut self) { + match self.filter_type { + FilterType::Lowpass => { + self.filter.set_lowpass(self.cutoff, self.resonance, self.sample_rate as f32); + } + FilterType::Highpass => { + self.filter.set_highpass(self.cutoff, self.resonance, self.sample_rate as f32); + } + } + } +} + +impl AudioNode for FilterNode { + fn category(&self) -> NodeCategory { + NodeCategory::Effect + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, id: u32, value: f32) { + match id { + PARAM_CUTOFF => { + self.cutoff = value.clamp(20.0, 20000.0); + self.update_filter(); + } + PARAM_RESONANCE => { + self.resonance = value.clamp(0.1, 10.0); + self.update_filter(); + } + PARAM_TYPE => { + self.filter_type = FilterType::from_f32(value); + self.update_filter(); + } + _ => {} + } + } + + fn get_parameter(&self, id: u32) -> f32 { + match id { + PARAM_CUTOFF => self.cutoff, + PARAM_RESONANCE => self.resonance, + PARAM_TYPE => self.filter_type as i32 as f32, + _ => 0.0, + } + } + + fn process( + &mut self, + inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + sample_rate: u32, + ) { + if inputs.is_empty() || outputs.is_empty() { + return; + } + + // Update sample rate if changed + if self.sample_rate != sample_rate { + self.sample_rate = sample_rate; + self.update_filter(); + } + + let input = inputs[0]; + let output = &mut outputs[0]; + let len = input.len().min(output.len()); + + // Copy input to output + output[..len].copy_from_slice(&input[..len]); + + // Check for CV modulation (modulates cutoff) + // CV input (0..1) scales the cutoff: 0 = 20 Hz, 1 = base cutoff * 2 + // Sample CV at the start of the buffer - per-sample would be too expensive + let cutoff_cv_raw = cv_input_or_default(inputs, 1, 0, f32::NAN); + let effective_cutoff = if cutoff_cv_raw.is_nan() { + self.cutoff + } else { + // Map CV (0..1) to frequency range around the base cutoff + // 0.5 = base cutoff, 0 = cutoff / 4, 1 = cutoff * 4 (two octaves each way) + let octave_shift = (cutoff_cv_raw.clamp(0.0, 1.0) - 0.5) * 4.0; + self.cutoff * 2.0_f32.powf(octave_shift) + }; + if (effective_cutoff - self.last_applied_cutoff).abs() > 0.01 { + let new_cutoff = effective_cutoff.clamp(20.0, 20000.0); + self.last_applied_cutoff = new_cutoff; + match self.filter_type { + FilterType::Lowpass => { + self.filter.set_lowpass(new_cutoff, self.resonance, self.sample_rate as f32); + } + FilterType::Highpass => { + self.filter.set_highpass(new_cutoff, self.resonance, self.sample_rate as f32); + } + } + } + + // Apply filter (processes stereo interleaved) + self.filter.process_buffer(&mut output[..len], 2); + } + + fn reset(&mut self) { + self.filter.reset(); + } + + fn node_type(&self) -> &str { + "Filter" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + // Create new filter with same parameters but reset state + let mut new_filter = BiquadFilter::new(); + + // Set filter to match current type + match self.filter_type { + FilterType::Lowpass => { + new_filter.set_lowpass(self.cutoff, self.resonance, self.sample_rate as f32); + } + FilterType::Highpass => { + new_filter.set_highpass(self.cutoff, self.resonance, self.sample_rate as f32); + } + } + + Box::new(Self { + name: self.name.clone(), + filter: new_filter, + cutoff: self.cutoff, + resonance: self.resonance, + filter_type: self.filter_type, + sample_rate: self.sample_rate, + last_applied_cutoff: self.cutoff, + inputs: self.inputs.clone(), + outputs: self.outputs.clone(), + parameters: self.parameters.clone(), + }) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/flanger.rs b/daw-backend/src/audio/node_graph/nodes/flanger.rs new file mode 100644 index 0000000..883c385 --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/flanger.rs @@ -0,0 +1,251 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; +use crate::audio::midi::MidiEvent; +use std::f32::consts::PI; + +const PARAM_RATE: u32 = 0; +const PARAM_DEPTH: u32 = 1; +const PARAM_FEEDBACK: u32 = 2; +const PARAM_WET_DRY: u32 = 3; + +const MAX_DELAY_MS: f32 = 10.0; +const BASE_DELAY_MS: f32 = 1.0; + +/// Flanger effect using modulated delay lines with feedback +pub struct FlangerNode { + name: String, + rate: f32, // LFO rate in Hz (0.1 to 10 Hz) + depth: f32, // Modulation depth 0.0 to 1.0 + feedback: f32, // Feedback amount -0.95 to 0.95 + wet_dry: f32, // 0.0 = dry only, 1.0 = wet only + + // Delay buffers for left and right channels + delay_buffer_left: Vec, + delay_buffer_right: Vec, + write_position: usize, + max_delay_samples: usize, + sample_rate: u32, + + // LFO state + lfo_phase: f32, + + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl FlangerNode { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + + let inputs = vec![ + NodePort::new("Audio In", SignalType::Audio, 0), + ]; + + let outputs = vec![ + NodePort::new("Audio Out", SignalType::Audio, 0), + ]; + + let parameters = vec![ + Parameter::new(PARAM_RATE, "Rate", 0.1, 10.0, 0.5, ParameterUnit::Frequency), + Parameter::new(PARAM_DEPTH, "Depth", 0.0, 1.0, 0.7, ParameterUnit::Generic), + Parameter::new(PARAM_FEEDBACK, "Feedback", -0.95, 0.95, 0.5, ParameterUnit::Generic), + Parameter::new(PARAM_WET_DRY, "Wet/Dry", 0.0, 1.0, 0.5, ParameterUnit::Generic), + ]; + + // Allocate max delay buffer size + let max_delay_samples = ((MAX_DELAY_MS / 1000.0) * 48000.0) as usize; + + Self { + name, + rate: 0.5, + depth: 0.7, + feedback: 0.5, + wet_dry: 0.5, + delay_buffer_left: vec![0.0; max_delay_samples], + delay_buffer_right: vec![0.0; max_delay_samples], + write_position: 0, + max_delay_samples, + sample_rate: 48000, + lfo_phase: 0.0, + inputs, + outputs, + parameters, + } + } + + fn read_interpolated_sample(&self, buffer: &[f32], delay_samples: f32) -> f32 { + // Linear interpolation for smooth delay modulation + let delay_samples = delay_samples.clamp(0.0, (self.max_delay_samples - 1) as f32); + + let read_pos_float = self.write_position as f32 - delay_samples; + let read_pos_float = if read_pos_float < 0.0 { + read_pos_float + self.max_delay_samples as f32 + } else { + read_pos_float + }; + + let read_pos_int = read_pos_float.floor() as usize; + let frac = read_pos_float - read_pos_int as f32; + + let sample1 = buffer[read_pos_int % self.max_delay_samples]; + let sample2 = buffer[(read_pos_int + 1) % self.max_delay_samples]; + + sample1 * (1.0 - frac) + sample2 * frac + } +} + +impl AudioNode for FlangerNode { + fn category(&self) -> NodeCategory { + NodeCategory::Effect + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, id: u32, value: f32) { + match id { + PARAM_RATE => { + self.rate = value.clamp(0.1, 10.0); + } + PARAM_DEPTH => { + self.depth = value.clamp(0.0, 1.0); + } + PARAM_FEEDBACK => { + self.feedback = value.clamp(-0.95, 0.95); + } + PARAM_WET_DRY => { + self.wet_dry = value.clamp(0.0, 1.0); + } + _ => {} + } + } + + fn get_parameter(&self, id: u32) -> f32 { + match id { + PARAM_RATE => self.rate, + PARAM_DEPTH => self.depth, + PARAM_FEEDBACK => self.feedback, + PARAM_WET_DRY => self.wet_dry, + _ => 0.0, + } + } + + fn process( + &mut self, + inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + sample_rate: u32, + ) { + if inputs.is_empty() || outputs.is_empty() { + return; + } + + // Update sample rate if changed + if self.sample_rate != sample_rate { + self.sample_rate = sample_rate; + self.max_delay_samples = ((MAX_DELAY_MS / 1000.0) * sample_rate as f32) as usize; + self.delay_buffer_left.resize(self.max_delay_samples, 0.0); + self.delay_buffer_right.resize(self.max_delay_samples, 0.0); + self.write_position = 0; + } + + let input = inputs[0]; + let output = &mut outputs[0]; + + // Audio signals are stereo (interleaved L/R) + let frames = input.len() / 2; + let output_frames = output.len() / 2; + let frames_to_process = frames.min(output_frames); + + let dry_gain = 1.0 - self.wet_dry; + let wet_gain = self.wet_dry; + + let base_delay_samples = (BASE_DELAY_MS / 1000.0) * self.sample_rate as f32; + let max_modulation_samples = (MAX_DELAY_MS - BASE_DELAY_MS) / 1000.0 * self.sample_rate as f32; + + for frame in 0..frames_to_process { + let left_in = input[frame * 2]; + let right_in = input[frame * 2 + 1]; + + // Generate LFO value (sine wave, 0 to 1) + let lfo_value = ((self.lfo_phase * 2.0 * PI).sin() * 0.5 + 0.5) * self.depth; + + // Calculate modulated delay time + let delay_samples = base_delay_samples + lfo_value * max_modulation_samples; + + // Read delayed samples with interpolation + let left_delayed = self.read_interpolated_sample(&self.delay_buffer_left, delay_samples); + let right_delayed = self.read_interpolated_sample(&self.delay_buffer_right, delay_samples); + + // Mix dry and wet signals + output[frame * 2] = left_in * dry_gain + left_delayed * wet_gain; + output[frame * 2 + 1] = right_in * dry_gain + right_delayed * wet_gain; + + // Write to delay buffer with feedback + self.delay_buffer_left[self.write_position] = left_in + left_delayed * self.feedback; + self.delay_buffer_right[self.write_position] = right_in + right_delayed * self.feedback; + + // Advance write position + self.write_position = (self.write_position + 1) % self.max_delay_samples; + + // Advance LFO phase + self.lfo_phase += self.rate / self.sample_rate as f32; + if self.lfo_phase >= 1.0 { + self.lfo_phase -= 1.0; + } + } + } + + fn reset(&mut self) { + self.delay_buffer_left.fill(0.0); + self.delay_buffer_right.fill(0.0); + self.write_position = 0; + self.lfo_phase = 0.0; + } + + fn node_type(&self) -> &str { + "Flanger" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + Box::new(Self { + name: self.name.clone(), + rate: self.rate, + depth: self.depth, + feedback: self.feedback, + wet_dry: self.wet_dry, + delay_buffer_left: vec![0.0; self.max_delay_samples], + delay_buffer_right: vec![0.0; self.max_delay_samples], + write_position: 0, + max_delay_samples: self.max_delay_samples, + sample_rate: self.sample_rate, + lfo_phase: 0.0, + inputs: self.inputs.clone(), + outputs: self.outputs.clone(), + parameters: self.parameters.clone(), + }) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/fm_synth.rs b/daw-backend/src/audio/node_graph/nodes/fm_synth.rs new file mode 100644 index 0000000..fe2ce47 --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/fm_synth.rs @@ -0,0 +1,304 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default}; +use crate::audio::midi::MidiEvent; +use std::f32::consts::PI; + +// Parameters for the FM synth +const PARAM_ALGORITHM: u32 = 0; +const PARAM_OP1_RATIO: u32 = 1; +const PARAM_OP1_LEVEL: u32 = 2; +const PARAM_OP2_RATIO: u32 = 3; +const PARAM_OP2_LEVEL: u32 = 4; +const PARAM_OP3_RATIO: u32 = 5; +const PARAM_OP3_LEVEL: u32 = 6; +const PARAM_OP4_RATIO: u32 = 7; +const PARAM_OP4_LEVEL: u32 = 8; + +/// FM Algorithm types (inspired by DX7) +/// Algorithm determines how operators modulate each other +#[derive(Debug, Clone, Copy, PartialEq)] +enum FMAlgorithm { + /// Stack: 1->2->3->4 (most harmonic) + Stack = 0, + /// Parallel: All operators to output (organ-like) + Parallel = 1, + /// Bell: 1->2, 3->4, both to output + Bell = 2, + /// Dual: 1->2->output, 3->4->output + Dual = 3, +} + +impl FMAlgorithm { + fn from_u32(value: u32) -> Self { + match value { + 0 => FMAlgorithm::Stack, + 1 => FMAlgorithm::Parallel, + 2 => FMAlgorithm::Bell, + 3 => FMAlgorithm::Dual, + _ => FMAlgorithm::Stack, + } + } +} + +/// Single FM operator (oscillator) +struct FMOperator { + phase: f32, + frequency_ratio: f32, // Multiplier of base frequency (e.g., 1.0, 2.0, 0.5) + level: f32, // Output amplitude 0.0-1.0 +} + +impl FMOperator { + fn new() -> Self { + Self { + phase: 0.0, + frequency_ratio: 1.0, + level: 1.0, + } + } + + /// Process one sample with optional frequency modulation + fn process(&mut self, base_freq: f32, modulation: f32, sample_rate: f32) -> f32 { + let freq = base_freq * self.frequency_ratio; + + // Phase modulation (PM, which sounds like FM) + let output = (self.phase * 2.0 * PI + modulation).sin() * self.level; + + // Advance phase + self.phase += freq / sample_rate; + if self.phase >= 1.0 { + self.phase -= 1.0; + } + + output + } + + fn reset(&mut self) { + self.phase = 0.0; + } +} + +/// 4-operator FM synthesizer node +pub struct FMSynthNode { + name: String, + algorithm: FMAlgorithm, + + // Four operators + operators: [FMOperator; 4], + + // Current frequency from V/oct input + current_frequency: f32, + gate_active: bool, + + sample_rate: u32, + + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl FMSynthNode { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + + let inputs = vec![ + NodePort::new("V/Oct", SignalType::CV, 0), + NodePort::new("Gate", SignalType::CV, 1), + ]; + + let outputs = vec![ + NodePort::new("Audio Out", SignalType::Audio, 0), + ]; + + let parameters = vec![ + Parameter::new(PARAM_ALGORITHM, "Algorithm", 0.0, 3.0, 0.0, ParameterUnit::Generic), + Parameter::new(PARAM_OP1_RATIO, "Op1 Ratio", 0.25, 16.0, 1.0, ParameterUnit::Generic), + Parameter::new(PARAM_OP1_LEVEL, "Op1 Level", 0.0, 1.0, 1.0, ParameterUnit::Generic), + Parameter::new(PARAM_OP2_RATIO, "Op2 Ratio", 0.25, 16.0, 2.0, ParameterUnit::Generic), + Parameter::new(PARAM_OP2_LEVEL, "Op2 Level", 0.0, 1.0, 0.8, ParameterUnit::Generic), + Parameter::new(PARAM_OP3_RATIO, "Op3 Ratio", 0.25, 16.0, 3.0, ParameterUnit::Generic), + Parameter::new(PARAM_OP3_LEVEL, "Op3 Level", 0.0, 1.0, 0.6, ParameterUnit::Generic), + Parameter::new(PARAM_OP4_RATIO, "Op4 Ratio", 0.25, 16.0, 4.0, ParameterUnit::Generic), + Parameter::new(PARAM_OP4_LEVEL, "Op4 Level", 0.0, 1.0, 0.4, ParameterUnit::Generic), + ]; + + Self { + name, + algorithm: FMAlgorithm::Stack, + operators: [ + FMOperator::new(), + FMOperator::new(), + FMOperator::new(), + FMOperator::new(), + ], + current_frequency: 440.0, + gate_active: false, + sample_rate: 48000, + inputs, + outputs, + parameters, + } + } + + /// Convert V/oct CV to frequency + fn voct_to_freq(voct: f32) -> f32 { + 440.0 * 2.0_f32.powf(voct) + } + + /// Process FM synthesis based on current algorithm + fn process_algorithm(&mut self) -> f32 { + if !self.gate_active { + return 0.0; + } + + let base_freq = self.current_frequency; + let sr = self.sample_rate as f32; + + match self.algorithm { + FMAlgorithm::Stack => { + // 1 -> 2 -> 3 -> 4 -> output + let op4_out = self.operators[3].process(base_freq, 0.0, sr); + let op3_out = self.operators[2].process(base_freq, op4_out * 2.0, sr); + let op2_out = self.operators[1].process(base_freq, op3_out * 2.0, sr); + let op1_out = self.operators[0].process(base_freq, op2_out * 2.0, sr); + op1_out + } + FMAlgorithm::Parallel => { + // All operators output directly (no modulation) + let op1_out = self.operators[0].process(base_freq, 0.0, sr); + let op2_out = self.operators[1].process(base_freq, 0.0, sr); + let op3_out = self.operators[2].process(base_freq, 0.0, sr); + let op4_out = self.operators[3].process(base_freq, 0.0, sr); + (op1_out + op2_out + op3_out + op4_out) * 0.25 + } + FMAlgorithm::Bell => { + // 1 -> 2, 3 -> 4, both to output + let op2_out = self.operators[1].process(base_freq, 0.0, sr); + let op1_out = self.operators[0].process(base_freq, op2_out * 2.0, sr); + let op4_out = self.operators[3].process(base_freq, 0.0, sr); + let op3_out = self.operators[2].process(base_freq, op4_out * 2.0, sr); + (op1_out + op3_out) * 0.5 + } + FMAlgorithm::Dual => { + // 1 -> 2 -> output, 3 -> 4 -> output + let op2_out = self.operators[1].process(base_freq, 0.0, sr); + let op1_out = self.operators[0].process(base_freq, op2_out * 2.0, sr); + let op4_out = self.operators[3].process(base_freq, 0.0, sr); + let op3_out = self.operators[2].process(base_freq, op4_out * 2.0, sr); + (op1_out + op3_out) * 0.5 + } + } + } +} + +impl AudioNode for FMSynthNode { + fn category(&self) -> NodeCategory { + NodeCategory::Generator + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, id: u32, value: f32) { + match id { + PARAM_ALGORITHM => { + self.algorithm = FMAlgorithm::from_u32(value as u32); + } + PARAM_OP1_RATIO => self.operators[0].frequency_ratio = value.clamp(0.25, 16.0), + PARAM_OP1_LEVEL => self.operators[0].level = value.clamp(0.0, 1.0), + PARAM_OP2_RATIO => self.operators[1].frequency_ratio = value.clamp(0.25, 16.0), + PARAM_OP2_LEVEL => self.operators[1].level = value.clamp(0.0, 1.0), + PARAM_OP3_RATIO => self.operators[2].frequency_ratio = value.clamp(0.25, 16.0), + PARAM_OP3_LEVEL => self.operators[2].level = value.clamp(0.0, 1.0), + PARAM_OP4_RATIO => self.operators[3].frequency_ratio = value.clamp(0.25, 16.0), + PARAM_OP4_LEVEL => self.operators[3].level = value.clamp(0.0, 1.0), + _ => {} + } + } + + fn get_parameter(&self, id: u32) -> f32 { + match id { + PARAM_ALGORITHM => self.algorithm as u32 as f32, + PARAM_OP1_RATIO => self.operators[0].frequency_ratio, + PARAM_OP1_LEVEL => self.operators[0].level, + PARAM_OP2_RATIO => self.operators[1].frequency_ratio, + PARAM_OP2_LEVEL => self.operators[1].level, + PARAM_OP3_RATIO => self.operators[2].frequency_ratio, + PARAM_OP3_LEVEL => self.operators[2].level, + PARAM_OP4_RATIO => self.operators[3].frequency_ratio, + PARAM_OP4_LEVEL => self.operators[3].level, + _ => 0.0, + } + } + + fn process( + &mut self, + inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + sample_rate: u32, + ) { + if outputs.is_empty() { + return; + } + + self.sample_rate = sample_rate; + + let output = &mut outputs[0]; + let frames = output.len() / 2; + + for frame in 0..frames { + // Read CV inputs (both are mono signals) + // V/Oct: when unconnected, defaults to 0.0 (A4 440 Hz) + let voct = cv_input_or_default(inputs, 0, frame, 0.0); + // Gate: when unconnected, defaults to 0.0 (off) + let gate = cv_input_or_default(inputs, 1, frame, 0.0); + + // Update state + self.current_frequency = Self::voct_to_freq(voct); + self.gate_active = gate > 0.5; + + // Generate sample + let sample = self.process_algorithm() * 0.3; // Scale down to prevent clipping + + // Output stereo (same signal to both channels) + output[frame * 2] = sample; + output[frame * 2 + 1] = sample; + } + } + + fn reset(&mut self) { + for op in &mut self.operators { + op.reset(); + } + self.gate_active = false; + } + + fn node_type(&self) -> &str { + "FMSynth" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + Box::new(Self::new(self.name.clone())) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/gain.rs b/daw-backend/src/audio/node_graph/nodes/gain.rs new file mode 100644 index 0000000..fc4a769 --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/gain.rs @@ -0,0 +1,134 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default}; +use crate::audio::midi::MidiEvent; + +const PARAM_GAIN: u32 = 0; + +/// Gain/volume control node +pub struct GainNode { + name: String, + gain: f32, + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl GainNode { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + + let inputs = vec![ + NodePort::new("Audio In", SignalType::Audio, 0), + NodePort::new("Gain CV", SignalType::CV, 1), + ]; + + let outputs = vec![ + NodePort::new("Audio Out", SignalType::Audio, 0), + ]; + + let parameters = vec![ + Parameter::new(PARAM_GAIN, "Gain", 0.0, 2.0, 1.0, ParameterUnit::Generic), + ]; + + Self { + name, + gain: 1.0, + inputs, + outputs, + parameters, + } + } +} + +impl AudioNode for GainNode { + fn category(&self) -> NodeCategory { + NodeCategory::Utility + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, id: u32, value: f32) { + match id { + PARAM_GAIN => self.gain = value.clamp(0.0, 2.0), + _ => {} + } + } + + fn get_parameter(&self, id: u32) -> f32 { + match id { + PARAM_GAIN => self.gain, + _ => 0.0, + } + } + + fn process( + &mut self, + inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + _sample_rate: u32, + ) { + if inputs.is_empty() || outputs.is_empty() { + return; + } + + let input = inputs[0]; + let output = &mut outputs[0]; + + // Audio signals are stereo (interleaved L/R) + // Process by frames, not samples + let frames = input.len().min(output.len()) / 2; + + for frame in 0..frames { + // CV input acts as a VCA (voltage-controlled amplifier) + // CV ranges from 0.0 (silence) to 1.0 (full gain parameter value) + // When unconnected (NaN), defaults to 1.0 (no modulation, use gain parameter as-is) + let cv = cv_input_or_default(inputs, 1, frame, 1.0); + let final_gain = self.gain * cv; + + // Apply gain to both channels + output[frame * 2] = input[frame * 2] * final_gain; // Left + output[frame * 2 + 1] = input[frame * 2 + 1] * final_gain; // Right + } + } + + fn reset(&mut self) { + // No state to reset + } + + fn node_type(&self) -> &str { + "Gain" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + Box::new(Self { + name: self.name.clone(), + gain: self.gain, + inputs: self.inputs.clone(), + outputs: self.outputs.clone(), + parameters: self.parameters.clone(), + }) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/lfo.rs b/daw-backend/src/audio/node_graph/nodes/lfo.rs new file mode 100644 index 0000000..7aecfc9 --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/lfo.rs @@ -0,0 +1,230 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; +use crate::audio::midi::MidiEvent; +use std::f32::consts::PI; +use rand::Rng; + +const PARAM_FREQUENCY: u32 = 0; +const PARAM_AMPLITUDE: u32 = 1; +const PARAM_WAVEFORM: u32 = 2; +const PARAM_PHASE_OFFSET: u32 = 3; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum LFOWaveform { + Sine = 0, + Triangle = 1, + Saw = 2, + Square = 3, + Random = 4, +} + +impl LFOWaveform { + fn from_f32(value: f32) -> Self { + match value.round() as i32 { + 1 => LFOWaveform::Triangle, + 2 => LFOWaveform::Saw, + 3 => LFOWaveform::Square, + 4 => LFOWaveform::Random, + _ => LFOWaveform::Sine, + } + } +} + +/// Low Frequency Oscillator node for modulation +pub struct LFONode { + name: String, + frequency: f32, + amplitude: f32, + waveform: LFOWaveform, + phase_offset: f32, + phase: f32, + last_random_value: f32, + next_random_value: f32, + random_phase: f32, + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl LFONode { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + + let inputs = vec![]; + + let outputs = vec![ + NodePort::new("CV Out", SignalType::CV, 0), + ]; + + let parameters = vec![ + Parameter::new(PARAM_FREQUENCY, "Frequency", 0.01, 20.0, 1.0, ParameterUnit::Frequency), + Parameter::new(PARAM_AMPLITUDE, "Amplitude", 0.0, 1.0, 1.0, ParameterUnit::Generic), + Parameter::new(PARAM_WAVEFORM, "Waveform", 0.0, 4.0, 0.0, ParameterUnit::Generic), + Parameter::new(PARAM_PHASE_OFFSET, "Phase", 0.0, 1.0, 0.0, ParameterUnit::Generic), + ]; + + let mut rng = rand::thread_rng(); + + Self { + name, + frequency: 1.0, + amplitude: 1.0, + waveform: LFOWaveform::Sine, + phase_offset: 0.0, + phase: 0.0, + last_random_value: rng.gen_range(-1.0..1.0), + next_random_value: rng.gen_range(-1.0..1.0), + random_phase: 0.0, + inputs, + outputs, + parameters, + } + } +} + +impl AudioNode for LFONode { + fn category(&self) -> NodeCategory { + NodeCategory::Utility + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, id: u32, value: f32) { + match id { + PARAM_FREQUENCY => self.frequency = value.clamp(0.01, 20.0), + PARAM_AMPLITUDE => self.amplitude = value.clamp(0.0, 1.0), + PARAM_WAVEFORM => self.waveform = LFOWaveform::from_f32(value), + PARAM_PHASE_OFFSET => self.phase_offset = value.clamp(0.0, 1.0), + _ => {} + } + } + + fn get_parameter(&self, id: u32) -> f32 { + match id { + PARAM_FREQUENCY => self.frequency, + PARAM_AMPLITUDE => self.amplitude, + PARAM_WAVEFORM => self.waveform as i32 as f32, + PARAM_PHASE_OFFSET => self.phase_offset, + _ => 0.0, + } + } + + fn process( + &mut self, + _inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + sample_rate: u32, + ) { + if outputs.is_empty() { + return; + } + + let output = &mut outputs[0]; + let sample_rate_f32 = sample_rate as f32; + + // CV signals are mono + for sample_idx in 0..output.len() { + let current_phase = (self.phase + self.phase_offset) % 1.0; + + // Generate waveform sample based on waveform type + let raw_sample = match self.waveform { + LFOWaveform::Sine => (current_phase * 2.0 * PI).sin(), + LFOWaveform::Triangle => { + // Triangle: rises from -1 to 1, falls back to -1 + 4.0 * (current_phase - 0.5).abs() - 1.0 + } + LFOWaveform::Saw => { + // Sawtooth: ramp from -1 to 1 + 2.0 * current_phase - 1.0 + } + LFOWaveform::Square => { + if current_phase < 0.5 { 1.0 } else { -1.0 } + } + LFOWaveform::Random => { + // Sample & hold random values with smooth interpolation + // Interpolate between last and next random value + let t = self.random_phase; + self.last_random_value * (1.0 - t) + self.next_random_value * t + } + }; + + // Scale to 0-1 range and apply amplitude + let sample = (raw_sample * 0.5 + 0.5) * self.amplitude; + output[sample_idx] = sample; + + // Update phase + self.phase += self.frequency / sample_rate_f32; + if self.phase >= 1.0 { + self.phase -= 1.0; + + // For random waveform, generate new random value at each cycle + if self.waveform == LFOWaveform::Random { + self.last_random_value = self.next_random_value; + let mut rng = rand::thread_rng(); + self.next_random_value = rng.gen_range(-1.0..1.0); + self.random_phase = 0.0; + } + } + + // Update random interpolation phase + if self.waveform == LFOWaveform::Random { + self.random_phase += self.frequency / sample_rate_f32; + if self.random_phase >= 1.0 { + self.random_phase -= 1.0; + } + } + } + } + + fn reset(&mut self) { + self.phase = 0.0; + self.random_phase = 0.0; + let mut rng = rand::thread_rng(); + self.last_random_value = rng.gen_range(-1.0..1.0); + self.next_random_value = rng.gen_range(-1.0..1.0); + } + + fn node_type(&self) -> &str { + "LFO" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + Box::new(Self { + name: self.name.clone(), + frequency: self.frequency, + amplitude: self.amplitude, + waveform: self.waveform, + phase_offset: self.phase_offset, + phase: 0.0, // Reset phase for new instance + last_random_value: self.last_random_value, + next_random_value: self.next_random_value, + random_phase: 0.0, + inputs: self.inputs.clone(), + outputs: self.outputs.clone(), + parameters: self.parameters.clone(), + }) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/limiter.rs b/daw-backend/src/audio/node_graph/nodes/limiter.rs new file mode 100644 index 0000000..b9f6388 --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/limiter.rs @@ -0,0 +1,223 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; +use crate::audio::midi::MidiEvent; + +const PARAM_THRESHOLD: u32 = 0; +const PARAM_RELEASE: u32 = 1; +const PARAM_CEILING: u32 = 2; + +/// Limiter node for preventing audio peaks from exceeding a threshold +/// Essentially a compressor with infinite ratio and very fast attack +pub struct LimiterNode { + name: String, + threshold_db: f32, + release_ms: f32, + ceiling_db: f32, + + // State + envelope: f32, + release_coeff: f32, + sample_rate: u32, + + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl LimiterNode { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + + let inputs = vec![ + NodePort::new("Audio In", SignalType::Audio, 0), + ]; + + let outputs = vec![ + NodePort::new("Audio Out", SignalType::Audio, 0), + ]; + + let parameters = vec![ + Parameter::new(PARAM_THRESHOLD, "Threshold", -60.0, 0.0, -1.0, ParameterUnit::Decibels), + Parameter::new(PARAM_RELEASE, "Release", 1.0, 500.0, 50.0, ParameterUnit::Time), + Parameter::new(PARAM_CEILING, "Ceiling", -60.0, 0.0, 0.0, ParameterUnit::Decibels), + ]; + + let sample_rate = 44100; + let release_coeff = Self::ms_to_coeff(50.0, sample_rate); + + Self { + name, + threshold_db: -1.0, + release_ms: 50.0, + ceiling_db: 0.0, + envelope: 0.0, + release_coeff, + sample_rate, + inputs, + outputs, + parameters, + } + } + + /// Convert milliseconds to exponential smoothing coefficient + fn ms_to_coeff(time_ms: f32, sample_rate: u32) -> f32 { + let time_seconds = time_ms / 1000.0; + let samples = time_seconds * sample_rate as f32; + (-1.0 / samples).exp() + } + + fn update_coefficients(&mut self) { + self.release_coeff = Self::ms_to_coeff(self.release_ms, self.sample_rate); + } + + /// Convert linear amplitude to dB + fn linear_to_db(linear: f32) -> f32 { + if linear > 0.0 { + 20.0 * linear.log10() + } else { + -160.0 + } + } + + /// Convert dB to linear gain + fn db_to_linear(db: f32) -> f32 { + 10.0_f32.powf(db / 20.0) + } + + fn process_sample(&mut self, input: f32) -> f32 { + // Detect input level (using absolute value as peak detector) + let input_level = input.abs(); + + // Convert to dB + let input_db = Self::linear_to_db(input_level); + + // Calculate gain reduction needed + // If above threshold, apply infinite ratio (hard limit) + let target_gr_db = if input_db > self.threshold_db { + input_db - self.threshold_db // Amount of overshoot to reduce + } else { + 0.0 + }; + + let target_gr_linear = Self::db_to_linear(-target_gr_db); + + // Very fast attack (instant for limiter), but slower release + // Attack coeff is very close to 0 for near-instant response + let attack_coeff = 0.0001; // Extremely fast attack + + let coeff = if target_gr_linear < self.envelope { + attack_coeff // Attack (instant response to louder signal) + } else { + self.release_coeff // Release (slower recovery) + }; + + self.envelope = target_gr_linear + coeff * (self.envelope - target_gr_linear); + + // Apply limiting and output ceiling + let limited = input * self.envelope; + let ceiling_linear = Self::db_to_linear(self.ceiling_db); + + // Hard clip at ceiling + limited.clamp(-ceiling_linear, ceiling_linear) + } +} + +impl AudioNode for LimiterNode { + fn category(&self) -> NodeCategory { + NodeCategory::Effect + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, id: u32, value: f32) { + match id { + PARAM_THRESHOLD => self.threshold_db = value, + PARAM_RELEASE => { + self.release_ms = value; + self.update_coefficients(); + } + PARAM_CEILING => self.ceiling_db = value, + _ => {} + } + } + + fn get_parameter(&self, id: u32) -> f32 { + match id { + PARAM_THRESHOLD => self.threshold_db, + PARAM_RELEASE => self.release_ms, + PARAM_CEILING => self.ceiling_db, + _ => 0.0, + } + } + + fn process( + &mut self, + inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + sample_rate: u32, + ) { + if inputs.is_empty() || outputs.is_empty() { + return; + } + + // Update sample rate if changed + if self.sample_rate != sample_rate { + self.sample_rate = sample_rate; + self.update_coefficients(); + } + + let input = inputs[0]; + let output = &mut outputs[0]; + let len = input.len().min(output.len()); + + for i in 0..len { + output[i] = self.process_sample(input[i]); + } + } + + fn reset(&mut self) { + self.envelope = 0.0; + } + + fn node_type(&self) -> &str { + "Limiter" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + Box::new(Self { + name: self.name.clone(), + threshold_db: self.threshold_db, + release_ms: self.release_ms, + ceiling_db: self.ceiling_db, + envelope: 0.0, // Reset state for clone + release_coeff: self.release_coeff, + sample_rate: self.sample_rate, + inputs: self.inputs.clone(), + outputs: self.outputs.clone(), + parameters: self.parameters.clone(), + }) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/math.rs b/daw-backend/src/audio/node_graph/nodes/math.rs new file mode 100644 index 0000000..fbdf993 --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/math.rs @@ -0,0 +1,172 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; +use crate::audio::midi::MidiEvent; + +const PARAM_OPERATION: u32 = 0; + +/// Mathematical and logical operations on CV signals +/// Operations: +/// 0 = Add, 1 = Subtract, 2 = Multiply, 3 = Divide +/// 4 = Min, 5 = Max, 6 = Average +/// 7 = Invert (1.0 - x), 8 = Absolute Value +/// 9 = Clamp (0.0 to 1.0), 10 = Wrap (-1.0 to 1.0) +/// 11 = Greater Than, 12 = Less Than, 13 = Equal (with tolerance) +pub struct MathNode { + name: String, + operation: u32, + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl MathNode { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + + let inputs = vec![ + NodePort::new("CV In A", SignalType::CV, 0), + NodePort::new("CV In B", SignalType::CV, 1), + ]; + + let outputs = vec![ + NodePort::new("CV Out", SignalType::CV, 0), + ]; + + let parameters = vec![ + Parameter::new(PARAM_OPERATION, "Operation", 0.0, 13.0, 0.0, ParameterUnit::Generic), + ]; + + Self { + name, + operation: 0, + inputs, + outputs, + parameters, + } + } + + fn apply_operation(&self, a: f32, b: f32) -> f32 { + match self.operation { + 0 => a + b, // Add + 1 => a - b, // Subtract + 2 => a * b, // Multiply + 3 => if b.abs() > 0.0001 { a / b } else { 0.0 }, // Divide (with protection) + 4 => a.min(b), // Min + 5 => a.max(b), // Max + 6 => (a + b) * 0.5, // Average + 7 => 1.0 - a, // Invert (ignores b) + 8 => a.abs(), // Absolute Value (ignores b) + 9 => a.clamp(0.0, 1.0), // Clamp to 0-1 (ignores b) + 10 => { // Wrap -1 to 1 + let mut result = a; + while result > 1.0 { + result -= 2.0; + } + while result < -1.0 { + result += 2.0; + } + result + }, + 11 => if a > b { 1.0 } else { 0.0 }, // Greater Than + 12 => if a < b { 1.0 } else { 0.0 }, // Less Than + 13 => if (a - b).abs() < 0.01 { 1.0 } else { 0.0 }, // Equal (with tolerance) + _ => a, // Unknown operation - pass through + } + } +} + +impl AudioNode for MathNode { + fn category(&self) -> NodeCategory { + NodeCategory::Utility + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, id: u32, value: f32) { + match id { + PARAM_OPERATION => self.operation = (value as u32).clamp(0, 13), + _ => {} + } + } + + fn get_parameter(&self, id: u32) -> f32 { + match id { + PARAM_OPERATION => self.operation as f32, + _ => 0.0, + } + } + + fn process( + &mut self, + inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + _sample_rate: u32, + ) { + if outputs.is_empty() { + return; + } + + let output = &mut outputs[0]; + let length = output.len(); + + // Process each sample + for i in 0..length { + // Get input A (or 0.0 if not connected) + let a = if !inputs.is_empty() && i < inputs[0].len() { + inputs[0][i] + } else { + 0.0 + }; + + // Get input B (or 0.0 if not connected) + let b = if inputs.len() > 1 && i < inputs[1].len() { + inputs[1][i] + } else { + 0.0 + }; + + output[i] = self.apply_operation(a, b); + } + } + + fn reset(&mut self) { + // No state to reset + } + + fn node_type(&self) -> &str { + "Math" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + Box::new(Self { + name: self.name.clone(), + operation: self.operation, + inputs: self.inputs.clone(), + outputs: self.outputs.clone(), + parameters: self.parameters.clone(), + }) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/midi_input.rs b/daw-backend/src/audio/node_graph/nodes/midi_input.rs new file mode 100644 index 0000000..20cf01a --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/midi_input.rs @@ -0,0 +1,113 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, SignalType}; +use crate::audio::midi::MidiEvent; + +/// MIDI Input node - receives MIDI events from the track and passes them through +pub struct MidiInputNode { + name: String, + inputs: Vec, + outputs: Vec, + parameters: Vec, + pending_events: Vec, +} + +impl MidiInputNode { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + + let inputs = vec![]; + let outputs = vec![ + NodePort::new("MIDI Out", SignalType::Midi, 0), + ]; + + Self { + name, + inputs, + outputs, + parameters: vec![], + pending_events: Vec::new(), + } + } + + /// Add MIDI events to be processed + pub fn add_midi_events(&mut self, events: Vec) { + self.pending_events.extend(events); + } + + /// Get pending MIDI events (used for routing to connected nodes) + pub fn take_midi_events(&mut self) -> Vec { + std::mem::take(&mut self.pending_events) + } +} + +impl AudioNode for MidiInputNode { + fn category(&self) -> NodeCategory { + NodeCategory::Input + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, _id: u32, _value: f32) { + // No parameters + } + + fn get_parameter(&self, _id: u32) -> f32 { + 0.0 + } + + fn process( + &mut self, + _inputs: &[&[f32]], + _outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + _sample_rate: u32, + ) { + // MidiInput receives MIDI from external sources (marked as MIDI target) + // and outputs it through the graph + // The MIDI was already placed in midi_outputs by the graph before calling process() + } + + fn reset(&mut self) { + self.pending_events.clear(); + } + + fn node_type(&self) -> &str { + "MidiInput" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + Box::new(Self { + name: self.name.clone(), + inputs: self.inputs.clone(), + outputs: self.outputs.clone(), + parameters: self.parameters.clone(), + pending_events: Vec::new(), + }) + } + + fn handle_midi(&mut self, event: &MidiEvent) { + self.pending_events.push(*event); + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/midi_to_cv.rs b/daw-backend/src/audio/node_graph/nodes/midi_to_cv.rs new file mode 100644 index 0000000..085333f --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/midi_to_cv.rs @@ -0,0 +1,233 @@ +use crate::audio::midi::MidiEvent; +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; + +const PARAM_PITCH_BEND_RANGE: u32 = 0; + +/// MIDI to CV converter +/// Converts MIDI note events to control voltage signals +pub struct MidiToCVNode { + name: String, + note: u8, // Current MIDI note number + gate: f32, // Gate CV (1.0 when note on, 0.0 when off) + velocity: f32, // Velocity CV (0.0-1.0) + pitch_cv: f32, // Pitch CV (V/Oct: 0V = A4, ±1V per octave), without bend + pitch_bend_range: f32, // Pitch bend range in semitones (default 2.0) + current_bend: f32, // Current pitch bend, normalised -1.0..=1.0 (0 = centre) + current_mod: f32, // Current modulation (CC1), 0.0..=1.0 + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl MidiToCVNode { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + + let inputs = vec![ + NodePort::new("MIDI In", SignalType::Midi, 0), + NodePort::new("Bend CV", SignalType::CV, 0), // External pitch bend in semitones + NodePort::new("Mod CV", SignalType::CV, 1), // External modulation 0.0..=1.0 + ]; + + let outputs = vec![ + NodePort::new("V/Oct", SignalType::CV, 0), // V/Oct: 0V = A4, ±1V per octave (with bend applied) + NodePort::new("Gate", SignalType::CV, 1), // 1.0 = on, 0.0 = off + NodePort::new("Velocity", SignalType::CV, 2), // 0.0-1.0 + NodePort::new("Bend", SignalType::CV, 3), // Total pitch bend in semitones (MIDI + CV) + NodePort::new("Mod", SignalType::CV, 4), // Total modulation 0.0..=1.0 (MIDI CC1 + CV) + ]; + + let parameters = vec![ + Parameter::new( + PARAM_PITCH_BEND_RANGE, + "Pitch Bend Range", + 0.0, 48.0, 2.0, + ParameterUnit::Generic, + ), + ]; + + Self { + name, + note: 60, + gate: 0.0, + velocity: 0.0, + pitch_cv: Self::midi_note_to_voct(60), + pitch_bend_range: 2.0, + current_bend: 0.0, + current_mod: 0.0, + inputs, + outputs, + parameters, + } + } + + /// Convert MIDI note to V/oct CV (proper V/Oct standard) + /// 0V = A4 (MIDI 69), ±1V per octave + /// Middle C (MIDI 60) = -0.75V, A5 (MIDI 81) = +1.0V + fn midi_note_to_voct(note: u8) -> f32 { + // Standard V/Oct: 0V at A4, 1V per octave (12 semitones) + (note as f32 - 69.0) / 12.0 + } + + fn apply_midi_event(&mut self, event: &MidiEvent) { + let status = event.status & 0xF0; + match status { + 0x90 if event.data2 > 0 => { + // Note on — reset per-note expression so previous note's bend doesn't bleed in + self.note = event.data1; + self.pitch_cv = Self::midi_note_to_voct(self.note); + self.velocity = event.data2 as f32 / 127.0; + self.gate = 1.0; + self.current_bend = 0.0; + self.current_mod = 0.0; + } + 0x80 | 0x90 => { + // Note off (or note on with velocity 0) + if event.data1 == self.note { + self.gate = 0.0; + } + } + 0xE0 => { + // Pitch bend: 14-bit value, center = 8192 + let bend_raw = ((event.data2 as i16) << 7) | (event.data1 as i16); + self.current_bend = (bend_raw - 8192) as f32 / 8192.0; + } + 0xB0 if event.data1 == 1 => { + // CC1 (modulation wheel) + self.current_mod = event.data2 as f32 / 127.0; + } + _ => {} + } + } +} + +impl AudioNode for MidiToCVNode { + fn category(&self) -> NodeCategory { + NodeCategory::Input + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, id: u32, value: f32) { + if id == PARAM_PITCH_BEND_RANGE { + self.pitch_bend_range = value.clamp(0.0, 48.0); + } + } + + fn get_parameter(&self, id: u32) -> f32 { + if id == PARAM_PITCH_BEND_RANGE { + self.pitch_bend_range + } else { + 0.0 + } + } + + fn handle_midi(&mut self, event: &MidiEvent) { + self.apply_midi_event(event); + } + + fn process( + &mut self, + inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + _sample_rate: u32, + ) { + // Process MIDI events from input buffer + if !midi_inputs.is_empty() { + for event in midi_inputs[0] { + self.apply_midi_event(event); + } + } + + if outputs.len() < 5 { + return; + } + + // Read CV inputs (use first sample of buffer). NaN = unconnected port → treat as 0. + let bend_cv = inputs.get(0).and_then(|b| b.first().copied()) + .filter(|v| v.is_finite()).unwrap_or(0.0); + let mod_cv = inputs.get(1).and_then(|b| b.first().copied()) + .filter(|v| v.is_finite()).unwrap_or(0.0); + + // Total bend in semitones: MIDI bend + CV bend + let bend_semitones = self.current_bend * self.pitch_bend_range + bend_cv; + // Total mod: MIDI CC1 + CV mod, clamped to 0..1 + let total_mod = (self.current_mod + mod_cv).clamp(0.0, 1.0); + // Pitch output includes bend + let pitch_out_val = self.pitch_cv + bend_semitones / 12.0; + + // Use split_at_mut to get multiple mutable references + let (v0, rest) = outputs.split_at_mut(1); + let (v1, rest) = rest.split_at_mut(1); + let (v2, rest) = rest.split_at_mut(1); + let (v3, v4_slice) = rest.split_at_mut(1); + + let pitch_out = &mut v0[0]; + let gate_out = &mut v1[0]; + let velocity_out = &mut v2[0]; + let bend_out = &mut v3[0]; + let mod_out = &mut v4_slice[0]; + + let frames = pitch_out.len(); + + // Output constant CV values for the entire buffer + for frame in 0..frames { + pitch_out[frame] = pitch_out_val; + gate_out[frame] = self.gate; + velocity_out[frame] = self.velocity; + bend_out[frame] = bend_semitones; + mod_out[frame] = total_mod; + } + } + + fn reset(&mut self) { + self.gate = 0.0; + self.velocity = 0.0; + self.current_bend = 0.0; + self.current_mod = 0.0; + } + + fn node_type(&self) -> &str { + "MidiToCV" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + Box::new(Self { + name: self.name.clone(), + note: 60, + gate: 0.0, + velocity: 0.0, + pitch_cv: Self::midi_note_to_voct(60), + pitch_bend_range: self.pitch_bend_range, + current_bend: 0.0, + current_mod: 0.0, + inputs: self.inputs.clone(), + outputs: self.outputs.clone(), + parameters: self.parameters.clone(), + }) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/mixer.rs b/daw-backend/src/audio/node_graph/nodes/mixer.rs new file mode 100644 index 0000000..3f57c11 --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/mixer.rs @@ -0,0 +1,164 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; +use crate::audio::midi::MidiEvent; + +/// Mixer node — combines N audio inputs with independent gain controls. +/// +/// The number of input ports is dynamic: one spare unconnected port is always present +/// beyond however many are currently wired, so users can keep patching in without +/// manually adding inputs. Port count is managed by `AudioGraph::connect` / +/// `AudioGraph::disconnect` calling `ensure_min_ports` / `resize`. +/// +/// Gain values are stored separately from the port list so they survive resize +/// operations and can be set via `set_parameter` before the port is visible. +pub struct MixerNode { + name: String, + /// Displayed input ports. Length = num_ports (connected + 1 spare). + inputs: Vec, + outputs: Vec, + /// Per-channel gains, indexed by port. May be longer than `inputs` if gains + /// were set before ports were created (handled gracefully). + gains: Vec, + /// Mirrored parameter list so `parameters()` stays in sync with `inputs`. + parameters: Vec, +} + +impl MixerNode { + pub fn new(name: impl Into) -> Self { + let mut node = Self { + name: name.into(), + inputs: Vec::new(), + outputs: vec![NodePort::new("Mixed Out", SignalType::Audio, 0)], + gains: Vec::new(), + parameters: Vec::new(), + }; + node.resize(1); // start with one spare input + node + } + + /// Return the current number of input ports (connected + 1 spare). + pub fn num_inputs(&self) -> usize { + self.inputs.len() + } + + /// Set the exact number of input ports. + /// + /// Existing gain values are preserved. Truncates spare gains when shrinking, + /// but gain slots that have already been written survive a grow-shrink-grow cycle. + pub fn resize(&mut self, n: usize) { + let n = n.max(1); // always at least one spare + + self.inputs = (0..n) + .map(|i| NodePort::new(format!("Input {}", i + 1).as_str(), SignalType::Audio, i)) + .collect(); + + // Extend gains with 1.0 for new slots; preserve existing values. + if self.gains.len() < n { + self.gains.resize(n, 1.0); + } + + self.parameters = (0..n) + .map(|i| { + Parameter::new(i as u32, format!("Gain {}", i + 1).as_str(), 0.0, 2.0, 1.0, ParameterUnit::Generic) + }) + .collect(); + } + + /// Ensure at least `n` input ports exist, growing if needed but never shrinking. + /// + /// Called by `AudioGraph::connect` after adding a connection. + pub fn ensure_min_ports(&mut self, n: usize) { + if n > self.inputs.len() { + self.resize(n); + } + } +} + +impl AudioNode for MixerNode { + fn category(&self) -> NodeCategory { + NodeCategory::Utility + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, id: u32, value: f32) { + let idx = id as usize; + // Extend gains if this port hasn't been created yet (e.g. loaded from preset + // before connections are restored). + if idx >= self.gains.len() { + self.gains.resize(idx + 1, 1.0); + } + self.gains[idx] = value.clamp(0.0, 2.0); + } + + fn get_parameter(&self, id: u32) -> f32 { + self.gains.get(id as usize).copied().unwrap_or(1.0) + } + + fn process( + &mut self, + inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + _sample_rate: u32, + ) { + if outputs.is_empty() { + return; + } + + let output = &mut outputs[0]; + let frames = output.len() / 2; + output.fill(0.0); + + for (input_idx, input) in inputs.iter().enumerate() { + let gain = self.gains.get(input_idx).copied().unwrap_or(1.0); + let input_frames = input.len() / 2; + let process_frames = frames.min(input_frames); + + for frame in 0..process_frames { + output[frame * 2] += input[frame * 2] * gain; // Left + output[frame * 2 + 1] += input[frame * 2 + 1] * gain; // Right + } + } + } + + fn reset(&mut self) { + // No per-frame state + } + + fn node_type(&self) -> &str { + "Mixer" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + Box::new(Self { + name: self.name.clone(), + inputs: self.inputs.clone(), + outputs: self.outputs.clone(), + gains: self.gains.clone(), + parameters: self.parameters.clone(), + }) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/mod.rs b/daw-backend/src/audio/node_graph/nodes/mod.rs new file mode 100644 index 0000000..1f9f8b3 --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/mod.rs @@ -0,0 +1,160 @@ +mod amp_sim; +pub mod bundled_models; +mod adsr; +mod subtrack_inputs; +mod arpeggiator; +mod audio_input; +mod audio_to_cv; +mod automation_input; +mod beat; +mod bit_crusher; +mod bpm_detector; +mod chorus; +mod compressor; +mod constant; +mod echo; +mod distortion; +mod envelope_follower; +mod eq; +mod filter; +mod flanger; +mod limiter; +mod fm_synth; +mod gain; +mod lfo; +mod math; +mod midi_input; +mod midi_to_cv; +mod mixer; +mod multi_sampler; +mod noise; +mod oscillator; +mod oscilloscope; +mod output; +mod pan; +mod phaser; +mod quantizer; +mod reverb; +mod ring_modulator; +mod sample_hold; +mod script_node; +mod sequencer; +mod simple_sampler; +mod slew_limiter; +mod splitter; +mod svf; +mod template_io; +mod vibrato; +mod vocoder; +mod voice_allocator; +mod wavetable_oscillator; + +pub use amp_sim::AmpSimNode; +pub use adsr::ADSRNode; +pub use arpeggiator::ArpeggiatorNode; +pub use audio_input::AudioInputNode; +pub use audio_to_cv::AudioToCVNode; +pub use automation_input::{AutomationInputNode, AutomationKeyframe, InterpolationType}; +pub use beat::BeatNode; +pub use bit_crusher::BitCrusherNode; +pub use bpm_detector::BpmDetectorNode; +pub use chorus::ChorusNode; +pub use compressor::CompressorNode; +pub use constant::ConstantNode; +pub use echo::EchoNode; +pub use distortion::DistortionNode; +pub use envelope_follower::EnvelopeFollowerNode; +pub use eq::EQNode; +pub use filter::FilterNode; +pub use flanger::FlangerNode; +pub use limiter::LimiterNode; +pub use fm_synth::FMSynthNode; +pub use gain::GainNode; +pub use lfo::LFONode; +pub use math::MathNode; +pub use midi_input::MidiInputNode; +pub use midi_to_cv::MidiToCVNode; +pub use mixer::MixerNode; +pub use multi_sampler::{MultiSamplerNode, LoopMode}; +pub use noise::NoiseGeneratorNode; +pub use oscillator::OscillatorNode; +pub use oscilloscope::OscilloscopeNode; +pub use output::AudioOutputNode; +pub use pan::PanNode; +pub use phaser::PhaserNode; +pub use quantizer::QuantizerNode; +pub use reverb::ReverbNode; +pub use ring_modulator::RingModulatorNode; +pub use sample_hold::SampleHoldNode; +pub use script_node::ScriptNode; +pub use sequencer::SequencerNode; +pub use simple_sampler::SimpleSamplerNode; +pub use slew_limiter::SlewLimiterNode; +pub use splitter::SplitterNode; +pub use svf::SVFNode; +pub use template_io::{TemplateInputNode, TemplateOutputNode}; +pub use vibrato::VibratoNode; +pub use vocoder::VocoderNode; +pub use voice_allocator::VoiceAllocatorNode; +pub use wavetable_oscillator::WavetableOscillatorNode; +pub use subtrack_inputs::SubtrackInputsNode; + +/// Create a node instance by type name string. +/// +/// Returns `None` for unknown type names. `sample_rate` and `buffer_size` +/// are only used by VoiceAllocator; other nodes ignore them. +pub fn create_node(node_type: &str, sample_rate: u32, buffer_size: usize) -> Option> { + 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, + }) +} diff --git a/daw-backend/src/audio/node_graph/nodes/multi_sampler.rs b/daw-backend/src/audio/node_graph/nodes/multi_sampler.rs new file mode 100644 index 0000000..8ba01b3 --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/multi_sampler.rs @@ -0,0 +1,832 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; +use crate::audio::midi::MidiEvent; + +// Parameters +const PARAM_GAIN: u32 = 0; +const PARAM_ATTACK: u32 = 1; +const PARAM_RELEASE: u32 = 2; +const PARAM_TRANSPOSE: u32 = 3; +const PARAM_PITCH_BEND_RANGE: u32 = 4; + +/// Loop playback mode +#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LoopMode { + /// Play sample once, no looping + OneShot, + /// Loop continuously between loop_start and loop_end + Continuous, +} + +/// Metadata about a loaded sample layer (for preset serialization) +#[derive(Clone, Debug)] +pub struct LayerInfo { + pub file_path: String, + pub key_min: u8, + pub key_max: u8, + pub root_key: u8, + pub velocity_min: u8, + pub velocity_max: u8, + pub loop_start: Option, // Loop start point in samples + pub loop_end: Option, // Loop end point in samples + pub loop_mode: LoopMode, +} + +/// Single sample with velocity range and key range +#[derive(Clone)] +struct SampleLayer { + sample_data: Vec, + sample_rate: f32, + + // Key range: C-1 = 0, C0 = 12, middle C (C4) = 60, C9 = 120 + key_min: u8, + key_max: u8, + root_key: u8, // The original pitch of the sample + + // Velocity range: 0-127 + velocity_min: u8, + velocity_max: u8, + + // Loop points (in samples) + loop_start: Option, + loop_end: Option, + loop_mode: LoopMode, +} + +impl SampleLayer { + fn new( + sample_data: Vec, + sample_rate: f32, + key_min: u8, + key_max: u8, + root_key: u8, + velocity_min: u8, + velocity_max: u8, + loop_start: Option, + loop_end: Option, + loop_mode: LoopMode, + ) -> Self { + Self { + sample_data, + sample_rate, + key_min, + key_max, + root_key, + velocity_min, + velocity_max, + loop_start, + loop_end, + loop_mode, + } + } + + /// Check if this layer matches the given key and velocity + fn matches(&self, key: u8, velocity: u8) -> bool { + key >= self.key_min + && key <= self.key_max + && velocity >= self.velocity_min + && velocity <= self.velocity_max + } + + /// Auto-detect loop points using autocorrelation to find a good loop region + /// Returns (loop_start, loop_end) in samples + fn detect_loop_points(sample_data: &[f32], sample_rate: f32) -> Option<(usize, usize)> { + if sample_data.len() < (sample_rate * 0.5) as usize { + return None; // Need at least 0.5 seconds of audio + } + + // Look for loop in the sustain region (skip attack/decay, avoid release) + // For sustained instruments, look in the middle 50% of the sample + let search_start = (sample_data.len() as f32 * 0.25) as usize; + let search_end = (sample_data.len() as f32 * 0.75) as usize; + + if search_end <= search_start { + return None; + } + + // Find the best loop point using autocorrelation + // For sustained instruments like brass/woodwind, we want longer loops + let min_loop_length = (sample_rate * 0.1) as usize; // Min 0.1s loop (more stable) + let max_loop_length = (sample_rate * 10.0) as usize; // Max 10 second loop + + let mut best_correlation = -1.0; + let mut best_loop_start = search_start; + let mut best_loop_end = search_end; + + // Try different loop lengths from LONGEST to SHORTEST + // This way we prefer longer loops and stop early if we find a good one + let length_step = ((sample_rate * 0.05) as usize).max(512); // 50ms steps + let actual_max_length = max_loop_length.min(search_end - search_start); + + // Manually iterate backwards since step_by().rev() doesn't work on RangeInclusive + let mut loop_length = actual_max_length; + while loop_length >= min_loop_length { + // Try different starting points in the sustain region (finer steps) + let start_step = ((sample_rate * 0.02) as usize).max(256); // 20ms steps + for start in (search_start..search_end - loop_length).step_by(start_step) { + let end = start + loop_length; + if end > search_end { + break; + } + + // Calculate correlation between loop end and loop start + let correlation = Self::calculate_loop_correlation(sample_data, start, end); + + if correlation > best_correlation { + best_correlation = correlation; + best_loop_start = start; + best_loop_end = end; + } + } + + // If we found a good enough loop, stop searching shorter ones + if best_correlation > 0.8 { + break; + } + + // Decrement loop_length, with underflow protection + if loop_length < length_step { + break; + } + loop_length -= length_step; + } + + // Lower threshold since longer loops are harder to match perfectly + if best_correlation > 0.6 { + Some((best_loop_start, best_loop_end)) + } else { + // Fallback: use a reasonable chunk of the sustain region + let fallback_length = ((search_end - search_start) / 2).max(min_loop_length); + Some((search_start, search_start + fallback_length)) + } + } + + /// Calculate how well the audio loops at the given points + /// Returns correlation value between -1.0 and 1.0 (higher is better) + fn calculate_loop_correlation(sample_data: &[f32], loop_start: usize, loop_end: usize) -> f32 { + let loop_length = loop_end - loop_start; + let window_size = (loop_length / 10).max(128).min(2048); // Compare last 10% of loop + + if loop_end + window_size >= sample_data.len() { + return -1.0; + } + + // Compare the end of the loop region with the beginning + let region1_start = loop_end - window_size; + let region2_start = loop_start; + + let mut sum_xy = 0.0; + let mut sum_x2 = 0.0; + let mut sum_y2 = 0.0; + + for i in 0..window_size { + let x = sample_data[region1_start + i]; + let y = sample_data[region2_start + i]; + sum_xy += x * y; + sum_x2 += x * x; + sum_y2 += y * y; + } + + // Pearson correlation coefficient + let denominator = (sum_x2 * sum_y2).sqrt(); + if denominator > 0.0 { + sum_xy / denominator + } else { + -1.0 + } + } +} + +/// Active voice playing a sample +struct Voice { + layer_index: usize, + playhead: f32, + note: u8, + channel: u8, // MIDI channel this voice was activated on + velocity: u8, + is_active: bool, + + // Envelope + envelope_phase: EnvelopePhase, + envelope_value: f32, + + // Loop crossfade state + crossfade_buffer: Vec, // Stores samples from before loop_start for crossfading + crossfade_length: usize, // Length of crossfade in samples (e.g., 100 samples = ~2ms @ 48kHz) +} + +#[derive(Debug, Clone, Copy, PartialEq)] +enum EnvelopePhase { + Attack, + Sustain, + Release, +} + +impl Voice { + fn new(layer_index: usize, note: u8, channel: u8, velocity: u8) -> Self { + Self { + layer_index, + playhead: 0.0, + note, + channel, + velocity, + is_active: true, + envelope_phase: EnvelopePhase::Attack, + envelope_value: 0.0, + crossfade_buffer: Vec::new(), + crossfade_length: 4800, // ~100ms at 48kHz — hides loop seams in sustained instruments + } + } +} + +/// Multi-sample instrument with velocity layers and key zones +pub struct MultiSamplerNode { + name: String, + + // Sample layers + layers: Vec, + layer_infos: Vec, // Metadata about loaded layers + + // Voice management + voices: Vec, + max_voices: usize, + + // Parameters + gain: f32, + attack_time: f32, // seconds + release_time: f32, // seconds + transpose: i8, // semitones + pitch_bend_range: f32, // semitones (default 2.0) + + // Live MIDI state + bend_per_channel: [f32; 16], // Pitch bend per MIDI channel; ch0 = global broadcast + current_mod: f32, // MIDI CC1 modulation 0.0..=1.0 + + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl MultiSamplerNode { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + + let inputs = vec![ + NodePort::new("MIDI In", SignalType::Midi, 0), + NodePort::new("Bend CV", SignalType::CV, 0), // External pitch bend in semitones + NodePort::new("Mod CV", SignalType::CV, 1), // External modulation 0.0..=1.0 + ]; + + let outputs = vec![ + NodePort::new("Audio Out", SignalType::Audio, 0), + ]; + + let parameters = vec![ + Parameter::new(PARAM_GAIN, "Gain", 0.0, 2.0, 1.0, ParameterUnit::Generic), + Parameter::new(PARAM_ATTACK, "Attack", 0.001, 1.0, 0.01, ParameterUnit::Time), + Parameter::new(PARAM_RELEASE, "Release", 0.01, 5.0, 0.1, ParameterUnit::Time), + Parameter::new(PARAM_TRANSPOSE, "Transpose", -24.0, 24.0, 0.0, ParameterUnit::Generic), + Parameter::new(PARAM_PITCH_BEND_RANGE, "Pitch Bend Range", 0.0, 48.0, 2.0, ParameterUnit::Generic), + ]; + + Self { + name, + layers: Vec::new(), + layer_infos: Vec::new(), + voices: Vec::new(), + max_voices: 16, + gain: 1.0, + attack_time: 0.01, + release_time: 0.1, + transpose: 0, + pitch_bend_range: 2.0, + bend_per_channel: [0.0; 16], + current_mod: 0.0, + inputs, + outputs, + parameters, + } + } + + /// Add a sample layer + pub fn add_layer( + &mut self, + sample_data: Vec, + sample_rate: f32, + key_min: u8, + key_max: u8, + root_key: u8, + velocity_min: u8, + velocity_max: u8, + loop_start: Option, + loop_end: Option, + loop_mode: LoopMode, + ) { + let layer = SampleLayer::new( + sample_data, + sample_rate, + key_min, + key_max, + root_key, + velocity_min, + velocity_max, + loop_start, + loop_end, + loop_mode, + ); + self.layers.push(layer); + } + + /// Load a sample layer from a file path + pub fn load_layer_from_file( + &mut self, + path: &str, + key_min: u8, + key_max: u8, + root_key: u8, + velocity_min: u8, + velocity_max: u8, + loop_start: Option, + loop_end: Option, + loop_mode: LoopMode, + ) -> Result<(), String> { + use crate::audio::sample_loader::load_audio_file; + + let sample_data = load_audio_file(path)?; + + // Auto-detect loop points if not provided and mode is Continuous + let (final_loop_start, final_loop_end) = if loop_mode == LoopMode::Continuous && loop_start.is_none() && loop_end.is_none() { + if let Some((start, end)) = SampleLayer::detect_loop_points(&sample_data.samples, sample_data.sample_rate as f32) { + (Some(start), Some(end)) + } else { + (None, None) + } + } else { + (loop_start, loop_end) + }; + + self.add_layer( + sample_data.samples, + sample_data.sample_rate as f32, + key_min, + key_max, + root_key, + velocity_min, + velocity_max, + final_loop_start, + final_loop_end, + loop_mode, + ); + + // Store layer metadata for preset serialization + self.layer_infos.push(LayerInfo { + file_path: path.to_string(), + key_min, + key_max, + root_key, + velocity_min, + velocity_max, + loop_start: final_loop_start, + loop_end: final_loop_end, + loop_mode, + }); + + Ok(()) + } + + /// Get information about all loaded layers + pub fn get_layers_info(&self) -> &[LayerInfo] { + &self.layer_infos + } + + /// Get sample data for a specific layer (for preset embedding) + pub fn get_layer_data(&self, layer_index: usize) -> Option<(Vec, f32)> { + self.layers.get(layer_index).map(|layer| { + (layer.sample_data.clone(), layer.sample_rate) + }) + } + + /// Update a layer's configuration + pub fn update_layer( + &mut self, + layer_index: usize, + key_min: u8, + key_max: u8, + root_key: u8, + velocity_min: u8, + velocity_max: u8, + loop_start: Option, + loop_end: Option, + loop_mode: LoopMode, + ) -> Result<(), String> { + if layer_index >= self.layers.len() { + return Err("Layer index out of bounds".to_string()); + } + + // Update the layer data + self.layers[layer_index].key_min = key_min; + self.layers[layer_index].key_max = key_max; + self.layers[layer_index].root_key = root_key; + self.layers[layer_index].velocity_min = velocity_min; + self.layers[layer_index].velocity_max = velocity_max; + self.layers[layer_index].loop_start = loop_start; + self.layers[layer_index].loop_end = loop_end; + self.layers[layer_index].loop_mode = loop_mode; + + // Update the layer info + if layer_index < self.layer_infos.len() { + self.layer_infos[layer_index].key_min = key_min; + self.layer_infos[layer_index].key_max = key_max; + self.layer_infos[layer_index].root_key = root_key; + self.layer_infos[layer_index].velocity_min = velocity_min; + self.layer_infos[layer_index].velocity_max = velocity_max; + self.layer_infos[layer_index].loop_start = loop_start; + self.layer_infos[layer_index].loop_end = loop_end; + self.layer_infos[layer_index].loop_mode = loop_mode; + } + + Ok(()) + } + + /// Remove a layer + pub fn remove_layer(&mut self, layer_index: usize) -> Result<(), String> { + if layer_index >= self.layers.len() { + return Err("Layer index out of bounds".to_string()); + } + + self.layers.remove(layer_index); + if layer_index < self.layer_infos.len() { + self.layer_infos.remove(layer_index); + } + + // Stop any voices playing this layer + for voice in &mut self.voices { + if voice.layer_index == layer_index { + voice.is_active = false; + } else if voice.layer_index > layer_index { + // Adjust indices for layers that were shifted down + voice.layer_index -= 1; + } + } + + Ok(()) + } + + /// Remove all layers + pub fn clear_layers(&mut self) { + self.layers.clear(); + self.layer_infos.clear(); + // Stop all active voices + for voice in &mut self.voices { + voice.is_active = false; + } + } + + /// Find the best matching layer for a given note and velocity + fn find_layer(&self, note: u8, velocity: u8) -> Option { + self.layers + .iter() + .enumerate() + .find(|(_, layer)| layer.matches(note, velocity)) + .map(|(index, _)| index) + } + + /// Trigger a note + fn note_on(&mut self, note: u8, channel: u8, velocity: u8) { + // Reset per-channel bend on note-on so a previous note's bend doesn't bleed in + self.bend_per_channel[channel as usize] = 0.0; + let transposed_note = (note as i16 + self.transpose as i16).clamp(0, 127) as u8; + + if let Some(layer_index) = self.find_layer(transposed_note, velocity) { + // Find an inactive voice or reuse the oldest one + let voice_index = self + .voices + .iter() + .position(|v| !v.is_active) + .unwrap_or_else(|| { + // All voices active, reuse the first one + if self.voices.len() < self.max_voices { + self.voices.len() + } else { + 0 + } + }); + + let voice = Voice::new(layer_index, note, channel, velocity); + + if voice_index < self.voices.len() { + self.voices[voice_index] = voice; + } else { + self.voices.push(voice); + } + } + } + + /// Release a note + fn note_off(&mut self, note: u8) { + for voice in &mut self.voices { + if voice.note == note && voice.is_active { + voice.envelope_phase = EnvelopePhase::Release; + } + } + } +} + +impl AudioNode for MultiSamplerNode { + fn category(&self) -> NodeCategory { + NodeCategory::Generator + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, id: u32, value: f32) { + match id { + PARAM_GAIN => { + self.gain = value.clamp(0.0, 2.0); + } + PARAM_ATTACK => { + self.attack_time = value.clamp(0.001, 1.0); + } + PARAM_RELEASE => { + self.release_time = value.clamp(0.01, 5.0); + } + PARAM_TRANSPOSE => { + self.transpose = value.clamp(-24.0, 24.0) as i8; + } + PARAM_PITCH_BEND_RANGE => { + self.pitch_bend_range = value.clamp(0.0, 48.0); + } + _ => {} + } + } + + fn get_parameter(&self, id: u32) -> f32 { + match id { + PARAM_GAIN => self.gain, + PARAM_ATTACK => self.attack_time, + PARAM_RELEASE => self.release_time, + PARAM_TRANSPOSE => self.transpose as f32, + PARAM_PITCH_BEND_RANGE => self.pitch_bend_range, + _ => 0.0, + } + } + + fn process( + &mut self, + inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + sample_rate: u32, + ) { + if outputs.is_empty() { + return; + } + + let output = &mut outputs[0]; + let frames = output.len() / 2; + + // Clear output + output.fill(0.0); + + // Process MIDI events + if !midi_inputs.is_empty() { + for event in midi_inputs[0].iter() { + let status = event.status & 0xF0; + match status { + _ if event.is_note_on() => self.note_on(event.data1, event.status & 0x0F, event.data2), + _ if event.is_note_off() => self.note_off(event.data1), + 0xE0 => { + // Pitch bend: 14-bit value, center = 8192; stored per-channel + let bend_raw = ((event.data2 as i16) << 7) | (event.data1 as i16); + let ch = (event.status & 0x0F) as usize; + self.bend_per_channel[ch] = (bend_raw - 8192) as f32 / 8192.0; + } + 0xB0 if event.data1 == 1 => { + // CC1 (modulation wheel) + self.current_mod = event.data2 as f32 / 127.0; + } + _ => {} + } + } + } + + // Read CV inputs. NaN = unconnected port → treat as 0. + let bend_cv = inputs.get(0).and_then(|b| b.first().copied()) + .filter(|v| v.is_finite()).unwrap_or(0.0); + // Global bend (channel 0) applies to all voices; per-channel bend is added per-voice below. + let global_bend_norm = self.bend_per_channel[0]; + let bend_per_channel = self.bend_per_channel; + + // Extract parameters needed for processing + let gain = self.gain; + let attack_time = self.attack_time; + let release_time = self.release_time; + + // Process all active voices + for voice in &mut self.voices { + if !voice.is_active { + continue; + } + + if voice.layer_index >= self.layers.len() { + continue; + } + + let layer = &self.layers[voice.layer_index]; + + // Calculate playback speed (includes pitch bend) + // Channel-0 = global; voice's own channel bend is added on top. + let voice_bend_norm = global_bend_norm + bend_per_channel[voice.channel as usize]; + let total_bend_semitones = voice_bend_norm * self.pitch_bend_range + bend_cv; + let semitone_diff = voice.note as i16 - layer.root_key as i16; + let speed = 2.0_f32.powf((semitone_diff as f32 + total_bend_semitones) / 12.0); + let speed_adjusted = speed * (layer.sample_rate / sample_rate as f32); + + for frame in 0..frames { + // Read sample with linear interpolation and loop handling + let playhead = voice.playhead; + let mut sample = 0.0; + + if !layer.sample_data.is_empty() && playhead >= 0.0 { + let index = playhead.floor() as usize; + + // Check if we need to handle looping + if layer.loop_mode == LoopMode::Continuous { + if let (Some(loop_start), Some(loop_end)) = (layer.loop_start, layer.loop_end) { + // Validate loop points + if loop_start < loop_end && loop_end <= layer.sample_data.len() { + // Fill crossfade buffer on first loop with samples just before loop_start + // These will be crossfaded with the beginning of the loop for seamless looping + if voice.crossfade_buffer.is_empty() && loop_start >= voice.crossfade_length { + let crossfade_start = loop_start.saturating_sub(voice.crossfade_length); + voice.crossfade_buffer = layer.sample_data[crossfade_start..loop_start].to_vec(); + } + + // Check if we've reached the loop end + if index >= loop_end { + // Wrap around to loop start + let loop_length = loop_end - loop_start; + let offset_from_end = index - loop_end; + let wrapped_index = loop_start + (offset_from_end % loop_length); + voice.playhead = wrapped_index as f32 + (playhead - playhead.floor()); + } + + // Read sample at current position + let current_index = voice.playhead.floor() as usize; + if current_index < layer.sample_data.len() { + let frac = voice.playhead - voice.playhead.floor(); + let sample1 = layer.sample_data[current_index]; + let sample2 = if current_index + 1 < layer.sample_data.len() { + layer.sample_data[current_index + 1] + } else { + layer.sample_data[loop_start] // Wrap to loop start for interpolation + }; + sample = sample1 + (sample2 - sample1) * frac; + + // Apply crossfade only at the END of loop + // Crossfade the end of loop with samples BEFORE loop_start + if current_index >= loop_start && current_index < loop_end { + if !voice.crossfade_buffer.is_empty() { + let crossfade_len = voice.crossfade_length.min(voice.crossfade_buffer.len()); + + // Only crossfade at loop end (last crossfade_length samples) + // This blends end samples (i,j,k) with pre-loop samples (a,b,c) + if current_index >= loop_end - crossfade_len && current_index < loop_end { + let crossfade_pos = current_index - (loop_end - crossfade_len); + if crossfade_pos < voice.crossfade_buffer.len() { + let end_sample = sample; // Current sample at end of loop (i, j, or k) + let pre_loop_sample = voice.crossfade_buffer[crossfade_pos]; // Corresponding pre-loop sample (a, b, or c) + // Equal-power crossfade: fade out end, fade in pre-loop + let fade_ratio = crossfade_pos as f32 / crossfade_len as f32; + let fade_out = (1.0 - fade_ratio).sqrt(); + let fade_in = fade_ratio.sqrt(); + sample = end_sample * fade_out + pre_loop_sample * fade_in; + } + } + } + } + } + } else { + // Invalid loop points, play normally + if index < layer.sample_data.len() { + let frac = playhead - playhead.floor(); + let sample1 = layer.sample_data[index]; + let sample2 = if index + 1 < layer.sample_data.len() { + layer.sample_data[index + 1] + } else { + 0.0 + }; + sample = sample1 + (sample2 - sample1) * frac; + } + } + } else { + // No loop points defined, play normally + if index < layer.sample_data.len() { + let frac = playhead - playhead.floor(); + let sample1 = layer.sample_data[index]; + let sample2 = if index + 1 < layer.sample_data.len() { + layer.sample_data[index + 1] + } else { + 0.0 + }; + sample = sample1 + (sample2 - sample1) * frac; + } + } + } else { + // OneShot mode - play normally without looping + if index < layer.sample_data.len() { + let frac = playhead - playhead.floor(); + let sample1 = layer.sample_data[index]; + let sample2 = if index + 1 < layer.sample_data.len() { + layer.sample_data[index + 1] + } else { + 0.0 + }; + sample = sample1 + (sample2 - sample1) * frac; + } + } + } + + // Process envelope + match voice.envelope_phase { + EnvelopePhase::Attack => { + let attack_samples = attack_time * sample_rate as f32; + voice.envelope_value += 1.0 / attack_samples; + if voice.envelope_value >= 1.0 { + voice.envelope_value = 1.0; + voice.envelope_phase = EnvelopePhase::Sustain; + } + } + EnvelopePhase::Sustain => { + voice.envelope_value = 1.0; + } + EnvelopePhase::Release => { + let release_samples = release_time * sample_rate as f32; + voice.envelope_value -= 1.0 / release_samples; + if voice.envelope_value <= 0.0 { + voice.envelope_value = 0.0; + voice.is_active = false; + } + } + } + let envelope = voice.envelope_value.clamp(0.0, 1.0); + + // Apply velocity scaling (0-127 -> 0-1) + let velocity_scale = voice.velocity as f32 / 127.0; + + // Mix into output + let final_sample = sample * envelope * velocity_scale * gain; + output[frame * 2] += final_sample; + output[frame * 2 + 1] += final_sample; + + // Advance playhead + voice.playhead += speed_adjusted; + + // Stop if we've reached the end (only for OneShot mode) + if layer.loop_mode == LoopMode::OneShot { + if voice.playhead >= layer.sample_data.len() as f32 { + voice.is_active = false; + break; + } + } + } + } + } + + fn reset(&mut self) { + self.voices.clear(); + self.bend_per_channel = [0.0; 16]; + self.current_mod = 0.0; + } + + fn node_type(&self) -> &str { + "MultiSampler" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + Box::new(Self::new(self.name.clone())) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/noise.rs b/daw-backend/src/audio/node_graph/nodes/noise.rs new file mode 100644 index 0000000..cc1bb39 --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/noise.rs @@ -0,0 +1,205 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; +use crate::audio::midi::MidiEvent; +use rand::Rng; + +const PARAM_AMPLITUDE: u32 = 0; +const PARAM_COLOR: u32 = 1; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum NoiseColor { + White = 0, + Pink = 1, +} + +impl NoiseColor { + fn from_f32(value: f32) -> Self { + match value.round() as i32 { + 1 => NoiseColor::Pink, + _ => NoiseColor::White, + } + } +} + +/// Noise generator node with white and pink noise +pub struct NoiseGeneratorNode { + name: String, + amplitude: f32, + color: NoiseColor, + // Pink noise state (Paul Kellet's pink noise algorithm) + pink_b0: f32, + pink_b1: f32, + pink_b2: f32, + pink_b3: f32, + pink_b4: f32, + pink_b5: f32, + pink_b6: f32, + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl NoiseGeneratorNode { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + + let inputs = vec![]; + + let outputs = vec![ + NodePort::new("Audio Out", SignalType::Audio, 0), + ]; + + let parameters = vec![ + Parameter::new(PARAM_AMPLITUDE, "Amplitude", 0.0, 1.0, 0.5, ParameterUnit::Generic), + Parameter::new(PARAM_COLOR, "Color", 0.0, 1.0, 0.0, ParameterUnit::Generic), + ]; + + Self { + name, + amplitude: 0.5, + color: NoiseColor::White, + pink_b0: 0.0, + pink_b1: 0.0, + pink_b2: 0.0, + pink_b3: 0.0, + pink_b4: 0.0, + pink_b5: 0.0, + pink_b6: 0.0, + inputs, + outputs, + parameters, + } + } + + /// Generate white noise sample + fn generate_white(&self) -> f32 { + let mut rng = rand::thread_rng(); + rng.gen_range(-1.0..1.0) + } + + /// Generate pink noise sample using Paul Kellet's algorithm + fn generate_pink(&mut self) -> f32 { + let mut rng = rand::thread_rng(); + let white: f32 = rng.gen_range(-1.0..1.0); + + self.pink_b0 = 0.99886 * self.pink_b0 + white * 0.0555179; + self.pink_b1 = 0.99332 * self.pink_b1 + white * 0.0750759; + self.pink_b2 = 0.96900 * self.pink_b2 + white * 0.1538520; + self.pink_b3 = 0.86650 * self.pink_b3 + white * 0.3104856; + self.pink_b4 = 0.55000 * self.pink_b4 + white * 0.5329522; + self.pink_b5 = -0.7616 * self.pink_b5 - white * 0.0168980; + + let pink = self.pink_b0 + self.pink_b1 + self.pink_b2 + self.pink_b3 + self.pink_b4 + self.pink_b5 + self.pink_b6 + white * 0.5362; + self.pink_b6 = white * 0.115926; + + // Scale to approximately -1 to 1 + pink * 0.11 + } +} + +impl AudioNode for NoiseGeneratorNode { + fn category(&self) -> NodeCategory { + NodeCategory::Generator + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, id: u32, value: f32) { + match id { + PARAM_AMPLITUDE => self.amplitude = value.clamp(0.0, 1.0), + PARAM_COLOR => self.color = NoiseColor::from_f32(value), + _ => {} + } + } + + fn get_parameter(&self, id: u32) -> f32 { + match id { + PARAM_AMPLITUDE => self.amplitude, + PARAM_COLOR => self.color as i32 as f32, + _ => 0.0, + } + } + + fn process( + &mut self, + _inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + _sample_rate: u32, + ) { + if outputs.is_empty() { + return; + } + + let output = &mut outputs[0]; + + // Audio signals are stereo (interleaved L/R) + // Process by frames, not samples + let frames = output.len() / 2; + + for frame in 0..frames { + let sample = match self.color { + NoiseColor::White => self.generate_white(), + NoiseColor::Pink => self.generate_pink(), + } * self.amplitude; + + // Write to both channels (mono source duplicated to stereo) + output[frame * 2] = sample; // Left + output[frame * 2 + 1] = sample; // Right + } + } + + fn reset(&mut self) { + self.pink_b0 = 0.0; + self.pink_b1 = 0.0; + self.pink_b2 = 0.0; + self.pink_b3 = 0.0; + self.pink_b4 = 0.0; + self.pink_b5 = 0.0; + self.pink_b6 = 0.0; + } + + fn node_type(&self) -> &str { + "NoiseGenerator" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + Box::new(Self { + name: self.name.clone(), + amplitude: self.amplitude, + color: self.color, + pink_b0: 0.0, + pink_b1: 0.0, + pink_b2: 0.0, + pink_b3: 0.0, + pink_b4: 0.0, + pink_b5: 0.0, + pink_b6: 0.0, + inputs: self.inputs.clone(), + outputs: self.outputs.clone(), + parameters: self.parameters.clone(), + }) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/oscillator.rs b/daw-backend/src/audio/node_graph/nodes/oscillator.rs new file mode 100644 index 0000000..243a7bf --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/oscillator.rs @@ -0,0 +1,207 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default}; +use crate::audio::midi::MidiEvent; +use std::f32::consts::PI; + +const PARAM_FREQUENCY: u32 = 0; +const PARAM_AMPLITUDE: u32 = 1; +const PARAM_WAVEFORM: u32 = 2; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Waveform { + Sine = 0, + Saw = 1, + Square = 2, + Triangle = 3, +} + +impl Waveform { + fn from_f32(value: f32) -> Self { + match value.round() as i32 { + 1 => Waveform::Saw, + 2 => Waveform::Square, + 3 => Waveform::Triangle, + _ => Waveform::Sine, + } + } +} + +/// Oscillator node with multiple waveforms +pub struct OscillatorNode { + name: String, + frequency: f32, + amplitude: f32, + waveform: Waveform, + phase: f32, + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl OscillatorNode { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + + let inputs = vec![ + NodePort::new("V/Oct", SignalType::CV, 0), + NodePort::new("FM", SignalType::CV, 1), + ]; + + let outputs = vec![ + NodePort::new("Audio Out", SignalType::Audio, 0), + ]; + + let parameters = vec![ + Parameter::new(PARAM_FREQUENCY, "Frequency", 20.0, 20000.0, 440.0, ParameterUnit::Frequency), + Parameter::new(PARAM_AMPLITUDE, "Amplitude", 0.0, 1.0, 0.5, ParameterUnit::Generic), + Parameter::new(PARAM_WAVEFORM, "Waveform", 0.0, 3.0, 0.0, ParameterUnit::Generic), + ]; + + Self { + name, + frequency: 440.0, + amplitude: 0.5, + waveform: Waveform::Sine, + phase: 0.0, + inputs, + outputs, + parameters, + } + } +} + +impl AudioNode for OscillatorNode { + fn category(&self) -> NodeCategory { + NodeCategory::Generator + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, id: u32, value: f32) { + match id { + PARAM_FREQUENCY => self.frequency = value.clamp(20.0, 20000.0), + PARAM_AMPLITUDE => self.amplitude = value.clamp(0.0, 1.0), + PARAM_WAVEFORM => self.waveform = Waveform::from_f32(value), + _ => {} + } + } + + fn get_parameter(&self, id: u32) -> f32 { + match id { + PARAM_FREQUENCY => self.frequency, + PARAM_AMPLITUDE => self.amplitude, + PARAM_WAVEFORM => self.waveform as i32 as f32, + _ => 0.0, + } + } + + fn process( + &mut self, + inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + sample_rate: u32, + ) { + if outputs.is_empty() { + return; + } + + let output = &mut outputs[0]; + let sample_rate_f32 = sample_rate as f32; + + // Audio signals are stereo (interleaved L/R) + // Process by frames, not samples + let frames = output.len() / 2; + + for frame in 0..frames { + // V/Oct input: Standard V/Oct (0V = A4 440Hz, ±1V per octave) + // Port 0: V/Oct CV input + // If connected, interprets the CV signal as V/Oct (440 * 2^voct) + // If unconnected, uses self.frequency directly as Hz + let voct = cv_input_or_default(inputs, 0, frame, f32::NAN); + let base_frequency = if voct.is_nan() { + // Unconnected: use frequency parameter directly + self.frequency + } else { + // Connected: convert V/Oct to frequency + // voct = 0.0 -> 440 Hz (A4) + // voct = 1.0 -> 880 Hz (A5) + // voct = -0.75 -> 261.6 Hz (C4, middle C) + 440.0 * 2.0_f32.powf(voct) + }; + + // FM input: modulates the frequency + // Port 1: FM CV input + // If connected, applies FM modulation (multiply by 1 + fm) + // If unconnected, no modulation (fm = 0.0) + let fm = cv_input_or_default(inputs, 1, frame, 0.0); + let freq_mod = base_frequency * (1.0 + fm); + + // Generate waveform sample based on waveform type + let sample = match self.waveform { + Waveform::Sine => (self.phase * 2.0 * PI).sin(), + Waveform::Saw => 2.0 * self.phase - 1.0, // Ramp from -1 to 1 + Waveform::Square => { + if self.phase < 0.5 { 1.0 } else { -1.0 } + } + Waveform::Triangle => { + // Triangle: rises from -1 to 1, falls back to -1 + 4.0 * (self.phase - 0.5).abs() - 1.0 + } + } * self.amplitude; + + // Write to both channels (mono source duplicated to stereo) + output[frame * 2] = sample; // Left + output[frame * 2 + 1] = sample; // Right + + // Update phase once per frame + self.phase += freq_mod / sample_rate_f32; + if self.phase >= 1.0 { + self.phase -= 1.0; + } + } + } + + fn reset(&mut self) { + self.phase = 0.0; + } + + fn node_type(&self) -> &str { + "Oscillator" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + Box::new(Self { + name: self.name.clone(), + frequency: self.frequency, + amplitude: self.amplitude, + waveform: self.waveform, + phase: 0.0, // Reset phase for new instance + inputs: self.inputs.clone(), + outputs: self.outputs.clone(), + parameters: self.parameters.clone(), + }) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/oscilloscope.rs b/daw-backend/src/audio/node_graph/nodes/oscilloscope.rs new file mode 100644 index 0000000..daa5a31 --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/oscilloscope.rs @@ -0,0 +1,321 @@ +use crate::audio::midi::MidiEvent; +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; +use std::sync::{Arc, Mutex}; + +const PARAM_TIME_SCALE: u32 = 0; +const PARAM_TRIGGER_MODE: u32 = 1; +const PARAM_TRIGGER_LEVEL: u32 = 2; + +const BUFFER_SIZE: usize = 96000; // 2 seconds at 48kHz (stereo) + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum TriggerMode { + FreeRunning = 0, + RisingEdge = 1, + FallingEdge = 2, + VoltPerOctave = 3, +} + +impl TriggerMode { + fn from_f32(value: f32) -> Self { + match value.round() as i32 { + 1 => TriggerMode::RisingEdge, + 2 => TriggerMode::FallingEdge, + 3 => TriggerMode::VoltPerOctave, + _ => TriggerMode::FreeRunning, + } + } +} + +/// Circular buffer for storing audio samples +pub struct CircularBuffer { + buffer: Vec, + write_pos: usize, + capacity: usize, +} + +impl CircularBuffer { + fn new(capacity: usize) -> Self { + Self { + buffer: vec![0.0; capacity], + write_pos: 0, + capacity, + } + } + + fn write(&mut self, samples: &[f32]) { + for &sample in samples { + self.buffer[self.write_pos] = sample; + self.write_pos = (self.write_pos + 1) % self.capacity; + } + } + + fn read(&self, count: usize) -> Vec { + let count = count.min(self.capacity); + let mut result = Vec::with_capacity(count); + + // Read backwards from current write position + let start_pos = if self.write_pos >= count { + self.write_pos - count + } else { + self.capacity - (count - self.write_pos) + }; + + for i in 0..count { + let pos = (start_pos + i) % self.capacity; + result.push(self.buffer[pos]); + } + + result + } + + fn clear(&mut self) { + self.buffer.fill(0.0); + self.write_pos = 0; + } +} + +/// Oscilloscope node for visualizing audio and CV signals +pub struct OscilloscopeNode { + name: String, + time_scale: f32, // Milliseconds to display (10-1000ms) + trigger_mode: TriggerMode, + trigger_level: f32, // -1.0 to 1.0 + last_sample: f32, // For edge detection + voct_value: f32, // Current V/oct input value + sample_counter: usize, // Counter for V/oct triggering + trigger_period: usize, // Period in samples for V/oct triggering + + // Shared buffers for reading from Tauri commands + buffer: Arc>, // Audio buffer (mono downmix) + cv_buffer: Arc>, // CV buffer + mono_buf: Vec, // Scratch buffer for stereo-to-mono downmix + + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl OscilloscopeNode { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + + let inputs = vec![ + NodePort::new("Audio In", SignalType::Audio, 0), + NodePort::new("CV In", SignalType::CV, 1), + ]; + + let outputs = vec![ + NodePort::new("Audio Out", SignalType::Audio, 0), + ]; + + let parameters = vec![ + Parameter::new(PARAM_TIME_SCALE, "Time Scale", 10.0, 1000.0, 100.0, ParameterUnit::Time), + Parameter::new(PARAM_TRIGGER_MODE, "Trigger", 0.0, 3.0, 0.0, ParameterUnit::Generic), + Parameter::new(PARAM_TRIGGER_LEVEL, "Trigger Level", -1.0, 1.0, 0.0, ParameterUnit::Generic), + ]; + + Self { + name, + time_scale: 100.0, + trigger_mode: TriggerMode::FreeRunning, + trigger_level: 0.0, + last_sample: 0.0, + voct_value: 0.0, + sample_counter: 0, + trigger_period: 480, // Default to ~100Hz at 48kHz + buffer: Arc::new(Mutex::new(CircularBuffer::new(BUFFER_SIZE))), + cv_buffer: Arc::new(Mutex::new(CircularBuffer::new(BUFFER_SIZE))), + mono_buf: vec![0.0; 2048], + inputs, + outputs, + parameters, + } + } + + /// Get a clone of the buffer Arc for reading from external code (Tauri commands) + pub fn get_buffer(&self) -> Arc> { + Arc::clone(&self.buffer) + } + + /// Read samples from the buffer (for Tauri commands) + pub fn read_samples(&self, count: usize) -> Vec { + if let Ok(buffer) = self.buffer.lock() { + buffer.read(count) + } else { + vec![0.0; count] + } + } + + /// Read CV samples from the CV buffer (for Tauri commands) + pub fn read_cv_samples(&self, count: usize) -> Vec { + if let Ok(buffer) = self.cv_buffer.lock() { + buffer.read(count) + } else { + vec![0.0; count] + } + } + + /// Clear the buffer + pub fn clear_buffer(&self) { + if let Ok(mut buffer) = self.buffer.lock() { + buffer.clear(); + } + if let Ok(mut cv_buffer) = self.cv_buffer.lock() { + cv_buffer.clear(); + } + } + + /// Convert V/oct to frequency in Hz (matches oscillator convention) + /// 0V = A4 (440 Hz), ±1V per octave + fn voct_to_frequency(voct: f32) -> f32 { + 440.0 * 2.0_f32.powf(voct) + } +} + +impl AudioNode for OscilloscopeNode { + fn category(&self) -> NodeCategory { + NodeCategory::Utility + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, id: u32, value: f32) { + match id { + PARAM_TIME_SCALE => self.time_scale = value.clamp(10.0, 1000.0), + PARAM_TRIGGER_MODE => self.trigger_mode = TriggerMode::from_f32(value), + PARAM_TRIGGER_LEVEL => self.trigger_level = value.clamp(-1.0, 1.0), + _ => {} + } + } + + fn get_parameter(&self, id: u32) -> f32 { + match id { + PARAM_TIME_SCALE => self.time_scale, + PARAM_TRIGGER_MODE => self.trigger_mode as i32 as f32, + PARAM_TRIGGER_LEVEL => self.trigger_level, + _ => 0.0, + } + } + + fn process( + &mut self, + inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + sample_rate: u32, + ) { + if inputs.is_empty() || outputs.is_empty() { + return; + } + + let input = inputs[0]; + let output = &mut outputs[0]; + let stereo_len = input.len().min(output.len()); + let frame_count = stereo_len / 2; + + // Read CV input if available (port 1) — used for both display and V/Oct triggering + if inputs.len() > 1 && !inputs[1].is_empty() { + let cv_input = inputs[1]; + let cv_len = frame_count.min(cv_input.len()); + + // Check if connected (not NaN sentinel) + if cv_len > 0 && !cv_input[0].is_nan() { + // Update V/Oct trigger period from CV value + self.voct_value = cv_input[0]; + let frequency = Self::voct_to_frequency(self.voct_value); + let period_samples = (sample_rate as f32 / frequency).max(1.0); + self.trigger_period = period_samples as usize; + + // Capture CV samples to buffer + if let Ok(mut cv_buffer) = self.cv_buffer.lock() { + cv_buffer.write(&cv_input[..cv_len]); + } + } + } + + // Update sample counter for V/oct triggering + if self.trigger_mode == TriggerMode::VoltPerOctave { + self.sample_counter = (self.sample_counter + frame_count) % self.trigger_period; + } + + // Pass through audio (copy input to output) + output[..stereo_len].copy_from_slice(&input[..stereo_len]); + + // Capture audio as mono downmix to match CV time scale + if let Ok(mut buffer) = self.buffer.lock() { + for frame in 0..frame_count { + let left = input[frame * 2]; + let right = input[frame * 2 + 1]; + self.mono_buf[frame] = (left + right) * 0.5; + } + buffer.write(&self.mono_buf[..frame_count]); + } + + // Update last sample for trigger detection + if frame_count > 0 { + self.last_sample = (input[0] + input[1]) * 0.5; + } + } + + fn reset(&mut self) { + self.last_sample = 0.0; + self.voct_value = 0.0; + self.sample_counter = 0; + self.clear_buffer(); + } + + fn node_type(&self) -> &str { + "Oscilloscope" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + Box::new(Self { + name: self.name.clone(), + time_scale: self.time_scale, + trigger_mode: self.trigger_mode, + trigger_level: self.trigger_level, + last_sample: 0.0, + voct_value: 0.0, + sample_counter: 0, + trigger_period: 480, + buffer: Arc::new(Mutex::new(CircularBuffer::new(BUFFER_SIZE))), + cv_buffer: Arc::new(Mutex::new(CircularBuffer::new(BUFFER_SIZE))), + mono_buf: vec![0.0; 2048], + inputs: self.inputs.clone(), + outputs: self.outputs.clone(), + parameters: self.parameters.clone(), + }) + } + + fn get_oscilloscope_data(&self, sample_count: usize) -> Option> { + Some(self.read_samples(sample_count)) + } + + fn get_oscilloscope_cv_data(&self, sample_count: usize) -> Option> { + Some(self.read_cv_samples(sample_count)) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/output.rs b/daw-backend/src/audio/node_graph/nodes/output.rs new file mode 100644 index 0000000..8286c79 --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/output.rs @@ -0,0 +1,104 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, SignalType}; +use crate::audio::midi::MidiEvent; + +/// Audio output node - collects audio and passes it to the main output +pub struct AudioOutputNode { + name: String, + inputs: Vec, + outputs: Vec, +} + +impl AudioOutputNode { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + + let inputs = vec![ + NodePort::new("Audio In", SignalType::Audio, 0), + ]; + + // Output node has an output for graph consistency, but it's typically the final node + let outputs = vec![ + NodePort::new("Audio Out", SignalType::Audio, 0), + ]; + + Self { + name, + inputs, + outputs, + } + } +} + +impl AudioNode for AudioOutputNode { + fn category(&self) -> NodeCategory { + NodeCategory::Output + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &[] // No parameters + } + + fn set_parameter(&mut self, _id: u32, _value: f32) { + // No parameters + } + + fn get_parameter(&self, _id: u32) -> f32 { + 0.0 + } + + fn process( + &mut self, + inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + _sample_rate: u32, + ) { + if inputs.is_empty() || outputs.is_empty() { + return; + } + + // Simply pass through the input to the output + let input = inputs[0]; + let output = &mut outputs[0]; + let len = input.len().min(output.len()); + + output[..len].copy_from_slice(&input[..len]); + } + + fn reset(&mut self) { + // No state to reset + } + + fn node_type(&self) -> &str { + "AudioOutput" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + Box::new(Self { + name: self.name.clone(), + inputs: self.inputs.clone(), + outputs: self.outputs.clone(), + }) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/pan.rs b/daw-backend/src/audio/node_graph/nodes/pan.rs new file mode 100644 index 0000000..e7738e8 --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/pan.rs @@ -0,0 +1,168 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default}; +use crate::audio::midi::MidiEvent; +use std::f32::consts::PI; + +const PARAM_PAN: u32 = 0; + +/// Stereo panning node using constant-power panning law +/// Converts mono audio to stereo with controllable pan position +pub struct PanNode { + name: String, + pan: f32, + left_gain: f32, + right_gain: f32, + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl PanNode { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + + let inputs = vec![ + NodePort::new("Audio In", SignalType::Audio, 0), + NodePort::new("Pan CV", SignalType::CV, 1), + ]; + + let outputs = vec![ + NodePort::new("Audio Out", SignalType::Audio, 0), + ]; + + let parameters = vec![ + Parameter::new(PARAM_PAN, "Pan", -1.0, 1.0, 0.0, ParameterUnit::Generic), + ]; + + let mut node = Self { + name, + pan: 0.0, + left_gain: 1.0, + right_gain: 1.0, + inputs, + outputs, + parameters, + }; + + node.update_gains(); + node + } + + /// Update left/right gains using constant-power panning law + fn update_gains(&mut self) { + // Constant-power panning: pan from -1 to +1 maps to angle 0 to PI/2 + let angle = (self.pan + 1.0) * 0.5 * PI / 2.0; + + self.left_gain = angle.cos(); + self.right_gain = angle.sin(); + } +} + +impl AudioNode for PanNode { + fn category(&self) -> NodeCategory { + NodeCategory::Utility + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, id: u32, value: f32) { + match id { + PARAM_PAN => { + self.pan = value.clamp(-1.0, 1.0); + self.update_gains(); + } + _ => {} + } + } + + fn get_parameter(&self, id: u32) -> f32 { + match id { + PARAM_PAN => self.pan, + _ => 0.0, + } + } + + fn process( + &mut self, + inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + _sample_rate: u32, + ) { + if inputs.is_empty() || outputs.is_empty() { + return; + } + + let audio_input = inputs[0]; + let output = &mut outputs[0]; + + // Audio signals are stereo (interleaved L/R) + // Process by frames, not samples + let frames = audio_input.len() / 2; + let output_frames = output.len() / 2; + let frames_to_process = frames.min(output_frames); + + for frame in 0..frames_to_process { + // Pan CV input: -1..+1 directly (0 = center), defaults to parameter value when unconnected + let pan = cv_input_or_default(inputs, 1, frame, self.pan).clamp(-1.0, 1.0); + + // Calculate gains using constant-power panning law + let angle = (pan + 1.0) * 0.5 * PI / 2.0; + let left_gain = angle.cos(); + let right_gain = angle.sin(); + + // Read stereo input + let left_in = audio_input[frame * 2]; + let right_in = audio_input[frame * 2 + 1]; + + // Mix both input channels with panning + // When pan is -1 (full left), left gets full signal, right gets nothing + // When pan is 0 (center), both get equal signal + // When pan is +1 (full right), right gets full signal, left gets nothing + output[frame * 2] = (left_in + right_in) * left_gain; // Left + output[frame * 2 + 1] = (left_in + right_in) * right_gain; // Right + } + } + + fn reset(&mut self) { + // No state to reset + } + + fn node_type(&self) -> &str { + "Pan" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + Box::new(Self { + name: self.name.clone(), + pan: self.pan, + left_gain: self.left_gain, + right_gain: self.right_gain, + inputs: self.inputs.clone(), + outputs: self.outputs.clone(), + parameters: self.parameters.clone(), + }) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/phaser.rs b/daw-backend/src/audio/node_graph/nodes/phaser.rs new file mode 100644 index 0000000..42ec906 --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/phaser.rs @@ -0,0 +1,297 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; +use crate::audio::midi::MidiEvent; +use std::f32::consts::PI; + +const PARAM_RATE: u32 = 0; +const PARAM_DEPTH: u32 = 1; +const PARAM_STAGES: u32 = 2; +const PARAM_FEEDBACK: u32 = 3; +const PARAM_WET_DRY: u32 = 4; + +const MAX_STAGES: usize = 8; + +/// First-order all-pass filter for phaser +struct AllPassFilter { + a1: f32, + zm1_left: f32, + zm1_right: f32, +} + +impl AllPassFilter { + fn new() -> Self { + Self { + a1: 0.0, + zm1_left: 0.0, + zm1_right: 0.0, + } + } + + fn set_coefficient(&mut self, frequency: f32, sample_rate: f32) { + // First-order all-pass coefficient + // a1 = (tan(π*f/fs) - 1) / (tan(π*f/fs) + 1) + let tan_val = ((PI * frequency) / sample_rate).tan(); + self.a1 = (tan_val - 1.0) / (tan_val + 1.0); + } + + fn process(&mut self, input: f32, is_left: bool) -> f32 { + let zm1 = if is_left { + &mut self.zm1_left + } else { + &mut self.zm1_right + }; + + // All-pass filter: y[n] = a1*x[n] + x[n-1] - a1*y[n-1] + let output = self.a1 * input + *zm1; + *zm1 = input - self.a1 * output; + output + } + + fn reset(&mut self) { + self.zm1_left = 0.0; + self.zm1_right = 0.0; + } +} + +/// Phaser effect using cascaded all-pass filters +pub struct PhaserNode { + name: String, + rate: f32, // LFO rate in Hz (0.1 to 10 Hz) + depth: f32, // Modulation depth 0.0 to 1.0 + stages: usize, // Number of all-pass stages (2, 4, 6, or 8) + feedback: f32, // Feedback amount -0.95 to 0.95 + wet_dry: f32, // 0.0 = dry only, 1.0 = wet only + + // All-pass filters + filters: Vec, + + // Feedback buffers + feedback_left: f32, + feedback_right: f32, + + // LFO state + lfo_phase: f32, + + sample_rate: u32, + + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl PhaserNode { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + + let inputs = vec![ + NodePort::new("Audio In", SignalType::Audio, 0), + ]; + + let outputs = vec![ + NodePort::new("Audio Out", SignalType::Audio, 0), + ]; + + let parameters = vec![ + Parameter::new(PARAM_RATE, "Rate", 0.1, 10.0, 0.5, ParameterUnit::Frequency), + Parameter::new(PARAM_DEPTH, "Depth", 0.0, 1.0, 0.7, ParameterUnit::Generic), + Parameter::new(PARAM_STAGES, "Stages", 2.0, 8.0, 6.0, ParameterUnit::Generic), + Parameter::new(PARAM_FEEDBACK, "Feedback", -0.95, 0.95, 0.5, ParameterUnit::Generic), + Parameter::new(PARAM_WET_DRY, "Wet/Dry", 0.0, 1.0, 0.5, ParameterUnit::Generic), + ]; + + let mut filters = Vec::with_capacity(MAX_STAGES); + for _ in 0..MAX_STAGES { + filters.push(AllPassFilter::new()); + } + + Self { + name, + rate: 0.5, + depth: 0.7, + stages: 6, + feedback: 0.5, + wet_dry: 0.5, + filters, + feedback_left: 0.0, + feedback_right: 0.0, + lfo_phase: 0.0, + sample_rate: 48000, + inputs, + outputs, + parameters, + } + } +} + +impl AudioNode for PhaserNode { + fn category(&self) -> NodeCategory { + NodeCategory::Effect + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, id: u32, value: f32) { + match id { + PARAM_RATE => { + self.rate = value.clamp(0.1, 10.0); + } + PARAM_DEPTH => { + self.depth = value.clamp(0.0, 1.0); + } + PARAM_STAGES => { + // Round to even numbers: 2, 4, 6, 8 + let stages = (value.round() as usize).clamp(2, 8); + self.stages = if stages % 2 == 0 { stages } else { stages + 1 }; + } + PARAM_FEEDBACK => { + self.feedback = value.clamp(-0.95, 0.95); + } + PARAM_WET_DRY => { + self.wet_dry = value.clamp(0.0, 1.0); + } + _ => {} + } + } + + fn get_parameter(&self, id: u32) -> f32 { + match id { + PARAM_RATE => self.rate, + PARAM_DEPTH => self.depth, + PARAM_STAGES => self.stages as f32, + PARAM_FEEDBACK => self.feedback, + PARAM_WET_DRY => self.wet_dry, + _ => 0.0, + } + } + + fn process( + &mut self, + inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + sample_rate: u32, + ) { + if inputs.is_empty() || outputs.is_empty() { + return; + } + + // Update sample rate if changed + if self.sample_rate != sample_rate { + self.sample_rate = sample_rate; + } + + let input = inputs[0]; + let output = &mut outputs[0]; + + // Audio signals are stereo (interleaved L/R) + let frames = input.len() / 2; + let output_frames = output.len() / 2; + let frames_to_process = frames.min(output_frames); + + let dry_gain = 1.0 - self.wet_dry; + let wet_gain = self.wet_dry; + + // Frequency range for all-pass filters (200 Hz to 2000 Hz) + let min_freq = 200.0; + let max_freq = 2000.0; + + for frame in 0..frames_to_process { + let left_in = input[frame * 2]; + let right_in = input[frame * 2 + 1]; + + // Generate LFO value (sine wave, 0 to 1) + let lfo_value = (self.lfo_phase * 2.0 * PI).sin() * 0.5 + 0.5; + + // Calculate modulated frequency + let frequency = min_freq + (max_freq - min_freq) * lfo_value * self.depth; + + // Update all filter coefficients + for filter in self.filters.iter_mut().take(self.stages) { + filter.set_coefficient(frequency, self.sample_rate as f32); + } + + // Add feedback + let mut left_sig = left_in + self.feedback_left * self.feedback; + let mut right_sig = right_in + self.feedback_right * self.feedback; + + // Process through all-pass filter chain + for i in 0..self.stages { + left_sig = self.filters[i].process(left_sig, true); + right_sig = self.filters[i].process(right_sig, false); + } + + // Store feedback + self.feedback_left = left_sig; + self.feedback_right = right_sig; + + // Mix dry and wet signals + output[frame * 2] = left_in * dry_gain + left_sig * wet_gain; + output[frame * 2 + 1] = right_in * dry_gain + right_sig * wet_gain; + + // Advance LFO phase + self.lfo_phase += self.rate / self.sample_rate as f32; + if self.lfo_phase >= 1.0 { + self.lfo_phase -= 1.0; + } + } + } + + fn reset(&mut self) { + for filter in &mut self.filters { + filter.reset(); + } + self.feedback_left = 0.0; + self.feedback_right = 0.0; + self.lfo_phase = 0.0; + } + + fn node_type(&self) -> &str { + "Phaser" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + let mut filters = Vec::with_capacity(MAX_STAGES); + for _ in 0..MAX_STAGES { + filters.push(AllPassFilter::new()); + } + + Box::new(Self { + name: self.name.clone(), + rate: self.rate, + depth: self.depth, + stages: self.stages, + feedback: self.feedback, + wet_dry: self.wet_dry, + filters, + feedback_left: 0.0, + feedback_right: 0.0, + lfo_phase: 0.0, + sample_rate: self.sample_rate, + inputs: self.inputs.clone(), + outputs: self.outputs.clone(), + parameters: self.parameters.clone(), + }) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/quantizer.rs b/daw-backend/src/audio/node_graph/nodes/quantizer.rs new file mode 100644 index 0000000..6ff3063 --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/quantizer.rs @@ -0,0 +1,232 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; +use crate::audio::midi::MidiEvent; + +const PARAM_SCALE: u32 = 0; +const PARAM_ROOT_NOTE: u32 = 1; + +/// Quantizer - snaps CV values to musical scales +/// Converts continuous CV into discrete pitch values based on a scale +/// Scale parameter: +/// 0 = Chromatic (all 12 notes) +/// 1 = Major scale +/// 2 = Minor scale (natural) +/// 3 = Pentatonic major +/// 4 = Pentatonic minor +/// 5 = Dorian +/// 6 = Phrygian +/// 7 = Lydian +/// 8 = Mixolydian +/// 9 = Whole tone +/// 10 = Octaves only +pub struct QuantizerNode { + name: String, + scale: u32, + root_note: u32, // 0-11 (C-B) + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl QuantizerNode { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + + let inputs = vec![ + NodePort::new("CV In", SignalType::CV, 0), + ]; + + let outputs = vec![ + NodePort::new("CV Out", SignalType::CV, 0), + NodePort::new("Gate Out", SignalType::CV, 1), // Trigger when note changes + ]; + + let parameters = vec![ + Parameter::new(PARAM_SCALE, "Scale", 0.0, 10.0, 0.0, ParameterUnit::Generic), + Parameter::new(PARAM_ROOT_NOTE, "Root", 0.0, 11.0, 0.0, ParameterUnit::Generic), + ]; + + Self { + name, + scale: 0, + root_note: 0, + inputs, + outputs, + parameters, + } + } + + /// Get the scale intervals (semitones from root) + fn get_scale_intervals(&self) -> Vec { + match self.scale { + 0 => vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], // Chromatic + 1 => vec![0, 2, 4, 5, 7, 9, 11], // Major + 2 => vec![0, 2, 3, 5, 7, 8, 10], // Minor (natural) + 3 => vec![0, 2, 4, 7, 9], // Pentatonic major + 4 => vec![0, 3, 5, 7, 10], // Pentatonic minor + 5 => vec![0, 2, 3, 5, 7, 9, 10], // Dorian + 6 => vec![0, 1, 3, 5, 7, 8, 10], // Phrygian + 7 => vec![0, 2, 4, 6, 7, 9, 11], // Lydian + 8 => vec![0, 2, 4, 5, 7, 9, 10], // Mixolydian + 9 => vec![0, 2, 4, 6, 8, 10], // Whole tone + 10 => vec![0], // Octaves only + _ => vec![0, 2, 4, 5, 7, 9, 11], // Default to major + } + } + + /// Quantize a CV value to the nearest note in the scale + fn quantize(&self, cv: f32) -> f32 { + // Convert V/Oct to MIDI note (standard: 0V = A4 = MIDI 69) + // cv = (midi_note - 69) / 12.0 + // midi_note = cv * 12.0 + 69 + let input_midi_note = cv * 12.0 + 69.0; + + // Clamp to reasonable range + let input_midi_note = input_midi_note.clamp(0.0, 127.0); + + // Get scale intervals (relative to root) + let intervals = self.get_scale_intervals(); + + // Find which octave we're in (relative to C) + let octave = (input_midi_note / 12.0).floor() as i32; + let note_in_octave = input_midi_note % 12.0; + + // Adjust note relative to root (e.g., if root is D (2), then C becomes 10, D becomes 0) + let note_relative_to_root = (note_in_octave - self.root_note as f32 + 12.0) % 12.0; + + // Find the nearest note in the scale (scale intervals are relative to root) + let mut closest_interval = intervals[0]; + let mut min_distance = (note_relative_to_root - closest_interval as f32).abs(); + + for &interval in &intervals { + let distance = (note_relative_to_root - interval as f32).abs(); + if distance < min_distance { + min_distance = distance; + closest_interval = interval; + } + } + + // Calculate final MIDI note + // The scale interval is relative to root, so add root back to get absolute note + let quantized_note_in_octave = (self.root_note + closest_interval) % 12; + let quantized_midi_note = (octave * 12) as f32 + quantized_note_in_octave as f32; + + // Clamp result + let quantized_midi_note = quantized_midi_note.clamp(0.0, 127.0); + + // Convert back to V/Oct: voct = (midi_note - 69) / 12.0 + (quantized_midi_note - 69.0) / 12.0 + } +} + +impl AudioNode for QuantizerNode { + fn category(&self) -> NodeCategory { + NodeCategory::Utility + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, id: u32, value: f32) { + match id { + PARAM_SCALE => self.scale = (value as u32).clamp(0, 10), + PARAM_ROOT_NOTE => self.root_note = (value as u32).clamp(0, 11), + _ => {} + } + } + + fn get_parameter(&self, id: u32) -> f32 { + match id { + PARAM_SCALE => self.scale as f32, + PARAM_ROOT_NOTE => self.root_note as f32, + _ => 0.0, + } + } + + fn process( + &mut self, + inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + _sample_rate: u32, + ) { + if inputs.is_empty() || outputs.is_empty() { + return; + } + + let input = inputs[0]; + let length = input.len().min(outputs[0].len()); + + // Split outputs to avoid borrow conflicts + if outputs.len() > 1 { + let (cv_out, gate_out) = outputs.split_at_mut(1); + let cv_output = &mut cv_out[0]; + let gate_output = &mut gate_out[0]; + let gate_length = length.min(gate_output.len()); + + let mut last_note: Option = None; + + for i in 0..length { + let quantized = self.quantize(input[i]); + cv_output[i] = quantized; + + // Generate gate trigger when note changes + if i < gate_length { + if let Some(prev) = last_note { + gate_output[i] = if (quantized - prev).abs() > 0.001 { 1.0 } else { 0.0 }; + } else { + gate_output[i] = 1.0; // First note triggers gate + } + } + + last_note = Some(quantized); + } + } else { + // No gate output, just quantize CV + let cv_output = &mut outputs[0]; + for i in 0..length { + cv_output[i] = self.quantize(input[i]); + } + } + } + + fn reset(&mut self) { + // No state to reset + } + + fn node_type(&self) -> &str { + "Quantizer" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + Box::new(Self { + name: self.name.clone(), + scale: self.scale, + root_note: self.root_note, + inputs: self.inputs.clone(), + outputs: self.outputs.clone(), + parameters: self.parameters.clone(), + }) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/reverb.rs b/daw-backend/src/audio/node_graph/nodes/reverb.rs new file mode 100644 index 0000000..bd4d50c --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/reverb.rs @@ -0,0 +1,321 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; +use crate::audio::midi::MidiEvent; + +const PARAM_ROOM_SIZE: u32 = 0; +const PARAM_DAMPING: u32 = 1; +const PARAM_WET_DRY: u32 = 2; + +// Schroeder reverb uses a parallel bank of comb filters followed by series all-pass filters +// Comb filter delays (in samples at 48kHz) +const COMB_DELAYS: [usize; 8] = [1557, 1617, 1491, 1422, 1277, 1356, 1188, 1116]; +// All-pass filter delays (in samples at 48kHz) +const ALLPASS_DELAYS: [usize; 4] = [225, 556, 441, 341]; + +/// Process a single channel through comb and all-pass filters +fn process_channel( + input: f32, + comb_filters: &mut [CombFilter], + allpass_filters: &mut [AllPassFilter], +) -> f32 { + // Sum parallel comb filters and scale down to prevent excessive gain + // With 8 comb filters, we need to scale the output significantly + let mut output = 0.0; + for comb in comb_filters.iter_mut() { + output += comb.process(input); + } + output *= 0.015; // Scale down the summed comb output + + // Series all-pass filters + for allpass in allpass_filters.iter_mut() { + output = allpass.process(output); + } + + output +} + +/// Single comb filter for reverb +struct CombFilter { + buffer: Vec, + buffer_size: usize, + filter_store: f32, + write_pos: usize, + damp: f32, + feedback: f32, +} + +impl CombFilter { + fn new(size: usize) -> Self { + Self { + buffer: vec![0.0; size], + buffer_size: size, + filter_store: 0.0, + write_pos: 0, + damp: 0.5, + feedback: 0.5, + } + } + + fn process(&mut self, input: f32) -> f32 { + let output = self.buffer[self.write_pos]; + + // One-pole lowpass filter + self.filter_store = output * (1.0 - self.damp) + self.filter_store * self.damp; + + self.buffer[self.write_pos] = input + self.filter_store * self.feedback; + + self.write_pos = (self.write_pos + 1) % self.buffer_size; + + output + } + + fn mute(&mut self) { + self.buffer.fill(0.0); + self.filter_store = 0.0; + } + + fn set_damp(&mut self, val: f32) { + self.damp = val; + } + + fn set_feedback(&mut self, val: f32) { + self.feedback = val; + } +} + +/// Single all-pass filter for reverb +struct AllPassFilter { + buffer: Vec, + buffer_size: usize, + write_pos: usize, +} + +impl AllPassFilter { + fn new(size: usize) -> Self { + Self { + buffer: vec![0.0; size], + buffer_size: size, + write_pos: 0, + } + } + + fn process(&mut self, input: f32) -> f32 { + let delayed = self.buffer[self.write_pos]; + let output = -input + delayed; + + self.buffer[self.write_pos] = input + delayed * 0.5; + + self.write_pos = (self.write_pos + 1) % self.buffer_size; + + output + } + + fn mute(&mut self) { + self.buffer.fill(0.0); + } +} + +/// Schroeder reverb node with room size and damping controls +pub struct ReverbNode { + name: String, + room_size: f32, // 0.0 to 1.0 + damping: f32, // 0.0 to 1.0 + wet_dry: f32, // 0.0 = dry only, 1.0 = wet only + + // Left channel filters + comb_filters_left: Vec, + allpass_filters_left: Vec, + + // Right channel filters + comb_filters_right: Vec, + allpass_filters_right: Vec, + + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl ReverbNode { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + + let inputs = vec![ + NodePort::new("Audio In", SignalType::Audio, 0), + ]; + + let outputs = vec![ + NodePort::new("Audio Out", SignalType::Audio, 0), + ]; + + let parameters = vec![ + Parameter::new(PARAM_ROOM_SIZE, "Room Size", 0.0, 1.0, 0.5, ParameterUnit::Generic), + Parameter::new(PARAM_DAMPING, "Damping", 0.0, 1.0, 0.5, ParameterUnit::Generic), + Parameter::new(PARAM_WET_DRY, "Wet/Dry", 0.0, 1.0, 0.3, ParameterUnit::Generic), + ]; + + // Create comb filters for both channels + // Right channel has slightly different delays to create stereo effect + let comb_filters_left: Vec = COMB_DELAYS.iter().map(|&d| CombFilter::new(d)).collect(); + let comb_filters_right: Vec = COMB_DELAYS.iter().map(|&d| CombFilter::new(d + 23)).collect(); + + // Create all-pass filters for both channels + let allpass_filters_left: Vec = ALLPASS_DELAYS.iter().map(|&d| AllPassFilter::new(d)).collect(); + let allpass_filters_right: Vec = ALLPASS_DELAYS.iter().map(|&d| AllPassFilter::new(d + 23)).collect(); + + let mut node = Self { + name, + room_size: 0.5, + damping: 0.5, + wet_dry: 0.3, + comb_filters_left, + allpass_filters_left, + comb_filters_right, + allpass_filters_right, + inputs, + outputs, + parameters, + }; + + node.update_filters(); + node + } + + fn update_filters(&mut self) { + // Room size affects feedback (larger room = more feedback) + let feedback = 0.28 + self.room_size * 0.7; + + // Update all comb filters + for comb in &mut self.comb_filters_left { + comb.set_feedback(feedback); + comb.set_damp(self.damping); + } + for comb in &mut self.comb_filters_right { + comb.set_feedback(feedback); + comb.set_damp(self.damping); + } + } + +} + +impl AudioNode for ReverbNode { + fn category(&self) -> NodeCategory { + NodeCategory::Effect + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, id: u32, value: f32) { + match id { + PARAM_ROOM_SIZE => { + self.room_size = value.clamp(0.0, 1.0); + self.update_filters(); + } + PARAM_DAMPING => { + self.damping = value.clamp(0.0, 1.0); + self.update_filters(); + } + PARAM_WET_DRY => { + self.wet_dry = value.clamp(0.0, 1.0); + } + _ => {} + } + } + + fn get_parameter(&self, id: u32) -> f32 { + match id { + PARAM_ROOM_SIZE => self.room_size, + PARAM_DAMPING => self.damping, + PARAM_WET_DRY => self.wet_dry, + _ => 0.0, + } + } + + fn process( + &mut self, + inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + _sample_rate: u32, + ) { + if inputs.is_empty() || outputs.is_empty() { + return; + } + + let input = inputs[0]; + let output = &mut outputs[0]; + + // Audio signals are stereo (interleaved L/R) + let frames = input.len() / 2; + let output_frames = output.len() / 2; + let frames_to_process = frames.min(output_frames); + + let dry_gain = 1.0 - self.wet_dry; + let wet_gain = self.wet_dry; + + for frame in 0..frames_to_process { + let left_in = input[frame * 2]; + let right_in = input[frame * 2 + 1]; + + // Process both channels + let left_wet = process_channel( + left_in, + &mut self.comb_filters_left, + &mut self.allpass_filters_left, + ); + let right_wet = process_channel( + right_in, + &mut self.comb_filters_right, + &mut self.allpass_filters_right, + ); + + // Mix dry and wet signals + output[frame * 2] = left_in * dry_gain + left_wet * wet_gain; + output[frame * 2 + 1] = right_in * dry_gain + right_wet * wet_gain; + } + } + + fn reset(&mut self) { + for comb in &mut self.comb_filters_left { + comb.mute(); + } + for comb in &mut self.comb_filters_right { + comb.mute(); + } + for allpass in &mut self.allpass_filters_left { + allpass.mute(); + } + for allpass in &mut self.allpass_filters_right { + allpass.mute(); + } + } + + fn node_type(&self) -> &str { + "Reverb" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + Box::new(Self::new(self.name.clone())) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/ring_modulator.rs b/daw-backend/src/audio/node_graph/nodes/ring_modulator.rs new file mode 100644 index 0000000..90c4099 --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/ring_modulator.rs @@ -0,0 +1,145 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; +use crate::audio::midi::MidiEvent; + +const PARAM_MIX: u32 = 0; + +/// Ring Modulator - multiplies two signals together +/// Creates metallic, inharmonic timbres by multiplying carrier and modulator +pub struct RingModulatorNode { + name: String, + mix: f32, // 0.0 = dry (carrier only), 1.0 = fully modulated + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl RingModulatorNode { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + + let inputs = vec![ + NodePort::new("Carrier", SignalType::Audio, 0), + NodePort::new("Modulator", SignalType::Audio, 1), + ]; + + let outputs = vec![ + NodePort::new("Audio Out", SignalType::Audio, 0), + ]; + + let parameters = vec![ + Parameter::new(PARAM_MIX, "Mix", 0.0, 1.0, 1.0, ParameterUnit::Generic), + ]; + + Self { + name, + mix: 1.0, + inputs, + outputs, + parameters, + } + } +} + +impl AudioNode for RingModulatorNode { + fn category(&self) -> NodeCategory { + NodeCategory::Effect + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, id: u32, value: f32) { + match id { + PARAM_MIX => self.mix = value.clamp(0.0, 1.0), + _ => {} + } + } + + fn get_parameter(&self, id: u32) -> f32 { + match id { + PARAM_MIX => self.mix, + _ => 0.0, + } + } + + fn process( + &mut self, + inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + _sample_rate: u32, + ) { + if outputs.is_empty() { + return; + } + + let output = &mut outputs[0]; + let length = output.len(); + + // Get carrier input + let carrier = if !inputs.is_empty() && !inputs[0].is_empty() { + inputs[0] + } else { + &[] + }; + + // Get modulator input + let modulator = if inputs.len() > 1 && !inputs[1].is_empty() { + inputs[1] + } else { + &[] + }; + + // Process each sample + for i in 0..length { + let carrier_sample = if i < carrier.len() { carrier[i] } else { 0.0 }; + let modulator_sample = if i < modulator.len() { modulator[i] } else { 0.0 }; + + // Ring modulation: multiply the two signals + let modulated = carrier_sample * modulator_sample; + + // Mix between dry (carrier) and wet (modulated) + output[i] = carrier_sample * (1.0 - self.mix) + modulated * self.mix; + } + } + + fn reset(&mut self) { + // No state to reset + } + + fn node_type(&self) -> &str { + "RingModulator" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + Box::new(Self { + name: self.name.clone(), + mix: self.mix, + inputs: self.inputs.clone(), + outputs: self.outputs.clone(), + parameters: self.parameters.clone(), + }) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/sample_hold.rs b/daw-backend/src/audio/node_graph/nodes/sample_hold.rs new file mode 100644 index 0000000..4aa7f95 --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/sample_hold.rs @@ -0,0 +1,145 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, SignalType}; +use crate::audio::midi::MidiEvent; + +/// Sample & Hold - samples input CV when triggered by a gate signal +/// Classic modular synth utility for creating stepped sequences +pub struct SampleHoldNode { + name: String, + held_value: f32, + last_gate: f32, + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl SampleHoldNode { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + + let inputs = vec![ + NodePort::new("CV In", SignalType::CV, 0), + NodePort::new("Gate In", SignalType::CV, 1), + ]; + + let outputs = vec![ + NodePort::new("CV Out", SignalType::CV, 0), + ]; + + let parameters = vec![]; + + Self { + name, + held_value: 0.0, + last_gate: 0.0, + inputs, + outputs, + parameters, + } + } +} + +impl AudioNode for SampleHoldNode { + fn category(&self) -> NodeCategory { + NodeCategory::Utility + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, _id: u32, _value: f32) { + // No parameters + } + + fn get_parameter(&self, _id: u32) -> f32 { + 0.0 + } + + fn process( + &mut self, + inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + _sample_rate: u32, + ) { + if outputs.is_empty() { + return; + } + + let output = &mut outputs[0]; + let length = output.len(); + + // Get CV input + let cv_input = if !inputs.is_empty() && !inputs[0].is_empty() { + inputs[0] + } else { + &[] + }; + + // Get Gate input + let gate_input = if inputs.len() > 1 && !inputs[1].is_empty() { + inputs[1] + } else { + &[] + }; + + // Process each sample + for i in 0..length { + let cv = if i < cv_input.len() { cv_input[i] } else { 0.0 }; + let gate = if i < gate_input.len() { gate_input[i] } else { 0.0 }; + + // Detect rising edge (trigger) + let gate_active = gate > 0.5; + let last_gate_active = self.last_gate > 0.5; + + if gate_active && !last_gate_active { + // Rising edge detected - sample the input + self.held_value = cv; + } + + self.last_gate = gate; + output[i] = self.held_value; + } + } + + fn reset(&mut self) { + self.held_value = 0.0; + self.last_gate = 0.0; + } + + fn node_type(&self) -> &str { + "SampleHold" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + Box::new(Self { + name: self.name.clone(), + held_value: self.held_value, + last_gate: self.last_gate, + inputs: self.inputs.clone(), + outputs: self.outputs.clone(), + parameters: self.parameters.clone(), + }) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/script_node.rs b/daw-backend/src/audio/node_graph/nodes/script_node.rs new file mode 100644 index 0000000..f04f728 --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/script_node.rs @@ -0,0 +1,229 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; +use crate::audio::midi::MidiEvent; +use beamdsp::{ScriptVM, SampleSlot}; + +/// A user-scriptable audio node powered by the BeamDSP VM +pub struct ScriptNode { + name: String, + script_name: String, + inputs: Vec, + outputs: Vec, + parameters: Vec, + 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) -> 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 { + 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, 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 { + 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], + 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 { + 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 + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/sequencer.rs b/daw-backend/src/audio/node_graph/nodes/sequencer.rs new file mode 100644 index 0000000..14ac7d3 --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/sequencer.rs @@ -0,0 +1,308 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default}; +use crate::audio::midi::MidiEvent; +use crate::time::Beats; + +const PARAM_MODE: u32 = 0; +const PARAM_STEPS: u32 = 1; +const PARAM_SCALE_MODE: u32 = 2; +const PARAM_KEY: u32 = 3; +const PARAM_SCALE_TYPE: u32 = 4; +const PARAM_OCTAVE: u32 = 5; +const PARAM_VELOCITY: u32 = 6; +const PARAM_ROW_BASE: u32 = 7; +const NUM_ROWS: usize = 8; + +#[derive(Debug, Clone, Copy, PartialEq)] +enum SeqMode { + OnePerCycle = 0, + AllPerCycle = 1, +} + +impl SeqMode { + fn from_f32(v: f32) -> Self { + if v.round() as i32 >= 1 { SeqMode::AllPerCycle } else { SeqMode::OnePerCycle } + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +enum ScaleMode { + Chromatic = 0, + Diatonic = 1, +} + +impl ScaleMode { + fn from_f32(v: f32) -> Self { + if v.round() as i32 >= 1 { ScaleMode::Diatonic } else { ScaleMode::Chromatic } + } +} + +/// Scale interval patterns (semitones from root) +const SCALES: &[&[u8]] = &[ + &[0, 2, 4, 5, 7, 9, 11], // Major + &[0, 2, 3, 5, 7, 8, 10], // Minor + &[0, 2, 3, 5, 7, 9, 10], // Dorian + &[0, 2, 4, 5, 7, 9, 10], // Mixolydian + &[0, 2, 4, 7, 9], // Pentatonic Major + &[0, 3, 5, 7, 10], // Pentatonic Minor + &[0, 3, 5, 6, 7, 10], // Blues + &[0, 2, 3, 5, 7, 8, 11], // Harmonic Minor +]; + +/// Step Sequencer node — MxN grid of note triggers with CV phase input and MIDI output. +pub struct SequencerNode { + name: String, + /// Grid state: row_patterns[row] is a u16 bitmask (bit N = step N active) + row_patterns: [u16; 16], + num_steps: usize, + /// Scale mapping + scale_mode: ScaleMode, + key: u8, + scale_type: usize, + base_octave: u8, + velocity: u8, + /// Playback state + mode: SeqMode, + current_step: usize, + prev_phase: f32, + /// Notes currently "on" from the previous step + prev_active_notes: Vec, + + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl SequencerNode { + pub fn new(name: impl Into) -> 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], + _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 { + 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 + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/simple_sampler.rs b/daw-backend/src/audio/node_graph/nodes/simple_sampler.rs new file mode 100644 index 0000000..5f8dbec --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/simple_sampler.rs @@ -0,0 +1,293 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default}; +use crate::audio::midi::MidiEvent; +use std::sync::{Arc, Mutex}; + +// Parameters +const PARAM_GAIN: u32 = 0; +const PARAM_LOOP: u32 = 1; +const PARAM_PITCH_SHIFT: u32 = 2; + +/// Simple single-sample playback node with pitch shifting +pub struct SimpleSamplerNode { + name: String, + + // Sample data (shared, can be set externally) + sample_data: Arc>>, + sample_rate_original: f32, + sample_path: Option, // Path to loaded sample file + + // Playback state + playhead: f32, // Fractional position in sample + is_playing: bool, + gate_prev: bool, + + // Parameters + gain: f32, + loop_enabled: bool, + pitch_shift: f32, // Additional pitch shift in semitones + root_note: u8, // MIDI note for original pitch playback (default 69 = A4) + + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl SimpleSamplerNode { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + + let inputs = vec![ + NodePort::new("V/Oct", SignalType::CV, 0), + NodePort::new("Gate", SignalType::CV, 1), + ]; + + let outputs = vec![ + NodePort::new("Audio Out", SignalType::Audio, 0), + ]; + + let parameters = vec![ + Parameter::new(PARAM_GAIN, "Gain", 0.0, 2.0, 1.0, ParameterUnit::Generic), + Parameter::new(PARAM_LOOP, "Loop", 0.0, 1.0, 0.0, ParameterUnit::Generic), + Parameter::new(PARAM_PITCH_SHIFT, "Pitch Shift", -12.0, 12.0, 0.0, ParameterUnit::Generic), + ]; + + Self { + name, + sample_data: Arc::new(Mutex::new(Vec::new())), + sample_rate_original: 48000.0, + sample_path: None, + playhead: 0.0, + is_playing: false, + gate_prev: false, + gain: 1.0, + loop_enabled: false, + pitch_shift: 0.0, + root_note: 69, // A4 — V/Oct 0.0 from MIDI-to-CV + inputs, + outputs, + parameters, + } + } + + /// Set the sample data (mono) + pub fn set_sample(&mut self, data: Vec, sample_rate: f32) { + let mut sample = self.sample_data.lock().unwrap(); + *sample = data; + self.sample_rate_original = sample_rate; + } + + /// Get the sample data reference (for external loading) + pub fn get_sample_data(&self) -> Arc>> { + Arc::clone(&self.sample_data) + } + + /// Load a sample from a file path + pub fn load_sample_from_file(&mut self, path: &str) -> Result<(), String> { + use crate::audio::sample_loader::load_audio_file; + + let sample_data = load_audio_file(path)?; + self.set_sample(sample_data.samples, sample_data.sample_rate as f32); + self.sample_path = Some(path.to_string()); + Ok(()) + } + + /// Get the currently loaded sample path + pub fn get_sample_path(&self) -> Option<&str> { + self.sample_path.as_deref() + } + + /// Get the current sample data and sample rate (for preset embedding) + pub fn get_sample_data_for_embedding(&self) -> (Vec, f32) { + let sample = self.sample_data.lock().unwrap(); + (sample.clone(), self.sample_rate_original) + } + + /// Convert V/oct CV to playback speed multiplier + /// Accounts for root_note: when the incoming MIDI note matches root_note, + /// the sample plays at original speed. V/Oct 0.0 = A4 (MIDI 69) by convention. + fn voct_to_speed(&self, voct: f32) -> f32 { + // Offset so root_note plays at original speed + let root_offset = (self.root_note as f32 - 69.0) / 12.0; + let total_semitones = (voct - root_offset) * 12.0 + self.pitch_shift; + 2.0_f32.powf(total_semitones / 12.0) + } + + /// Set the root note (MIDI note number for original-pitch playback) + pub fn set_root_note(&mut self, note: u8) { + self.root_note = note.min(127); + } + + /// Get the current root note + pub fn root_note(&self) -> u8 { + self.root_note + } + + /// Read sample at playhead with linear interpolation + fn read_sample(&self, playhead: f32, sample: &[f32]) -> f32 { + if sample.is_empty() { + return 0.0; + } + + let index = playhead.floor() as usize; + let frac = playhead - playhead.floor(); + + if index >= sample.len() { + return 0.0; + } + + let sample1 = sample[index]; + let sample2 = if index + 1 < sample.len() { + sample[index + 1] + } else if self.loop_enabled { + sample[0] // Loop back to start + } else { + 0.0 + }; + + // Linear interpolation + sample1 + (sample2 - sample1) * frac + } +} + +impl AudioNode for SimpleSamplerNode { + fn category(&self) -> NodeCategory { + NodeCategory::Generator + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, id: u32, value: f32) { + match id { + PARAM_GAIN => { + self.gain = value.clamp(0.0, 2.0); + } + PARAM_LOOP => { + self.loop_enabled = value > 0.5; + } + PARAM_PITCH_SHIFT => { + self.pitch_shift = value.clamp(-12.0, 12.0); + } + _ => {} + } + } + + fn get_parameter(&self, id: u32) -> f32 { + match id { + PARAM_GAIN => self.gain, + PARAM_LOOP => if self.loop_enabled { 1.0 } else { 0.0 }, + PARAM_PITCH_SHIFT => self.pitch_shift, + _ => 0.0, + } + } + + fn process( + &mut self, + inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + sample_rate: u32, + ) { + if outputs.is_empty() { + return; + } + + // Lock the sample data + let sample_data = self.sample_data.lock().unwrap(); + if sample_data.is_empty() { + // No sample loaded, output silence + for output in outputs.iter_mut() { + output.fill(0.0); + } + return; + } + + let output = &mut outputs[0]; + let frames = output.len() / 2; + + for frame in 0..frames { + // Read CV inputs (both are mono signals) + // V/Oct: when unconnected, defaults to 0.0 (original pitch) + let voct = cv_input_or_default(inputs, 0, frame, 0.0); + // Gate: when unconnected, defaults to 0.0 (off) + let gate = cv_input_or_default(inputs, 1, frame, 0.0); + + // Detect gate trigger (rising edge) + let gate_active = gate > 0.5; + if gate_active && !self.gate_prev { + // Trigger: start playback from beginning + self.playhead = 0.0; + self.is_playing = true; + } + self.gate_prev = gate_active; + + // Generate sample + let sample = if self.is_playing { + let s = self.read_sample(self.playhead, &sample_data); + + // Calculate playback speed from V/Oct + let speed = self.voct_to_speed(voct); + + // Advance playhead with resampling + let speed_adjusted = speed * (self.sample_rate_original / sample_rate as f32); + self.playhead += speed_adjusted; + + // Check if we've reached the end + if self.playhead >= sample_data.len() as f32 { + if self.loop_enabled { + // Loop back to start + self.playhead = self.playhead % sample_data.len() as f32; + } else { + // Stop playback + self.is_playing = false; + self.playhead = 0.0; + } + } + + s * self.gain + } else { + 0.0 + }; + + // Output stereo (same signal to both channels) + output[frame * 2] = sample; + output[frame * 2 + 1] = sample; + } + } + + fn reset(&mut self) { + self.playhead = 0.0; + self.is_playing = false; + self.gate_prev = false; + } + + fn node_type(&self) -> &str { + "SimpleSampler" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + Box::new(Self::new(self.name.clone())) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/slew_limiter.rs b/daw-backend/src/audio/node_graph/nodes/slew_limiter.rs new file mode 100644 index 0000000..d3fb4fd --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/slew_limiter.rs @@ -0,0 +1,165 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default}; +use crate::audio::midi::MidiEvent; + +const PARAM_RISE_TIME: u32 = 0; +const PARAM_FALL_TIME: u32 = 1; + +/// Slew limiter - limits the rate of change of a CV signal +/// Useful for creating portamento/glide effects and smoothing control signals +pub struct SlewLimiterNode { + name: String, + rise_time: f32, // Time in seconds to rise from 0 to 1 + fall_time: f32, // Time in seconds to fall from 1 to 0 + last_value: f32, + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl SlewLimiterNode { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + + let inputs = vec![ + NodePort::new("CV In", SignalType::CV, 0), + ]; + + let outputs = vec![ + NodePort::new("CV Out", SignalType::CV, 0), + ]; + + let parameters = vec![ + Parameter::new(PARAM_RISE_TIME, "Rise Time", 0.0, 5.0, 0.01, ParameterUnit::Time), + Parameter::new(PARAM_FALL_TIME, "Fall Time", 0.0, 5.0, 0.01, ParameterUnit::Time), + ]; + + Self { + name, + rise_time: 0.01, + fall_time: 0.01, + last_value: 0.0, + inputs, + outputs, + parameters, + } + } +} + +impl AudioNode for SlewLimiterNode { + fn category(&self) -> NodeCategory { + NodeCategory::Utility + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, id: u32, value: f32) { + match id { + PARAM_RISE_TIME => self.rise_time = value.clamp(0.0, 5.0), + PARAM_FALL_TIME => self.fall_time = value.clamp(0.0, 5.0), + _ => {} + } + } + + fn get_parameter(&self, id: u32) -> f32 { + match id { + PARAM_RISE_TIME => self.rise_time, + PARAM_FALL_TIME => self.fall_time, + _ => 0.0, + } + } + + fn process( + &mut self, + inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + sample_rate: u32, + ) { + if inputs.is_empty() || outputs.is_empty() { + return; + } + + let output = &mut outputs[0]; + let length = output.len(); + + // Calculate maximum change per sample + let sample_duration = 1.0 / sample_rate as f32; + + // Rise/fall rates (units per second) + let rise_rate = if self.rise_time > 0.0001 { + 1.0 / self.rise_time + } else { + f32::MAX // No limiting + }; + + let fall_rate = if self.fall_time > 0.0001 { + 1.0 / self.fall_time + } else { + f32::MAX // No limiting + }; + + for i in 0..length { + // Use cv_input_or_default to handle unconnected inputs (NaN sentinel) + // Default to last_value so output holds steady when unconnected + let target = cv_input_or_default(inputs, 0, i, self.last_value); + let difference = target - self.last_value; + + let max_change = if difference > 0.0 { + // Rising + rise_rate * sample_duration + } else { + // Falling + fall_rate * sample_duration + }; + + // Limit the change + let limited_difference = difference.clamp(-max_change, max_change); + self.last_value += limited_difference; + + output[i] = self.last_value; + } + } + + fn reset(&mut self) { + self.last_value = 0.0; + } + + fn node_type(&self) -> &str { + "SlewLimiter" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + Box::new(Self { + name: self.name.clone(), + rise_time: self.rise_time, + fall_time: self.fall_time, + last_value: self.last_value, + inputs: self.inputs.clone(), + outputs: self.outputs.clone(), + parameters: self.parameters.clone(), + }) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/splitter.rs b/daw-backend/src/audio/node_graph/nodes/splitter.rs new file mode 100644 index 0000000..092b9ea --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/splitter.rs @@ -0,0 +1,112 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, SignalType}; +use crate::audio::midi::MidiEvent; + +/// Splitter node - copies input to multiple outputs for parallel routing +pub struct SplitterNode { + name: String, + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl SplitterNode { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + + let inputs = vec![ + NodePort::new("Audio In", SignalType::Audio, 0), + ]; + + let outputs = vec![ + NodePort::new("Out 1", SignalType::Audio, 0), + NodePort::new("Out 2", SignalType::Audio, 1), + NodePort::new("Out 3", SignalType::Audio, 2), + NodePort::new("Out 4", SignalType::Audio, 3), + ]; + + let parameters = vec![]; + + Self { + name, + inputs, + outputs, + parameters, + } + } +} + +impl AudioNode for SplitterNode { + fn category(&self) -> NodeCategory { + NodeCategory::Utility + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, _id: u32, _value: f32) { + // No parameters + } + + fn get_parameter(&self, _id: u32) -> f32 { + 0.0 + } + + fn process( + &mut self, + inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + _sample_rate: u32, + ) { + if inputs.is_empty() || outputs.is_empty() { + return; + } + + let input = inputs[0]; + + // Copy input to all outputs + for output in outputs.iter_mut() { + let len = input.len().min(output.len()); + output[..len].copy_from_slice(&input[..len]); + } + } + + fn reset(&mut self) { + // No state to reset + } + + fn node_type(&self) -> &str { + "Splitter" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + Box::new(Self { + name: self.name.clone(), + inputs: self.inputs.clone(), + outputs: self.outputs.clone(), + parameters: self.parameters.clone(), + }) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/subtrack_inputs.rs b/daw-backend/src/audio/node_graph/nodes/subtrack_inputs.rs new file mode 100644 index 0000000..83d9087 --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/subtrack_inputs.rs @@ -0,0 +1,177 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, SignalType}; +use crate::audio::midi::MidiEvent; +use crate::audio::track::TrackId; + +/// Subtrack inputs node for metatracks. +/// +/// Exposes one output port per child track so users can route individual subtracks +/// independently in the mixing graph (e.g., for sidechain effects). +/// +/// Audio is injected into pre-allocated per-slot buffers by the render system before +/// the graph is processed — no heap allocation occurs during audio rendering. +pub struct SubtrackInputsNode { + name: String, + /// Ordered list of (TrackId, display_name) for each subtrack slot. + /// TrackId is used by the render system to match the right buffer to the right slot. + subtracks: Vec<(TrackId, String)>, + /// Output port descriptors — rebuilt whenever subtracks changes. + outputs: Vec, + /// 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>, + /// The buffer size this node was last sized for. + buffer_size: usize, +} + +impl SubtrackInputsNode { + pub fn new(name: impl Into, 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 { + 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 { + 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], + _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 { + 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 + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/svf.rs b/daw-backend/src/audio/node_graph/nodes/svf.rs new file mode 100644 index 0000000..44b8ee5 --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/svf.rs @@ -0,0 +1,199 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default}; +use crate::audio::midi::MidiEvent; +use crate::dsp::svf::SvfFilter; + +const PARAM_CUTOFF: u32 = 0; +const PARAM_RESONANCE: u32 = 1; + +/// State Variable Filter node — simultaneously outputs lowpass, highpass, +/// bandpass, and notch from one filter, with per-sample CV modulation of +/// cutoff and resonance. +pub struct SVFNode { + name: String, + filter: SvfFilter, + cutoff: f32, + resonance: f32, + sample_rate: u32, + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl SVFNode { + pub fn new(name: impl Into) -> 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], + 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 { + 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 + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/template_io.rs b/daw-backend/src/audio/node_graph/nodes/template_io.rs new file mode 100644 index 0000000..d72ff6a --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/template_io.rs @@ -0,0 +1,192 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, SignalType}; +use crate::audio::midi::MidiEvent; + +/// Template Input node - represents the MIDI input for one voice in a VoiceAllocator +pub struct TemplateInputNode { + name: String, + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl TemplateInputNode { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + + let inputs = vec![]; + let outputs = vec![ + NodePort::new("MIDI Out", SignalType::Midi, 0), + ]; + + Self { + name, + inputs, + outputs, + parameters: vec![], + } + } +} + +impl AudioNode for TemplateInputNode { + fn category(&self) -> NodeCategory { + NodeCategory::Input + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, _id: u32, _value: f32) {} + + fn get_parameter(&self, _id: u32) -> f32 { + 0.0 + } + + fn process( + &mut self, + _inputs: &[&[f32]], + _outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + _sample_rate: u32, + ) { + // TemplateInput receives MIDI from VoiceAllocator and outputs it + // The MIDI was already placed in midi_outputs by the graph before calling process() + // So there's nothing to do here - the MIDI is already in the output buffer + } + + fn reset(&mut self) {} + + fn node_type(&self) -> &str { + "TemplateInput" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + Box::new(Self { + name: self.name.clone(), + inputs: self.inputs.clone(), + outputs: self.outputs.clone(), + parameters: self.parameters.clone(), + }) + } + + fn handle_midi(&mut self, _event: &MidiEvent) { + // Pass through to connected nodes + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} + +/// Template Output node - represents the audio output from one voice in a VoiceAllocator +pub struct TemplateOutputNode { + name: String, + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl TemplateOutputNode { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + + let inputs = vec![ + NodePort::new("Audio In", SignalType::Audio, 0), + ]; + let outputs = vec![ + NodePort::new("Audio Out", SignalType::Audio, 0), + ]; + + Self { + name, + inputs, + outputs, + parameters: vec![], + } + } +} + +impl AudioNode for TemplateOutputNode { + fn category(&self) -> NodeCategory { + NodeCategory::Output + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, _id: u32, _value: f32) {} + + fn get_parameter(&self, _id: u32) -> f32 { + 0.0 + } + + fn process( + &mut self, + inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + _sample_rate: u32, + ) { + // Copy input to output - the graph reads from output buffers + if !inputs.is_empty() && !outputs.is_empty() { + let input = inputs[0]; + let output = &mut outputs[0]; + let len = input.len().min(output.len()); + output[..len].copy_from_slice(&input[..len]); + } + } + + fn reset(&mut self) {} + + fn node_type(&self) -> &str { + "TemplateOutput" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + Box::new(Self { + name: self.name.clone(), + inputs: self.inputs.clone(), + outputs: self.outputs.clone(), + parameters: self.parameters.clone(), + }) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/vibrato.rs b/daw-backend/src/audio/node_graph/nodes/vibrato.rs new file mode 100644 index 0000000..11c12bb --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/vibrato.rs @@ -0,0 +1,270 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; +use crate::audio::midi::MidiEvent; +use std::f32::consts::PI; + +const PARAM_RATE: u32 = 0; +const PARAM_DEPTH: u32 = 1; + +const MAX_DELAY_MS: f32 = 7.0; +const BASE_DELAY_MS: f32 = 0.5; + +/// Vibrato effect — periodic pitch modulation via a short modulated delay line. +/// +/// 100% wet signal (no dry mix). Supports an external Mod CV input that, when +/// connected, replaces the internal sine LFO with the incoming CV signal. +pub struct VibratoNode { + name: String, + rate: f32, + depth: f32, + + delay_buffer_left: Vec, + delay_buffer_right: Vec, + write_position: usize, + max_delay_samples: usize, + sample_rate: u32, + + lfo_phase: f32, + + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl VibratoNode { + pub fn new(name: impl Into) -> 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], + 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 { + 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 + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/vocoder.rs b/daw-backend/src/audio/node_graph/nodes/vocoder.rs new file mode 100644 index 0000000..ce1285d --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/vocoder.rs @@ -0,0 +1,370 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; +use crate::audio::midi::MidiEvent; +use std::f32::consts::PI; + +const PARAM_BANDS: u32 = 0; +const PARAM_ATTACK: u32 = 1; +const PARAM_RELEASE: u32 = 2; +const PARAM_MIX: u32 = 3; + +const MAX_BANDS: usize = 32; + +/// Simple bandpass filter using biquad +struct BandpassFilter { + // Biquad coefficients + b0: f32, + b1: f32, + b2: f32, + a1: f32, + a2: f32, + + // State variables (separate for modulator and carrier, L/R channels) + mod_z1_left: f32, + mod_z2_left: f32, + mod_z1_right: f32, + mod_z2_right: f32, + car_z1_left: f32, + car_z2_left: f32, + car_z1_right: f32, + car_z2_right: f32, +} + +impl BandpassFilter { + fn new() -> Self { + Self { + b0: 0.0, + b1: 0.0, + b2: 0.0, + a1: 0.0, + a2: 0.0, + mod_z1_left: 0.0, + mod_z2_left: 0.0, + mod_z1_right: 0.0, + mod_z2_right: 0.0, + car_z1_left: 0.0, + car_z2_left: 0.0, + car_z1_right: 0.0, + car_z2_right: 0.0, + } + } + + fn set_bandpass(&mut self, frequency: f32, q: f32, sample_rate: f32) { + let omega = 2.0 * PI * frequency / sample_rate; + let sin_omega = omega.sin(); + let cos_omega = omega.cos(); + let alpha = sin_omega / (2.0 * q); + + let a0 = 1.0 + alpha; + self.b0 = alpha / a0; + self.b1 = 0.0; + self.b2 = -alpha / a0; + self.a1 = -2.0 * cos_omega / a0; + self.a2 = (1.0 - alpha) / a0; + } + + fn process_modulator(&mut self, input: f32, is_left: bool) -> f32 { + let (z1, z2) = if is_left { + (&mut self.mod_z1_left, &mut self.mod_z2_left) + } else { + (&mut self.mod_z1_right, &mut self.mod_z2_right) + }; + + let output = self.b0 * input + self.b1 * *z1 + self.b2 * *z2 - self.a1 * *z1 - self.a2 * *z2; + *z2 = *z1; + *z1 = output; + output + } + + fn process_carrier(&mut self, input: f32, is_left: bool) -> f32 { + let (z1, z2) = if is_left { + (&mut self.car_z1_left, &mut self.car_z2_left) + } else { + (&mut self.car_z1_right, &mut self.car_z2_right) + }; + + let output = self.b0 * input + self.b1 * *z1 + self.b2 * *z2 - self.a1 * *z1 - self.a2 * *z2; + *z2 = *z1; + *z1 = output; + output + } + + fn reset(&mut self) { + self.mod_z1_left = 0.0; + self.mod_z2_left = 0.0; + self.mod_z1_right = 0.0; + self.mod_z2_right = 0.0; + self.car_z1_left = 0.0; + self.car_z2_left = 0.0; + self.car_z1_right = 0.0; + self.car_z2_right = 0.0; + } +} + +/// Vocoder band with filter and envelope follower +struct VocoderBand { + filter: BandpassFilter, + envelope_left: f32, + envelope_right: f32, +} + +impl VocoderBand { + fn new() -> Self { + Self { + filter: BandpassFilter::new(), + envelope_left: 0.0, + envelope_right: 0.0, + } + } + + fn reset(&mut self) { + self.filter.reset(); + self.envelope_left = 0.0; + self.envelope_right = 0.0; + } +} + +/// Vocoder effect - imposes spectral envelope of modulator onto carrier +pub struct VocoderNode { + name: String, + num_bands: usize, // 8 to 32 bands + attack_time: f32, // 0.001 to 0.1 seconds + release_time: f32, // 0.001 to 1.0 seconds + mix: f32, // 0.0 to 1.0 + + bands: Vec, + + sample_rate: u32, + + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl VocoderNode { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + + let inputs = vec![ + NodePort::new("Modulator", SignalType::Audio, 0), + NodePort::new("Carrier", SignalType::Audio, 1), + ]; + + let outputs = vec![ + NodePort::new("Audio Out", SignalType::Audio, 0), + ]; + + let parameters = vec![ + Parameter::new(PARAM_BANDS, "Bands", 8.0, 32.0, 16.0, ParameterUnit::Generic), + Parameter::new(PARAM_ATTACK, "Attack", 0.001, 0.1, 0.01, ParameterUnit::Time), + Parameter::new(PARAM_RELEASE, "Release", 0.001, 1.0, 0.05, ParameterUnit::Time), + Parameter::new(PARAM_MIX, "Mix", 0.0, 1.0, 1.0, ParameterUnit::Generic), + ]; + + let mut bands = Vec::with_capacity(MAX_BANDS); + for _ in 0..MAX_BANDS { + bands.push(VocoderBand::new()); + } + + Self { + name, + num_bands: 16, + attack_time: 0.01, + release_time: 0.05, + mix: 1.0, + bands, + sample_rate: 48000, + inputs, + outputs, + parameters, + } + } + + fn setup_bands(&mut self) { + // Distribute bands logarithmically from 200 Hz to 5000 Hz + let min_freq: f32 = 200.0; + let max_freq: f32 = 5000.0; + let q: f32 = 4.0; // Fairly narrow bands + + for i in 0..self.num_bands { + let t = i as f32 / (self.num_bands - 1) as f32; + let freq = min_freq * (max_freq / min_freq).powf(t); + self.bands[i].filter.set_bandpass(freq, q, self.sample_rate as f32); + } + } +} + +impl AudioNode for VocoderNode { + fn category(&self) -> NodeCategory { + NodeCategory::Effect + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, id: u32, value: f32) { + match id { + PARAM_BANDS => { + let bands = (value.round() as usize).clamp(8, 32); + if bands != self.num_bands { + self.num_bands = bands; + self.setup_bands(); + } + } + PARAM_ATTACK => self.attack_time = value.clamp(0.001, 0.1), + PARAM_RELEASE => self.release_time = value.clamp(0.001, 1.0), + PARAM_MIX => self.mix = value.clamp(0.0, 1.0), + _ => {} + } + } + + fn get_parameter(&self, id: u32) -> f32 { + match id { + PARAM_BANDS => self.num_bands as f32, + PARAM_ATTACK => self.attack_time, + PARAM_RELEASE => self.release_time, + PARAM_MIX => self.mix, + _ => 0.0, + } + } + + fn process( + &mut self, + inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + sample_rate: u32, + ) { + if inputs.len() < 2 || outputs.is_empty() { + return; + } + + // Update sample rate if changed + if self.sample_rate != sample_rate { + self.sample_rate = sample_rate; + self.setup_bands(); + } + + let modulator = inputs[0]; + let carrier = inputs[1]; + let output = &mut outputs[0]; + + // Audio signals are stereo (interleaved L/R) + let mod_frames = modulator.len() / 2; + let car_frames = carrier.len() / 2; + let out_frames = output.len() / 2; + let frames_to_process = mod_frames.min(car_frames).min(out_frames); + + // Calculate envelope follower coefficients + let sample_duration = 1.0 / self.sample_rate as f32; + let attack_coeff = (sample_duration / self.attack_time).min(1.0); + let release_coeff = (sample_duration / self.release_time).min(1.0); + + for frame in 0..frames_to_process { + let mod_left = modulator[frame * 2]; + let mod_right = modulator[frame * 2 + 1]; + let car_left = carrier[frame * 2]; + let car_right = carrier[frame * 2 + 1]; + + let mut out_left = 0.0; + let mut out_right = 0.0; + + // Process each band + for i in 0..self.num_bands { + let band = &mut self.bands[i]; + + // Filter modulator and carrier through bandpass + let mod_band_left = band.filter.process_modulator(mod_left, true); + let mod_band_right = band.filter.process_modulator(mod_right, false); + let car_band_left = band.filter.process_carrier(car_left, true); + let car_band_right = band.filter.process_carrier(car_right, false); + + // Extract envelope from modulator band (rectify + smooth) + let mod_level_left = mod_band_left.abs(); + let mod_level_right = mod_band_right.abs(); + + // Envelope follower + let coeff_left = if mod_level_left > band.envelope_left { + attack_coeff + } else { + release_coeff + }; + let coeff_right = if mod_level_right > band.envelope_right { + attack_coeff + } else { + release_coeff + }; + + band.envelope_left += (mod_level_left - band.envelope_left) * coeff_left; + band.envelope_right += (mod_level_right - band.envelope_right) * coeff_right; + + // Apply envelope to carrier band + out_left += car_band_left * band.envelope_left; + out_right += car_band_right * band.envelope_right; + } + + // Normalize output (roughly compensate for band summing) + let norm_factor = 1.0 / (self.num_bands as f32).sqrt(); + out_left *= norm_factor; + out_right *= norm_factor; + + // Mix with carrier (dry signal) + output[frame * 2] = car_left * (1.0 - self.mix) + out_left * self.mix; + output[frame * 2 + 1] = car_right * (1.0 - self.mix) + out_right * self.mix; + } + } + + fn reset(&mut self) { + for band in &mut self.bands { + band.reset(); + } + } + + fn node_type(&self) -> &str { + "Vocoder" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + let mut bands = Vec::with_capacity(MAX_BANDS); + for _ in 0..MAX_BANDS { + bands.push(VocoderBand::new()); + } + + let mut node = Self { + name: self.name.clone(), + num_bands: self.num_bands, + attack_time: self.attack_time, + release_time: self.release_time, + mix: self.mix, + bands, + sample_rate: self.sample_rate, + inputs: self.inputs.clone(), + outputs: self.outputs.clone(), + parameters: self.parameters.clone(), + }; + + node.setup_bands(); + Box::new(node) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/voice_allocator.rs b/daw-backend/src/audio/node_graph/nodes/voice_allocator.rs new file mode 100644 index 0000000..8e0cb3c --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/voice_allocator.rs @@ -0,0 +1,414 @@ +use crate::audio::midi::MidiEvent; +use crate::audio::node_graph::{AudioNode, AudioGraph, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; + +const PARAM_VOICE_COUNT: u32 = 0; +const MAX_VOICES: usize = 16; // Maximum allowed voices +const DEFAULT_VOICES: usize = 8; + +/// Voice state for voice allocation +#[derive(Clone)] +struct VoiceState { + active: bool, + releasing: bool, // Note-off received, still processing (e.g. ADSR release) + note: u8, + note_channel: u8, // MIDI channel this voice was allocated on (0 = global/unset) + age: u32, // For voice stealing + pending_events: Vec, // MIDI events to send to this voice +} + +impl VoiceState { + fn new() -> Self { + Self { + active: false, + releasing: false, + note: 0, + note_channel: 0, + age: 0, + pending_events: Vec::new(), + } + } +} + +/// VoiceAllocatorNode - A group node that creates N polyphonic instances of its internal graph +/// +/// This node acts as a container for a "voice template" graph. At runtime, it creates +/// N instances of that graph (one per voice) and routes MIDI note events to them. +/// All voice outputs are mixed together into a single output. +pub struct VoiceAllocatorNode { + name: String, + + /// The template graph (edited by user via UI) + template_graph: AudioGraph, + + /// Runtime voice instances (clones of template) + voice_instances: Vec, + + /// Voice allocation state + voices: [VoiceState; MAX_VOICES], + + /// Number of active voices (configurable parameter) + voice_count: usize, + + /// Mix buffer for combining voice outputs + mix_buffer: Vec, + + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl VoiceAllocatorNode { + pub fn new(name: impl Into, sample_rate: u32, buffer_size: usize) -> Self { + let name = name.into(); + + // MIDI input for receiving note events + let inputs = vec![ + NodePort::new("MIDI In", SignalType::Midi, 0), + ]; + + // Single mixed audio output + let outputs = vec![ + NodePort::new("Mixed Out", SignalType::Audio, 0), + ]; + + // Voice count parameter + let parameters = vec![ + Parameter::new(PARAM_VOICE_COUNT, "Voices", 1.0, MAX_VOICES as f32, DEFAULT_VOICES as f32, ParameterUnit::Generic), + ]; + + // Create template graph with default TemplateInput and TemplateOutput nodes + let mut template_graph = AudioGraph::new(sample_rate, buffer_size); + { + use super::template_io::{TemplateInputNode, TemplateOutputNode}; + let input_node = Box::new(TemplateInputNode::new("Template Input")); + let output_node = Box::new(TemplateOutputNode::new("Template Output")); + let input_idx = template_graph.add_node(input_node); + let output_idx = template_graph.add_node(output_node); + template_graph.set_node_position(input_idx, -200.0, 0.0); + template_graph.set_node_position(output_idx, 200.0, 0.0); + template_graph.set_midi_target(input_idx, true); + template_graph.set_output_node(Some(output_idx)); + } + + // Create voice instances (initially empty clones of template) + let voice_instances: Vec = (0..MAX_VOICES) + .map(|_| AudioGraph::new(sample_rate, buffer_size)) + .collect(); + + Self { + name, + template_graph, + voice_instances, + voices: std::array::from_fn(|_| VoiceState::new()), + voice_count: DEFAULT_VOICES, + mix_buffer: vec![0.0; buffer_size * 2], // Stereo + inputs, + outputs, + parameters, + } + } + + /// Get mutable reference to template graph (for UI editing) + pub fn template_graph_mut(&mut self) -> &mut AudioGraph { + &mut self.template_graph + } + + /// Get reference to template graph (for serialization) + pub fn template_graph(&self) -> &AudioGraph { + &self.template_graph + } + + /// Rebuild voice instances from template (called after template is edited) + pub fn rebuild_voices(&mut self) { + // Clone template to all voice instances + for voice in &mut self.voice_instances { + *voice = self.template_graph.clone_graph(); + + // Find TemplateInput and TemplateOutput nodes + let mut template_input_idx = None; + let mut template_output_idx = None; + + for node_idx in voice.node_indices() { + if let Some(node) = voice.get_node(node_idx) { + match node.node_type() { + "TemplateInput" => template_input_idx = Some(node_idx), + "TemplateOutput" => template_output_idx = Some(node_idx), + _ => {} + } + } + } + + // Mark ONLY TemplateInput as a MIDI target + // MIDI will flow through graph connections to other nodes (like MidiToCV) + if let Some(input_idx) = template_input_idx { + voice.set_midi_target(input_idx, true); + } + + // Set TemplateOutput as output node + voice.set_output_node(template_output_idx); + } + } + + /// Find a free voice, or steal one + /// Priority: inactive → oldest releasing → oldest held + fn find_voice_for_note_on(&mut self) -> usize { + // First, look for an inactive voice + for (i, voice) in self.voices[..self.voice_count].iter().enumerate() { + if !voice.active { + return i; + } + } + + // No inactive voices — steal the oldest releasing voice + if let Some((i, _)) = self.voices[..self.voice_count] + .iter() + .enumerate() + .filter(|(_, v)| v.releasing) + .max_by_key(|(_, v)| v.age) + { + return i; + } + + // No releasing voices either — steal the oldest held voice + self.voices[..self.voice_count] + .iter() + .enumerate() + .max_by_key(|(_, v)| v.age) + .map(|(i, _)| i) + .unwrap_or(0) + } + + /// Get oscilloscope data from the most relevant voice's subgraph. + /// Priority: first active voice → first releasing voice → first voice. + pub fn get_voice_oscilloscope_data(&self, node_id: u32, sample_count: usize) -> Option<(Vec, Vec)> { + let voice_idx = self.best_voice_index(); + let graph = &self.voice_instances[voice_idx]; + let node_idx = petgraph::stable_graph::NodeIndex::new(node_id as usize); + let audio = graph.get_oscilloscope_data(node_idx, sample_count)?; + let cv = graph.get_oscilloscope_cv_data(node_idx, sample_count).unwrap_or_default(); + Some((audio, cv)) + } + + /// Find the best voice index to observe: first active → first releasing → 0 + fn best_voice_index(&self) -> usize { + // First active (non-releasing) voice + for (i, v) in self.voices[..self.voice_count].iter().enumerate() { + if v.active && !v.releasing { + return i; + } + } + // First releasing voice + for (i, v) in self.voices[..self.voice_count].iter().enumerate() { + if v.active && v.releasing { + return i; + } + } + // Fallback to first voice + 0 + } + + /// Find all voices playing a specific note (held, not yet releasing) + fn find_voices_for_note_off(&self, note: u8) -> Vec { + self.voices[..self.voice_count] + .iter() + .enumerate() + .filter_map(|(i, v)| { + if v.active && !v.releasing && v.note == note { + Some(i) + } else { + None + } + }) + .collect() + } +} + +impl AudioNode for VoiceAllocatorNode { + fn category(&self) -> NodeCategory { + NodeCategory::Utility + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, id: u32, value: f32) { + match id { + PARAM_VOICE_COUNT => { + let new_count = (value.round() as usize).clamp(1, MAX_VOICES); + if new_count != self.voice_count { + self.voice_count = new_count; + // Stop voices beyond the new count + for voice in &mut self.voices[new_count..] { + voice.active = false; + voice.releasing = false; + } + } + } + _ => {} + } + } + + fn get_parameter(&self, id: u32) -> f32 { + match id { + PARAM_VOICE_COUNT => self.voice_count as f32, + _ => 0.0, + } + } + + fn handle_midi(&mut self, event: &MidiEvent) { + let status = event.status & 0xF0; + + match status { + 0x90 => { + // Note on + if event.data2 > 0 { + let voice_idx = self.find_voice_for_note_on(); + self.voices[voice_idx].active = true; + self.voices[voice_idx].releasing = false; + self.voices[voice_idx].note = event.data1; + self.voices[voice_idx].note_channel = event.status & 0x0F; + self.voices[voice_idx].age = 0; + + // Store MIDI event for this voice to process + self.voices[voice_idx].pending_events.push(*event); + } else { + // Velocity = 0 means note off — mark releasing, keep active for ADSR release + let voice_indices = self.find_voices_for_note_off(event.data1); + for voice_idx in voice_indices { + self.voices[voice_idx].releasing = true; + self.voices[voice_idx].pending_events.push(*event); + } + } + } + 0x80 => { + // Note off — mark releasing, keep active for ADSR release + let voice_indices = self.find_voices_for_note_off(event.data1); + for voice_idx in voice_indices { + self.voices[voice_idx].releasing = true; + self.voices[voice_idx].pending_events.push(*event); + } + } + _ => { + // Route to matching-channel voices; channel 0 = global broadcast + let event_channel = event.status & 0x0F; + for voice_idx in 0..self.voice_count { + let voice = &mut self.voices[voice_idx]; + if voice.active && (event_channel == 0 || voice.note_channel == event_channel) { + voice.pending_events.push(*event); + } + } + } + } + } + + fn process( + &mut self, + _inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + _sample_rate: u32, + ) { + // Process MIDI events from input (allocate notes to voices) + if !midi_inputs.is_empty() { + for event in midi_inputs[0] { + self.handle_midi(event); + } + } + + if outputs.is_empty() { + return; + } + + let output = &mut outputs[0]; + let output_len = output.len(); + + // Process each active voice and mix (only up to voice_count) + for voice_idx in 0..self.voice_count { + let voice_state = &mut self.voices[voice_idx]; + if voice_state.active { + voice_state.age = voice_state.age.saturating_add(1); + + // Get pending MIDI events for this voice + let midi_events = std::mem::take(&mut voice_state.pending_events); + + // IMPORTANT: Process only the slice of mix_buffer that matches output size + // This prevents phase discontinuities in oscillators + let mix_slice = &mut self.mix_buffer[..output_len]; + mix_slice.fill(0.0); + + // Process this voice's graph with its MIDI events + // Note: playback_time is 0.0 since voice allocator doesn't track time + self.voice_instances[voice_idx].process(mix_slice, &midi_events, crate::time::Beats::ZERO); + + // Auto-deactivate releasing voices that have gone silent + if voice_state.releasing { + let peak = mix_slice.iter().fold(0.0f32, |max, &s| max.max(s.abs())); + if peak < 1e-6 { + voice_state.active = false; + voice_state.releasing = false; + continue; // Don't mix silent output + } + } + + // Mix into output (accumulate) + for (i, sample) in mix_slice.iter().enumerate() { + output[i] += sample; + } + } + } + } + + fn reset(&mut self) { + for voice in &mut self.voices { + voice.active = false; + voice.releasing = false; + voice.pending_events.clear(); + } + for graph in &mut self.voice_instances { + graph.reset(); + } + self.template_graph.reset(); + } + + fn node_type(&self) -> &str { + "VoiceAllocator" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + // Clone creates a new VoiceAllocator with the same template graph + // Voice instances will be rebuilt when rebuild_voices() is called + Box::new(Self { + name: self.name.clone(), + template_graph: self.template_graph.clone_graph(), + voice_instances: self.voice_instances.iter().map(|g| g.clone_graph()).collect(), + voices: std::array::from_fn(|_| VoiceState::new()), // Reset voices + voice_count: self.voice_count, + mix_buffer: vec![0.0; self.mix_buffer.len()], + inputs: self.inputs.clone(), + outputs: self.outputs.clone(), + parameters: self.parameters.clone(), + }) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/nodes/wavetable_oscillator.rs b/daw-backend/src/audio/node_graph/nodes/wavetable_oscillator.rs new file mode 100644 index 0000000..180ba62 --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/wavetable_oscillator.rs @@ -0,0 +1,291 @@ +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default}; +use crate::audio::midi::MidiEvent; +use std::f32::consts::PI; + +const WAVETABLE_SIZE: usize = 256; + +// Parameters +const PARAM_WAVETABLE: u32 = 0; +const PARAM_FINE_TUNE: u32 = 1; +const PARAM_POSITION: u32 = 2; + +/// Types of preset wavetables +#[derive(Debug, Clone, Copy, PartialEq)] +enum WavetableType { + Sine = 0, + Saw = 1, + Square = 2, + Triangle = 3, + PWM = 4, // Pulse Width Modulated + Harmonic = 5, // Rich harmonics + Inharmonic = 6, // Metallic/bell-like + Digital = 7, // Stepped/digital artifacts +} + +impl WavetableType { + fn from_u32(value: u32) -> Self { + match value { + 0 => WavetableType::Sine, + 1 => WavetableType::Saw, + 2 => WavetableType::Square, + 3 => WavetableType::Triangle, + 4 => WavetableType::PWM, + 5 => WavetableType::Harmonic, + 6 => WavetableType::Inharmonic, + 7 => WavetableType::Digital, + _ => WavetableType::Sine, + } + } +} + +/// Generate a wavetable of the specified type +fn generate_wavetable(wave_type: WavetableType) -> Vec { + let mut table = vec![0.0; WAVETABLE_SIZE]; + + match wave_type { + WavetableType::Sine => { + for i in 0..WAVETABLE_SIZE { + let phase = (i as f32 / WAVETABLE_SIZE as f32) * 2.0 * PI; + table[i] = phase.sin(); + } + } + WavetableType::Saw => { + for i in 0..WAVETABLE_SIZE { + let t = i as f32 / WAVETABLE_SIZE as f32; + table[i] = 2.0 * t - 1.0; + } + } + WavetableType::Square => { + for i in 0..WAVETABLE_SIZE { + table[i] = if i < WAVETABLE_SIZE / 2 { 1.0 } else { -1.0 }; + } + } + WavetableType::Triangle => { + for i in 0..WAVETABLE_SIZE { + let t = i as f32 / WAVETABLE_SIZE as f32; + table[i] = if t < 0.5 { + 4.0 * t - 1.0 + } else { + -4.0 * t + 3.0 + }; + } + } + WavetableType::PWM => { + // Variable pulse width + for i in 0..WAVETABLE_SIZE { + let duty = 0.25; // 25% duty cycle + table[i] = if (i as f32 / WAVETABLE_SIZE as f32) < duty { 1.0 } else { -1.0 }; + } + } + WavetableType::Harmonic => { + // Multiple harmonics for rich sound + for i in 0..WAVETABLE_SIZE { + let phase = (i as f32 / WAVETABLE_SIZE as f32) * 2.0 * PI; + table[i] = phase.sin() * 0.5 + + (phase * 2.0).sin() * 0.25 + + (phase * 3.0).sin() * 0.125 + + (phase * 4.0).sin() * 0.0625; + } + } + WavetableType::Inharmonic => { + // Non-integer harmonics for metallic/bell-like sounds + for i in 0..WAVETABLE_SIZE { + let phase = (i as f32 / WAVETABLE_SIZE as f32) * 2.0 * PI; + table[i] = phase.sin() * 0.4 + + (phase * 2.13).sin() * 0.3 + + (phase * 3.76).sin() * 0.2 + + (phase * 5.41).sin() * 0.1; + } + } + WavetableType::Digital => { + // Stepped waveform with digital artifacts + for i in 0..WAVETABLE_SIZE { + let steps = 8; + let step = (i * steps / WAVETABLE_SIZE) as f32 / steps as f32; + table[i] = step * 2.0 - 1.0; + } + } + } + + table +} + +/// Wavetable oscillator node +pub struct WavetableOscillatorNode { + name: String, + + // Current wavetable + wavetable_type: WavetableType, + wavetable: Vec, + + // Oscillator state + phase: f32, + fine_tune: f32, // -1.0 to 1.0 semitones + position: f32, // 0.0 to 1.0 (for future multi-cycle wavetables) + + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl WavetableOscillatorNode { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + + let inputs = vec![ + NodePort::new("V/Oct", SignalType::CV, 0), + ]; + + let outputs = vec![ + NodePort::new("Audio Out", SignalType::Audio, 0), + ]; + + let parameters = vec![ + Parameter::new(PARAM_WAVETABLE, "Wavetable", 0.0, 7.0, 0.0, ParameterUnit::Generic), + Parameter::new(PARAM_FINE_TUNE, "Fine Tune", -1.0, 1.0, 0.0, ParameterUnit::Generic), + Parameter::new(PARAM_POSITION, "Position", 0.0, 1.0, 0.0, ParameterUnit::Generic), + ]; + + let wavetable_type = WavetableType::Sine; + let wavetable = generate_wavetable(wavetable_type); + + Self { + name, + wavetable_type, + wavetable, + phase: 0.0, + fine_tune: 0.0, + position: 0.0, + inputs, + outputs, + parameters, + } + } + + /// Convert V/oct CV to frequency with fine tune + fn voct_to_freq(&self, voct: f32) -> f32 { + let semitones = voct * 12.0 + self.fine_tune; + 440.0 * 2.0_f32.powf(semitones / 12.0) + } + + /// Read from wavetable with linear interpolation + fn read_wavetable(&self, phase: f32) -> f32 { + let index = phase * WAVETABLE_SIZE as f32; + let index_floor = index.floor() as usize % WAVETABLE_SIZE; + let index_ceil = (index_floor + 1) % WAVETABLE_SIZE; + let frac = index - index.floor(); + + // Linear interpolation + let sample1 = self.wavetable[index_floor]; + let sample2 = self.wavetable[index_ceil]; + sample1 + (sample2 - sample1) * frac + } +} + +impl AudioNode for WavetableOscillatorNode { + fn category(&self) -> NodeCategory { + NodeCategory::Generator + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, id: u32, value: f32) { + match id { + PARAM_WAVETABLE => { + let new_type = WavetableType::from_u32(value as u32); + if new_type != self.wavetable_type { + self.wavetable_type = new_type; + self.wavetable = generate_wavetable(new_type); + } + } + PARAM_FINE_TUNE => { + self.fine_tune = value.clamp(-1.0, 1.0); + } + PARAM_POSITION => { + self.position = value.clamp(0.0, 1.0); + } + _ => {} + } + } + + fn get_parameter(&self, id: u32) -> f32 { + match id { + PARAM_WAVETABLE => self.wavetable_type as u32 as f32, + PARAM_FINE_TUNE => self.fine_tune, + PARAM_POSITION => self.position, + _ => 0.0, + } + } + + fn process( + &mut self, + inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + sample_rate: u32, + ) { + if outputs.is_empty() { + return; + } + + let output = &mut outputs[0]; + let frames = output.len() / 2; + + for frame in 0..frames { + // V/Oct input: when unconnected, defaults to 0.0 (A4 440 Hz) + // CV signals are mono, so read from frame index directly + let voct = cv_input_or_default(inputs, 0, frame, 0.0); + + // Calculate frequency from V/Oct + let freq = self.voct_to_freq(voct); + + // Read from wavetable + let sample = self.read_wavetable(self.phase); + + // Advance phase + self.phase += freq / sample_rate as f32; + if self.phase >= 1.0 { + self.phase -= 1.0; + } + + // Output stereo (same signal to both channels) + output[frame * 2] = sample * 0.5; // Scale down to prevent clipping + output[frame * 2 + 1] = sample * 0.5; + } + } + + fn reset(&mut self) { + self.phase = 0.0; + } + + fn node_type(&self) -> &str { + "WavetableOscillator" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + Box::new(Self::new(self.name.clone())) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/preset.rs b/daw-backend/src/audio/node_graph/preset.rs new file mode 100644 index 0000000..ab77a14 --- /dev/null +++ b/daw-backend/src/audio/node_graph/preset.rs @@ -0,0 +1,274 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use super::nodes::LoopMode; + +/// Sample data for preset serialization +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum SampleData { + #[serde(rename = "simple_sampler")] + SimpleSampler { + #[serde(skip_serializing_if = "Option::is_none")] + file_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + embedded_data: Option, + }, + #[serde(rename = "multi_sampler")] + MultiSampler { layers: Vec }, +} + +/// Embedded sample data (base64-encoded for JSON compatibility) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EmbeddedSampleData { + /// Base64-encoded audio samples (f32 little-endian) + pub data_base64: String, + /// Original sample rate + pub sample_rate: u32, +} + +/// Layer data for MultiSampler +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LayerData { + #[serde(skip_serializing_if = "Option::is_none")] + pub file_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub embedded_data: Option, + pub key_min: u8, + pub key_max: u8, + pub root_key: u8, + pub velocity_min: u8, + pub velocity_max: u8, + #[serde(skip_serializing_if = "Option::is_none")] + pub loop_start: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub loop_end: Option, + #[serde(default = "default_loop_mode")] + pub loop_mode: LoopMode, +} + +fn default_loop_mode() -> LoopMode { + LoopMode::OneShot +} + +/// Serializable representation of a node graph preset +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GraphPreset { + /// Preset metadata + pub metadata: PresetMetadata, + + /// Nodes in the graph + pub nodes: Vec, + + /// Connections between nodes + pub connections: Vec, + + /// Which node indices are MIDI targets + pub midi_targets: Vec, + + /// Which node index is the audio output (None if not set) + pub output_node: Option, + + /// Frontend-only group definitions (backend stores opaquely, does not interpret) + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub groups: Vec, +} + +/// Metadata about the preset +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PresetMetadata { + /// Preset name + pub name: String, + + /// Description of what the preset sounds like + #[serde(default)] + pub description: String, + + /// Preset author + #[serde(default)] + pub author: String, + + /// Preset version (for compatibility) + #[serde(default = "default_version")] + pub version: u32, + + /// Tags for categorization (e.g., "bass", "lead", "pad") + #[serde(default)] + pub tags: Vec, +} + +fn default_version() -> u32 { + 1 +} + +/// Serialized keyframe for AutomationInput nodes +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SerializedKeyframe { + pub time: f64, + pub value: f32, + pub interpolation: String, + pub ease_out: (f32, f32), + pub ease_in: (f32, f32), +} + +/// Serialized node representation +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SerializedNode { + /// Unique ID (node index in the graph) + pub id: u32, + + /// Node type (e.g., "Oscillator", "Filter", "ADSR") + pub node_type: String, + + /// Parameter values (param_id -> value) + pub parameters: HashMap, + + /// UI position (for visual editor) + #[serde(default)] + pub position: (f32, f32), + + /// For VoiceAllocator nodes: the nested template graph + #[serde(skip_serializing_if = "Option::is_none")] + pub template_graph: Option>, + + /// For sampler nodes: loaded sample data + #[serde(skip_serializing_if = "Option::is_none")] + pub sample_data: Option, + + /// For Script nodes: BeamDSP source code + #[serde(skip_serializing_if = "Option::is_none")] + pub script_source: Option, + + /// For AmpSim nodes: path to the .nam model file + #[serde(skip_serializing_if = "Option::is_none")] + pub nam_model_path: Option, + + /// 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, + + /// 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, + + /// For AutomationInput nodes: user-visible display name + #[serde(skip_serializing_if = "Option::is_none")] + pub automation_display_name: Option, + + /// For AutomationInput nodes: saved keyframes + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub automation_keyframes: Vec, +} + +/// 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, + pub position: (f32, f32), + pub boundary_inputs: Vec, + pub boundary_outputs: Vec, + /// Parent group ID for nested groups (None = top-level group) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_group_id: Option, +} + +/// Serialized boundary connection for group definitions +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SerializedBoundaryConnection { + pub external_node: u32, + pub external_port: usize, + pub internal_node: u32, + pub internal_port: usize, + pub port_name: String, + /// Signal type as string ("Audio", "Midi", "CV") + pub data_type: String, +} + +/// Serialized connection between nodes +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SerializedConnection { + /// Source node ID + pub from_node: u32, + + /// Source port index + pub from_port: usize, + + /// Destination node ID + pub to_node: u32, + + /// Destination port index + pub to_port: usize, +} + +impl GraphPreset { + /// Create a new preset with the given name + pub fn new(name: impl Into) -> Self { + Self { + metadata: PresetMetadata { + name: name.into(), + description: String::new(), + author: String::new(), + version: 1, + tags: Vec::new(), + }, + nodes: Vec::new(), + connections: Vec::new(), + midi_targets: Vec::new(), + output_node: None, + groups: Vec::new(), + } + } + + /// Serialize to JSON string + pub fn to_json(&self) -> Result { + serde_json::to_string_pretty(self) + } + + /// Deserialize from JSON string + pub fn from_json(json: &str) -> Result { + serde_json::from_str(json) + } + + /// Add a node to the preset + pub fn add_node(&mut self, node: SerializedNode) { + self.nodes.push(node); + } + + /// Add a connection to the preset + pub fn add_connection(&mut self, connection: SerializedConnection) { + self.connections.push(connection); + } +} + +impl SerializedNode { + /// Create a new serialized node + pub fn new(id: u32, node_type: impl Into) -> Self { + Self { + id, + node_type: node_type.into(), + parameters: HashMap::new(), + position: (0.0, 0.0), + template_graph: None, + sample_data: None, + script_source: None, + nam_model_path: None, + num_ports: None, + port_names: Vec::new(), + automation_display_name: None, + automation_keyframes: Vec::new(), + } + } + + /// Set a parameter value + pub fn set_parameter(&mut self, param_id: u32, value: f32) { + self.parameters.insert(param_id, value); + } + + /// Set UI position + pub fn set_position(&mut self, x: f32, y: f32) { + self.position = (x, y); + } +} diff --git a/daw-backend/src/audio/node_graph/types.rs b/daw-backend/src/audio/node_graph/types.rs new file mode 100644 index 0000000..2ce63e1 --- /dev/null +++ b/daw-backend/src/audio/node_graph/types.rs @@ -0,0 +1,96 @@ +use serde::{Deserialize, Serialize}; + +/// Three distinct signal types for graph edges +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SignalType { + /// Audio-rate signals (-1.0 to 1.0 typically) - Blue in UI + Audio, + /// MIDI events (discrete messages) - Green in UI + Midi, + /// Control Voltage (modulation signals, typically 0.0 to 1.0) - Orange in UI + CV, +} + +/// Port definition for node inputs/outputs +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NodePort { + pub name: String, + pub signal_type: SignalType, + pub index: usize, +} + +impl NodePort { + pub fn new(name: impl Into, signal_type: SignalType, index: usize) -> Self { + Self { + name: name.into(), + signal_type, + index, + } + } +} + +/// Node category for UI organization +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum NodeCategory { + Input, + Generator, + Effect, + Utility, + Output, +} + +/// User-facing parameter definition +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Parameter { + pub id: u32, + pub name: String, + pub min: f32, + pub max: f32, + pub default: f32, + pub unit: ParameterUnit, +} + +impl Parameter { + pub fn new(id: u32, name: impl Into, min: f32, max: f32, default: f32, unit: ParameterUnit) -> Self { + Self { + id, + name: name.into(), + min, + max, + default, + unit, + } + } +} + +/// Units for parameter values +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ParameterUnit { + Generic, + Frequency, // Hz + Decibels, // dB + Time, // seconds + Percent, // 0-100 +} + +/// Errors that can occur during graph operations +#[derive(Debug, Clone)] +pub enum ConnectionError { + TypeMismatch { expected: SignalType, got: SignalType }, + InvalidPort, + WouldCreateCycle, +} + +impl std::fmt::Display for ConnectionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ConnectionError::TypeMismatch { expected, got } => { + write!(f, "Signal type mismatch: expected {:?}, got {:?}", expected, got) + } + ConnectionError::InvalidPort => write!(f, "Invalid port index"), + ConnectionError::WouldCreateCycle => write!(f, "Connection would create a cycle"), + } + } +} + +impl std::error::Error for ConnectionError {} diff --git a/daw-backend/src/audio/pool.rs b/daw-backend/src/audio/pool.rs new file mode 100644 index 0000000..ecf0745 --- /dev/null +++ b/daw-backend/src/audio/pool.rs @@ -0,0 +1,1168 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::f32::consts::PI; +use serde::{Deserialize, Serialize}; +use crate::time::Seconds; + +/// Windowed sinc interpolation for high-quality time stretching +/// This is stateless and can handle arbitrary fractional positions +#[inline] +fn sinc(x: f32) -> f32 { + if x.abs() < 1e-5 { + 1.0 + } else { + let px = PI * x; + px.sin() / px + } +} + +/// Blackman window function +#[inline] +fn blackman_window(x: f32, width: f32) -> f32 { + if x.abs() > width { + 0.0 + } else { + let a0 = 0.42; + let a1 = 0.5; + let a2 = 0.08; + // Map x from [-width, width] to [0, 1] for proper Blackman window evaluation + let n = (x / width + 1.0) / 2.0; + a0 - a1 * (2.0 * PI * n).cos() + a2 * (4.0 * PI * n).cos() + } +} + +/// High-quality windowed sinc interpolation +/// Uses a 32-tap windowed sinc kernel for smooth, artifact-free interpolation +/// frac: fractional position to interpolate at (0.0 to 1.0) +/// samples: array of samples centered around the target position +#[inline] +fn windowed_sinc_interpolate(samples: &[f32], frac: f32) -> f32 { + let mut result = 0.0; + let kernel_size = samples.len(); + let half_kernel = (kernel_size / 2) as f32; + + for i in 0..kernel_size { + // Distance from interpolation point + // samples[half_kernel] is at position 0, we want to interpolate at position frac + let x = frac + half_kernel - (i as f32); + let sinc_val = sinc(x); + let window_val = blackman_window(x, half_kernel); + result += samples[i] * sinc_val * window_val; + } + + result +} + +/// PCM sample format for memory-mapped audio files +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PcmSampleFormat { + I16, + I24, + F32, +} + +/// How audio data is stored for a pool entry +#[derive(Debug, Clone)] +pub enum AudioStorage { + /// Fully decoded interleaved f32 samples in memory + InMemory(Vec), + + /// Memory-mapped PCM file (WAV/AIFF) — instant load, OS-managed paging + Mapped { + mmap: Arc, + data_offset: usize, + sample_format: PcmSampleFormat, + bytes_per_sample: usize, + total_frames: u64, + }, + + /// Compressed audio — playback handled by disk reader's stream decoder. + /// `decoded_for_waveform` is progressively filled by a background thread. + Compressed { + decoded_for_waveform: Vec, + decoded_frames: u64, + total_frames: u64, + }, +} + +/// Audio file stored in the pool +#[derive(Debug, Clone)] +pub struct AudioFile { + pub path: PathBuf, + pub storage: AudioStorage, + pub channels: u32, + pub sample_rate: u32, + pub frames: u64, + /// Original file format (mp3, ogg, wav, flac, etc.) + /// Used to determine if we should preserve lossy encoding during save + pub original_format: Option, + /// Original compressed file bytes (preserved across save/load to avoid re-encoding) + pub original_bytes: Option>, +} + +impl AudioFile { + /// Create a new AudioFile with in-memory interleaved f32 data + pub fn new(path: PathBuf, data: Vec, channels: u32, sample_rate: u32) -> Self { + let frames = (data.len() / channels as usize) as u64; + Self { + path, + storage: AudioStorage::InMemory(data), + channels, + sample_rate, + frames, + original_format: None, + original_bytes: None, + } + } + + /// Create a new AudioFile with original format information + pub fn with_format(path: PathBuf, data: Vec, channels: u32, sample_rate: u32, original_format: Option) -> Self { + let frames = (data.len() / channels as usize) as u64; + Self { + path, + storage: AudioStorage::InMemory(data), + channels, + sample_rate, + frames, + original_format, + original_bytes: None, + } + } + + /// Create an AudioFile backed by a memory-mapped WAV/AIFF file + pub fn from_mmap( + path: PathBuf, + mmap: memmap2::Mmap, + data_offset: usize, + sample_format: PcmSampleFormat, + channels: u32, + sample_rate: u32, + total_frames: u64, + ) -> Self { + let bytes_per_sample = match sample_format { + PcmSampleFormat::I16 => 2, + PcmSampleFormat::I24 => 3, + PcmSampleFormat::F32 => 4, + }; + Self { + path, + storage: AudioStorage::Mapped { + mmap: Arc::new(mmap), + data_offset, + sample_format, + bytes_per_sample, + total_frames, + }, + channels, + sample_rate, + frames: total_frames, + original_format: Some("wav".to_string()), + original_bytes: None, + } + } + + /// Create a placeholder AudioFile for a compressed format (playback via disk reader) + pub fn from_compressed( + path: PathBuf, + channels: u32, + sample_rate: u32, + total_frames: u64, + original_format: Option, + ) -> Self { + Self { + path, + storage: AudioStorage::Compressed { + decoded_for_waveform: Vec::new(), + decoded_frames: 0, + total_frames, + }, + channels, + sample_rate, + frames: total_frames, + original_format, + original_bytes: None, + } + } + + /// Get interleaved f32 sample data. + /// + /// - **InMemory**: returns the full slice directly. + /// - **Mapped F32**: reinterprets the mmap'd bytes as `&[f32]` (zero-copy). + /// - **Mapped I16/I24 or Compressed**: returns an empty slice (use + /// `read_samples()` or the disk reader's `ReadAheadBuffer` instead). + pub fn data(&self) -> &[f32] { + match &self.storage { + AudioStorage::InMemory(data) => data, + AudioStorage::Mapped { + mmap, + data_offset, + sample_format, + total_frames, + .. + } if *sample_format == PcmSampleFormat::F32 => { + let byte_slice = &mmap[*data_offset..]; + let ptr = byte_slice.as_ptr(); + // Check 4-byte alignment (required for f32) + if ptr.align_offset(std::mem::align_of::()) == 0 { + let len = (*total_frames as usize) * self.channels as usize; + let available = byte_slice.len() / 4; + let safe_len = len.min(available); + // SAFETY: pointer is aligned, mmap is read-only and outlives + // this borrow, and we clamp to the available byte range. + unsafe { std::slice::from_raw_parts(ptr as *const f32, safe_len) } + } else { + &[] + } + } + _ => &[], + } + } + + /// Read samples for a specific channel into the output buffer. + /// Works for InMemory and Mapped storage. Returns the number of frames read. + pub fn read_samples( + &self, + start_frame: usize, + count: usize, + channel: usize, + out: &mut [f32], + ) -> usize { + let channels = self.channels as usize; + let total_frames = self.frames as usize; + + match &self.storage { + AudioStorage::InMemory(data) => { + let mut written = 0; + for i in 0..count.min(out.len()) { + let frame = start_frame + i; + if frame >= total_frames { break; } + let idx = frame * channels + channel; + out[i] = data[idx]; + written += 1; + } + written + } + AudioStorage::Mapped { mmap, data_offset, sample_format, bytes_per_sample, .. } => { + let mut written = 0; + for i in 0..count.min(out.len()) { + let frame = start_frame + i; + if frame >= total_frames { break; } + let sample_index = frame * channels + channel; + let byte_offset = data_offset + sample_index * bytes_per_sample; + let end = byte_offset + bytes_per_sample; + if end > mmap.len() { break; } + let bytes = &mmap[byte_offset..end]; + out[i] = match sample_format { + PcmSampleFormat::I16 => { + let val = i16::from_le_bytes([bytes[0], bytes[1]]); + val as f32 / 32768.0 + } + PcmSampleFormat::I24 => { + // Sign-extend 24-bit to 32-bit + let val = ((bytes[0] as i32) + | ((bytes[1] as i32) << 8) + | ((bytes[2] as i32) << 16)) + << 8 + >> 8; + val as f32 / 8388608.0 + } + PcmSampleFormat::F32 => { + f32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) + } + }; + written += 1; + } + written + } + AudioStorage::Compressed { .. } => { + // Compressed files are read through the disk reader + 0 + } + } + } + + /// Get duration in seconds + pub fn duration_seconds(&self) -> f64 { + self.frames as f64 / self.sample_rate as f64 + } + + /// 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 { + 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 { + 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 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 mut min = f32::MAX; + let mut max = f32::MIN; + + // Scan all samples in this window + let data = self.data(); + for frame_idx in peak_start..peak_end { + // 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; + if sample_idx < data.len() { + let sample = data[sample_idx]; + min = min.min(sample); + max = max.max(sample); + } + } + } + + // If no samples were found, clamp to safe defaults + if min == f32::MAX { + min = 0.0; + } + if max == f32::MIN { + max = 0.0; + } + + peaks.push(crate::io::WaveformPeak { min, max }); + } + + peaks + } +} + +/// Pool of shared audio files (audio clip content) +pub struct AudioClipPool { + files: Vec, + /// Waveform chunk cache for multi-resolution waveform generation + waveform_cache: crate::audio::waveform_cache::WaveformCache, +} + +/// Type alias for backwards compatibility +pub type AudioPool = AudioClipPool; + +impl AudioClipPool { + /// Create a new empty audio clip pool + pub fn new() -> Self { + Self { + files: Vec::new(), + waveform_cache: crate::audio::waveform_cache::WaveformCache::new(100), // 100MB cache + } + } + + /// Get the number of files in the pool + pub fn len(&self) -> usize { + self.files.len() + } + + /// Check if the pool is empty + pub fn is_empty(&self) -> bool { + self.files.is_empty() + } + + /// Get file info for waveform generation (duration, sample_rate, channels) + pub fn get_file_info(&self, pool_index: usize) -> Option<(f64, u32, u32)> { + self.files.get(pool_index).map(|file| { + (file.duration_seconds(), file.sample_rate, file.channels) + }) + } + + /// Generate waveform overview for a file in the pool + pub fn generate_waveform(&self, pool_index: usize, target_peaks: usize) -> Option> { + self.files.get(pool_index).map(|file| { + file.generate_waveform_overview(target_peaks) + }) + } + + /// Generate waveform overview for a specific range of a file in the pool + /// + /// # Arguments + /// * `pool_index` - Index of the file in the pool + /// * `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_range( + &self, + pool_index: usize, + start_frame: usize, + end_frame: usize, + target_peaks: usize, + ) -> Option> { + self.files.get(pool_index).map(|file| { + file.generate_waveform_overview_range(start_frame, end_frame, target_peaks) + }) + } + + /// Add an audio file to the pool and return its index + pub fn add_file(&mut self, file: AudioFile) -> usize { + let index = self.files.len(); + self.files.push(file); + index + } + + /// Get an audio file by index + pub fn get_file(&self, index: usize) -> Option<&AudioFile> { + self.files.get(index) + } + + /// Get a mutable reference to an audio file by index + pub fn get_file_mut(&mut self, index: usize) -> Option<&mut AudioFile> { + self.files.get_mut(index) + } + + /// Get number of files in the pool + pub fn file_count(&self) -> usize { + self.files.len() + } + + /// Render audio from a file in the pool with high-quality windowed sinc interpolation + /// start_time: position in the audio file to start reading from (in seconds) + /// clip_read_ahead: per-clip-instance read-ahead buffer for compressed audio streaming + /// Returns the number of samples actually rendered + pub fn render_from_file( + &self, + pool_index: usize, + output: &mut [f32], + start_time: Seconds, + gain: f32, + engine_sample_rate: u32, + engine_channels: u32, + clip_read_ahead: Option<&super::disk_reader::ReadAheadBuffer>, + ) -> usize { + let start_time_seconds = start_time.0; + let Some(audio_file) = self.files.get(pool_index) else { + return 0; + }; + + let audio_data = audio_file.data(); + let read_ahead = clip_read_ahead; + let use_read_ahead = audio_data.is_empty(); + let src_channels = audio_file.channels as usize; + + // Nothing to render: no data and no read-ahead buffer + if use_read_ahead && read_ahead.is_none() { + // Log once per pool_index to diagnose silent clips + static LOGGED: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(u64::MAX); + let prev = LOGGED.swap(pool_index as u64, std::sync::atomic::Ordering::Relaxed); + if prev != pool_index as u64 { + eprintln!("[RENDER] pool={}: data empty, no read_ahead! storage={:?}, frames={}", + pool_index, std::mem::discriminant(&audio_file.storage), audio_file.frames); + } + return 0; + } + + // In export mode, block-wait until the disk reader has filled the + // frames we need, so offline rendering never gets buffer misses. + if use_read_ahead { + let ra = read_ahead.unwrap(); + if ra.is_export_mode() { + let src_start = (start_time_seconds * audio_file.sample_rate as f64) as u64; + // Tell the disk reader where we need data BEFORE waiting + ra.set_target_frame(src_start); + // Pad by 64 frames for sinc interpolation taps + let frames_needed = (output.len() / engine_channels as usize) as u64 + 64; + // Spin-wait with small sleeps until the disk reader fills the buffer + let mut wait_iters = 0u64; + while !ra.has_range(src_start, frames_needed) { + std::thread::sleep(std::time::Duration::from_micros(100)); + wait_iters += 1; + if wait_iters > 100_000 { + // Safety valve: 10 seconds of waiting + eprintln!("[EXPORT] Timed out waiting for disk reader (need frames {}..{})", + src_start, src_start + frames_needed); + break; + } + } + } + } + + // Snapshot the read-ahead buffer range once for the entire render call. + // This ensures all sinc interpolation taps within a single callback + // see a consistent range, preventing crackle from concurrent updates. + let (ra_start, ra_end) = if use_read_ahead { + read_ahead.unwrap().snapshot() + } else { + (0, 0) + }; + + // Buffer-miss counter: how many times we wanted a sample the ring + // buffer didn't have (frame in file range but outside buffer range). + let mut buffer_misses: u32 = 0; + + // Read a single interleaved sample by (frame, channel). + // Uses direct slice access for InMemory/Mapped, or the disk reader's + // ReadAheadBuffer for compressed files. + macro_rules! get_sample { + ($frame:expr, $ch:expr) => {{ + if use_read_ahead { + let f = $frame as u64; + let s = read_ahead.unwrap().read_sample(f, $ch, ra_start, ra_end); + if s == 0.0 && (f < ra_start || f >= ra_end) { + buffer_misses += 1; + } + s + } else { + let idx = ($frame) * src_channels + ($ch); + if idx < audio_data.len() { audio_data[idx] } else { 0.0 } + } + }}; + } + let dst_channels = engine_channels as usize; + let output_frames = output.len() / dst_channels; + + let src_start_position = start_time_seconds * audio_file.sample_rate as f64; + + // Tell the disk reader where we're reading so it buffers the right region. + if use_read_ahead { + read_ahead.unwrap().set_target_frame(src_start_position as u64); + } + + let mut rendered_frames = 0; + + if audio_file.sample_rate == engine_sample_rate { + // Fast path: matching sample rates — direct sample copy, no interpolation + let src_start_frame = src_start_position.floor() as i64; + + // Continuity check: detect gaps/overlaps between consecutive callbacks (DAW_AUDIO_DEBUG=1) + if std::env::var("DAW_AUDIO_DEBUG").is_ok() { + use std::sync::atomic::{AtomicI64, Ordering as AO}; + static EXPECTED_NEXT: AtomicI64 = AtomicI64::new(-1); + static DISCONTINUITIES: AtomicI64 = AtomicI64::new(0); + let expected = EXPECTED_NEXT.load(AO::Relaxed); + if expected >= 0 && src_start_frame != expected { + let count = DISCONTINUITIES.fetch_add(1, AO::Relaxed) + 1; + eprintln!("[RENDER CONTINUITY] DISCONTINUITY #{}: expected frame {}, got {} (delta={})", + count, expected, src_start_frame, src_start_frame - expected); + } + EXPECTED_NEXT.store(src_start_frame + output_frames as i64, AO::Relaxed); + } + + for output_frame in 0..output_frames { + let src_frame = src_start_frame + output_frame as i64; + if src_frame < 0 || src_frame as u64 >= audio_file.frames { + break; + } + let sf = src_frame as usize; + + for dst_ch in 0..dst_channels { + let sample = if src_channels == dst_channels { + get_sample!(sf, dst_ch) + } else if src_channels == 1 { + get_sample!(sf, 0) + } else if dst_channels == 1 { + let mut sum = 0.0f32; + for src_ch in 0..src_channels { + sum += get_sample!(sf, src_ch); + } + sum / src_channels as f32 + } else { + get_sample!(sf, dst_ch % src_channels) + }; + + output[output_frame * dst_channels + dst_ch] += sample * gain; + } + + rendered_frames += 1; + } + } else { + // Sample rate conversion with windowed sinc interpolation + let rate_ratio = audio_file.sample_rate as f64 / engine_sample_rate as f64; + const KERNEL_SIZE: usize = 32; + const HALF_KERNEL: usize = KERNEL_SIZE / 2; + + for output_frame in 0..output_frames { + let src_position = src_start_position + (output_frame as f64 * rate_ratio); + let src_frame = src_position.floor() as i32; + let frac = (src_position - src_frame as f64) as f32; + + if src_frame < 0 || src_frame as usize >= audio_file.frames as usize { + break; + } + + for dst_ch in 0..dst_channels { + let src_ch = if src_channels == dst_channels { + dst_ch + } else if src_channels == 1 { + 0 + } else if dst_channels == 1 { + usize::MAX // sentinel: average all channels below + } else { + dst_ch % src_channels + }; + + let sample = if src_ch == usize::MAX { + let mut sum = 0.0; + for ch in 0..src_channels { + let mut channel_samples = [0.0f32; KERNEL_SIZE]; + for (j, i) in (-(HALF_KERNEL as i32)..(HALF_KERNEL as i32)).enumerate() { + let idx = src_frame + i; + if idx >= 0 && (idx as usize) < audio_file.frames as usize { + channel_samples[j] = get_sample!(idx as usize, ch); + } + } + sum += windowed_sinc_interpolate(&channel_samples, frac); + } + sum / src_channels as f32 + } else { + let mut channel_samples = [0.0f32; KERNEL_SIZE]; + for (j, i) in (-(HALF_KERNEL as i32)..(HALF_KERNEL as i32)).enumerate() { + let idx = src_frame + i; + if idx >= 0 && (idx as usize) < audio_file.frames as usize { + channel_samples[j] = get_sample!(idx as usize, src_ch); + } + } + windowed_sinc_interpolate(&channel_samples, frac) + }; + + output[output_frame * dst_channels + dst_ch] += sample * gain; + } + + rendered_frames += 1; + } + } + + if use_read_ahead && buffer_misses > 0 { + static MISS_COUNT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let total = MISS_COUNT.fetch_add(buffer_misses as u64, std::sync::atomic::Ordering::Relaxed) + buffer_misses as u64; + // Log every 100 misses to avoid flooding + if total % 100 < buffer_misses as u64 { + eprintln!("[RENDER] buffer misses this call: {}, total: {}, snap=[{}..{}], src_start_frame={}", + buffer_misses, total, ra_start, ra_end, + (start_time_seconds * audio_file.sample_rate as f64) as u64); + } + } + + rendered_frames * dst_channels + } + + /// Generate waveform chunks for a file in the pool + /// + /// This generates chunks at a specific detail level and caches them. + /// Returns the generated chunks. + pub fn generate_waveform_chunks( + &mut self, + pool_index: usize, + detail_level: u8, + chunk_indices: &[u32], + ) -> Vec { + let file = match self.files.get(pool_index) { + Some(f) => f, + None => return Vec::new(), + }; + + let chunks = crate::audio::waveform_cache::WaveformCache::generate_chunks( + file, + pool_index, + detail_level, + chunk_indices, + ); + + // Store chunks in cache + for chunk in &chunks { + let key = crate::io::WaveformChunkKey { + pool_index, + detail_level: chunk.detail_level, + chunk_index: chunk.chunk_index, + }; + self.waveform_cache.store_chunk(key, chunk.peaks.clone()); + } + + chunks + } + + /// Generate Level 0 (overview) chunks for a file + /// + /// This should be called immediately when a file is imported. + /// Returns the generated chunks. + pub fn generate_overview_chunks( + &mut self, + pool_index: usize, + ) -> Vec { + let file = match self.files.get(pool_index) { + Some(f) => f, + None => return Vec::new(), + }; + + self.waveform_cache.generate_overview_chunks(file, pool_index) + } + + /// Get a cached waveform chunk + pub fn get_waveform_chunk( + &self, + pool_index: usize, + detail_level: u8, + chunk_index: u32, + ) -> Option<&Vec> { + let key = crate::io::WaveformChunkKey { + pool_index, + detail_level, + chunk_index, + }; + self.waveform_cache.get_chunk(&key) + } + + /// Check if a waveform chunk is cached + pub fn has_waveform_chunk( + &self, + pool_index: usize, + detail_level: u8, + chunk_index: u32, + ) -> bool { + let key = crate::io::WaveformChunkKey { + pool_index, + detail_level, + chunk_index, + }; + self.waveform_cache.has_chunk(&key) + } + + /// Get waveform cache memory usage in MB + pub fn waveform_cache_memory_mb(&self) -> f64 { + self.waveform_cache.memory_usage_mb() + } + + /// Get number of cached waveform chunks + pub fn waveform_chunk_count(&self) -> usize { + self.waveform_cache.chunk_count() + } +} + +impl Default for AudioClipPool { + fn default() -> Self { + Self::new() + } +} + +/// Embedded audio data stored as base64 in the project file +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EmbeddedAudioData { + /// Base64-encoded audio data + pub data_base64: String, + /// Original file format (wav, mp3, etc.) + pub format: String, +} + +/// Serializable audio pool entry for project save/load +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AudioPoolEntry { + /// Index in the audio pool + pub pool_index: usize, + /// Original filename + pub name: String, + /// Path relative to project file (None if embedded) + pub relative_path: Option, + /// Duration in seconds + pub duration: f64, + /// Sample rate + pub sample_rate: u32, + /// Number of channels + pub channels: u32, + /// Embedded audio data (for files < 10MB) + pub embedded_data: Option, +} + +impl AudioClipPool { + /// Serialize the audio clip pool for project saving + /// + /// Files smaller than 10MB are embedded as base64. + /// Larger files are stored as relative paths to the project file. + pub fn serialize(&self, project_path: &Path) -> Result, String> { + let project_dir = project_path.parent() + .ok_or_else(|| "Project path has no parent directory".to_string())?; + + let mut entries = Vec::new(); + + for (index, file) in self.files.iter().enumerate() { + let file_path = &file.path; + let file_path_str = file_path.to_string_lossy(); + + // Check if this is a temp file (from recording) or previously embedded audio + // Always embed these + let is_temp_file = file_path.starts_with(std::env::temp_dir()); + let is_embedded = file_path_str.starts_with(" bool { + const TEN_MB: u64 = 10_000_000; + + std::fs::metadata(file_path) + .map(|m| m.len() < TEN_MB) + .unwrap_or(false) + } + + /// Embed audio from memory (already loaded in the pool) + fn embed_from_memory(audio_file: &AudioFile) -> EmbeddedAudioData { + use base64::{Engine as _, engine::general_purpose}; + + // Check if this is a lossy format that should be preserved + let is_lossy = audio_file.original_format.as_ref().map_or(false, |fmt| { + let fmt_lower = fmt.to_lowercase(); + fmt_lower == "mp3" || fmt_lower == "ogg" || fmt_lower == "aac" + || fmt_lower == "m4a" || fmt_lower == "opus" + }); + + // Check for preserved original bytes first (from previous load cycle) + if let Some(ref original_bytes) = audio_file.original_bytes { + let data_base64 = general_purpose::STANDARD.encode(original_bytes); + return EmbeddedAudioData { + data_base64, + format: audio_file.original_format.clone().unwrap_or_else(|| "wav".to_string()), + }; + } + + if is_lossy { + // For lossy formats, read the original file bytes (if it still exists) + if let Ok(original_bytes) = std::fs::read(&audio_file.path) { + let data_base64 = general_purpose::STANDARD.encode(&original_bytes); + return EmbeddedAudioData { + data_base64, + format: audio_file.original_format.clone().unwrap_or_else(|| "mp3".to_string()), + }; + } + // If we can't read the original file, fall through to WAV conversion + } + + // For lossless/PCM or if we couldn't read the original lossy file, + // convert the f32 interleaved samples to WAV format bytes + let wav_data = Self::encode_wav( + audio_file.data(), + audio_file.channels, + audio_file.sample_rate + ); + + let data_base64 = general_purpose::STANDARD.encode(&wav_data); + + EmbeddedAudioData { + data_base64, + format: "wav".to_string(), + } + } + + /// Encode f32 interleaved samples as WAV file bytes + fn encode_wav(samples: &[f32], channels: u32, sample_rate: u32) -> Vec { + let num_samples = samples.len(); + let bytes_per_sample = 4; // 32-bit float + let data_size = num_samples * bytes_per_sample; + let file_size = 36 + data_size; + + let mut wav_data = Vec::with_capacity(44 + data_size); + + // RIFF header + wav_data.extend_from_slice(b"RIFF"); + wav_data.extend_from_slice(&(file_size as u32).to_le_bytes()); + wav_data.extend_from_slice(b"WAVE"); + + // fmt chunk + wav_data.extend_from_slice(b"fmt "); + wav_data.extend_from_slice(&16u32.to_le_bytes()); // chunk size + wav_data.extend_from_slice(&3u16.to_le_bytes()); // format code (3 = IEEE float) + wav_data.extend_from_slice(&(channels as u16).to_le_bytes()); + wav_data.extend_from_slice(&sample_rate.to_le_bytes()); + wav_data.extend_from_slice(&(sample_rate * channels * bytes_per_sample as u32).to_le_bytes()); // byte rate + wav_data.extend_from_slice(&((channels * bytes_per_sample as u32) as u16).to_le_bytes()); // block align + wav_data.extend_from_slice(&32u16.to_le_bytes()); // bits per sample + + // data chunk + wav_data.extend_from_slice(b"data"); + wav_data.extend_from_slice(&(data_size as u32).to_le_bytes()); + + // Write samples as little-endian f32 + for &sample in samples { + wav_data.extend_from_slice(&sample.to_le_bytes()); + } + + wav_data + } + + /// Load audio pool from serialized entries + /// + /// Returns a list of pool indices that failed to load (missing files). + /// The caller should present these to the user for resolution. + pub fn load_from_serialized( + &mut self, + entries: Vec, + project_path: &Path, + ) -> Result, String> { + let fn_start = std::time::Instant::now(); + eprintln!("📊 [LOAD_SERIALIZED] Starting load_from_serialized with {} entries...", entries.len()); + + let project_dir = project_path.parent() + .ok_or_else(|| "Project path has no parent directory".to_string())?; + + let mut missing_indices = Vec::new(); + + // Clear existing pool + let clear_start = std::time::Instant::now(); + self.files.clear(); + eprintln!("📊 [LOAD_SERIALIZED] Clear pool took {:.2}ms", clear_start.elapsed().as_secs_f64() * 1000.0); + + // Find the maximum pool index to determine required size + let max_index = entries.iter() + .map(|e| e.pool_index) + .max() + .unwrap_or(0); + + // Ensure we have space for all entries + let resize_start = std::time::Instant::now(); + self.files.resize(max_index + 1, AudioFile::new(PathBuf::new(), Vec::new(), 2, 44100)); + eprintln!("📊 [LOAD_SERIALIZED] Resize pool to {} took {:.2}ms", max_index + 1, resize_start.elapsed().as_secs_f64() * 1000.0); + + for (i, entry) in entries.iter().enumerate() { + let entry_start = std::time::Instant::now(); + eprintln!("📊 [LOAD_SERIALIZED] Processing entry {}/{}: '{}'", i + 1, entries.len(), entry.name); + + let success = if let Some(ref embedded) = entry.embedded_data { + // Load from embedded data + eprintln!("📊 [LOAD_SERIALIZED] Entry has embedded data (format: {})", embedded.format); + match Self::load_from_embedded_into_pool(self, entry.pool_index, embedded.clone(), &entry.name) { + Ok(_) => { + eprintln!("[AudioPool] Successfully loaded embedded audio: {}", entry.name); + true + } + Err(e) => { + eprintln!("[AudioPool] Failed to load embedded audio {}: {}", entry.name, e); + false + } + } + } else if let Some(ref rel_path) = entry.relative_path { + // Load from file path + eprintln!("📊 [LOAD_SERIALIZED] Entry has file path: {:?}", rel_path); + let full_path = project_dir.join(&rel_path); + + if full_path.exists() { + Self::load_file_into_pool(self, entry.pool_index, &full_path).is_ok() + } else { + eprintln!("[AudioPool] File not found: {:?}", full_path); + false + } + } else { + eprintln!("[AudioPool] Entry has neither embedded data nor path: {}", entry.name); + false + }; + + if !success { + missing_indices.push(entry.pool_index); + } + + eprintln!("📊 [LOAD_SERIALIZED] Entry {} took {:.2}ms (success: {})", i + 1, entry_start.elapsed().as_secs_f64() * 1000.0, success); + } + + eprintln!("📊 [LOAD_SERIALIZED] ✅ Total load_from_serialized time: {:.2}ms", fn_start.elapsed().as_secs_f64() * 1000.0); + + Ok(missing_indices) + } + + /// Load audio from embedded base64 data + fn load_from_embedded_into_pool( + &mut self, + pool_index: usize, + embedded: EmbeddedAudioData, + name: &str, + ) -> Result<(), String> { + use base64::{Engine as _, engine::general_purpose}; + + let fn_start = std::time::Instant::now(); + eprintln!("📊 [POOL] Loading embedded audio '{}'...", name); + + // Decode base64 + let step1_start = std::time::Instant::now(); + let data = general_purpose::STANDARD + .decode(&embedded.data_base64) + .map_err(|e| format!("Failed to decode base64: {}", e))?; + eprintln!("📊 [POOL] Step 1: Decode base64 ({} bytes) took {:.2}ms", data.len(), step1_start.elapsed().as_secs_f64() * 1000.0); + + // Write to temporary file for symphonia to decode + let step2_start = std::time::Instant::now(); + let temp_dir = std::env::temp_dir(); + let temp_path = temp_dir.join(format!("lightningbeam_embedded_{}.{}", pool_index, embedded.format)); + + std::fs::write(&temp_path, &data) + .map_err(|e| format!("Failed to write temporary file: {}", e))?; + eprintln!("📊 [POOL] Step 2: Write temp file took {:.2}ms", step2_start.elapsed().as_secs_f64() * 1000.0); + + // Load the temporary file using existing infrastructure + let step3_start = std::time::Instant::now(); + let result = Self::load_file_into_pool(self, pool_index, &temp_path); + eprintln!("📊 [POOL] Step 3: Decode audio with Symphonia took {:.2}ms", step3_start.elapsed().as_secs_f64() * 1000.0); + + // Clean up temporary file + let _ = std::fs::remove_file(&temp_path); + + // Update the path to reflect it was embedded, and preserve original bytes + if result.is_ok() && pool_index < self.files.len() { + self.files[pool_index].path = PathBuf::from(format!("", name)); + // Preserve the original compressed/encoded bytes so re-save doesn't need to re-encode + self.files[pool_index].original_bytes = Some(data); + self.files[pool_index].original_format = Some(embedded.format.clone()); + } + + eprintln!("📊 [POOL] ✅ Total load_from_embedded time: {:.2}ms", fn_start.elapsed().as_secs_f64() * 1000.0); + + result + } + + /// Load an audio file into a specific pool index + fn load_file_into_pool(&mut self, pool_index: usize, file_path: &Path) -> Result<(), String> { + use symphonia::core::audio::SampleBuffer; + use symphonia::core::codecs::{DecoderOptions, CODEC_TYPE_NULL}; + use symphonia::core::formats::FormatOptions; + use symphonia::core::io::MediaSourceStream; + use symphonia::core::meta::MetadataOptions; + use symphonia::core::probe::Hint; + + let file = std::fs::File::open(file_path) + .map_err(|e| format!("Failed to open audio file: {}", e))?; + + let mss = MediaSourceStream::new(Box::new(file), Default::default()); + + let mut hint = Hint::new(); + if let Some(ext) = file_path.extension() { + hint.with_extension(&ext.to_string_lossy()); + } + + let format_opts = FormatOptions::default(); + let metadata_opts = MetadataOptions::default(); + let decoder_opts = DecoderOptions::default(); + + let probed = symphonia::default::get_probe() + .format(&hint, mss, &format_opts, &metadata_opts) + .map_err(|e| format!("Failed to probe audio file: {}", e))?; + + let mut format = probed.format; + let track = format + .tracks() + .iter() + .find(|t| t.codec_params.codec != CODEC_TYPE_NULL) + .ok_or_else(|| "No audio track found".to_string())?; + + let mut decoder = symphonia::default::get_codecs() + .make(&track.codec_params, &decoder_opts) + .map_err(|e| format!("Failed to create decoder: {}", e))?; + + let track_id = track.id; + let sample_rate = track.codec_params.sample_rate.unwrap_or(44100); + let channels = track.codec_params.channels.map(|c| c.count()).unwrap_or(2) as u32; + + let mut samples = Vec::new(); + let mut sample_buf = None; + + loop { + let packet = match format.next_packet() { + Ok(packet) => packet, + 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::::new(duration, spec)); + } + + if let Some(ref mut buf) = sample_buf { + buf.copy_interleaved_ref(decoded); + samples.extend_from_slice(buf.samples()); + } + } + Err(_) => continue, + } + } + + // Detect original format from file extension + let original_format = file_path.extension() + .and_then(|ext| ext.to_str()) + .map(|s| s.to_lowercase()); + + let audio_file = AudioFile::with_format( + file_path.to_path_buf(), + samples, + channels, + sample_rate, + original_format, + ); + + if pool_index >= self.files.len() { + return Err(format!("Pool index {} out of bounds", pool_index)); + } + + self.files[pool_index] = audio_file; + Ok(()) + } + + /// Resolve a missing audio file by loading from a new path + /// This is called from the UI when the user manually locates a missing file + pub fn resolve_missing_file(&mut self, pool_index: usize, new_path: &Path) -> Result<(), String> { + Self::load_file_into_pool(self, pool_index, new_path) + } +} diff --git a/daw-backend/src/audio/project.rs b/daw-backend/src/audio/project.rs new file mode 100644 index 0000000..d301445 --- /dev/null +++ b/daw-backend/src/audio/project.rs @@ -0,0 +1,706 @@ +use super::buffer_pool::BufferPool; +use super::clip::{AudioClipInstanceId, Clip}; +use super::midi::{MidiClip, MidiClipId, MidiClipInstance, MidiClipInstanceId, MidiEvent}; +use super::midi_pool::MidiClipPool; +use super::pool::AudioClipPool; +use super::track::{AudioTrack, Metatrack, MidiTrack, RenderContext, TrackId, TrackNode}; +use crate::tempo_map::TempoMap; +use crate::time::{Beats, Seconds}; +use serde::{Serialize, Deserialize}; +use std::collections::HashMap; + +/// Project manages the hierarchical track structure and clip pools +/// +/// Tracks are stored in a flat HashMap but can be organized into groups, +/// forming a tree structure. Groups render their children recursively. +/// +/// Clip content is stored in pools (MidiClipPool), while tracks store +/// clip instances that reference the pool content. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Project { + tracks: HashMap, + next_track_id: TrackId, + root_tracks: Vec, // Top-level tracks (not in any group) + sample_rate: u32, // System sample rate + /// Pool for MIDI clip content + pub midi_clip_pool: MidiClipPool, + /// Next MIDI clip instance ID (for generating unique IDs) + next_midi_clip_instance_id: MidiClipInstanceId, +} + +impl Project { + /// Create a new empty project + pub fn new(sample_rate: u32) -> Self { + Self { + tracks: HashMap::new(), + next_track_id: 0, + root_tracks: Vec::new(), + sample_rate, + midi_clip_pool: MidiClipPool::new(), + next_midi_clip_instance_id: 1, + } + } + + /// Generate a new unique track ID + fn next_id(&mut self) -> TrackId { + let id = self.next_track_id; + self.next_track_id += 1; + id + } + + /// Add an audio track to the project + /// + /// # Arguments + /// * `name` - Track name + /// * `parent_id` - Optional parent group ID + /// + /// # Returns + /// The new track's ID + pub fn add_audio_track(&mut self, name: String, parent_id: Option) -> TrackId { + let id = self.next_id(); + let track = AudioTrack::new(id, name, self.sample_rate); + self.tracks.insert(id, TrackNode::Audio(track)); + + if let Some(parent) = parent_id { + // Add to parent group + if let Some(TrackNode::Group(group)) = self.tracks.get_mut(&parent) { + group.add_child(id); + } + } else { + // Add to root level + self.root_tracks.push(id); + } + + id + } + + /// Add a group track to the project + /// + /// # Arguments + /// * `name` - Group name + /// * `parent_id` - Optional parent group ID + /// + /// # Returns + /// The new group's ID + pub fn add_group_track(&mut self, name: String, parent_id: Option) -> TrackId { + let id = self.next_id(); + let group = Metatrack::new(id, name, self.sample_rate); + self.tracks.insert(id, TrackNode::Group(group)); + + if let Some(parent) = parent_id { + // Add to parent group + if let Some(TrackNode::Group(parent_group)) = self.tracks.get_mut(&parent) { + parent_group.add_child(id); + } + } else { + // Add to root level + self.root_tracks.push(id); + } + + id + } + + /// Add a MIDI track to the project + /// + /// # Arguments + /// * `name` - Track name + /// * `parent_id` - Optional parent group ID + /// + /// # Returns + /// The new track's ID + pub fn add_midi_track(&mut self, name: String, parent_id: Option) -> TrackId { + let id = self.next_id(); + let track = MidiTrack::new(id, name, self.sample_rate); + self.tracks.insert(id, TrackNode::Midi(track)); + + if let Some(parent) = parent_id { + // Add to parent group + if let Some(TrackNode::Group(group)) = self.tracks.get_mut(&parent) { + group.add_child(id); + } + } else { + // Add to root level + self.root_tracks.push(id); + } + + id + } + + /// Remove a track from the project + /// + /// If the track is a group, all children are moved to the parent (or root) + pub fn remove_track(&mut self, track_id: TrackId) { + if let Some(node) = self.tracks.remove(&track_id) { + // If it's a group, handle its children + if let TrackNode::Group(group) = node { + // Find the parent of this group + let parent_id = self.find_parent(track_id); + + // Move children to parent or root + for child_id in group.children { + if let Some(parent) = parent_id { + if let Some(TrackNode::Group(parent_group)) = self.tracks.get_mut(&parent) { + parent_group.add_child(child_id); + } + } else { + self.root_tracks.push(child_id); + } + } + } + + // Remove from parent or root + if let Some(parent_id) = self.find_parent(track_id) { + if let Some(TrackNode::Group(parent)) = self.tracks.get_mut(&parent_id) { + parent.remove_child(track_id); + } + } else { + self.root_tracks.retain(|&id| id != track_id); + } + } + } + + /// Find the parent group of a track + fn find_parent(&self, track_id: TrackId) -> Option { + for (id, node) in &self.tracks { + if let TrackNode::Group(group) = node { + if group.children.contains(&track_id) { + return Some(*id); + } + } + } + None + } + + /// Move a track to a different group + pub fn move_to_group(&mut self, track_id: TrackId, new_parent_id: TrackId) { + // First remove from current parent + if let Some(old_parent_id) = self.find_parent(track_id) { + if let Some(TrackNode::Group(parent)) = self.tracks.get_mut(&old_parent_id) { + parent.remove_child(track_id); + } + } else { + // Remove from root + self.root_tracks.retain(|&id| id != track_id); + } + + // Add to new parent + if let Some(TrackNode::Group(new_parent)) = self.tracks.get_mut(&new_parent_id) { + new_parent.add_child(track_id); + } + } + + /// Move a track to the root level (remove from any group) + pub fn move_to_root(&mut self, track_id: TrackId) { + // Remove from current parent if any + if let Some(parent_id) = self.find_parent(track_id) { + if let Some(TrackNode::Group(parent)) = self.tracks.get_mut(&parent_id) { + parent.remove_child(track_id); + } + // Add to root if not already there + if !self.root_tracks.contains(&track_id) { + self.root_tracks.push(track_id); + } + } + } + + /// Get a reference to a track node + pub fn get_track(&self, track_id: TrackId) -> Option<&TrackNode> { + self.tracks.get(&track_id) + } + + /// Get a mutable reference to a track node + pub fn get_track_mut(&mut self, track_id: TrackId) -> Option<&mut TrackNode> { + self.tracks.get_mut(&track_id) + } + + /// Iterate over all tracks in the project. + pub fn track_iter(&self) -> impl Iterator { + 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, Vec)> { + if let Some(TrackNode::Midi(track)) = self.tracks.get(&track_id) { + let graph = &track.instrument_graph; + let node_idx = petgraph::stable_graph::NodeIndex::new(node_id as usize); + + // Get audio data + let audio = graph.get_oscilloscope_data(node_idx, sample_count)?; + + // Get CV data (may be empty if no CV input or not an oscilloscope node) + let cv = graph.get_oscilloscope_cv_data(node_idx, sample_count).unwrap_or_default(); + + return Some((audio, cv)); + } + None + } + + /// Get oscilloscope data from a node inside a VoiceAllocator's best voice + pub fn get_voice_oscilloscope_data(&self, track_id: TrackId, va_node_id: u32, inner_node_id: u32, sample_count: usize) -> Option<(Vec, Vec)> { + 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::()?; + return va.get_voice_oscilloscope_data(inner_node_id, sample_count); + } + None + } + + /// Get all root-level track IDs + pub fn root_tracks(&self) -> &[TrackId] { + &self.root_tracks + } + + /// Get the number of tracks in the project + pub fn track_count(&self) -> usize { + self.tracks.len() + } + + /// Check if any track is soloed + pub fn any_solo(&self) -> bool { + self.tracks.values().any(|node| node.is_solo()) + } + + /// Add a clip to an audio track + pub fn add_clip(&mut self, track_id: TrackId, clip: Clip) -> Result { + if let Some(TrackNode::Audio(track)) = self.tracks.get_mut(&track_id) { + let instance_id = clip.id; + track.add_clip(clip); + Ok(instance_id) + } else { + Err("Track not found or is not an audio track") + } + } + + /// Add a MIDI clip instance to a MIDI track + /// The clip content should already exist in the midi_clip_pool + pub fn add_midi_clip_instance(&mut self, track_id: TrackId, instance: MidiClipInstance) -> Result<(), &'static str> { + if let Some(TrackNode::Midi(track)) = self.tracks.get_mut(&track_id) { + track.add_clip_instance(instance); + Ok(()) + } else { + Err("Track not found or is not a MIDI track") + } + } + + /// Create a new MIDI clip in the pool and add an instance to a track + /// Returns (clip_id, instance_id) on success + pub fn create_midi_clip_with_instance( + &mut self, + track_id: TrackId, + events: Vec, + duration: Beats, + name: String, + external_start: Beats, + ) -> Result<(MidiClipId, MidiClipInstanceId), &'static str> { + // Verify track exists and is a MIDI track + if !matches!(self.tracks.get(&track_id), Some(TrackNode::Midi(_))) { + return Err("Track not found or is not a MIDI track"); + } + + // Create clip in pool + let clip_id = self.midi_clip_pool.add_clip(events, duration, name); + + // Create instance + let instance_id = self.next_midi_clip_instance_id; + self.next_midi_clip_instance_id += 1; + + let instance = MidiClipInstance::from_full_clip(instance_id, clip_id, duration, external_start); + + // Add instance to track + if let Some(TrackNode::Midi(track)) = self.tracks.get_mut(&track_id) { + track.add_clip_instance(instance); + } + + Ok((clip_id, instance_id)) + } + + /// Generate a new unique MIDI clip instance ID + pub fn next_midi_clip_instance_id(&mut self) -> MidiClipInstanceId { + let id = self.next_midi_clip_instance_id; + self.next_midi_clip_instance_id += 1; + id + } + + /// Legacy method for backwards compatibility - creates clip and instance from old MidiClip format + pub fn add_midi_clip(&mut self, track_id: TrackId, clip: MidiClip) -> Result { + self.add_midi_clip_at(track_id, clip, Beats::ZERO) + } + + /// Add a MIDI clip to the pool and create an instance at the given timeline position + pub fn add_midi_clip_at(&mut self, track_id: TrackId, clip: MidiClip, start_time: Beats) -> Result { + // Add the clip to the pool (it already has events and duration) + let duration = clip.duration; + let clip_id = clip.id; + self.midi_clip_pool.add_existing_clip(clip); + + // Create an instance that uses the full clip at the given position + let instance_id = self.next_midi_clip_instance_id(); + let instance = MidiClipInstance::from_full_clip(instance_id, clip_id, duration, start_time); + + self.add_midi_clip_instance(track_id, instance)?; + Ok(instance_id) + } + + /// Remove a MIDI clip instance from a track (for undo/redo support) + pub fn remove_midi_clip(&mut self, track_id: TrackId, instance_id: MidiClipInstanceId) -> Result<(), &'static str> { + if let Some(track) = self.get_track_mut(track_id) { + track.remove_midi_clip_instance(instance_id); + Ok(()) + } else { + Err("Track not found") + } + } + + /// Remove an audio clip instance from a track (for undo/redo support) + pub fn remove_audio_clip(&mut self, track_id: TrackId, instance_id: AudioClipInstanceId) -> Result<(), &'static str> { + if let Some(track) = self.get_track_mut(track_id) { + track.remove_audio_clip_instance(instance_id); + Ok(()) + } else { + Err("Track not found") + } + } + + /// Render all root tracks into the output buffer. + /// + /// When `live_only` is true, MIDI tracks skip clip event collection and only process + /// their live MIDI queue (note-off tails + keyboard input). Audio tracks produce silence. + /// This lets the caller use the same group-hierarchy render path regardless of play state. + pub fn render( + &mut self, + output: &mut [f32], + audio_pool: &AudioClipPool, + buffer_pool: &mut BufferPool, + playhead_seconds: Seconds, + tempo_map: &TempoMap, + sample_rate: u32, + channels: u32, + live_only: bool, + ) { + output.fill(0.0); + + let any_solo = self.any_solo(); + + // Create initial render context + let ctx = RenderContext { + live_only, + ..RenderContext::new(playhead_seconds, tempo_map, sample_rate, channels, output.len()) + }; + + // Render each root track (index-based to avoid clone) + for i in 0..self.root_tracks.len() { + let track_id = self.root_tracks[i]; + self.render_track( + track_id, + output, + audio_pool, + buffer_pool, + ctx, + any_solo, + false, // root tracks are not inside a soloed parent + ); + } + } + + /// Recursively render a track (audio or group) into the output buffer + fn render_track( + &mut self, + track_id: TrackId, + output: &mut [f32], + audio_pool: &AudioClipPool, + buffer_pool: &mut BufferPool, + ctx: RenderContext, + any_solo: bool, + parent_is_soloed: bool, + ) { + // Check if track should be rendered based on mute/solo + let should_render = match self.tracks.get(&track_id) { + Some(TrackNode::Audio(track)) => { + // If parent is soloed, only check mute state + // Otherwise, check normal solo logic + if parent_is_soloed { + !track.muted + } else { + track.is_active(any_solo) + } + } + Some(TrackNode::Midi(track)) => { + // Same logic for MIDI tracks + if parent_is_soloed { + !track.muted + } else { + track.is_active(any_solo) + } + } + Some(TrackNode::Group(group)) => { + // Same logic for groups + if parent_is_soloed { + !group.muted + } else { + group.is_active(any_solo) + } + } + None => return, + }; + + if !should_render { + return; + } + + // Handle audio track vs MIDI track vs group track + match self.tracks.get_mut(&track_id) { + Some(TrackNode::Audio(track)) => { + // Audio tracks have no live input; skip in live_only mode. + if ctx.live_only { + return; + } + // Render audio track into a temp buffer for peak measurement + let mut track_buffer = buffer_pool.acquire(); + track_buffer.resize(output.len(), 0.0); + track_buffer.fill(0.0); + track.render(&mut track_buffer, audio_pool, ctx); + // Accumulate peak level for VU metering (max over meter interval) + let buffer_peak = track_buffer.iter().map(|s| s.abs()).fold(0.0f32, f32::max); + track.peak_level = track.peak_level.max(buffer_peak); + // Mix into output + for (out, src) in output.iter_mut().zip(track_buffer.iter()) { + *out += src; + } + buffer_pool.release(track_buffer); + } + Some(TrackNode::Midi(track)) => { + // Render MIDI track into a temp buffer for peak measurement + let mut track_buffer = buffer_pool.acquire(); + track_buffer.resize(output.len(), 0.0); + track_buffer.fill(0.0); + track.render(&mut track_buffer, &self.midi_clip_pool, ctx); + // Accumulate peak level for VU metering (max over meter interval) + let buffer_peak = track_buffer.iter().map(|s| s.abs()).fold(0.0f32, f32::max); + track.peak_level = track.peak_level.max(buffer_peak); + // Mix into output + for (out, src) in output.iter_mut().zip(track_buffer.iter()) { + *out += src; + } + buffer_pool.release(track_buffer); + } + Some(TrackNode::Group(group)) => { + // Skip rendering if playhead is outside the metatrack's trim window. + // In live_only mode always render so note-off tails pass through the mixer. + if !ctx.live_only && !group.is_active_at_time(ctx.playhead_seconds) { + return; + } + + // Read group properties and transform context before any mutable borrows + let num_children = group.children.len(); + let this_group_is_soloed = group.solo; + let child_ctx = group.transform_context(ctx); + let children_parent_soloed = parent_is_soloed || this_group_is_soloed; + + // Render each child into its own buffer and inject into SubtrackInputsNode. + // One pool buffer is reused per child (no extra allocation per frame). + for i in 0..num_children { + let child_id = match self.tracks.get(&track_id) { + Some(TrackNode::Group(g)) => g.children[i], + _ => break, + }; + + let mut child_buffer = buffer_pool.acquire(); + child_buffer.resize(output.len(), 0.0); + child_buffer.fill(0.0); + + self.render_track( + child_id, + &mut child_buffer, + audio_pool, + buffer_pool, + child_ctx, + any_solo, + children_parent_soloed, + ); + + // Inject into the SubtrackInputsNode slot for this child + if let Some(TrackNode::Group(group)) = self.tracks.get_mut(&track_id) { + use super::node_graph::nodes::SubtrackInputsNode; + let node_indices: Vec<_> = group.audio_graph.node_indices().collect(); + for node_idx in node_indices { + if let Some(gn) = group.audio_graph.get_graph_node_mut(node_idx) { + if gn.node.node_type() == "SubtrackInputs" { + if let Some(si) = gn.node.as_any_mut() + .downcast_mut::() + { + if let Some(slot) = si.subtrack_index_for(child_id) { + si.inject_subtrack_audio(slot, &child_buffer); + } + } + break; + } + } + } + } + + buffer_pool.release(child_buffer); + } + + // Process children's audio through the metatrack's mixing graph + if let Some(TrackNode::Group(group)) = self.tracks.get_mut(&track_id) { + let mut graph_output = buffer_pool.acquire(); + graph_output.resize(output.len(), 0.0); + graph_output.fill(0.0); + group.audio_graph.process(&mut graph_output, &[], ctx.playhead_beats()); + + for (out_sample, graph_sample) in output.iter_mut().zip(graph_output.iter()) { + *out_sample += graph_sample * group.volume; + } + buffer_pool.release(graph_output); + } + } + None => {} + } + } + + /// Reset all per-clip read-ahead target frames before a new render cycle. + pub fn reset_read_ahead_targets(&self) { + for track in self.tracks.values() { + if let TrackNode::Audio(audio_track) = track { + for clip in &audio_track.clips { + if let Some(ra) = clip.read_ahead.as_deref() { + ra.reset_target_frame(); + } + } + } + } + } + + /// Collect per-track peak levels for VU metering and reset accumulators + pub fn collect_track_peaks(&mut self) -> Vec<(TrackId, f32)> { + let mut levels = Vec::new(); + for (id, track) in &mut self.tracks { + match track { + TrackNode::Audio(t) => { + levels.push((*id, t.peak_level)); + t.peak_level = 0.0; + } + TrackNode::Midi(t) => { + levels.push((*id, t.peak_level)); + t.peak_level = 0.0; + } + TrackNode::Group(_) => {} + } + } + levels + } + + /// Stop all notes on all MIDI tracks + pub fn stop_all_notes(&mut self) { + for track in self.tracks.values_mut() { + if let TrackNode::Midi(midi_track) = track { + midi_track.stop_all_notes(); + } + } + } + + /// Set export (blocking) mode on all clip read-ahead buffers. + /// When enabled, `render_from_file` blocks until the disk reader + /// has filled the needed frames instead of returning silence. + pub fn set_export_mode(&self, export: bool) { + for track in self.tracks.values() { + if let TrackNode::Audio(t) = track { + for clip in &t.clips { + if let Some(ref ra) = clip.read_ahead { + ra.set_export_mode(export); + } + } + } + } + } + + /// Reset all node graphs (clears effect buffers on seek) + pub fn reset_all_graphs(&mut self) { + for track in self.tracks.values_mut() { + match track { + TrackNode::Audio(t) => t.effects_graph.reset(), + TrackNode::Midi(t) => t.instrument_graph.reset(), + TrackNode::Group(_) => {} + } + } + } + + /// Propagate tempo to all audio graphs (for BeatNode sync) + pub fn set_tempo(&mut self, bpm: f32, beats_per_bar: u32) { + for track in self.tracks.values_mut() { + match track { + TrackNode::Audio(t) => t.effects_graph.set_tempo(bpm, beats_per_bar), + TrackNode::Midi(t) => t.instrument_graph.set_tempo(bpm, beats_per_bar), + TrackNode::Group(g) => g.audio_graph.set_tempo(bpm, beats_per_bar), + } + } + } + + /// Send a live MIDI note on event to a track's instrument + /// Note: With node-based instruments, MIDI events are handled during the process() call + pub fn send_midi_note_on(&mut self, track_id: TrackId, note: u8, velocity: u8) { + // Queue the MIDI note-on event to the track's live MIDI queue + if let Some(TrackNode::Midi(track)) = self.tracks.get_mut(&track_id) { + let event = MidiEvent::note_on(Beats::ZERO, 0, note, velocity); + track.queue_live_midi(event); + } + } + + /// Send a live MIDI note off event to a track's instrument + pub fn send_midi_note_off(&mut self, track_id: TrackId, note: u8) { + // Queue the MIDI note-off event to the track's live MIDI queue + if let Some(TrackNode::Midi(track)) = self.tracks.get_mut(&track_id) { + let event = MidiEvent::note_off(Beats::ZERO, 0, note, 0); + track.queue_live_midi(event); + } + } + + /// Prepare all tracks for serialization by saving their audio graphs as presets + pub fn prepare_for_save(&mut self) { + for track in self.tracks.values_mut() { + match track { + TrackNode::Audio(audio_track) => { + audio_track.prepare_for_save(); + } + TrackNode::Midi(midi_track) => { + midi_track.prepare_for_save(); + } + TrackNode::Group(group) => { + group.prepare_for_save(); + } + } + } + } + + /// Rebuild all audio graphs from presets after deserialization + /// + /// This should be called after deserializing a Project to reconstruct + /// the AudioGraph instances from their stored presets. + /// + /// # Arguments + /// * `buffer_size` - Buffer size for audio processing (typically 8192) + pub fn rebuild_audio_graphs(&mut self, buffer_size: usize) -> Result<(), String> { + for track in self.tracks.values_mut() { + match track { + TrackNode::Audio(audio_track) => { + audio_track.rebuild_audio_graph(self.sample_rate, buffer_size)?; + } + TrackNode::Midi(midi_track) => { + midi_track.rebuild_audio_graph(self.sample_rate, buffer_size)?; + } + TrackNode::Group(group) => { + group.rebuild_audio_graph(self.sample_rate, buffer_size)?; + } + } + } + Ok(()) + } +} + +impl Default for Project { + fn default() -> Self { + Self::new(48000) // Use 48kHz as default, will be overridden when created with actual sample rate + } +} diff --git a/daw-backend/src/audio/recording.rs b/daw-backend/src/audio/recording.rs new file mode 100644 index 0000000..8203081 --- /dev/null +++ b/daw-backend/src/audio/recording.rs @@ -0,0 +1,264 @@ +/// Audio recording system for capturing microphone input +use crate::audio::{ClipId, MidiClipId, TrackId}; +use crate::io::{WavWriter, WaveformPeak}; +use crate::time::{Beats, Seconds}; +use std::collections::HashMap; +use std::path::PathBuf; + +/// State of an active recording session +pub struct RecordingState { + /// Track being recorded to + pub track_id: TrackId, + /// Clip ID for the intermediate clip + pub clip_id: ClipId, + /// Path to temporary WAV file + pub temp_file_path: PathBuf, + /// WAV file writer (only used at finalization, not during recording) + pub writer: WavWriter, + /// Sample rate of recording + pub sample_rate: u32, + /// Number of channels + pub channels: u32, + /// Timeline start position + pub start_time: Beats, + /// Total frames recorded + pub frames_written: usize, + /// Whether recording is currently paused + pub paused: bool, + /// Number of samples remaining to skip (to discard stale buffer data) + pub samples_to_skip: usize, + /// Waveform peaks generated incrementally during recording + pub waveform: Vec, + /// Temporary buffer for collecting samples for next waveform peak + pub waveform_buffer: Vec, + /// Number of frames per waveform peak + pub frames_per_peak: usize, + /// All recorded audio data accumulated in memory (written to disk at finalization) + pub audio_data: Vec, +} + +impl RecordingState { + /// Create a new recording state + pub fn new( + track_id: TrackId, + clip_id: ClipId, + temp_file_path: PathBuf, + writer: WavWriter, + sample_rate: u32, + channels: u32, + start_time: Beats, + _flush_interval_seconds: f64, // No longer used - kept for API compatibility + ) -> Self { + // Calculate frames per waveform peak + // Target ~300 peaks per second with minimum 1000 samples per peak + let target_peaks_per_second = 300; + let frames_per_peak = (sample_rate / target_peaks_per_second).max(1000) as usize; + + Self { + track_id, + clip_id, + temp_file_path, + writer, + sample_rate, + channels, + start_time, + frames_written: 0, + paused: false, + samples_to_skip: 0, // Will be set by engine when it knows buffer size + waveform: Vec::new(), + waveform_buffer: Vec::new(), + frames_per_peak, + audio_data: Vec::new(), + } + } + + /// Add samples to the accumulation buffer + /// Returns true if a flush occurred + pub fn add_samples(&mut self, samples: &[f32]) -> Result { + if self.paused { + return Ok(false); + } + + // Determine which samples to process + let samples_to_process = if self.samples_to_skip > 0 { + let to_skip = self.samples_to_skip.min(samples.len()); + self.samples_to_skip -= to_skip; + + if to_skip == samples.len() { + // Skip entire batch + return Ok(false); + } + + // Skip partial batch and process the rest + &samples[to_skip..] + } else { + samples + }; + + // Add to audio data (accumulate in memory - disk write happens at finalization only) + self.audio_data.extend_from_slice(samples_to_process); + + // Add to waveform buffer and generate peaks incrementally + self.waveform_buffer.extend_from_slice(samples_to_process); + self.generate_waveform_peaks(); + + // Track frames for duration calculation (no disk I/O in audio callback!) + let frames_added = samples_to_process.len() / self.channels as usize; + self.frames_written += frames_added; + + Ok(false) + } + + /// Generate waveform peaks from accumulated samples + /// This is called incrementally as samples arrive + fn generate_waveform_peaks(&mut self) { + let samples_per_peak = self.frames_per_peak * self.channels as usize; + + while self.waveform_buffer.len() >= samples_per_peak { + let mut min = 0.0f32; + let mut max = 0.0f32; + + // Scan all samples for this peak + for sample in &self.waveform_buffer[..samples_per_peak] { + min = min.min(*sample); + max = max.max(*sample); + } + + self.waveform.push(WaveformPeak { min, max }); + + // Remove processed samples from waveform buffer + self.waveform_buffer.drain(..samples_per_peak); + } + } + + /// Get current recording duration + pub fn duration(&self) -> Seconds { + Seconds(self.frames_written as f64 / self.sample_rate as f64) + } + + /// Finalize the recording and return the temp file path, waveform, and audio data + pub fn finalize(mut self) -> Result<(PathBuf, Vec, Vec), std::io::Error> { + // Write all audio data to disk at once (outside audio callback - safe to do I/O) + if !self.audio_data.is_empty() { + self.writer.write_samples(&self.audio_data)?; + } + + // Generate final waveform peak from any remaining samples + if !self.waveform_buffer.is_empty() { + let mut min = 0.0f32; + let mut max = 0.0f32; + + for sample in &self.waveform_buffer { + min = min.min(*sample); + max = max.max(*sample); + } + + self.waveform.push(WaveformPeak { min, max }); + } + + // Finalize the WAV file + self.writer.finalize()?; + + Ok((self.temp_file_path, self.waveform, self.audio_data)) + } + + /// Pause recording + pub fn pause(&mut self) { + self.paused = true; + } + + /// Resume recording + pub fn resume(&mut self) { + self.paused = false; + } +} + +/// Active MIDI note waiting for its noteOff event +#[derive(Debug, Clone)] +struct ActiveMidiNote { + note: u8, + velocity: u8, + start_time: Beats, +} + +/// State of an active MIDI recording session. +pub struct MidiRecordingState { + pub track_id: TrackId, + pub clip_id: MidiClipId, + pub start_time: Beats, + active_notes: HashMap, + /// Completed notes: (time_offset, note, velocity, duration) — all times in beats + pub completed_notes: Vec<(Beats, u8, u8, Beats)>, +} + +impl MidiRecordingState { + pub fn new(track_id: TrackId, clip_id: MidiClipId, start_time: Beats) -> Self { + Self { + track_id, + clip_id, + start_time, + active_notes: HashMap::new(), + completed_notes: Vec::new(), + } + } + + pub fn note_on(&mut self, note: u8, velocity: u8, absolute_time: Beats) { + self.active_notes.insert(note, ActiveMidiNote { note, velocity, start_time: absolute_time }); + } + + pub fn note_off(&mut self, note: u8, absolute_time: Beats) { + if let Some(active_note) = self.active_notes.remove(¬e) { + if absolute_time <= self.start_time { + return; + } + let note_start = active_note.start_time.max(self.start_time); + self.completed_notes.push(( + note_start - self.start_time, + active_note.note, + active_note.velocity, + absolute_time - note_start, + )); + } + } + + pub fn get_notes(&self) -> &[(Beats, u8, u8, Beats)] { + &self.completed_notes + } + + pub fn note_count(&self) -> usize { + self.completed_notes.len() + } + + /// Get all completed notes plus currently-held notes with a provisional duration. + pub fn get_notes_with_active(&self, current_time: Beats) -> Vec<(Beats, u8, u8, Beats)> { + let mut notes = self.completed_notes.clone(); + for active in self.active_notes.values() { + let note_start = active.start_time.max(self.start_time); + notes.push(( + note_start - self.start_time, + active.note, + active.velocity, + (current_time - note_start).max(Beats::ZERO), + )); + } + notes + } + + pub fn active_note_numbers(&self) -> Vec { + self.active_notes.keys().copied().collect() + } + + pub fn close_active_notes(&mut self, end_time: Beats) { + let active_notes: Vec<_> = self.active_notes.drain().collect(); + + for (_note_num, active_note) in active_notes { + let note_start = active_note.start_time.max(self.start_time); + self.completed_notes.push(( + note_start - self.start_time, + active_note.note, + active_note.velocity, + end_time - note_start, + )); + } + } +} diff --git a/daw-backend/src/audio/sample_loader.rs b/daw-backend/src/audio/sample_loader.rs new file mode 100644 index 0000000..40e00ac --- /dev/null +++ b/daw-backend/src/audio/sample_loader.rs @@ -0,0 +1,306 @@ +use symphonia::core::audio::{AudioBufferRef, Signal}; +use symphonia::core::codecs::{DecoderOptions, CODEC_TYPE_NULL}; +use symphonia::core::errors::Error as SymphoniaError; +use symphonia::core::formats::FormatOptions; +use symphonia::core::io::MediaSourceStream; +use symphonia::core::meta::MetadataOptions; +use symphonia::core::probe::Hint; +use std::fs::File; +use std::io::Cursor; +use std::path::Path; + +/// Loaded audio sample data +#[derive(Debug, Clone)] +pub struct SampleData { + /// Audio samples (mono, f32 format) + pub samples: Vec, + /// Original sample rate + pub sample_rate: u32, +} + +/// Load an audio file and decode it to mono f32 samples +pub fn load_audio_file(path: impl AsRef) -> Result { + let path = path.as_ref(); + let file = File::open(path).map_err(|e| format!("Failed to open file: {}", e))?; + let mss = MediaSourceStream::new(Box::new(file), Default::default()); + let mut hint = Hint::new(); + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + hint.with_extension(ext); + } + decode_mss(mss, hint) +} + +/// Load audio from an in-memory byte slice and decode it to mono f32 samples. +/// Supports WAV, FLAC, MP3, AAC, and any other format Symphonia recognises. +/// `filename_hint` is used to help Symphonia detect the format (e.g. "kick.wav"). +pub fn load_audio_from_bytes(bytes: &[u8], filename_hint: &str) -> Result { + let cursor = Cursor::new(bytes.to_vec()); + let mss = MediaSourceStream::new(Box::new(cursor), Default::default()); + let mut hint = Hint::new(); + if let Some(ext) = std::path::Path::new(filename_hint).extension().and_then(|e| e.to_str()) { + hint.with_extension(ext); + } + decode_mss(mss, hint) +} + +/// Shared decode logic: probe `mss`, find the first audio track, decode to mono f32. +fn decode_mss(mss: MediaSourceStream, hint: Hint) -> Result { + let probed = symphonia::default::get_probe() + .format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default()) + .map_err(|e| format!("Failed to probe format: {}", e))?; + + let mut format = probed.format; + + let track = format + .tracks() + .iter() + .find(|t| t.codec_params.codec != CODEC_TYPE_NULL) + .ok_or_else(|| "No audio tracks found".to_string())?; + + let track_id = track.id; + let sample_rate = track.codec_params.sample_rate.unwrap_or(48000); + + let mut decoder = symphonia::default::get_codecs() + .make(&track.codec_params, &DecoderOptions::default()) + .map_err(|e| format!("Failed to create decoder: {}", e))?; + + let mut all_samples = Vec::new(); + + loop { + let packet = match format.next_packet() { + Ok(packet) => packet, + Err(SymphoniaError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => { + break; + } + Err(e) => return Err(format!("Error reading packet: {}", e)), + }; + + if packet.track_id() != track_id { + continue; + } + + let decoded = decoder + .decode(&packet) + .map_err(|e| format!("Failed to decode packet: {}", e))?; + + all_samples.extend_from_slice(&convert_to_mono_f32(&decoded)); + } + + Ok(SampleData { samples: all_samples, sample_rate }) +} + +/// Convert an audio buffer to mono f32 samples +fn convert_to_mono_f32(buf: &AudioBufferRef) -> Vec { + match buf { + AudioBufferRef::F32(buf) => { + let channels = buf.spec().channels.count(); + let frames = buf.frames(); + let mut mono = Vec::with_capacity(frames); + + if channels == 1 { + // Already mono + mono.extend_from_slice(buf.chan(0)); + } else { + // Mix down to mono by averaging all channels + for frame in 0..frames { + let mut sum = 0.0; + for ch in 0..channels { + sum += buf.chan(ch)[frame]; + } + mono.push(sum / channels as f32); + } + } + + mono + } + AudioBufferRef::U8(buf) => { + let channels = buf.spec().channels.count(); + let frames = buf.frames(); + let mut mono = Vec::with_capacity(frames); + + if channels == 1 { + for &sample in buf.chan(0) { + mono.push((sample as f32 - 128.0) / 128.0); + } + } else { + for frame in 0..frames { + let mut sum = 0.0; + for ch in 0..channels { + sum += (buf.chan(ch)[frame] as f32 - 128.0) / 128.0; + } + mono.push(sum / channels as f32); + } + } + + mono + } + AudioBufferRef::U16(buf) => { + let channels = buf.spec().channels.count(); + let frames = buf.frames(); + let mut mono = Vec::with_capacity(frames); + + if channels == 1 { + for &sample in buf.chan(0) { + mono.push((sample as f32 - 32768.0) / 32768.0); + } + } else { + for frame in 0..frames { + let mut sum = 0.0; + for ch in 0..channels { + sum += (buf.chan(ch)[frame] as f32 - 32768.0) / 32768.0; + } + mono.push(sum / channels as f32); + } + } + + mono + } + AudioBufferRef::U24(buf) => { + let channels = buf.spec().channels.count(); + let frames = buf.frames(); + let mut mono = Vec::with_capacity(frames); + + if channels == 1 { + for &sample in buf.chan(0) { + mono.push((sample.inner() as f32 - 8388608.0) / 8388608.0); + } + } else { + for frame in 0..frames { + let mut sum = 0.0; + for ch in 0..channels { + sum += (buf.chan(ch)[frame].inner() as f32 - 8388608.0) / 8388608.0; + } + mono.push(sum / channels as f32); + } + } + + mono + } + AudioBufferRef::U32(buf) => { + let channels = buf.spec().channels.count(); + let frames = buf.frames(); + let mut mono = Vec::with_capacity(frames); + + if channels == 1 { + for &sample in buf.chan(0) { + mono.push((sample as f32 - 2147483648.0) / 2147483648.0); + } + } else { + for frame in 0..frames { + let mut sum = 0.0; + for ch in 0..channels { + sum += (buf.chan(ch)[frame] as f32 - 2147483648.0) / 2147483648.0; + } + mono.push(sum / channels as f32); + } + } + + mono + } + AudioBufferRef::S8(buf) => { + let channels = buf.spec().channels.count(); + let frames = buf.frames(); + let mut mono = Vec::with_capacity(frames); + + if channels == 1 { + for &sample in buf.chan(0) { + mono.push(sample as f32 / 128.0); + } + } else { + for frame in 0..frames { + let mut sum = 0.0; + for ch in 0..channels { + sum += buf.chan(ch)[frame] as f32 / 128.0; + } + mono.push(sum / channels as f32); + } + } + + mono + } + AudioBufferRef::S16(buf) => { + let channels = buf.spec().channels.count(); + let frames = buf.frames(); + let mut mono = Vec::with_capacity(frames); + + if channels == 1 { + for &sample in buf.chan(0) { + mono.push(sample as f32 / 32768.0); + } + } else { + for frame in 0..frames { + let mut sum = 0.0; + for ch in 0..channels { + sum += buf.chan(ch)[frame] as f32 / 32768.0; + } + mono.push(sum / channels as f32); + } + } + + mono + } + AudioBufferRef::S24(buf) => { + let channels = buf.spec().channels.count(); + let frames = buf.frames(); + let mut mono = Vec::with_capacity(frames); + + if channels == 1 { + for &sample in buf.chan(0) { + mono.push(sample.inner() as f32 / 8388608.0); + } + } else { + for frame in 0..frames { + let mut sum = 0.0; + for ch in 0..channels { + sum += buf.chan(ch)[frame].inner() as f32 / 8388608.0; + } + mono.push(sum / channels as f32); + } + } + + mono + } + AudioBufferRef::S32(buf) => { + let channels = buf.spec().channels.count(); + let frames = buf.frames(); + let mut mono = Vec::with_capacity(frames); + + if channels == 1 { + for &sample in buf.chan(0) { + mono.push(sample as f32 / 2147483648.0); + } + } else { + for frame in 0..frames { + let mut sum = 0.0; + for ch in 0..channels { + sum += buf.chan(ch)[frame] as f32 / 2147483648.0; + } + mono.push(sum / channels as f32); + } + } + + mono + } + AudioBufferRef::F64(buf) => { + let channels = buf.spec().channels.count(); + let frames = buf.frames(); + let mut mono = Vec::with_capacity(frames); + + if channels == 1 { + for &sample in buf.chan(0) { + mono.push(sample as f32); + } + } else { + for frame in 0..frames { + let mut sum = 0.0; + for ch in 0..channels { + sum += buf.chan(ch)[frame] as f32; + } + mono.push(sum / channels as f32); + } + } + + mono + } + } +} diff --git a/daw-backend/src/audio/track.rs b/daw-backend/src/audio/track.rs new file mode 100644 index 0000000..f7da3d2 --- /dev/null +++ b/daw-backend/src/audio/track.rs @@ -0,0 +1,1304 @@ +use super::automation::{AutomationLane, AutomationLaneId, ParameterId}; +use super::clip::{AudioClipInstance, AudioClipInstanceId}; +use super::midi::{MidiClipInstance, MidiClipInstanceId, MidiEvent}; +use super::midi_pool::MidiClipPool; +use super::node_graph::AudioGraph; +use super::node_graph::nodes::{AudioInputNode, AudioOutputNode}; +use super::node_graph::preset::GraphPreset; +use super::pool::AudioClipPool; +use crate::tempo_map::TempoMap; +use crate::time::{Beats, Seconds}; +use serde::{Serialize, Deserialize}; +use std::collections::{HashMap, HashSet}; + +/// Track ID type +pub type TrackId = u32; + +/// Default function for creating empty AudioGraph during deserialization +fn default_audio_graph() -> AudioGraph { + AudioGraph::new(48000, 8192) +} + +/// Type alias for backwards compatibility +pub type Track = AudioTrack; + +/// Rendering context that carries timing information through the track hierarchy +/// +/// This allows metatracks to transform time for their children (time stretch, offset, etc.) +#[derive(Clone, Copy)] +pub struct RenderContext<'a> { + /// Current playhead position in seconds (in transformed time) + pub playhead_seconds: Seconds, + /// Tempo map for beat ↔ second conversion + pub tempo_map: &'a TempoMap, + /// Audio sample rate + pub sample_rate: u32, + /// Number of channels + pub channels: u32, + /// Size of the buffer being rendered (in interleaved samples) + pub buffer_size: usize, + /// Accumulated time stretch factor (1.0 = normal, 0.5 = half speed, 2.0 = double speed) + pub time_stretch: f32, + /// When true: skip clip event collection; only render instrument state and live MIDI queue. + /// Used after pause/stop to route note-off tails through the normal group hierarchy + /// without re-triggering notes from clips at the paused position. + pub live_only: bool, +} + +impl<'a> RenderContext<'a> { + pub fn new( + playhead_seconds: Seconds, + tempo_map: &'a TempoMap, + sample_rate: u32, + channels: u32, + buffer_size: usize, + ) -> Self { + Self { + playhead_seconds, + tempo_map, + sample_rate, + channels, + buffer_size, + time_stretch: 1.0, + live_only: false, + } + } + + pub fn buffer_duration(&self) -> Seconds { + Seconds(self.buffer_size as f64 / (self.sample_rate as f64 * self.channels as f64)) + } + + pub fn buffer_end(&self) -> Seconds { + self.playhead_seconds + self.buffer_duration() + } + + pub fn playhead_beats(&self) -> Beats { + self.tempo_map.seconds_to_beats(self.playhead_seconds) + } + + pub fn buffer_end_beats(&self) -> Beats { + self.tempo_map.seconds_to_beats(self.buffer_end()) + } +} + +/// Node in the track hierarchy - can be an audio track, MIDI track, or a metatrack +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum TrackNode { + Audio(AudioTrack), + Midi(MidiTrack), + Group(Metatrack), +} + +impl TrackNode { + /// Get the track ID + pub fn id(&self) -> TrackId { + match self { + TrackNode::Audio(track) => track.id, + TrackNode::Midi(track) => track.id, + TrackNode::Group(group) => group.id, + } + } + + /// Get the track name + pub fn name(&self) -> &str { + match self { + TrackNode::Audio(track) => &track.name, + TrackNode::Midi(track) => &track.name, + TrackNode::Group(group) => &group.name, + } + } + + /// Get muted state + pub fn is_muted(&self) -> bool { + match self { + TrackNode::Audio(track) => track.muted, + TrackNode::Midi(track) => track.muted, + TrackNode::Group(group) => group.muted, + } + } + + /// Get solo state + pub fn is_solo(&self) -> bool { + match self { + TrackNode::Audio(track) => track.solo, + TrackNode::Midi(track) => track.solo, + TrackNode::Group(group) => group.solo, + } + } + + /// Set volume + pub fn set_volume(&mut self, volume: f32) { + match self { + TrackNode::Audio(track) => track.set_volume(volume), + TrackNode::Midi(track) => track.set_volume(volume), + TrackNode::Group(group) => group.set_volume(volume), + } + } + + /// Set muted state + pub fn set_muted(&mut self, muted: bool) { + match self { + TrackNode::Audio(track) => track.set_muted(muted), + TrackNode::Midi(track) => track.set_muted(muted), + TrackNode::Group(group) => group.set_muted(muted), + } + } + + /// Set solo state + pub fn set_solo(&mut self, solo: bool) { + match self { + TrackNode::Audio(track) => track.set_solo(solo), + TrackNode::Midi(track) => track.set_solo(solo), + TrackNode::Group(group) => group.set_solo(solo), + } + } + + /// Remove a MIDI clip instance (only works on MIDI tracks) + pub fn remove_midi_clip_instance(&mut self, instance_id: MidiClipInstanceId) { + if let TrackNode::Midi(track) = self { + track.remove_midi_clip_instance(instance_id); + } + } + + /// Remove an audio clip instance (only works on audio tracks) + pub fn remove_audio_clip_instance(&mut self, instance_id: AudioClipInstanceId) { + if let TrackNode::Audio(track) = self { + track.remove_audio_clip_instance(instance_id); + } + } +} + +/// Metatrack that contains other tracks with time transformation capabilities +#[derive(Debug, Serialize, Deserialize)] +pub struct Metatrack { + pub id: TrackId, + pub name: String, + pub children: Vec, + pub volume: f32, + pub muted: bool, + pub solo: bool, + /// Time stretch factor (0.5 = half speed, 1.0 = normal, 2.0 = double speed) + pub time_stretch: f32, + /// Pitch shift in semitones (for future implementation) + pub pitch_shift: f32, + /// Time offset (shift content forward/backward in time) + pub offset: Seconds, + /// Trim start: offset into the metatrack's internal content + /// Children will see time starting from this point + pub trim_start: Seconds, + /// Trim end: offset into the metatrack's internal content + /// None means no end trim (play until content ends) + pub trim_end: Option, + /// Automation lanes for this metatrack + pub automation_lanes: HashMap, + next_automation_id: AutomationLaneId, + /// Audio node graph for effects processing (input → output) + #[serde(skip, default = "default_audio_graph")] + pub audio_graph: AudioGraph, + /// Saved graph preset for serialization + audio_graph_preset: Option, + /// True while the mixing graph is still the auto-generated default (no user edits). + /// Used to auto-connect new subtracks and to prompt before loading a preset. + #[serde(default)] + pub graph_is_default: bool, +} + +impl Clone for Metatrack { + fn clone(&self) -> Self { + Self { + id: self.id, + name: self.name.clone(), + children: self.children.clone(), + volume: self.volume, + muted: self.muted, + solo: self.solo, + time_stretch: self.time_stretch, + pitch_shift: self.pitch_shift, + offset: self.offset, + trim_start: self.trim_start, + trim_end: self.trim_end, + automation_lanes: self.automation_lanes.clone(), + next_automation_id: self.next_automation_id, + audio_graph: default_audio_graph(), // Create fresh graph, not cloned + audio_graph_preset: self.audio_graph_preset.clone(), + graph_is_default: self.graph_is_default, + } + } +} + +impl Metatrack { + /// Create a new metatrack. The mixing graph is set up later via `set_subtrack_graph` + /// once the child track list is known. + pub fn new(id: TrackId, name: String, sample_rate: u32) -> Self { + let default_buffer_size = 8192; + let audio_graph = Self::create_empty_graph(sample_rate, default_buffer_size); + + Self { + id, + name, + children: Vec::new(), + volume: 1.0, + muted: false, + solo: false, + time_stretch: 1.0, + pitch_shift: 0.0, + offset: Seconds::ZERO, + trim_start: Seconds::ZERO, + trim_end: None, + automation_lanes: HashMap::new(), + next_automation_id: 0, + audio_graph, + audio_graph_preset: None, + graph_is_default: true, + } + } + + /// Minimal graph used before subtracks are known (just an AudioOutput node). + fn create_empty_graph(sample_rate: u32, buffer_size: usize) -> AudioGraph { + let mut graph = AudioGraph::new(sample_rate, buffer_size); + let output_node = Box::new(AudioOutputNode::new("Audio Output")); + let output_id = graph.add_node(output_node); + graph.set_node_position(output_id, 500.0, 150.0); + graph.set_output_node(Some(output_id)); + graph + } + + /// Build the default subtrack mixing graph: SubtrackInputs → Mixer → Gain → AudioOutput, + /// with an AutomationInput ("Volume", range 0..2) feeding the Gain's CV port. + /// + /// Existing Volume keyframes are preserved across rebuilds so that adding/removing + /// a child track doesn't reset the automation. + /// + /// `subtracks` is an ordered list of (backend TrackId, display name) for each child. + /// Replaces the current graph and marks `graph_is_default = true`. + pub fn set_subtrack_graph( + &mut self, + subtracks: Vec<(TrackId, String)>, + sample_rate: u32, + buffer_size: usize, + ) { + use super::node_graph::nodes::{SubtrackInputsNode, MixerNode, GainNode, AutomationInputNode}; + use super::node_graph::nodes::AutomationKeyframe; + use crate::time::Beats; + + // Preserve existing Volume keyframes before rebuilding. + let existing_volume_kfs = self.get_volume_automation_keyframes(); + + let n = subtracks.len(); + let mut graph = AudioGraph::new(sample_rate, buffer_size); + + // SubtrackInputs node (N outputs, one per child) + let mut inputs_node = SubtrackInputsNode::new("Subtrack Inputs", subtracks); + let subtracks_copy = inputs_node.subtracks().to_vec(); + inputs_node.update_subtracks(subtracks_copy, buffer_size); + let inputs_id = graph.add_node(Box::new(inputs_node)); + graph.set_node_position(inputs_id, 100.0, 150.0); + + // Mixer node + let mixer_node = Box::new(MixerNode::new("Mixer")); + let mixer_id = graph.add_node(mixer_node); + graph.set_node_position(mixer_id, 330.0, 150.0); + + // Gain node — group volume control + let gain_id = graph.add_node(Box::new(GainNode::new("Volume"))); + graph.set_node_position(gain_id, 520.0, 150.0); + + // AutomationInput — drives the Gain's CV port + let mut auto_node = AutomationInputNode::new("Volume CV"); + auto_node.set_display_name("Volume".to_string()); + auto_node.value_min = 0.0; + auto_node.value_max = 2.0; + auto_node.clear_keyframes(); + if existing_volume_kfs.is_empty() { + auto_node.add_keyframe(AutomationKeyframe::new(Beats::ZERO, 1.0)); + } else { + for kf in existing_volume_kfs { + auto_node.add_keyframe(kf); + } + } + let auto_id = graph.add_node(Box::new(auto_node)); + graph.set_node_position(auto_id, 520.0, 320.0); + + // AudioOutput node + let output_node = Box::new(AudioOutputNode::new("Audio Output")); + let output_id = graph.add_node(output_node); + graph.set_node_position(output_id, 720.0, 150.0); + + // Connect SubtrackInputs[i] → Mixer[i] for each subtrack + for i in 0..n { + let _ = graph.connect(inputs_id, i, mixer_id, i); + } + let _ = graph.connect(mixer_id, 0, gain_id, 0); // Mixer → Gain audio + let _ = graph.connect(auto_id, 0, gain_id, 1); // AutomationInput → Gain CV + let _ = graph.connect(gain_id, 0, output_id, 0); // Gain → Audio Out + graph.set_output_node(Some(output_id)); + + self.audio_graph = graph; + self.audio_graph_preset = None; + self.graph_is_default = true; + } + + /// Extract Volume AutomationInput keyframes from the current graph (if any), + /// so they can be preserved across `set_subtrack_graph` rebuilds. + fn get_volume_automation_keyframes(&self) -> Vec { + use super::node_graph::nodes::AutomationInputNode; + for idx in self.audio_graph.node_indices() { + if let Some(node) = self.audio_graph.get_graph_node(idx) { + if node.node.node_type() == "AutomationInput" { + if let Some(auto_node) = node.node.as_any().downcast_ref::() { + return auto_node.keyframes().to_vec(); + } + } + } + } + Vec::new() + } + + /// Add a new subtrack port to the existing graph. + /// + /// If `graph_is_default`: also connects the new port to a new Mixer input. + /// If the user has modified the graph: just adds the port (unconnected). + pub fn add_subtrack_to_graph(&mut self, track_id: TrackId, name: String, buffer_size: usize) { + use super::node_graph::nodes::SubtrackInputsNode; + + // Find SubtrackInputs node index + let si_idx = self.audio_graph.node_indices() + .find(|&idx| self.audio_graph.get_graph_node(idx) + .map(|n| n.node.node_type() == "SubtrackInputs") + .unwrap_or(false)); + + let si_idx = match si_idx { + Some(idx) => idx, + None => return, // No subtrack graph set up yet + }; + + // Get current subtrack count (= new port index after adding) + let new_slot = { + let gn = self.audio_graph.get_graph_node_mut(si_idx).unwrap(); + let si = gn.node.as_any_mut().downcast_mut::().unwrap(); + let mut subtracks = si.subtracks().to_vec(); + subtracks.push((track_id, name)); + let n = subtracks.len(); + si.update_subtracks(subtracks, buffer_size); + // Rebuild output buffers for the new port count + n - 1 // index of the newly added slot + }; + // Reallocate GraphNode output buffers to match new port count + self.audio_graph.reallocate_node_output_buffers(si_idx, buffer_size); + + if self.graph_is_default { + // Find the Mixer node and connect the new subtrack port to a new Mixer input + let mixer_idx = self.audio_graph.node_indices() + .find(|&idx| self.audio_graph.get_graph_node(idx) + .map(|n| n.node.node_type() == "Mixer") + .unwrap_or(false)); + + if let Some(mixer_idx) = mixer_idx { + // n_incoming after connecting = new_slot + 1; auto-grow handled by connect() + let _ = self.audio_graph.connect(si_idx, new_slot, mixer_idx, new_slot); + } + } + } + + /// Remove a subtrack from the graph (by TrackId). + /// + /// Always disconnects any connections from the removed port and removes the port. + /// If `graph_is_default`: also reshuffles Mixer connections to stay compact. + pub fn remove_subtrack_from_graph(&mut self, track_id: TrackId, buffer_size: usize) { + use super::node_graph::nodes::SubtrackInputsNode; + + let si_idx = self.audio_graph.node_indices() + .find(|&idx| self.audio_graph.get_graph_node(idx) + .map(|n| n.node.node_type() == "SubtrackInputs") + .unwrap_or(false)); + + let si_idx = match si_idx { + Some(idx) => idx, + None => return, + }; + + // Find the slot index for this track + let slot = { + let gn = self.audio_graph.get_graph_node(si_idx).unwrap(); + let si = gn.node.as_any().downcast_ref::().unwrap(); + si.subtrack_index_for(track_id) + }; + let slot = match slot { + Some(s) => s, + None => return, + }; + + // Remove all connections from this output port + self.audio_graph.disconnect_output_port(si_idx, slot); + + // Update the SubtrackInputsNode's subtrack list + { + let gn = self.audio_graph.get_graph_node_mut(si_idx).unwrap(); + let si = gn.node.as_any_mut().downcast_mut::().unwrap(); + let mut subtracks = si.subtracks().to_vec(); + subtracks.remove(slot); + si.update_subtracks(subtracks, buffer_size); + } + self.audio_graph.reallocate_node_output_buffers(si_idx, buffer_size); + + if self.graph_is_default { + // Rebuild default Mixer connections (they've shifted after removal) + let mixer_idx = self.audio_graph.node_indices() + .find(|&idx| self.audio_graph.get_graph_node(idx) + .map(|n| n.node.node_type() == "Mixer") + .unwrap_or(false)); + + if let Some(mixer_idx) = mixer_idx { + // Clear all connections TO mixer + self.audio_graph.disconnect_all_inputs(mixer_idx); + // Get new subtrack count + let n = { + let gn = self.audio_graph.get_graph_node(si_idx).unwrap(); + gn.node.as_any().downcast_ref::().unwrap().num_subtracks() + }; + // Resize mixer and reconnect + { + let gn = self.audio_graph.get_graph_node_mut(mixer_idx).unwrap(); + let mixer = gn.node.as_any_mut().downcast_mut::().unwrap(); + mixer.resize(n + 1); + } + for i in 0..n { + let _ = self.audio_graph.connect(si_idx, i, mixer_idx, i); + } + } + } + } + + /// Return the current ordered subtrack list from SubtrackInputsNode, or empty vec if none. + pub fn current_subtracks(&self) -> Vec<(TrackId, String)> { + use super::node_graph::nodes::SubtrackInputsNode; + for idx in self.audio_graph.node_indices().collect::>() { + if let Some(gn) = self.audio_graph.get_graph_node(idx) { + if let Some(si) = gn.node.as_any().downcast_ref::() { + return si.subtracks().to_vec(); + } + } + } + Vec::new() + } + + /// Prepare for serialization by saving the audio graph as a preset + pub fn prepare_for_save(&mut self) { + self.audio_graph_preset = Some(self.audio_graph.to_preset("Metatrack Graph")); + } + + /// Rebuild the audio graph from preset after deserialization. + /// + /// After loading, the caller must call `update_subtrack_ids` to re-associate + /// backend TrackIds with the SubtrackInputsNode's port slots. + pub fn rebuild_audio_graph(&mut self, sample_rate: u32, buffer_size: usize) -> Result<(), String> { + if let Some(preset) = &self.audio_graph_preset { + if !preset.nodes.is_empty() && preset.output_node.is_some() { + self.audio_graph = AudioGraph::from_preset(preset, sample_rate, buffer_size, None, None)?; + // graph_is_default remains as serialized (false for user-modified graphs) + } else { + self.audio_graph = Self::create_empty_graph(sample_rate, buffer_size); + self.graph_is_default = true; + } + } else { + self.audio_graph = Self::create_empty_graph(sample_rate, buffer_size); + self.graph_is_default = true; + } + Ok(()) + } + + /// Re-associate backend TrackIds with the SubtrackInputsNode's port slots after reload. + /// + /// The preset stores placeholder TrackId=0 entries; this call fills in the real IDs. + pub fn update_subtrack_ids(&mut self, subtracks: Vec<(TrackId, String)>, buffer_size: usize) { + use super::node_graph::nodes::SubtrackInputsNode; + + for idx in self.audio_graph.node_indices().collect::>() { + if let Some(gn) = self.audio_graph.get_graph_node_mut(idx) { + if let Some(si) = gn.node.as_any_mut().downcast_mut::() { + si.update_subtracks(subtracks, buffer_size); + return; + } + } + } + } + + /// Add an automation lane to this metatrack + pub fn add_automation_lane(&mut self, parameter_id: ParameterId) -> AutomationLaneId { + let lane_id = self.next_automation_id; + self.next_automation_id += 1; + + let lane = AutomationLane::new(lane_id, parameter_id); + self.automation_lanes.insert(lane_id, lane); + lane_id + } + + /// Get an automation lane by ID + pub fn get_automation_lane(&self, lane_id: AutomationLaneId) -> Option<&AutomationLane> { + self.automation_lanes.get(&lane_id) + } + + /// Get a mutable automation lane by ID + pub fn get_automation_lane_mut(&mut self, lane_id: AutomationLaneId) -> Option<&mut AutomationLane> { + self.automation_lanes.get_mut(&lane_id) + } + + /// Remove an automation lane + pub fn remove_automation_lane(&mut self, lane_id: AutomationLaneId) -> bool { + self.automation_lanes.remove(&lane_id).is_some() + } + + /// Evaluate automation at a specific time and return effective parameters + pub fn evaluate_automation_at_time(&self, time: Beats) -> (f32, f32, Seconds) { + let mut volume = self.volume; + let mut time_stretch = self.time_stretch; + let mut offset = self.offset; + + // Check for automation + for lane in self.automation_lanes.values() { + if !lane.enabled { + continue; + } + + match lane.parameter_id { + ParameterId::TrackVolume => { + if let Some(automated_value) = lane.evaluate(time) { + volume = automated_value; + } + } + ParameterId::TimeStretch => { + if let Some(automated_value) = lane.evaluate(time) { + time_stretch = automated_value; + } + } + ParameterId::TimeOffset => { + if let Some(automated_value) = lane.evaluate(time) { + offset = Seconds(automated_value as f64); + } + } + _ => {} + } + } + + (volume, time_stretch, offset) + } + + /// Add a child track to this group + pub fn add_child(&mut self, track_id: TrackId) { + if !self.children.contains(&track_id) { + self.children.push(track_id); + } + } + + /// Remove a child track from this group + pub fn remove_child(&mut self, track_id: TrackId) { + self.children.retain(|&id| id != track_id); + } + + /// Set group volume + pub fn set_volume(&mut self, volume: f32) { + self.volume = volume.max(0.0); + } + + /// Set mute state + pub fn set_muted(&mut self, muted: bool) { + self.muted = muted; + } + + /// Set solo state + pub fn set_solo(&mut self, solo: bool) { + self.solo = solo; + } + + /// Check if this group should be audible given the solo state + pub fn is_active(&self, any_solo: bool) -> bool { + !self.muted && (!any_solo || self.solo) + } + + /// Check whether this metatrack should produce audio at the given parent time. + /// Returns false if the playhead is outside the trim window. + pub fn is_active_at_time(&self, parent_playhead: Seconds) -> bool { + let local_time = (parent_playhead - self.offset) * self.time_stretch as f64; + if local_time < self.trim_start { + return false; + } + if let Some(end) = self.trim_end { + if local_time >= end { + return false; + } + } + true + } + + /// Transform a render context for this metatrack's children + /// + /// Applies time stretching, offset, and trim transformations. + /// Time stretch affects how fast content plays: 0.5 = half speed, 2.0 = double speed + /// Offset shifts content forward/backward in time + /// Trim start offsets into the internal content + pub fn transform_context<'a>(&self, ctx: RenderContext<'a>) -> RenderContext<'a> { + let mut transformed = ctx; + + // Apply transformations in order: + // 1. First, subtract offset (positive offset = content appears later) + // At parent time 0.0s with offset=2.0s, child sees -2.0s (before content starts) + // At parent time 2.0s with offset=2.0s, child sees 0.0s (content starts) + let adjusted_playhead = transformed.playhead_seconds - self.offset; + + // 2. Then apply time stretch (< 1.0 = slower/half speed, > 1.0 = faster/double speed) + // With stretch=0.5, when parent time is 2.0s, child reads from 1.0s (plays slower, pitches down) + // With stretch=2.0, when parent time is 2.0s, child reads from 4.0s (plays faster, pitches up) + // Note: This creates pitch shift as well - true time stretching would require resampling + let stretched = adjusted_playhead * self.time_stretch as f64; + + // 3. Add trim_start so children see time starting from the trim point + // If trim_start=2.0s, children start seeing time 2.0s when parent reaches offset + transformed.playhead_seconds = stretched + self.trim_start; + + // Accumulate time stretch for nested metatracks + transformed.time_stretch *= self.time_stretch; + + transformed + } +} + +/// MIDI track with MIDI clip instances and a node-based instrument +#[derive(Debug, Serialize, Deserialize)] +pub struct MidiTrack { + pub id: TrackId, + pub name: String, + /// Clip instances placed on this track (reference clips in the MidiClipPool) + pub clip_instances: Vec, + + /// Serialized instrument graph (used for save/load) + #[serde(default, skip_serializing_if = "Option::is_none")] + instrument_graph_preset: Option, + + /// Runtime instrument graph (rebuilt from preset on load) + #[serde(skip, default = "default_audio_graph")] + pub instrument_graph: AudioGraph, + + pub volume: f32, + pub muted: bool, + pub solo: bool, + /// Automation lanes for this track + pub automation_lanes: HashMap, + next_automation_id: AutomationLaneId, + /// Queue for live MIDI input (virtual keyboard, MIDI controllers) + #[serde(skip)] + live_midi_queue: Vec, + /// Clip instances that were active (overlapping playhead) in the previous render buffer. + /// Used to detect when the playhead exits a clip, so we can send all-notes-off. + #[serde(skip)] + prev_active_instances: HashSet, + + /// Peak level of last render() call (for VU metering) + #[serde(skip, default)] + pub peak_level: f32, + + /// True while the instrument graph is still the auto-generated default (no user edits). + /// Used to prompt before loading a preset. + #[serde(default)] + pub graph_is_default: bool, +} + +impl Clone for MidiTrack { + fn clone(&self) -> Self { + Self { + id: self.id, + name: self.name.clone(), + clip_instances: self.clip_instances.clone(), + instrument_graph_preset: self.instrument_graph_preset.clone(), + instrument_graph: default_audio_graph(), // Create fresh graph, not cloned + volume: self.volume, + muted: self.muted, + solo: self.solo, + automation_lanes: self.automation_lanes.clone(), + next_automation_id: self.next_automation_id, + live_midi_queue: Vec::new(), // Don't clone live MIDI queue + prev_active_instances: HashSet::new(), + peak_level: 0.0, + graph_is_default: self.graph_is_default, + } + } +} + +impl MidiTrack { + /// Create a new MIDI track with default settings + pub fn new(id: TrackId, name: String, sample_rate: u32) -> Self { + // Use a large buffer size that can accommodate any callback + let default_buffer_size = 8192; + + // Start with empty graph — the frontend loads a default instrument preset + // (bass.json) via graph_load_preset which replaces the entire graph + let instrument_graph = AudioGraph::new(sample_rate, default_buffer_size); + + Self { + id, + name, + clip_instances: Vec::new(), + instrument_graph_preset: None, + instrument_graph, + volume: 1.0, + muted: false, + solo: false, + automation_lanes: HashMap::new(), + next_automation_id: 0, + live_midi_queue: Vec::new(), + prev_active_instances: HashSet::new(), + peak_level: 0.0, + graph_is_default: true, + } + } + + /// Prepare for serialization by saving the instrument graph as a preset + pub fn prepare_for_save(&mut self) { + self.instrument_graph_preset = Some(self.instrument_graph.to_preset("Instrument Graph")); + } + + /// Rebuild the instrument graph from preset after deserialization + pub fn rebuild_audio_graph(&mut self, sample_rate: u32, buffer_size: usize) -> Result<(), String> { + if let Some(preset) = &self.instrument_graph_preset { + self.instrument_graph = AudioGraph::from_preset(preset, sample_rate, buffer_size, None, None)?; + } else { + // No preset - create default graph + self.instrument_graph = AudioGraph::new(sample_rate, buffer_size); + } + Ok(()) + } + + /// Add an automation lane to this track + pub fn add_automation_lane(&mut self, parameter_id: ParameterId) -> AutomationLaneId { + let lane_id = self.next_automation_id; + self.next_automation_id += 1; + + let lane = AutomationLane::new(lane_id, parameter_id); + self.automation_lanes.insert(lane_id, lane); + lane_id + } + + /// Get an automation lane by ID + pub fn get_automation_lane(&self, lane_id: AutomationLaneId) -> Option<&AutomationLane> { + self.automation_lanes.get(&lane_id) + } + + /// Get a mutable automation lane by ID + pub fn get_automation_lane_mut(&mut self, lane_id: AutomationLaneId) -> Option<&mut AutomationLane> { + self.automation_lanes.get_mut(&lane_id) + } + + /// Remove an automation lane + pub fn remove_automation_lane(&mut self, lane_id: AutomationLaneId) -> bool { + self.automation_lanes.remove(&lane_id).is_some() + } + + /// Add a MIDI clip instance to this track + pub fn add_clip_instance(&mut self, instance: MidiClipInstance) { + self.clip_instances.push(instance); + } + + /// Remove a MIDI clip instance from this track by instance ID (for undo/redo support) + pub fn remove_midi_clip_instance(&mut self, instance_id: MidiClipInstanceId) { + self.clip_instances.retain(|instance| instance.id != instance_id); + } + + /// Set track volume + pub fn set_volume(&mut self, volume: f32) { + self.volume = volume.max(0.0); + } + + /// Set mute state + pub fn set_muted(&mut self, muted: bool) { + self.muted = muted; + } + + /// Set solo state + pub fn set_solo(&mut self, solo: bool) { + self.solo = solo; + } + + /// Check if this track should be audible given the solo state + pub fn is_active(&self, any_solo: bool) -> bool { + !self.muted && (!any_solo || self.solo) + } + + /// Stop all currently playing notes on this track's instrument + /// Note: With node-based instruments, stopping is handled by ceasing MIDI input + pub fn stop_all_notes(&mut self) { + // Send note-off for all 128 possible MIDI notes to silence the instrument + let mut note_offs = Vec::new(); + for note in 0..128 { + note_offs.push(MidiEvent::note_off(Beats::ZERO, 0, note, 0)); + } + + // Create a silent buffer to process the note-offs + let buffer_size = 512 * 2; // stereo + let mut silent_buffer = vec![0.0f32; buffer_size]; + self.instrument_graph.process(&mut silent_buffer, ¬e_offs, Beats::ZERO); + } + + /// Queue a live MIDI event (from virtual keyboard or MIDI controller) + pub fn queue_live_midi(&mut self, event: MidiEvent) { + self.live_midi_queue.push(event); + } + + /// Clear the live MIDI queue + pub fn clear_live_midi_queue(&mut self) { + self.live_midi_queue.clear(); + } + + /// Render this MIDI track into the output buffer. + /// + /// When `ctx.live_only` is true, clip event collection is skipped and only the live MIDI + /// queue is processed. This lets note-off tails (and live keyboard input) route through + /// the normal group hierarchy without re-triggering notes from clips at the paused position. + pub fn render( + &mut self, + output: &mut [f32], + midi_pool: &MidiClipPool, + ctx: RenderContext, + ) { + let mut midi_events = Vec::new(); + + if !ctx.live_only { + let playhead_beats = ctx.playhead_beats(); + let buffer_end_beats = ctx.buffer_end_beats(); + + // Collect MIDI events from all clip instances that overlap with current beat range + let mut currently_active = HashSet::new(); + for instance in &self.clip_instances { + if instance.overlaps_range(playhead_beats, buffer_end_beats) { + currently_active.insert(instance.id); + } + if let Some(clip) = midi_pool.get_clip(instance.clip_id) { + let events = instance.get_events_in_range(clip, playhead_beats, buffer_end_beats); + midi_events.extend(events); + } + } + + // Send all-notes-off for clip instances that just became inactive + for prev_id in &self.prev_active_instances { + if !currently_active.contains(prev_id) { + for note in 0..128u8 { + midi_events.push(MidiEvent::note_off(playhead_beats, 0, note, 0)); + } + break; + } + } + self.prev_active_instances = currently_active; + } + + // Add live MIDI events (from virtual keyboard or MIDI controllers) + midi_events.extend(self.live_midi_queue.drain(..)); + + // Generate audio using instrument graph + self.instrument_graph.process(output, &midi_events, ctx.playhead_beats()); + + // Evaluate and apply automation (skip automation in live_only mode — no playhead to evaluate at) + let effective_volume = if ctx.live_only { self.volume } else { self.evaluate_automation_at_time(ctx.playhead_beats()) }; + + // Apply track volume + for sample in output.iter_mut() { + *sample *= effective_volume; + } + } + + /// Evaluate automation at a specific time and return the effective volume + fn evaluate_automation_at_time(&self, time: Beats) -> f32 { + let mut volume = self.volume; + + // Check for volume automation + for lane in self.automation_lanes.values() { + if !lane.enabled { + continue; + } + + match lane.parameter_id { + ParameterId::TrackVolume => { + if let Some(automated_value) = lane.evaluate(time) { + volume = automated_value; + } + } + _ => {} + } + } + + volume + } +} + +/// Audio track with audio clip instances +#[derive(Debug, Serialize, Deserialize)] +pub struct AudioTrack { + pub id: TrackId, + pub name: String, + /// Audio clip instances (reference content in the AudioClipPool) + pub clips: Vec, + pub volume: f32, + pub muted: bool, + pub solo: bool, + /// Automation lanes for this track + pub automation_lanes: HashMap, + next_automation_id: AutomationLaneId, + + /// Serialized effects graph (used for save/load) + #[serde(default, skip_serializing_if = "Option::is_none")] + effects_graph_preset: Option, + + /// Runtime effects processing graph (rebuilt from preset on load) + #[serde(skip, default = "default_audio_graph")] + pub effects_graph: AudioGraph, + + /// Pre-allocated buffer for clip rendering (avoids heap allocation per callback) + #[serde(skip, default)] + clip_render_buffer: Vec, + + /// Peak level of last render() call (for VU metering) + #[serde(skip, default)] + pub peak_level: f32, + + /// True while the effects graph is still the auto-generated default (no user edits). + /// Used to prompt before loading a preset. + #[serde(default)] + pub graph_is_default: bool, +} + +impl Clone for AudioTrack { + fn clone(&self) -> Self { + Self { + id: self.id, + name: self.name.clone(), + clips: self.clips.clone(), + volume: self.volume, + muted: self.muted, + solo: self.solo, + automation_lanes: self.automation_lanes.clone(), + next_automation_id: self.next_automation_id, + effects_graph_preset: self.effects_graph_preset.clone(), + effects_graph: default_audio_graph(), // Create fresh graph, not cloned + clip_render_buffer: Vec::new(), + peak_level: 0.0, + graph_is_default: self.graph_is_default, + } + } +} + +impl AudioTrack { + /// Create a new audio track with default settings + pub fn new(id: TrackId, name: String, sample_rate: u32) -> Self { + // Use a large buffer size that can accommodate any callback + let default_buffer_size = 8192; + + // Create the effects graph with default AudioInput -> AudioOutput chain + let mut effects_graph = AudioGraph::new(sample_rate, default_buffer_size); + + // Add AudioInput node + let input_node = Box::new(AudioInputNode::new("Audio Input")); + let input_id = effects_graph.add_node(input_node); + // Set position for AudioInput (left side, similar to instrument preset spacing) + effects_graph.set_node_position(input_id, 100.0, 150.0); + + // Add AudioOutput node + let output_node = Box::new(AudioOutputNode::new("Audio Output")); + let output_id = effects_graph.add_node(output_node); + // Set position for AudioOutput (right side, spaced apart) + effects_graph.set_node_position(output_id, 500.0, 150.0); + + // Connect AudioInput -> AudioOutput + let _ = effects_graph.connect(input_id, 0, output_id, 0); + + // Set the AudioOutput node as the graph's output + effects_graph.set_output_node(Some(output_id)); + + Self { + id, + name, + clips: Vec::new(), + volume: 1.0, + muted: false, + solo: false, + automation_lanes: HashMap::new(), + next_automation_id: 0, + effects_graph_preset: None, + effects_graph, + clip_render_buffer: Vec::new(), + peak_level: 0.0, + graph_is_default: true, + } + } + + /// Prepare for serialization by saving the effects graph as a preset + pub fn prepare_for_save(&mut self) { + self.effects_graph_preset = Some(self.effects_graph.to_preset("Effects Graph")); + } + + /// Rebuild the effects graph from preset after deserialization + pub fn rebuild_audio_graph(&mut self, sample_rate: u32, buffer_size: usize) -> Result<(), String> { + if let Some(preset) = &self.effects_graph_preset { + // Check if preset is empty or missing required nodes + let has_nodes = !preset.nodes.is_empty(); + let has_output = preset.output_node.is_some(); + + if has_nodes && has_output { + // Valid preset - rebuild from it + self.effects_graph = AudioGraph::from_preset(preset, sample_rate, buffer_size, None, None)?; + } else { + // Empty or invalid preset - create default graph + self.effects_graph = Self::create_default_graph(sample_rate, buffer_size); + } + } else { + // No preset - create default graph + self.effects_graph = Self::create_default_graph(sample_rate, buffer_size); + } + Ok(()) + } + + /// Create a default effects graph with AudioInput -> AudioOutput + fn create_default_graph(sample_rate: u32, buffer_size: usize) -> AudioGraph { + let mut effects_graph = AudioGraph::new(sample_rate, buffer_size); + + // Add AudioInput node + let input_node = Box::new(AudioInputNode::new("Audio Input")); + let input_id = effects_graph.add_node(input_node); + effects_graph.set_node_position(input_id, 100.0, 150.0); + + // Add AudioOutput node + let output_node = Box::new(AudioOutputNode::new("Audio Output")); + let output_id = effects_graph.add_node(output_node); + effects_graph.set_node_position(output_id, 500.0, 150.0); + + // Connect AudioInput -> AudioOutput + let _ = effects_graph.connect(input_id, 0, output_id, 0); + + // Set the AudioOutput node as the graph's output + effects_graph.set_output_node(Some(output_id)); + + effects_graph + } + + /// Add an automation lane to this track + pub fn add_automation_lane(&mut self, parameter_id: ParameterId) -> AutomationLaneId { + let lane_id = self.next_automation_id; + self.next_automation_id += 1; + + let lane = AutomationLane::new(lane_id, parameter_id); + self.automation_lanes.insert(lane_id, lane); + lane_id + } + + /// Get an automation lane by ID + pub fn get_automation_lane(&self, lane_id: AutomationLaneId) -> Option<&AutomationLane> { + self.automation_lanes.get(&lane_id) + } + + /// Get a mutable automation lane by ID + pub fn get_automation_lane_mut(&mut self, lane_id: AutomationLaneId) -> Option<&mut AutomationLane> { + self.automation_lanes.get_mut(&lane_id) + } + + /// Remove an automation lane + pub fn remove_automation_lane(&mut self, lane_id: AutomationLaneId) -> bool { + self.automation_lanes.remove(&lane_id).is_some() + } + + /// Add an audio clip instance to this track + pub fn add_clip(&mut self, clip: AudioClipInstance) { + self.clips.push(clip); + } + + /// Remove an audio clip instance from this track by instance ID (for undo/redo support) + pub fn remove_audio_clip_instance(&mut self, instance_id: AudioClipInstanceId) { + self.clips.retain(|instance| instance.id != instance_id); + } + + /// Set track volume (0.0 = silence, 1.0 = unity gain, >1.0 = amplification) + pub fn set_volume(&mut self, volume: f32) { + self.volume = volume.max(0.0); + } + + /// Set mute state + pub fn set_muted(&mut self, muted: bool) { + self.muted = muted; + } + + /// Set solo state + pub fn set_solo(&mut self, solo: bool) { + self.solo = solo; + } + + /// Check if this track should be audible given the solo state of all tracks + pub fn is_active(&self, any_solo: bool) -> bool { + !self.muted && (!any_solo || self.solo) + } + + /// Render this track into the output buffer at a given timeline position + /// Returns the number of samples actually rendered + pub fn render( + &mut self, + output: &mut [f32], + pool: &AudioClipPool, + ctx: RenderContext<'_>, + ) -> usize { + let buffer_end = ctx.buffer_end(); + + // Split borrow: take clip_render_buffer out to avoid borrow conflict with &self methods + let mut clip_buffer = std::mem::take(&mut self.clip_render_buffer); + clip_buffer.resize(output.len(), 0.0); + clip_buffer.fill(0.0); + let mut rendered = 0; + + // Render all active clip instances into the buffer + for clip in &self.clips { + if clip.external_start_secs(ctx.tempo_map) < buffer_end && clip.external_end_secs(ctx.tempo_map) > ctx.playhead_seconds { + rendered += self.render_clip(clip, &mut clip_buffer, pool, ctx); + } + } + + // Find and inject audio into the AudioInputNode + let node_indices: Vec<_> = self.effects_graph.node_indices().collect(); + for node_idx in node_indices { + if let Some(graph_node) = self.effects_graph.get_graph_node_mut(node_idx) { + if graph_node.node.node_type() == "AudioInput" { + if let Some(input_node) = graph_node.node.as_any_mut().downcast_mut::() { + input_node.inject_audio(&clip_buffer); + break; + } + } + } + } + + // Process through the effects graph (this will write to output buffer) + self.effects_graph.process(output, &[], ctx.playhead_beats()); + + // Put the buffer back for reuse next callback + self.clip_render_buffer = clip_buffer; + + // Evaluate and apply automation + let effective_volume = self.evaluate_automation_at_time(ctx.playhead_beats()); + + // Apply track volume + for sample in output.iter_mut() { + *sample *= effective_volume; + } + + rendered + } + + /// Evaluate automation at a specific time and return the effective volume + fn evaluate_automation_at_time(&self, time: Beats) -> f32 { + let mut volume = self.volume; + + // Check for volume automation + for lane in self.automation_lanes.values() { + if !lane.enabled { + continue; + } + + match lane.parameter_id { + ParameterId::TrackVolume => { + if let Some(automated_value) = lane.evaluate(time) { + volume = automated_value; + } + } + _ => {} + } + } + + volume + } + + /// Render a single audio clip instance into the output buffer + /// Handles looping when external_duration > internal_duration + fn render_clip( + &self, + clip: &AudioClipInstance, + output: &mut [f32], + pool: &AudioClipPool, + ctx: RenderContext<'_>, + ) -> usize { + let playhead = ctx.playhead_seconds; + let buffer_end = ctx.buffer_end(); + let tempo_map = ctx.tempo_map; + let sample_rate = ctx.sample_rate; + let channels = ctx.channels; + + // Determine the time range we need to render (intersection of buffer and clip external bounds) + let render_start = playhead.max(clip.external_start_secs(tempo_map)); + let render_end = buffer_end.min(clip.external_end_secs(tempo_map)); + + if render_start >= render_end { + return 0; + } + + let internal_duration = clip.internal_duration(); + if internal_duration <= Seconds::ZERO { + return 0; + } + + let combined_gain = clip.gain; + let mut total_rendered = 0; + let samples_per_second = sample_rate as f64 * channels as f64; + + let output_start_offset = ((render_start - playhead).0 * samples_per_second + 0.5) as usize; + let output_end_offset = ((render_end - playhead).0 * samples_per_second + 0.5) as usize; + + if output_end_offset > output.len() || output_start_offset > output.len() { + return 0; + } + + if !clip.is_looping(tempo_map) { + let content_start = clip.get_content_position(render_start, tempo_map).unwrap_or(clip.internal_start); + let output_len = output.len(); + let output_slice = &mut output[output_start_offset..output_end_offset.min(output_len)]; + + total_rendered = pool.render_from_file( + clip.audio_pool_index, + output_slice, + content_start, + combined_gain, + sample_rate, + channels, + clip.read_ahead.as_deref(), + ); + } else { + // Looping case: handle wrap-around at loop boundaries + let mut timeline_pos = render_start; + let mut output_offset = output_start_offset; + + while timeline_pos < render_end && output_offset < output.len() { + let relative_pos = timeline_pos - clip.external_start_secs(tempo_map); + let loop_offset = relative_pos.0 % internal_duration.0; + let content_pos = clip.internal_start + Seconds(loop_offset); + + let time_to_loop_end = Seconds(internal_duration.0 - loop_offset); + let time_to_render_end = render_end - timeline_pos; + let chunk_duration = time_to_loop_end.min(time_to_render_end); + + let chunk_samples = (chunk_duration.0 * samples_per_second) as usize; + let chunk_samples = chunk_samples.min(output.len() - output_offset); + + if chunk_samples == 0 { + break; + } + + let output_slice = &mut output[output_offset..output_offset + chunk_samples]; + + let rendered = pool.render_from_file( + clip.audio_pool_index, + output_slice, + content_pos, + combined_gain, + sample_rate, + channels, + clip.read_ahead.as_deref(), + ); + + total_rendered += rendered; + output_offset += chunk_samples; + timeline_pos = timeline_pos + chunk_duration; + } + } + + total_rendered + } +} diff --git a/daw-backend/src/audio/waveform_cache.rs b/daw-backend/src/audio/waveform_cache.rs new file mode 100644 index 0000000..feb3913 --- /dev/null +++ b/daw-backend/src/audio/waveform_cache.rs @@ -0,0 +1,290 @@ +//! Waveform chunk cache for scalable multi-resolution waveform generation +//! +//! This module provides a chunk-based waveform caching system that generates +//! waveform data progressively at multiple detail levels, avoiding the limitations +//! of the old fixed 20,000-peak approach. + +use crate::io::{WaveformChunk, WaveformChunkKey, WaveformPeak}; +use crate::audio::pool::AudioFile; +use std::collections::HashMap; + +/// Detail levels for multi-resolution waveform storage +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DetailLevel { + Overview = 0, // 1 peak per second + Low = 1, // 10 peaks per second + Medium = 2, // 100 peaks per second + High = 3, // 1000 peaks per second + Max = 4, // Full resolution (sample-accurate) +} + +impl DetailLevel { + /// Get peaks per second for this detail level + pub fn peaks_per_second(self) -> usize { + match self { + DetailLevel::Overview => 1, + DetailLevel::Low => 10, + DetailLevel::Medium => 100, + DetailLevel::High => 1000, + DetailLevel::Max => 48000, // Approximate max for sample-accurate + } + } + + /// Create from u8 value + pub fn from_u8(value: u8) -> Option { + 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>, + + /// 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> { + self.chunks.get(key) + } + + /// Store a chunk in the cache + pub fn store_chunk(&mut self, key: WaveformChunkKey, peaks: Vec) { + let chunk_size = peaks.len() * std::mem::size_of::(); + 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::(); + 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 { + 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 { + 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 { + let duration = audio_file.duration_seconds(); + let chunk_count = Self::calculate_chunk_count(duration, 0); + + let chunk_indices: Vec = (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); + } +} diff --git a/daw-backend/src/command/mod.rs b/daw-backend/src/command/mod.rs new file mode 100644 index 0000000..5baaac2 --- /dev/null +++ b/daw-backend/src/command/mod.rs @@ -0,0 +1,3 @@ +pub mod types; + +pub use types::{AudioEvent, Command, MidiClipData, OscilloscopeData, Query, QueryResponse}; diff --git a/daw-backend/src/command/types.rs b/daw-backend/src/command/types.rs new file mode 100644 index 0000000..5d481cb --- /dev/null +++ b/daw-backend/src/command/types.rs @@ -0,0 +1,535 @@ +use crate::audio::{ + AudioClipInstanceId, AutomationLaneId, ClipId, CurveType, MidiClip, MidiClipId, + MidiClipInstanceId, ParameterId, TrackId, +}; +use crate::audio::midi::MidiEvent; +use crate::audio::buffer_pool::BufferPoolStats; +use crate::audio::node_graph::nodes::LoopMode; +use crate::io::WaveformPeak; +use crate::time::{Beats, Seconds}; + +/// Commands sent from UI/control thread to audio thread +#[derive(Debug, Clone)] +pub enum Command { + // Transport commands + /// Start playback + Play, + /// Stop playback and reset to beginning + Stop, + /// Pause playback (maintains position) + Pause, + /// Seek to a specific position in seconds + Seek(f64), + + // Track management commands + /// Set track volume (0.0 = silence, 1.0 = unity gain) + SetTrackVolume(TrackId, f32), + /// Set track mute state + SetTrackMute(TrackId, bool), + /// Set track solo state + SetTrackSolo(TrackId, bool), + + // Clip management commands + /// Move a clip to a new timeline position (track_id, clip_id, new_external_start) + MoveClip(TrackId, ClipId, f64), + /// Trim a clip's internal boundaries (track_id, clip_id, new_internal_start, new_internal_end) + /// This changes which portion of the source content is used + TrimClip(TrackId, ClipId, f64, f64), + /// Extend/shrink a clip's external duration (track_id, clip_id, new_external_duration) + /// If duration > internal duration, the clip will loop + ExtendClip(TrackId, ClipId, f64), + + // Metatrack management commands + /// Create a new metatrack with a name and optional parent group + CreateMetatrack(String, Option), + /// Add a track to a metatrack (track_id, metatrack_id) + AddToMetatrack(TrackId, TrackId), + /// Remove a track from its parent metatrack + RemoveFromMetatrack(TrackId), + + // Metatrack transformation commands + /// Set metatrack time stretch factor (track_id, stretch_factor) + /// 0.5 = half speed, 1.0 = normal, 2.0 = double speed + SetTimeStretch(TrackId, f32), + /// Set metatrack time offset in seconds (track_id, offset) + /// Positive = shift content later, negative = shift earlier + SetOffset(TrackId, f64), + /// Set metatrack pitch shift in semitones (track_id, semitones) - for future use + SetPitchShift(TrackId, f32), + /// Set metatrack trim start in seconds (track_id, trim_start) + /// Children won't hear content before this point + SetTrimStart(TrackId, f64), + /// Set metatrack trim end in seconds (track_id, trim_end) + /// None means no end trim + SetTrimEnd(TrackId, Option), + + // Audio track commands + /// Create a new audio track with a name and optional parent group + CreateAudioTrack(String, Option), + /// Add an audio file to the pool (path, data, channels, sample_rate) + /// Returns the pool index via an AudioEvent + AddAudioFile(String, Vec, u32, u32), + /// Add a clip to an audio track (track_id, clip_id, pool_index, start_time, duration, offset) + /// The clip_id is pre-assigned by the caller (via EngineController::next_audio_clip_id()) + AddAudioClip(TrackId, AudioClipInstanceId, usize, f64, f64, f64), + + // MIDI commands + /// Create a new MIDI track with a name and optional parent group + CreateMidiTrack(String, Option), + /// Add a MIDI clip to the pool without placing it on a track + AddMidiClipToPool(MidiClip), + /// Create a new MIDI clip on a track (track_id, start_time, duration) + CreateMidiClip(TrackId, f64, f64), + /// Add a MIDI note to a clip (track_id, clip_id, time_offset, note, velocity, duration) + AddMidiNote(TrackId, MidiClipId, f64, u8, u8, f64), + /// Add a pre-loaded MIDI clip to a track (track_id, clip, start_time) + AddLoadedMidiClip(TrackId, MidiClip, f64), + /// Update MIDI clip notes (track_id, clip_id, notes: Vec<(start_time, note, velocity, duration)>) + /// NOTE: May need to switch to individual note operations if this becomes slow on clips with many notes + UpdateMidiClipNotes(TrackId, MidiClipId, Vec<(f64, u8, u8, f64)>), + /// Replace all events in a MIDI clip (track_id, clip_id, events). Used for CC/pitch bend editing. + UpdateMidiClipEvents(TrackId, MidiClipId, Vec), + /// Remove a MIDI clip instance from a track (track_id, instance_id) - for undo/redo support + RemoveMidiClip(TrackId, MidiClipInstanceId), + /// Remove an audio clip instance from a track (track_id, instance_id) - for undo/redo support + RemoveAudioClip(TrackId, AudioClipInstanceId), + + // Diagnostics commands + /// Request buffer pool statistics + RequestBufferPoolStats, + + // Automation commands + /// Create a new automation lane on a track (track_id, parameter_id) + CreateAutomationLane(TrackId, ParameterId), + /// Add an automation point to a lane (track_id, lane_id, time, value, curve) + AddAutomationPoint(TrackId, AutomationLaneId, f64, f32, CurveType), + /// Remove an automation point at a specific time (track_id, lane_id, time, tolerance) + RemoveAutomationPoint(TrackId, AutomationLaneId, f64, f64), + /// Clear all automation points from a lane (track_id, lane_id) + ClearAutomationLane(TrackId, AutomationLaneId), + /// Remove an automation lane (track_id, lane_id) + RemoveAutomationLane(TrackId, AutomationLaneId), + /// Enable/disable an automation lane (track_id, lane_id, enabled) + SetAutomationLaneEnabled(TrackId, AutomationLaneId, bool), + + // Recording commands + /// Start recording on a track (track_id, start_time) + StartRecording(TrackId, Beats), + /// Stop the current recording + StopRecording, + /// Pause the current recording + PauseRecording, + /// Resume the current recording + ResumeRecording, + + // MIDI Recording commands + /// Start MIDI recording on a track (track_id, clip_id, start_time) + StartMidiRecording(TrackId, MidiClipId, Beats), + /// Stop the current MIDI recording + StopMidiRecording, + + // Project commands + /// Reset the entire project (remove all tracks, clear audio pool, reset state) + Reset, + + // Live MIDI input commands + /// Send a live MIDI note on event to a track's instrument (track_id, note, velocity) + SendMidiNoteOn(TrackId, u8, u8), + /// Send a live MIDI note off event to a track's instrument (track_id, note) + SendMidiNoteOff(TrackId, u8), + /// Set the active MIDI track for external MIDI input routing (track_id or None) + SetActiveMidiTrack(Option), + + // Metronome command + /// Enable or disable the metronome click track + SetMetronomeEnabled(bool), + /// Set project tempo and time signature (bpm, (numerator, denominator)) + SetTempo(f32, (u32, u32)), + /// Replace the entire tempo map (multi-entry variable tempo support) + SetTempoMap(crate::TempoMap), + // Node graph commands + /// Add a node to a track's instrument graph (track_id, node_type, position_x, position_y) + GraphAddNode(TrackId, String, f32, f32), + /// Add a node to a VoiceAllocator's template graph (track_id, voice_allocator_node_id, node_type, position_x, position_y) + GraphAddNodeToTemplate(TrackId, u32, String, f32, f32), + /// Remove a node from a track's instrument graph (track_id, node_index) + GraphRemoveNode(TrackId, u32), + /// Connect two nodes in a track's graph (track_id, from_node, from_port, to_node, to_port) + GraphConnect(TrackId, u32, usize, u32, usize), + /// Connect nodes in a VoiceAllocator template (track_id, voice_allocator_node_id, from_node, from_port, to_node, to_port) + GraphConnectInTemplate(TrackId, u32, u32, usize, u32, usize), + /// Disconnect two nodes in a track's graph (track_id, from_node, from_port, to_node, to_port) + GraphDisconnect(TrackId, u32, usize, u32, usize), + /// Disconnect nodes in a VoiceAllocator template (track_id, voice_allocator_node_id, from_node, from_port, to_node, to_port) + GraphDisconnectInTemplate(TrackId, u32, u32, usize, u32, usize), + /// Remove a node from a VoiceAllocator's template graph (track_id, voice_allocator_node_id, node_index) + GraphRemoveNodeFromTemplate(TrackId, u32, u32), + /// Set a parameter on a node (track_id, node_index, param_id, value) + GraphSetParameter(TrackId, u32, u32, f32), + /// Set a parameter on a node in a VoiceAllocator's template graph (track_id, voice_allocator_node_id, node_index, param_id, value) + GraphSetParameterInTemplate(TrackId, u32, u32, u32, f32), + /// Set the UI position of a node (track_id, node_index, x, y) + GraphSetNodePosition(TrackId, u32, f32, f32), + /// Set the UI position of a node in a VoiceAllocator's template (track_id, voice_allocator_id, node_index, x, y) + GraphSetNodePositionInTemplate(TrackId, u32, u32, f32, f32), + /// Set which node receives MIDI events (track_id, node_index, enabled) + GraphSetMidiTarget(TrackId, u32, bool), + /// Set which node is the audio output (track_id, node_index) + GraphSetOutputNode(TrackId, u32), + + /// Set frontend-only group definitions on a track's graph (track_id, serialized groups) + GraphSetGroups(TrackId, Vec), + /// Set frontend-only group definitions on a VA template graph (track_id, voice_allocator_id, serialized groups) + GraphSetGroupsInTemplate(TrackId, u32, Vec), + + /// Save current graph as a preset (track_id, preset_path, preset_name, description, tags) + GraphSavePreset(TrackId, String, String, String, Vec), + /// 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), + + // 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, 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, Option, 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, Option, LoopMode), + /// Remove a layer from a MultiSampler node (track_id, node_id, layer_index) + MultiSamplerRemoveLayer(TrackId, u32, usize), + /// Clear all layers from a MultiSampler node (track_id, node_id) + MultiSamplerClearLayers(TrackId, u32), + + // Automation Input Node commands + /// Add or update a keyframe on an AutomationInput node (track_id, node_id, time, value, interpolation, ease_out, ease_in) + AutomationAddKeyframe(TrackId, u32, f64, f32, String, (f32, f32), (f32, f32)), + /// Remove a keyframe from an AutomationInput node (track_id, node_id, time) + AutomationRemoveKeyframe(TrackId, u32, f64), + /// Set the display name of an AutomationInput node (track_id, node_id, name) + AutomationSetName(TrackId, u32, String), + + // Waveform chunk generation commands + /// Generate waveform chunks for an audio file + /// (pool_index, detail_level, chunk_indices, priority) + GenerateWaveformChunks { + pool_index: usize, + detail_level: u8, + chunk_indices: Vec, + priority: u8, // 0=Low, 1=Medium, 2=High + }, + + // Input monitoring/gain commands + /// Enable or disable input monitoring (mic level metering) + SetInputMonitoring(bool), + /// Set the input gain multiplier (applied before recording) + SetInputGain(f32), + + // Async audio import + /// Import an audio file asynchronously. The engine probes the file format + /// and either memory-maps it (WAV/AIFF) or sets up stream decode + /// (compressed). Emits `AudioFileReady` when playback-ready and + /// `AudioDecodeProgress` for compressed files as waveform data is decoded. + ImportAudio(std::path::PathBuf), +} + +/// Events sent from audio thread back to UI/control thread +#[derive(Debug, Clone)] +pub enum AudioEvent { + /// Current playback position in seconds + PlaybackPosition(f64), + /// Playback has stopped (reached end of audio) + PlaybackStopped, + /// Audio buffer underrun detected + BufferUnderrun, + /// A new track was created (track_id, is_metatrack, name) + TrackCreated(TrackId, bool, String), + /// An audio file was added to the pool (pool_index, path) + AudioFileAdded(usize, String), + /// A clip was added to a track (track_id, clip_id) + ClipAdded(TrackId, ClipId), + /// Buffer pool statistics response + BufferPoolStats(BufferPoolStats), + /// Automation lane created (track_id, lane_id, parameter_id) + AutomationLaneCreated(TrackId, AutomationLaneId, ParameterId), + /// Recording started (track_id, clip_id, sample_rate, channels) + RecordingStarted(TrackId, ClipId, u32, u32), + /// Recording progress update (clip_id, current_duration) + RecordingProgress(ClipId, Seconds), + /// Recording stopped (clip_id, pool_index, waveform) + RecordingStopped(ClipId, usize, Vec), + /// Recording error (error_message) + RecordingError(String), + /// MIDI recording stopped (track_id, clip_id, note_count) + MidiRecordingStopped(TrackId, MidiClipId, usize), + /// MIDI recording progress (track_id, clip_id, duration, notes) + /// Notes format: (start_time, note, velocity, duration) — all times in beats + MidiRecordingProgress(TrackId, MidiClipId, Beats, Vec<(Beats, u8, u8, Beats)>), + /// Project has been reset + ProjectReset, + /// MIDI note started playing (note, velocity) + NoteOn(u8, u8), + /// MIDI note stopped playing (note) + NoteOff(u8), + + // Node graph events + /// Node added to graph (track_id, node_index, node_type) + GraphNodeAdded(TrackId, u32, String), + /// Connection error occurred (track_id, error_message) + GraphConnectionError(TrackId, String), + /// Graph state changed (for full UI sync) + GraphStateChanged(TrackId), + /// Preset fully loaded (track_id, preset_name) - emitted after all nodes and samples are loaded + GraphPresetLoaded(TrackId, String), + /// Preset has been saved to file (track_id, preset_path) + GraphPresetSaved(TrackId, String), + /// Script compilation result (track_id, node_id, success, error, ui_declaration, source) + ScriptCompiled { + track_id: TrackId, + node_id: u32, + success: bool, + error: Option, + ui_declaration: Option, + 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), + + /// 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)>, + }, + + /// 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, + 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, + decoded_frames: u64, + total_frames: u64, + }, +} + +/// Synchronous queries sent from UI thread to audio thread +#[derive(Debug)] +pub enum Query { + /// Get the current graph state as JSON (track_id) + GetGraphState(TrackId), + /// Get a voice allocator's template graph state as JSON (track_id, voice_allocator_id) + GetTemplateState(TrackId, u32), + /// Get oscilloscope data from a node (track_id, node_id, sample_count) + GetOscilloscopeData(TrackId, u32, usize), + /// Get oscilloscope data from a node inside a VoiceAllocator's best voice + /// (track_id, va_node_id, inner_node_id, sample_count) + GetVoiceOscilloscopeData(TrackId, u32, u32, usize), + /// Get MIDI clip data (track_id, clip_id) + GetMidiClip(TrackId, MidiClipId), + /// Get keyframes from an AutomationInput node (track_id, node_id) + GetAutomationKeyframes(TrackId, u32), + /// Get the display name of an AutomationInput node (track_id, node_id) + GetAutomationName(TrackId, u32), + /// Get the value range (min, max) of an AutomationInput node (track_id, node_id) + GetAutomationRange(TrackId, u32), + /// Serialize audio pool for project saving (project_path) + SerializeAudioPool(std::path::PathBuf), + /// Load audio pool from serialized entries (entries, project_path) + LoadAudioPool(Vec, std::path::PathBuf), + /// Resolve a missing audio file (pool_index, new_path) + ResolveMissingAudioFile(usize, std::path::PathBuf), + /// Serialize a track's effects/instrument graph (track_id, project_path) + SerializeTrackGraph(TrackId, std::path::PathBuf), + /// Load a track's effects/instrument graph (track_id, preset_json, project_path) + LoadTrackGraph(TrackId, String, std::path::PathBuf), + /// Create a new audio track (name, parent) - returns track ID synchronously + CreateAudioTrackSync(String, Option), + /// Create a new MIDI track (name, parent) - returns track ID synchronously + CreateMidiTrackSync(String, Option), + /// Create a new metatrack/group (name, parent) - returns track ID synchronously + CreateMetatrackSync(String, Option), + /// Get waveform data from audio pool (pool_index, target_peaks) + GetPoolWaveform(usize, usize), + /// Get file info from audio pool (pool_index) - returns (duration, sample_rate, channels) + GetPoolFileInfo(usize), + /// Export audio to file (settings, output_path) + ExportAudio(crate::audio::ExportSettings, std::path::PathBuf), + /// Add a MIDI clip to a track synchronously (track_id, clip, start_time) - returns instance ID + AddMidiClipSync(TrackId, crate::audio::midi::MidiClip, f64), + /// Add a MIDI clip instance to a track synchronously (track_id, instance) - returns instance ID + /// The clip must already exist in the MidiClipPool + AddMidiClipInstanceSync(TrackId, crate::audio::midi::MidiClipInstance), + /// Add an audio file to the pool synchronously (path, data, channels, sample_rate) - returns pool index + AddAudioFileSync(String, Vec, u32, u32), + /// Import an audio file synchronously (path) - returns pool index. + /// Does the same work as Command::ImportAudio (mmap for PCM, streaming + /// setup for compressed) but returns the real pool index in the response. + /// NOTE: briefly blocks the UI thread during file setup (sub-ms for PCM + /// mmap; a few ms for compressed streaming init). If this becomes a + /// problem for very large files, switch to async import with event-based + /// pool index reconciliation. + ImportAudioSync(std::path::PathBuf), + /// Get raw audio samples from pool (pool_index) - returns (samples, sample_rate, channels) + GetPoolAudioSamples(usize), + /// Get a clone of the current project for serialization + GetProject, + /// Set the project (replaces current project state) + SetProject(Box), + /// Duplicate a MIDI clip in the pool, returning the new clip's ID + DuplicateMidiClipSync(MidiClipId), + /// Get whether a track's graph is still the auto-generated default + GetGraphIsDefault(TrackId), + /// Get the pitch bend range (in semitones) for the instrument on a MIDI track. + /// Searches for MidiToCVNode (in VA templates) or MultiSamplerNode (direct). + GetPitchBendRange(TrackId), +} + +/// Oscilloscope data from a node +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct OscilloscopeData { + /// Audio samples + pub audio: Vec, + /// CV samples (may be empty if no CV input) + pub cv: Vec, +} + +/// MIDI clip data for serialization +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct MidiClipData { + pub duration: f64, + pub events: Vec, +} + +/// Automation keyframe data for serialization +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct AutomationKeyframeData { + pub time: f64, + pub value: f32, + pub interpolation: String, + pub ease_out: (f32, f32), + pub ease_in: (f32, f32), +} + +/// Responses to synchronous queries +#[derive(Debug)] +pub enum QueryResponse { + /// Graph state as JSON string + GraphState(Result), + /// Oscilloscope data samples + OscilloscopeData(Result), + /// MIDI clip data + MidiClipData(Result), + /// Automation keyframes + AutomationKeyframes(Result, String>), + /// Automation node name + AutomationName(Result), + /// Automation node value range (min, max) + AutomationRange(Result<(f32, f32), String>), + /// Serialized audio pool entries + AudioPoolSerialized(Result, String>), + /// Audio pool loaded (returns list of missing pool indices) + AudioPoolLoaded(Result, String>), + /// Audio file resolved + AudioFileResolved(Result<(), String>), + /// Track graph serialized as JSON + TrackGraphSerialized(Result), + /// Track graph loaded + TrackGraphLoaded(Result<(), String>), + /// Track created (returns track ID) + TrackCreated(Result), + /// Pool waveform data + PoolWaveform(Result, String>), + /// Pool file info (duration, sample_rate, channels) + PoolFileInfo(Result<(f64, u32, u32), String>), + /// Audio exported + AudioExported(Result<(), String>), + /// MIDI clip instance added (returns instance ID) + MidiClipInstanceAdded(Result), + /// Audio file added to pool (returns pool index) + AudioFileAddedSync(Result), + /// Audio file imported to pool (returns pool index) + AudioImportedSync(Result), + /// Raw audio samples from pool (samples, sample_rate, channels) + PoolAudioSamples(Result<(Vec, u32, u32), String>), + /// Project retrieved + ProjectRetrieved(Result, String>), + /// Project set + ProjectSet(Result<(), String>), + /// MIDI clip duplicated (returns new clip ID) + MidiClipDuplicated(Result), + /// Whether a track's graph is the auto-generated default + GraphIsDefault(bool), + /// Pitch bend range in semitones for the track's instrument + PitchBendRange(f32), +} diff --git a/daw-backend/src/dsp/biquad.rs b/daw-backend/src/dsp/biquad.rs new file mode 100644 index 0000000..5f0fbee --- /dev/null +++ b/daw-backend/src/dsp/biquad.rs @@ -0,0 +1,175 @@ +use std::f32::consts::PI; + +/// Biquad filter implementation (2-pole IIR filter) +/// +/// Transfer function: H(z) = (b0 + b1*z^-1 + b2*z^-2) / (1 + a1*z^-1 + a2*z^-2) +#[derive(Clone)] +pub struct BiquadFilter { + // Filter coefficients + b0: f32, + b1: f32, + b2: f32, + a1: f32, + a2: f32, + + // State variables (per channel, supporting up to 2 channels) + x1: [f32; 2], + x2: [f32; 2], + y1: [f32; 2], + y2: [f32; 2], +} + +impl BiquadFilter { + /// Create a new biquad filter with unity gain (pass-through) + pub fn new() -> Self { + Self { + b0: 1.0, + b1: 0.0, + b2: 0.0, + a1: 0.0, + a2: 0.0, + x1: [0.0; 2], + x2: [0.0; 2], + y1: [0.0; 2], + y2: [0.0; 2], + } + } + + /// Create a lowpass filter + /// + /// # Arguments + /// * `frequency` - Cutoff frequency in Hz + /// * `q` - Quality factor (resonance), typically 0.707 for Butterworth + /// * `sample_rate` - Sample rate in Hz + pub fn lowpass(frequency: f32, q: f32, sample_rate: f32) -> Self { + let mut filter = Self::new(); + filter.set_lowpass(frequency, q, sample_rate); + filter + } + + /// Create a highpass filter + /// + /// # Arguments + /// * `frequency` - Cutoff frequency in Hz + /// * `q` - Quality factor (resonance), typically 0.707 for Butterworth + /// * `sample_rate` - Sample rate in Hz + pub fn highpass(frequency: f32, q: f32, sample_rate: f32) -> Self { + let mut filter = Self::new(); + filter.set_highpass(frequency, q, sample_rate); + filter + } + + /// Create a peaking EQ filter + /// + /// # Arguments + /// * `frequency` - Center frequency in Hz + /// * `q` - Quality factor (bandwidth) + /// * `gain_db` - Gain in decibels + /// * `sample_rate` - Sample rate in Hz + pub fn peaking(frequency: f32, q: f32, gain_db: f32, sample_rate: f32) -> Self { + let mut filter = Self::new(); + filter.set_peaking(frequency, q, gain_db, sample_rate); + filter + } + + /// Set coefficients for a lowpass filter + pub fn set_lowpass(&mut self, frequency: f32, q: f32, sample_rate: f32) { + let omega = 2.0 * PI * frequency / sample_rate; + let sin_omega = omega.sin(); + let cos_omega = omega.cos(); + let alpha = sin_omega / (2.0 * q); + + let a0 = 1.0 + alpha; + self.b0 = ((1.0 - cos_omega) / 2.0) / a0; + self.b1 = (1.0 - cos_omega) / a0; + self.b2 = ((1.0 - cos_omega) / 2.0) / a0; + self.a1 = (-2.0 * cos_omega) / a0; + self.a2 = (1.0 - alpha) / a0; + } + + /// Set coefficients for a highpass filter + pub fn set_highpass(&mut self, frequency: f32, q: f32, sample_rate: f32) { + let omega = 2.0 * PI * frequency / sample_rate; + let sin_omega = omega.sin(); + let cos_omega = omega.cos(); + let alpha = sin_omega / (2.0 * q); + + let a0 = 1.0 + alpha; + self.b0 = ((1.0 + cos_omega) / 2.0) / a0; + self.b1 = -(1.0 + cos_omega) / a0; + self.b2 = ((1.0 + cos_omega) / 2.0) / a0; + self.a1 = (-2.0 * cos_omega) / a0; + self.a2 = (1.0 - alpha) / a0; + } + + /// Set coefficients for a peaking EQ filter + pub fn set_peaking(&mut self, frequency: f32, q: f32, gain_db: f32, sample_rate: f32) { + let omega = 2.0 * PI * frequency / sample_rate; + let sin_omega = omega.sin(); + let cos_omega = omega.cos(); + let a_gain = 10.0_f32.powf(gain_db / 40.0); + let alpha = sin_omega / (2.0 * q); + + let a0 = 1.0 + alpha / a_gain; + self.b0 = (1.0 + alpha * a_gain) / a0; + self.b1 = (-2.0 * cos_omega) / a0; + self.b2 = (1.0 - alpha * a_gain) / a0; + self.a1 = (-2.0 * cos_omega) / a0; + self.a2 = (1.0 - alpha / a_gain) / a0; + } + + /// Process a single sample + /// + /// # Arguments + /// * `input` - Input sample + /// * `channel` - Channel index (0 or 1) + /// + /// # Returns + /// Filtered output sample + #[inline] + pub fn process_sample(&mut self, input: f32, channel: usize) -> f32 { + let channel = channel.min(1); // Clamp to 0 or 1 + + // Direct Form II Transposed implementation + let output = self.b0 * input + self.x1[channel]; + + self.x1[channel] = self.b1 * input - self.a1 * output + self.x2[channel]; + self.x2[channel] = self.b2 * input - self.a2 * output; + + output + } + + /// Process a buffer of interleaved samples + /// + /// # Arguments + /// * `buffer` - Interleaved audio samples + /// * `channels` - Number of channels + pub fn process_buffer(&mut self, buffer: &mut [f32], channels: usize) { + if channels == 1 { + // Mono + for sample in buffer.iter_mut() { + *sample = self.process_sample(*sample, 0); + } + } else if channels == 2 { + // Stereo + for frame in buffer.chunks_exact_mut(2) { + frame[0] = self.process_sample(frame[0], 0); + frame[1] = self.process_sample(frame[1], 1); + } + } + } + + /// Reset filter state (clear delay lines) + pub fn reset(&mut self) { + self.x1 = [0.0; 2]; + self.x2 = [0.0; 2]; + self.y1 = [0.0; 2]; + self.y2 = [0.0; 2]; + } +} + +impl Default for BiquadFilter { + fn default() -> Self { + Self::new() + } +} diff --git a/daw-backend/src/dsp/mod.rs b/daw-backend/src/dsp/mod.rs new file mode 100644 index 0000000..3e052fe --- /dev/null +++ b/daw-backend/src/dsp/mod.rs @@ -0,0 +1,5 @@ +pub mod biquad; +pub mod svf; + +pub use biquad::BiquadFilter; +pub use svf::SvfFilter; diff --git a/daw-backend/src/dsp/svf.rs b/daw-backend/src/dsp/svf.rs new file mode 100644 index 0000000..315667e --- /dev/null +++ b/daw-backend/src/dsp/svf.rs @@ -0,0 +1,135 @@ +use std::f32::consts::PI; + +/// State Variable Filter mode +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum SvfMode { + Lowpass = 0, + Highpass = 1, + Bandpass = 2, + Notch = 3, +} + +impl SvfMode { + pub fn from_f32(value: f32) -> Self { + match value.round() as i32 { + 1 => SvfMode::Highpass, + 2 => SvfMode::Bandpass, + 3 => SvfMode::Notch, + _ => SvfMode::Lowpass, + } + } +} + +/// Linear trapezoidal integrated State Variable Filter (Simper/Cytomic) +/// +/// Zero-delay feedback topology. Per-sample cutoff modulation is cheap — +/// just update `g` and `k` coefficients (no per-sample trig needed if +/// cutoff hasn't changed). +#[derive(Clone)] +pub struct SvfFilter { + // Coefficients + g: f32, // frequency warping: tan(π * cutoff / sample_rate) + k: f32, // damping: 2 - 2*resonance + a1: f32, // 1 / (1 + g*(g+k)) + a2: f32, // g * a1 + + // State per channel (up to 2 for stereo) + ic1eq: [f32; 2], + ic2eq: [f32; 2], + + mode: SvfMode, +} + +impl SvfFilter { + /// Create a new SVF with default parameters (1kHz lowpass, no resonance) + pub fn new() -> Self { + let mut filter = Self { + g: 0.0, + k: 2.0, + a1: 0.0, + a2: 0.0, + ic1eq: [0.0; 2], + ic2eq: [0.0; 2], + mode: SvfMode::Lowpass, + }; + filter.set_params(1000.0, 0.0, 44100.0); + filter + } + + /// Set filter parameters + /// + /// # Arguments + /// * `cutoff_hz` - Cutoff frequency in Hz (clamped to valid range) + /// * `resonance` - Resonance 0.0 (none) to 1.0 (self-oscillation) + /// * `sample_rate` - Sample rate in Hz + #[inline] + pub fn set_params(&mut self, cutoff_hz: f32, resonance: f32, sample_rate: f32) { + // Clamp cutoff to avoid instability near Nyquist + let cutoff = cutoff_hz.clamp(5.0, sample_rate * 0.49); + let resonance = resonance.clamp(0.0, 1.0); + + self.g = (PI * cutoff / sample_rate).tan(); + self.k = 2.0 - 2.0 * resonance; + self.a1 = 1.0 / (1.0 + self.g * (self.g + self.k)); + self.a2 = self.g * self.a1; + } + + /// Set filter mode + pub fn set_mode(&mut self, mode: SvfMode) { + self.mode = mode; + } + + /// Process a single sample, returning all four outputs: (lowpass, highpass, bandpass, notch) + #[inline] + pub fn process_sample_quad(&mut self, input: f32, channel: usize) -> (f32, f32, f32, f32) { + let ch = channel.min(1); + + let v3 = input - self.ic2eq[ch]; + let v1 = self.a1 * self.ic1eq[ch] + self.a2 * v3; + let v2 = self.ic2eq[ch] + self.g * v1; + + self.ic1eq[ch] = 2.0 * v1 - self.ic1eq[ch]; + self.ic2eq[ch] = 2.0 * v2 - self.ic2eq[ch]; + + let hp = input - self.k * v1 - v2; + (v2, hp, v1, hp + v2) + } + + /// Process a single sample with a selected mode + #[inline] + pub fn process_sample(&mut self, input: f32, channel: usize) -> f32 { + let (lp, hp, bp, notch) = self.process_sample_quad(input, channel); + match self.mode { + SvfMode::Lowpass => lp, + SvfMode::Highpass => hp, + SvfMode::Bandpass => bp, + SvfMode::Notch => notch, + } + } + + /// Process a buffer of interleaved samples + pub fn process_buffer(&mut self, buffer: &mut [f32], channels: usize) { + if channels == 1 { + for sample in buffer.iter_mut() { + *sample = self.process_sample(*sample, 0); + } + } else if channels == 2 { + for frame in buffer.chunks_exact_mut(2) { + frame[0] = self.process_sample(frame[0], 0); + frame[1] = self.process_sample(frame[1], 1); + } + } + } + + /// Reset filter state (clear delay lines) + pub fn reset(&mut self) { + self.ic1eq = [0.0; 2]; + self.ic2eq = [0.0; 2]; + } +} + +impl Default for SvfFilter { + fn default() -> Self { + Self::new() + } +} diff --git a/daw-backend/src/effects/effect_trait.rs b/daw-backend/src/effects/effect_trait.rs new file mode 100644 index 0000000..975ab6b --- /dev/null +++ b/daw-backend/src/effects/effect_trait.rs @@ -0,0 +1,35 @@ +/// Audio effect processor trait +/// +/// All effects must be Send to be usable in the audio thread. +/// Effects should be real-time safe: no allocations, no blocking operations. +pub trait Effect: Send { + /// Process audio buffer in-place + /// + /// # Arguments + /// * `buffer` - Interleaved audio samples to process + /// * `channels` - Number of audio channels (2 for stereo) + /// * `sample_rate` - Sample rate in Hz + fn process(&mut self, buffer: &mut [f32], channels: usize, sample_rate: u32); + + /// Set an effect parameter + /// + /// # Arguments + /// * `id` - Parameter identifier + /// * `value` - Parameter value (normalized or specific units depending on parameter) + fn set_parameter(&mut self, id: u32, value: f32); + + /// Get an effect parameter value + /// + /// # Arguments + /// * `id` - Parameter identifier + /// + /// # Returns + /// Current parameter value + fn get_parameter(&self, id: u32) -> f32; + + /// Reset effect state (clear delays, resonances, etc.) + fn reset(&mut self); + + /// Get the effect name + fn name(&self) -> &str; +} diff --git a/daw-backend/src/effects/eq.rs b/daw-backend/src/effects/eq.rs new file mode 100644 index 0000000..074390a --- /dev/null +++ b/daw-backend/src/effects/eq.rs @@ -0,0 +1,148 @@ +use super::Effect; +use crate::dsp::BiquadFilter; + +/// Simple 3-band EQ (low shelf, mid peak, high shelf) +/// +/// Parameters: +/// - 0: Low gain in dB (-12.0 to +12.0) +/// - 1: Mid gain in dB (-12.0 to +12.0) +/// - 2: High gain in dB (-12.0 to +12.0) +/// - 3: Low frequency in Hz (default: 250) +/// - 4: Mid frequency in Hz (default: 1000) +/// - 5: High frequency in Hz (default: 8000) +pub struct SimpleEQ { + low_gain: f32, + mid_gain: f32, + high_gain: f32, + low_freq: f32, + mid_freq: f32, + high_freq: f32, + + low_filter: BiquadFilter, + mid_filter: BiquadFilter, + high_filter: BiquadFilter, + + sample_rate: f32, +} + +impl SimpleEQ { + /// Create a new SimpleEQ with flat response + pub fn new() -> Self { + Self { + low_gain: 0.0, + mid_gain: 0.0, + high_gain: 0.0, + low_freq: 250.0, + mid_freq: 1000.0, + high_freq: 8000.0, + low_filter: BiquadFilter::new(), + mid_filter: BiquadFilter::new(), + high_filter: BiquadFilter::new(), + sample_rate: 48000.0, // Default, will be updated on first process + } + } + + /// Set low band gain in decibels + pub fn set_low_gain(&mut self, gain_db: f32) { + self.low_gain = gain_db.clamp(-12.0, 12.0); + self.update_filters(); + } + + /// Set mid band gain in decibels + pub fn set_mid_gain(&mut self, gain_db: f32) { + self.mid_gain = gain_db.clamp(-12.0, 12.0); + self.update_filters(); + } + + /// Set high band gain in decibels + pub fn set_high_gain(&mut self, gain_db: f32) { + self.high_gain = gain_db.clamp(-12.0, 12.0); + self.update_filters(); + } + + /// Set low band frequency + pub fn set_low_freq(&mut self, freq: f32) { + self.low_freq = freq.clamp(20.0, 500.0); + self.update_filters(); + } + + /// Set mid band frequency + pub fn set_mid_freq(&mut self, freq: f32) { + self.mid_freq = freq.clamp(200.0, 5000.0); + self.update_filters(); + } + + /// Set high band frequency + pub fn set_high_freq(&mut self, freq: f32) { + self.high_freq = freq.clamp(2000.0, 20000.0); + self.update_filters(); + } + + /// Update filter coefficients based on current parameters + fn update_filters(&mut self) { + // Only update if sample rate has been set + if self.sample_rate > 0.0 { + // Use peaking filters for all bands + // Q factor of 1.0 gives a moderate bandwidth + self.low_filter.set_peaking(self.low_freq, 1.0, self.low_gain, self.sample_rate); + self.mid_filter.set_peaking(self.mid_freq, 1.0, self.mid_gain, self.sample_rate); + self.high_filter.set_peaking(self.high_freq, 1.0, self.high_gain, self.sample_rate); + } + } +} + +impl Default for SimpleEQ { + fn default() -> Self { + Self::new() + } +} + +impl Effect for SimpleEQ { + fn process(&mut self, buffer: &mut [f32], channels: usize, sample_rate: u32) { + // Update sample rate if it changed + let sr = sample_rate as f32; + if (self.sample_rate - sr).abs() > 0.1 { + self.sample_rate = sr; + self.update_filters(); + } + + // Process through each filter in series + self.low_filter.process_buffer(buffer, channels); + self.mid_filter.process_buffer(buffer, channels); + self.high_filter.process_buffer(buffer, channels); + } + + fn set_parameter(&mut self, id: u32, value: f32) { + match id { + 0 => self.set_low_gain(value), + 1 => self.set_mid_gain(value), + 2 => self.set_high_gain(value), + 3 => self.set_low_freq(value), + 4 => self.set_mid_freq(value), + 5 => self.set_high_freq(value), + _ => {} + } + } + + fn get_parameter(&self, id: u32) -> f32 { + match id { + 0 => self.low_gain, + 1 => self.mid_gain, + 2 => self.high_gain, + 3 => self.low_freq, + 4 => self.mid_freq, + 5 => self.high_freq, + _ => 0.0, + } + } + + fn reset(&mut self) { + self.low_filter.reset(); + self.mid_filter.reset(); + self.high_filter.reset(); + } + + fn name(&self) -> &str { + "SimpleEQ" + } +} diff --git a/daw-backend/src/effects/gain.rs b/daw-backend/src/effects/gain.rs new file mode 100644 index 0000000..6852bef --- /dev/null +++ b/daw-backend/src/effects/gain.rs @@ -0,0 +1,97 @@ +use super::Effect; + +/// Simple gain/volume effect +/// +/// Parameters: +/// - 0: Gain in dB (-60.0 to +12.0) +pub struct GainEffect { + gain_db: f32, + gain_linear: f32, +} + +impl GainEffect { + /// Create a new gain effect with 0 dB (unity) gain + pub fn new() -> Self { + Self { + gain_db: 0.0, + gain_linear: 1.0, + } + } + + /// Create a gain effect with a specific dB value + pub fn with_gain_db(gain_db: f32) -> Self { + let gain_linear = db_to_linear(gain_db); + Self { + gain_db, + gain_linear, + } + } + + /// Set gain in decibels + pub fn set_gain_db(&mut self, gain_db: f32) { + self.gain_db = gain_db.clamp(-60.0, 12.0); + self.gain_linear = db_to_linear(self.gain_db); + } + + /// Get current gain in decibels + pub fn gain_db(&self) -> f32 { + self.gain_db + } +} + +impl Default for GainEffect { + fn default() -> Self { + Self::new() + } +} + +impl Effect for GainEffect { + fn process(&mut self, buffer: &mut [f32], _channels: usize, _sample_rate: u32) { + for sample in buffer.iter_mut() { + *sample *= self.gain_linear; + } + } + + fn set_parameter(&mut self, id: u32, value: f32) { + if id == 0 { + self.set_gain_db(value); + } + } + + fn get_parameter(&self, id: u32) -> f32 { + if id == 0 { + self.gain_db + } else { + 0.0 + } + } + + fn reset(&mut self) { + // Gain has no state to reset + } + + fn name(&self) -> &str { + "Gain" + } +} + +/// Convert decibels to linear gain +#[inline] +fn db_to_linear(db: f32) -> f32 { + if db <= -60.0 { + 0.0 + } else { + 10.0_f32.powf(db / 20.0) + } +} + +/// Convert linear gain to decibels +#[inline] +#[allow(dead_code)] +fn linear_to_db(linear: f32) -> f32 { + if linear <= 0.0 { + -60.0 + } else { + 20.0 * linear.log10() + } +} diff --git a/daw-backend/src/effects/mod.rs b/daw-backend/src/effects/mod.rs new file mode 100644 index 0000000..413adcb --- /dev/null +++ b/daw-backend/src/effects/mod.rs @@ -0,0 +1,11 @@ +pub mod effect_trait; +pub mod eq; +pub mod gain; +pub mod pan; +pub mod synth; + +pub use effect_trait::Effect; +pub use eq::SimpleEQ; +pub use gain::GainEffect; +pub use pan::PanEffect; +pub use synth::SimpleSynth; diff --git a/daw-backend/src/effects/pan.rs b/daw-backend/src/effects/pan.rs new file mode 100644 index 0000000..9804249 --- /dev/null +++ b/daw-backend/src/effects/pan.rs @@ -0,0 +1,98 @@ +use super::Effect; + +/// Stereo panning effect using constant-power panning law +/// +/// Parameters: +/// - 0: Pan position (-1.0 = full left, 0.0 = center, +1.0 = full right) +pub struct PanEffect { + pan: f32, + left_gain: f32, + right_gain: f32, +} + +impl PanEffect { + /// Create a new pan effect with center panning + pub fn new() -> Self { + let mut effect = Self { + pan: 0.0, + left_gain: 1.0, + right_gain: 1.0, + }; + effect.update_gains(); + effect + } + + /// Create a pan effect with a specific pan position + pub fn with_pan(pan: f32) -> Self { + let mut effect = Self { + pan: pan.clamp(-1.0, 1.0), + left_gain: 1.0, + right_gain: 1.0, + }; + effect.update_gains(); + effect + } + + /// Set pan position (-1.0 = left, 0.0 = center, +1.0 = right) + pub fn set_pan(&mut self, pan: f32) { + self.pan = pan.clamp(-1.0, 1.0); + self.update_gains(); + } + + /// Get current pan position + pub fn pan(&self) -> f32 { + self.pan + } + + /// Update left/right gains using constant-power panning law + fn update_gains(&mut self) { + use std::f32::consts::PI; + + // Constant-power panning: pan from -1 to +1 maps to angle 0 to PI/2 + let angle = (self.pan + 1.0) * 0.5 * PI / 2.0; + + self.left_gain = angle.cos(); + self.right_gain = angle.sin(); + } +} + +impl Default for PanEffect { + fn default() -> Self { + Self::new() + } +} + +impl Effect for PanEffect { + fn process(&mut self, buffer: &mut [f32], channels: usize, _sample_rate: u32) { + if channels == 2 { + // Stereo processing + for frame in buffer.chunks_exact_mut(2) { + frame[0] *= self.left_gain; + frame[1] *= self.right_gain; + } + } + // Mono and other channel counts: no panning applied + } + + fn set_parameter(&mut self, id: u32, value: f32) { + if id == 0 { + self.set_pan(value); + } + } + + fn get_parameter(&self, id: u32) -> f32 { + if id == 0 { + self.pan + } else { + 0.0 + } + } + + fn reset(&mut self) { + // Pan has no state to reset + } + + fn name(&self) -> &str { + "Pan" + } +} diff --git a/daw-backend/src/effects/synth.rs b/daw-backend/src/effects/synth.rs new file mode 100644 index 0000000..06e6cea --- /dev/null +++ b/daw-backend/src/effects/synth.rs @@ -0,0 +1,285 @@ +use super::Effect; +use crate::audio::midi::MidiEvent; +use std::f32::consts::PI; + +/// Maximum number of simultaneous voices +const MAX_VOICES: usize = 16; + +/// Envelope state for a voice +#[derive(Clone, Copy, PartialEq)] +enum EnvelopeState { + Attack, + Sustain, + Release, + Off, +} + +/// A single synthesizer voice +#[derive(Clone)] +struct SynthVoice { + active: bool, + note: u8, + channel: u8, + velocity: u8, + phase: f32, + frequency: f32, + age: u32, // For voice stealing + + // Envelope + envelope_state: EnvelopeState, + envelope_level: f32, // 0.0 to 1.0 +} + +impl SynthVoice { + fn new() -> Self { + Self { + active: false, + note: 0, + channel: 0, + velocity: 0, + phase: 0.0, + frequency: 0.0, + age: 0, + envelope_state: EnvelopeState::Off, + envelope_level: 0.0, + } + } + + /// Calculate frequency from MIDI note number + fn note_to_frequency(note: u8) -> f32 { + 440.0 * 2.0_f32.powf((note as f32 - 69.0) / 12.0) + } + + /// Start playing a note + fn note_on(&mut self, channel: u8, note: u8, velocity: u8) { + self.active = true; + self.channel = channel; + self.note = note; + self.velocity = velocity; + self.frequency = Self::note_to_frequency(note); + self.phase = 0.0; + self.age = 0; + self.envelope_state = EnvelopeState::Attack; + self.envelope_level = 0.0; // Start from silence + } + + /// Stop playing (start release phase) + fn note_off(&mut self) { + // Don't stop immediately - start release phase + if self.envelope_state != EnvelopeState::Off { + self.envelope_state = EnvelopeState::Release; + } + } + + /// Generate one sample + fn process_sample(&mut self, sample_rate: f32) -> f32 { + if self.envelope_state == EnvelopeState::Off { + return 0.0; + } + + // Envelope timing constants (in seconds) + const ATTACK_TIME: f32 = 0.005; // 5ms attack + const RELEASE_TIME: f32 = 0.05; // 50ms release + + // Update envelope + let attack_increment = 1.0 / (ATTACK_TIME * sample_rate); + let release_decrement = 1.0 / (RELEASE_TIME * sample_rate); + + match self.envelope_state { + EnvelopeState::Attack => { + self.envelope_level += attack_increment; + if self.envelope_level >= 1.0 { + self.envelope_level = 1.0; + self.envelope_state = EnvelopeState::Sustain; + } + } + EnvelopeState::Sustain => { + // Stay at full level + self.envelope_level = 1.0; + } + EnvelopeState::Release => { + self.envelope_level -= release_decrement; + if self.envelope_level <= 0.0 { + self.envelope_level = 0.0; + self.envelope_state = EnvelopeState::Off; + self.active = false; // Now we can truly stop + } + } + EnvelopeState::Off => { + return 0.0; + } + } + + // Simple sine wave + let sample = (self.phase * 2.0 * PI).sin() * (self.velocity as f32 / 127.0) * 0.3; + + // Update phase + self.phase += self.frequency / sample_rate; + if self.phase >= 1.0 { + self.phase -= 1.0; + } + + self.age += 1; + + // Apply envelope + sample * self.envelope_level + } +} + +/// Simple polyphonic synthesizer using sine waves +pub struct SimpleSynth { + voices: Vec, + sample_rate: f32, + pub pending_events: Vec, +} + +impl SimpleSynth { + /// Create a new SimpleSynth + pub fn new() -> Self { + Self { + voices: vec![SynthVoice::new(); MAX_VOICES], + sample_rate: 44100.0, + pending_events: Vec::new(), + } + } + + /// Find a free voice, or steal the oldest one + fn find_voice_for_note_on(&mut self) -> usize { + // First, look for an inactive voice + for (i, voice) in self.voices.iter().enumerate() { + if !voice.active { + return i; + } + } + + // No free voices, steal the oldest one + self.voices + .iter() + .enumerate() + .max_by_key(|(_, v)| v.age) + .map(|(i, _)| i) + .unwrap_or(0) + } + + /// Find the voice playing a specific note on a specific channel + /// Only matches voices in Attack or Sustain state (not already releasing) + fn find_voice_for_note_off(&mut self, channel: u8, note: u8) -> Option { + self.voices + .iter() + .position(|v| { + v.active + && v.channel == channel + && v.note == note + && (v.envelope_state == EnvelopeState::Attack + || v.envelope_state == EnvelopeState::Sustain) + }) + } + + /// Handle a MIDI event + pub fn handle_event(&mut self, event: &MidiEvent) { + if event.is_note_on() { + let voice_idx = self.find_voice_for_note_on(); + self.voices[voice_idx].note_on(event.channel(), event.data1, event.data2); + } else if event.is_note_off() { + if let Some(voice_idx) = self.find_voice_for_note_off(event.channel(), event.data1) { + self.voices[voice_idx].note_off(); + } + } + } + + /// Queue a MIDI event to be processed + pub fn queue_event(&mut self, event: MidiEvent) { + self.pending_events.push(event); + } + + /// Stop all currently playing notes immediately (no release envelope) + pub fn all_notes_off(&mut self) { + for voice in &mut self.voices { + voice.active = false; + voice.envelope_state = EnvelopeState::Off; + voice.envelope_level = 0.0; + } + self.pending_events.clear(); + } + + /// Process all queued events + fn process_events(&mut self) { + // Collect events first to avoid borrowing issues + let events: Vec = self.pending_events.drain(..).collect(); + for event in events { + self.handle_event(&event); + } + } +} + +impl Effect for SimpleSynth { + fn process(&mut self, buffer: &mut [f32], channels: usize, sample_rate: u32) { + self.sample_rate = sample_rate as f32; + + // Process any queued MIDI events + self.process_events(); + + // Generate audio from all active voices + if channels == 1 { + // Mono + for sample in buffer.iter_mut() { + let mut sum = 0.0; + for voice in &mut self.voices { + sum += voice.process_sample(self.sample_rate); + } + *sample += sum; + } + } else if channels == 2 { + // Stereo (duplicate mono signal) + for frame in buffer.chunks_exact_mut(2) { + let mut sum = 0.0; + for voice in &mut self.voices { + sum += voice.process_sample(self.sample_rate); + } + frame[0] += sum; + frame[1] += sum; + } + } + } + + fn set_parameter(&mut self, id: u32, value: f32) { + // Parameter 0: Note on + // Parameter 1: Note off + // This is a simple interface for testing without proper MIDI routing + match id { + 0 => { + let note = value as u8; + let voice_idx = self.find_voice_for_note_on(); + self.voices[voice_idx].note_on(0, note, 100); + } + 1 => { + let note = value as u8; + if let Some(voice_idx) = self.find_voice_for_note_off(0, note) { + self.voices[voice_idx].note_off(); + } + } + _ => {} + } + } + + fn get_parameter(&self, _id: u32) -> f32 { + 0.0 + } + + fn reset(&mut self) { + for voice in &mut self.voices { + voice.note_off(); + } + self.pending_events.clear(); + } + + fn name(&self) -> &str { + "SimpleSynth" + } +} + +impl Default for SimpleSynth { + fn default() -> Self { + Self::new() + } +} diff --git a/daw-backend/src/io/audio_file.rs b/daw-backend/src/io/audio_file.rs new file mode 100644 index 0000000..fec6477 --- /dev/null +++ b/daw-backend/src/io/audio_file.rs @@ -0,0 +1,524 @@ +use std::path::Path; +use symphonia::core::audio::SampleBuffer; +use symphonia::core::codecs::DecoderOptions; +use symphonia::core::errors::Error; +use symphonia::core::formats::FormatOptions; +use symphonia::core::io::MediaSourceStream; +use symphonia::core::meta::MetadataOptions; +use symphonia::core::probe::Hint; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct WaveformPeak { + pub min: f32, + 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, // 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, + pub format: AudioFormat, +} + +pub struct AudioFile { + pub data: Vec, + pub channels: u32, + pub sample_rate: u32, + 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>(path: P) -> Result { + 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 { + 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>(path: P) -> Result { + let path = path.as_ref(); + + // Open the media source + 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()); + + // Create a probe hint using the file extension + let mut hint = Hint::new(); + if let Some(extension) = path.extension() { + if let Some(ext_str) = extension.to_str() { + hint.with_extension(ext_str); + } + } + + // Probe the media source + let probed = symphonia::default::get_probe() + .format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default()) + .map_err(|e| format!("Failed to probe file: {}", e))?; + + let mut format = probed.format; + + // Find the default audio track + 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 track_id = track.id; + + // Get audio parameters + 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())?; + + // Create decoder + let mut decoder = symphonia::default::get_codecs() + .make(&codec_params, &DecoderOptions::default()) + .map_err(|e| format!("Failed to create decoder: {}", e))?; + + // Decode all packets + let mut audio_data = Vec::new(); + let mut sample_buf = None; + + loop { + let packet = match format.next_packet() { + Ok(packet) => packet, + Err(Error::ResetRequired) => { + return Err("Decoder reset required (not implemented)".to_string()); + } + Err(Error::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => { + // End of file + break; + } + Err(e) => { + return Err(format!("Failed to read packet: {}", e)); + } + }; + + // Skip packets for other tracks + if packet.track_id() != track_id { + continue; + } + + // Decode the packet + match decoder.decode(&packet) { + Ok(decoded) => { + // Initialize sample buffer on first packet + if sample_buf.is_none() { + let spec = *decoded.spec(); + let duration = decoded.capacity() as u64; + sample_buf = Some(SampleBuffer::::new(duration, spec)); + } + + // Copy decoded audio to sample buffer + if let Some(ref mut buf) = sample_buf { + buf.copy_interleaved_ref(decoded); + audio_data.extend_from_slice(buf.samples()); + } + } + Err(Error::DecodeError(e)) => { + eprintln!("Decode error: {}", e); + continue; + } + Err(e) => { + return Err(format!("Decode failed: {}", e)); + } + } + } + + let frames = (audio_data.len() / channels as usize) as u64; + + Ok(AudioFile { + data: audio_data, + channels, + sample_rate, + frames, + }) + } + + /// 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, 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::::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 + } + + /// 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 { + 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 { + 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 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 mut min = 0.0f32; + let mut max = 0.0f32; + + // Scan all samples in this window + for frame_idx in peak_start..peak_end { + // 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; + if sample_idx < self.data.len() { + let sample = self.data[sample_idx]; + min = min.min(sample); + max = max.max(sample); + } + } + } + + peaks.push(WaveformPeak { min, max }); + } + + peaks + } +} diff --git a/daw-backend/src/io/midi_file.rs b/daw-backend/src/io/midi_file.rs new file mode 100644 index 0000000..955da81 --- /dev/null +++ b/daw-backend/src/io/midi_file.rs @@ -0,0 +1,69 @@ +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. +pub fn load_midi_file>( + path: P, + clip_id: MidiClipId, + _sample_rate: u32, +) -> Result { + let data = fs::read(path.as_ref()).map_err(|e| format!("Failed to read MIDI file: {}", e))?; + let smf = midly::Smf::parse(&data).map_err(|e| format!("Failed to parse MIDI file: {}", e))?; + + 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 + (fps.as_f32() * subframe as f32) as f64 + } + }; + + let mut events = Vec::new(); + let mut max_tick = 0u64; + + 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); + + match event.kind { + midly::TrackEventKind::Midi { channel, message } => { + let ch = channel.as_int(); + 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)); + } else { + events.push(MidiEvent::note_off(timestamp, ch, key.as_int(), 64)); + } + } + midly::MidiMessage::NoteOff { key, vel } => { + events.push(MidiEvent::note_off(timestamp, ch, 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())); + } + _ => {} + } + } + _ => {} // 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()); + Ok(clip) +} diff --git a/daw-backend/src/io/midi_input.rs b/daw-backend/src/io/midi_input.rs new file mode 100644 index 0000000..c3fed06 --- /dev/null +++ b/daw-backend/src/io/midi_input.rs @@ -0,0 +1,258 @@ +use crate::audio::track::TrackId; +use crate::command::Command; +use midir::{MidiInput, MidiInputConnection}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; + +/// Manages external MIDI input devices and routes MIDI to the currently active track +pub struct MidiInputManager { + connections: Arc>>, + active_track_id: Arc>>, + #[allow(dead_code)] + command_tx: Arc>>, +} + +struct ActiveMidiConnection { + #[allow(dead_code)] + device_name: String, + #[allow(dead_code)] + connection: MidiInputConnection<()>, +} + +impl MidiInputManager { + /// Create a new MIDI input manager and auto-connect to all available devices + pub fn new(command_tx: rtrb::Producer) -> Result { + let active_track_id = Arc::new(Mutex::new(None)); + let connections = Arc::new(Mutex::new(Vec::new())); + + // Wrap command producer in Arc for sharing across MIDI callbacks + let shared_command_tx = Arc::new(Mutex::new(command_tx)); + + // Connect to all currently available devices + Self::connect_to_devices(&connections, &shared_command_tx, &active_track_id)?; + + // Create the manager + let manager = Self { + connections: connections.clone(), + active_track_id: active_track_id.clone(), + command_tx: shared_command_tx.clone(), + }; + + // Spawn hot-plug monitoring thread + let hotplug_connections = connections.clone(); + let hotplug_command_tx = shared_command_tx.clone(); + let hotplug_active_id = active_track_id.clone(); + + thread::spawn(move || { + loop { + thread::sleep(Duration::from_secs(2)); // Check every 2 seconds + + // Try to connect to new devices + if let Err(e) = Self::connect_to_devices( + &hotplug_connections, + &hotplug_command_tx, + &hotplug_active_id, + ) { + eprintln!("MIDI hot-plug scan error: {}", e); + } + } + }); + + Ok(manager) + } + + /// Connect to all available MIDI devices (skips already connected devices) + fn connect_to_devices( + connections: &Arc>>, + command_tx: &Arc>>, + active_track_id: &Arc>>, + ) -> Result<(), String> { + // Initialize MIDI input + let mut midi_in = MidiInput::new("Lightningbeam") + .map_err(|e| format!("Failed to initialize MIDI input: {}", e))?; + + // Get all available MIDI input ports + let ports = midi_in.ports(); + + // Get list of currently available device names + let mut available_devices = Vec::new(); + for port in &ports { + if let Ok(port_name) = midi_in.port_name(port) { + available_devices.push(port_name); + } + } + + // Remove disconnected devices from our connections list + { + let mut conns = connections.lock().unwrap(); + let before_count = conns.len(); + conns.retain(|conn| available_devices.contains(&conn.device_name)); + let after_count = conns.len(); + + if before_count != after_count { + println!("MIDI: Removed {} disconnected device(s)", before_count - after_count); + } + } + + // Get list of already connected device names + let connected_devices: Vec = { + let conns = connections.lock().unwrap(); + conns.iter().map(|c| c.device_name.clone()).collect() + }; + + // Store port info first + let mut port_infos = Vec::new(); + for port in &ports { + if let Ok(port_name) = midi_in.port_name(port) { + // Skip if already connected + if !connected_devices.contains(&port_name) { + port_infos.push((port.clone(), port_name)); + } + } + } + + // If no new devices, return early + if port_infos.is_empty() { + return Ok(()); + } + + println!("MIDI: Found {} new device(s)", port_infos.len()); + + // Connect to each new device + for (port, port_name) in port_infos { + println!("MIDI: Connecting to device: {}", port_name); + + // Recreate MidiInput for this connection + midi_in = MidiInput::new("Lightningbeam") + .map_err(|e| format!("Failed to recreate MIDI input: {}", e))?; + + let device_name = port_name.clone(); + let cmd_tx = command_tx.clone(); + let active_id = active_track_id.clone(); + + match midi_in.connect( + &port, + &format!("lightningbeam-{}", port_name), + move |_timestamp, message, _| { + Self::on_midi_message(message, &cmd_tx, &active_id, &device_name); + }, + (), + ) { + Ok(connection) => { + let mut conns = connections.lock().unwrap(); + conns.push(ActiveMidiConnection { + device_name: port_name.clone(), + connection, + }); + println!("MIDI: Connected to: {}", port_name); + } + Err(e) => { + eprintln!("MIDI: Failed to connect to {}: {}", port_name, e); + } + } + } + + let conn_count = connections.lock().unwrap().len(); + println!("MIDI Input: Total connected devices: {}", conn_count); + + Ok(()) + } + + /// MIDI input callback - parses MIDI messages and sends commands to audio engine + fn on_midi_message( + message: &[u8], + command_tx: &Mutex>, + active_track_id: &Arc>>, + device_name: &str, + ) { + if message.is_empty() { + return; + } + + // Get the currently active track + let track_id = { + let active = active_track_id.lock().unwrap(); + match *active { + Some(id) => id, + None => { + // No active track, ignore MIDI input + return; + } + } + }; + + let status_byte = message[0]; + let status = status_byte & 0xF0; + let _channel = status_byte & 0x0F; + + match status { + 0x90 => { + // Note On + if message.len() >= 3 { + let note = message[1]; + let velocity = message[2]; + + // Treat velocity 0 as Note Off (per MIDI spec) + if velocity == 0 { + let mut tx = command_tx.lock().unwrap(); + let _ = tx.push(Command::SendMidiNoteOff(track_id, note)); + println!("MIDI [{}] Note Off: {} (velocity 0)", device_name, note); + } else { + let mut tx = command_tx.lock().unwrap(); + let _ = tx.push(Command::SendMidiNoteOn(track_id, note, velocity)); + println!("MIDI [{}] Note On: {} vel {}", device_name, note, velocity); + } + } + } + 0x80 => { + // Note Off + if message.len() >= 3 { + let note = message[1]; + let mut tx = command_tx.lock().unwrap(); + let _ = tx.push(Command::SendMidiNoteOff(track_id, note)); + println!("MIDI [{}] Note Off: {}", device_name, note); + } + } + 0xB0 => { + // Control Change + if message.len() >= 3 { + let controller = message[1]; + let value = message[2]; + println!("MIDI [{}] CC: {} = {}", device_name, controller, value); + // TODO: Map to automation lanes in Phase 5 + } + } + 0xE0 => { + // Pitch Bend + if message.len() >= 3 { + let lsb = message[1] as u16; + let msb = message[2] as u16; + let value = (msb << 7) | lsb; + println!("MIDI [{}] Pitch Bend: {}", device_name, value); + // TODO: Map to pitch automation in Phase 5 + } + } + _ => { + // Other MIDI messages (aftertouch, program change, etc.) + // Ignore for now + } + } + } + + /// Set the currently active MIDI track + pub fn set_active_track(&self, track_id: Option) { + let mut active = self.active_track_id.lock().unwrap(); + *active = track_id; + + match track_id { + Some(id) => println!("MIDI Input: Routing to track {}", id), + None => println!("MIDI Input: No active track"), + } + } + + /// Get the number of connected devices + pub fn device_count(&self) -> usize { + self.connections.lock().unwrap().len() + } +} diff --git a/daw-backend/src/io/mod.rs b/daw-backend/src/io/mod.rs new file mode 100644 index 0000000..b5b68a1 --- /dev/null +++ b/daw-backend/src/io/mod.rs @@ -0,0 +1,9 @@ +pub mod audio_file; +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 midi_file::load_midi_file; +pub use midi_input::MidiInputManager; +pub use wav_writer::WavWriter; diff --git a/daw-backend/src/io/wav_writer.rs b/daw-backend/src/io/wav_writer.rs new file mode 100644 index 0000000..9b17f04 --- /dev/null +++ b/daw-backend/src/io/wav_writer.rs @@ -0,0 +1,125 @@ +/// Incremental WAV file writer for streaming audio to disk +use std::fs::File; +use std::io::{self, Seek, SeekFrom, Write}; +use std::path::Path; + +/// WAV file writer that supports incremental writing +pub struct WavWriter { + file: File, + sample_rate: u32, + channels: u32, + frames_written: usize, +} + +impl WavWriter { + /// Create a new WAV file and write initial header + /// The header is written with placeholder sizes that will be updated on finalization + pub fn create(path: impl AsRef, sample_rate: u32, channels: u32) -> io::Result { + let mut file = File::create(path)?; + + // Write initial WAV header with placeholder sizes + write_wav_header(&mut file, sample_rate, channels, 0)?; + + Ok(Self { + file, + sample_rate, + channels, + frames_written: 0, + }) + } + + /// Append audio samples to the file + /// Expects interleaved f32 samples in range [-1.0, 1.0] + pub fn write_samples(&mut self, samples: &[f32]) -> io::Result<()> { + // Convert f32 samples to 16-bit PCM + let pcm_data: Vec = samples + .iter() + .flat_map(|&sample| { + let clamped = sample.clamp(-1.0, 1.0); + let pcm_value = (clamped * 32767.0) as i16; + pcm_value.to_le_bytes() + }) + .collect(); + + self.file.write_all(&pcm_data)?; + self.frames_written += samples.len() / self.channels as usize; + + Ok(()) + } + + /// Get the current number of frames written + pub fn frames_written(&self) -> usize { + self.frames_written + } + + /// Get the current duration in seconds + pub fn duration(&self) -> f64 { + self.frames_written as f64 / self.sample_rate as f64 + } + + /// Finalize the WAV file by updating the header with correct sizes + pub fn finalize(mut self) -> io::Result<()> { + // Flush any remaining data + self.file.flush()?; + + // Calculate total data size + let data_size = self.frames_written * self.channels as usize * 2; // 2 bytes per sample (16-bit) + + // WAV file structure: + // RIFF header (12 bytes): "RIFF" + size + "WAVE" + // fmt chunk (24 bytes): "fmt " + size + format data + // data chunk header (8 bytes): "data" + size + // Total header = 44 bytes + // RIFF chunk size = everything after offset 8 = 4 (WAVE) + 24 (fmt) + 8 (data header) + data_size + let riff_chunk_size = 36 + data_size; // 36 = size from "WAVE" to end of data chunk header + + // Seek to RIFF chunk size (offset 4) + self.file.seek(SeekFrom::Start(4))?; + self.file.write_all(&(riff_chunk_size as u32).to_le_bytes())?; + + // Seek to data chunk size (offset 40) + self.file.seek(SeekFrom::Start(40))?; + self.file.write_all(&(data_size as u32).to_le_bytes())?; + + // Flush and sync to ensure all data is written to disk before file is closed + self.file.flush()?; + self.file.sync_all()?; + + Ok(()) + } +} + +/// Write WAV header with specified parameters +fn write_wav_header(file: &mut File, sample_rate: u32, channels: u32, frames: usize) -> io::Result<()> { + let bytes_per_sample = 2u16; // 16-bit PCM + let data_size = (frames * channels as usize * bytes_per_sample as usize) as u32; + + // RIFF chunk size = everything after offset 8 + // = 4 (WAVE) + 24 (fmt chunk) + 8 (data chunk header) + data_size + let riff_chunk_size = 36 + data_size; + + // RIFF header + file.write_all(b"RIFF")?; + file.write_all(&riff_chunk_size.to_le_bytes())?; + file.write_all(b"WAVE")?; + + // fmt chunk + file.write_all(b"fmt ")?; + file.write_all(&16u32.to_le_bytes())?; // fmt chunk size + file.write_all(&1u16.to_le_bytes())?; // PCM format + file.write_all(&(channels as u16).to_le_bytes())?; + file.write_all(&sample_rate.to_le_bytes())?; + + let byte_rate = sample_rate * channels * bytes_per_sample as u32; + file.write_all(&byte_rate.to_le_bytes())?; + + let block_align = channels as u16 * bytes_per_sample; + file.write_all(&block_align.to_le_bytes())?; + file.write_all(&(bytes_per_sample * 8).to_le_bytes())?; // bits per sample + + // data chunk header + file.write_all(b"data")?; + file.write_all(&data_size.to_le_bytes())?; + + Ok(()) +} diff --git a/daw-backend/src/lib.rs b/daw-backend/src/lib.rs new file mode 100644 index 0000000..a8153e6 --- /dev/null +++ b/daw-backend/src/lib.rs @@ -0,0 +1,361 @@ +// DAW Backend - Phase 6: Hierarchical Tracks +// +// A DAW backend with timeline-based playback, clips, audio pool, effects, and hierarchical track groups. +// Supports multiple tracks, mixing, per-track volume/mute/solo, shared audio data, effect chains, and nested groups. +// Uses lock-free command queues, cpal for audio I/O, and symphonia for audio file decoding. + +pub mod audio; +pub mod command; +pub mod time; +pub mod tempo_map; +pub mod dsp; +pub mod effects; +pub mod io; +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, + 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}; + +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; + +/// Trait for emitting audio events to external systems (UI, logging, etc.) +/// This allows the DAW backend to remain framework-agnostic +pub trait EventEmitter: Send + Sync { + /// Emit an audio event + fn emit(&self, event: AudioEvent); +} + +/// Simple audio system that handles cpal initialization internally +pub struct AudioSystem { + pub controller: EngineController, + 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>, + /// Consumer for recording audio mirror (streams recorded samples to UI for live waveform) + recording_mirror_rx: Option>, + /// 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>, + /// The live microphone/line-in stream. `None` until `open_input_stream()` is called. + input_stream: Option, +} + +impl AudioSystem { + /// Initialize the audio system with default input and output devices + /// + /// # Arguments + /// * `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>, + buffer_size: u32, + ) -> Result { + let host = cpal::default_host(); + + // Get output device + let output_device = host + .default_output_device() + .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 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 + let (event_tx, event_rx) = rtrb::RingBuffer::new(256); + let (query_tx, query_rx) = rtrb::RingBuffer::new(16); // Smaller buffer for synchronous queries + let (query_response_tx, query_response_rx) = rtrb::RingBuffer::new(16); + + // 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); + + // 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 + // Create a separate command channel for MIDI input + let (midi_command_tx, midi_command_rx) = rtrb::RingBuffer::new(256); + match io::MidiInputManager::new(midi_command_tx) { + Ok(midi_manager) => { + println!("MIDI input initialized successfully"); + engine.set_midi_input_manager(midi_manager); + engine.set_midi_command_rx(midi_command_rx); + } + Err(e) => { + eprintln!("Warning: Failed to initialize MIDI input: {}", e); + eprintln!("External MIDI controllers will not be available"); + } + } + + // Build output stream + let mut output_config: cpal::StreamConfig = default_output_config.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 { + output_config.buffer_size = cpal::BufferSize::Fixed(buffer_size); + } + + let mut output_buffer = vec![0.0f32; 16384]; + + let output_stream = output_device + .build_output_stream( + &output_config, + move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { + let buf = &mut output_buffer[..data.len()]; + buf.fill(0.0); + engine.process(buf); + data.copy_from_slice(buf); + }, + |err| eprintln!("Output stream error: {}", err), + None, + ) + .map_err(|e| format!("Failed to build output stream: {e:?}"))?; + + // Start output stream + 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 { + Self::spawn_emitter_thread(event_rx, emitter); + None + } else { + Some(event_rx) + }; + + // Input stream is NOT opened here — call open_input_stream() when an + // audio input track is actually selected, to avoid constant ALSA wakeups. + 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> { + 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 { + self.input_tx.take().map(|tx| InputStreamOpener { + input_tx: tx, + sample_rate: self.sample_rate, + channels: self.channels, + }) + } + + /// Spawn a background thread to emit events from the ringbuffer + fn spawn_emitter_thread(mut event_rx: rtrb::Consumer, emitter: std::sync::Arc) { + std::thread::spawn(move || { + loop { + // Wait for events and emit them + if let Ok(event) = event_rx.pop() { + emitter.emit(event); + } else { + // No events available, sleep briefly to avoid busy-waiting + std::thread::sleep(std::time::Duration::from_millis(1)); + } + } + }); + } +} + +/// 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, + 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 { + 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) + } +} diff --git a/daw-backend/src/main.rs b/daw-backend/src/main.rs new file mode 100644 index 0000000..92773bc --- /dev/null +++ b/daw-backend/src/main.rs @@ -0,0 +1,84 @@ +use daw_backend::{AudioEvent, AudioSystem, EventEmitter}; +use daw_backend::tui::run_tui; +use std::env; +use std::sync::{Arc, Mutex}; + +/// Event emitter that pushes events to a ringbuffer for the TUI +struct TuiEventEmitter { + tx: Arc>>, +} + +impl TuiEventEmitter { + fn new(tx: rtrb::Producer) -> Self { + Self { + tx: Arc::new(Mutex::new(tx)), + } + } +} + +impl EventEmitter for TuiEventEmitter { + fn emit(&self, event: AudioEvent) { + if let Ok(mut tx) = self.tx.lock() { + let _ = tx.push(event); + } + } +} + +fn main() -> Result<(), Box> { + // Check if user wants the old CLI mode + let args: Vec = env::args().collect(); + if args.len() > 1 && args[1] == "--help" { + print_usage(); + return Ok(()); + } + + println!("Lightningbeam DAW - Starting TUI...\n"); + println!("Controls:"); + println!(" ESC - Enter Command mode (type commands like 'track MyTrack')"); + println!(" i - Enter Play mode (play MIDI notes with keyboard)"); + println!(" awsedftgyhujkolp;' - Play MIDI notes (chromatic scale in Play mode)"); + println!(" r - Release all notes (in Play mode)"); + println!(" SPACE - Play/Pause"); + println!(" Ctrl+Q - Quit"); + println!("\nStarting audio system..."); + + // Create event channel for TUI + let (event_tx, event_rx) = rtrb::RingBuffer::new(256); + let emitter = Arc::new(TuiEventEmitter::new(event_tx)); + + // Initialize audio system with event emitter and default buffer size + let mut audio_system = AudioSystem::new(Some(emitter), 256)?; + + println!("Audio system initialized:"); + println!(" Sample rate: {} Hz", audio_system.sample_rate); + println!(" Channels: {}", audio_system.channels); + + // Create a test MIDI track to verify event handling + audio_system.controller.create_midi_track("Test Track".to_string()); + + println!("\nTUI starting...\n"); + std::thread::sleep(std::time::Duration::from_millis(100)); // Give time for event + + // Wrap event receiver for TUI + let event_rx = Arc::new(Mutex::new(event_rx)); + + // Run the TUI + run_tui(audio_system.controller, event_rx)?; + + println!("\nGoodbye!"); + Ok(()) +} + +fn print_usage() { + println!("Lightningbeam DAW - Terminal User Interface"); + println!("\nUsage: {} [OPTIONS]", env::args().next().unwrap()); + println!("\nOptions:"); + println!(" --help Show this help message"); + println!("\nThe DAW will start in TUI mode with an empty project."); + println!("Use commands to create tracks and load audio:"); + println!(" :track - Create MIDI track"); + println!(" :audiotrack - Create audio track"); + println!(" :play - Start playback"); + println!(" :stop - Stop playback"); + println!(" :quit - Exit application"); +} diff --git a/daw-backend/src/tempo_map.rs b/daw-backend/src/tempo_map.rs new file mode 100644 index 0000000..b34751a --- /dev/null +++ b/daw-backend/src/tempo_map.rs @@ -0,0 +1,332 @@ +//! 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` 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, + /// 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 { + 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 { + 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 = 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); + } + } +} diff --git a/daw-backend/src/time.rs b/daw-backend/src/time.rs new file mode 100644 index 0000000..b2e2913 --- /dev/null +++ b/daw-backend/src/time.rs @@ -0,0 +1,125 @@ +/// 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 for Beats { + type Output = Self; + fn mul(self, rhs: f64) -> Self { Self(self.0 * rhs) } +} +impl Div for Beats { + type Output = Self; + fn div(self, rhs: f64) -> Self { Self(self.0 / rhs) } +} +/// Beats / Beats = dimensionless ratio (f64) +impl Div 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 for Seconds { + type Output = Self; + fn mul(self, rhs: f64) -> Self { Self(self.0 * rhs) } +} +impl Div for Seconds { + type Output = Self; + fn div(self, rhs: f64) -> Self { Self(self.0 / rhs) } +} +/// Seconds / Seconds = dimensionless ratio (f64) +impl Div 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) + } +} diff --git a/daw-backend/src/tui/mod.rs b/daw-backend/src/tui/mod.rs new file mode 100644 index 0000000..3dc57bc --- /dev/null +++ b/daw-backend/src/tui/mod.rs @@ -0,0 +1,922 @@ +use crate::audio::EngineController; +use crate::command::AudioEvent; +use crate::io::load_midi_file; +use crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{ + backend::CrosstermBackend, + layout::{Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, Paragraph}, + Frame, Terminal, +}; +use std::io; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +/// TUI application mode +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum AppMode { + /// Command mode - type vim-style commands + Command, + /// Play mode - use keyboard to play MIDI notes + Play, +} + +/// TUI application state +pub struct TuiApp { + /// Current application mode + mode: AppMode, + /// Command input buffer (for Command mode) + command_input: String, + /// Current playback position (seconds) + playback_position: f64, + /// Whether playback is active + is_playing: bool, + /// Status message to display + status_message: String, + /// List of tracks (track_id, name) + tracks: Vec<(u32, String)>, + /// Currently selected track for MIDI input + selected_track: Option, + /// Active MIDI notes (currently held down) + active_notes: Vec, + /// Command history for up/down navigation + command_history: Vec, + /// Current position in command history + history_index: Option, + /// Clips on timeline: (track_id, clip_id, start_time, duration, name, notes) + /// Notes: Vec<(pitch, time_offset, duration)> + clips: Vec<(u32, u32, f64, f64, String, Vec<(u8, f64, f64)>)>, + /// Next clip ID for locally created clips + next_clip_id: u32, + /// Timeline scroll offset in seconds (start of visible window) + timeline_scroll: f64, + /// Timeline visible duration in seconds (zoom level) + timeline_visible_duration: f64, +} + +impl TuiApp { + pub fn new() -> Self { + Self { + mode: AppMode::Command, + command_input: String::new(), + playback_position: 0.0, + is_playing: false, + status_message: "SPACE=play/pause | ←/→ scroll | -/+ zoom | 'i'=Play mode | Type 'help'".to_string(), + tracks: Vec::new(), + selected_track: None, + active_notes: Vec::new(), + command_history: Vec::new(), + history_index: None, + clips: Vec::new(), + next_clip_id: 0, + timeline_scroll: 0.0, + timeline_visible_duration: 10.0, // Show 10 seconds at a time by default + } + } + + /// Switch to command mode + pub fn enter_command_mode(&mut self) { + self.mode = AppMode::Command; + self.command_input.clear(); + self.history_index = None; + self.status_message = "-- COMMAND -- SPACE=play/pause | ←/→ scroll | -/+ zoom | 'i' for Play mode | Type 'help'".to_string(); + } + + /// Switch to play mode + pub fn enter_play_mode(&mut self) { + self.mode = AppMode::Play; + self.command_input.clear(); + self.status_message = "-- PLAY -- Press '?' for help, 'ESC' for Command mode".to_string(); + } + + /// Add a character to command input + pub fn push_command_char(&mut self, c: char) { + self.command_input.push(c); + } + + /// Remove last character from command input + pub fn pop_command_char(&mut self) { + self.command_input.pop(); + } + + /// Get the current command input + pub fn command_input(&self) -> &str { + &self.command_input + } + + /// Clear command input + pub fn clear_command(&mut self) { + self.command_input.clear(); + self.history_index = None; + } + + /// Add command to history + pub fn add_to_history(&mut self, command: String) { + if !command.is_empty() && self.command_history.last() != Some(&command) { + self.command_history.push(command); + } + } + + /// Navigate command history up + pub fn history_up(&mut self) { + if self.command_history.is_empty() { + return; + } + + let new_index = match self.history_index { + None => Some(self.command_history.len() - 1), + Some(0) => Some(0), + Some(i) => Some(i - 1), + }; + + if let Some(idx) = new_index { + self.history_index = Some(idx); + self.command_input = self.command_history[idx].clone(); + } + } + + /// Navigate command history down + pub fn history_down(&mut self) { + match self.history_index { + None => {} + Some(i) if i >= self.command_history.len() - 1 => { + self.history_index = None; + self.command_input.clear(); + } + Some(i) => { + let new_idx = i + 1; + self.history_index = Some(new_idx); + self.command_input = self.command_history[new_idx].clone(); + } + } + } + + /// Update playback position and auto-scroll timeline if needed + pub fn update_playback_position(&mut self, position: f64) { + self.playback_position = position; + + // Auto-scroll to keep playhead in view when playing + if self.is_playing { + // Keep playhead in the visible window, with some margin + let margin = self.timeline_visible_duration * 0.1; // 10% margin + + // If playhead is ahead of visible window, scroll forward + if position > self.timeline_scroll + self.timeline_visible_duration - margin { + self.timeline_scroll = (position - self.timeline_visible_duration * 0.5).max(0.0); + } + // If playhead is behind visible window, scroll backward + else if position < self.timeline_scroll + margin { + self.timeline_scroll = (position - margin).max(0.0); + } + } + } + + /// Set playing state + pub fn set_playing(&mut self, playing: bool) { + self.is_playing = playing; + } + + /// Set status message + pub fn set_status(&mut self, message: String) { + self.status_message = message; + } + + /// Add a track to the UI + pub fn add_track(&mut self, track_id: u32, name: String) { + self.tracks.push((track_id, name)); + // Auto-select first MIDI track for playing + if self.selected_track.is_none() { + self.selected_track = Some(track_id); + } + } + + /// Clear all tracks + pub fn clear_tracks(&mut self) { + self.tracks.clear(); + self.clips.clear(); + self.selected_track = None; + self.next_clip_id = 0; + self.timeline_scroll = 0.0; + } + + /// Select a track by index + pub fn select_track(&mut self, index: usize) { + if let Some((track_id, _)) = self.tracks.get(index) { + self.selected_track = Some(*track_id); + } + } + + /// Get selected track + pub fn selected_track(&self) -> Option { + self.selected_track + } + + /// Add a clip to the timeline + pub fn add_clip(&mut self, track_id: u32, clip_id: u32, start_time: f64, duration: f64, name: String, notes: Vec<(u8, f64, f64)>) { + self.clips.push((track_id, clip_id, start_time, duration, name, notes)); + } + + /// Get max timeline duration based on clips + pub fn get_timeline_duration(&self) -> f64 { + self.clips + .iter() + .map(|(_, _, start, dur, _, _)| start + dur) + .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) + .unwrap_or(10.0) // Default to 10 seconds if no clips + } + + /// Add an active MIDI note + pub fn add_active_note(&mut self, note: u8) { + if !self.active_notes.contains(¬e) { + self.active_notes.push(note); + } + } + + /// Remove an active MIDI note + pub fn remove_active_note(&mut self, note: u8) { + self.active_notes.retain(|&n| n != note); + } + + /// Get current mode + pub fn mode(&self) -> AppMode { + self.mode + } + + /// Scroll timeline left + pub fn scroll_timeline_left(&mut self) { + let scroll_amount = self.timeline_visible_duration * 0.2; // Scroll by 20% of visible duration + self.timeline_scroll = (self.timeline_scroll - scroll_amount).max(0.0); + } + + /// Scroll timeline right + pub fn scroll_timeline_right(&mut self) { + let scroll_amount = self.timeline_visible_duration * 0.2; // Scroll by 20% of visible duration + let max_duration = self.get_timeline_duration(); + self.timeline_scroll = (self.timeline_scroll + scroll_amount).min(max_duration - self.timeline_visible_duration).max(0.0); + } + + /// Zoom timeline in (show less time, more detail) + pub fn zoom_timeline_in(&mut self) { + self.timeline_visible_duration = (self.timeline_visible_duration * 0.8).max(1.0); // Min 1 second visible + } + + /// Zoom timeline out (show more time, less detail) + pub fn zoom_timeline_out(&mut self) { + let max_duration = self.get_timeline_duration(); + self.timeline_visible_duration = (self.timeline_visible_duration * 1.25).min(max_duration).max(1.0); + } +} + +/// Map keyboard keys to MIDI notes +/// Uses chromatic layout: awsedftgyhujkolp;' +/// This provides 1.5 octaves starting from C4 (MIDI note 60) +pub fn key_to_midi_note(key: KeyCode) -> Option { + let base = 60; // C4 + + match key { + KeyCode::Char('a') => Some(base), // C4 + KeyCode::Char('w') => Some(base + 1), // C#4 + KeyCode::Char('s') => Some(base + 2), // D4 + KeyCode::Char('e') => Some(base + 3), // D#4 + KeyCode::Char('d') => Some(base + 4), // E4 + KeyCode::Char('f') => Some(base + 5), // F4 + KeyCode::Char('t') => Some(base + 6), // F#4 + KeyCode::Char('g') => Some(base + 7), // G4 + KeyCode::Char('y') => Some(base + 8), // G#4 + KeyCode::Char('h') => Some(base + 9), // A4 + KeyCode::Char('u') => Some(base + 10), // A#4 + KeyCode::Char('j') => Some(base + 11), // B4 + KeyCode::Char('k') => Some(base + 12), // C5 + KeyCode::Char('o') => Some(base + 13), // C#5 + KeyCode::Char('l') => Some(base + 14), // D5 + KeyCode::Char('p') => Some(base + 15), // D#5 + KeyCode::Char(';') => Some(base + 16), // E5 + KeyCode::Char('\'') => Some(base + 17), // F5 + + _ => None, + } +} + +/// Convert pitch % 8 to braille dot bit position +fn pitch_to_braille_bit(pitch_mod_8: u8) -> u8 { + match pitch_mod_8 { + 0 => 0x01, // Dot 1 + 1 => 0x02, // Dot 2 + 2 => 0x04, // Dot 3 + 3 => 0x40, // Dot 7 + 4 => 0x08, // Dot 4 + 5 => 0x10, // Dot 5 + 6 => 0x20, // Dot 6 + 7 => 0x80, // Dot 8 + _ => 0x00, + } +} + +/// Draw the timeline view with clips +fn draw_timeline(f: &mut Frame, area: ratatui::layout::Rect, app: &TuiApp) { + let num_tracks = app.tracks.len(); + + // Use visible duration for the timeline window + let visible_start = app.timeline_scroll; + let visible_end = app.timeline_scroll + app.timeline_visible_duration; + + // Create the timeline block with visible range + let block = Block::default() + .borders(Borders::ALL) + .title(format!("Timeline ({:.1}s - {:.1}s) | ←/→ scroll | -/+ zoom", visible_start, visible_end)); + + let inner_area = block.inner(area); + f.render_widget(block, area); + + // Calculate dimensions + let width = inner_area.width as usize; + + if width == 0 || num_tracks == 0 { + return; + } + + // Fixed track height: 2 lines per track + let track_height = 2; + + // Build timeline content with braille characters + let mut lines: Vec = Vec::new(); + + for track_idx in 0..num_tracks { + let track_id = if let Some((id, _)) = app.tracks.get(track_idx) { + *id + } else { + continue; + }; + + // Create exactly 2 lines for this track + for _ in 0..track_height { + let mut spans = Vec::new(); + + // Build the timeline character by character + for char_x in 0..width { + // Map character position to time, using scroll offset + let time_pos = visible_start + (char_x as f64 / width as f64) * app.timeline_visible_duration; + + // Check if playhead is at this position + let is_playhead = (time_pos - app.playback_position).abs() < (app.timeline_visible_duration / width as f64); + + // Find all notes active at this time position on this track + let mut braille_pattern: u8 = 0; + let mut has_notes = false; + + for (clip_track_id, _clip_id, clip_start, _clip_duration, _name, notes) in &app.clips { + if *clip_track_id == track_id { + // Check each note in this clip + for (pitch, note_offset, note_duration) in notes { + let note_start = clip_start + note_offset; + let note_end = note_start + note_duration; + + // Is this note active at current time position? + if time_pos >= note_start && time_pos < note_end { + let pitch_mod = pitch % 8; + braille_pattern |= pitch_to_braille_bit(pitch_mod); + has_notes = true; + } + } + } + } + + // Determine color + let color = if Some(track_id) == app.selected_track { + Color::Yellow + } else { + Color::Cyan + }; + + // Create span + if is_playhead { + // Playhead: red background + if has_notes { + // Show white notes with red background + let braille_char = char::from_u32(0x2800 + braille_pattern as u32).unwrap_or(' '); + spans.push(Span::styled(braille_char.to_string(), Style::default().fg(Color::White).bg(Color::Red))); + } else { + spans.push(Span::styled(" ", Style::default().bg(Color::Red))); + } + } else if has_notes { + // Show white braille pattern on colored background + let braille_char = char::from_u32(0x2800 + braille_pattern as u32).unwrap_or(' '); + spans.push(Span::styled(braille_char.to_string(), Style::default().fg(Color::White).bg(color))); + } else { + // Empty space + spans.push(Span::raw(" ")); + } + } + + lines.push(Line::from(spans)); + } + } + + let paragraph = Paragraph::new(lines); + f.render_widget(paragraph, inner_area); +} + +/// Draw the TUI +pub fn draw_ui(f: &mut Frame, app: &TuiApp) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Title bar + Constraint::Min(10), // Main content + Constraint::Length(3), // Status bar + Constraint::Length(1), // Command line + ]) + .split(f.size()); + + // Title bar + let title = Paragraph::new("Lightningbeam DAW") + .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)) + .block(Block::default().borders(Borders::ALL)); + f.render_widget(title, chunks[0]); + + // Main content area - split into tracks and timeline + let content_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(20), Constraint::Percentage(80)]) + .split(chunks[1]); + + // Tracks list - each track gets 2 lines to match timeline + let track_items: Vec = app + .tracks + .iter() + .map(|(id, name)| { + let style = if app.selected_track == Some(*id) { + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + // Create a 2-line item: track info on first line, empty second line + let lines = vec![ + Line::from(format!("T{}: {}", id, name)), + Line::from(""), + ]; + ListItem::new(lines).style(style) + }) + .collect(); + + let tracks_list = List::new(track_items) + .block(Block::default().borders(Borders::ALL).title("Tracks")); + f.render_widget(tracks_list, content_chunks[0]); + + // Timeline area - split vertically into playback info and timeline view + let timeline_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(4), Constraint::Min(5)]) + .split(content_chunks[1]); + + // Playback info + let playback_info = vec![ + Line::from(vec![ + Span::raw("Position: "), + Span::styled( + format!("{:.2}s", app.playback_position), + Style::default().fg(Color::Green), + ), + Span::raw(" | Status: "), + Span::styled( + if app.is_playing { "Playing" } else { "Stopped" }, + if app.is_playing { + Style::default().fg(Color::Green) + } else { + Style::default().fg(Color::Red) + }, + ), + ]), + Line::from(format!("Active Notes: {}", + app.active_notes + .iter() + .map(|n| format!("{} ", n)) + .collect::() + )), + ]; + + let info = Paragraph::new(playback_info) + .block(Block::default().borders(Borders::ALL).title("Playback")); + f.render_widget(info, timeline_chunks[0]); + + // Draw timeline + draw_timeline(f, timeline_chunks[1], app); + + // Status bar + let mode_indicator = match app.mode { + AppMode::Command => "COMMAND", + AppMode::Play => "PLAY", + }; + + let status_text = format!("Mode: {} | {}", mode_indicator, app.status_message); + let status_bar = Paragraph::new(status_text) + .style(Style::default().fg(Color::White)) + .block(Block::default().borders(Borders::ALL)); + f.render_widget(status_bar, chunks[2]); + + // Command line + let command_line = if app.mode == AppMode::Command { + format!(":{}", app.command_input) + } else { + String::from("ESC=cmd mode | awsedftgyhujkolp;'=notes | R=release notes | ?=help | SPACE=play/pause") + }; + + let cmd_widget = Paragraph::new(command_line).style(Style::default().fg(Color::Yellow)); + f.render_widget(cmd_widget, chunks[3]); +} + +/// Run the TUI application +pub fn run_tui( + mut controller: EngineController, + event_rx: Arc>>, +) -> Result<(), Box> { + // Setup terminal + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + // Create app state + let mut app = TuiApp::new(); + + // Main loop + loop { + // Draw UI + terminal.draw(|f| draw_ui(f, &app))?; + + // Poll for audio events + if let Ok(mut rx) = event_rx.lock() { + while let Ok(event) = rx.pop() { + match event { + AudioEvent::PlaybackPosition(pos) => { + app.update_playback_position(pos); + } + AudioEvent::PlaybackStopped => { + app.set_playing(false); + } + AudioEvent::TrackCreated(track_id, _, name) => { + app.add_track(track_id, name); + } + AudioEvent::RecordingStopped(clip_id, _pool_index, _waveform) => { + // Update status + app.set_status(format!("Recording stopped - Clip {}", clip_id)); + } + AudioEvent::ProjectReset => { + app.clear_tracks(); + app.set_status("Project reset".to_string()); + } + _ => {} + } + } + } + + // Handle keyboard input with timeout + if event::poll(Duration::from_millis(100))? { + if let Event::Key(key) = event::read()? { + match app.mode() { + AppMode::Command => { + match key.code { + KeyCode::Left => { + // Scroll timeline left only if command buffer is empty + if app.command_input().is_empty() { + app.scroll_timeline_left(); + } + } + KeyCode::Right => { + // Scroll timeline right only if command buffer is empty + if app.command_input().is_empty() { + app.scroll_timeline_right(); + } + } + KeyCode::Char('-') | KeyCode::Char('_') => { + // Zoom out only if command buffer is empty + if app.command_input().is_empty() { + app.zoom_timeline_out(); + } + } + KeyCode::Char('+') | KeyCode::Char('=') => { + // Zoom in only if command buffer is empty + if app.command_input().is_empty() { + app.zoom_timeline_in(); + } + } + KeyCode::Char(' ') => { + // Spacebar toggles play/pause only if command buffer is empty + // Otherwise, add space to command + if app.command_input().is_empty() { + if app.is_playing { + controller.pause(); + app.set_playing(false); + app.set_status("Paused".to_string()); + } else { + controller.play(); + app.set_playing(true); + app.set_status("Playing".to_string()); + } + } else { + app.push_command_char(' '); + } + } + KeyCode::Esc => { + app.clear_command(); + } + KeyCode::Enter => { + let command = app.command_input().to_string(); + app.add_to_history(command.clone()); + + // Execute command + match execute_command(&command, &mut controller, &mut app) { + Err(e) if e == "Quit requested" => { + break; // Exit the application + } + Err(e) => { + app.set_status(format!("Error: {}", e)); + } + Ok(_) => {} + } + + app.clear_command(); + } + KeyCode::Backspace => { + app.pop_command_char(); + } + KeyCode::Up => { + app.history_up(); + } + KeyCode::Down => { + app.history_down(); + } + KeyCode::Char('i') => { + // Only switch to Play mode if command buffer is empty + if app.command_input().is_empty() { + app.enter_play_mode(); + } else { + app.push_command_char('i'); + } + } + KeyCode::Char(c) => { + app.push_command_char(c); + } + _ => {} + } + } + AppMode::Play => { + // Check for mode switch first + if key.code == KeyCode::Esc { + app.enter_command_mode(); + continue; + } + + // Check for quit + if key.code == KeyCode::Char('q') && key.modifiers.contains(KeyModifiers::CONTROL) { + break; + } + + // Handle MIDI note playing + if let Some(note) = key_to_midi_note(key.code) { + if let Some(track_id) = app.selected_track() { + // Release all previous notes before playing new one + for prev_note in app.active_notes.clone() { + controller.send_midi_note_off(track_id, prev_note); + } + app.active_notes.clear(); + + // Play the new note + controller.send_midi_note_on(track_id, note, 100); + app.add_active_note(note); + } + } else { + // Handle other play mode shortcuts + match key.code { + KeyCode::Char(' ') => { + // Release all notes and toggle play/pause + if let Some(track_id) = app.selected_track() { + for note in app.active_notes.clone() { + controller.send_midi_note_off(track_id, note); + } + app.active_notes.clear(); + } + + if app.is_playing { + controller.pause(); + app.set_playing(false); + } else { + controller.play(); + app.set_playing(true); + } + } + KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => { + // Release all notes and stop + if let Some(track_id) = app.selected_track() { + for note in app.active_notes.clone() { + controller.send_midi_note_off(track_id, note); + } + app.active_notes.clear(); + } + controller.stop(); + app.set_playing(false); + } + KeyCode::Char('r') | KeyCode::Char('R') => { + // Release all notes manually (r for release) + if let Some(track_id) = app.selected_track() { + for note in app.active_notes.clone() { + controller.send_midi_note_off(track_id, note); + } + app.active_notes.clear(); + } + app.set_status("All notes released".to_string()); + } + KeyCode::Char('?') | KeyCode::Char('h') | KeyCode::Char('H') => { + app.set_status("Play Mode: awsedftgyhujkolp;'=notes | R=release | SPACE=play/pause | ESC=command | Ctrl+Q=quit".to_string()); + } + _ => {} + } + } + } + } + } + } + } + + // Restore terminal + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + + Ok(()) +} + +/// Execute a command string +fn execute_command( + command: &str, + controller: &mut EngineController, + app: &mut TuiApp, +) -> Result<(), String> { + let parts: Vec<&str> = command.trim().split_whitespace().collect(); + + if parts.is_empty() { + return Ok(()); + } + + match parts[0] { + "play" => { + controller.play(); + app.set_playing(true); + app.set_status("Playing".to_string()); + } + "pause" => { + controller.pause(); + app.set_playing(false); + app.set_status("Paused".to_string()); + } + "stop" => { + controller.stop(); + app.set_playing(false); + app.set_status("Stopped".to_string()); + } + "seek" => { + if parts.len() < 2 { + return Err("Usage: seek ".to_string()); + } + let pos: f64 = parts[1].parse().map_err(|_| "Invalid position")?; + controller.seek(pos); + app.set_status(format!("Seeked to {:.2}s", pos)); + } + "track" => { + if parts.len() < 2 { + return Err("Usage: track ".to_string()); + } + let name = parts[1..].join(" "); + controller.create_midi_track(name.clone()); + app.set_status(format!("Created MIDI track: {}", name)); + } + "audiotrack" => { + if parts.len() < 2 { + return Err("Usage: audiotrack ".to_string()); + } + let name = parts[1..].join(" "); + controller.create_audio_track(name.clone()); + app.set_status(format!("Created audio track: {}", name)); + } + "select" => { + if parts.len() < 2 { + return Err("Usage: select ".to_string()); + } + let idx: usize = parts[1].parse().map_err(|_| "Invalid track number")?; + app.select_track(idx); + app.set_status(format!("Selected track {}", idx)); + } + "clip" => { + if parts.len() < 4 { + return Err("Usage: clip ".to_string()); + } + let track_id: u32 = parts[1].parse().map_err(|_| "Invalid track ID")?; + let start_time: f64 = parts[2].parse().map_err(|_| "Invalid start time")?; + let duration: f64 = parts[3].parse().map_err(|_| "Invalid duration")?; + + // Add clip to local UI state (empty clip, no notes) + let clip_id = app.next_clip_id; + app.next_clip_id += 1; + app.add_clip(track_id, clip_id, start_time, duration, format!("Clip {}", clip_id), Vec::new()); + + controller.create_midi_clip(track_id, start_time, duration); + app.set_status(format!("Created MIDI clip on track {} at {:.2}s for {:.2}s", track_id, start_time, duration)); + } + "loadmidi" => { + if parts.len() < 3 { + return Err("Usage: loadmidi [start_time]".to_string()); + } + let track_id: u32 = parts[1].parse().map_err(|_| "Invalid track ID")?; + let file_path = parts[2]; + let start_time: f64 = if parts.len() >= 4 { + parts[3].parse().unwrap_or(0.0) + } else { + 0.0 + }; + + // Load the MIDI file + match load_midi_file(file_path, app.next_clip_id, 48000) { + Ok(midi_clip) => { + let clip_id = midi_clip.id; + let duration = midi_clip.duration; + let event_count = midi_clip.events.len(); + + // Extract note data for visualization + let mut notes = Vec::new(); + let mut active_notes: std::collections::HashMap = std::collections::HashMap::new(); + let sample_rate = 48000.0; // Sample rate used for loading MIDI + + for event in &midi_clip.events { + let status = event.status & 0xF0; + let time_seconds = event.timestamp.beats_to_f64() / sample_rate; + + match status { + 0x90 if event.data2 > 0 => { + // Note on + active_notes.insert(event.data1, time_seconds); + } + 0x80 | 0x90 => { + // Note off (or note on with velocity 0) + if let Some(start) = active_notes.remove(&event.data1) { + let note_duration = time_seconds - start; + notes.push((event.data1, start, note_duration)); + } + } + _ => {} + } + } + + // 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.next_clip_id += 1; + + // Send to audio engine with the start_time (clip content is separate from timeline position) + controller.add_loaded_midi_clip(track_id, midi_clip, start_time); + + app.set_status(format!("Loaded {} ({} events, {:.2}s) to track {} at {:.2}s", + file_path, event_count, duration, track_id, start_time)); + } + Err(e) => { + return Err(format!("Failed to load MIDI file: {}", e)); + } + } + } + "reset" => { + controller.reset(); + app.clear_tracks(); + app.set_status("Project reset".to_string()); + } + "q" | "quit" => { + return Err("Quit requested".to_string()); + } + "help" | "h" | "?" => { + // Show comprehensive help + let help_msg = concat!( + "Commands: ", + "play | pause | stop | seek | ", + "track | audiotrack | select | ", + "clip | ", + "loadmidi [start] | ", + "reset | quit | help | ", + "Keys: ←/→ scroll | -/+ zoom" + ); + app.set_status(help_msg.to_string()); + } + _ => { + return Err(format!("Unknown command: '{}'. Type 'help' for commands", parts[0])); + } + } + + Ok(()) +} diff --git a/daw-backend/test.wav b/daw-backend/test.wav new file mode 100644 index 0000000..1c49b4a Binary files /dev/null and b/daw-backend/test.wav differ diff --git a/daw-backend/tests/node_graph_test.rs b/daw-backend/tests/node_graph_test.rs new file mode 100644 index 0000000..01385ba --- /dev/null +++ b/daw-backend/tests/node_graph_test.rs @@ -0,0 +1,109 @@ +use daw_backend::audio::node_graph::{ + nodes::{AudioOutputNode, GainNode, OscillatorNode}, + ConnectionError, InstrumentGraph, SignalType, +}; + +#[test] +fn test_basic_node_graph() { + // Create a graph with sample rate 44100 and buffer size 512 + let mut graph = InstrumentGraph::new(44100, 512); + + // Create nodes + let osc = Box::new(OscillatorNode::new("Oscillator")); + let gain = Box::new(GainNode::new("Gain")); + let output = Box::new(AudioOutputNode::new("Output")); + + // Add nodes to graph + let osc_idx = graph.add_node(osc); + let gain_idx = graph.add_node(gain); + let output_idx = graph.add_node(output); + + // Connect: Oscillator -> Gain -> Output + assert!(graph.connect(osc_idx, 0, gain_idx, 0).is_ok()); + assert!(graph.connect(gain_idx, 0, output_idx, 0).is_ok()); + + // Set output node + graph.set_output_node(Some(output_idx)); + + // Set oscillator frequency to 440 Hz + if let Some(node) = graph.get_graph_node_mut(osc_idx) { + node.node.set_parameter(0, 440.0); // Frequency parameter + } + + // Process a buffer + let mut output_buffer = vec![0.0f32; 512]; + graph.process(&mut output_buffer, &[]); + + // Check that we got some audio output (oscillator should produce non-zero samples) + let has_output = output_buffer.iter().any(|&s| s != 0.0); + assert!(has_output, "Expected non-zero audio output from oscillator"); + + // Check that output is within reasonable bounds + let max_amplitude = output_buffer.iter().map(|s| s.abs()).fold(0.0f32, f32::max); + assert!(max_amplitude <= 1.0, "Output amplitude too high: {}", max_amplitude); +} + +#[test] +fn test_connection_type_validation() { + + let mut graph = InstrumentGraph::new(44100, 512); + + let osc = Box::new(OscillatorNode::new("Oscillator")); + let output = Box::new(AudioOutputNode::new("Output")); + + let osc_idx = graph.add_node(osc); + let output_idx = graph.add_node(output); + + // This should work (Audio -> Audio) + let result = graph.connect(osc_idx, 0, output_idx, 0); + assert!(result.is_ok()); + + // Try to connect CV to Audio - should fail + // Oscillator CV input (port 0 - wait, actually oscillator has CV input) + // Let me create a more clear test: + let osc2 = Box::new(OscillatorNode::new("Oscillator2")); + let osc2_idx = graph.add_node(osc2); + + // Try to connect audio output to CV input + // This would be caught if we had different signal types + // For now, just verify the connection succeeds with matching types + let result = graph.connect(osc_idx, 0, osc2_idx, 0); + // This should actually fail because audio output can't connect to CV input + assert!(result.is_err()); + + match result { + Err(ConnectionError::TypeMismatch { expected, got }) => { + assert_eq!(expected, SignalType::CV); + assert_eq!(got, SignalType::Audio); + } + _ => panic!("Expected TypeMismatch error"), + } +} + +#[test] +fn test_cycle_detection() { + let mut graph = InstrumentGraph::new(44100, 512); + + let gain1 = Box::new(GainNode::new("Gain1")); + let gain2 = Box::new(GainNode::new("Gain2")); + let gain3 = Box::new(GainNode::new("Gain3")); + + let g1 = graph.add_node(gain1); + let g2 = graph.add_node(gain2); + let g3 = graph.add_node(gain3); + + // Create a chain: g1 -> g2 -> g3 + assert!(graph.connect(g1, 0, g2, 0).is_ok()); + assert!(graph.connect(g2, 0, g3, 0).is_ok()); + + // Try to create a cycle: g3 -> g1 + let result = graph.connect(g3, 0, g1, 0); + assert!(result.is_err()); + + match result { + Err(ConnectionError::WouldCreateCycle) => { + // Expected! + } + _ => panic!("Expected WouldCreateCycle error"), + } +} diff --git a/daw-backend/ttls.mid b/daw-backend/ttls.mid new file mode 100644 index 0000000..e526e25 Binary files /dev/null and b/daw-backend/ttls.mid differ diff --git a/docs/AUDIO_SYSTEM.md b/docs/AUDIO_SYSTEM.md new file mode 100644 index 0000000..635c31a --- /dev/null +++ b/docs/AUDIO_SYSTEM.md @@ -0,0 +1,1092 @@ +# Audio System Architecture + +This document describes the architecture of Lightningbeam's audio engine (`daw-backend`), including real-time constraints, lock-free design patterns, and how to extend the system with new effects and features. + +## Table of Contents + +- [Overview](#overview) +- [Architecture](#architecture) +- [Real-Time Constraints](#real-time-constraints) +- [Lock-Free Communication](#lock-free-communication) +- [Audio Processing Pipeline](#audio-processing-pipeline) +- [Adding Effects](#adding-effects) +- [Adding Synthesizers](#adding-synthesizers) +- [MIDI System](#midi-system) +- [Performance Optimization](#performance-optimization) +- [Debugging Audio Issues](#debugging-audio-issues) + +## Overview + +The `daw-backend` crate is a standalone real-time audio engine designed for: + +- **Multi-track audio playback and recording** +- **Real-time audio effects processing** +- **MIDI input/output and sequencing** +- **Modular audio routing** (node graph system) +- **Audio export** (WAV, MP3, AAC) + +### Key Features + +- Lock-free design for real-time safety +- Cross-platform audio I/O via cpal +- Audio decoding via symphonia (MP3, FLAC, WAV, Ogg, AAC) +- Node-based audio graph processing +- Comprehensive effects library +- Multiple synthesizer types +- Zero-allocation audio thread + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ UI Thread │ +│ (lightningbeam-editor or other application) │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ AudioSystem::new() ─────> Creates audio stream │ +│ │ │ +│ ├─> command_sender (rtrb::Producer) │ +│ └─> state_receiver (rtrb::Consumer) │ +│ │ +│ Commands sent: │ +│ - Play / Stop / Seek │ +│ - Add / Remove tracks │ +│ - Load audio files │ +│ - Add / Remove effects │ +│ - Update parameters │ +│ │ +└──────────────────────┬──────────────────────────────────────┘ + │ + │ Lock-free queues (rtrb) + │ +┌──────────────────────▼──────────────────────────────────────┐ +│ Audio Thread (Real-Time) │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ Engine::process(output_buffer) │ +│ │ │ +│ ├─> Receive commands from queue │ +│ ├─> Update playhead position │ +│ ├─> For each track: │ +│ │ ├─> Read audio samples at playhead │ +│ │ ├─> Apply effects chain │ +│ │ └─> Mix to output │ +│ ├─> Apply master effects │ +│ └─> Write samples to output_buffer │ +│ │ +│ Send state updates back to UI thread │ +│ - Playhead position │ +│ - Meter levels │ +│ - Overrun warnings │ +│ │ +└──────────────────────┬──────────────────────────────────────┘ + │ + ▼ + ┌───────────┐ + │ cpal │ + │ (Audio │ + │ I/O) │ + └───────────┘ + │ + ▼ + ┌──────────────┐ + │ Audio Output │ + │ (Speakers) │ + └──────────────┘ +``` + +### Core Components + +#### AudioSystem (`src/lib.rs`) +- Entry point for the audio engine +- Creates the audio stream +- Sets up lock-free communication channels +- Manages audio device configuration + +#### Engine (`src/audio/engine.rs`) +- The main audio callback +- Runs on the real-time audio thread +- Processes commands, mixes tracks, applies effects +- Must complete in ~5ms (at 44.1kHz, 256 frame buffer) + +#### Project (`src/audio/project.rs`) +- Top-level audio state +- Contains tracks, tempo, time signature +- Manages global settings + +#### Track (`src/audio/track.rs`) +- Individual audio track +- Contains audio clips and effects chain +- Handles track-specific state (volume, pan, mute, solo) + +## Real-Time Constraints + +### The Golden Rule + +**The audio thread must NEVER block.** + +Audio callbacks run with strict timing deadlines: +- **Buffer size**: 256 frames (default) = ~5.8ms at 44.1kHz +- **ALSA on Linux**: May provide smaller buffers (64-75 frames = ~1.5ms) +- **Deadline**: Audio callback must complete before next buffer is needed + +If the audio callback takes too long: +- **Audio dropout**: Audible glitch/pop in output +- **Buffer underrun**: Missing samples +- **System instability**: Priority inversion, thread starvation + +### Forbidden Operations in Audio Thread + +❌ **Never do these in the audio callback:** + +- **Locking**: `Mutex`, `RwLock`, or any blocking synchronization +- **Allocation**: `Vec::push()`, `Box::new()`, `String` operations +- **I/O**: File operations, network, print statements +- **System calls**: Most OS operations +- **Unbounded loops**: Must have guaranteed completion time + +✅ **Safe operations:** + +- Reading/writing lock-free queues (rtrb) +- Fixed-size array operations +- Arithmetic and DSP calculations +- Pre-allocated buffer operations + +### Optimized Debug Builds + +To meet real-time deadlines, audio code is compiled with optimizations even in debug builds: + +```toml +# In lightningbeam-ui/Cargo.toml +[profile.dev.package.daw-backend] +opt-level = 2 + +[profile.dev.package.symphonia] +opt-level = 2 +# ... other audio libraries +``` + +This allows fast iteration while maintaining audio performance. + +## Lock-Free Communication + +### Command Queue (UI → Audio) + +The UI thread sends commands to the audio thread via a lock-free ringbuffer: + +```rust +// UI Thread +let command = AudioCommand::Play; +command_sender.push(command).ok(); + +// Audio Thread (in Engine::process) +while let Ok(command) = command_receiver.pop() { + match command { + AudioCommand::Play => self.playing = true, + AudioCommand::Stop => self.playing = false, + AudioCommand::Seek(time) => self.playhead = time, + // ... handle other commands + } +} +``` + +### State Updates (Audio → UI) + +The audio thread sends state updates back to the UI: + +```rust +// Audio Thread +let state = AudioState { + playhead: self.playhead, + is_playing: self.playing, + meter_levels: self.compute_meters(), +}; +state_sender.push(state).ok(); + +// UI Thread +if let Ok(state) = state_receiver.pop() { + // Update UI with new state +} +``` + +### Design Pattern: Command-Response + +1. **UI initiates action**: Send command to audio thread +2. **Audio thread executes**: In `Engine::process()`, between buffer fills +3. **Audio thread confirms**: Send state update back to UI +4. **UI updates**: Reflect new state in user interface + +This pattern ensures: +- No blocking on either side +- UI remains responsive +- Audio thread never waits + +## Audio Processing Pipeline + +### Per-Buffer Processing + +Every audio buffer (typically 256 frames), the `Engine::process()` callback: + +```rust +pub fn process(&mut self, output: &mut [f32]) -> Result<(), AudioError> { + // 1. Process commands from UI thread + self.process_commands(); + + // 2. Update playhead + if self.playing { + self.playhead += buffer_duration; + } + + // 3. Clear output buffer + output.fill(0.0); + + // 4. Process each track + for track in &mut self.tracks { + if track.muted { + continue; + } + + // Read audio samples at playhead position + let samples = track.read_samples(self.playhead, output.len()); + + // Apply track effects chain + let mut processed = samples; + for effect in &mut track.effects { + processed = effect.process(processed); + } + + // Mix to output with volume/pan + mix_to_output(output, &processed, track.volume, track.pan); + } + + // 5. Apply master effects + for effect in &mut self.master_effects { + effect.process_in_place(output); + } + + // 6. Send state updates to UI + self.send_state_update(); + + Ok(()) +} +``` + +### Sample Rate and Buffer Size + +- **Sample rate**: 44.1kHz (default) or 48kHz +- **Buffer size**: 256 frames (configurable) +- **Channels**: Stereo (2 channels) + +Buffer is interleaved: `[L, R, L, R, L, R, ...]` + +### Time Representation + +- **Playhead position**: Stored as `f64` seconds +- **Sample index**: `(playhead * sample_rate) as usize` +- **Frame index**: `sample_index / channels` + +## Node Graph System + +### Overview + +Tracks use a node graph architecture powered by `dasp_graph` for flexible audio routing. Unlike simple serial effect chains, the node graph allows: + +- **Parallel processing**: Multiple effects processing the same input +- **Complex routing**: Effects feeding into each other in arbitrary configurations +- **Modular synthesis**: Build synthesizers from oscillators, filters, and modulators +- **Send/return chains**: Shared effects (reverb, delay) fed by multiple tracks +- **Sidechain processing**: One signal controlling another (compression, vocoding) + +### Node Graph Architecture + +``` +┌─────────────────────────────────────────────────────────┐ +│ Track Node Graph │ +├─────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────┐ │ +│ │ Input │ (Audio clip or synthesizer) │ +│ └────┬────┘ │ +│ │ │ +│ ├──────┬──────────────┬─────────────┐ │ +│ │ │ │ │ │ +│ ▼ ▼ ▼ ▼ │ +│ ┌────────┐ ┌────────┐ ┌────────┐ ┌─────────┐ │ +│ │Filter │ │Distort │ │ EQ │ │ Reverb │ │ +│ │(Node 1)│ │(Node 2)│ │(Node 3)│ │(Node 4) │ │ +│ └───┬────┘ └───┬────┘ └───┬────┘ └────┬────┘ │ +│ │ │ │ │ │ +│ └────┬─────┴──────┬───┘ │ │ +│ │ │ │ │ +│ ▼ ▼ │ │ +│ ┌─────────┐ ┌─────────┐ │ │ +│ │ Mixer │ │Compress │ │ │ +│ │(Node 5) │ │(Node 6) │◄──────────┘ │ +│ └────┬────┘ └────┬────┘ (sidechain) │ +│ │ │ │ +│ └─────┬──────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────┐ │ +│ │ Output │ │ +│ └──────────┘ │ +│ │ +└────────────────────────────────────────────────────────┘ +``` + +### Node Types + +#### Input Nodes +- **Audio Clip Reader**: Reads samples from audio file +- **Oscillator**: Generates waveforms (sine, saw, square, triangle) +- **Noise Generator**: White/pink noise +- **External Input**: Microphone or line-in + +#### Processing Nodes +- **Effects**: Any audio effect (see [Adding Effects](#adding-effects)) +- **Filters**: Low-pass, high-pass, band-pass, notch +- **Mixers**: Combine multiple inputs with gain control +- **Splitters**: Duplicate signal to multiple outputs + +#### Output Nodes +- **Track Output**: Sends to mixer or master bus +- **Send Output**: Feeds auxiliary effects + +### Building a Node Graph + +```rust +use dasp_graph::{Node, NodeData, Input, BoxedNode}; +use petgraph::graph::NodeIndex; + +pub struct TrackGraph { + graph: dasp_graph::Graph, + input_node: NodeIndex, + output_node: NodeIndex, +} + +impl TrackGraph { + pub fn new() -> Self { + let mut graph = dasp_graph::Graph::new(); + + // Create input and output nodes + let input_node = graph.add_node(NodeData::new1( + Input::default(), + PassThrough, // Simple input node + )); + + let output_node = graph.add_node(NodeData::new1( + Input::default(), + PassThrough, // Simple output node + )); + + Self { + graph, + input_node, + output_node, + } + } + + pub fn add_effect(&mut self, effect: BoxedNode) -> NodeIndex { + // Add effect node between input and output + let effect_node = self.graph.add_node(NodeData::new1( + Input::default(), + effect, + )); + + // Connect: input -> effect -> output + self.graph.add_edge(self.input_node, effect_node, ()); + self.graph.add_edge(effect_node, self.output_node, ()); + + effect_node + } + + pub fn connect(&mut self, from: NodeIndex, to: NodeIndex) { + self.graph.add_edge(from, to, ()); + } + + pub fn process(&mut self, input: &[f32], output: &mut [f32]) { + // Set input samples + self.graph.set_input(self.input_node, input); + + // Process entire graph + self.graph.process(); + + // Read output samples + self.graph.get_output(self.output_node, output); + } +} +``` + +### Example: Serial Effect Chain + +Simple effects chain (the most common case): + +```rust +// Input -> Distortion -> EQ -> Reverb -> Output + +let mut graph = TrackGraph::new(); + +let distortion = graph.add_effect(Box::new(Distortion::new(0.5))); +let eq = graph.add_effect(Box::new(EQ::new())); +let reverb = graph.add_effect(Box::new(Reverb::new())); + +// Connect in series +graph.connect(graph.input_node, distortion); +graph.connect(distortion, eq); +graph.connect(eq, reverb); +graph.connect(reverb, graph.output_node); +``` + +### Example: Parallel Processing + +Split signal into parallel paths: + +```rust +// Input -> Split -> [Distortion + Clean] -> Mix -> Output + +let mut graph = TrackGraph::new(); + +// Create parallel paths +let distortion = graph.add_effect(Box::new(Distortion::new(0.7))); +let clean = graph.add_effect(Box::new(Gain::new(1.0))); +let mixer = graph.add_effect(Box::new(Mixer::new(2))); // 2 inputs + +// Connect parallel paths +graph.connect(graph.input_node, distortion); +graph.connect(graph.input_node, clean); +graph.connect(distortion, mixer); +graph.connect(clean, mixer); +graph.connect(mixer, graph.output_node); +``` + +### Example: Modular Synthesizer + +Build a synthesizer from basic components: + +```rust +// ┌─ LFO ────┐ (modulation) +// │ ▼ +// Oscillator -> Filter -> Envelope -> Output + +let mut graph = TrackGraph::new(); + +// Sound source +let oscillator = graph.add_effect(Box::new(Oscillator::new(440.0))); + +// Modulation source +let lfo = graph.add_effect(Box::new(LFO::new(5.0))); // 5 Hz + +// Filter with LFO modulation +let filter = graph.add_effect(Box::new(Filter::new_modulated())); + +// Envelope +let envelope = graph.add_effect(Box::new(ADSREnvelope::new())); + +// Connect sound path +graph.connect(oscillator, filter); +graph.connect(filter, envelope); +graph.connect(envelope, graph.output_node); + +// Connect modulation path +graph.connect(lfo, filter); // LFO modulates filter cutoff +``` + +### Example: Sidechain Compression + +One signal controls another: + +```rust +// Input (bass) ──────────────────┐ +// ▼ +// Kick drum ────> Compressor (sidechain) -> Output + +let mut graph = TrackGraph::new(); + +// Main signal input (bass) +let bass_input = graph.add_effect(Box::new(PassThrough)); + +// Sidechain signal input (kick drum) +let kick_input = graph.add_effect(Box::new(PassThrough)); + +// Compressor with sidechain +let compressor = graph.add_effect(Box::new(SidechainCompressor::new())); + +// Connect main signal +graph.connect(bass_input, compressor); + +// Connect sidechain signal (port 1 = main, port 2 = sidechain) +graph.connect_to_port(kick_input, compressor, 1); + +graph.connect(compressor, graph.output_node); +``` + +### Node Interface + +All nodes implement the `dasp_graph::Node` trait: + +```rust +pub trait Node { + /// Process audio for this node + fn process(&mut self, inputs: &[Input], output: &mut [f32]); + + /// Number of input ports + fn num_inputs(&self) -> usize; + + /// Number of output ports + fn num_outputs(&self) -> usize; + + /// Reset internal state + fn reset(&mut self); +} +``` + +### Multi-Channel Processing + +Nodes can have multiple input/output channels: + +```rust +pub struct StereoEffect { + left_processor: Processor, + right_processor: Processor, +} + +impl Node for StereoEffect { + fn process(&mut self, inputs: &[Input], output: &mut [f32]) { + // Split stereo input + let (left_in, right_in) = inputs[0].as_stereo(); + + // Process each channel + let left_out = self.left_processor.process(left_in); + let right_out = self.right_processor.process(right_in); + + // Interleave output + for i in 0..left_out.len() { + output[i * 2] = left_out[i]; + output[i * 2 + 1] = right_out[i]; + } + } + + fn num_inputs(&self) -> usize { 1 } // One stereo input + fn num_outputs(&self) -> usize { 1 } // One stereo output + + fn reset(&mut self) { + self.left_processor.reset(); + self.right_processor.reset(); + } +} +``` + +### Parameter Modulation + +Nodes can expose parameters for automation or modulation: + +```rust +pub struct ModulatableFilter { + filter: Filter, + cutoff: f32, + resonance: f32, +} + +impl Node for ModulatableFilter { + fn process(&mut self, inputs: &[Input], output: &mut [f32]) { + let audio_in = &inputs[0]; // Port 0: audio input + + // Port 1 (optional): cutoff modulation + if inputs.len() > 1 { + let mod_signal = &inputs[1]; + // Modulate cutoff: base + modulation + self.filter.set_cutoff(self.cutoff + mod_signal[0] * 1000.0); + } + + // Process audio + self.filter.process(audio_in, output); + } + + fn num_inputs(&self) -> usize { 2 } // Audio + modulation + fn num_outputs(&self) -> usize { 1 } + + fn reset(&mut self) { + self.filter.reset(); + } +} +``` + +### Graph Execution Order + +`dasp_graph` automatically determines execution order using topological sort: + +1. Nodes with no dependencies execute first (inputs, oscillators) +2. Nodes execute when all inputs are ready +3. Cycles are detected and prevented +4. Output nodes execute last + +This ensures: +- No node processes before its inputs are ready +- Efficient CPU cache usage +- Deterministic execution + +### Performance Considerations + +#### Graph Overhead + +Node graphs have small overhead: +- **Topological sort**: Done once when graph changes, not per-buffer +- **Buffer copying**: Minimized by reusing buffers +- **Indirection**: Virtual function calls (unavoidable with trait objects) + +For simple serial chains, the overhead is negligible (<1% CPU). + +#### When to Use Node Graphs vs Simple Chains + +**Use node graphs when:** +- Complex routing (parallel, feedback, modulation) +- Building synthesizers from components +- User-configurable effect routing +- Sidechain processing + +**Use simple chains when:** +- Just a few effects in series +- Performance is critical +- Graph structure never changes + +**Note**: In Lightningbeam, audio layers always use node graphs to provide maximum flexibility for users. This allows any track to have complex routing, modular synthesis, or effect configurations without requiring different track types. + +```rust +// Simple chain (no graph overhead) +pub struct SimpleChain { + effects: Vec>, +} + +impl SimpleChain { + fn process(&mut self, buffer: &mut [f32]) { + for effect in &mut self.effects { + effect.process_in_place(buffer); + } + } +} +``` + +### Debugging Node Graphs + +Enable graph visualization: + +```rust +// Print graph structure +println!("{:?}", graph); + +// Export to DOT format for visualization +let dot = graph.to_dot(); +std::fs::write("graph.dot", dot)?; +// Then: dot -Tpng graph.dot -o graph.png +``` + +Trace signal flow: + +```rust +// Add probe nodes to inspect signals +let probe = graph.add_effect(Box::new(SignalProbe::new("After Filter"))); +graph.connect(filter, probe); +graph.connect(probe, output); + +// Probe prints min/max/RMS of signal +``` + +## Adding Effects + +### Effect Trait + +All effects implement the `AudioEffect` trait: + +```rust +pub trait AudioEffect: Send { + fn process(&mut self, input: &[f32], output: &mut [f32]); + fn process_in_place(&mut self, buffer: &mut [f32]); + fn reset(&mut self); +} +``` + +### Example: Simple Gain Effect + +```rust +pub struct Gain { + gain: f32, +} + +impl Gain { + pub fn new(gain: f32) -> Self { + Self { gain } + } +} + +impl AudioEffect for Gain { + fn process(&mut self, input: &[f32], output: &mut [f32]) { + for (i, &sample) in input.iter().enumerate() { + output[i] = sample * self.gain; + } + } + + fn process_in_place(&mut self, buffer: &mut [f32]) { + for sample in buffer.iter_mut() { + *sample *= self.gain; + } + } + + fn reset(&mut self) { + // No state to reset for gain + } +} +``` + +### Example: Delay Effect (with state) + +```rust +pub struct Delay { + buffer: Vec, + write_pos: usize, + delay_samples: usize, + feedback: f32, + mix: f32, +} + +impl Delay { + pub fn new(sample_rate: f32, delay_time: f32, feedback: f32, mix: f32) -> Self { + let delay_samples = (delay_time * sample_rate) as usize; + let buffer_size = delay_samples.next_power_of_two(); + + Self { + buffer: vec![0.0; buffer_size], + write_pos: 0, + delay_samples, + feedback, + mix, + } + } +} + +impl AudioEffect for Delay { + fn process_in_place(&mut self, buffer: &mut [f32]) { + for sample in buffer.iter_mut() { + // Read delayed sample + let read_pos = (self.write_pos + self.buffer.len() - self.delay_samples) + % self.buffer.len(); + let delayed = self.buffer[read_pos]; + + // Write new sample with feedback + self.buffer[self.write_pos] = *sample + delayed * self.feedback; + self.write_pos = (self.write_pos + 1) % self.buffer.len(); + + // Mix dry and wet signals + *sample = *sample * (1.0 - self.mix) + delayed * self.mix; + } + } + + fn reset(&mut self) { + self.buffer.fill(0.0); + self.write_pos = 0; + } +} +``` + +### Adding Effects to Tracks + +```rust +// UI Thread +let command = AudioCommand::AddEffect { + track_id: track_id, + effect: Box::new(Delay::new(44100.0, 0.5, 0.3, 0.5)), +}; +command_sender.push(command).ok(); +``` + +### Built-In Effects + +Located in `daw-backend/src/effects/`: + +- **reverb.rs**: Reverb +- **delay.rs**: Delay +- **eq.rs**: Equalizer +- **compressor.rs**: Dynamic range compressor +- **distortion.rs**: Distortion/overdrive +- **chorus.rs**: Chorus +- **flanger.rs**: Flanger +- **phaser.rs**: Phaser +- **limiter.rs**: Brick-wall limiter + +## Adding Synthesizers + +### Synthesizer Trait + +```rust +pub trait Synthesizer: Send { + fn process(&mut self, output: &mut [f32], sample_rate: f32); + fn note_on(&mut self, note: u8, velocity: u8); + fn note_off(&mut self, note: u8); + fn reset(&mut self); +} +``` + +### Example: Simple Oscillator + +```rust +pub struct Oscillator { + phase: f32, + frequency: f32, + amplitude: f32, + sample_rate: f32, +} + +impl Oscillator { + pub fn new(sample_rate: f32) -> Self { + Self { + phase: 0.0, + frequency: 440.0, + amplitude: 0.0, + sample_rate, + } + } +} + +impl Synthesizer for Oscillator { + fn process(&mut self, output: &mut [f32], _sample_rate: f32) { + for sample in output.iter_mut() { + // Generate sine wave + *sample = (self.phase * 2.0 * std::f32::consts::PI).sin() * self.amplitude; + + // Advance phase + self.phase += self.frequency / self.sample_rate; + if self.phase >= 1.0 { + self.phase -= 1.0; + } + } + } + + fn note_on(&mut self, note: u8, velocity: u8) { + // Convert MIDI note to frequency + self.frequency = 440.0 * 2.0_f32.powf((note as f32 - 69.0) / 12.0); + self.amplitude = velocity as f32 / 127.0; + } + + fn note_off(&mut self, _note: u8) { + self.amplitude = 0.0; + } + + fn reset(&mut self) { + self.phase = 0.0; + self.amplitude = 0.0; + } +} +``` + +### Built-In Synthesizers + +Located in `daw-backend/src/synth/`: + +- **oscillator.rs**: Basic waveform generator (sine, saw, square, triangle) +- **fm_synth.rs**: FM synthesis +- **wavetable.rs**: Wavetable synthesis +- **sampler.rs**: Sample-based synthesis + +## MIDI System + +### MIDI Input + +```rust +// Setup MIDI input (UI thread) +let midi_input = midir::MidiInput::new("Lightningbeam")?; +let port = midi_input.ports()[0]; + +midi_input.connect(&port, "input", move |_timestamp, message, _| { + // Parse MIDI message + match message[0] & 0xF0 { + 0x90 => { + // Note On + let note = message[1]; + let velocity = message[2]; + command_sender.push(AudioCommand::NoteOn { note, velocity }).ok(); + } + 0x80 => { + // Note Off + let note = message[1]; + command_sender.push(AudioCommand::NoteOff { note }).ok(); + } + _ => {} + } +}, ())?; +``` + +### MIDI File Parsing + +```rust +use midly::{Smf, TrackEventKind}; + +let smf = Smf::parse(&midi_data)?; +for track in smf.tracks { + for event in track { + match event.kind { + TrackEventKind::Midi { channel, message } => { + // Process MIDI message + } + _ => {} + } + } +} +``` + +## Performance Optimization + +### Pre-Allocation + +Allocate all buffers before audio thread starts: + +```rust +// Good: Pre-allocated +pub struct Track { + buffer: Vec, // Allocated once in constructor + // ... +} + +// Bad: Allocates in audio thread +fn process(&mut self) { + let mut temp = Vec::new(); // ❌ Allocates! + // ... +} +``` + +### Memory-Mapped Audio Files + +Large audio files use memory-mapped I/O for zero-copy access: + +```rust +use memmap2::Mmap; + +let file = File::open(path)?; +let mmap = unsafe { Mmap::map(&file)? }; +// Audio samples can be read directly from mmap +``` + +### SIMD Optimization + +For portable SIMD operations, use the `fearless_simd` crate: + +```rust +use fearless_simd::*; + +fn process_simd(samples: &mut [f32], gain: f32) { + // Automatically uses best available SIMD instructions + // (SSE, AVX, NEON, etc.) without unsafe code + for chunk in samples.chunks_exact_mut(f32x8::LEN) { + let simd_samples = f32x8::from_slice(chunk); + let simd_gain = f32x8::splat(gain); + let result = simd_samples * simd_gain; + result.write_to_slice(chunk); + } + + // Handle remainder + let remainder = samples.chunks_exact_mut(f32x8::LEN).into_remainder(); + for sample in remainder { + *sample *= gain; + } +} +``` + +This approach is: +- **Portable**: Works across x86, ARM, and other architectures +- **Safe**: No unsafe code required +- **Automatic**: Uses best available SIMD instructions for the target +- **Fallback**: Gracefully degrades on platforms without SIMD + +### Avoid Branching in Inner Loops + +```rust +// Bad: Branch in inner loop +for sample in samples.iter_mut() { + if self.gain > 0.5 { + *sample *= 2.0; + } +} + +// Good: Branch outside loop +let multiplier = if self.gain > 0.5 { 2.0 } else { 1.0 }; +for sample in samples.iter_mut() { + *sample *= multiplier; +} +``` + +## Debugging Audio Issues + +### Enable Debug Logging + +```bash +DAW_AUDIO_DEBUG=1 cargo run +``` + +Output includes: +``` +[AUDIO] Buffer size: 256 frames (5.8ms at 44100 Hz) +[AUDIO] Processing time: avg=0.8ms, worst=2.1ms +[AUDIO] Playhead: 1.234s +[AUDIO] WARNING: Audio overrun detected! +``` + +### Common Issues + +#### Audio Dropouts + +**Symptoms**: Clicks, pops, glitches in audio output + +**Causes**: +- Audio callback taking too long +- Blocking operation in audio thread +- Insufficient CPU resources + +**Solutions**: +- Increase buffer size (reduces CPU pressure, increases latency) +- Optimize audio processing code +- Remove debug prints from audio thread +- Check `DAW_AUDIO_DEBUG=1` output for timing info + +#### Crackling/Distortion + +**Symptoms**: Harsh, noisy audio + +**Causes**: +- Samples exceeding [-1.0, 1.0] range (clipping) +- Incorrect sample rate conversion +- Denormal numbers in filters + +**Solutions**: +- Add limiter to master output +- Use hard clipping: `sample.clamp(-1.0, 1.0)` +- Enable flush-to-zero for denormals + +#### No Audio Output + +**Symptoms**: Silence, but no errors + +**Causes**: +- Audio device not found +- Wrong device selected +- All tracks muted +- Volume set to zero + +**Solutions**: +- Check `cpal` device enumeration +- Verify track volumes and mute states +- Check master volume +- Test with simple sine wave + +### Profiling Audio Performance + +```bash +# Use perf on Linux +perf record --call-graph dwarf cargo run --release +perf report + +# Look for hot spots in Engine::process() +``` + +## Related Documentation + +- [ARCHITECTURE.md](../ARCHITECTURE.md) - Overall system architecture +- [docs/UI_SYSTEM.md](UI_SYSTEM.md) - UI integration with audio system +- [docs/BUILDING.md](BUILDING.md) - Build troubleshooting diff --git a/docs/BUILDING.md b/docs/BUILDING.md new file mode 100644 index 0000000..ebc62f8 --- /dev/null +++ b/docs/BUILDING.md @@ -0,0 +1,545 @@ +# 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 diff --git a/docs/RENDERING.md b/docs/RENDERING.md new file mode 100644 index 0000000..060f7c4 --- /dev/null +++ b/docs/RENDERING.md @@ -0,0 +1,812 @@ +# 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, + + // 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 = 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 { + 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, // 64 bytes + viewport_size: vec2, // 8 bytes + zoom: f32, // 4 bytes + _pad1: f32, // 4 bytes (padding) + tint_color: vec4, // 16 bytes (requires 16-byte alignment) + // Total: 96 bytes +} + +@group(0) @binding(0) var params: WaveformParams; +@group(0) @binding(1) var waveform_texture: texture_1d; +@group(0) @binding(2) var waveform_sampler: sampler; + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) uv: vec2, +} + +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { + // Generate fullscreen quad + var positions = array, 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 { + // 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; +@group(0) @binding(1) var dst_texture: texture_storage_2d; +@group(0) @binding(2) var params: MipgenParams; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) global_id: vec3) { + 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(src_x), i32(src_y)), 0); + let s10 = textureLoad(src_texture, vec2(i32(src_x + 1u), i32(src_y)), 0); + let s01 = textureLoad(src_texture, vec2(i32(src_x), i32(src_y + 1u)), 0); + let s11 = textureLoad(src_texture, vec2(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(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` 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` | 8 bytes | 8 bytes | +| `vec3` | 12 bytes | 16 bytes ⚠️ | +| `vec4` | 16 bytes | 16 bytes | +| `mat4x4` | 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::(), 96); +const_assert_eq!(std::mem::align_of::(), 16); + +// Runtime validation +fn validate_uniform_buffer(data: &T) { + let size = std::mem::size_of::(); + let align = std::mem::align_of::(); + + 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 { + // 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, +} + +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 diff --git a/docs/UI_SYSTEM.md b/docs/UI_SYSTEM.md new file mode 100644 index 0000000..3d1ecac --- /dev/null +++ b/docs/UI_SYSTEM.md @@ -0,0 +1,848 @@ +# 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, + // ... 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>, + pub redo_stack: Vec>, + + // Tool state + pub selected_tool: Tool, + pub tool_state: ToolState, + + // Actions to execute after rendering + pub pending_actions: Vec>, + + // Audio engine + pub audio_system: AudioSystem, + pub playhead_position: f64, + pub is_playing: bool, + + // Selection state + pub selected_clips: HashSet, + pub selected_shapes: HashSet, + + // Clipboard + pub clipboard: Option, + + // 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, + + // Tool-specific state + pub draw_points: Vec, + 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, +} + +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>, + waveform_renderer: Arc>, +} + +impl egui_wgpu::CallbackTrait for StageCallback { + fn prepare( + &self, + device: &wgpu::Device, + queue: &wgpu::Queue, + encoder: &mut wgpu::CommandEncoder, + resources: &egui_wgpu::CallbackResources, + ) -> Vec { + // 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, +} + +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::(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 diff --git a/lightningbeam-ui/.gitignore b/lightningbeam-ui/.gitignore new file mode 100644 index 0000000..c91cf97 --- /dev/null +++ b/lightningbeam-ui/.gitignore @@ -0,0 +1,17 @@ +# 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 diff --git a/lightningbeam-ui/Cargo.lock b/lightningbeam-ui/Cargo.lock new file mode 100644 index 0000000..f2aa015 --- /dev/null +++ b/lightningbeam-ui/Cargo.lock @@ -0,0 +1,8277 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "accesskit" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf203f9d3bd8f29f98833d1fbef628df18f759248a547e7e01cfbf63cda36a99" +dependencies = [ + "enumn", + "serde", +] + +[[package]] +name = "accesskit_atspi_common" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "890d241cf51fc784f0ac5ac34dfc847421f8d39da6c7c91a0fcc987db62a8267" +dependencies = [ + "accesskit", + "accesskit_consumer", + "atspi-common", + "serde", + "thiserror 1.0.69", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db81010a6895d8707f9072e6ce98070579b43b717193d2614014abd5cb17dd43" +dependencies = [ + "accesskit", + "hashbrown 0.15.5", +] + +[[package]] +name = "accesskit_macos" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0089e5c0ac0ca281e13ea374773898d9354cc28d15af9f0f7394d44a495b575" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.15.5", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301e55b39cfc15d9c48943ce5f572204a551646700d0e8efa424585f94fec528" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2d63dd5041e49c363d83f5419a896ecb074d309c414036f616dc0b04faca971" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.15.5", + "static_assertions", + "windows 0.61.3", + "windows-core 0.61.2", +] + +[[package]] +name = "accesskit_winit" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8cfabe59d0eaca7412bfb1f70198dd31e3b0496fee7e15b066f9c36a1a140a0" +dependencies = [ + "accesskit", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "alsa" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2562ad8dcf0f789f65c6fdaad8a8a9708ed6b488e649da28c01656ad66b8b47" +dependencies = [ + "alsa-sys", + "bitflags 1.3.2", + "libc", + "nix 0.24.3", +] + +[[package]] +name = "alsa" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c88dbbce13b232b26250e1e2e6ac18b6a891a646b8148285036ebce260ac5c3" +dependencies = [ + "alsa-sys", + "bitflags 2.10.0", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "android-activity" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef6978589202a00cd7e118380c448a08b6ed394c3a8df3a430d0898e3a42d046" +dependencies = [ + "android-properties", + "bitflags 2.10.0", + "cc", + "cesu8", + "jni", + "jni-sys", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "ashpd" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cbdf310d77fd3aaee6ea2093db7011dc2d35d2eb3481e5607f1f8d942ed99df" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.2", + "raw-window-handle", + "serde", + "serde_repr", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.2", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.2", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.2", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atspi" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83247582e7508838caf5f316c00791eee0e15c0bf743e6880585b867e16815c" +dependencies = [ + "atspi-common", + "atspi-connection", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33dfc05e7cdf90988a197803bf24f5788f94f7c94a69efa95683e8ffe76cfdfb" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-connection" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4193d51303d8332304056ae0004714256b46b6635a5c556109b319c0d3784938" +dependencies = [ + "atspi-common", + "atspi-proxies", + "futures-lite", + "zbus", +] + +[[package]] +name = "atspi-proxies" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2eebcb9e7e76f26d0bcfd6f0295e1cd1e6f33bedbc5698a971db8dc43d7751c" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom 8.0.0", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c8fbc0f831f4519fe8b810b6a7a91410ec83031b8233f730a0480029f6a23f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64-simd" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "781dd20c3aff0bd194fe7d2a977dd92f21c173891f3a03b677359e5fa457e5d5" +dependencies = [ + "simd-abstraction", +] + +[[package]] +name = "base64ct" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" + +[[package]] +name = "beamdsp" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.10.0", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.1", + "shlex", + "syn 2.0.110", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitstream-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.3", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "built" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b" + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" +dependencies = [ + "bzip2-sys", + "libc", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.10.0", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.10.0", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb9f6e1368bd4621d2c86baa7e37de77a938adf5221e5dd3d6133340101b309e" +dependencies = [ + "bitflags 2.10.0", + "polling", + "rustix 1.1.2", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.3", + "rustix 1.1.2", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35900b6c8d709fb1d854671ae27aeaa9eec2f8b01b364e1619a40da3e6fe2afe" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clap" +version = "4.5.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "claxon" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bfbf56724aa9eca8afa4fcfadeb479e722935bb2a0900c2d37e0cc477af0688" + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "cmake" +version = "0.1.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" +dependencies = [ + "cc", +] + +[[package]] +name = "codespan-reporting" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "color" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18ef4657441fb193b65f34dc39b3781f0dfec23d3bd94d0eeb4e88cde421edb" + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compact_str" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "ryu", + "static_assertions", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-str" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21077772762a1002bb421c3af42ac1725fa56066bfc53d9a55bb79905df2aaf3" +dependencies = [ + "const-str-proc-macro", +] + +[[package]] +name = "const-str-proc-macro" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e1e0fdd2e5d3041e530e1b21158aeeef8b5d0e306bc5c1e3d6cf0930d10e25a" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "coreaudio-rs" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aae284fbaf7d27aa0e292f7677dfbe26503b0d555026f702940805a630eac17" +dependencies = [ + "bitflags 1.3.2", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", +] + +[[package]] +name = "coremidi" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a7847ca018a67204508b77cb9e6de670125075f7464fff5f673023378fa34f5" +dependencies = [ + "core-foundation 0.9.4", + "core-foundation-sys", + "coremidi-sys", +] + +[[package]] +name = "coremidi-sys" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc9504310988d938e49fff1b5f1e56e3dafe39bb1bae580c19660b58b83a191e" +dependencies = [ + "core-foundation-sys", +] + +[[package]] +name = "cpal" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b1f9c7312f19fc2fa12fd7acaf38de54e8320ba10d1a02dcbe21038def51ccb" +dependencies = [ + "alsa 0.10.0", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "objc2 0.6.3", + "objc2-audio-toolbox", + "objc2-avf-audio", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.61.3", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49fc9a695bca7f35f5f4c15cddc84415f66a74ea78eef08e90c5024f2b540e23" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccaeedb56da03b09f598226e25e80088cb4cd25f316e6e4df7d695f0feeb1403" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crossterm" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +dependencies = [ + "bitflags 2.10.0", + "crossterm_winapi", + "libc", + "mio", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9be934d936a0fbed5bcdc01042b770de1398bf79d0e192f49fa7faea0e99281e" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-color" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556c099a61d85989d7af52b692e35a8d68a57e7df8c6d07563dc0778b3960c9f" +dependencies = [ + "cssparser", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.110", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "dasp_envelope" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ec617ce7016f101a87fe85ed44180839744265fae73bb4aa43e7ece1b7668b6" +dependencies = [ + "dasp_frame", + "dasp_peak", + "dasp_ring_buffer", + "dasp_rms", + "dasp_sample", +] + +[[package]] +name = "dasp_frame" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a3937f5fe2135702897535c8d4a5553f8b116f76c1529088797f2eee7c5cd6" +dependencies = [ + "dasp_sample", +] + +[[package]] +name = "dasp_graph" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39b17b071a1fa4c78054730085620c3bb22dc5fded00483312557a3fdf26d7c4" +dependencies = [ + "dasp_frame", + "dasp_ring_buffer", + "dasp_signal", + "dasp_slice", + "petgraph 0.5.1", +] + +[[package]] +name = "dasp_interpolate" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fc975a6563bb7ca7ec0a6c784ead49983a21c24835b0bc96eea11ee407c7486" +dependencies = [ + "dasp_frame", + "dasp_ring_buffer", + "dasp_sample", +] + +[[package]] +name = "dasp_peak" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cf88559d79c21f3d8523d91250c397f9a15b5fc72fbb3f87fdb0a37b79915bf" +dependencies = [ + "dasp_frame", + "dasp_sample", +] + +[[package]] +name = "dasp_ring_buffer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07d79e19b89618a543c4adec9c5a347fe378a19041699b3278e616e387511ea1" + +[[package]] +name = "dasp_rms" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6c5dcb30b7e5014486e2822537ea2beae50b19722ffe2ed7549ab03774575aa" +dependencies = [ + "dasp_frame", + "dasp_ring_buffer", + "dasp_sample", +] + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "dasp_signal" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa1ab7d01689c6ed4eae3d38fe1cea08cba761573fbd2d592528d55b421077e7" +dependencies = [ + "dasp_envelope", + "dasp_frame", + "dasp_interpolate", + "dasp_peak", + "dasp_ring_buffer", + "dasp_rms", + "dasp_sample", + "dasp_window", +] + +[[package]] +name = "dasp_slice" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e1c7335d58e7baedafa516cb361360ff38d6f4d3f9d9d5ee2a2fc8e27178fa1" +dependencies = [ + "dasp_frame", + "dasp_sample", +] + +[[package]] +name = "dasp_window" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99ded7b88821d2ce4e8b842c9f1c86ac911891ab89443cc1de750cae764c5076" +dependencies = [ + "dasp_sample", +] + +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + +[[package]] +name = "data-url" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a30bfce702bcfa94e906ef82421f2c0e61c076ad76030c16ee5d2e9a32fe193" +dependencies = [ + "matches", +] + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[package]] +name = "daw-backend" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "beamdsp", + "cpal", + "crossterm", + "dasp_envelope", + "dasp_graph", + "dasp_interpolate", + "dasp_peak", + "dasp_ring_buffer", + "dasp_rms", + "dasp_sample", + "dasp_signal", + "ffmpeg-next", + "hound", + "memmap2", + "midir", + "midly", + "nam-ffi", + "pathdiff", + "petgraph 0.6.5", + "rand 0.8.5", + "ratatui", + "rayon", + "rtrb", + "serde", + "serde_json", + "symphonia", + "zip", +] + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.10.0", + "block2 0.6.2", + "libc", + "objc2 0.6.3", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "dlib" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "dtoa" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6add3b8cff394282be81f3fc1a0605db594ed69890078ca6e2cab1c408bcf04" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "ecolor" +version = "0.33.3" +dependencies = [ + "bytemuck", + "emath", + "serde", +] + +[[package]] +name = "eframe" +version = "0.33.3" +dependencies = [ + "ahash 0.8.12", + "bytemuck", + "document-features", + "egui", + "egui-wgpu", + "egui-winit", + "egui_glow", + "glow", + "glutin", + "glutin-winit", + "image", + "js-sys", + "log", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "parking_lot", + "percent-encoding", + "pollster 0.4.0", + "profiling", + "raw-window-handle", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "web-time", + "wgpu", + "windows-sys 0.61.2", + "winit", +] + +[[package]] +name = "egui" +version = "0.33.3" +dependencies = [ + "accesskit", + "ahash 0.8.12", + "bitflags 2.10.0", + "emath", + "epaint", + "log", + "nohash-hasher", + "profiling", + "ron", + "serde", + "smallvec", + "unicode-segmentation", +] + +[[package]] +name = "egui-wgpu" +version = "0.33.3" +dependencies = [ + "ahash 0.8.12", + "bytemuck", + "document-features", + "egui", + "epaint", + "log", + "profiling", + "thiserror 2.0.17", + "type-map", + "web-time", + "wgpu", + "winit", +] + +[[package]] +name = "egui-winit" +version = "0.33.3" +dependencies = [ + "accesskit_winit", + "arboard", + "bytemuck", + "egui", + "log", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "profiling", + "raw-window-handle", + "smithay-clipboard", + "web-time", + "webbrowser", + "winit", +] + +[[package]] +name = "egui_code_editor" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a1b847b0ff5a3bac4e8604eca605808970bbcdaba76b3101db1051e4206dc6" +dependencies = [ + "egui", +] + +[[package]] +name = "egui_extras" +version = "0.33.3" +dependencies = [ + "ahash 0.8.12", + "egui", + "enum-map", + "image", + "log", + "mime_guess2", + "profiling", + "resvg 0.45.1", + "syntect", +] + +[[package]] +name = "egui_glow" +version = "0.33.3" +dependencies = [ + "bytemuck", + "egui", + "glow", + "log", + "memoffset", + "profiling", + "wasm-bindgen", + "web-sys", + "winit", +] + +[[package]] +name = "egui_node_graph2" +version = "0.7.0" +dependencies = [ + "egui", + "serde", + "slotmap", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "emath" +version = "0.33.3" +dependencies = [ + "bytemuck", + "serde", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "endi" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" + +[[package]] +name = "enum-map" +version = "2.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" +dependencies = [ + "enum-map-derive", +] + +[[package]] +name = "enum-map-derive" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "enumn" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "epaint" +version = "0.33.3" +dependencies = [ + "ab_glyph", + "ahash 0.8.12", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "log", + "nohash-hasher", + "parking_lot", + "profiling", + "serde", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.33.3" + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "euclid" +version = "0.22.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad9cdb4b747e485a12abb0e6566612956c7a1bafa3bdb8d682c5b6d403589e48" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + +[[package]] +name = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "ffmpeg-next" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d658424d233cbd993a972dd73a66ca733acd12a494c68995c9ac32ae1fe65b40" +dependencies = [ + "bitflags 2.10.0", + "ffmpeg-sys-next", + "libc", +] + +[[package]] +name = "ffmpeg-sys-next" +version = "8.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bca20aa4ee774fe384c2490096c122b0b23cf524a9910add0686691003d797b" +dependencies = [ + "bindgen", + "cc", + "libc", + "num_cpus", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" + +[[package]] +name = "fixedbitset" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37ab347416e802de484e4d03c7316c48f1ecb56574dfd4a46a80f173ce1de04d" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flacenc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb6da14d3c6605689b5c9ed5187a5218a6d3888e14b747bc18fd4e4bafd452bd" +dependencies = [ + "built", + "crc", + "crossbeam-channel", + "heapless", + "log", + "md-5", + "num-traits", + "rustversion", + "seq-macro", + "serde", +] + +[[package]] +name = "flate2" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a654f404bbcbd48ea58c617c2993ee91d1cb63727a37bf2323a4edeed1b8c5" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e32eac81c1135c1df01d4e6d4233c47ba11f6a6d07f33e0bba09d18797077770" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser 0.21.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.2", + "windows-link 0.2.1", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.10.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.10.0", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" +dependencies = [ + "bitflags 2.10.0", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "gpu-allocator" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "windows 0.58.0", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.10.0", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "guillotiere" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62d5865c036cb1393e23c50693df631d3f5d7bcca4c04fe4cc0fd592e74a782" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "bytemuck", + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "serde", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "hound" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.61.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png 0.18.0", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imagesize" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "029d73f573d8e8d63e6d5020011d3255b28c3ba85d6cf870a07184ed23de9284" + +[[package]] +name = "imagesize" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" + +[[package]] +name = "imgref" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + +[[package]] +name = "indexmap" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +dependencies = [ + "equivalent", + "hashbrown 0.16.0", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "jpeg-decoder" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" + +[[package]] +name = "js-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.10.0", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + +[[package]] +name = "kurbo" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce9729cc38c18d86123ab736fd2e7151763ba226ac2490ec092d1dd148825e32" +dependencies = [ + "arrayvec", + "euclid", + "serde", + "smallvec", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + +[[package]] +name = "libredox" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +dependencies = [ + "bitflags 2.10.0", + "libc", + "redox_syscall 0.5.18", +] + +[[package]] +name = "libxdo" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00333b8756a3d28e78def82067a377de7fa61b24909000aeaa2b446a948d14db" +dependencies = [ + "libxdo-sys", +] + +[[package]] +name = "libxdo-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db23b9e7e2b7831bbd8aac0bbeeeb7b68cbebc162b227e7052e8e55829a09212" +dependencies = [ + "libc", + "x11", +] + +[[package]] +name = "lightningbeam-core" +version = "0.1.0" +dependencies = [ + "arboard", + "base64 0.21.7", + "bytemuck", + "chrono", + "claxon", + "daw-backend", + "egui", + "ffmpeg-next", + "flacenc", + "image", + "kurbo 0.12.0", + "lru", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "pathdiff", + "rstar", + "serde", + "serde_json", + "tiny-skia", + "uuid", + "vello", + "wgpu", + "windows-sys 0.60.2", + "wl-clipboard-rs", + "x11-clipboard", + "zip", +] + +[[package]] +name = "lightningbeam-editor" +version = "1.0.3-alpha" +dependencies = [ + "beamdsp", + "bytemuck", + "clap", + "cpal", + "daw-backend", + "directories", + "eframe", + "egui-wgpu", + "egui_code_editor", + "egui_extras", + "egui_node_graph2", + "ffmpeg-next", + "half", + "image", + "kurbo 0.12.0", + "lightningbeam-core", + "lightningcss", + "memory-stats", + "muda", + "notify-rust", + "peniko", + "petgraph 0.6.5", + "pollster 0.3.0", + "rayon", + "resvg 0.42.0", + "rfd", + "rtrb", + "serde", + "serde_json", + "tiny-skia", + "uuid", + "vello", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-sys", + "wgpu", + "winit", + "x11rb", +] + +[[package]] +name = "lightningcss" +version = "1.0.0-alpha.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b407ca668368d1d5a86cea58ac82d9f9f9ca4bac1e9dce6f16f875f0f081a911" +dependencies = [ + "ahash 0.8.12", + "bitflags 2.10.0", + "const-str", + "cssparser", + "cssparser-color", + "dashmap", + "data-encoding", + "getrandom 0.3.4", + "indexmap 2.12.0", + "itertools 0.10.5", + "lazy_static", + "lightningcss-derive", + "parcel_selectors", + "parcel_sourcemap", + "pastey", + "pathdiff", + "rayon", + "serde", + "serde-content", + "smallvec", +] + +[[package]] +name = "lightningcss-derive" +version = "1.0.0-alpha.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c12744d1279367caed41739ef094c325d53fb0ffcd4f9b84a368796f870252" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "mac-notification-sys" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65fd3f75411f4725061682ed91f131946e912859d0044d39c4ec0aac818d7621" +dependencies = [ + "cc", + "objc2 0.6.3", + "objc2-foundation 0.3.2", + "time", +] + +[[package]] +name = "mach2" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" +dependencies = [ + "libc", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "memmap2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "memory-stats" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c73f5c649995a115e1a0220b35e4df0a1294500477f97a91d0660fb5abeb574a" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "metal" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00c15a6f673ff72ddcc22394663290f870fb224c1bfce55734a75c414150e605" +dependencies = [ + "bitflags 2.10.0", + "block", + "core-graphics-types 0.2.0", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "midir" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a456444d83e7ead06ae6a5c0a215ed70282947ff3897fb45fcb052b757284731" +dependencies = [ + "alsa 0.7.1", + "bitflags 1.3.2", + "coremidi", + "js-sys", + "libc", + "wasm-bindgen", + "web-sys", + "windows 0.43.0", +] + +[[package]] +name = "midly" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "207d755f4cb882d20c4da58d707ca9130a0c9bc5061f657a4f299b8e36362b7a" +dependencies = [ + "rayon", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess2" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1706dc14a2e140dec0a7a07109d9a3d5890b81e85bd6c60b906b249a77adf0ca" +dependencies = [ + "mime", + "phf", + "phf_shared", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + +[[package]] +name = "moxcms" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbdd3d7436f8b5e892b8b7ea114271ff0fa00bc5acae845d53b07d498616ef6" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "muda" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdae9c00e61cc0579bcac625e8ad22104c60548a025bfc972dc83868a28e1484" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "libxdo", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "once_cell", + "png 0.17.16", + "thiserror 1.0.69", + "windows-sys 0.59.0", +] + +[[package]] +name = "naga" +version = "27.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.16.0", + "hexf-parse", + "indexmap 2.12.0", + "libm", + "log", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.17", + "unicode-ident", +] + +[[package]] +name = "nam-ffi" +version = "0.1.0" +dependencies = [ + "cmake", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.10.0", + "jni-sys", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.24.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa52e972a9a719cecb6864fb88568781eb706bac2cd1d4f04a648542dbf78069" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "notify-rust" +version = "4.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6442248665a5aa2514e794af3b39661a8e73033b1cc5e59899e1276117ee4400" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_enum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.10.0", + "block2 0.6.2", + "libc", + "objc2 0.6.3", + "objc2-cloud-kit 0.3.2", + "objc2-core-data 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image 0.3.2", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "objc2-audio-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" +dependencies = [ + "bitflags 2.10.0", + "libc", + "objc2 0.6.3", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-avf-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +dependencies = [ + "objc2 0.6.3", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.10.0", + "objc2 0.6.3", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2 0.6.3", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags 2.10.0", + "objc2 0.6.3", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags 2.10.0", + "objc2 0.6.3", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.10.0", + "block2 0.6.2", + "dispatch2", + "libc", + "objc2 0.6.3", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.10.0", + "dispatch2", + "objc2 0.6.3", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2 0.6.3", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.10.0", + "objc2 0.6.3", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.10.0", + "objc2 0.6.3", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.10.0", + "block2 0.6.2", + "libc", + "objc2 0.6.3", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.10.0", + "objc2 0.6.3", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.10.0", + "objc2 0.6.3", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-cloud-kit 0.2.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "orbclient" +version = "0.3.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "247ad146e19b9437f8604c21f8652423595cf710ad108af40e77d3ae6e96b827" +dependencies = [ + "libredox", +] + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "outref" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f222829ae9293e33a9f5e9f440c6760a3d450a64affe1846486b140db81c1f4" + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser 0.25.1", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parcel_selectors" +version = "0.28.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54fd03f1ad26cb6b3ec1b7414fa78a3bd639e7dbb421b1a60513c96ce886a196" +dependencies = [ + "bitflags 2.10.0", + "cssparser", + "log", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash 2.1.1", + "smallvec", +] + +[[package]] +name = "parcel_sourcemap" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "485b74d7218068b2b7c0e3ff12fbc61ae11d57cb5d8224f525bd304c6be05bbb" +dependencies = [ + "base64-simd", + "data-url 0.1.1", + "rkyv", + "serde", + "serde_json", + "vlq", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "password-hash" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest", + "hmac", + "password-hash", + "sha2", +] + +[[package]] +name = "peniko" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3c76095c9a636173600478e0373218c7b955335048c2bcd12dc6a79657649d8" +dependencies = [ + "color", + "kurbo 0.12.0", + "linebender_resource_handle", + "smallvec", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "467d164a6de56270bd7c4d070df81d07beace25012d5103ced4e9ff08d6afdb7" +dependencies = [ + "fixedbitset 0.2.0", + "indexmap 1.9.3", +] + +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset 0.4.2", + "indexmap 2.12.0", +] + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset 0.5.7", + "hashbrown 0.15.5", + "indexmap 2.12.0", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.110", + "unicase", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", + "unicase", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plist" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64 0.22.1", + "indexmap 2.12.0", + "quick-xml 0.38.4", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +dependencies = [ + "bitflags 2.10.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2" + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit 0.23.7", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +dependencies = [ + "quote", + "syn 2.0.110", +] + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "pxfm" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3cbdf373972bf78df4d3b518d07003938e2c7d1fb5891e55f9cb6df57009d84" +dependencies = [ + "num-traits", +] + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "range-alloc" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d6831663a5098ea164f89cff59c6284e95f4e3c76ce9848d4529f5ccca9bde" + +[[package]] +name = "ratatui" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f44c9e68fd46eda15c646fbb85e1040b657a58cdc8c98db1d97a55930d991eef" +dependencies = [ + "bitflags 2.10.0", + "cassowary", + "compact_str", + "crossterm", + "itertools 0.12.1", + "lru", + "paste", + "stability", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "rav1e" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd87ce80a7665b1cce111f8a16c1f3929f6547ce91ade6addf4ec86a8dda5ce9" +dependencies = [ + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools 0.12.1", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "once_cell", + "paste", + "profiling", + "rand 0.8.5", + "rand_chacha 0.3.1", + "simd_helpers", + "system-deps", + "thiserror 1.0.69", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.11.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5825c26fddd16ab9f515930d49028a630efec172e903483c94796cfe31893e6b" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "read-fonts" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358" +dependencies = [ + "bytemuck", + "font-types", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "resvg" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "944d052815156ac8fa77eaac055220e95ba0b01fa8887108ca710c03805d9051" +dependencies = [ + "gif", + "jpeg-decoder", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg 0.42.0", +] + +[[package]] +name = "resvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" +dependencies = [ + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg 0.45.1", +] + +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "ashpd", + "block2 0.6.2", + "dispatch2", + "js-sys", + "log", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "pollster 0.4.0", + "raw-window-handle", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rgb" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "rkyv" +version = "0.7.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9008cd6385b9e161d8229e1f6549dd23c3d022f132a2ea37ac3a10ac4935779b" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503d1d27590a2b0a3a4ca4c94755aa2875657196ecbf401a42eff41d7de532c0" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ron" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db09040cc89e461f1a265139777a2bde7f8d8c67c4936f700c63ce3e2904d468" +dependencies = [ + "base64 0.22.1", + "bitflags 2.10.0", + "serde", + "serde_derive", + "unicode-ident", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rstar" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "421400d13ccfd26dfa5858199c30a5d76f9c54e0dba7575273025b43c5175dbb" +dependencies = [ + "heapless", + "num-traits", + "smallvec", +] + +[[package]] +name = "rtrb" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8388ea1a9e0ea807e442e8263a699e7edcb320ecbcd21b4fa8ff859acce3ba" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rustybuzz" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfb9cf8877777222e4a3bc7eb247e398b56baba500c38c1c46842431adc8b55c" +dependencies = [ + "bitflags 2.10.0", + "bytemuck", + "smallvec", + "ttf-parser 0.21.1", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit 0.19.2", + "tiny-skia", +] + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "seq-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-content" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3753ca04f350fa92d00b6146a3555e63c55388c9ef2e11e09bce2ff1c0b509c6" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + +[[package]] +name = "simd-abstraction" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cadb29c57caadc51ff8346233b5cec1d240b68ce55cf1afc764818791876987" +dependencies = [ + "outref", +] + +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] +name = "skrifa" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c31071dedf532758ecf3fed987cdb4bd9509f900e026ab684b4ecb81ea49841" +dependencies = [ + "bytemuck", + "read-fonts", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "slotmap" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbff4acf519f630b3a3ddcfaea6c06b42174d9a44bc70c620e9ed1649d58b82a" +dependencies = [ + "serde", + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.10.0", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.10.0", + "calloop 0.14.3", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.2", + "thiserror 2.0.17", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "stability" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d904e7009df136af5297832a3ace3370cd14ff1546a232f4f185036c2736fcac" +dependencies = [ + "quote", + "syn 2.0.110", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.110", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "svg_fmt" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" + +[[package]] +name = "svgtypes" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +dependencies = [ + "kurbo 0.11.3", + "siphasher", +] + +[[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-adpcm", + "symphonia-codec-alac", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-caf", + "symphonia-format-isomp4", + "symphonia-format-mkv", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-adpcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-alac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-caf" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8faf379316b6b6e6bbc274d00e7a592e0d63ff1a7e182ce8ba25e24edd3d096" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-format-isomp4" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" +dependencies = [ + "encoding_rs", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-mkv" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "122d786d2c43a49beb6f397551b4a050d8229eaa54c7ddf9ee4b98899b8742d0" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.110" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "syntect" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" +dependencies = [ + "bincode", + "fancy-regex", + "flate2", + "fnv", + "once_cell", + "plist", + "regex-syntax", + "serde", + "serde_derive", + "serde_json", + "thiserror 2.0.17", + "walkdir", + "yaml-rust", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri-winrt-notification" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" +dependencies = [ + "quick-xml 0.37.5", + "thiserror 2.0.17", + "windows 0.61.3", + "windows-version", +] + +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix 1.1.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "tiff" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png 0.17.16", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.12.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.12.0", + "serde", + "serde_spanned", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.23.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +dependencies = [ + "indexmap 2.12.0", + "toml_datetime 0.7.3", + "toml_parser", + "winnow 0.7.13", +] + +[[package]] +name = "toml_parser" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +dependencies = [ + "winnow 0.7.13", +] + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom 8.0.0", + "petgraph 0.8.3", +] + +[[package]] +name = "ttf-parser" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.1", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "uds_windows" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +dependencies = [ + "memoffset", + "tempfile", + "winapi", +] + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cb788ffebc92c5948d0e997106233eeb1d8b9512f93f41651f52b6c5f5af86" + +[[package]] +name = "unicode-ccc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1df77b101bcc4ea3d78dafc5ad7e4f58ceffe0b2b16bf446aeb50b6cb4157656" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb421b350c9aff471779e262955939f565ec18b86c15364e6bdf0d662ca7c1f" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "usvg" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b84ea542ae85c715f07b082438a4231c3760539d902e11d093847a0b22963032" +dependencies = [ + "base64 0.22.1", + "data-url 0.3.2", + "flate2", + "fontdb", + "imagesize 0.12.0", + "kurbo 0.11.3", + "log", + "pico-args", + "roxmltree", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + +[[package]] +name = "usvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" +dependencies = [ + "base64 0.22.1", + "data-url 0.3.2", + "flate2", + "imagesize 0.13.0", + "kurbo 0.11.3", + "log", + "pico-args", + "roxmltree", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "xmlwriter", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "vello" +version = "0.6.0" +source = "git+https://github.com/linebender/vello?branch=main#809244764436824f20426bd53f92e645e9845ab3" +dependencies = [ + "bytemuck", + "futures-intrusive", + "log", + "peniko", + "png 0.17.16", + "skrifa", + "static_assertions", + "thiserror 2.0.17", + "vello_encoding", + "vello_shaders", + "wgpu", +] + +[[package]] +name = "vello_encoding" +version = "0.6.0" +source = "git+https://github.com/linebender/vello?branch=main#809244764436824f20426bd53f92e645e9845ab3" +dependencies = [ + "bytemuck", + "guillotiere", + "peniko", + "skrifa", + "smallvec", +] + +[[package]] +name = "vello_shaders" +version = "0.6.0" +source = "git+https://github.com/linebender/vello?branch=main#809244764436824f20426bd53f92e645e9845ab3" +dependencies = [ + "bytemuck", + "log", + "naga", + "thiserror 2.0.17", + "vello_encoding", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vlq" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65dd7eed29412da847b0f78bcec0ac98588165988a8cfe41d4ea1d429f8ccfff" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.110", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.2", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" +dependencies = [ + "bitflags 2.10.0", + "rustix 1.1.2", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.10.0", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "447ccc440a881271b19e9989f75726d60faa09b95b0200a9b7eb5cc47c3eeb29" +dependencies = [ + "rustix 1.1.2", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dfe33d551eb8bffd03ff067a8b44bb963919157841a99957151299a6307d19c" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a07a14257c077ab3279987c4f8bb987851bf57081b93710381daea94f2c2c032" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3" +dependencies = [ + "proc-macro2", + "quick-xml 0.37.5", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webbrowser" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00f1243ef785213e3a32fa0396093424a3a6ea566f9948497e5a2309261a4c97" +dependencies = [ + "core-foundation 0.10.1", + "jni", + "log", + "ndk-context", + "objc2 0.6.3", + "objc2-foundation 0.3.2", + "url", + "web-sys", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77" +dependencies = [ + "arrayvec", + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.16.0", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "27.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.10.0", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.16.0", + "indexmap 2.12.0", + "log", + "naga", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.17", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0772ae958e9be0c729561d5e3fd9a19679bcdfb945b8b1a1969d9bfe8056d233" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b06ac3444a95b0813ecfd81ddb2774b66220b264b3e2031152a4a29fda4da6b5" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71197027d61a71748e4120f05a9242b2ad142e3c01f8c1b47707945a879a03c3" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "27.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.10.0", + "block", + "bytemuck", + "cfg-if", + "cfg_aliases", + "core-graphics-types 0.2.0", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "hashbrown 0.16.0", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "metal", + "naga", + "ndk-sys", + "objc", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "smallvec", + "thiserror 2.0.17", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "windows 0.58.0", + "windows-core 0.58.0", +] + +[[package]] +name = "wgpu-types" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afdcf84c395990db737f2dd91628706cb31e86d72e53482320d368e52b5da5eb" +dependencies = [ + "bitflags 2.10.0", + "bytemuck", + "js-sys", + "log", + "thiserror 2.0.17", + "web-sys", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04662ed0e3e5630dfa9b26e4cb823b817f1a9addda855d973a9458c236556244" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winit" +version = "0.30.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66d4b9ed69c4009f6321f762d6e61ad8a2389cd431b97cb1e146812e9e6c732" +dependencies = [ + "ahash 0.8.12", + "android-activity", + "atomic-waker", + "bitflags 2.10.0", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "wl-clipboard-rs" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.2", + "thiserror 2.0.17", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-clipboard" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "662d74b3d77e396b8e5beb00b9cad6a9eccf40b2ef68cc858784b14c41d535a3" +dependencies = [ + "libc", + "x11rb", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.2", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.10.0", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "nix 0.30.1", + "ordered-stream", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 0.7.13", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.110", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" +dependencies = [ + "serde", + "static_assertions", + "winnow 0.7.13", + "zvariant", +] + +[[package]] +name = "zbus_xml" +version = "5.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589e9a02bfafb9754bb2340a9e3b38f389772684c63d9637e76b1870377bec29" +dependencies = [ + "quick-xml 0.36.2", + "serde", + "static_assertions", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "aes", + "byteorder", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "flate2", + "hmac", + "pbkdf2", + "sha1", + "time", + "zstd", +] + +[[package]] +name = "zstd" +version = "0.11.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "5.0.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" +dependencies = [ + "libc", + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "5.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2be61892e4f2b1772727be11630a62664a1826b62efa43a6fe7449521cb8744c" +dependencies = [ + "endi", + "enumflags2", + "serde", + "url", + "winnow 0.7.13", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da58575a1b2b20766513b1ec59d8e2e68db2745379f961f86650655e862d2006" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.110", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.110", + "winnow 0.7.13", +] diff --git a/lightningbeam-ui/Cargo.toml b/lightningbeam-ui/Cargo.toml new file mode 100644 index 0000000..7a66854 --- /dev/null +++ b/lightningbeam-ui/Cargo.toml @@ -0,0 +1,89 @@ +[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" } diff --git a/lightningbeam-ui/beamdsp/BEAMDSP.md b/lightningbeam-ui/beamdsp/BEAMDSP.md new file mode 100644 index 0000000..3fccdea --- /dev/null +++ b/lightningbeam-ui/beamdsp/BEAMDSP.md @@ -0,0 +1,613 @@ +# 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. diff --git a/lightningbeam-ui/beamdsp/Cargo.toml b/lightningbeam-ui/beamdsp/Cargo.toml new file mode 100644 index 0000000..7b1f574 --- /dev/null +++ b/lightningbeam-ui/beamdsp/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "beamdsp" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { version = "1", features = ["derive"] } diff --git a/lightningbeam-ui/beamdsp/src/ast.rs b/lightningbeam-ui/beamdsp/src/ast.rs new file mode 100644 index 0000000..de3a76f --- /dev/null +++ b/lightningbeam-ui/beamdsp/src/ast.rs @@ -0,0 +1,158 @@ +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, + pub outputs: Vec, + pub params: Vec, + pub state: Vec, + pub ui: Option>, + pub process: Block, + pub draw: Option, +} + +#[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; + +#[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, + 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, Span), +} + +#[derive(Debug, Clone)] +pub enum Expr { + FloatLit(f32, Span), + IntLit(i32, Span), + BoolLit(bool, Span), + Ident(String, Span), + BinOp(Box, BinOp, Box, Span), + UnaryOp(UnaryOp, Box, Span), + Call(String, Vec, Span), + Index(Box, Box, Span), + Cast(CastKind, Box, 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, +} diff --git a/lightningbeam-ui/beamdsp/src/codegen.rs b/lightningbeam-ui/beamdsp/src/codegen.rs new file mode 100644 index 0000000..1faa67a --- /dev/null +++ b/lightningbeam-ui/beamdsp/src/codegen.rs @@ -0,0 +1,1122 @@ +use crate::ast::*; +use crate::error::CompileError; +use crate::opcodes::OpCode; +use crate::token::Span; +use crate::ui_decl::{UiDeclaration, UiElement}; +use crate::validator::VType; +use crate::vm::ScriptVM; + +/// Where a named variable lives in the VM +#[derive(Debug, Clone, Copy)] +enum VarLoc { + Local(u16, VType), + Param(u16), + StateScalar(u16, VType), + InputBuffer(u8), + OutputBuffer(u8), + StateArray(u16, VType), // VType is the element type + SampleSlot(u8), + BuiltinSampleRate, + BuiltinBufferSize, +} + +struct Compiler { + code: Vec, + constants_f32: Vec, + constants_i32: Vec, + vars: Vec<(String, VarLoc)>, + next_local: u16, + scope_stack: Vec, // local count at scope entry + draw_context: bool, // true when compiling a draw {} block +} + +impl Compiler { + fn new() -> Self { + Self { + code: Vec::new(), + constants_f32: Vec::new(), + constants_i32: Vec::new(), + vars: Vec::new(), + next_local: 0, + scope_stack: Vec::new(), + draw_context: false, + } + } + + fn emit(&mut self, op: OpCode) { + self.code.push(op as u8); + } + + fn emit_u8(&mut self, v: u8) { + self.code.push(v); + } + + fn emit_u16(&mut self, v: u16) { + self.code.extend_from_slice(&v.to_le_bytes()); + } + + fn emit_u32(&mut self, v: u32) { + self.code.extend_from_slice(&v.to_le_bytes()); + } + + /// Returns index into constants_f32 + fn add_const_f32(&mut self, v: f32) -> u16 { + // Reuse existing constant if possible + for (i, &c) in self.constants_f32.iter().enumerate() { + if c.to_bits() == v.to_bits() { + return i as u16; + } + } + let idx = self.constants_f32.len() as u16; + self.constants_f32.push(v); + idx + } + + /// Returns index into constants_i32 + fn add_const_i32(&mut self, v: i32) -> u16 { + for (i, &c) in self.constants_i32.iter().enumerate() { + if c == v { + return i as u16; + } + } + let idx = self.constants_i32.len() as u16; + self.constants_i32.push(v); + idx + } + + fn push_scope(&mut self) { + self.scope_stack.push(self.next_local); + } + + fn pop_scope(&mut self) { + let prev = self.scope_stack.pop().unwrap(); + // Remove variables defined in this scope + self.vars.retain(|(_, loc)| { + if let VarLoc::Local(idx, _) = loc { + *idx < prev + } else { + true + } + }); + self.next_local = prev; + } + + fn alloc_local(&mut self, name: String, ty: VType) -> u16 { + let idx = self.next_local; + self.next_local += 1; + self.vars.push((name, VarLoc::Local(idx, ty))); + idx + } + + fn lookup(&self, name: &str) -> Option { + self.vars.iter().rev().find(|(n, _)| n == name).map(|(_, l)| *l) + } + + /// Emit a placeholder u32 and return the offset where it was written + fn emit_jump_placeholder(&mut self, op: OpCode) -> usize { + self.emit(op); + let pos = self.code.len(); + self.emit_u32(0); + pos + } + + /// Patch a previously emitted u32 placeholder + fn patch_jump(&mut self, placeholder_pos: usize) { + let target = self.code.len() as u32; + let bytes = target.to_le_bytes(); + self.code[placeholder_pos] = bytes[0]; + self.code[placeholder_pos + 1] = bytes[1]; + self.code[placeholder_pos + 2] = bytes[2]; + self.code[placeholder_pos + 3] = bytes[3]; + } + + fn compile_script(&mut self, script: &Script) -> Result<(), CompileError> { + // Register built-in variables + self.vars.push(("sample_rate".into(), VarLoc::BuiltinSampleRate)); + self.vars.push(("buffer_size".into(), VarLoc::BuiltinBufferSize)); + + // Register inputs + for (i, input) in script.inputs.iter().enumerate() { + match input.signal { + SignalKind::Audio | SignalKind::Cv => { + self.vars.push((input.name.clone(), VarLoc::InputBuffer(i as u8))); + } + SignalKind::Midi => {} + } + } + + // Register outputs + for (i, output) in script.outputs.iter().enumerate() { + match output.signal { + SignalKind::Audio | SignalKind::Cv => { + self.vars.push((output.name.clone(), VarLoc::OutputBuffer(i as u8))); + } + SignalKind::Midi => {} + } + } + + self.register_params_and_state(script, true); + + // Compile process block + for stmt in &script.process { + self.compile_stmt(stmt)?; + } + + self.emit(OpCode::Halt); + Ok(()) + } + + /// Compile the draw block into separate bytecode (for the DrawVM) + fn compile_draw(&mut self, script: &Script) -> Result<(), CompileError> { + self.draw_context = true; + self.register_params_and_state(script, false); + + // Compile draw block + if let Some(draw) = &script.draw { + for stmt in draw { + self.compile_stmt(stmt)?; + } + } + + self.emit(OpCode::Halt); + Ok(()) + } + + /// Register params and state variables. If `include_samples` is true, also registers sample slots. + fn register_params_and_state(&mut self, script: &Script, include_samples: bool) { + for (i, param) in script.params.iter().enumerate() { + self.vars.push((param.name.clone(), VarLoc::Param(i as u16))); + } + + let mut scalar_idx: u16 = 0; + let mut array_idx: u16 = 0; + let mut sample_idx: u8 = 0; + for state in &script.state { + match &state.ty { + StateType::F32 => { + self.vars.push((state.name.clone(), VarLoc::StateScalar(scalar_idx, VType::F32))); + scalar_idx += 1; + } + StateType::Int => { + self.vars.push((state.name.clone(), VarLoc::StateScalar(scalar_idx, VType::Int))); + scalar_idx += 1; + } + StateType::Bool => { + self.vars.push((state.name.clone(), VarLoc::StateScalar(scalar_idx, VType::Bool))); + scalar_idx += 1; + } + StateType::ArrayF32(_) => { + self.vars.push((state.name.clone(), VarLoc::StateArray(array_idx, VType::F32))); + array_idx += 1; + } + StateType::ArrayInt(_) => { + self.vars.push((state.name.clone(), VarLoc::StateArray(array_idx, VType::Int))); + array_idx += 1; + } + StateType::Sample if include_samples => { + self.vars.push((state.name.clone(), VarLoc::SampleSlot(sample_idx))); + sample_idx += 1; + } + StateType::Sample => {} + } + } + } + + fn compile_stmt(&mut self, stmt: &Stmt) -> Result<(), CompileError> { + match stmt { + Stmt::Let { name, init, .. } => { + let ty = self.infer_type(init)?; + self.compile_expr(init)?; + let _idx = self.alloc_local(name.clone(), ty); + self.emit(OpCode::StoreLocal); + self.emit_u16(_idx); + } + Stmt::Assign { target, value, span } => { + match target { + LValue::Ident(name, _) => { + let loc = self.lookup(name).ok_or_else(|| { + CompileError::new(format!("Undefined variable: {}", name), *span) + })?; + self.compile_expr(value)?; + match loc { + VarLoc::Local(idx, _) => { + self.emit(OpCode::StoreLocal); + self.emit_u16(idx); + } + VarLoc::StateScalar(idx, _) => { + self.emit(OpCode::StoreState); + self.emit_u16(idx); + } + VarLoc::Param(idx) if self.draw_context => { + self.emit(OpCode::StoreParam); + self.emit_u16(idx); + } + _ => { + return Err(CompileError::new( + format!("Cannot assign to {}", name), *span, + )); + } + } + } + LValue::Index(name, idx_expr, s) => { + let loc = self.lookup(name).ok_or_else(|| { + CompileError::new(format!("Undefined variable: {}", name), *s) + })?; + match loc { + VarLoc::OutputBuffer(port) => { + // StoreOutput: pops value then index + self.compile_expr(idx_expr)?; + self.compile_expr(value)?; + self.emit(OpCode::StoreOutput); + self.emit_u8(port); + } + VarLoc::StateArray(arr_id, _) => { + // StoreStateArray: pops value then index + self.compile_expr(idx_expr)?; + self.compile_expr(value)?; + self.emit(OpCode::StoreStateArray); + self.emit_u16(arr_id); + } + _ => { + return Err(CompileError::new( + format!("Cannot index-assign to {}", name), *s, + )); + } + } + } + } + } + Stmt::If { cond, then_block, else_block, .. } => { + self.compile_expr(cond)?; + if let Some(else_b) = else_block { + // JumpIfFalse -> else + let else_jump = self.emit_jump_placeholder(OpCode::JumpIfFalse); + self.push_scope(); + self.compile_block(then_block)?; + self.pop_scope(); + // Jump -> end (skip else) + let end_jump = self.emit_jump_placeholder(OpCode::Jump); + self.patch_jump(else_jump); + self.push_scope(); + self.compile_block(else_b)?; + self.pop_scope(); + self.patch_jump(end_jump); + } else { + let end_jump = self.emit_jump_placeholder(OpCode::JumpIfFalse); + self.push_scope(); + self.compile_block(then_block)?; + self.pop_scope(); + self.patch_jump(end_jump); + } + } + Stmt::For { var, end, body, span: _ } => { + // Allocate loop variable as local + self.push_scope(); + let loop_var = self.alloc_local(var.clone(), VType::Int); + + // Initialize loop var to 0 + let zero_idx = self.add_const_i32(0); + self.emit(OpCode::PushI32); + self.emit_u16(zero_idx); + self.emit(OpCode::StoreLocal); + self.emit_u16(loop_var); + + // Loop start: check condition (i < end) + let loop_start = self.code.len(); + self.emit(OpCode::LoadLocal); + self.emit_u16(loop_var); + self.compile_expr(end)?; + self.emit(OpCode::LtI); + + let exit_jump = self.emit_jump_placeholder(OpCode::JumpIfFalse); + + // Body + self.compile_block(body)?; + + // Increment loop var + self.emit(OpCode::LoadLocal); + self.emit_u16(loop_var); + let one_idx = self.add_const_i32(1); + self.emit(OpCode::PushI32); + self.emit_u16(one_idx); + self.emit(OpCode::AddI); + self.emit(OpCode::StoreLocal); + self.emit_u16(loop_var); + + // Jump back to loop start + self.emit(OpCode::Jump); + self.emit_u32(loop_start as u32); + + // Patch exit + self.patch_jump(exit_jump); + self.pop_scope(); + } + Stmt::ExprStmt(expr) => { + let is_void = self.is_void_call(expr); + self.compile_expr(expr)?; + if !is_void { + self.emit(OpCode::Pop); + } + } + } + Ok(()) + } + + fn compile_block(&mut self, block: &[Stmt]) -> Result<(), CompileError> { + for stmt in block { + self.compile_stmt(stmt)?; + } + Ok(()) + } + + fn compile_expr(&mut self, expr: &Expr) -> Result<(), CompileError> { + match expr { + Expr::FloatLit(v, _) => { + let idx = self.add_const_f32(*v); + self.emit(OpCode::PushF32); + self.emit_u16(idx); + } + Expr::IntLit(v, _) => { + let idx = self.add_const_i32(*v); + self.emit(OpCode::PushI32); + self.emit_u16(idx); + } + Expr::BoolLit(v, _) => { + self.emit(OpCode::PushBool); + self.emit_u8(if *v { 1 } else { 0 }); + } + Expr::Ident(name, span) => { + let loc = self.lookup(name).ok_or_else(|| { + CompileError::new(format!("Undefined variable: {}", name), *span) + })?; + match loc { + VarLoc::Local(idx, _) => { + self.emit(OpCode::LoadLocal); + self.emit_u16(idx); + } + VarLoc::Param(idx) => { + self.emit(OpCode::LoadParam); + self.emit_u16(idx); + } + VarLoc::StateScalar(idx, _) => { + self.emit(OpCode::LoadState); + self.emit_u16(idx); + } + VarLoc::BuiltinSampleRate => { + self.emit(OpCode::LoadSampleRate); + } + VarLoc::BuiltinBufferSize => { + self.emit(OpCode::LoadBufferSize); + } + // Arrays/buffers/samples used bare (for len(), etc.) — handled by call codegen + _ => {} + } + } + Expr::BinOp(left, op, right, _span) => { + let lt = self.infer_type(left)?; + let rt = self.infer_type(right)?; + self.compile_expr(left)?; + self.compile_expr(right)?; + + match op { + BinOp::Add => { + if lt == VType::F32 || rt == VType::F32 { + self.emit(OpCode::AddF); + } else { + self.emit(OpCode::AddI); + } + } + BinOp::Sub => { + if lt == VType::F32 || rt == VType::F32 { + self.emit(OpCode::SubF); + } else { + self.emit(OpCode::SubI); + } + } + BinOp::Mul => { + if lt == VType::F32 || rt == VType::F32 { + self.emit(OpCode::MulF); + } else { + self.emit(OpCode::MulI); + } + } + BinOp::Div => { + if lt == VType::F32 || rt == VType::F32 { + self.emit(OpCode::DivF); + } else { + self.emit(OpCode::DivI); + } + } + BinOp::Mod => { + if lt == VType::F32 || rt == VType::F32 { + self.emit(OpCode::ModF); + } else { + self.emit(OpCode::ModI); + } + } + BinOp::Eq => { + if lt == VType::F32 || rt == VType::F32 { + self.emit(OpCode::EqF); + } else if lt == VType::Int || rt == VType::Int { + self.emit(OpCode::EqI); + } else { + // bool comparison: treat as int + self.emit(OpCode::EqI); + } + } + BinOp::Ne => { + if lt == VType::F32 || rt == VType::F32 { + self.emit(OpCode::NeF); + } else { + self.emit(OpCode::NeI); + } + } + BinOp::Lt => { + if lt == VType::F32 || rt == VType::F32 { + self.emit(OpCode::LtF); + } else { + self.emit(OpCode::LtI); + } + } + BinOp::Gt => { + if lt == VType::F32 || rt == VType::F32 { + self.emit(OpCode::GtF); + } else { + self.emit(OpCode::GtI); + } + } + BinOp::Le => { + if lt == VType::F32 || rt == VType::F32 { + self.emit(OpCode::LeF); + } else { + self.emit(OpCode::LeI); + } + } + BinOp::Ge => { + if lt == VType::F32 || rt == VType::F32 { + self.emit(OpCode::GeF); + } else { + self.emit(OpCode::GeI); + } + } + BinOp::And => self.emit(OpCode::And), + BinOp::Or => self.emit(OpCode::Or), + } + } + Expr::UnaryOp(op, inner, _) => { + let ty = self.infer_type(inner)?; + self.compile_expr(inner)?; + match op { + UnaryOp::Neg => { + if ty == VType::F32 { + self.emit(OpCode::NegF); + } else { + self.emit(OpCode::NegI); + } + } + UnaryOp::Not => self.emit(OpCode::Not), + } + } + Expr::Cast(kind, inner, _) => { + self.compile_expr(inner)?; + match kind { + CastKind::ToInt => self.emit(OpCode::F32ToI32), + CastKind::ToFloat => self.emit(OpCode::I32ToF32), + } + } + Expr::Index(base, idx, span) => { + // base must be an Ident referencing an array/buffer + if let Expr::Ident(name, _) = base.as_ref() { + let loc = self.lookup(name).ok_or_else(|| { + CompileError::new(format!("Undefined variable: {}", name), *span) + })?; + match loc { + VarLoc::InputBuffer(port) => { + self.compile_expr(idx)?; + self.emit(OpCode::LoadInput); + self.emit_u8(port); + } + VarLoc::OutputBuffer(port) => { + self.compile_expr(idx)?; + self.emit(OpCode::LoadInput); + // Reading from output buffer — use same port but from outputs + // Actually outputs aren't readable in the VM. This would be + // an error in practice, but the validator should catch it. + // For now, treat as input read (will read zeros). + self.emit_u8(port); + } + VarLoc::StateArray(arr_id, _) => { + self.compile_expr(idx)?; + self.emit(OpCode::LoadStateArray); + self.emit_u16(arr_id); + } + _ => { + return Err(CompileError::new( + format!("Cannot index variable: {}", name), *span, + )); + } + } + } else { + return Err(CompileError::new("Index base must be an identifier", *span)); + } + } + Expr::Call(name, args, span) => { + self.compile_call(name, args, *span)?; + } + } + Ok(()) + } + + /// Returns true if the expression is a call to a void function (no return value). + fn is_void_call(&self, expr: &Expr) -> bool { + if let Expr::Call(name, _, _) = expr { + matches!(name.as_str(), + "fill_circle" | "stroke_circle" | "stroke_arc" | + "line" | "fill_rect" | "stroke_rect" + ) + } else { + false + } + } + + fn compile_call(&mut self, name: &str, args: &[Expr], span: Span) -> Result<(), CompileError> { + match name { + // 1-arg math → push arg, emit opcode + "sin" => { self.compile_expr(&args[0])?; self.emit(OpCode::Sin); } + "cos" => { self.compile_expr(&args[0])?; self.emit(OpCode::Cos); } + "tan" => { self.compile_expr(&args[0])?; self.emit(OpCode::Tan); } + "asin" => { self.compile_expr(&args[0])?; self.emit(OpCode::Asin); } + "acos" => { self.compile_expr(&args[0])?; self.emit(OpCode::Acos); } + "atan" => { self.compile_expr(&args[0])?; self.emit(OpCode::Atan); } + "exp" => { self.compile_expr(&args[0])?; self.emit(OpCode::Exp); } + "log" => { self.compile_expr(&args[0])?; self.emit(OpCode::Log); } + "log2" => { self.compile_expr(&args[0])?; self.emit(OpCode::Log2); } + "sqrt" => { self.compile_expr(&args[0])?; self.emit(OpCode::Sqrt); } + "floor" => { self.compile_expr(&args[0])?; self.emit(OpCode::Floor); } + "ceil" => { self.compile_expr(&args[0])?; self.emit(OpCode::Ceil); } + "round" => { self.compile_expr(&args[0])?; self.emit(OpCode::Round); } + "trunc" => { self.compile_expr(&args[0])?; self.emit(OpCode::Trunc); } + "fract" => { self.compile_expr(&args[0])?; self.emit(OpCode::Fract); } + "abs" => { self.compile_expr(&args[0])?; self.emit(OpCode::Abs); } + "sign" => { self.compile_expr(&args[0])?; self.emit(OpCode::Sign); } + + // 2-arg math + "atan2" => { + self.compile_expr(&args[0])?; + self.compile_expr(&args[1])?; + self.emit(OpCode::Atan2); + } + "pow" => { + self.compile_expr(&args[0])?; + self.compile_expr(&args[1])?; + self.emit(OpCode::Pow); + } + "min" => { + self.compile_expr(&args[0])?; + self.compile_expr(&args[1])?; + self.emit(OpCode::Min); + } + "max" => { + self.compile_expr(&args[0])?; + self.compile_expr(&args[1])?; + self.emit(OpCode::Max); + } + + // 3-arg math + "clamp" => { + self.compile_expr(&args[0])?; + self.compile_expr(&args[1])?; + self.compile_expr(&args[2])?; + self.emit(OpCode::Clamp); + } + "mix" => { + self.compile_expr(&args[0])?; + self.compile_expr(&args[1])?; + self.compile_expr(&args[2])?; + self.emit(OpCode::Mix); + } + "smoothstep" => { + self.compile_expr(&args[0])?; + self.compile_expr(&args[1])?; + self.compile_expr(&args[2])?; + self.emit(OpCode::Smoothstep); + } + + // cv_or(value, default) — if value is NaN, use default + "cv_or" => { + // Compile: push value, check IsNan, if true use default else keep value + // Strategy: push value, dup-like via local, IsNan, branch + // Simpler: push value, push value again, IsNan, JumpIfFalse skip, Pop, push default, skip: + // But we don't have Dup. Use a temp local instead. + let temp = self.next_local; + self.next_local += 1; + self.compile_expr(&args[0])?; + // Store to temp + self.emit(OpCode::StoreLocal); + self.emit_u16(temp); + // Load and check NaN + self.emit(OpCode::LoadLocal); + self.emit_u16(temp); + self.emit(OpCode::IsNan); + let skip_default = self.emit_jump_placeholder(OpCode::JumpIfFalse); + // NaN path: use default + self.compile_expr(&args[1])?; + let skip_end = self.emit_jump_placeholder(OpCode::Jump); + // Not NaN path: use original value + self.patch_jump(skip_default); + self.emit(OpCode::LoadLocal); + self.emit_u16(temp); + self.patch_jump(skip_end); + self.next_local -= 1; // release temp + } + + // len(array) -> int + "len" => { + // Arg must be an ident referencing a state array or input/output buffer + if let Expr::Ident(arr_name, s) = &args[0] { + let loc = self.lookup(arr_name).ok_or_else(|| { + CompileError::new(format!("Undefined variable: {}", arr_name), *s) + })?; + match loc { + VarLoc::StateArray(arr_id, _) => { + self.emit(OpCode::ArrayLen); + self.emit_u16(arr_id); + } + VarLoc::InputBuffer(_) | VarLoc::OutputBuffer(_) => { + // Buffer length is buffer_size (for CV) or buffer_size*2 (for audio) + // We emit LoadBufferSize — scripts use buffer_size for iteration + self.emit(OpCode::LoadBufferSize); + } + _ => { + return Err(CompileError::new("len() argument must be an array", span)); + } + } + } else { + return Err(CompileError::new("len() argument must be an identifier", span)); + } + } + + // sample_len(sample) -> int + "sample_len" => { + if let Expr::Ident(sname, s) = &args[0] { + let loc = self.lookup(sname).ok_or_else(|| { + CompileError::new(format!("Undefined: {}", sname), *s) + })?; + if let VarLoc::SampleSlot(slot) = loc { + self.emit(OpCode::SampleLen); + self.emit_u8(slot); + } else { + return Err(CompileError::new("sample_len() requires a sample", span)); + } + } else { + return Err(CompileError::new("sample_len() requires an identifier", span)); + } + } + + // sample_read(sample, index) -> f32 + "sample_read" => { + if let Expr::Ident(sname, s) = &args[0] { + let loc = self.lookup(sname).ok_or_else(|| { + CompileError::new(format!("Undefined: {}", sname), *s) + })?; + if let VarLoc::SampleSlot(slot) = loc { + self.compile_expr(&args[1])?; + self.emit(OpCode::SampleRead); + self.emit_u8(slot); + } else { + return Err(CompileError::new("sample_read() requires a sample", span)); + } + } else { + return Err(CompileError::new("sample_read() requires an identifier", span)); + } + } + + // sample_rate_of(sample) -> int + "sample_rate_of" => { + if let Expr::Ident(sname, s) = &args[0] { + let loc = self.lookup(sname).ok_or_else(|| { + CompileError::new(format!("Undefined: {}", sname), *s) + })?; + if let VarLoc::SampleSlot(slot) = loc { + self.emit(OpCode::SampleRateOf); + self.emit_u8(slot); + } else { + return Err(CompileError::new("sample_rate_of() requires a sample", span)); + } + } else { + return Err(CompileError::new("sample_rate_of() requires an identifier", span)); + } + } + + // Draw builtins (only valid in draw context) + "fill_circle" | "stroke_circle" | "stroke_arc" | "line" | + "fill_rect" | "stroke_rect" | + "mouse_x" | "mouse_y" | "mouse_down" if self.draw_context => { + match name { + "fill_circle" => { + // fill_circle(cx, cy, r, color) + for arg in args { self.compile_expr(arg)?; } + self.emit(OpCode::DrawFillCircle); + } + "stroke_circle" => { + // stroke_circle(cx, cy, r, color, width) + for arg in args { self.compile_expr(arg)?; } + self.emit(OpCode::DrawStrokeCircle); + } + "stroke_arc" => { + // stroke_arc(cx, cy, r, start_deg, end_deg, color, width) + for arg in args { self.compile_expr(arg)?; } + self.emit(OpCode::DrawStrokeArc); + } + "line" => { + // line(x1, y1, x2, y2, color, width) + for arg in args { self.compile_expr(arg)?; } + self.emit(OpCode::DrawLine); + } + "fill_rect" => { + // fill_rect(x, y, w, h, color) + for arg in args { self.compile_expr(arg)?; } + self.emit(OpCode::DrawFillRect); + } + "stroke_rect" => { + // stroke_rect(x, y, w, h, color, width) + for arg in args { self.compile_expr(arg)?; } + self.emit(OpCode::DrawStrokeRect); + } + "mouse_x" => { self.emit(OpCode::MouseX); } + "mouse_y" => { self.emit(OpCode::MouseY); } + "mouse_down" => { self.emit(OpCode::MouseDown); } + _ => unreachable!(), + } + } + + _ => { + return Err(CompileError::new(format!("Unknown function: {}", name), span)); + } + } + Ok(()) + } + + /// Infer the type of an expression (mirrors validator logic, needed for selecting typed opcodes) + fn infer_type(&self, expr: &Expr) -> Result { + match expr { + Expr::FloatLit(_, _) => Ok(VType::F32), + Expr::IntLit(_, _) => Ok(VType::Int), + Expr::BoolLit(_, _) => Ok(VType::Bool), + Expr::Ident(name, span) => { + let loc = self.lookup(name).ok_or_else(|| { + CompileError::new(format!("Undefined variable: {}", name), *span) + })?; + match loc { + VarLoc::Local(_, ty) => Ok(ty), + VarLoc::Param(_) => Ok(VType::F32), + VarLoc::StateScalar(_, ty) => Ok(ty), + VarLoc::InputBuffer(_) => Ok(VType::ArrayF32), + VarLoc::OutputBuffer(_) => Ok(VType::ArrayF32), + VarLoc::StateArray(_, elem_ty) => { + if elem_ty == VType::Int { Ok(VType::ArrayInt) } else { Ok(VType::ArrayF32) } + } + VarLoc::SampleSlot(_) => Ok(VType::Sample), + VarLoc::BuiltinSampleRate => Ok(VType::Int), + VarLoc::BuiltinBufferSize => Ok(VType::Int), + } + } + Expr::BinOp(left, op, right, _) => { + let lt = self.infer_type(left)?; + let rt = self.infer_type(right)?; + match op { + BinOp::And | BinOp::Or | BinOp::Eq | BinOp::Ne | + BinOp::Lt | BinOp::Gt | BinOp::Le | BinOp::Ge => Ok(VType::Bool), + _ => { + if lt == VType::F32 || rt == VType::F32 { + Ok(VType::F32) + } else { + Ok(VType::Int) + } + } + } + } + Expr::UnaryOp(op, inner, _) => { + match op { + UnaryOp::Neg => self.infer_type(inner), + UnaryOp::Not => Ok(VType::Bool), + } + } + Expr::Cast(kind, _, _) => match kind { + CastKind::ToInt => Ok(VType::Int), + CastKind::ToFloat => Ok(VType::F32), + }, + Expr::Index(base, _, _) => { + let base_ty = self.infer_type(base)?; + match base_ty { + VType::ArrayF32 => Ok(VType::F32), + VType::ArrayInt => Ok(VType::Int), + _ => Ok(VType::F32), // fallback + } + } + Expr::Call(name, _, _) => { + match name.as_str() { + "len" | "sample_len" | "sample_rate_of" => Ok(VType::Int), + "isnan" => Ok(VType::Bool), + _ => Ok(VType::F32), // all math functions return f32 + } + } + } + } +} + +/// Compile a validated AST into bytecode VM and UI declaration +pub fn compile(script: &Script) -> Result<(ScriptVM, UiDeclaration, Option), CompileError> { + let mut compiler = Compiler::new(); + compiler.compile_script(script)?; + + // Collect state layout info + let mut num_state_scalars = 0usize; + let mut state_array_sizes = Vec::new(); + let mut num_sample_slots = 0usize; + + for state in &script.state { + match &state.ty { + StateType::F32 | StateType::Int | StateType::Bool => { + num_state_scalars += 1; + } + StateType::ArrayF32(sz) => state_array_sizes.push(*sz), + StateType::ArrayInt(sz) => state_array_sizes.push(*sz), + StateType::Sample => num_sample_slots += 1, + } + } + + let param_defaults: Vec = script.params.iter().map(|p| p.default).collect(); + + let vm = ScriptVM::new( + compiler.code, + compiler.constants_f32, + compiler.constants_i32, + script.params.len(), + ¶m_defaults, + num_state_scalars, + &state_array_sizes, + num_sample_slots, + ); + + // Build UI declaration + let ui_decl = if let Some(elements) = &script.ui { + UiDeclaration { elements: elements.clone() } + } else { + // Auto-generate: sample pickers first, then all params + let mut elements = Vec::new(); + for state in &script.state { + if state.ty == StateType::Sample { + elements.push(UiElement::Sample(state.name.clone())); + } + } + for param in &script.params { + elements.push(UiElement::Param(param.name.clone())); + } + UiDeclaration { elements } + }; + + // Compile draw block if present + let draw_vm = if script.draw.is_some() { + let mut draw_compiler = Compiler::new(); + draw_compiler.compile_draw(script)?; + Some(crate::vm::DrawVM::new( + draw_compiler.code, + draw_compiler.constants_f32, + draw_compiler.constants_i32, + script.params.len(), + ¶m_defaults, + num_state_scalars, + &state_array_sizes, + )) + } else { + None + }; + + Ok((vm, ui_decl, draw_vm)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::lexer::Lexer; + use crate::parser::Parser; + use crate::validator; + + fn compile_source(src: &str) -> Result<(ScriptVM, UiDeclaration), CompileError> { + let mut lexer = Lexer::new(src); + let tokens = lexer.tokenize()?; + let mut parser = Parser::new(&tokens); + let script = parser.parse()?; + let validated = validator::validate(&script)?; + let (vm, ui, _draw_vm) = compile(validated)?; + Ok((vm, ui)) + } + + #[test] + fn test_passthrough() { + let src = r#" + name "Pass" + category effect + inputs { audio_in: audio } + outputs { audio_out: audio } + process { + for i in 0..buffer_size { + audio_out[i] = audio_in[i]; + } + } + "#; + let (mut vm, _) = compile_source(src).unwrap(); + let input = vec![1.0f32, 2.0, 3.0, 4.0]; + let mut output = vec![0.0f32; 4]; + let inputs: Vec<&[f32]> = vec![&input]; + let mut out_slice: Vec<&mut [f32]> = vec![&mut output]; + vm.execute(&inputs, &mut out_slice, 44100, 4).unwrap(); + assert_eq!(output, vec![1.0, 2.0, 3.0, 4.0]); + } + + #[test] + fn test_gain() { + let src = r#" + name "Gain" + category effect + inputs { audio_in: audio } + outputs { audio_out: audio } + params { gain: 0.5 [0.0, 1.0] "" } + process { + for i in 0..buffer_size { + audio_out[i] = audio_in[i] * gain; + } + } + "#; + let (mut vm, _) = compile_source(src).unwrap(); + let input = vec![1.0f32, 2.0, 3.0, 4.0]; + let mut output = vec![0.0f32; 4]; + let inputs: Vec<&[f32]> = vec![&input]; + let mut out_slice: Vec<&mut [f32]> = vec![&mut output]; + vm.execute(&inputs, &mut out_slice, 44100, 4).unwrap(); + assert_eq!(output, vec![0.5, 1.0, 1.5, 2.0]); + } + + #[test] + fn test_state_array() { + let src = r#" + name "Delay" + category effect + inputs { audio_in: audio } + outputs { audio_out: audio } + state { buf: [8]f32 } + process { + for i in 0..buffer_size { + audio_out[i] = buf[i]; + buf[i] = audio_in[i]; + } + } + "#; + let (mut vm, _) = compile_source(src).unwrap(); + + // First call: output should be zeros (state initialized to 0), state gets input + let input = vec![10.0f32, 20.0, 30.0, 40.0]; + let mut output = vec![0.0f32; 4]; + { + let inputs: Vec<&[f32]> = vec![&input]; + let mut out_slice: Vec<&mut [f32]> = vec![&mut output]; + vm.execute(&inputs, &mut out_slice, 44100, 4).unwrap(); + } + assert_eq!(output, vec![0.0, 0.0, 0.0, 0.0]); + + // Second call: output should be previous input + let input2 = vec![50.0f32, 60.0, 70.0, 80.0]; + let mut output2 = vec![0.0f32; 4]; + { + let inputs: Vec<&[f32]> = vec![&input2]; + let mut out_slice: Vec<&mut [f32]> = vec![&mut output2]; + vm.execute(&inputs, &mut out_slice, 44100, 4).unwrap(); + } + assert_eq!(output2, vec![10.0, 20.0, 30.0, 40.0]); + } + + #[test] + fn test_if_else() { + let src = r#" + name "Gate" + category effect + inputs { audio_in: audio } + outputs { audio_out: audio } + params { threshold: 0.5 [0.0, 1.0] "" } + process { + for i in 0..buffer_size { + if audio_in[i] >= threshold { + audio_out[i] = audio_in[i]; + } else { + audio_out[i] = 0.0; + } + } + } + "#; + let (mut vm, _) = compile_source(src).unwrap(); + let input = vec![0.2f32, 0.8, 0.1, 0.9]; + let mut output = vec![0.0f32; 4]; + let inputs: Vec<&[f32]> = vec![&input]; + let mut out_slice: Vec<&mut [f32]> = vec![&mut output]; + vm.execute(&inputs, &mut out_slice, 44100, 4).unwrap(); + assert_eq!(output, vec![0.0, 0.8, 0.0, 0.9]); + } + + #[test] + fn test_auto_ui() { + let src = r#" + name "Test" + category utility + params { gain: 1.0 [0.0, 2.0] "dB" } + state { clip: sample } + outputs { out: audio } + process {} + "#; + let (_, ui) = compile_source(src).unwrap(); + // Auto-generated: sample first, then params + assert_eq!(ui.elements.len(), 2); + assert!(matches!(&ui.elements[0], UiElement::Sample(n) if n == "clip")); + assert!(matches!(&ui.elements[1], UiElement::Param(n) if n == "gain")); + } + + #[test] + fn test_draw_block() { + let src = r#" + name "Knob" + category utility + params { volume: 0.75 [0.0, 1.0] "" } + outputs { out: audio } + ui { canvas [80, 80] } + draw { + let cx = 40.0; + let cy = 40.0; + fill_circle(cx, cy, 35.0, 0x333333FF); + let angle = volume * 270.0 - 135.0; + stroke_arc(cx, cy, 30.0, -135.0, angle, 0x4488FFFF, 3.0); + } + process { + for i in 0..buffer_size { + out[i] = 0.0; + } + } + "#; + let mut lexer = Lexer::new(src); + let tokens = lexer.tokenize().unwrap(); + let mut parser = Parser::new(&tokens); + let script = parser.parse().unwrap(); + let validated = validator::validate(&script).unwrap(); + let (_vm, _ui, draw_vm) = compile(validated).unwrap(); + + // Draw VM should exist + let mut dvm = draw_vm.expect("draw_vm should be Some"); + assert!(dvm.has_bytecode()); + + // Execute should succeed without stack errors + dvm.execute().unwrap(); + + // Should have produced draw commands + assert_eq!(dvm.draw_commands.len(), 2); // fill_circle + stroke_arc + + } +} diff --git a/lightningbeam-ui/beamdsp/src/error.rs b/lightningbeam-ui/beamdsp/src/error.rs new file mode 100644 index 0000000..3c472ef --- /dev/null +++ b/lightningbeam-ui/beamdsp/src/error.rs @@ -0,0 +1,55 @@ +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, +} + +impl CompileError { + pub fn new(message: impl Into, span: Span) -> Self { + Self { + message: message.into(), + span, + hint: None, + } + } + + pub fn with_hint(mut self, hint: impl Into) -> 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), + } + } +} diff --git a/lightningbeam-ui/beamdsp/src/lexer.rs b/lightningbeam-ui/beamdsp/src/lexer.rs new file mode 100644 index 0000000..c67ef09 --- /dev/null +++ b/lightningbeam-ui/beamdsp/src/lexer.rs @@ -0,0 +1,306 @@ +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, 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 { + self.source.get(self.pos).copied() + } + + fn peek_next(&self) -> Option { + 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 { + 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 { + 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 { + // 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 { + 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)); + } +} diff --git a/lightningbeam-ui/beamdsp/src/lib.rs b/lightningbeam-ui/beamdsp/src/lib.rs new file mode 100644 index 0000000..916f58f --- /dev/null +++ b/lightningbeam-ui/beamdsp/src/lib.rs @@ -0,0 +1,110 @@ +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, + pub output_ports: Vec, + pub parameters: Vec, + pub sample_slots: Vec, + pub ui_declaration: UiDeclaration, + pub source: String, + pub draw_vm: Option, +} + +#[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 { + 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, + }) +} diff --git a/lightningbeam-ui/beamdsp/src/opcodes.rs b/lightningbeam-ui/beamdsp/src/opcodes.rs new file mode 100644 index 0000000..ec7a59c --- /dev/null +++ b/lightningbeam-ui/beamdsp/src/opcodes.rs @@ -0,0 +1,223 @@ +/// 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 { + // 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, + } + } +} diff --git a/lightningbeam-ui/beamdsp/src/parser.rs b/lightningbeam-ui/beamdsp/src/parser.rs new file mode 100644 index 0000000..84e6c1a --- /dev/null +++ b/lightningbeam-ui/beamdsp/src/parser.rs @@ -0,0 +1,771 @@ +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 { + 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 { + 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 { + 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, 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, 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 { + 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, 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 { + 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, 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + self.parse_or() + } + + fn parse_or(&mut self) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/lightningbeam-ui/beamdsp/src/token.rs b/lightningbeam-ui/beamdsp/src/token.rs new file mode 100644 index 0000000..660ee58 --- /dev/null +++ b/lightningbeam-ui/beamdsp/src/token.rs @@ -0,0 +1,145 @@ +/// 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()), + } + } +} diff --git a/lightningbeam-ui/beamdsp/src/ui_decl.rs b/lightningbeam-ui/beamdsp/src/ui_decl.rs new file mode 100644 index 0000000..0a11b5c --- /dev/null +++ b/lightningbeam-ui/beamdsp/src/ui_decl.rs @@ -0,0 +1,27 @@ +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, +} + +#[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, + }, + /// Drawable canvas area (phase 2) + Canvas { + width: f32, + height: f32, + }, + /// Vertical spacer + Spacer(f32), +} diff --git a/lightningbeam-ui/beamdsp/src/validator.rs b/lightningbeam-ui/beamdsp/src/validator.rs new file mode 100644 index 0000000..fb759bf --- /dev/null +++ b/lightningbeam-ui/beamdsp/src/validator.rs @@ -0,0 +1,388 @@ +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, +} + +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 { + 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 { + // 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) +} diff --git a/lightningbeam-ui/beamdsp/src/vm.rs b/lightningbeam-ui/beamdsp/src/vm.rs new file mode 100644 index 0000000..084e1ea --- /dev/null +++ b/lightningbeam-ui/beamdsp/src/vm.rs @@ -0,0 +1,674 @@ +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, + 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, + constants_f32: Vec, + constants_i32: Vec, + stack: Vec, + sp: usize, + locals: Vec, + params: Vec, + state_scalars: Vec, + state_arrays: Vec>, + instruction_limit: u64, +} + +impl VmCore { + fn new( + bytecode: Vec, + constants_f32: Vec, + constants_i32: Vec, + 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 { + 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 { + if self.sp == 0 { + return Err(ScriptError::StackUnderflow); + } + self.sp -= 1; + Ok(self.stack[self.sp]) + } + + #[inline] + fn pop_f(&mut self) -> Result { Ok(unsafe { self.pop()?.f }) } + #[inline] + fn pop_i(&mut self) -> Result { Ok(unsafe { self.pop()?.i }) } + #[inline] + fn pop_b(&mut self) -> Result { 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, +} + +impl ScriptVM { + pub fn new( + bytecode: Vec, + constants_f32: Vec, + constants_i32: Vec, + 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 { + &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, + pub mouse: MouseState, +} + +impl DrawVM { + pub fn new( + bytecode: Vec, + constants_f32: Vec, + constants_i32: Vec, + 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 { + &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(()) + } +} diff --git a/lightningbeam-ui/build-static.sh b/lightningbeam-ui/build-static.sh new file mode 100755 index 0000000..0c6dea1 --- /dev/null +++ b/lightningbeam-ui/build-static.sh @@ -0,0 +1,27 @@ +#!/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)" diff --git a/lightningbeam-ui/build-windows.bat b/lightningbeam-ui/build-windows.bat new file mode 100644 index 0000000..6ea2663 --- /dev/null +++ b/lightningbeam-ui/build-windows.bat @@ -0,0 +1,32 @@ +@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 %* diff --git a/lightningbeam-ui/egui_node_graph2/Cargo.toml b/lightningbeam-ui/egui_node_graph2/Cargo.toml new file mode 100644 index 0000000..8d3e4d5 --- /dev/null +++ b/lightningbeam-ui/egui_node_graph2/Cargo.toml @@ -0,0 +1,21 @@ +[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" diff --git a/lightningbeam-ui/egui_node_graph2/src/color_hex_utils.rs b/lightningbeam-ui/egui_node_graph2/src/color_hex_utils.rs new file mode 100644 index 0000000..0479a8c --- /dev/null +++ b/lightningbeam-ui/egui_node_graph2/src/color_hex_utils.rs @@ -0,0 +1,94 @@ +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 { + // Convert a hex string to decimal. Eg. "00" -> 0. "FF" -> 255. + fn _hex_dec(hex_string: &str) -> Result { + 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() + ); + } +} diff --git a/lightningbeam-ui/egui_node_graph2/src/editor_ui.rs b/lightningbeam-ui/egui_node_graph2/src/editor_ui.rs new file mode 100644 index 0000000..add939f --- /dev/null +++ b/lightningbeam-ui/egui_node_graph2/src/editor_ui.rs @@ -0,0 +1,1255 @@ +use std::collections::HashSet; +use std::num::NonZeroU32; + +use crate::color_hex_utils::*; +use crate::utils::ColorUtils; + +use super::*; +use egui::epaint::{CubicBezierShape, RectShape}; +use egui::*; + +/// Mapping from parameter id to positions of hooks it contains. +/// +/// Outputs and short inputs always only have one hook, so the value is +/// just `vec![port_position]`. Wide inputs may have multiple hooks. +pub type PortLocations = std::collections::HashMap>; + +/// Destination positions of connections made to a given input. +/// +/// This is not equivalent to [`PortLocations`] because connections may be moved +/// around (e.g. while an in-progress connection is hovered over a wide port), +/// while hooks within a port are strictly a function of the port. +pub type ConnLocations = std::collections::HashMap>; + +/// Rectangle containing each node. +pub type NodeRects = std::collections::HashMap; + +const DISTANCE_TO_CONNECT: f32 = 20.0; + +/// Nodes communicate certain events to the parent graph when drawn. There is +/// one special `User` variant which can be used by users as the return value +/// when executing some custom actions in the UI of the node. +#[derive(Clone, Debug)] +pub enum NodeResponse { + ConnectEventStarted(NodeId, AnyParameterId), + ConnectEventEnded { + output: OutputId, + input: InputId, + /// Index of the connection in wide input ports. + /// + /// If the input isn't a wide port this is always 0 and may be ignored. + input_hook: usize, + }, + CreatedNode(NodeId), + SelectNode(NodeId), + /// As a user of this library, prefer listening for `DeleteNodeFull` which + /// will also contain the user data for the deleted node. + DeleteNodeUi(NodeId), + /// Emitted when a node is deleted. The node will no longer exist in the + /// graph after this response is returned from the draw function, but its + /// contents are passed along with the event. + DeleteNodeFull { + node_id: NodeId, + node: Node, + }, + DisconnectEvent { + output: OutputId, + input: InputId, + }, + /// Emitted when a node is interacted with, and should be raised + RaiseNode(NodeId), + MoveNode { + node: NodeId, + drag_delta: Vec2, + }, + /// Emitted when a node's title bar is double-clicked. + DoubleClick(NodeId), + User(UserResponse), +} + +/// The return value of [`draw_graph_editor`]. This value can be used to make +/// user code react to specific events that happened when drawing the graph. +#[derive(Clone, Debug)] +pub struct GraphResponse { + /// Events that occurred during this frame of rendering the graph. Check the + /// [`UserResponse`] type for a description of each event. + pub node_responses: Vec>, + /// Is the mouse currently hovering the graph editor? Note that the node + /// finder is considered part of the graph editor, even when it floats + /// outside the graph editor rect. + pub cursor_in_editor: bool, + /// Is the mouse currently hovering the node finder? + pub cursor_in_finder: bool, + /// Screen-space rects of all rendered nodes (for hit-testing) + pub node_rects: NodeRects, +} +impl Default + for GraphResponse +{ + fn default() -> Self { + Self { + node_responses: Default::default(), + cursor_in_editor: false, + cursor_in_finder: false, + node_rects: NodeRects::new(), + } + } +} +pub struct GraphNodeWidget<'a, NodeData, DataType, ValueType> { + pub position: &'a mut Pos2, + pub graph: &'a mut Graph, + pub port_locations: &'a mut PortLocations, + pub conn_locations: &'a mut ConnLocations, + pub node_rects: &'a mut NodeRects, + pub node_id: NodeId, + pub ongoing_drag: Option<(NodeId, AnyParameterId)>, + pub selected: bool, + pub pan: egui::Vec2, +} + +impl + GraphEditorState +where + NodeData: NodeDataTrait< + Response = UserResponse, + UserState = UserState, + DataType = DataType, + ValueType = ValueType, + >, + UserResponse: UserResponseTrait, + ValueType: + WidgetValueTrait, + NodeTemplate: NodeTemplateTrait< + NodeData = NodeData, + DataType = DataType, + ValueType = ValueType, + UserState = UserState, + CategoryType = CategoryType, + >, + DataType: DataTypeTrait, + CategoryType: CategoryTrait, +{ + #[must_use] + pub fn draw_graph_editor( + &mut self, + ui: &mut Ui, + all_kinds: impl NodeTemplateIter, + user_state: &mut UserState, + prepend_responses: Vec>, + ) -> GraphResponse { + ui.set_clip_rect(ui.max_rect()); + let clip_rect = ui.clip_rect(); + // Zoom may have never taken place, so ensure we use parent style + if !self.pan_zoom.started { + self.zoom(ui, 1.0); + self.pan_zoom.started = true; + } + + // Zoom only within area where graph is shown + if ui.rect_contains_pointer(clip_rect) { + let scroll_delta = ui.input(|i| i.smooth_scroll_delta.y); + if scroll_delta != 0.0 { + let zoom_delta = (scroll_delta * 0.002).exp(); + self.zoom(ui, zoom_delta); + } + } + + // Render graph zoomed + let zoomed_style = self.pan_zoom.zoomed_style.clone(); + let graph_response = show_zoomed(ui.style().clone(), zoomed_style, ui, |ui| { + self.draw_graph_editor_inside_zoom(ui, all_kinds, user_state, prepend_responses) + }); + + graph_response + } + + /// Reset zoom to 1.0 + pub fn reset_zoom(&mut self, ui: &Ui) { + let new_zoom = 1.0 / self.pan_zoom.zoom; + self.zoom(ui, new_zoom); + } + + /// Zoom within the where you call `draw_graph_editor`. Use values like 1.01, or 0.99 to zoom. + /// For example: `let zoom_delta = (scroll_delta * 0.002).exp();` + pub fn zoom(&mut self, ui: &Ui, zoom_delta: f32) { + // Update zoom, and styles + let zoom_before = self.pan_zoom.zoom; + self.pan_zoom.zoom(ui.clip_rect(), ui.style(), zoom_delta); + if zoom_before != self.pan_zoom.zoom { + let actual_delta = self.pan_zoom.zoom / zoom_before; + self.update_node_positions_after_zoom(actual_delta); + } + } + + fn update_node_positions_after_zoom(&mut self, zoom_delta: f32) { + // Update node positions, zoom towards center + let half_size = self.pan_zoom.clip_rect.size() / 2.0; + for (_id, node_pos) in self.node_positions.iter_mut() { + // 1. Get node local position (relative to origo) + let local_pos = node_pos.to_vec2() - half_size + self.pan_zoom.pan; + // 2. Scale local position by zoom delta + let scaled_local_pos = (local_pos * zoom_delta).to_pos2(); + // 3. Transform back to global position + *node_pos = scaled_local_pos + half_size - self.pan_zoom.pan; + // This way we can retain pan untouched when zooming :) + } + } + + fn draw_graph_editor_inside_zoom( + &mut self, + ui: &mut Ui, + all_kinds: impl NodeTemplateIter, + user_state: &mut UserState, + prepend_responses: Vec>, + ) -> GraphResponse { + // This causes the graph editor to use as much free space as it can. + // (so for windows it will use up to the resizeably set limit + // and for a Panel it will fill it completely) + let editor_rect = ui.max_rect(); + let resp = ui.allocate_rect(editor_rect, Sense::hover()); + + let cursor_pos = ui + .ctx() + .input(|i| i.pointer.hover_pos().unwrap_or(Pos2::ZERO)); + let mut cursor_in_editor = resp.contains_pointer(); + let mut cursor_in_finder = false; + + // Gets filled with the node metrics as they are drawn + let mut port_locations = PortLocations::new(); + let mut node_rects = NodeRects::new(); + + // actual dest location of each connection + let mut conn_locations = ConnLocations::default(); + + // The responses returned from node drawing have side effects that are best + // executed at the end of this function. + let mut delayed_responses: Vec> = prepend_responses; + + // Used to detect when the background was clicked + let mut click_on_background = false; + + // Used to detect drag events in the background + let mut drag_started_on_background = false; + let mut drag_released_on_background = false; + + debug_assert_eq!( + self.node_order.iter().copied().collect::>(), + self.graph.iter_nodes().collect::>(), + "The node_order field of the GraphEditorself was left in an \ + inconsistent self. It has either more or less values than the graph." + ); + + // Allocate rect before the nodes, otherwise this will block the interaction + // with the nodes. + let r = ui.allocate_rect(ui.min_rect(), Sense::click().union(Sense::drag())); + if r.clicked() { + click_on_background = true; + } else if r.drag_started() { + drag_started_on_background = true; + } else if r.drag_stopped() { + drag_released_on_background = true; + } + + /* Draw nodes */ + for node_id in self.node_order.iter().copied() { + let responses = GraphNodeWidget { + position: self.node_positions.get_mut(node_id).unwrap(), + graph: &mut self.graph, + port_locations: &mut port_locations, + conn_locations: &mut conn_locations, + node_rects: &mut node_rects, + node_id, + ongoing_drag: self.connection_in_progress, + selected: self + .selected_nodes + .iter() + .any(|selected| *selected == node_id), + pan: self.pan_zoom.pan + editor_rect.min.to_vec2(), + } + .show(&self.pan_zoom, ui, user_state); + + // Actions executed later + delayed_responses.extend(responses); + } + + /* Draw the node finder, if open */ + let mut should_close_node_finder = false; + if let Some(ref mut node_finder) = self.node_finder { + let mut node_finder_area = Area::new(Id::new("node_finder")).order(Order::Foreground); + if let Some(pos) = node_finder.position { + node_finder_area = node_finder_area.current_pos(pos); + } + node_finder_area.show(ui.ctx(), |ui| { + if let Some(node_kind) = node_finder.show(ui, all_kinds, user_state) { + let new_node = self.graph.add_node( + node_kind.node_graph_label(user_state), + node_kind.user_data(user_state), + |graph, node_id| node_kind.build_node(graph, user_state, node_id), + ); + self.node_positions.insert( + new_node, + node_finder.position.unwrap_or(cursor_pos) + - self.pan_zoom.pan + - editor_rect.min.to_vec2(), + ); + self.node_order.push(new_node); + + should_close_node_finder = true; + delayed_responses.push(NodeResponse::CreatedNode(new_node)); + } + let finder_rect = ui.min_rect(); + // If the cursor is not in the main editor, check if the cursor is in the finder + // if the cursor is in the finder, then we can consider that also in the editor. + if finder_rect.contains(cursor_pos) { + cursor_in_editor = true; + cursor_in_finder = true; + } + }); + } + if should_close_node_finder { + self.node_finder = None; + } + + // draw in-progress connections + if let Some((_, ref locator)) = self.connection_in_progress { + let port_type = self.graph.any_param_type(*locator).unwrap(); + let connection_color = port_type.data_type_color(user_state); + + // outputs can't be wide yet so this is fine. + let start_pos = *port_locations[locator].last().unwrap(); + + // Find a port to connect to + fn snap_to_ports< + NodeData, + UserState, + DataType: DataTypeTrait, + ValueType, + Key: slotmap::Key + Into, + Value, + >( + graph: &Graph, + port_type: &DataType, + ports: &SlotMap, + port_locations: &PortLocations, + cursor_pos: Pos2, + zoom: f32, + ) -> Pos2 { + let snap_distance = DISTANCE_TO_CONNECT * zoom; + ports + .iter() + .find_map(|(port_id, _)| { + let compatible_ports = graph + .any_param_type(port_id.into()) + .map(|other| other == port_type) + .unwrap_or(false); + + if compatible_ports { + port_locations.get(&port_id.into()).and_then(|hooks| { + hooks + .iter() + .min_by(|hook1, hook2| { + hook1 + .distance(cursor_pos) + .partial_cmp(&hook2.distance(cursor_pos)) + .unwrap() + }) + .filter(|nearest_hook| { + nearest_hook.distance(cursor_pos) < snap_distance + }) + .copied() + }) + } else { + None + } + }) + .unwrap_or(cursor_pos) + } + + let (src_pos, dst_pos) = match locator { + AnyParameterId::Output(_) => ( + start_pos, + snap_to_ports( + &self.graph, + port_type, + &self.graph.inputs, + &port_locations, + cursor_pos, + self.pan_zoom.zoom, + ), + ), + AnyParameterId::Input(_) => ( + snap_to_ports( + &self.graph, + port_type, + &self.graph.outputs, + &port_locations, + cursor_pos, + self.pan_zoom.zoom, + ), + start_pos, + ), + }; + draw_connection( + &self.pan_zoom, + ui.painter(), + src_pos, + dst_pos, + connection_color, + false, + ); + } + + // draw existing connections + for (input, outputs) in self.graph.iter_connection_groups() { + for (hook_n, &output) in outputs.iter().enumerate() { + let port_type = self + .graph + .any_param_type(AnyParameterId::Output(output)) + .unwrap(); + let connection_color = port_type.data_type_color(user_state); + let highlighted = + self.highlighted_connection == Some((input, output)); + // outputs can't be wide yet so this is fine. + let src_pos = port_locations[&AnyParameterId::Output(output)][0]; + let dst_pos = conn_locations[&input][hook_n]; + draw_connection( + &self.pan_zoom, + ui.painter(), + src_pos, + dst_pos, + connection_color, + highlighted, + ); + } + } + + /* Handle responses from drawing nodes */ + + // Some responses generate additional responses when processed. These + // are stored here to report them back to the user. + let mut extra_responses: Vec> = Vec::new(); + + for response in delayed_responses.iter() { + match response { + NodeResponse::ConnectEventStarted(node_id, port) => { + self.connection_in_progress = Some((*node_id, *port)); + } + NodeResponse::ConnectEventEnded { + output, + input, + input_hook, + } => self.graph.add_connection(*output, *input, *input_hook), + NodeResponse::CreatedNode(_) => { + //Convenience NodeResponse for users + } + NodeResponse::SelectNode(node_id) => { + self.selected_nodes = Vec::from([*node_id]); + } + NodeResponse::DeleteNodeUi(node_id) => { + let (node, disc_events) = self.graph.remove_node(*node_id); + + // Pass the disconnection responses first so user code can perform cleanup + // before node removal response. + extra_responses.extend( + disc_events + .into_iter() + .map(|(input, output)| NodeResponse::DisconnectEvent { input, output }), + ); + // Pass the full node as a response so library users can + // listen for it and get their user data. + extra_responses.push(NodeResponse::DeleteNodeFull { + node_id: *node_id, + node, + }); + self.node_positions.remove(*node_id); + // Make sure to not leave references to old nodes hanging + self.selected_nodes.retain(|id| *id != *node_id); + self.node_order.retain(|id| *id != *node_id); + } + NodeResponse::DisconnectEvent { input, output } => { + let other_node = self.graph.get_output(*output).node; + self.graph.remove_connection(*input, *output); + self.connection_in_progress = + Some((other_node, AnyParameterId::Output(*output))); + } + NodeResponse::RaiseNode(node_id) => { + let old_pos = self + .node_order + .iter() + .position(|id| *id == *node_id) + .expect("Node to be raised should be in `node_order`"); + self.node_order.remove(old_pos); + self.node_order.push(*node_id); + } + NodeResponse::MoveNode { node, drag_delta } => { + self.node_positions[*node] += *drag_delta; + // Handle multi-node selection movement + if self.selected_nodes.contains(node) && self.selected_nodes.len() > 1 { + for n in self.selected_nodes.iter().copied() { + if n != *node { + self.node_positions[n] += *drag_delta; + } + } + } + } + NodeResponse::DoubleClick(_) => { + // Handled by user code. + } + NodeResponse::User(_) => { + // These are handled by the user code. + } + NodeResponse::DeleteNodeFull { .. } => { + unreachable!("The UI should never produce a DeleteNodeFull event.") + } + } + } + + // Handle box selection + if let Some(box_start) = self.ongoing_box_selection { + let selection_rect = Rect::from_two_pos(cursor_pos, box_start); + let bg_color = Color32::from_rgba_unmultiplied(200, 200, 200, 20); + let stroke_color = Color32::from_rgba_unmultiplied(200, 200, 200, 180); + ui.painter().rect( + selection_rect, + 2.0, + bg_color, + Stroke::new(3.0, stroke_color), + StrokeKind::Middle, + ); + + self.selected_nodes = node_rects + .iter() + .filter_map(|(&node_id, &rect)| { + if selection_rect.intersects(rect) { + Some(node_id) + } else { + None + } + }) + .collect(); + } + + // Push any responses that were generated during response handling. + // These are only informative for the end-user and need no special + // treatment here. + delayed_responses.extend(extra_responses); + + /* Mouse input handling */ + + // This locks the context, so don't hold on to it for too long. + let mouse = &ui.ctx().input(|i| i.pointer.clone()); + + if mouse.any_released() && self.connection_in_progress.is_some() { + self.connection_in_progress = None; + } + + if mouse.secondary_released() && cursor_in_editor && !cursor_in_finder { + self.node_finder = Some(NodeFinder::new_at(cursor_pos)); + } + if ui.ctx().input(|i| i.key_pressed(Key::Escape)) { + self.node_finder = None; + } + + if r.dragged() + && ui + .ctx() + .input(|i| i.pointer.middle_down() || i.modifiers.command_only()) + { + self.pan_zoom.pan += ui.ctx().input(|i| i.pointer.delta()); + } + + // Deselect and deactivate finder if the editor background is clicked, + // *or* if the the mouse clicks off the ui + if click_on_background || (mouse.any_click() && !cursor_in_editor) { + self.selected_nodes = Vec::new(); + self.node_finder = None; + } + + if drag_started_on_background + && mouse.primary_down() + && !ui.ctx().input(|i| i.modifiers.command_only()) + { + self.ongoing_box_selection = Some(cursor_pos); + } + if mouse.primary_released() || drag_released_on_background { + self.ongoing_box_selection = None; + } + + GraphResponse { + node_responses: delayed_responses, + cursor_in_editor, + cursor_in_finder, + node_rects, + } + } +} + +fn draw_connection( + pan_zoom: &PanZoom, + painter: &Painter, + src_pos: Pos2, + dst_pos: Pos2, + color: Color32, + highlighted: bool, +) { + let (width, draw_color) = if highlighted { + (7.0 * pan_zoom.zoom, Color32::from_rgb(100, 220, 100)) + } else { + (5.0 * pan_zoom.zoom, color) + }; + let connection_stroke = egui::Stroke { + width, + color: draw_color, + }; + + let control_scale = ((dst_pos.x - src_pos.x) * pan_zoom.zoom / 2.0).max(30.0 * pan_zoom.zoom); + let src_control = src_pos + Vec2::X * control_scale; + let dst_control = dst_pos - Vec2::X * control_scale; + + let bezier = CubicBezierShape::from_points_stroke( + [src_pos, src_control, dst_control, dst_pos], + false, + Color32::TRANSPARENT, + connection_stroke, + ); + + painter.add(bezier); +} + +#[derive(Clone, Copy, Debug)] +struct OuterRectMemory(Rect); + +impl<'a, NodeData, DataType, ValueType, UserResponse, UserState> + GraphNodeWidget<'a, NodeData, DataType, ValueType> +where + NodeData: NodeDataTrait< + Response = UserResponse, + UserState = UserState, + DataType = DataType, + ValueType = ValueType, + >, + UserResponse: UserResponseTrait, + ValueType: + WidgetValueTrait, + DataType: DataTypeTrait, +{ + pub const MAX_NODE_SIZE: [f32; 2] = [200.0, 200.0]; + + pub fn show( + self, + pan_zoom: &PanZoom, + ui: &mut Ui, + user_state: &mut UserState, + ) -> Vec> { + let mut child_ui = ui.new_child( + egui::UiBuilder::new() + .max_rect(Rect::from_min_size(*self.position + self.pan, Self::MAX_NODE_SIZE.into())) + .layout(Layout::default()) + .id_salt(self.node_id), + ); + + Self::show_graph_node(self, pan_zoom, &mut child_ui, user_state) + } + + /// Draws this node. Also fills in the list of port locations with all of its ports. + /// Returns responses indicating multiple events. + fn show_graph_node( + self, + pan_zoom: &PanZoom, + ui: &mut Ui, + user_state: &mut UserState, + ) -> Vec> { + let margin = egui::vec2(15.0, 5.0) * pan_zoom.zoom; + let mut responses = Vec::>::new(); + + let background_color; + let text_color; + if ui.visuals().dark_mode { + background_color = color_from_hex("#3f3f3f").unwrap(); + text_color = color_from_hex("#fefefe").unwrap(); + } else { + background_color = color_from_hex("#ffffff").unwrap(); + text_color = color_from_hex("#505050").unwrap(); + } + + ui.visuals_mut().widgets.noninteractive.fg_stroke = + Stroke::new(2.0 * pan_zoom.zoom, text_color); + + // Preallocate shapes to paint below contents + let outline_shape = ui.painter().add(Shape::Noop); + let background_shape = ui.painter().add(Shape::Noop); + + let mut outer_rect_bounds = ui.available_rect_before_wrap(); + // Scale hack, otherwise some (larger) rects expand too much when zoomed out + outer_rect_bounds.max.x = + outer_rect_bounds.min.x + outer_rect_bounds.width() * pan_zoom.zoom; + + let mut inner_rect = outer_rect_bounds.shrink2(margin); + + // Make sure we don't shrink to the negative: + inner_rect.max.x = inner_rect.max.x.max(inner_rect.min.x); + inner_rect.max.y = inner_rect.max.y.max(inner_rect.min.y); + + let mut child_ui = ui.new_child( + egui::UiBuilder::new() + .max_rect(inner_rect) + .layout(*ui.layout()), + ); + + // Get interaction rect from memory, it may expand after the window response on resize. + let interaction_rect = ui + .ctx() + .memory_mut(|mem| { + mem.data + .get_temp::(child_ui.id()) + .map(|stored| stored.0) + }) + .unwrap_or(outer_rect_bounds); + // After 0.20, layers added over others can block hover interaction. Call this first + // before creating the node content. + let window_response = ui.interact( + interaction_rect, + Id::new((self.node_id, "window")), + Sense::click_and_drag(), + ); + + let mut title_height = 0.0; + + let mut input_port_heights = vec![]; + let mut output_port_heights = vec![]; + + child_ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.add(Label::new( + RichText::new(&self.graph[self.node_id].label) + .text_style(TextStyle::Button) + .color(text_color), + ).selectable(false)); + responses.extend(self.graph[self.node_id].user_data.top_bar_ui( + ui, + self.node_id, + self.graph, + user_state, + )); + ui.add_space(8.0 * pan_zoom.zoom); // The size of the little cross icon + }); + ui.add_space(margin.y); + title_height = ui.min_size().y; + + // First pass: Draw the inner fields. Compute port heights + let inputs = self.graph[self.node_id].inputs.clone(); + for (param_name, param_id) in inputs { + if self.graph[param_id].shown_inline { + let height_before = ui.min_rect().bottom(); + + if self.graph[param_id].max_connections == NonZeroU32::new(1) { + // NOTE: We want to pass the `user_data` to + // `value_widget`, but we can't since that would require + // borrowing the graph twice. Here, we make the + // assumption that the value is cheaply replaced, and + // use `std::mem::take` to temporarily replace it with a + // dummy value. This requires `ValueType` to implement + // Default, but results in a totally safe alternative. + let mut value = std::mem::take(&mut self.graph[param_id].value); + + if !self.graph.connections(param_id).is_empty() { + let node_responses = value.value_widget_connected( + ¶m_name, + self.node_id, + ui, + user_state, + &self.graph[self.node_id].user_data, + ); + + responses.extend(node_responses.into_iter().map(NodeResponse::User)); + } else { + let node_responses = value.value_widget( + ¶m_name, + self.node_id, + ui, + user_state, + &self.graph[self.node_id].user_data, + ); + + responses.extend(node_responses.into_iter().map(NodeResponse::User)); + } + + self.graph[param_id].value = value; + } else { + ui.label(param_name); + } + + let height_intermediate = ui.min_rect().bottom(); + + let max_connections = self.graph[param_id] + .max_connections + .map(NonZeroU32::get) + .unwrap_or(std::u32::MAX) + as usize; + let port_height = port_height( + max_connections != 1, + self.graph.connections(param_id).len(), + max_connections, + ); + let margin = 5.0; + let missing_space = + port_height - (height_intermediate - height_before) + margin; + if missing_space > 0.0 { + ui.add_space(missing_space); + } + + self.graph[self.node_id].user_data.separator( + ui, + self.node_id, + AnyParameterId::Input(param_id), + self.graph, + user_state, + ); + + let height_after = ui.min_rect().bottom(); + + input_port_heights.push((height_before + height_after) / 2.0); + } + } + + let outputs = self.graph[self.node_id].outputs.clone(); + for (param_name, param_id) in outputs { + let height_before = ui.min_rect().bottom(); + responses.extend( + self.graph[self.node_id] + .user_data + .output_ui(ui, self.node_id, self.graph, user_state, ¶m_name) + .into_iter(), + ); + + self.graph[self.node_id].user_data.separator( + ui, + self.node_id, + AnyParameterId::Output(param_id), + self.graph, + user_state, + ); + + let height_after = ui.min_rect().bottom(); + output_port_heights.push((height_before + height_after) / 2.0); + } + + responses.extend(self.graph[self.node_id].user_data.bottom_ui( + ui, + self.node_id, + self.graph, + user_state, + )); + }); + + // Second pass, iterate again to draw the ports. This happens outside + // the child_ui because we want ports to overflow the node background. + + let outer_rect = child_ui.min_rect().expand2(margin); + let port_left = outer_rect.left(); + let port_right = outer_rect.right(); + + // Save expanded rect to memory. + ui.ctx().memory_mut(|mem| { + mem.data + .insert_temp(child_ui.id(), OuterRectMemory(outer_rect)) + }); + + fn port_height(wide_port: bool, connections: usize, max_connections: usize) -> f32 { + let port_full = connections == max_connections; + if wide_port { + let hooks = connections + if port_full { 0 } else { 1 }; + + 5.0 + (10.0 * hooks as f32).max(10.0) + } else { + 10.0 + } + } + + #[allow(clippy::too_many_arguments)] + fn draw_port( + pan_zoom: &PanZoom, + ui: &mut Ui, + graph: &Graph, + node_id: NodeId, + user_state: &mut UserState, + port_pos: Pos2, + responses: &mut Vec>, + param_id: AnyParameterId, + port_locations: &mut PortLocations, + conn_locations: &mut ConnLocations, + ongoing_drag: Option<(NodeId, AnyParameterId)>, + wide_port: bool, + connections: usize, + max_connections: usize, + ) where + DataType: DataTypeTrait, + UserResponse: UserResponseTrait, + NodeData: NodeDataTrait, + { + let port_type = graph.any_param_type(param_id).unwrap(); + + let port_rect = Rect::from_center_size( + port_pos, + egui::vec2(DISTANCE_TO_CONNECT * 2.0, port_height(wide_port, connections, max_connections)) + * pan_zoom.zoom, + ); + + let port_full = connections == max_connections; + + let inner_ports = if wide_port { + connections + if port_full { 0 } else { 1 } + } else { + 1 + }; + + port_locations.insert( + param_id, + (0..inner_ports) + .map(|k| { + port_rect.center_top() + + Vec2::new(0.0, 5.0 * pan_zoom.zoom) + + Vec2::new(0.0, 10.0 * pan_zoom.zoom) * k as f32 + }) + .collect(), + ); + + let sense = if ongoing_drag.is_some() { + Sense::hover() + } else { + Sense::click_and_drag() + }; + + let resp = ui.allocate_rect(port_rect, sense); + + // Check if the mouse is within snap distance of the port center + // Uses circular distance to match snap_to_ports() behavior + let close_enough = if let Some(pointer_pos) = ui.ctx().pointer_hover_pos() { + port_pos.distance(pointer_pos) < DISTANCE_TO_CONNECT * pan_zoom.zoom + } else { + false + }; + + let port_color = if close_enough { + Color32::WHITE + } else { + port_type.data_type_color(user_state) + }; + + if wide_port { + ui.painter() + .rect_filled(port_rect, 5.0 * pan_zoom.zoom, port_color); + } else { + ui.painter().circle( + port_rect.center(), + 5.0 * pan_zoom.zoom, + port_color, + Stroke::NONE, + ); + } + + if connections > 0 { + if let AnyParameterId::Input(input) = param_id { + for (k, dst_pos) in port_locations[&AnyParameterId::Input(input)] + .iter() + .enumerate() + { + conn_locations.entry(input).or_default().insert(k, *dst_pos); + } + } + } + + let nearest_hook = ui + .input(|in_state| in_state.pointer.hover_pos()) + .and_then(|mouse_pos| match param_id { + AnyParameterId::Input(input) => Some((mouse_pos, input)), + AnyParameterId::Output(_) => None, + }) + .and_then(|(mouse_pos, input)| { + let hooks = 0..inner_ports; + hooks.min_by(|&hook1, &hook2| { + let out1_dist = conn_locations[&input][hook1].distance(mouse_pos); + let out2_dist = conn_locations[&input][hook2].distance(mouse_pos); + + out1_dist.partial_cmp(&out2_dist).unwrap() + }) + }); + + if resp.drag_started() { + match param_id { + AnyParameterId::Input(input) => { + match nearest_hook + .and_then(|hook| graph.connections(input).get(hook).copied()) + { + Some(output) => { + responses.push(NodeResponse::DisconnectEvent { input, output }); + } + None => { + responses + .push(NodeResponse::ConnectEventStarted(node_id, param_id)); + } + } + } + AnyParameterId::Output(_) => { + responses.push(NodeResponse::ConnectEventStarted(node_id, param_id)); + } + } + } + + if let Some((origin_node, origin_param)) = ongoing_drag { + if origin_node != node_id { + // Don't allow self-loops + if graph.any_param_type(origin_param).unwrap() == port_type && close_enough { + match (param_id, origin_param) { + (AnyParameterId::Input(input), AnyParameterId::Output(output)) + | (AnyParameterId::Output(output), AnyParameterId::Input(input)) => { + let input_hook = + nearest_hook.unwrap_or(graph.connections(input).len()); + + if ui.input(|i| i.pointer.any_released()) { + responses.push(NodeResponse::ConnectEventEnded { + output, + input, + input_hook, + }); + } else if wide_port && !port_full { + // move connections below the in-progress one to a lower position + for k in input_hook..graph.connections(input).len() { + conn_locations.get_mut(&input).unwrap()[k].y += 7.5; + } + } + } + _ => { /* Ignore in-in or out-out connections */ } + } + } + } + } + } + + // Input ports + for ((_, param), port_height) in self.graph[self.node_id] + .inputs + .iter() + .zip(input_port_heights.into_iter()) + { + let should_draw = match self.graph[*param].kind() { + InputParamKind::ConnectionOnly => true, + InputParamKind::ConstantOnly => false, + InputParamKind::ConnectionOrConstant => true, + }; + + if should_draw { + let pos_left = pos2(port_left, port_height); + let max_connections = self.graph[*param] + .max_connections + .map(NonZeroU32::get) + .unwrap_or(std::u32::MAX) as usize; + draw_port( + pan_zoom, + ui, + self.graph, + self.node_id, + user_state, + pos_left, + &mut responses, + AnyParameterId::Input(*param), + self.port_locations, + self.conn_locations, + self.ongoing_drag, + max_connections > 1, + self.graph.connections(*param).len(), + max_connections, + ); + } + } + + // Output ports + for ((_, param), port_height) in self.graph[self.node_id] + .outputs + .iter() + .zip(output_port_heights.into_iter()) + { + let pos_right = pos2(port_right, port_height); + draw_port( + pan_zoom, + ui, + self.graph, + self.node_id, + user_state, + pos_right, + &mut responses, + AnyParameterId::Output(*param), + self.port_locations, + self.conn_locations, + self.ongoing_drag, + false, + 0, + 1, + ); + } + + // Draw the background shape. + // NOTE: This code is a bit more involved than it needs to be because egui + // does not support drawing rectangles with asymmetrical round corners. + + let (shape, outline) = { + let rounding_radius = 4.0 * pan_zoom.zoom; + let rounding = CornerRadius::same(rounding_radius as u8); + + let titlebar_height = title_height + margin.y; + let titlebar_rect = + Rect::from_min_size(outer_rect.min, vec2(outer_rect.width(), titlebar_height)); + let titlebar = Shape::Rect(RectShape { + rect: titlebar_rect, + corner_radius: rounding, + fill: self.graph[self.node_id] + .user_data + .titlebar_color(ui, self.node_id, self.graph, user_state) + .unwrap_or_else(|| background_color.lighten(0.8)), + stroke: Stroke::NONE, + blur_width: 0.0, + round_to_pixels: None, + brush: None, + stroke_kind: StrokeKind::Inside, + }); + + let body_rect = Rect::from_min_size( + outer_rect.min + vec2(0.0, titlebar_height - rounding_radius), + vec2(outer_rect.width(), outer_rect.height() - titlebar_height), + ); + let body = Shape::Rect(RectShape { + rect: body_rect, + corner_radius: CornerRadius::ZERO, + fill: background_color, + stroke: Stroke::NONE, + blur_width: 0.0, + round_to_pixels: None, + brush: None, + stroke_kind: StrokeKind::Inside, + }); + + let bottom_body_rect = Rect::from_min_size( + body_rect.min + vec2(0.0, body_rect.height() - titlebar_height * 0.5), + vec2(outer_rect.width(), titlebar_height), + ); + let bottom_body = Shape::Rect(RectShape { + rect: bottom_body_rect, + corner_radius: rounding, + fill: background_color, + stroke: Stroke::NONE, + blur_width: 0.0, + round_to_pixels: None, + brush: None, + stroke_kind: StrokeKind::Inside, + }); + + let node_rect = titlebar_rect.union(body_rect).union(bottom_body_rect); + let outline = if self.selected { + Shape::Rect(RectShape { + rect: node_rect.expand(1.0 * pan_zoom.zoom), + corner_radius: rounding, + fill: Color32::WHITE.lighten(0.8), + stroke: Stroke::NONE, + blur_width: 0.0, + round_to_pixels: None, + brush: None, + stroke_kind: StrokeKind::Inside, + }) + } else { + Shape::Noop + }; + + // Take note of the node rect, so the editor can use it later to compute intersections. + self.node_rects.insert(self.node_id, node_rect); + + (Shape::Vec(vec![titlebar, body, bottom_body]), outline) + }; + + ui.painter().set(background_shape, shape); + ui.painter().set(outline_shape, outline); + + // --- Interaction --- + + // Titlebar buttons + let can_delete = self.graph.nodes[self.node_id].user_data.can_delete( + self.node_id, + self.graph, + user_state, + ); + + if can_delete && Self::close_button(pan_zoom, ui, outer_rect).clicked() { + responses.push(NodeResponse::DeleteNodeUi(self.node_id)); + }; + + // Movement + let drag_delta = window_response.drag_delta(); + if drag_delta.length_sq() > 0.0 { + responses.push(NodeResponse::MoveNode { + node: self.node_id, + drag_delta, + }); + responses.push(NodeResponse::RaiseNode(self.node_id)); + } + + // Node selection + // + // HACK: Only set the select response when no other response is active. + // This prevents some issues. + if responses.is_empty() && window_response.clicked_by(PointerButton::Primary) { + responses.push(NodeResponse::SelectNode(self.node_id)); + responses.push(NodeResponse::RaiseNode(self.node_id)); + } + + // Double-click detection (emitted alongside other responses) + if window_response.double_clicked() { + responses.push(NodeResponse::DoubleClick(self.node_id)); + } + + responses + } + + fn close_button(pan_zoom: &PanZoom, ui: &mut Ui, node_rect: Rect) -> Response { + // Measurements + let margin = 8.0 * pan_zoom.zoom; + let size = 10.0 * pan_zoom.zoom; + let stroke_width = 2.0; + let offs = margin + size / 2.0; + + let position = pos2(node_rect.right() - offs, node_rect.top() + offs); + let rect = Rect::from_center_size(position, vec2(size, size)); + let resp = ui.allocate_rect(rect, Sense::click()); + + let dark_mode = ui.visuals().dark_mode; + let color = if resp.clicked() { + if dark_mode { + color_from_hex("#ffffff").unwrap() + } else { + color_from_hex("#000000").unwrap() + } + } else if resp.hovered() { + if dark_mode { + color_from_hex("#dddddd").unwrap() + } else { + color_from_hex("#222222").unwrap() + } + } else { + #[allow(clippy::collapsible_else_if)] + if dark_mode { + color_from_hex("#aaaaaa").unwrap() + } else { + color_from_hex("#555555").unwrap() + } + }; + let stroke = Stroke { + width: stroke_width, + color, + }; + + ui.painter() + .line_segment([rect.left_top(), rect.right_bottom()], stroke); + ui.painter() + .line_segment([rect.right_top(), rect.left_bottom()], stroke); + + resp + } +} diff --git a/lightningbeam-ui/egui_node_graph2/src/error.rs b/lightningbeam-ui/egui_node_graph2/src/error.rs new file mode 100644 index 0000000..8033727 --- /dev/null +++ b/lightningbeam-ui/egui_node_graph2/src/error.rs @@ -0,0 +1,10 @@ +use super::*; + +#[derive(Debug, thiserror::Error)] +pub enum EguiGraphError { + #[error("Node {0:?} has no parameter named {1}")] + NoParameterNamed(NodeId, String), + + #[error("Parameter {0:?} was not found in the graph.")] + InvalidParameterId(AnyParameterId), +} diff --git a/lightningbeam-ui/egui_node_graph2/src/graph.rs b/lightningbeam-ui/egui_node_graph2/src/graph.rs new file mode 100644 index 0000000..85c142e --- /dev/null +++ b/lightningbeam-ui/egui_node_graph2/src/graph.rs @@ -0,0 +1,95 @@ +use std::num::NonZeroU32; + +use super::*; + +#[cfg(feature = "persistence")] +use serde::{Deserialize, Serialize}; + +/// A node inside the [`Graph`]. Nodes have input and output parameters, stored +/// as ids. They also contain a custom `NodeData` struct with whatever data the +/// user wants to store per-node. +#[derive(Debug, Clone)] +#[cfg_attr(feature = "persistence", derive(Serialize, Deserialize))] +pub struct Node { + pub id: NodeId, + pub label: String, + pub inputs: Vec<(String, InputId)>, + pub outputs: Vec<(String, OutputId)>, + pub user_data: NodeData, +} + +/// The three kinds of input params. These describe how the graph must behave +/// with respect to inline widgets and connections for this parameter. +#[derive(Debug, Clone, Copy)] +#[cfg_attr(feature = "persistence", derive(Serialize, Deserialize))] +pub enum InputParamKind { + /// No constant value can be set. Only incoming connections can produce it + ConnectionOnly, + /// Only a constant value can be set. No incoming connections accepted. + ConstantOnly, + /// Both incoming connections and constants are accepted. Connections take + /// precedence over the constant values. + ConnectionOrConstant, +} + +#[cfg(feature = "persistence")] +fn shown_inline_default() -> bool { + true +} + +/// An input parameter. Input parameters are inside a node, and represent data +/// that this node receives. Unlike their [`OutputParam`] counterparts, input +/// parameters also display an inline widget which allows setting its "value". +/// The `DataType` generic parameter is used to restrict the range of input +/// connections for this parameter, and the `ValueType` is use to represent the +/// data for the inline widget (i.e. constant) value. +#[derive(Debug, Clone)] +#[cfg_attr(feature = "persistence", derive(Serialize, Deserialize))] +pub struct InputParam { + pub id: InputId, + /// The data type of this node. Used to determine incoming connections. This + /// should always match the type of the InputParamValue, but the property is + /// not actually enforced. + pub typ: DataType, + /// The constant value stored in this parameter. + pub value: ValueType, + /// The input kind. See [`InputParamKind`] + pub kind: InputParamKind, + /// Back-reference to the node containing this parameter. + pub node: NodeId, + /// How many connections can be made with this input. `None` means no limit. + pub max_connections: Option, + /// When true, the node is shown inline inside the node graph. + #[cfg_attr(feature = "persistence", serde(default = "shown_inline_default"))] + pub shown_inline: bool, +} + +/// An output parameter. Output parameters are inside a node, and represent the +/// data that the node produces. Output parameters can be linked to the input +/// parameters of other nodes. Unlike an [`InputParam`], output parameters +/// cannot have a constant inline value. +#[derive(Debug, Clone)] +#[cfg_attr(feature = "persistence", derive(Serialize, Deserialize))] +pub struct OutputParam { + pub id: OutputId, + /// Back-reference to the node containing this parameter. + pub node: NodeId, + pub typ: DataType, +} + +/// The graph, containing nodes, input parameters and output parameters. Because +/// graphs are full of self-referential structures, this type uses the `slotmap` +/// crate to represent all the inner references in the data. +#[derive(Debug, Clone)] +#[cfg_attr(feature = "persistence", derive(Serialize, Deserialize))] +pub struct Graph { + /// The [`Node`]s of the graph + pub nodes: SlotMap>, + /// The [`InputParam`]s of the graph + pub inputs: SlotMap>, + /// The [`OutputParam`]s of the graph + pub outputs: SlotMap>, + // Connects the input of a node, to the output of its predecessor that + // produces it + pub connections: SecondaryMap>, +} diff --git a/lightningbeam-ui/egui_node_graph2/src/graph_impls.rs b/lightningbeam-ui/egui_node_graph2/src/graph_impls.rs new file mode 100644 index 0000000..9ab0e3e --- /dev/null +++ b/lightningbeam-ui/egui_node_graph2/src/graph_impls.rs @@ -0,0 +1,292 @@ +use std::num::NonZeroU32; + +use super::*; + +impl Graph { + pub fn new() -> Self { + Self { + nodes: SlotMap::default(), + inputs: SlotMap::default(), + outputs: SlotMap::default(), + connections: SecondaryMap::default(), + } + } + + pub fn add_node( + &mut self, + label: String, + user_data: NodeData, + f: impl FnOnce(&mut Graph, NodeId), + ) -> NodeId { + let node_id = self.nodes.insert_with_key(|node_id| { + Node { + id: node_id, + label, + // These get filled in later by the user function + inputs: Vec::default(), + outputs: Vec::default(), + user_data, + } + }); + + f(self, node_id); + + node_id + } + + #[allow(clippy::too_many_arguments)] + pub fn add_wide_input_param( + &mut self, + node_id: NodeId, + name: String, + typ: DataType, + value: ValueType, + kind: InputParamKind, + max_connections: Option, + shown_inline: bool, + ) -> InputId { + let input_id = self.inputs.insert_with_key(|input_id| InputParam { + id: input_id, + typ, + value, + kind, + node: node_id, + max_connections, + shown_inline, + }); + self.nodes[node_id].inputs.push((name, input_id)); + input_id + } + + pub fn add_input_param( + &mut self, + node_id: NodeId, + name: String, + typ: DataType, + value: ValueType, + kind: InputParamKind, + shown_inline: bool, + ) -> InputId { + self.add_wide_input_param( + node_id, + name, + typ, + value, + kind, + NonZeroU32::new(1), + shown_inline, + ) + } + + pub fn remove_input_param(&mut self, param: InputId) { + let node = self[param].node; + self[node].inputs.retain(|(_, id)| *id != param); + self.inputs.remove(param); + self.connections.retain(|i, _| i != param); + } + + pub fn remove_output_param(&mut self, param: OutputId) { + let node = self[param].node; + self[node].outputs.retain(|(_, id)| *id != param); + self.outputs.remove(param); + for (_, conns) in &mut self.connections { + conns.retain(|o| *o != param); + } + } + + pub fn add_output_param(&mut self, node_id: NodeId, name: String, typ: DataType) -> OutputId { + let output_id = self.outputs.insert_with_key(|output_id| OutputParam { + id: output_id, + node: node_id, + typ, + }); + self.nodes[node_id].outputs.push((name, output_id)); + output_id + } + + /// Removes a node from the graph with given `node_id`. This also removes + /// any incoming or outgoing connections from that node + /// + /// This function returns the list of connections that has been removed + /// after deleting this node as input-output pairs. Note that one of the two + /// ids in the pair (the one on `node_id`'s end) will be invalid after + /// calling this function. + pub fn remove_node(&mut self, node_id: NodeId) -> (Node, Vec<(InputId, OutputId)>) { + let mut disconnect_events = vec![]; + + for (i, conns) in &mut self.connections { + conns.retain(|o| { + if self.outputs[*o].node == node_id || self.inputs[i].node == node_id { + disconnect_events.push((i, *o)); + false + } else { + true + } + }); + } + + // NOTE: Collect is needed because we can't borrow the input ids while + // we remove them inside the loop. + for input in self[node_id].input_ids().collect::>() { + self.inputs.remove(input); + } + for output in self[node_id].output_ids().collect::>() { + self.outputs.remove(output); + } + let removed_node = self.nodes.remove(node_id).expect("Node should exist"); + + (removed_node, disconnect_events) + } + + pub fn remove_connection(&mut self, input_id: InputId, output_id: OutputId) -> bool { + self.connections + .get_mut(input_id) + .map(|conns| { + let old_size = conns.len(); + conns.retain(|id| id != &output_id); + + // connection removed if `conn` size changes + old_size != conns.len() + }) + .unwrap_or(false) + } + + pub fn iter_nodes(&self) -> impl Iterator + '_ { + self.nodes.iter().map(|(id, _)| id) + } + + pub fn add_connection(&mut self, output: OutputId, input: InputId, pos: usize) { + if !self.connections.contains_key(input) { + self.connections.insert(input, Vec::default()); + } + + let max_connections = self + .get_input(input) + .max_connections + .map(NonZeroU32::get) + .unwrap_or(std::u32::MAX) as usize; + let already_in = self.connections[input].contains(&output); + + // connecting twice to the same port is a no-op + // even for wide ports. + if already_in { + return; + } + + if self.connections[input].len() == max_connections { + // if full, replace the connected output + self.connections[input][pos] = output; + } else { + // otherwise, insert at a selected position + self.connections[input].insert(pos, output); + } + } + + pub fn iter_connection_groups(&self) -> impl Iterator)> + '_ { + self.connections.iter().map(|(i, conns)| (i, conns.clone())) + } + + pub fn iter_connections(&self) -> impl Iterator + '_ { + self.iter_connection_groups() + .flat_map(|(i, conns)| conns.into_iter().map(move |o| (i, o))) + } + + pub fn connections(&self, input: InputId) -> Vec { + self.connections.get(input).cloned().unwrap_or_default() + } + + pub fn connection(&self, input: InputId) -> Option { + let is_limit_1 = self.get_input(input).max_connections == NonZeroU32::new(1); + let connections = self.connections(input); + + if is_limit_1 && connections.len() == 1 { + connections.into_iter().next() + } else { + None + } + } + + pub fn any_param_type(&self, param: AnyParameterId) -> Result<&DataType, EguiGraphError> { + match param { + AnyParameterId::Input(input) => self.inputs.get(input).map(|x| &x.typ), + AnyParameterId::Output(output) => self.outputs.get(output).map(|x| &x.typ), + } + .ok_or(EguiGraphError::InvalidParameterId(param)) + } + + pub fn try_get_input(&self, input: InputId) -> Option<&InputParam> { + self.inputs.get(input) + } + + pub fn get_input(&self, input: InputId) -> &InputParam { + &self.inputs[input] + } + + pub fn try_get_output(&self, output: OutputId) -> Option<&OutputParam> { + self.outputs.get(output) + } + + pub fn get_output(&self, output: OutputId) -> &OutputParam { + &self.outputs[output] + } +} + +impl Default for Graph { + fn default() -> Self { + Self::new() + } +} + +impl Node { + pub fn inputs<'a, DataType, DataValue>( + &'a self, + graph: &'a Graph, + ) -> impl Iterator> + 'a { + self.input_ids().map(|id| graph.get_input(id)) + } + + pub fn outputs<'a, DataType, DataValue>( + &'a self, + graph: &'a Graph, + ) -> impl Iterator> + 'a { + self.output_ids().map(|id| graph.get_output(id)) + } + + pub fn input_ids(&self) -> impl Iterator + '_ { + self.inputs.iter().map(|(_name, id)| *id) + } + + pub fn output_ids(&self) -> impl Iterator + '_ { + self.outputs.iter().map(|(_name, id)| *id) + } + + pub fn get_input(&self, name: &str) -> Result { + self.inputs + .iter() + .find(|(param_name, _id)| param_name == name) + .map(|x| x.1) + .ok_or_else(|| EguiGraphError::NoParameterNamed(self.id, name.into())) + } + + pub fn get_output(&self, name: &str) -> Result { + self.outputs + .iter() + .find(|(param_name, _id)| param_name == name) + .map(|x| x.1) + .ok_or_else(|| EguiGraphError::NoParameterNamed(self.id, name.into())) + } +} + +impl InputParam { + pub fn value(&self) -> &ValueType { + &self.value + } + + pub fn kind(&self) -> InputParamKind { + self.kind + } + + pub fn node(&self) -> NodeId { + self.node + } +} diff --git a/lightningbeam-ui/egui_node_graph2/src/id_type.rs b/lightningbeam-ui/egui_node_graph2/src/id_type.rs new file mode 100644 index 0000000..5c272e1 --- /dev/null +++ b/lightningbeam-ui/egui_node_graph2/src/id_type.rs @@ -0,0 +1,37 @@ +slotmap::new_key_type! { pub struct NodeId; } +slotmap::new_key_type! { pub struct InputId; } +slotmap::new_key_type! { pub struct OutputId; } + +#[cfg_attr(feature = "persistence", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum AnyParameterId { + Input(InputId), + Output(OutputId), +} + +impl AnyParameterId { + pub fn assume_input(&self) -> InputId { + match self { + AnyParameterId::Input(input) => *input, + AnyParameterId::Output(output) => panic!("{:?} is not an InputId", output), + } + } + pub fn assume_output(&self) -> OutputId { + match self { + AnyParameterId::Output(output) => *output, + AnyParameterId::Input(input) => panic!("{:?} is not an OutputId", input), + } + } +} + +impl From for AnyParameterId { + fn from(output: OutputId) -> Self { + Self::Output(output) + } +} + +impl From for AnyParameterId { + fn from(input: InputId) -> Self { + Self::Input(input) + } +} diff --git a/lightningbeam-ui/egui_node_graph2/src/index_impls.rs b/lightningbeam-ui/egui_node_graph2/src/index_impls.rs new file mode 100644 index 0000000..f002330 --- /dev/null +++ b/lightningbeam-ui/egui_node_graph2/src/index_impls.rs @@ -0,0 +1,35 @@ +use super::*; + +macro_rules! impl_index_traits { + ($id_type:ty, $output_type:ty, $arena:ident) => { + impl std::ops::Index<$id_type> for Graph { + type Output = $output_type; + + fn index(&self, index: $id_type) -> &Self::Output { + self.$arena.get(index).unwrap_or_else(|| { + panic!( + "{} index error for {:?}. Has the value been deleted?", + stringify!($id_type), + index + ) + }) + } + } + + impl std::ops::IndexMut<$id_type> for Graph { + fn index_mut(&mut self, index: $id_type) -> &mut Self::Output { + self.$arena.get_mut(index).unwrap_or_else(|| { + panic!( + "{} index error for {:?}. Has the value been deleted?", + stringify!($id_type), + index + ) + }) + } + } + }; +} + +impl_index_traits!(NodeId, Node, nodes); +impl_index_traits!(InputId, InputParam, inputs); +impl_index_traits!(OutputId, OutputParam, outputs); diff --git a/lightningbeam-ui/egui_node_graph2/src/lib.rs b/lightningbeam-ui/egui_node_graph2/src/lib.rs new file mode 100644 index 0000000..a3d562e --- /dev/null +++ b/lightningbeam-ui/egui_node_graph2/src/lib.rs @@ -0,0 +1,47 @@ +#![forbid(unsafe_code)] + +use slotmap::{SecondaryMap, SlotMap}; + +pub type SVec = smallvec::SmallVec<[T; 4]>; + +/// Contains the main definitions for the node graph model. +pub mod graph; +pub use graph::*; + +/// Type declarations for the different id types (node, input, output) +pub mod id_type; +pub use id_type::*; + +/// Implements the index trait for the Graph type, allowing indexing by all +/// three id types +pub mod index_impls; + +/// Implementing the main methods for the `Graph` +pub mod graph_impls; + +/// Custom error types, crate-wide +pub mod error; +pub use error::*; + +/// The main struct in the library, contains all the necessary state to draw the +/// UI graph +pub mod ui_state; +pub use ui_state::*; + +/// The node finder is a tiny widget allowing to create new node types +pub mod node_finder; +pub use node_finder::*; + +/// The inner details of the egui implementation. Most egui code lives here. +pub mod editor_ui; +pub use editor_ui::*; + +/// Several traits that must be implemented by the user to customize the +/// behavior of this library. +pub mod traits; +pub use traits::*; + +mod utils; + +mod color_hex_utils; +mod scale; diff --git a/lightningbeam-ui/egui_node_graph2/src/node_finder.rs b/lightningbeam-ui/egui_node_graph2/src/node_finder.rs new file mode 100644 index 0000000..af0720d --- /dev/null +++ b/lightningbeam-ui/egui_node_graph2/src/node_finder.rs @@ -0,0 +1,153 @@ +use std::{collections::BTreeMap, marker::PhantomData}; + +use crate::{color_hex_utils::*, CategoryTrait, NodeTemplateIter, NodeTemplateTrait}; + +use egui::*; + +#[derive(Clone)] +#[cfg_attr(feature = "persistence", derive(serde::Serialize, serde::Deserialize))] +pub struct NodeFinder { + pub query: String, + /// Reset every frame. When set, the node finder will be moved at that position + pub position: Option, + pub just_spawned: bool, + _phantom: PhantomData, +} + +impl NodeFinder +where + NodeTemplate: + NodeTemplateTrait, + CategoryType: CategoryTrait, +{ + pub fn new_at(pos: Pos2) -> Self { + NodeFinder { + query: "".into(), + position: Some(pos), + just_spawned: true, + _phantom: Default::default(), + } + } + + /// Shows the node selector panel with a search bar. Returns whether a node + /// archetype was selected and, in that case, the finder should be hidden on + /// the next frame. + pub fn show( + &mut self, + ui: &mut Ui, + all_kinds: impl NodeTemplateIter, + user_state: &mut UserState, + ) -> Option { + let background_color; + let text_color; + + if ui.visuals().dark_mode { + background_color = color_from_hex("#3f3f3f").unwrap(); + text_color = color_from_hex("#fefefe").unwrap(); + } else { + background_color = color_from_hex("#fefefe").unwrap(); + text_color = color_from_hex("#3f3f3f").unwrap(); + } + + ui.visuals_mut().widgets.noninteractive.fg_stroke = Stroke::new(2.0, text_color); + + let frame = Frame::dark_canvas(ui.style()) + .fill(background_color) + .inner_margin(vec2(5.0, 5.0)); + + // The archetype that will be returned. + let mut submitted_archetype = None; + frame.show(ui, |ui| { + ui.vertical(|ui| { + let resp = ui.text_edit_singleline(&mut self.query); + if self.just_spawned { + resp.request_focus(); + self.just_spawned = false; + } + let update_open = resp.changed(); + + let mut query_submit = resp.lost_focus() && ui.input(|i| i.key_pressed(Key::Enter)); + + let max_height = ui.input(|i| i.content_rect().height() * 0.5); + let scroll_area_width = resp.rect.width() - 30.0; + + let all_kinds = all_kinds.all_kinds(); + let mut categories: BTreeMap> = Default::default(); + let mut orphan_kinds = Vec::new(); + + for kind in &all_kinds { + let kind_categories = kind.node_finder_categories(user_state); + + if kind_categories.is_empty() { + orphan_kinds.push(kind); + } else { + for category in kind_categories { + categories.entry(category.name()).or_default().push(kind); + } + } + } + + Frame::default() + .inner_margin(vec2(10.0, 10.0)) + .show(ui, |ui| { + ScrollArea::vertical() + .max_height(max_height) + .show(ui, |ui| { + ui.set_width(scroll_area_width); + ui.set_min_height(1000.); + for (category, kinds) in categories { + let mut filtered_kinds: Vec<_> = kinds + .into_iter() + .map(|kind| { + let kind_name = + kind.node_finder_label(user_state).to_string(); + (kind, kind_name) + }) + .filter(|(_kind, kind_name)| { + kind_name + .to_lowercase() + .contains(self.query.to_lowercase().as_str()) + }) + .collect(); + filtered_kinds.sort_by(|a, b| a.1.cmp(&b.1)); + + if !filtered_kinds.is_empty() { + let default_open = !self.query.is_empty(); + + CollapsingHeader::new(&category) + .default_open(default_open) + .open(update_open.then_some(default_open)) + .show(ui, |ui| { + for (kind, kind_name) in filtered_kinds { + if ui + .selectable_label(false, kind_name) + .clicked() + { + submitted_archetype = Some(kind.clone()); + } else if query_submit { + submitted_archetype = Some(kind.clone()); + query_submit = false; + } + } + }); + } + } + + for kind in orphan_kinds { + let kind_name = kind.node_finder_label(user_state).to_string(); + + if ui.selectable_label(false, kind_name).clicked() { + submitted_archetype = Some(kind.clone()); + } else if query_submit { + submitted_archetype = Some(kind.clone()); + query_submit = false; + } + } + }); + }); + }); + }); + + submitted_archetype + } +} diff --git a/lightningbeam-ui/egui_node_graph2/src/scale.rs b/lightningbeam-ui/egui_node_graph2/src/scale.rs new file mode 100644 index 0000000..2787690 --- /dev/null +++ b/lightningbeam-ui/egui_node_graph2/src/scale.rs @@ -0,0 +1,109 @@ +use egui::epaint::Shadow; +use egui::{style::WidgetVisuals, CornerRadius, Margin, Stroke, Style, Vec2}; + +// Copied from https://github.com/gzp-crey/shine + +pub trait Scale { + fn scale(&mut self, amount: f32); + + fn scaled(&self, amount: f32) -> Self + where + Self: Clone, + { + let mut scaled = self.clone(); + scaled.scale(amount); + scaled + } +} + +impl Scale for Vec2 { + fn scale(&mut self, amount: f32) { + self.x *= amount; + self.y *= amount; + } +} + +impl Scale for Margin { + fn scale(&mut self, amount: f32) { + self.left = (self.left as f32 * amount) as i8; + self.right = (self.right as f32 * amount) as i8; + self.top = (self.top as f32 * amount) as i8; + self.bottom = (self.bottom as f32 * amount) as i8; + } +} + +impl Scale for CornerRadius { + fn scale(&mut self, amount: f32) { + self.ne = (self.ne as f32 * amount) as u8; + self.nw = (self.nw as f32 * amount) as u8; + self.se = (self.se as f32 * amount) as u8; + self.sw = (self.sw as f32 * amount) as u8; + } +} + +impl Scale for Stroke { + fn scale(&mut self, amount: f32) { + self.width *= amount; + } +} + +impl Scale for Shadow { + fn scale(&mut self, amount: f32) { + self.spread = (self.spread as f32 * amount.clamp(0.4, 1.)) as u8; + } +} + +impl Scale for WidgetVisuals { + fn scale(&mut self, amount: f32) { + self.bg_stroke.scale(amount); + self.fg_stroke.scale(amount); + self.corner_radius.scale(amount); + self.expansion *= amount.clamp(0.4, 1.); + } +} + +impl Scale for Style { + fn scale(&mut self, amount: f32) { + if let Some(ov_font_id) = &mut self.override_font_id { + ov_font_id.size *= amount; + } + + for text_style in self.text_styles.values_mut() { + text_style.size *= amount; + } + + self.spacing.item_spacing.scale(amount); + self.spacing.window_margin.scale(amount); + self.spacing.button_padding.scale(amount); + self.spacing.indent *= amount; + self.spacing.interact_size.scale(amount); + self.spacing.slider_width *= amount; + self.spacing.text_edit_width *= amount; + self.spacing.icon_width *= amount; + self.spacing.icon_width_inner *= amount; + self.spacing.icon_spacing *= amount; + self.spacing.tooltip_width *= amount; + self.spacing.combo_height *= amount; + self.spacing.scroll.bar_width *= amount; + self.spacing.scroll.floating_allocated_width *= amount; + self.spacing.scroll.floating_width *= amount; + + self.interaction.resize_grab_radius_side *= amount; + self.interaction.resize_grab_radius_corner *= amount; + + self.visuals.widgets.noninteractive.scale(amount); + self.visuals.widgets.inactive.scale(amount); + self.visuals.widgets.hovered.scale(amount); + self.visuals.widgets.active.scale(amount); + self.visuals.widgets.open.scale(amount); + + self.visuals.selection.stroke.scale(amount); + + self.visuals.resize_corner_size *= amount; + self.visuals.text_cursor.stroke.width *= amount; + self.visuals.clip_rect_margin *= amount; + self.visuals.window_corner_radius.scale(amount); + self.visuals.window_shadow.scale(amount); + self.visuals.popup_shadow.scale(amount); + } +} diff --git a/lightningbeam-ui/egui_node_graph2/src/traits.rs b/lightningbeam-ui/egui_node_graph2/src/traits.rs new file mode 100644 index 0000000..0c40207 --- /dev/null +++ b/lightningbeam-ui/egui_node_graph2/src/traits.rs @@ -0,0 +1,284 @@ +use super::*; + +/// This trait must be implemented by the `ValueType` generic parameter of the +/// [`Graph`]. The trait allows drawing custom inline widgets for the different +/// types of the node graph. +/// +/// The [`Default`] trait bound is required to circumvent borrow checker issues +/// using `std::mem::take` Otherwise, it would be impossible to pass the +/// `node_data` parameter during `value_widget`. The default value is never +/// used, so the implementation is not important, but it should be reasonably +/// cheap to construct. +pub trait WidgetValueTrait: Default { + type Response; + type UserState; + type NodeData; + + /// This method will be called for each input parameter with a widget with an disconnected + /// input only. To display UI for connected inputs use [`WidgetValueTrait::value_widget_connected`]. + /// The return value is a vector of custom response objects which can be used + /// to implement handling of side effects. If unsure, the response Vec can + /// be empty. + fn value_widget( + &mut self, + param_name: &str, + node_id: NodeId, + ui: &mut egui::Ui, + user_state: &mut Self::UserState, + node_data: &Self::NodeData, + ) -> Vec; + + /// This method will be called for each input parameter with a widget with a connected + /// input only. To display UI for diconnected inputs use [`WidgetValueTrait::value_widget`]. + /// The return value is a vector of custom response objects which can be used + /// to implement handling of side effects. If unsure, the response Vec can + /// be empty. + /// + /// Shows the input name label by default. + fn value_widget_connected( + &mut self, + param_name: &str, + _node_id: NodeId, + ui: &mut egui::Ui, + _user_state: &mut Self::UserState, + _node_data: &Self::NodeData, + ) -> Vec { + ui.label(param_name); + + Default::default() + } +} + +/// This trait must be implemented by the `DataType` generic parameter of the +/// [`Graph`]. This trait tells the library how to visually expose data types +/// to the user. +pub trait DataTypeTrait: PartialEq + Eq { + /// The associated port color of this datatype + fn data_type_color(&self, user_state: &mut UserState) -> egui::Color32; + + /// The name of this datatype. Return type is specified as Cow because + /// some implementations will need to allocate a new string to provide an + /// answer while others won't. + /// + /// ## Example (borrowed value) + /// Use this when you can get the name of the datatype from its fields or as + /// a &'static str. Prefer this method when possible. + /// ```ignore + /// pub struct DataType { name: String } + /// + /// impl DataTypeTrait<()> for DataType { + /// fn name(&self) -> std::borrow::Cow { + /// Cow::Borrowed(&self.name) + /// } + /// } + /// ``` + /// + /// ## Example (owned value) + /// Use this when you can't derive the name of the datatype from its fields. + /// ```ignore + /// pub struct DataType { some_tag: i32 } + /// + /// impl DataTypeTrait<()> for DataType { + /// fn name(&self) -> std::borrow::Cow { + /// Cow::Owned(format!("Super amazing type #{}", self.some_tag)) + /// } + /// } + /// ``` + fn name(&self) -> std::borrow::Cow<'_, str>; +} + +/// This trait must be implemented for the `NodeData` generic parameter of the +/// [`Graph`]. This trait allows customizing some aspects of the node drawing. +pub trait NodeDataTrait +where + Self: Sized, +{ + /// Must be set to the custom user `NodeResponse` type + type Response; + /// Must be set to the custom user `UserState` type + type UserState; + /// Must be set to the custom user `DataType` type + type DataType; + /// Must be set to the custom user `ValueType` type + type ValueType; + + /// Additional UI elements to draw in the nodes, after the parameters. + fn bottom_ui( + &self, + ui: &mut egui::Ui, + node_id: NodeId, + graph: &Graph, + user_state: &mut Self::UserState, + ) -> Vec> + where + Self::Response: UserResponseTrait; + + /// UI to draw on the top bar of the node. + fn top_bar_ui( + &self, + _ui: &mut egui::Ui, + _node_id: NodeId, + _graph: &Graph, + _user_state: &mut Self::UserState, + ) -> Vec> + where + Self::Response: UserResponseTrait, + { + Default::default() + } + + /// UI to draw for each output + /// + /// Defaults to showing param_name as a simple label. + fn output_ui( + &self, + ui: &mut egui::Ui, + _node_id: NodeId, + _graph: &Graph, + _user_state: &mut Self::UserState, + param_name: &str, + ) -> Vec> + where + Self::Response: UserResponseTrait, + { + ui.label(param_name); + + Default::default() + } + + /// Set background color on titlebar + /// If the return value is None, the default color is set. + fn titlebar_color( + &self, + _ui: &egui::Ui, + _node_id: NodeId, + _graph: &Graph, + _user_state: &mut Self::UserState, + ) -> Option { + None + } + + /// Separator to put between elements in the node. + /// + /// Invoked between inputs, outputs and bottom UI. Useful for + /// complicated UIs that start to lose structure without explicit + /// separators. The `param_id` argument is the id of input or output + /// *preceeding* the separator. + /// + /// Default implementation does nothing. + fn separator( + &self, + _ui: &mut egui::Ui, + _node_id: NodeId, + _param_id: AnyParameterId, + _graph: &Graph, + _user_state: &mut Self::UserState, + ) { + } + + fn can_delete( + &self, + _node_id: NodeId, + _graph: &Graph, + _user_state: &mut Self::UserState, + ) -> bool { + true + } +} + +/// This trait can be implemented by any user type. The trait tells the library +/// how to enumerate the node templates it will present to the user as part of +/// the node finder. +pub trait NodeTemplateIter { + type Item; + fn all_kinds(&self) -> Vec; +} + +/// Describes a category of nodes. +/// +/// Used by [`NodeTemplateTrait::node_finder_categories`] to categorize nodes +/// templates into groups. +/// +/// If all nodes in a program are known beforehand, it's usefult to define +/// an enum containing all categories and implement [`CategoryTrait`] for it. This will +/// make it impossible to accidentally create a new category by mis-typing an existing +/// one, like in the case of using string types. +pub trait CategoryTrait { + /// Name of the category. + fn name(&self) -> String; +} + +impl CategoryTrait for () { + fn name(&self) -> String { + String::new() + } +} + +impl<'a> CategoryTrait for &'a str { + fn name(&self) -> String { + self.to_string() + } +} + +impl CategoryTrait for String { + fn name(&self) -> String { + self.clone() + } +} + +/// This trait must be implemented by the `NodeTemplate` generic parameter of +/// the [`GraphEditorState`]. It allows the customization of node templates. A +/// node template is what describes what kinds of nodes can be added to the +/// graph, what is their name, and what are their input / output parameters. +pub trait NodeTemplateTrait: Clone { + /// Must be set to the custom user `NodeData` type + type NodeData; + /// Must be set to the custom user `DataType` type + type DataType; + /// Must be set to the custom user `ValueType` type + type ValueType; + /// Must be set to the custom user `UserState` type + type UserState; + /// Must be a type that implements the [`CategoryTrait`] trait. + /// + /// `&'static str` is a good default if you intend to simply type out + /// the categories of your node. Use `()` if you don't need categories + /// at all. + type CategoryType; + + /// Returns a descriptive name for the node kind, used in the node finder. + /// + /// The return type is Cow to allow returning owned or borrowed values + /// more flexibly. Refer to the documentation for `DataTypeTrait::name` for + /// more information + fn node_finder_label(&self, user_state: &mut Self::UserState) -> std::borrow::Cow<'_, str>; + + /// Vec of categories to which the node belongs. + /// + /// It's often useful to organize similar nodes into categories, which will + /// then be used by the node finder to show a more manageable UI, especially + /// if the node template are numerous. + fn node_finder_categories(&self, _user_state: &mut Self::UserState) -> Vec { + Vec::default() + } + + /// Returns a descriptive name for the node kind, used in the graph. + fn node_graph_label(&self, user_state: &mut Self::UserState) -> String; + + /// Returns the user data for this node kind. + fn user_data(&self, user_state: &mut Self::UserState) -> Self::NodeData; + + /// This function is run when this node kind gets added to the graph. The + /// node will be empty by default, and this function can be used to fill its + /// parameters. + fn build_node( + &self, + graph: &mut Graph, + user_state: &mut Self::UserState, + node_id: NodeId, + ); +} + +/// The custom user response types when drawing nodes in the graph must +/// implement this trait. +pub trait UserResponseTrait: Clone + std::fmt::Debug {} diff --git a/lightningbeam-ui/egui_node_graph2/src/ui_state.rs b/lightningbeam-ui/egui_node_graph2/src/ui_state.rs new file mode 100644 index 0000000..17e1627 --- /dev/null +++ b/lightningbeam-ui/egui_node_graph2/src/ui_state.rs @@ -0,0 +1,134 @@ +use super::*; +use egui::{Rect, Style, Ui, Vec2}; +use std::marker::PhantomData; +use std::sync::Arc; + +use crate::scale::Scale; +#[cfg(feature = "persistence")] +use serde::{Deserialize, Serialize}; + +const MIN_ZOOM: f32 = 0.2; +const MAX_ZOOM: f32 = 2.0; + +#[derive(Clone)] +#[cfg_attr(feature = "persistence", derive(Serialize, Deserialize))] +pub struct GraphEditorState { + pub graph: Graph, + /// Nodes are drawn in this order. Draw order is important because nodes + /// that are drawn last are on top. + pub node_order: Vec, + /// An ongoing connection interaction: The mouse has dragged away from a + /// port and the user is holding the click + pub connection_in_progress: Option<(NodeId, AnyParameterId)>, + /// The currently selected node. Some interface actions depend on the + /// currently selected node. + pub selected_nodes: Vec, + /// The mouse drag start position for an ongoing box selection. + pub ongoing_box_selection: Option, + /// The position of each node. + pub node_positions: SecondaryMap, + /// The node finder is used to create new nodes. + pub node_finder: Option>, + /// The panning of the graph viewport. + pub pan_zoom: PanZoom, + /// A connection to highlight (e.g. as an insertion target during node drag). + /// Stored as (InputId, OutputId). Not serialized. + #[cfg_attr(feature = "persistence", serde(skip))] + pub highlighted_connection: Option<(InputId, OutputId)>, + pub _user_state: PhantomData UserState>, +} + +impl + GraphEditorState +{ + pub fn new(default_zoom: f32) -> Self { + Self { + pan_zoom: PanZoom::new(default_zoom), + ..Default::default() + } + } +} +impl Default + for GraphEditorState +{ + fn default() -> Self { + Self { + graph: Default::default(), + node_order: Default::default(), + connection_in_progress: Default::default(), + selected_nodes: Default::default(), + ongoing_box_selection: Default::default(), + node_positions: Default::default(), + node_finder: Default::default(), + pan_zoom: Default::default(), + highlighted_connection: Default::default(), + _user_state: Default::default(), + } + } +} + +#[cfg(feature = "persistence")] +fn _default_clip_rect() -> Rect { + Rect::NOTHING +} + +#[derive(Clone)] +#[cfg_attr(feature = "persistence", derive(Serialize, Deserialize))] +pub struct PanZoom { + pub pan: Vec2, + pub zoom: f32, + #[cfg_attr(feature = "persistence", serde(skip, default = "_default_clip_rect"))] + pub clip_rect: Rect, + #[cfg_attr(feature = "persistence", serde(skip, default))] + pub zoomed_style: Arc +

Export Audio

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ `; + + document.body.appendChild(dialog); + + return new Promise((resolve) => { + dialog.querySelector('#export-cancel').addEventListener('click', () => { + document.body.removeChild(dialog); + resolve(null); + }); + + dialog.querySelector('#export-ok').addEventListener('click', async () => { + const format = dialog.querySelector('#export-format').value; + const sampleRate = parseInt(dialog.querySelector('#export-sample-rate').value); + const bitDepth = parseInt(dialog.querySelector('#export-bit-depth').value); + const endTime = parseFloat(dialog.querySelector('#export-end-time').value); + + document.body.removeChild(dialog); + + // Show file save dialog + const path = await saveFileDialog({ + filters: [ + { + name: format.toUpperCase() + " files", + extensions: [format], + }, + ], + defaultPath: await join(await documentDir(), `export.${format}`), + }); + + if (path) { + try { + document.querySelector("body").style.cursor = "wait"; + + await invoke('audio_export', { + outputPath: path, + format: format, + sampleRate: sampleRate, + channels: 2, + bitDepth: bitDepth, + mp3Bitrate: 320, + startTime: 0.0, + endTime: endTime, + }); + + document.querySelector("body").style.cursor = "default"; + alert('Audio exported successfully!'); + } catch (error) { + document.querySelector("body").style.cursor = "default"; + console.error('Export failed:', error); + alert('Export failed: ' + error); + } + } + + resolve(); + }); + }); +} + function updateScrollPosition(zoomFactor) { if (context.mousePos) { for (let canvas of canvases) { @@ -5362,15 +3488,35 @@ function stage() { stage.addEventListener("wheel", (event) => { event.preventDefault(); - const deltaX = event.deltaX * config.scrollSpeed; - const deltaY = event.deltaY * config.scrollSpeed; - stage.offsetX += deltaX; - stage.offsetY += deltaY; - const currentTime = Date.now(); - if (currentTime - lastResizeTime > throttleIntervalMs) { - lastResizeTime = currentTime; + // Check if this is a pinch-zoom gesture (ctrlKey is set on trackpad pinch) + if (event.ctrlKey) { + // Pinch zoom - zoom in/out based on deltaY + const zoomFactor = event.deltaY > 0 ? 0.95 : 1.05; + const oldZoom = context.zoomLevel; + context.zoomLevel = Math.max(1/8, Math.min(8, context.zoomLevel * zoomFactor)); + + // Update scroll position to zoom towards mouse + if (context.mousePos) { + const actualZoomFactor = context.zoomLevel / oldZoom; + stage.offsetX = (stage.offsetX + context.mousePos.x) * actualZoomFactor - context.mousePos.x; + stage.offsetY = (stage.offsetY + context.mousePos.y) * actualZoomFactor - context.mousePos.y; + } + updateUI(); + updateMenu(); + } else { + // Regular scroll + const deltaX = event.deltaX * config.scrollSpeed; + const deltaY = event.deltaY * config.scrollSpeed; + + stage.offsetX += deltaX; + stage.offsetY += deltaY; + const currentTime = Date.now(); + if (currentTime - lastResizeTime > throttleIntervalMs) { + lastResizeTime = currentTime; + updateUI(); + } } }); // scroller.className = "scroll" @@ -5444,7 +3590,7 @@ function stage() { "image/jpeg", "image/webp", //'image/svg+xml' // Disabling SVG until we can export them nicely ]; - const audioTypes = ["audio/mpeg"]; // TODO: figure out what other audio formats Tone.js accepts + const audioTypes = ["audio/mpeg"]; if (e.dataTransfer.items) { let i = 0; for (let item of e.dataTransfer.items) { @@ -5500,12 +3646,13 @@ function stage() { // stageWrapper.appendChild(selectionRect) // scroller.appendChild(stageWrapper) stage.addEventListener("pointerdown", (e) => { + console.log("POINTERDOWN EVENT - context.mode:", context.mode); let mouse = getMousePos(stage, e); + console.log("Mouse position:", mouse); root.handleMouseEvent("mousedown", mouse.x, mouse.y) mouse = context.activeObject.transformMouse(mouse); let selection; - if (!context.activeObject.currentFrame?.exists) return; - switch (mode) { + switch (context.mode) { case "rectangle": case "ellipse": case "draw": @@ -5514,7 +3661,7 @@ function stage() { // context.lastMouse = mouse; break; case "select": - if (context.activeObject.currentFrame.frameType != "keyframe") break; + // No longer need keyframe check with AnimationData system selection = selectVertex(context, mouse); if (selection) { context.dragging = true; @@ -5553,8 +3700,12 @@ function stage() { if (hitTest(mouse, child)) { context.dragging = true; context.lastMouse = mouse; - context.activeAction = actions.editFrame.initialize( - context.activeObject.currentFrame, + const layer = context.activeObject.activeLayer; + const time = context.activeObject.currentTime || 0; + context.activeAction = actions.moveObjects.initialize( + context.selection, + layer, + time, ); break; } @@ -5568,8 +3719,15 @@ function stage() { i-- ) { child = context.activeObject.activeLayer.children[i]; - if (!(child.idx in context.activeObject.currentFrame.keys)) - continue; + + // Check if child exists using AnimationData curves + let currentTime = context.activeObject.currentTime || 0; + let childX = context.activeObject.activeLayer.animationData.interpolate(`child.${child.idx}.x`, currentTime); + let childY = context.activeObject.activeLayer.animationData.interpolate(`child.${child.idx}.y`, currentTime); + + // Skip if child doesn't have position data at current time + if (childX === null || childY === null) continue; + // let bbox = child.bbox() if (hitTest(mouse, child)) { if (context.selection.indexOf(child) != -1) { @@ -5686,94 +3844,7 @@ function stage() { } break; case "paint_bucket": - let line = { p1: mouse, p2: { x: mouse.x + 3000, y: mouse.y } }; - // debugCurves = []; - // debugPoints = []; - // let epsilon = context.fillGaps; - // let min_x = Infinity; - // let curveB = undefined; - // let point = undefined; - // let regionPoints; - - // // First, see if there's an existing shape to change the color of - // const startTime = performance.now(); - // let pointShape = getShapeAtPoint( - // mouse, - // context.activeObject.currentFrame.shapes, - // ); - // const endTime = performance.now(); - - // console.log( - // `getShapeAtPoint took ${endTime - startTime} milliseconds.`, - // ); - - // if (pointShape) { - // actions.colorShape.create(pointShape, context.fillStyle); - // break; - // } - - // // We didn't find an existing region to paintbucket, see if we can make one - // const offset = context.activeObject.transformMouse({x:0, y:0}) - // try { - // regionPoints = floodFillRegion( - // mouse, - // epsilon, - // offset, - // config.fileWidth, - // config.fileHeight, - // context, - // debugPoints, - // debugPaintbucket, - // ); - // } catch (e) { - // updateUI(); - // throw e; - // } - // if (regionPoints.length > 0 && regionPoints.length < 10) { - // // probably a very small area, rerun with minimum epsilon - // regionPoints = floodFillRegion( - // mouse, - // 1, - // offset, - // config.fileWidth, - // config.fileHeight, - // context, - // debugPoints, - // ); - // } - // let points = []; - // for (let point of regionPoints) { - // points.push([point.x, point.y]); - // } - // let cxt = { - // ...context, - // fillShape: true, - // strokeShape: false, - // sendToBack: true, - // }; - // let shape = new Shape(regionPoints[0].x, regionPoints[0].y, cxt); - // shape.fromPoints(points, 1); - // actions.addShape.create(context.activeObject, shape, cxt); - break; - // Loop labels in JS! - // Iterate in reverse so we paintbucket the frontmost shape - shapeLoop: for ( - let i = context.activeObject.currentFrame.shapes.length - 1; - i >= 0; - i-- - ) { - let shape = context.activeObject.currentFrame.shapes[i]; - // for (let region of shape.regions) { - let intersect_count = 0; - for (let curve of shape.curves) { - intersect_count += curve.intersects(line).length; - } - if (intersect_count % 2 == 1) { - actions.colorShape.create(shape, context.fillStyle); - break shapeLoop; - } - // } - } + // Paint bucket is now handled in Layer.mousedown (line ~3458) break; case "eyedropper": const ctx = stage.getContext("2d") @@ -5802,11 +3873,10 @@ function stage() { context.dragging = false; context.dragDirection = undefined; context.selectionRect = undefined; - if (!context.activeObject.currentFrame?.exists) return; let mouse = getMousePos(stage, e); root.handleMouseEvent("mouseup", mouse.x, mouse.y) mouse = context.activeObject.transformMouse(mouse); - switch (mode) { + switch (context.mode) { case "draw": // if (context.activeShape) { // context.activeShape.addLine(mouse.x, mouse.y); @@ -5848,6 +3918,15 @@ function stage() { } } actions.editShape.create(context.activeCurve.shape, newCurves); + // Add the shape to selection after editing + if (e.shiftKey) { + if (!context.shapeselection.includes(context.activeCurve.shape)) { + context.shapeselection.push(context.activeCurve.shape); + } + } else { + context.shapeselection = [context.activeCurve.shape]; + } + actions.select.create(); } else if (context.selection.length) { actions.select.create(); // actions.editFrame.create(context.activeObject.currentFrame) @@ -5890,8 +3969,7 @@ function stage() { stage.mouseup(e); return; } - if (!context.activeObject.currentFrame?.exists) return; - switch (mode) { + switch (context.mode) { case "draw": stage.style.cursor = "default"; context.activeCurve = undefined; @@ -6016,14 +4094,36 @@ function stage() { context.activeCurve.startmouse, ).points; } else { + // TODO: Add user preference for keyframing behavior: + // - Auto-keyframe (current): 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 + // Could also add modifier key (e.g. Shift) to toggle between modes + + // Move selected children (groups) using AnimationData with auto-keyframing for (let child of context.selection) { - if (!context.activeObject.currentFrame) continue; - if (!context.activeObject.currentFrame.keys) continue; - if (!(child.idx in context.activeObject.currentFrame.keys)) continue; - context.activeObject.currentFrame.keys[child.idx].x += - mouse.x - context.lastMouse.x; - context.activeObject.currentFrame.keys[child.idx].y += - mouse.y - context.lastMouse.y; + let currentTime = context.activeObject.currentTime || 0; + let layer = context.activeObject.activeLayer; + + // Get current position from AnimationData + let childX = layer.animationData.interpolate(`child.${child.idx}.x`, currentTime); + let childY = layer.animationData.interpolate(`child.${child.idx}.y`, currentTime); + + // Skip if child doesn't have position data + if (childX === null || childY === null) continue; + + // Update position + let newX = childX + (mouse.x - context.lastMouse.x); + let newY = childY + (mouse.y - context.lastMouse.y); + + // Auto-keyframe: create/update keyframe at current time + layer.animationData.addKeyframe(`child.${child.idx}.x`, new Keyframe(currentTime, newX, 'linear')); + layer.animationData.addKeyframe(`child.${child.idx}.y`, new Keyframe(currentTime, newY, 'linear')); + + // Trigger timeline redraw + if (context.timelineWidget && context.timelineWidget.requestRedraw) { + context.timelineWidget.requestRedraw(); + } } } } else if (context.selectionRect) { @@ -6036,9 +4136,14 @@ function stage() { context.selection.push(child); } } - for (let shape of context.activeObject.currentFrame.shapes) { - if (hitTest(regionToBbox(context.selectionRect), shape)) { - context.shapeselection.push(shape); + // Use getVisibleShapes instead of currentFrame.shapes + let currentTime = context.activeObject?.currentTime || 0; + let layer = context.activeObject?.activeLayer; + if (layer) { + for (let shape of layer.getVisibleShapes(currentTime)) { + if (hitTest(regionToBbox(context.selectionRect), shape)) { + context.shapeselection.push(shape); + } } } } else { @@ -6166,11 +4271,17 @@ function stage() { context.selectionRect = undefined; let mouse = getMousePos(stage, e); mouse = context.activeObject.transformMouse(mouse); - modeswitcher: switch (mode) { + modeswitcher: switch (context.mode) { case "select": for (let i = context.activeObject.activeLayer.children.length - 1; i >= 0; i--) { let child = context.activeObject.activeLayer.children[i]; - if (!(child.idx in context.activeObject.currentFrame.keys)) continue; + // Check if child exists at current time using AnimationData + // null means no exists curve (defaults to visible) + const existsValue = context.activeObject.activeLayer.animationData.interpolate( + `object.${child.idx}.exists`, + context.activeObject.currentTime + ); + if (existsValue !== null && existsValue <= 0) continue; if (hitTest(mouse, child)) { context.objectStack.push(child); context.selection = []; @@ -6185,7 +4296,7 @@ function stage() { // we didn't click on a child, go up a level if (context.activeObject.parent) { context.selection = [context.activeObject]; - context.activeObject.setFrameNum(0); + context.activeObject.setTime(0); context.shapeselection = []; context.objectStack.pop(); updateUI(); @@ -6207,13 +4318,14 @@ function toolbar() { for (let tool in tools) { let toolbtn = document.createElement("button"); toolbtn.className = "toolbtn"; + toolbtn.setAttribute("data-tool", tool); // For UI testing let icon = document.createElement("img"); icon.className = "icon"; icon.src = tools[tool].icon; toolbtn.appendChild(icon); tools_scroller.appendChild(toolbtn); toolbtn.addEventListener("click", () => { - mode = tool; + context.mode = tool; updateInfopanel(); updateUI(); console.log(`Switched tool to ${tool}`); @@ -6374,9 +4486,9 @@ function toolbar() { return tools_scroller; } -function timeline() { +function timelineDeprecated() { let timeline_cvs = document.createElement("canvas"); - timeline_cvs.className = "timeline"; + timeline_cvs.className = "timeline-deprecated"; // Start building widget hierarchy timeline_cvs.timelinewindow = new TimelineWindow(0, 0, context) @@ -6429,7 +4541,7 @@ function timeline() { let maxScroll = context.activeObject.layers.length * layerHeight + - context.activeObject.audioLayers.length * layerHeight + + context.activeObject.audioTracks.length * layerHeight + gutterHeight - timeline_cvs.height; @@ -6574,7 +4686,7 @@ function timeline() { updateUI(); updateMenu(); } else { - context.activeObject.currentLayer = i - context.activeObject.audioLayers.length; + context.activeObject.currentLayer = i - context.activeObject.audioTracks.length; } } } @@ -6644,6 +4756,571 @@ function timeline() { return timeline_cvs; } +function timeline() { + let canvas = document.createElement("canvas"); + canvas.className = "timeline"; + + // Create TimelineWindowV2 widget + const timelineWidget = new TimelineWindowV2(0, 0, context); + + // Store reference in context for zoom controls + context.timelineWidget = timelineWidget; + + // Update canvas size based on container + function updateCanvasSize() { + const canvasStyles = window.getComputedStyle(canvas); + canvas.width = parseInt(canvasStyles.width); + canvas.height = parseInt(canvasStyles.height); + + // Update widget dimensions + timelineWidget.width = canvas.width; + timelineWidget.height = canvas.height; + + // Render + const ctx = canvas.getContext("2d"); + ctx.clearRect(0, 0, canvas.width, canvas.height); + timelineWidget.draw(ctx); + } + + // Store updateCanvasSize on the widget so zoom controls can trigger redraw + timelineWidget.requestRedraw = updateCanvasSize; + + // Add custom property to store the time format toggle button + // so createPane can add it to the header + canvas.headerControls = () => { + const controls = []; + + // Playback controls group + const playbackGroup = document.createElement("div"); + playbackGroup.className = "playback-controls-group"; + + // Go to start button + const startButton = document.createElement("button"); + startButton.className = "playback-btn playback-btn-start"; + startButton.title = "Go to Start"; + startButton.addEventListener("click", goToStart); + playbackGroup.appendChild(startButton); + + // Rewind button + const rewindButton = document.createElement("button"); + rewindButton.className = "playback-btn playback-btn-rewind"; + rewindButton.title = "Rewind"; + rewindButton.addEventListener("click", rewind); + playbackGroup.appendChild(rewindButton); + + // Play/Pause button + const playPauseButton = document.createElement("button"); + playPauseButton.className = context.playing ? "playback-btn playback-btn-pause" : "playback-btn playback-btn-play"; + playPauseButton.title = context.playing ? "Pause" : "Play"; + playPauseButton.addEventListener("click", playPause); + + // Store reference so playPause() can update it + context.playPauseButton = playPauseButton; + + playbackGroup.appendChild(playPauseButton); + + // Fast-forward button + const ffButton = document.createElement("button"); + ffButton.className = "playback-btn playback-btn-ff"; + ffButton.title = "Fast Forward"; + ffButton.addEventListener("click", advance); + playbackGroup.appendChild(ffButton); + + // Go to end button + const endButton = document.createElement("button"); + endButton.className = "playback-btn playback-btn-end"; + endButton.title = "Go to End"; + endButton.addEventListener("click", goToEnd); + playbackGroup.appendChild(endButton); + + controls.push(playbackGroup); + + // Record button (separate group) + const recordGroup = document.createElement("div"); + recordGroup.className = "playback-controls-group"; + + const recordButton = document.createElement("button"); + recordButton.className = context.isRecording ? "playback-btn playback-btn-record recording" : "playback-btn playback-btn-record"; + recordButton.title = context.isRecording ? "Stop Recording" : "Record"; + recordButton.addEventListener("click", toggleRecording); + recordGroup.appendChild(recordButton); + + controls.push(recordGroup); + + // Metronome button (only visible in measures mode) + const metronomeGroup = document.createElement("div"); + metronomeGroup.className = "playback-controls-group"; + + // Initially hide if not in measures mode + if (timelineWidget.timelineState.timeFormat !== 'measures') { + metronomeGroup.style.display = 'none'; + } + + const metronomeButton = document.createElement("button"); + metronomeButton.className = context.metronomeEnabled + ? "playback-btn playback-btn-metronome active" + : "playback-btn playback-btn-metronome"; + metronomeButton.title = context.metronomeEnabled ? "Disable Metronome" : "Enable Metronome"; + + // Load SVG inline for currentColor support + (async () => { + try { + const response = await fetch('./assets/metronome.svg'); + const svgText = await response.text(); + metronomeButton.innerHTML = svgText; + } catch (error) { + console.error('Failed to load metronome icon:', error); + } + })(); + + metronomeButton.addEventListener("click", async () => { + context.metronomeEnabled = !context.metronomeEnabled; + const { invoke } = window.__TAURI__.core; + try { + await invoke('set_metronome_enabled', { enabled: context.metronomeEnabled }); + // Update button appearance + metronomeButton.className = context.metronomeEnabled + ? "playback-btn playback-btn-metronome active" + : "playback-btn playback-btn-metronome"; + metronomeButton.title = context.metronomeEnabled ? "Disable Metronome" : "Enable Metronome"; + } catch (error) { + console.error('Failed to set metronome:', error); + } + }); + metronomeGroup.appendChild(metronomeButton); + + // Store reference for state updates and visibility toggling + context.metronomeButton = metronomeButton; + context.metronomeGroup = metronomeGroup; + + controls.push(metronomeGroup); + + // Time display + const timeDisplay = document.createElement("div"); + timeDisplay.className = "time-display"; + timeDisplay.style.cursor = "pointer"; + timeDisplay.title = "Click to change time format"; + + // Function to update time display + const updateTimeDisplay = () => { + const currentTime = context.activeObject?.currentTime || 0; + const timeFormat = timelineWidget.timelineState.timeFormat; + const framerate = timelineWidget.timelineState.framerate; + const bpm = timelineWidget.timelineState.bpm; + const timeSignature = timelineWidget.timelineState.timeSignature; + + if (timeFormat === 'frames') { + // Frames mode: show frame number and framerate + const frameNumber = Math.floor(currentTime * framerate); + + timeDisplay.innerHTML = ` +
${frameNumber}
+
FRAME
+
+
${framerate}
+
FPS
+
+ `; + } else if (timeFormat === 'measures') { + // Measures mode: show measure.beat, BPM, and time signature + const { measure, beat } = timelineWidget.timelineState.timeToMeasure(currentTime); + + timeDisplay.innerHTML = ` +
${measure}.${beat}
+
BAR
+
+
${bpm}
+
BPM
+
+
+
${timeSignature.numerator}/${timeSignature.denominator}
+
TIME
+
+ `; + } else { + // Seconds mode: show MM:SS.mmm or HH:MM:SS.mmm + const totalSeconds = Math.floor(currentTime); + const milliseconds = Math.floor((currentTime - totalSeconds) * 1000); + const seconds = totalSeconds % 60; + const minutes = Math.floor(totalSeconds / 60) % 60; + const hours = Math.floor(totalSeconds / 3600); + + if (hours > 0) { + timeDisplay.innerHTML = ` +
${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(milliseconds).padStart(3, '0')}
+
SEC
+ `; + } else { + timeDisplay.innerHTML = ` +
${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(milliseconds).padStart(3, '0')}
+
SEC
+ `; + } + } + }; + + // Click handler for time display + timeDisplay.addEventListener("click", (e) => { + const target = e.target.closest('[data-action]'); + + if (!target) { + // Clicked outside specific elements in frames mode or anywhere in seconds mode + // Toggle format + timelineWidget.toggleTimeFormat(); + updateTimeDisplay(); + updateCanvasSize(); + // Update metronome button visibility + if (context.metronomeGroup) { + context.metronomeGroup.style.display = timelineWidget.timelineState.timeFormat === 'measures' ? '' : 'none'; + } + return; + } + + const action = target.getAttribute('data-action'); + + if (action === 'toggle-format') { + // Clicked on frame number - toggle format + timelineWidget.toggleTimeFormat(); + updateTimeDisplay(); + updateCanvasSize(); + // Update metronome button visibility + if (context.metronomeGroup) { + context.metronomeGroup.style.display = timelineWidget.timelineState.timeFormat === 'measures' ? '' : 'none'; + } + } else if (action === 'edit-fps') { + // Clicked on FPS - show input to edit framerate + console.log('[FPS Edit] Starting FPS edit'); + const currentFps = timelineWidget.timelineState.framerate; + console.log('[FPS Edit] Current FPS:', currentFps); + + const newFps = prompt('Enter framerate (FPS):', currentFps); + console.log('[FPS Edit] Prompt returned:', newFps); + + if (newFps !== null && !isNaN(newFps) && newFps > 0) { + const fps = parseFloat(newFps); + console.log('[FPS Edit] Parsed FPS:', fps); + + console.log('[FPS Edit] Setting framerate on timeline state'); + timelineWidget.timelineState.framerate = fps; + + console.log('[FPS Edit] Setting frameRate on activeObject'); + context.activeObject.frameRate = fps; + + console.log('[FPS Edit] Updating time display'); + updateTimeDisplay(); + + console.log('[FPS Edit] Requesting redraw'); + if (timelineWidget.requestRedraw) { + timelineWidget.requestRedraw(); + } + console.log('[FPS Edit] Done'); + } + } else if (action === 'edit-bpm') { + // Clicked on BPM - show input to edit BPM + const currentBpm = timelineWidget.timelineState.bpm; + const newBpm = prompt('Enter BPM (Beats Per Minute):', currentBpm); + + if (newBpm !== null && !isNaN(newBpm) && newBpm > 0) { + const bpm = parseFloat(newBpm); + timelineWidget.timelineState.bpm = bpm; + context.config.bpm = bpm; + updateTimeDisplay(); + if (timelineWidget.requestRedraw) { + timelineWidget.requestRedraw(); + } + // Notify all registered listeners of BPM change + if (context.notifyBpmChange) { + context.notifyBpmChange(bpm); + } + } + } else if (action === 'edit-time-signature') { + // Clicked on time signature - show custom dropdown with common options + const currentTimeSig = timelineWidget.timelineState.timeSignature; + const currentValue = `${currentTimeSig.numerator}/${currentTimeSig.denominator}`; + + // Create a custom dropdown list + const dropdown = document.createElement('div'); + dropdown.className = 'time-signature-dropdown'; + dropdown.style.position = 'absolute'; + dropdown.style.left = e.clientX + 'px'; + dropdown.style.top = e.clientY + 'px'; + dropdown.style.fontSize = '14px'; + dropdown.style.backgroundColor = 'var(--background-color)'; + dropdown.style.color = 'var(--label-color)'; + dropdown.style.border = '1px solid var(--shadow)'; + dropdown.style.borderRadius = '4px'; + dropdown.style.zIndex = '10000'; + dropdown.style.maxHeight = '300px'; + dropdown.style.overflowY = 'auto'; + dropdown.style.boxShadow = '0 4px 8px rgba(0,0,0,0.3)'; + + // Common time signatures + const commonTimeSigs = ['2/4', '3/4', '4/4', '5/4', '6/8', '7/8', '9/8', '12/8', 'Other...']; + + commonTimeSigs.forEach(sig => { + const item = document.createElement('div'); + item.textContent = sig; + item.style.padding = '8px 12px'; + item.style.cursor = 'pointer'; + item.style.backgroundColor = 'var(--background-color)'; + item.style.color = 'var(--label-color)'; + + if (sig === currentValue) { + item.style.backgroundColor = 'var(--foreground-color)'; + } + + item.addEventListener('mouseenter', () => { + item.style.backgroundColor = 'var(--foreground-color)'; + }); + + item.addEventListener('mouseleave', () => { + if (sig !== currentValue) { + item.style.backgroundColor = 'var(--background-color)'; + } + }); + + item.addEventListener('click', () => { + document.body.removeChild(dropdown); + + if (sig === 'Other...') { + // Show prompt for custom time signature + const newTimeSig = prompt( + 'Enter time signature (e.g., "4/4", "3/4", "6/8"):', + currentValue + ); + + if (newTimeSig !== null) { + const match = newTimeSig.match(/^(\d+)\/(\d+)$/); + if (match) { + const numerator = parseInt(match[1]); + const denominator = parseInt(match[2]); + + if (numerator > 0 && denominator > 0) { + timelineWidget.timelineState.timeSignature = { numerator, denominator }; + context.config.timeSignature = { numerator, denominator }; + updateTimeDisplay(); + if (timelineWidget.requestRedraw) { + timelineWidget.requestRedraw(); + } + } + } else { + alert('Invalid time signature format. Please use format like "4/4" or "6/8".'); + } + } + } else { + // Parse the selected common time signature + const match = sig.match(/^(\d+)\/(\d+)$/); + if (match) { + const numerator = parseInt(match[1]); + const denominator = parseInt(match[2]); + timelineWidget.timelineState.timeSignature = { numerator, denominator }; + context.config.timeSignature = { numerator, denominator }; + updateTimeDisplay(); + if (timelineWidget.requestRedraw) { + timelineWidget.requestRedraw(); + } + } + } + }); + + dropdown.appendChild(item); + }); + + document.body.appendChild(dropdown); + dropdown.focus(); + + // Close dropdown when clicking outside + const closeDropdown = (event) => { + if (!dropdown.contains(event.target)) { + if (document.body.contains(dropdown)) { + document.body.removeChild(dropdown); + } + document.removeEventListener('click', closeDropdown); + } + }; + + setTimeout(() => { + document.addEventListener('click', closeDropdown); + }, 0); + } + }); + + // Initial update + updateTimeDisplay(); + + // Store reference for updates + context.timeDisplay = timeDisplay; + context.updateTimeDisplay = updateTimeDisplay; + + controls.push(timeDisplay); + + // Snap checkbox + const snapGroup = document.createElement("div"); + snapGroup.className = "playback-controls-group"; + snapGroup.style.display = "flex"; + snapGroup.style.alignItems = "center"; + snapGroup.style.gap = "4px"; + + const snapCheckbox = document.createElement("input"); + snapCheckbox.type = "checkbox"; + snapCheckbox.id = "snap-checkbox"; + snapCheckbox.checked = timelineWidget.timelineState.snapToFrames; + snapCheckbox.style.cursor = "pointer"; + snapCheckbox.addEventListener("change", () => { + timelineWidget.timelineState.snapToFrames = snapCheckbox.checked; + console.log('Snapping', snapCheckbox.checked ? 'enabled' : 'disabled'); + }); + + const snapLabel = document.createElement("label"); + snapLabel.htmlFor = "snap-checkbox"; + snapLabel.textContent = "Snap"; + snapLabel.style.cursor = "pointer"; + snapLabel.style.fontSize = "12px"; + snapLabel.style.color = "var(--text-secondary)"; + + snapGroup.appendChild(snapCheckbox); + snapGroup.appendChild(snapLabel); + + controls.push(snapGroup); + + return controls; + }; + + // Set up ResizeObserver + const resizeObserver = new ResizeObserver(() => { + updateCanvasSize(); + }); + resizeObserver.observe(canvas); + + // Mouse event handlers + canvas.addEventListener("pointerdown", (e) => { + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + // Prevent default drag behavior on canvas + e.preventDefault(); + + // Capture pointer to ensure we get move/up events even if cursor leaves canvas + canvas.setPointerCapture(e.pointerId); + + // Store event for modifier key access during clicks (for Shift-click multi-select) + timelineWidget.lastClickEvent = e; + // Also store for drag operations initially + timelineWidget.lastDragEvent = e; + + timelineWidget.handleMouseEvent("mousedown", x, y); + updateCanvasSize(); // Redraw after interaction + }); + + canvas.addEventListener("pointermove", (e) => { + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + // Store event for modifier key access during drag (for Shift-drag constraint) + timelineWidget.lastDragEvent = e; + + timelineWidget.handleMouseEvent("mousemove", x, y); + + // Update cursor based on widget's cursor property + if (timelineWidget.cursor) { + canvas.style.cursor = timelineWidget.cursor; + } + + updateCanvasSize(); // Redraw after interaction + }); + + canvas.addEventListener("pointerup", (e) => { + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + // Release pointer capture + canvas.releasePointerCapture(e.pointerId); + + timelineWidget.handleMouseEvent("mouseup", x, y); + updateCanvasSize(); // Redraw after interaction + }); + + // Context menu (right-click) for deleting keyframes + canvas.addEventListener("contextmenu", (e) => { + e.preventDefault(); // Prevent default browser context menu + + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + // Store event for access to clientX/clientY for menu positioning + timelineWidget.lastEvent = e; + // Also store as click event for consistency + timelineWidget.lastClickEvent = e; + + timelineWidget.handleMouseEvent("contextmenu", x, y); + updateCanvasSize(); // Redraw after interaction + }); + + // Add wheel event for pinch-zoom support + canvas.addEventListener("wheel", (event) => { + event.preventDefault(); + + // Get mouse position + const rect = canvas.getBoundingClientRect(); + const mouseX = event.clientX - rect.left; + const mouseY = event.clientY - rect.top; + + // Check if this is a pinch-zoom gesture (ctrlKey is set on trackpad pinch) + if (event.ctrlKey) { + // Pinch zoom - zoom in/out based on deltaY + const zoomFactor = event.deltaY > 0 ? 0.95 : 1.05; + const oldPixelsPerSecond = timelineWidget.timelineState.pixelsPerSecond; + + // Adjust mouse position to account for track header offset + const timelineMouseX = mouseX - timelineWidget.trackHeaderWidth; + + // Calculate the time under the mouse BEFORE zooming + const mouseTimeBeforeZoom = timelineWidget.timelineState.pixelToTime(timelineMouseX); + + // Apply zoom + timelineWidget.timelineState.pixelsPerSecond *= zoomFactor; + + // Clamp to reasonable range + timelineWidget.timelineState.pixelsPerSecond = Math.max(10, Math.min(10000, timelineWidget.timelineState.pixelsPerSecond)); + + // Adjust viewport so the time under the mouse stays in the same place + // We want: pixelToTime(timelineMouseX) == mouseTimeBeforeZoom + // pixelToTime(timelineMouseX) = (timelineMouseX / pixelsPerSecond) + viewportStartTime + // So: viewportStartTime = mouseTimeBeforeZoom - (timelineMouseX / pixelsPerSecond) + timelineWidget.timelineState.viewportStartTime = mouseTimeBeforeZoom - (timelineMouseX / timelineWidget.timelineState.pixelsPerSecond); + timelineWidget.timelineState.viewportStartTime = Math.max(0, timelineWidget.timelineState.viewportStartTime); + + updateCanvasSize(); + } else { + // Regular scroll - handle both horizontal and vertical scrolling everywhere + const deltaX = event.deltaX * config.scrollSpeed; + const deltaY = event.deltaY * config.scrollSpeed; + + // Horizontal scroll for timeline + timelineWidget.timelineState.viewportStartTime += deltaX / timelineWidget.timelineState.pixelsPerSecond; + timelineWidget.timelineState.viewportStartTime = Math.max(0, timelineWidget.timelineState.viewportStartTime); + + // Vertical scroll for tracks + timelineWidget.trackScrollOffset -= deltaY; + + // Clamp scroll offset + const trackAreaHeight = canvas.height - timelineWidget.ruler.height; + const totalTracksHeight = timelineWidget.trackHierarchy.getTotalHeight(); + const maxScroll = Math.min(0, trackAreaHeight - totalTracksHeight); + timelineWidget.trackScrollOffset = Math.max(maxScroll, Math.min(0, timelineWidget.trackScrollOffset)); + + updateCanvasSize(); + } + }); + + updateCanvasSize(); + return canvas; +} + function infopanel() { let panel = document.createElement("div"); panel.className = "infopanel"; @@ -6777,18 +5454,126 @@ function outliner(object = undefined) { async function startup() { await loadConfig(); createNewFileDialog(_newFile, _open, config); + + // Create start screen with callback + createStartScreen(async (options) => { + hideStartScreen(); + + if (options.type === 'new') { + // Create new project with selected focus + await _newFile( + options.width || 800, + options.height || 600, + options.fps || 24, + options.projectFocus + ); + } else if (options.type === 'reopen' || options.type === 'recent') { + // Open existing file + await _open(options.filePath); + } + }); + + console.log('[startup] window.openedFiles:', window.openedFiles); + console.log('[startup] config.reopenLastSession:', config.reopenLastSession); + console.log('[startup] config.recentFiles:', config.recentFiles); + + // Always update start screen data so it's ready when needed + await updateStartScreen(config); + if (!window.openedFiles?.length) { if (config.reopenLastSession && config.recentFiles?.length) { + console.log('[startup] Reopening last session:', config.recentFiles[0]); document.body.style.cursor = "wait" setTimeout(()=>_open(config.recentFiles[0]), 10) } else { - showNewFileDialog(config); + console.log('[startup] Showing start screen'); + // Show start screen + showStartScreen(); } + } else { + console.log('[startup] Files already opened, skipping start screen'); } } startup(); +// Track maximized pane state +let maximizedPane = null; +let savedPaneParent = null; +let savedRootPaneChildren = []; +let savedRootPaneClasses = null; + +function toggleMaximizePane(paneDiv) { + if (maximizedPane === paneDiv) { + // Restore layout + if (savedPaneParent && savedRootPaneChildren.length > 0) { + // Remove pane from root + rootPane.removeChild(paneDiv); + + // Restore all root pane children + while (rootPane.firstChild) { + rootPane.removeChild(rootPane.firstChild); + } + for (const child of savedRootPaneChildren) { + rootPane.appendChild(child); + } + + // Put pane back in its original parent + savedPaneParent.appendChild(paneDiv); + + // Restore root pane classes + if (savedRootPaneClasses) { + rootPane.className = savedRootPaneClasses; + } + + savedPaneParent = null; + savedRootPaneChildren = []; + savedRootPaneClasses = null; + } + maximizedPane = null; + + // Update button + const btn = paneDiv.querySelector('.maximize-btn'); + if (btn) { + btn.innerHTML = "⛶"; + btn.title = "Maximize Pane"; + } + + // Trigger updates + updateAll(); + } else { + // Maximize pane + // Save pane's current parent + savedPaneParent = paneDiv.parentElement; + + // Save all root pane children + savedRootPaneChildren = Array.from(rootPane.children); + savedRootPaneClasses = rootPane.className; + + // Remove pane from its parent + savedPaneParent.removeChild(paneDiv); + + // Clear root pane + while (rootPane.firstChild) { + rootPane.removeChild(rootPane.firstChild); + } + + // Add only the maximized pane to root + rootPane.appendChild(paneDiv); + maximizedPane = paneDiv; + + // Update button + const btn = paneDiv.querySelector('.maximize-btn'); + if (btn) { + btn.innerHTML = "⛶"; // Could use different icon for restore + btn.title = "Restore Layout"; + } + + // Trigger updates + updateAll(); + } +} + function createPaneMenu(div) { const menuItems = ["Item 1", "Item 2", "Item 3"]; // The items for the menu @@ -6801,6 +5586,11 @@ function createPaneMenu(div) { // Loop through the menuItems array and create a
  • for each item for (let pane in panes) { + // Skip deprecated panes + if (pane === 'timelineDeprecated') { + continue; + } + const li = document.createElement("li"); // Create the element for the icon const img = document.createElement("img"); @@ -6858,17 +5648,60 @@ function createPane(paneType = undefined, div = undefined) { // Create and append the new menu to the DOM popupMenu = createPaneMenu(div); - // Position the menu below the button + // Position the menu intelligently to stay onscreen const buttonRect = event.target.getBoundingClientRect(); - popupMenu.style.left = `${buttonRect.left}px`; - popupMenu.style.top = `${buttonRect.bottom + window.scrollY}px`; + const menuRect = popupMenu.getBoundingClientRect(); + + // Default: position below and to the right of the button + let left = buttonRect.left; + let top = buttonRect.bottom + window.scrollY; + + // Check if menu goes off the right edge + if (left + menuRect.width > window.innerWidth) { + // Align right edge of menu with right edge of button + left = buttonRect.right - menuRect.width; + } + + // Check if menu goes off the bottom edge + if (buttonRect.bottom + menuRect.height > window.innerHeight) { + // Position above the button instead + top = buttonRect.top + window.scrollY - menuRect.height; + } + + // Ensure menu doesn't go off the left edge + left = Math.max(0, left); + + // Ensure menu doesn't go off the top edge + top = Math.max(window.scrollY, top); + + popupMenu.style.left = `${left}px`; + popupMenu.style.top = `${top}px`; } // Prevent the click event from propagating to the window click listener event.stopPropagation(); }); - div.className = "vertical-grid"; + // Add custom header controls if the content element provides them + if (content.headerControls && typeof content.headerControls === 'function') { + const controls = content.headerControls(); + for (const control of controls) { + header.appendChild(control); + } + } + + // Add maximize/restore button in top right + const maximizeBtn = document.createElement("button"); + maximizeBtn.className = "maximize-btn"; + maximizeBtn.title = "Maximize Pane"; + maximizeBtn.innerHTML = "⛶"; // Maximize icon + maximizeBtn.addEventListener("click", () => { + toggleMaximizePane(div); + }); + header.appendChild(maximizeBtn); + + div.className = "vertical-grid pane"; + div.setAttribute("data-pane-name", paneType.name); header.style.height = "calc( 2 * var(--lineheight))"; content.style.height = "calc( 100% - 2 * var(--lineheight) )"; div.appendChild(header); @@ -6904,6 +5737,7 @@ function splitPane(div, percent, horiz, newPane = undefined) { if (event.target === event.currentTarget) { if (event.button === 0) { // Left click + event.preventDefault(); // Prevent text selection during drag event.currentTarget.setAttribute("dragging", true); event.currentTarget.style.userSelect = "none"; rootPane.style.userSelect = "none"; @@ -7019,6 +5853,35 @@ function splitPane(div, percent, horiz, newPane = undefined) { } // TODO: use icon menu items // See https://github.com/tauri-apps/tauri/blob/dev/packages/api/src/menu/iconMenuItem.ts + + // Check if children contain nested splits to determine which joins are unambiguous + const leftUpChild = div.children[0]; + const rightDownChild = div.children[1]; + + // A child is a leaf if it's a panecontainer that directly contains another panecontainer + // A child has nested splits if it's a panecontainer that contains a grid + const leftUpHasSplit = leftUpChild && + leftUpChild.classList.contains("panecontainer") && + leftUpChild.firstElementChild && + (leftUpChild.firstElementChild.classList.contains("horizontal-grid") || + leftUpChild.firstElementChild.classList.contains("vertical-grid")) && + leftUpChild.firstElementChild.hasAttribute("lb-percent"); + + const rightDownHasSplit = rightDownChild && + rightDownChild.classList.contains("panecontainer") && + rightDownChild.firstElementChild && + (rightDownChild.firstElementChild.classList.contains("horizontal-grid") || + rightDownChild.firstElementChild.classList.contains("vertical-grid")) && + rightDownChild.firstElementChild.hasAttribute("lb-percent"); + + // Join Left/Up is unambiguous if we're keeping the left/up side (which may have splits) + // and removing the right/down side (which should be a simple pane) + const canJoinLeftUp = !rightDownHasSplit; + + // Join Right/Down is unambiguous if we're keeping the right/down side (which may have splits) + // and removing the left/up side (which should be a simple pane) + const canJoinRightDown = !leftUpHasSplit; + const menu = await Menu.new({ items: [ { id: "ctx_option0", text: "Area options", enabled: false }, @@ -7033,11 +5896,83 @@ function splitPane(div, percent, horiz, newPane = undefined) { action: () => createSplit("horizontal"), }, new PredefinedMenuItem("Separator"), - { id: "ctx_option3", text: horiz ? "Join Left" : "Join Up" }, - { id: "ctx_option4", text: horiz ? "Join Right" : "Join Down" }, + { + id: "ctx_option3", + text: horiz ? "Join Left" : "Join Up", + enabled: canJoinLeftUp, + action: () => { + // Join left/up: remove the left/up pane, keep the right/down pane + const keepChild = div.children[1]; + + // Move all children from the kept panecontainer to the parent + const children = Array.from(keepChild.children); + + // Replace the split div with just the kept child's contents + div.className = "panecontainer"; + div.innerHTML = ""; + children.forEach(child => { + // Recursively clear explicit sizing on grid and panecontainer elements only + function clearSizes(el) { + if (el.classList.contains("horizontal-grid") || + el.classList.contains("vertical-grid") || + el.classList.contains("panecontainer")) { + el.style.width = ""; + el.style.height = ""; + Array.from(el.children).forEach(clearSizes); + } + } + clearSizes(child); + div.appendChild(child); + }); + div.removeAttribute("lb-percent"); + + setTimeout(() => { + updateAll(); + updateUI(); + updateLayers(); + }, 20); + } + }, + { + id: "ctx_option4", + text: horiz ? "Join Right" : "Join Down", + enabled: canJoinRightDown, + action: () => { + // Join right/down: remove the right/down pane, keep the left/up pane + const keepChild = div.children[0]; + + // Move all children from the kept panecontainer to the parent + const children = Array.from(keepChild.children); + + // Replace the split div with just the kept child's contents + div.className = "panecontainer"; + div.innerHTML = ""; + children.forEach(child => { + // Recursively clear explicit sizing on grid and panecontainer elements only + function clearSizes(el) { + if (el.classList.contains("horizontal-grid") || + el.classList.contains("vertical-grid") || + el.classList.contains("panecontainer")) { + el.style.width = ""; + el.style.height = ""; + Array.from(el.children).forEach(clearSizes); + } + } + clearSizes(child); + div.appendChild(child); + }); + div.removeAttribute("lb-percent"); + + setTimeout(() => { + updateAll(); + updateUI(); + updateLayers(); + }, 20); + } + }, ], }); - menu.popup({ x: event.clientX, y: event.clientY }); + await menu.popup(new PhysicalPosition(event.clientX, event.clientY)); } console.log("Right-click on the element"); @@ -7097,7 +6032,16 @@ function updateUI() { uiDirty = true; } -function renderUI() { +// Add updateUI and updateMenu to context so widgets can call them +context.updateUI = updateUI; +context.updateMenu = updateMenu; + +async function renderUI() { + // Update video frames BEFORE drawing + if (context.activeObject) { + await updateVideoFrames(context.activeObject.currentTime); + } + for (let canvas of canvases) { let ctx = canvas.getContext("2d"); ctx.resetTransform(); @@ -7113,13 +6057,12 @@ function renderUI() { context.ctx = ctx; // root.draw(context); - root.draw(ctx) + root.draw(context) if (context.activeObject != root) { ctx.fillStyle = "rgba(255,255,255,0.5)"; ctx.fillRect(0, 0, config.fileWidth, config.fileHeight); const transform = ctx.getTransform() - context.activeObject.transformCanvas(ctx) - context.activeObject.draw(ctx); + context.activeObject.draw(context, true); ctx.setTransform(transform) } if (context.activeShape) { @@ -7138,7 +6081,9 @@ function renderUI() { y: { min: context.mousePos.y - ep, max: context.mousePos.y + ep }, }; debugCurves = []; - for (let shape of context.activeObject.currentFrame.shapes) { + const currentTime = context.activeObject.currentTime || 0; + const visibleShapes = context.activeObject.activeLayer.getVisibleShapes(currentTime); + for (let shape of visibleShapes) { for (let i of shape.quadtree.query(bbox)) { debugCurves.push(shape.curves[i]); } @@ -7189,7 +6134,7 @@ function renderUI() { for (let selectionRect of document.querySelectorAll(".selectionRect")) { selectionRect.style.display = "none"; } - if (mode == "transform") { + if (context.mode == "transform") { if (context.selection.length > 0) { for (let selectionRect of document.querySelectorAll(".selectionRect")) { let bbox = undefined; @@ -7217,7 +6162,12 @@ function updateLayers() { } function renderLayers() { - for (let canvas of document.querySelectorAll(".timeline")) { + // Also trigger TimelineV2 redraw if it exists + if (context.timelineWidget?.requestRedraw) { + context.timelineWidget.requestRedraw(); + } + + for (let canvas of document.querySelectorAll(".timeline-deprecated")) { const width = canvas.width; const height = canvas.height; const ctx = canvas.getContext("2d"); @@ -7465,7 +6415,7 @@ function renderLayers() { // // break; // // } // // }); - // } else if (layer instanceof AudioLayer) { + // } else if (layer instanceof AudioTrack) { // // TODO: split waveform into chunks // for (let i in layer.sounds) { // let sound = layer.sounds[i]; @@ -7614,7 +6564,7 @@ function renderLayers() { layerTrack.appendChild(highlightObj); } } - for (let audioLayer of context.activeObject.audioLayers) { + for (let audioTrack of context.activeObject.audioTracks) { let layerHeader = document.createElement("div"); layerHeader.className = "layer-header"; layerHeader.classList.add("audio"); @@ -7623,8 +6573,8 @@ function renderLayers() { layerTrack.className = "layer-track"; layerTrack.classList.add("audio"); framescontainer.appendChild(layerTrack); - for (let i in audioLayer.sounds) { - let sound = audioLayer.sounds[i]; + for (let i in audioTrack.sounds) { + let sound = audioTrack.sounds[i]; layerTrack.appendChild(sound.img); } let layerName = document.createElement("div"); @@ -7636,7 +6586,7 @@ function renderLayers() { layerName.addEventListener("blur", (e) => { actions.changeLayerName.create(audioLayer, layerName.innerText); }); - layerName.innerText = audioLayer.name; + layerName.innerText = audioTrack.name; layerHeader.appendChild(layerName); } } @@ -7700,8 +6650,8 @@ function renderInfopanel() { } }); panel.appendChild(breadcrumbs); - for (let property in tools[mode].properties) { - let prop = tools[mode].properties[property]; + for (let property in tools[context.mode].properties) { + let prop = tools[context.mode].properties[property]; label = document.createElement("label"); label.className = "infopanel-field"; span = document.createElement("span"); @@ -7901,6 +6851,7 @@ function updateMenu() { } async function renderMenu() { + console.log('[renderMenu] START - root.frameRate:', root.frameRate); let activeFrame; let activeKeyframe; let newFrameMenuItem; @@ -7911,6 +6862,7 @@ async function renderMenu() { // Move this updateOutliner(); + console.log('[renderMenu] After updateOutliner - root.frameRate:', root.frameRate); let recentFilesList = []; config.recentFiles.forEach((file) => { @@ -7924,19 +6876,9 @@ async function renderMenu() { }); }); - const frameInfo = context.activeObject.activeLayer.getFrameValue( - context.activeObject.currentFrameNum - ) - if (frameInfo.valueAtN) { - activeFrame = true; - activeKeyframe = true; - } else if (frameInfo.prev && frameInfo.next) { - activeFrame = true; - activeKeyframe = false; - } else { - activeFrame = false; - activeKeyframe = false; - } + // Legacy frame system removed - these are always false now + activeFrame = false; + activeKeyframe = false; const appSubmenu = await Submenu.new({ text: "Lightningbeam", items: [ @@ -8011,11 +6953,16 @@ async function renderMenu() { accelerator: getShortcut("import"), }, { - text: "Export...", + text: "Export Video...", enabled: true, action: render, accelerator: getShortcut("export"), }, + { + text: "Export Audio...", + enabled: true, + action: exportAudio, + }, { text: "Quit", enabled: true, @@ -8085,6 +7032,11 @@ async function renderMenu() { action: actions.selectNone.create, accelerator: getShortcut("selectNone"), }, + { + text: "Preferences", + enabled: true, + action: showPreferencesDialog, + }, ], }); @@ -8122,18 +7074,35 @@ async function renderMenu() { action: actions.addLayer.create, accelerator: getShortcut("addLayer"), }, + { + text: "Add Video Layer", + enabled: true, + action: addVideoLayer, + }, + { + text: "Add Audio Track", + enabled: true, + action: addEmptyAudioTrack, + accelerator: getShortcut("addAudioTrack") + }, + { + text: "Add MIDI Track", + enabled: true, + action: addEmptyMIDITrack, + accelerator: getShortcut("addMIDITrack") + }, { text: "Delete Layer", enabled: context.activeObject.layers.length > 1, action: actions.deleteLayer.create, }, { - text: context.activeObject.activeLayer.visible + text: context.activeObject.activeLayer?.visible ? "Hide Layer" : "Show Layer", - enabled: true, + enabled: !!context.activeObject.activeLayer, action: () => { - context.activeObject.activeLayer.toggleVisibility(); + context.activeObject.activeLayer?.toggleVisibility(); }, }, ], @@ -8146,9 +7115,10 @@ async function renderMenu() { }; newKeyframeMenuItem = { text: "New Keyframe", - enabled: !activeKeyframe, + enabled: (context.selection && context.selection.length > 0) || + (context.shapeselection && context.shapeselection.length > 0), accelerator: getShortcut("addKeyframe"), - action: addKeyframe, + action: addKeyframeAtPlayhead, }; newBlankKeyframeMenuItem = { text: "New Blank Keyframe", @@ -8179,6 +7149,13 @@ async function renderMenu() { newBlankKeyframeMenuItem, deleteFrameMenuItem, duplicateKeyframeMenuItem, + { + text: "Add Keyframe at Playhead", + enabled: (context.selection && context.selection.length > 0) || + (context.shapeselection && context.shapeselection.length > 0), + action: addKeyframeAtPlayhead, + accelerator: "K", + }, { text: "Add Motion Tween", enabled: activeFrame, @@ -8196,12 +7173,47 @@ async function renderMenu() { }, { text: "Play", - enabled: !playing, + enabled: !context.playing, action: playPause, accelerator: getShortcut("playAnimation"), }, ], }); + // Build layout submenu items + const layoutMenuItems = [ + { + text: "Next Layout", + enabled: true, + action: nextLayout, + accelerator: getShortcut("nextLayout"), + }, + { + text: "Previous Layout", + enabled: true, + action: previousLayout, + accelerator: getShortcut("previousLayout"), + }, + ]; + + // Add separator + layoutMenuItems.push(await PredefinedMenuItem.new({ item: "Separator" })); + + // Add individual layouts + for (const layoutKey of getLayoutNames()) { + const layout = getLayout(layoutKey); + const isCurrentLayout = config.currentLayout === layoutKey; + layoutMenuItems.push({ + text: isCurrentLayout ? `✓ ${layout.name}` : layout.name, + enabled: true, + action: () => switchLayout(layoutKey), + }); + } + + const layoutSubmenu = await Submenu.new({ + text: "Layout", + items: layoutMenuItems, + }); + const viewSubmenu = await Submenu.new({ text: "View", items: [ @@ -8229,6 +7241,7 @@ async function renderMenu() { action: recenter, // accelerator: getShortcut("recenter"), }, + layoutSubmenu, ], }); const helpSubmenu = await Submenu.new({ @@ -8257,10 +7270,4609 @@ async function renderMenu() { const menu = await Menu.new({ items: items, }); + console.log('[renderMenu] Before setAsWindowMenu - root.frameRate:', root.frameRate); await (macOS ? menu.setAsAppMenu() : menu.setAsWindowMenu()); + console.log('[renderMenu] END - root.frameRate:', root.frameRate); } updateMenu(); +// Helper function to get the current track (MIDI or Audio) for node graph editing +function getCurrentTrack() { + const activeLayer = context.activeObject?.activeLayer; + if (!activeLayer || !(activeLayer instanceof AudioTrack)) { + return null; + } + if (activeLayer.audioTrackId === null) { + return null; + } + // Return both track ID and track type + return { + trackId: activeLayer.audioTrackId, + trackType: activeLayer.type // 'midi' or 'audio' + }; +} + +// Backwards compatibility: function to get just the MIDI track ID +function getCurrentMidiTrack() { + const trackInfo = getCurrentTrack(); + if (trackInfo && trackInfo.trackType === 'midi') { + return trackInfo.trackId; + } + return null; +} + +function nodeEditor() { + // Create container for the node editor + const container = document.createElement("div"); + container.id = "node-editor-container"; + + // Prevent text selection during drag operations + container.addEventListener('selectstart', (e) => { + // Allow selection on input elements + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') { + return; + } + e.preventDefault(); + }); + container.addEventListener('mousedown', (e) => { + // Don't prevent default on inputs, textareas, or palette items (draggable) + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') { + return; + } + // Don't prevent default on palette items or their children + if (e.target.closest('.node-palette-item') || e.target.closest('.node-category-item')) { + return; + } + e.preventDefault(); + }); + + // Track editing context: null = main graph, {voiceAllocatorId, voiceAllocatorName} = editing template + let editingContext = null; + + // Track palette navigation: null = showing categories, string = showing nodes in that category + let selectedCategory = null; + + // Create breadcrumb/context header + const header = document.createElement("div"); + header.className = "node-editor-header"; + // Initial header will be updated by updateBreadcrumb() after track info is available + header.innerHTML = ` +
    Node Graph
    + + `; + container.appendChild(header); + + // Add clear button handler + const clearBtn = header.querySelector('.node-graph-clear-btn'); + clearBtn.addEventListener('click', async () => { + try { + // Get current track + const trackInfo = getCurrentTrack(); + if (trackInfo === null) { + console.error('No track selected'); + alert('Please select a track first'); + return; + } + const trackId = trackInfo.trackId; + + // Get the full backend graph state as JSON + const graphStateJson = await invoke('graph_get_state', { trackId }); + const graphState = JSON.parse(graphStateJson); + + if (!graphState.nodes || graphState.nodes.length === 0) { + return; // Nothing to clear + } + + // Create and execute the action + redoStack.length = 0; // Clear redo stack + const action = { + trackId, + savedGraphJson: graphStateJson // Save the entire graph state as JSON + }; + undoStack.push({ name: 'clearNodeGraph', action }); + await actions.clearNodeGraph.execute(action); + updateMenu(); + + console.log('Cleared node graph (undoable)'); + } catch (e) { + console.error('Failed to clear node graph:', e); + alert('Failed to clear node graph: ' + e); + } + }); + + // Create the Drawflow canvas + const editorDiv = document.createElement("div"); + editorDiv.id = "drawflow"; + editorDiv.style.position = "absolute"; + editorDiv.style.top = "40px"; // Start below header + editorDiv.style.left = "0"; + editorDiv.style.right = "0"; + editorDiv.style.bottom = "0"; + container.appendChild(editorDiv); + + // Create node palette + const palette = document.createElement("div"); + palette.className = "node-palette"; + container.appendChild(palette); + + // Create persistent search input + const paletteSearch = document.createElement("div"); + paletteSearch.className = "palette-search"; + paletteSearch.innerHTML = ` + + + `; + palette.appendChild(paletteSearch); + + // Create content container that will be updated + const paletteContent = document.createElement("div"); + paletteContent.className = "palette-content"; + palette.appendChild(paletteContent); + + // Get references to search elements + const searchInput = paletteSearch.querySelector(".palette-search-input"); + const searchClearBtn = paletteSearch.querySelector(".palette-search-clear"); + + // Create minimap + const minimap = document.createElement("div"); + minimap.className = "node-minimap"; + minimap.style.display = 'none'; // Hidden by default + minimap.innerHTML = ` + +
    + `; + container.appendChild(minimap); + + // Category display names + const categoryNames = { + [NodeCategory.INPUT]: 'Inputs', + [NodeCategory.GENERATOR]: 'Generators', + [NodeCategory.EFFECT]: 'Effects', + [NodeCategory.UTILITY]: 'Utilities', + [NodeCategory.OUTPUT]: 'Outputs' + }; + + // Search state + let searchQuery = ''; + + // Handle search input changes + searchInput.addEventListener('input', (e) => { + searchQuery = e.target.value; + searchClearBtn.style.display = searchQuery ? 'flex' : 'none'; + updatePalette(); + }); + + // Handle search clear + searchClearBtn.addEventListener('click', () => { + searchQuery = ''; + searchInput.value = ''; + searchClearBtn.style.display = 'none'; + searchInput.focus(); + updatePalette(); + }); + + // Function to update palette based on context and selected category + function updatePalette() { + const isTemplate = editingContext !== null; + const trackInfo = getCurrentTrack(); + const isMIDI = trackInfo?.trackType === 'midi'; + const isAudio = trackInfo?.trackType === 'audio'; + + if (selectedCategory === null && !searchQuery) { + // Show categories when no search query + const categories = getCategories().filter(category => { + // Filter categories based on context + if (isTemplate) { + // In template: show all categories + return true; + } else { + // In main graph: hide INPUT/OUTPUT categories that contain template nodes + return true; // We'll filter nodes instead + } + }); + + paletteContent.innerHTML = ` +

    Node Categories

    + ${categories.map(category => ` +
    + ${categoryNames[category] || category} +
    + `).join('')} + `; + } else if (selectedCategory === null && searchQuery) { + // Show all matching nodes across all categories when searching from main panel + const allCategories = getCategories(); + let allNodes = []; + + allCategories.forEach(category => { + const nodesInCategory = getNodesByCategory(category); + allNodes = allNodes.concat(nodesInCategory); + }); + + // Filter based on context + let filteredNodes = allNodes.filter(node => { + if (isTemplate) { + // In template: hide VoiceAllocator, AudioOutput, MidiInput + return node.type !== 'VoiceAllocator' && node.type !== 'AudioOutput' && node.type !== 'MidiInput'; + } else if (isMIDI) { + // MIDI track: hide AudioInput, show synth nodes + return node.type !== 'TemplateInput' && node.type !== 'TemplateOutput' && node.type !== 'AudioInput'; + } else if (isAudio) { + // Audio track: hide synth/MIDI nodes, show AudioInput + const synthNodes = ['Oscillator', 'FMSynth', 'WavetableOscillator', 'SimpleSampler', 'MultiSampler', 'VoiceAllocator', 'MidiInput', 'MidiToCV']; + return node.type !== 'TemplateInput' && node.type !== 'TemplateOutput' && !synthNodes.includes(node.type); + } else { + // Fallback: hide TemplateInput/TemplateOutput + return node.type !== 'TemplateInput' && node.type !== 'TemplateOutput'; + } + }); + + // Apply search filter + const query = searchQuery.toLowerCase(); + filteredNodes = filteredNodes.filter(node => { + return node.name.toLowerCase().includes(query) || + node.description.toLowerCase().includes(query); + }); + + // Function to highlight search matches in text + const highlightMatch = (text) => { + const regex = new RegExp(`(${searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'); + return text.replace(regex, '$1'); + }; + + paletteContent.innerHTML = ` +

    Search Results

    + ${filteredNodes.length > 0 ? filteredNodes.map(node => ` +
    + ${highlightMatch(node.name)} +
    + `).join('') : '
    No matching nodes found
    '} + `; + } else { + // Show nodes in selected category + const nodesInCategory = getNodesByCategory(selectedCategory); + + // Filter based on context + let filteredNodes = nodesInCategory.filter(node => { + if (isTemplate) { + // In template: hide VoiceAllocator, AudioOutput, MidiInput + return node.type !== 'VoiceAllocator' && node.type !== 'AudioOutput' && node.type !== 'MidiInput'; + } else if (isMIDI) { + // MIDI track: hide AudioInput, show synth nodes + return node.type !== 'TemplateInput' && node.type !== 'TemplateOutput' && node.type !== 'AudioInput'; + } else if (isAudio) { + // Audio track: hide synth/MIDI nodes, show AudioInput + const synthNodes = ['Oscillator', 'FMSynth', 'WavetableOscillator', 'SimpleSampler', 'MultiSampler', 'VoiceAllocator', 'MidiInput', 'MidiToCV']; + return node.type !== 'TemplateInput' && node.type !== 'TemplateOutput' && !synthNodes.includes(node.type); + } else { + // Fallback: hide TemplateInput/TemplateOutput + return node.type !== 'TemplateInput' && node.type !== 'TemplateOutput'; + } + }); + + // Apply search filter + if (searchQuery) { + const query = searchQuery.toLowerCase(); + filteredNodes = filteredNodes.filter(node => { + return node.name.toLowerCase().includes(query) || + node.description.toLowerCase().includes(query); + }); + } + + // Function to highlight search matches in text + const highlightMatch = (text) => { + if (!searchQuery) return text; + const regex = new RegExp(`(${searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'); + return text.replace(regex, '$1'); + }; + + paletteContent.innerHTML = ` +
    + +

    ${categoryNames[selectedCategory] || selectedCategory}

    +
    + ${filteredNodes.length > 0 ? filteredNodes.map(node => ` +
    + ${highlightMatch(node.name)} +
    + `).join('') : '
    No matching nodes found
    '} + `; + } + } + + updatePalette(); + + // Initialize Drawflow editor (will be set up after DOM insertion) + let editor = null; + let nodeIdCounter = 1; + + // Track expanded VoiceAllocator nodes + const expandedNodes = new Set(); // Set of node IDs that are expanded + const nodeParents = new Map(); // Map of child node ID -> parent VoiceAllocator ID + + // Cache node data for undo/redo (nodeId -> {nodeType, backendId, position, parameters}) + const nodeDataCache = new Map(); + + // Track node movement for undo/redo (nodeId -> {oldX, oldY}) + const nodeMoveTracker = new Map(); + + // Flag to prevent recording actions during undo/redo operations + let suppressActionRecording = false; + + // Wait for DOM insertion + setTimeout(() => { + const drawflowDiv = container.querySelector("#drawflow"); + if (!drawflowDiv) return; + + editor = new Drawflow(drawflowDiv); + editor.reroute = true; + editor.reroute_fix_curvature = true; + editor.force_first_input = false; + editor.start(); + + // Store editor reference in context + context.nodeEditor = editor; + context.reloadNodeEditor = reloadGraph; + context.nodeEditorState = { + get suppressActionRecording() { return suppressActionRecording; }, + set suppressActionRecording(value) { suppressActionRecording = value; } + }; + + // Initialize BPM change notification system + // This allows nodes to register callbacks to be notified when BPM changes + const bpmChangeListeners = new Set(); + + context.registerBpmChangeListener = (callback) => { + bpmChangeListeners.add(callback); + return () => bpmChangeListeners.delete(callback); // Return unregister function + }; + + context.notifyBpmChange = (newBpm) => { + console.log(`BPM changed to ${newBpm}, notifying ${bpmChangeListeners.size} listeners`); + bpmChangeListeners.forEach(callback => { + try { + callback(newBpm); + } catch (error) { + console.error('Error in BPM change listener:', error); + } + }); + }; + + // Register a listener to update all synced Phaser nodes when BPM changes + context.registerBpmChangeListener((newBpm) => { + if (!editor) return; + + const module = editor.module; + const allNodes = editor.drawflow.drawflow[module]?.data || {}; + + // Beat division definitions for conversion + const beatDivisions = [ + { label: '4 bars', multiplier: 16.0 }, + { label: '2 bars', multiplier: 8.0 }, + { label: '1 bar', multiplier: 4.0 }, + { label: '1/2', multiplier: 2.0 }, + { label: '1/4', multiplier: 1.0 }, + { label: '1/8', multiplier: 0.5 }, + { label: '1/16', multiplier: 0.25 }, + { label: '1/32', multiplier: 0.125 }, + { label: '1/2T', multiplier: 2.0/3.0 }, + { label: '1/4T', multiplier: 1.0/3.0 }, + { label: '1/8T', multiplier: 0.5/3.0 } + ]; + + // Iterate through all nodes to find synced Phaser nodes + for (const [nodeId, nodeData] of Object.entries(allNodes)) { + // Check if this is a Phaser node with sync enabled + if (nodeData.name === 'Phaser' && nodeData.data.backendId !== null) { + const nodeElement = document.getElementById(`node-${nodeId}`); + if (!nodeElement) continue; + + const syncCheckbox = nodeElement.querySelector(`#sync-${nodeId}`); + if (!syncCheckbox || !syncCheckbox.checked) continue; + + // Get the current rate slider value (beat division index) + const rateSlider = nodeElement.querySelector(`input[data-param="0"]`); // rate is param 0 + if (!rateSlider) continue; + + const beatDivisionIndex = Math.min(10, Math.max(0, Math.round(parseFloat(rateSlider.value)))); + const beatsPerSecond = newBpm / 60.0; + const quarterNotesPerCycle = beatDivisions[beatDivisionIndex].multiplier; + const hz = beatsPerSecond / quarterNotesPerCycle; + + // Update the backend parameter + const trackInfo = getCurrentTrack(); + if (trackInfo !== null) { + invoke("graph_set_parameter", { + trackId: trackInfo.trackId, + nodeId: nodeData.data.backendId, + paramId: 0, // rate parameter + value: hz + }).catch(err => { + console.error("Failed to update Phaser rate after BPM change:", err); + }); + console.log(`Updated Phaser node ${nodeId} rate to ${hz} Hz for BPM ${newBpm}`); + } + } + } + }); + + // Initialize minimap + const minimapCanvas = container.querySelector("#minimap-canvas"); + const minimapViewport = container.querySelector(".minimap-viewport"); + const minimapCtx = minimapCanvas.getContext('2d'); + + // Set canvas size to match container + minimapCanvas.width = 200; + minimapCanvas.height = 150; + + function updateMinimap() { + if (!editor) return; + + const ctx = minimapCtx; + const canvas = minimapCanvas; + + // Clear canvas + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // Get all nodes + const module = editor.module; + const nodes = editor.drawflow.drawflow[module]?.data || {}; + const nodeList = Object.values(nodes); + + if (nodeList.length === 0) { + minimap.style.display = 'none'; + return; + } + + // Calculate bounding box of all nodes + let minX = Infinity, minY = Infinity; + let maxX = -Infinity, maxY = -Infinity; + + nodeList.forEach(node => { + minX = Math.min(minX, node.pos_x); + minY = Math.min(minY, node.pos_y); + maxX = Math.max(maxX, node.pos_x + 160); // Approximate node width + maxY = Math.max(maxY, node.pos_y + 100); // Approximate node height + }); + + // Add padding + const padding = 20; + minX -= padding; + minY -= padding; + maxX += padding; + maxY += padding; + + // Calculate graph dimensions + const graphWidth = maxX - minX; + const graphHeight = maxY - minY; + + // Check if graph fits in viewport + const zoom = editor.zoom || 1; + const drawflowRect = drawflowDiv.getBoundingClientRect(); + const viewportWidth = drawflowRect.width / zoom; + const viewportHeight = drawflowRect.height / zoom; + + // Only show minimap if graph is larger than viewport + if (graphWidth <= viewportWidth && graphHeight <= viewportHeight) { + minimap.style.display = 'none'; + return; + } else { + minimap.style.display = 'block'; + } + + // Calculate scale to fit in minimap + const scale = Math.min(canvas.width / graphWidth, canvas.height / graphHeight); + + // Draw nodes + ctx.fillStyle = '#666'; + nodeList.forEach(node => { + const x = (node.pos_x - minX) * scale; + const y = (node.pos_y - minY) * scale; + const width = 160 * scale; + const height = 100 * scale; + + ctx.fillRect(x, y, width, height); + }); + + // Update viewport indicator + const canvasX = editor.canvas_x || 0; + const canvasY = editor.canvas_y || 0; + + const viewportX = (-canvasX / zoom - minX) * scale; + const viewportY = (-canvasY / zoom - minY) * scale; + const viewportIndicatorWidth = (drawflowRect.width / zoom) * scale; + const viewportIndicatorHeight = (drawflowRect.height / zoom) * scale; + + minimapViewport.style.left = Math.max(0, viewportX) + 'px'; + minimapViewport.style.top = Math.max(0, viewportY) + 'px'; + minimapViewport.style.width = viewportIndicatorWidth + 'px'; + minimapViewport.style.height = viewportIndicatorHeight + 'px'; + + // Store scale info for click navigation + minimapCanvas.dataset.scale = scale; + minimapCanvas.dataset.minX = minX; + minimapCanvas.dataset.minY = minY; + } + + // Update minimap on various events + editor.on('nodeCreated', () => setTimeout(updateMinimap, 100)); + editor.on('nodeRemoved', () => setTimeout(updateMinimap, 100)); + editor.on('nodeMoved', () => updateMinimap()); + + // Update minimap on pan/zoom + drawflowDiv.addEventListener('wheel', () => setTimeout(updateMinimap, 10)); + + // Store updateMinimap in context so it can be called from actions + context.updateMinimap = updateMinimap; + + // Initial minimap render + setTimeout(updateMinimap, 200); + + // Click-to-navigate on minimap + minimapCanvas.addEventListener('mousedown', (e) => { + const rect = minimapCanvas.getBoundingClientRect(); + const clickX = e.clientX - rect.left; + const clickY = e.clientY - rect.top; + + const scale = parseFloat(minimapCanvas.dataset.scale || 1); + const minX = parseFloat(minimapCanvas.dataset.minX || 0); + const minY = parseFloat(minimapCanvas.dataset.minY || 0); + + // Convert click position to graph coordinates + const graphX = (clickX / scale) + minX; + const graphY = (clickY / scale) + minY; + + // Center the viewport on the clicked position + const zoom = editor.zoom || 1; + const drawflowRect = drawflowDiv.getBoundingClientRect(); + const viewportCenterX = drawflowRect.width / (2 * zoom); + const viewportCenterY = drawflowRect.height / (2 * zoom); + + editor.canvas_x = -(graphX - viewportCenterX) * zoom; + editor.canvas_y = -(graphY - viewportCenterY) * zoom; + + // Update the canvas transform + const precanvas = drawflowDiv.querySelector('.drawflow'); + if (precanvas) { + precanvas.style.transform = `translate(${editor.canvas_x}px, ${editor.canvas_y}px) scale(${zoom})`; + } + + updateMinimap(); + }); + + // Add reconnection support: dragging from a connected input disconnects and starts new connection + drawflowDiv.addEventListener('mousedown', (e) => { + // Check if clicking on an input port + const inputPort = e.target.closest('.input'); + + if (inputPort) { + // Get the node and port information - the drawflow-node div has the id + const drawflowNode = inputPort.closest('.drawflow-node'); + if (!drawflowNode) return; + + const nodeId = parseInt(drawflowNode.id.replace('node-', '')); + + // Access the node data directly from the current module + const moduleName = editor.module; + const node = editor.drawflow.drawflow[moduleName]?.data[nodeId]; + if (!node) return; + + // Get the port class (input_1, input_2, etc.) + const portClasses = Array.from(inputPort.classList); + const portClass = portClasses.find(c => c.startsWith('input_')); + if (!portClass) return; + + // Check if this input has any connections + const inputConnections = node.inputs[portClass]; + if (inputConnections && inputConnections.connections && inputConnections.connections.length > 0) { + // Get the first connection (inputs should only have one connection) + const connection = inputConnections.connections[0]; + + // Prevent default to avoid interfering with the drag + e.stopPropagation(); + e.preventDefault(); + + // Remove the connection + editor.removeSingleConnection( + connection.node, + nodeId, + connection.input, + portClass + ); + + // Now trigger Drawflow's connection drag from the output that was connected + // We need to simulate starting a drag from the output port + const outputNodeElement = document.getElementById(`node-${connection.node}`); + if (outputNodeElement) { + const outputPort = outputNodeElement.querySelector(`.${connection.input}`); + if (outputPort) { + // Dispatch a synthetic mousedown event on the output port + // This will trigger Drawflow's normal connection start logic + setTimeout(() => { + const rect = outputPort.getBoundingClientRect(); + const syntheticEvent = new MouseEvent('mousedown', { + bubbles: true, + cancelable: true, + view: window, + clientX: rect.left + rect.width / 2, + clientY: rect.top + rect.height / 2, + button: 0 + }); + outputPort.dispatchEvent(syntheticEvent); + + // Then immediately dispatch a mousemove to the original cursor position + // to start dragging the connection line + setTimeout(() => { + const mousemoveEvent = new MouseEvent('mousemove', { + bubbles: true, + cancelable: true, + view: window, + clientX: e.clientX, + clientY: e.clientY, + button: 0 + }); + document.dispatchEvent(mousemoveEvent); + }, 0); + }, 0); + } + } + } + } + }, true); // Use capture phase to intercept before Drawflow + + // Add trackpad/mousewheel scrolling support for panning + drawflowDiv.addEventListener('wheel', (e) => { + // Don't scroll if hovering over palette or other UI elements + if (e.target.closest('.node-palette')) { + return; + } + + // Don't interfere with zoom (Ctrl+wheel) + if (e.ctrlKey) return; + + // Prevent default scrolling behavior + e.preventDefault(); + + // Pan the canvas based on scroll direction + const deltaX = e.deltaX; + const deltaY = e.deltaY; + + // Update Drawflow's canvas position + if (typeof editor.canvas_x === 'undefined') { + editor.canvas_x = 0; + } + if (typeof editor.canvas_y === 'undefined') { + editor.canvas_y = 0; + } + + editor.canvas_x -= deltaX; + editor.canvas_y -= deltaY; + + // Update the canvas transform + const precanvas = drawflowDiv.querySelector('.drawflow'); + if (precanvas) { + const zoom = editor.zoom || 1; + precanvas.style.transform = `translate(${editor.canvas_x}px, ${editor.canvas_y}px) scale(${zoom})`; + } + }, { passive: false }); + + // Add palette item drag-and-drop handlers using event delegation + let draggedNodeType = null; + + // Use event delegation for click on palette items, categories, and back button + palette.addEventListener("click", (e) => { + // Handle back button + const backBtn = e.target.closest(".palette-back-btn"); + if (backBtn) { + selectedCategory = null; + updatePalette(); + return; + } + + // Handle category selection + const categoryItem = e.target.closest(".node-category-item"); + if (categoryItem) { + selectedCategory = categoryItem.getAttribute("data-category"); + updatePalette(); + return; + } + + // Handle node selection + const item = e.target.closest(".node-palette-item"); + if (item) { + const nodeType = item.getAttribute("data-node-type"); + + // Calculate center of visible canvas viewport + const rect = drawflowDiv.getBoundingClientRect(); + const canvasX = editor.canvas_x || 0; + const canvasY = editor.canvas_y || 0; + const zoom = editor.zoom || 1; + + // Approximate node dimensions (nodes have min-width: 160px, typical height ~150px) + const nodeWidth = 160; + const nodeHeight = 150; + + // Center position in world coordinates, offset by half node size + const centerX = (rect.width / 2 - canvasX) / zoom - nodeWidth / 2; + const centerY = (rect.height / 2 - canvasY) / zoom - nodeHeight / 2; + + addNode(nodeType, centerX, centerY, null); + } + }); + + // Use event delegation for drag events + palette.addEventListener('dragstart', (e) => { + const item = e.target.closest(".node-palette-item"); + if (item) { + draggedNodeType = item.getAttribute('data-node-type'); + e.dataTransfer.effectAllowed = 'copy'; + e.dataTransfer.setData('text/plain', draggedNodeType); + console.log('Drag started:', draggedNodeType); + } + }); + + palette.addEventListener('dragend', (e) => { + const item = e.target.closest(".node-palette-item"); + if (item) { + console.log('Drag ended'); + draggedNodeType = null; + } + }); + + // Add drop handler to drawflow canvas + drawflowDiv.addEventListener('dragover', (e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + + // Check if dragging over a connection for insertion + const nodeType = e.dataTransfer.getData('text/plain') || draggedNodeType; + if (nodeType) { + const nodeDef = nodeTypes[nodeType]; + if (nodeDef) { + checkConnectionInsertionDuringDrag(e, nodeDef); + } + } + }); + + drawflowDiv.addEventListener('drop', (e) => { + e.preventDefault(); + + // Get node type from dataTransfer instead of global variable + const nodeType = e.dataTransfer.getData('text/plain'); + console.log('Drop event fired, nodeType:', nodeType); + + if (!nodeType) { + console.log('No nodeType in drop data, aborting'); + return; + } + + // Get drop position relative to the editor + const rect = drawflowDiv.getBoundingClientRect(); + + // Use canvas_x and canvas_y which are set by the wheel scroll handler + const canvasX = editor.canvas_x || 0; + const canvasY = editor.canvas_y || 0; + const zoom = editor.zoom || 1; + + // Approximate node dimensions (nodes have min-width: 160px, typical height ~150px) + const nodeWidth = 160; + const nodeHeight = 150; + + // Calculate position accounting for canvas pan offset, centered on cursor + const x = (e.clientX - rect.left - canvasX) / zoom - nodeWidth / 2; + const y = (e.clientY - rect.top - canvasY) / zoom - nodeHeight / 2; + + console.log('Position calculation:', JSON.stringify({ + clientX: e.clientX, + clientY: e.clientY, + rectLeft: rect.left, + rectTop: rect.top, + canvasX, + canvasY, + zoom, + x, + y + })); + + // Check if dropping into an expanded VoiceAllocator + let parentNodeId = null; + for (const expandedNodeId of expandedNodes) { + const contentsArea = document.getElementById(`voice-allocator-contents-${expandedNodeId}`); + if (contentsArea) { + const contentsRect = contentsArea.getBoundingClientRect(); + if (e.clientX >= contentsRect.left && e.clientX <= contentsRect.right && + e.clientY >= contentsRect.top && e.clientY <= contentsRect.bottom) { + parentNodeId = expandedNodeId; + console.log(`Dropping into VoiceAllocator ${expandedNodeId} at position (${x}, ${y})`); + break; + } + } + } + + // Add the node + console.log(`Adding node ${nodeType} at (${x}, ${y}) with parent ${parentNodeId}`); + const newNodeId = addNode(nodeType, x, y, parentNodeId); + + // Check if we should insert into a connection + if (pendingInsertionFromDrag && newNodeId) { + console.log('Pending insertion detected, will insert node into connection'); + // Defer insertion until after node is fully created + setTimeout(() => { + performConnectionInsertion(newNodeId, pendingInsertionFromDrag); + pendingInsertionFromDrag = null; + }, 100); + } + + // Clear the draggedNodeType and highlights + draggedNodeType = null; + clearConnectionHighlights(); + }); + + // Connection event handlers + editor.on("connectionCreated", (connection) => { + handleConnectionCreated(connection); + }); + + editor.on("connectionRemoved", (connection) => { + handleConnectionRemoved(connection); + }); + + // Node events + editor.on("nodeCreated", (nodeId) => { + setupNodeParameters(nodeId); + + // Add double-click handler for VoiceAllocator expansion + setTimeout(() => { + const nodeElement = document.getElementById(`node-${nodeId}`); + if (nodeElement) { + nodeElement.addEventListener('dblclick', (e) => { + // Prevent double-click from bubbling to canvas + e.stopPropagation(); + handleNodeDoubleClick(nodeId); + }); + } + }, 50); + }); + + // Track which node is being dragged + let draggingNodeId = null; + + // Track node drag start for undo/redo and connection insertion + drawflowDiv.addEventListener('mousedown', (e) => { + const nodeElement = e.target.closest('.drawflow-node'); + if (nodeElement && !e.target.closest('.input') && !e.target.closest('.output')) { + const nodeId = parseInt(nodeElement.id.replace('node-', '')); + const node = editor.getNodeFromId(nodeId); + if (node) { + nodeMoveTracker.set(nodeId, { x: node.pos_x, y: node.pos_y }); + draggingNodeId = nodeId; + } + } + }); + + // Check for connection insertion while dragging existing nodes + drawflowDiv.addEventListener('mousemove', (e) => { + if (draggingNodeId !== null) { + checkConnectionInsertion(draggingNodeId); + } + }); + + // Node moved - resize parent VoiceAllocator and check for connection insertion + editor.on("nodeMoved", (nodeId) => { + const node = editor.getNodeFromId(nodeId); + if (node && node.data.parentNodeId) { + resizeVoiceAllocatorToFit(node.data.parentNodeId); + } + + // Check if node should be inserted into a connection + checkConnectionInsertion(nodeId); + }); + + // Track node drag end for undo/redo and handle connection insertion + drawflowDiv.addEventListener('mouseup', (e) => { + // Check all tracked nodes for position changes and pending insertions + for (const [nodeId, oldPos] of nodeMoveTracker.entries()) { + const node = editor.getNodeFromId(nodeId); + const hasPendingInsertion = pendingNodeInsertions.has(nodeId); + + if (node) { + // Check for pending insertion first + if (hasPendingInsertion) { + const insertionMatch = pendingNodeInsertions.get(nodeId); + performConnectionInsertion(nodeId, insertionMatch); + pendingNodeInsertions.delete(nodeId); + } else if (node.pos_x !== oldPos.x || node.pos_y !== oldPos.y) { + // Position changed - record action + redoStack.length = 0; + undoStack.push({ + name: "graphMoveNode", + action: { + nodeId: nodeId, + oldPosition: oldPos, + newPosition: { x: node.pos_x, y: node.pos_y } + } + }); + updateMenu(); + } + } + } + // Clear tracker, dragging state, and highlights + nodeMoveTracker.clear(); + draggingNodeId = null; + clearConnectionHighlights(); + }); + + // Node removed - prevent deletion of template nodes + editor.on("nodeRemoved", (nodeId) => { + const nodeElement = document.getElementById(`node-${nodeId}`); + if (nodeElement && nodeElement.getAttribute('data-template-node') === 'true') { + console.warn('Cannot delete template nodes'); + // TODO: Re-add the node if it was deleted + return; + } + + // Get cached node data before removal + const cachedData = nodeDataCache.get(nodeId); + + if (cachedData && cachedData.backendId) { + // Call backend to remove the node + invoke('graph_remove_node', { + trackId: cachedData.trackId, + nodeId: cachedData.backendId + }).catch(err => { + console.error("Failed to remove node from backend:", err); + }); + + // Record action for undo (don't call execute since node is already removed from frontend) + redoStack.length = 0; + undoStack.push({ + name: "graphRemoveNode", + action: { + trackId: cachedData.trackId, + nodeId: nodeId, + backendId: cachedData.backendId, + nodeData: cachedData + } + }); + updateMenu(); + } + + // Stop oscilloscope visualization if this was an Oscilloscope node + stopOscilloscopeVisualization(nodeId); + + // Clean up parent-child tracking + const parentId = nodeParents.get(nodeId); + nodeParents.delete(nodeId); + + // Clean up node data cache + nodeDataCache.delete(nodeId); + + // Resize parent if needed + if (parentId) { + resizeVoiceAllocatorToFit(parentId); + } + }); + + }, 100); + + // Add a node to the graph + function addNode(nodeType, x, y, parentNodeId = null) { + if (!editor) return; + + const nodeDef = nodeTypes[nodeType]; + if (!nodeDef) return; + + const nodeId = nodeIdCounter++; + const html = nodeDef.getHTML(nodeId); + + // Count inputs and outputs by type + const inputsCount = nodeDef.inputs.length; + const outputsCount = nodeDef.outputs.length; + + // Add node to Drawflow + const drawflowNodeId = editor.addNode( + nodeType, + inputsCount, + outputsCount, + x, + y, + `node-${nodeType.toLowerCase()}`, + { nodeType, backendId: null, parentNodeId: parentNodeId }, + html + ); + + // Update all IDs in the HTML to use drawflowNodeId instead of nodeId + // This ensures parameter setup can find the correct elements + if (nodeId !== drawflowNodeId) { + setTimeout(() => { + const nodeElement = document.getElementById(`node-${drawflowNodeId}`); + if (nodeElement) { + // Update all elements with IDs containing the old nodeId + const elementsWithIds = nodeElement.querySelectorAll('[id*="-' + nodeId + '"]'); + elementsWithIds.forEach(el => { + const oldId = el.id; + const newId = oldId.replace('-' + nodeId, '-' + drawflowNodeId); + el.id = newId; + console.log(`Updated element ID: ${oldId} -> ${newId}`); + }); + } + }, 10); + } + + // Track parent-child relationship + if (parentNodeId !== null) { + nodeParents.set(drawflowNodeId, parentNodeId); + console.log(`Node ${drawflowNodeId} (${nodeType}) is child of VoiceAllocator ${parentNodeId}`); + + // Mark template nodes as non-deletable + const isTemplateNode = (nodeType === 'TemplateInput' || nodeType === 'TemplateOutput'); + + // Add CSS class to mark as child node + setTimeout(() => { + const nodeElement = document.getElementById(`node-${drawflowNodeId}`); + if (nodeElement) { + nodeElement.classList.add('child-node'); + nodeElement.setAttribute('data-parent-node', parentNodeId); + + if (isTemplateNode) { + nodeElement.classList.add('template-node'); + nodeElement.setAttribute('data-template-node', 'true'); + } + + // Only show if parent is currently expanded + if (!expandedNodes.has(parentNodeId)) { + nodeElement.style.display = 'none'; + } + } + + // Auto-resize parent VoiceAllocator after adding child node + resizeVoiceAllocatorToFit(parentNodeId); + }, 10); + } + + // Apply port styling based on signal types + setTimeout(() => { + styleNodePorts(drawflowNodeId, nodeDef); + }, 10); + + // Send command to backend + // Check editing context first (dedicated template view), then parent node (inline editing) + const trackInfo = getCurrentTrack(); + if (trackInfo === null) { + console.error('No track selected'); + alert('Please select a track first'); + editor.removeNodeId(`node-${drawflowNodeId}`); + return; + } + const trackId = trackInfo.trackId; + + // Determine if we're adding to a template or main graph + let commandName, commandArgs; + if (editingContext) { + // Adding to template in dedicated view + commandName = "graph_add_node_to_template"; + commandArgs = { + trackId: trackId, + voiceAllocatorId: editingContext.voiceAllocatorId, + nodeType: nodeType, + x: x, + y: y + }; + } else if (parentNodeId) { + // Adding to template inline (old approach, still supported for backwards compat) + commandName = "graph_add_node_to_template"; + commandArgs = { + trackId: trackId, + voiceAllocatorId: editor.getNodeFromId(parentNodeId).data.backendId, + nodeType: nodeType, + x: x, + y: y + }; + } else { + // Adding to main graph + commandName = "graph_add_node"; + commandArgs = { + trackId: trackId, + nodeType: nodeType, + x: x, + y: y + }; + } + + console.log(`[DEBUG] Invoking ${commandName} with args:`, commandArgs); + + // Create a promise that resolves when the GraphNodeAdded event arrives + const eventPromise = new Promise((resolve) => { + window.pendingNodeUpdate = { + drawflowNodeId, + nodeType, + resolve: (backendNodeId) => { + console.log(`[DEBUG] Event promise resolved with backend ID: ${backendNodeId}`); + resolve(backendNodeId); + } + }; + }); + + // Wait for both the invoke response and the event + Promise.all([ + invoke(commandName, commandArgs), + eventPromise + ]).then(([invokeReturnedId, eventBackendId]) => { + console.log(`[DEBUG] Both returned - invoke: ${invokeReturnedId}, event: ${eventBackendId}`); + + // Use the event's backend ID as it's the authoritative source + const backendNodeId = eventBackendId; + console.log(`Node ${nodeType} added with backend ID: ${backendNodeId} (parent: ${parentNodeId})`); + + // Store backend node ID using Drawflow's update method + editor.updateNodeDataFromId(drawflowNodeId, { nodeType, backendId: backendNodeId, parentNodeId: parentNodeId }); + + console.log("Verifying stored backend ID:", editor.getNodeFromId(drawflowNodeId).data.backendId); + + // Cache node data for undo/redo + const trackInfo = getCurrentTrack(); + nodeDataCache.set(drawflowNodeId, { + nodeType: nodeType, + backendId: backendNodeId, + position: { x, y }, + parentNodeId: parentNodeId, + trackId: trackInfo ? trackInfo.trackId : null + }); + + // Record action for undo (node is already added to frontend and backend) + redoStack.length = 0; + undoStack.push({ + name: "graphAddNode", + action: { + trackId: getCurrentMidiTrack(), + nodeType: nodeType, + position: { x, y }, + nodeId: drawflowNodeId, + backendId: backendNodeId + } + }); + updateMenu(); + + // If this is an AudioOutput node, automatically set it as the graph output + if (nodeType === "AudioOutput") { + console.log(`Setting node ${backendNodeId} as graph output`); + const trackInfo = getCurrentTrack(); + if (trackInfo !== null) { + invoke("graph_set_output_node", { + trackId: trackInfo.trackId, + nodeId: backendNodeId + }).then(() => { + console.log("Output node set successfully"); + }).catch(err => { + console.error("Failed to set output node:", err); + }); + } + } + + // If this is an AutomationInput node, create timeline curve + if (nodeType === "AutomationInput" && !parentNodeId) { + const trackInfo = getCurrentTrack(); + if (trackInfo !== null) { + const currentTrackId = trackInfo.trackId; + // Find the audio/MIDI track + const track = root.audioTracks?.find(t => t.audioTrackId === currentTrackId); + if (track) { + // Create curve parameter name: "automation.{nodeId}" + const curveName = `automation.${backendNodeId}`; + + // Check if curve already exists + if (!track.animationData.curves[curveName]) { + // Create the curve with a default keyframe at time 0, value 0 + const curve = track.animationData.getOrCreateCurve(curveName); + curve.addKeyframe({ + time: 0, + value: 0, + interpolation: 'linear', + easeIn: { x: 0.42, y: 0 }, + easeOut: { x: 0.58, y: 1 }, + idx: `${Date.now()}-${Math.random()}` + }); + console.log(`Initialized automation curve: ${curveName}`); + + // Redraw timeline if it's open + if (context.timeline?.requestRedraw) { + context.timeline.requestRedraw(); + } + } + } + } + } + + // If this is an Oscilloscope node, start the visualization + if (nodeType === "Oscilloscope") { + const trackInfo = getCurrentTrack(); + if (trackInfo !== null) { + const currentTrackId = trackInfo.trackId; + console.log(`Starting oscilloscope visualization for node ${drawflowNodeId} (backend ID: ${backendNodeId})`); + // Wait for DOM to update before starting visualization + setTimeout(() => { + startOscilloscopeVisualization(drawflowNodeId, currentTrackId, backendNodeId, editor); + }, 100); + } + } + + // If this is a VoiceAllocator, automatically create template I/O nodes inside it + if (nodeType === "VoiceAllocator") { + setTimeout(() => { + // Get the node position + const node = editor.getNodeFromId(drawflowNodeId); + if (node) { + // Create TemplateInput on the left + addNode("TemplateInput", node.pos_x + 50, node.pos_y + 100, drawflowNodeId); + // Create TemplateOutput on the right + addNode("TemplateOutput", node.pos_x + 450, node.pos_y + 100, drawflowNodeId); + } + }, 100); + } + }).catch(err => { + console.error("Failed to add node to backend:", err); + showError("Failed to add node: " + err); + }); + + return drawflowNodeId; + } + + // Auto-resize VoiceAllocator to fit its child nodes + function resizeVoiceAllocatorToFit(voiceAllocatorNodeId) { + if (!voiceAllocatorNodeId) return; + + const parentNode = editor.getNodeFromId(voiceAllocatorNodeId); + const parentElement = document.getElementById(`node-${voiceAllocatorNodeId}`); + if (!parentNode || !parentElement) return; + + // Find all child nodes + const childNodeIds = []; + for (const [childId, parentId] of nodeParents.entries()) { + if (parentId === voiceAllocatorNodeId) { + childNodeIds.push(childId); + } + } + + if (childNodeIds.length === 0) return; + + // Calculate bounding box of all child nodes + let minX = Infinity, minY = Infinity; + let maxX = -Infinity, maxY = -Infinity; + + for (const childId of childNodeIds) { + const childNode = editor.getNodeFromId(childId); + const childElement = document.getElementById(`node-${childId}`); + if (!childNode || !childElement) continue; + + const childWidth = childElement.offsetWidth || 200; + const childHeight = childElement.offsetHeight || 150; + + minX = Math.min(minX, childNode.pos_x); + minY = Math.min(minY, childNode.pos_y); + maxX = Math.max(maxX, childNode.pos_x + childWidth); + maxY = Math.max(maxY, childNode.pos_y + childHeight); + } + + // Add generous margin + const margin = 60; + const headerHeight = 120; // Space for VoiceAllocator header + + const requiredWidth = (maxX - minX) + (margin * 2); + const requiredHeight = (maxY - minY) + (margin * 2) + headerHeight; + + // Set minimum size + const finalWidth = Math.max(requiredWidth, 600); + const finalHeight = Math.max(requiredHeight, 400); + + // Only resize if expanded + if (expandedNodes.has(voiceAllocatorNodeId)) { + parentElement.style.width = `${finalWidth}px`; + parentElement.style.height = `${finalHeight}px`; + parentElement.style.minWidth = `${finalWidth}px`; + parentElement.style.minHeight = `${finalHeight}px`; + + console.log(`Resized VoiceAllocator ${voiceAllocatorNodeId} to ${finalWidth}x${finalHeight}`); + } + } + + // Style node ports based on signal types + function styleNodePorts(nodeId, nodeDef) { + const nodeElement = document.getElementById(`node-${nodeId}`); + if (!nodeElement) return; + + // Style input ports + const inputs = nodeElement.querySelectorAll(".input"); + inputs.forEach((input, index) => { + if (index < nodeDef.inputs.length) { + const portDef = nodeDef.inputs[index]; + // Add connector styling class directly to the input element + input.classList.add(getPortClass(portDef.type)); + // Add label + const label = document.createElement("span"); + label.textContent = portDef.name; + input.insertBefore(label, input.firstChild); + } + }); + + // Style output ports + const outputs = nodeElement.querySelectorAll(".output"); + outputs.forEach((output, index) => { + if (index < nodeDef.outputs.length) { + const portDef = nodeDef.outputs[index]; + // Add connector styling class directly to the output element + output.classList.add(getPortClass(portDef.type)); + // Add label + const label = document.createElement("span"); + label.textContent = portDef.name; + output.appendChild(label); + } + }); + } + + // Setup parameter event listeners for a node + function setupNodeParameters(nodeId) { + setTimeout(() => { + const nodeElement = document.getElementById(`node-${nodeId}`); + if (!nodeElement) return; + + const sliders = nodeElement.querySelectorAll('input[type="range"]'); + sliders.forEach(slider => { + // Track parameter change action for undo/redo + let paramAction = null; + + // Prevent node dragging when interacting with slider + slider.addEventListener("mousedown", (e) => { + e.stopPropagation(); + + // Initialize undo action + const paramId = parseInt(e.target.getAttribute("data-param")); + const currentValue = parseFloat(e.target.value); + const nodeData = editor.getNodeFromId(nodeId); + + if (nodeData && nodeData.data.backendId !== null) { + const currentTrackId = getCurrentMidiTrack(); + if (currentTrackId !== null) { + paramAction = actions.graphSetParameter.initialize( + currentTrackId, + nodeData.data.backendId, + paramId, + nodeId, + currentValue + ); + } + } + }); + slider.addEventListener("pointerdown", (e) => { + e.stopPropagation(); + }); + + slider.addEventListener("input", (e) => { + const paramId = parseInt(e.target.getAttribute("data-param")); + const value = parseFloat(e.target.value); + + console.log(`[setupNodeParameters] Slider input - nodeId: ${nodeId}, paramId: ${paramId}, value: ${value}`); + + // Update display + const nodeData = editor.getNodeFromId(nodeId); + if (nodeData) { + const nodeDef = nodeTypes[nodeData.name]; + console.log(`[setupNodeParameters] Found node type: ${nodeData.name}, parameters:`, nodeDef?.parameters); + if (nodeDef && nodeDef.parameters[paramId]) { + const param = nodeDef.parameters[paramId]; + console.log(`[setupNodeParameters] Looking for span: #${param.name}-${nodeId}`); + const displaySpan = nodeElement.querySelector(`#${param.name}-${nodeId}`); + console.log(`[setupNodeParameters] Found span:`, displaySpan); + if (displaySpan) { + // Special formatting for oscilloscope trigger mode + if (param.name === 'trigger_mode') { + const modes = ['Free', 'Rising', 'Falling', 'V/oct']; + displaySpan.textContent = modes[Math.round(value)] || 'Free'; + } + // Special formatting for Phaser rate in sync mode + else if (param.name === 'rate' && nodeData.name === 'Phaser') { + const syncCheckbox = nodeElement.querySelector(`#sync-${nodeId}`); + if (syncCheckbox && syncCheckbox.checked) { + const beatDivisions = [ + '4 bars', '2 bars', '1 bar', '1/2', '1/4', '1/8', '1/16', '1/32', '1/2T', '1/4T', '1/8T' + ]; + const idx = Math.round(value); + displaySpan.textContent = beatDivisions[Math.min(10, Math.max(0, idx))]; + } else { + displaySpan.textContent = value.toFixed(param.unit === 'Hz' ? 0 : 2); + } + } + else { + displaySpan.textContent = value.toFixed(param.unit === 'Hz' ? 0 : 2); + } + } + + // Update oscilloscope time scale if this is a time_scale parameter + if (param.name === 'time_scale' && oscilloscopeTimeScales) { + oscilloscopeTimeScales.set(nodeId, value); + console.log(`Updated oscilloscope time scale for node ${nodeId}: ${value}ms`); + } + } + + // Send to backend in real-time + if (nodeData.data.backendId !== null) { + const trackInfo = getCurrentTrack(); + if (trackInfo !== null) { + // Convert beat divisions to Hz for Phaser rate in sync mode + let backendValue = value; + if (nodeDef && nodeDef.parameters[paramId]) { + const param = nodeDef.parameters[paramId]; + if (param.name === 'rate' && nodeData.name === 'Phaser') { + const syncCheckbox = nodeElement.querySelector(`#sync-${nodeId}`); + if (syncCheckbox && syncCheckbox.checked && context.timelineWidget) { + const beatDivisions = [ + { label: '4 bars', multiplier: 16.0 }, + { label: '2 bars', multiplier: 8.0 }, + { label: '1 bar', multiplier: 4.0 }, + { label: '1/2', multiplier: 2.0 }, + { label: '1/4', multiplier: 1.0 }, + { label: '1/8', multiplier: 0.5 }, + { label: '1/16', multiplier: 0.25 }, + { label: '1/32', multiplier: 0.125 }, + { label: '1/2T', multiplier: 2.0/3.0 }, + { label: '1/4T', multiplier: 1.0/3.0 }, + { label: '1/8T', multiplier: 0.5/3.0 } + ]; + const idx = Math.min(10, Math.max(0, Math.round(value))); + const bpm = context.timelineWidget.timelineState.bpm; + const beatsPerSecond = bpm / 60.0; + const quarterNotesPerCycle = beatDivisions[idx].multiplier; + // Hz = how many cycles per second + backendValue = beatsPerSecond / quarterNotesPerCycle; + } + } + } + + invoke("graph_set_parameter", { + trackId: trackInfo.trackId, + nodeId: nodeData.data.backendId, + paramId: paramId, + value: backendValue + }).catch(err => { + console.error("Failed to set parameter:", err); + }); + } + } + } + }); + + // Finalize parameter change for undo/redo when slider is released + slider.addEventListener("change", (e) => { + const newValue = parseFloat(e.target.value); + + if (paramAction) { + actions.graphSetParameter.finalize(paramAction, newValue); + paramAction = null; + } + }); + }); + + // Handle select dropdowns + const selects = nodeElement.querySelectorAll('select[data-param]'); + selects.forEach(select => { + // Track parameter change action for undo/redo + let paramAction = null; + + // Prevent node dragging when interacting with select + select.addEventListener("mousedown", (e) => { + e.stopPropagation(); + + // Initialize undo action + const paramId = parseInt(e.target.getAttribute("data-param")); + const currentValue = parseFloat(e.target.value); + const nodeData = editor.getNodeFromId(nodeId); + + if (nodeData && nodeData.data.backendId !== null) { + const currentTrackId = getCurrentMidiTrack(); + if (currentTrackId !== null) { + paramAction = actions.graphSetParameter.initialize( + currentTrackId, + nodeData.data.backendId, + paramId, + nodeId, + currentValue + ); + } + } + }); + select.addEventListener("pointerdown", (e) => { + e.stopPropagation(); + }); + + select.addEventListener("change", (e) => { + const paramId = parseInt(e.target.getAttribute("data-param")); + const value = parseFloat(e.target.value); + + console.log(`[setupNodeParameters] Select change - nodeId: ${nodeId}, paramId: ${paramId}, value: ${value}`); + + // Update display span if it exists + const nodeData = editor.getNodeFromId(nodeId); + if (nodeData) { + const nodeDef = nodeTypes[nodeData.name]; + if (nodeDef && nodeDef.parameters[paramId]) { + const param = nodeDef.parameters[paramId]; + const displaySpan = nodeElement.querySelector(`#${param.name}-${nodeId}`); + if (displaySpan) { + // Update the span with the selected option text + displaySpan.textContent = e.target.options[e.target.selectedIndex].text; + } + } + + // Send to backend + if (nodeData.data.backendId !== null) { + const trackInfo = getCurrentTrack(); + if (trackInfo !== null) { + invoke("graph_set_parameter", { + trackId: trackInfo.trackId, + nodeId: nodeData.data.backendId, + paramId: paramId, + value: value + }).catch(err => { + console.error("Failed to set parameter:", err); + }); + } + } + } + + // Finalize undo action + if (paramAction) { + actions.graphSetParameter.finalize(paramAction, value); + paramAction = null; + } + }); + }); + + // Handle number inputs + const numberInputs = nodeElement.querySelectorAll('input[type="number"][data-param]'); + numberInputs.forEach(numberInput => { + // Track parameter change action for undo/redo + let paramAction = null; + + // Prevent node dragging when interacting with number input + numberInput.addEventListener("mousedown", (e) => { + e.stopPropagation(); + + // Initialize undo action + const paramId = parseInt(e.target.getAttribute("data-param")); + const currentValue = parseFloat(e.target.value); + const nodeData = editor.getNodeFromId(nodeId); + + if (nodeData && nodeData.data.backendId !== null) { + const currentTrackId = getCurrentMidiTrack(); + if (currentTrackId !== null) { + paramAction = actions.graphSetParameter.initialize( + currentTrackId, + nodeData.data.backendId, + paramId, + nodeId, + currentValue + ); + } + } + }); + numberInput.addEventListener("pointerdown", (e) => { + e.stopPropagation(); + }); + + numberInput.addEventListener("input", (e) => { + const paramId = parseInt(e.target.getAttribute("data-param")); + let value = parseFloat(e.target.value); + + // Validate and clamp to min/max + const min = parseFloat(e.target.min); + const max = parseFloat(e.target.max); + if (!isNaN(value)) { + value = Math.max(min, Math.min(max, value)); + } else { + value = 0; + } + + console.log(`[setupNodeParameters] Number input - nodeId: ${nodeId}, paramId: ${paramId}, value: ${value}`); + + // Update corresponding slider + const slider = nodeElement.querySelector(`input[type="range"][data-param="${paramId}"]`); + if (slider) { + slider.value = value; + } + + // Send to backend + const nodeData = editor.getNodeFromId(nodeId); + if (nodeData && nodeData.data.backendId !== null) { + const trackInfo = getCurrentTrack(); + if (trackInfo !== null) { + invoke("graph_set_parameter", { + trackId: trackInfo.trackId, + nodeId: nodeData.data.backendId, + paramId: paramId, + value: value + }).catch(err => { + console.error("Failed to set parameter:", err); + }); + } + } + }); + + numberInput.addEventListener("change", (e) => { + const value = parseFloat(e.target.value); + + // Finalize undo action + if (paramAction) { + actions.graphSetParameter.finalize(paramAction, value); + paramAction = null; + } + }); + }); + + // Handle checkboxes + const checkboxes = nodeElement.querySelectorAll('input[type="checkbox"][data-param]'); + checkboxes.forEach(checkbox => { + checkbox.addEventListener("change", (e) => { + const paramId = parseInt(e.target.getAttribute("data-param")); + const value = e.target.checked ? 1.0 : 0.0; + + console.log(`[setupNodeParameters] Checkbox change - nodeId: ${nodeId}, paramId: ${paramId}, value: ${value}`); + + // Send to backend + const nodeData = editor.getNodeFromId(nodeId); + if (nodeData && nodeData.data.backendId !== null) { + const trackInfo = getCurrentTrack(); + if (trackInfo !== null) { + invoke("graph_set_parameter", { + trackId: trackInfo.trackId, + nodeId: nodeData.data.backendId, + paramId: paramId, + value: value + }).then(() => { + console.log(`Parameter ${paramId} set to ${value}`); + }).catch(err => { + console.error("Failed to set parameter:", err); + }); + } + } + + // Special handling for Phaser sync checkbox + if (checkbox.id.startsWith('sync-')) { + const rateSlider = nodeElement.querySelector(`#rate-slider-${nodeId}`); + const rateDisplay = nodeElement.querySelector(`#rate-${nodeId}`); + const rateUnit = nodeElement.querySelector(`#rate-unit-${nodeId}`); + + if (rateSlider && rateDisplay && rateUnit) { + if (e.target.checked) { + // Sync mode: Use beat divisions + // Map slider 0-10 to different note divisions + // 0: 4 bars, 1: 2 bars, 2: 1 bar, 3: 1/2, 4: 1/4, 5: 1/8, 6: 1/16, 7: 1/32, 8: 1/2T, 9: 1/4T, 10: 1/8T + const beatDivisions = [ + { label: '4 bars', multiplier: 16.0 }, + { label: '2 bars', multiplier: 8.0 }, + { label: '1 bar', multiplier: 4.0 }, + { label: '1/2', multiplier: 2.0 }, + { label: '1/4', multiplier: 1.0 }, + { label: '1/8', multiplier: 0.5 }, + { label: '1/16', multiplier: 0.25 }, + { label: '1/32', multiplier: 0.125 }, + { label: '1/2T', multiplier: 2.0/3.0 }, + { label: '1/4T', multiplier: 1.0/3.0 }, + { label: '1/8T', multiplier: 0.5/3.0 } + ]; + + rateSlider.min = '0'; + rateSlider.max = '10'; + rateSlider.step = '1'; + const idx = Math.round(parseFloat(rateSlider.value) * 10 / 10); + rateSlider.value = Math.min(10, Math.max(0, idx)); + rateDisplay.textContent = beatDivisions[parseInt(rateSlider.value)].label; + rateUnit.textContent = ''; + } else { + // Free mode: Hz + rateSlider.min = '0.1'; + rateSlider.max = '10.0'; + rateSlider.step = '0.1'; + rateDisplay.textContent = parseFloat(rateSlider.value).toFixed(1); + rateUnit.textContent = ' Hz'; + } + } + } + }); + }); + + // Handle Load Sample button for SimpleSampler + const loadSampleBtn = nodeElement.querySelector(".load-sample-btn"); + if (loadSampleBtn) { + loadSampleBtn.addEventListener("mousedown", (e) => e.stopPropagation()); + loadSampleBtn.addEventListener("pointerdown", (e) => e.stopPropagation()); + loadSampleBtn.addEventListener("click", async (e) => { + e.stopPropagation(); + + const nodeData = editor.getNodeFromId(nodeId); + if (!nodeData || nodeData.data.backendId === null) { + showError("Node not yet created on backend"); + return; + } + + const currentTrackId = getCurrentMidiTrack(); + if (currentTrackId === null) { + showError("No MIDI track selected"); + return; + } + + try { + const filePath = await openFileDialog({ + title: "Load Audio Sample", + filters: [{ + name: "Audio Files", + extensions: audioExtensions + }] + }); + + if (filePath) { + await invoke("sampler_load_sample", { + trackId: currentTrackId, + nodeId: nodeData.data.backendId, + filePath: filePath + }); + + // Update UI to show filename + const sampleInfo = nodeElement.querySelector(`#sample-info-${nodeId}`); + if (sampleInfo) { + const filename = filePath.split('/').pop().split('\\').pop(); + sampleInfo.textContent = filename; + } + } + } catch (err) { + console.error("Failed to load sample:", err); + showError(`Failed to load sample: ${err}`); + } + }); + } + + // Handle Add Layer button for MultiSampler + const addLayerBtn = nodeElement.querySelector(".add-layer-btn"); + if (addLayerBtn) { + addLayerBtn.addEventListener("mousedown", (e) => e.stopPropagation()); + addLayerBtn.addEventListener("pointerdown", (e) => e.stopPropagation()); + addLayerBtn.addEventListener("click", async (e) => { + e.stopPropagation(); + + const nodeData = editor.getNodeFromId(nodeId); + if (!nodeData || nodeData.data.backendId === null) { + showError("Node not yet created on backend"); + return; + } + + const currentTrackId = getCurrentMidiTrack(); + if (currentTrackId === null) { + showError("No MIDI track selected"); + return; + } + + try { + const filePath = await openFileDialog({ + title: "Add Sample Layer", + filters: [{ + name: "Audio Files", + extensions: audioExtensions + }] + }); + + if (filePath) { + // Show dialog to configure layer mapping + const layerConfig = await showLayerConfigDialog(filePath); + + if (layerConfig) { + await invoke("multi_sampler_add_layer", { + trackId: currentTrackId, + nodeId: nodeData.data.backendId, + filePath: filePath, + keyMin: layerConfig.keyMin, + keyMax: layerConfig.keyMax, + rootKey: layerConfig.rootKey, + velocityMin: layerConfig.velocityMin, + velocityMax: layerConfig.velocityMax, + loopStart: layerConfig.loopStart, + loopEnd: layerConfig.loopEnd, + loopMode: layerConfig.loopMode + }); + + // Wait a bit for the audio thread to process the add command + await new Promise(resolve => setTimeout(resolve, 100)); + + // Refresh the layers list + await refreshSampleLayersList(nodeId); + } + } + } catch (err) { + console.error("Failed to add layer:", err); + showError(`Failed to add layer: ${err}`); + } + }); + } + + // Handle Import Folder button for MultiSampler + const importFolderBtn = nodeElement.querySelector(".import-folder-btn"); + if (importFolderBtn) { + importFolderBtn.addEventListener("mousedown", (e) => e.stopPropagation()); + importFolderBtn.addEventListener("pointerdown", (e) => e.stopPropagation()); + importFolderBtn.addEventListener("click", async (e) => { + e.stopPropagation(); + + const nodeData = editor.getNodeFromId(nodeId); + if (!nodeData || nodeData.data.backendId === null) { + showError("Node not yet created on backend"); + return; + } + + const currentTrackId = getCurrentMidiTrack(); + if (currentTrackId === null) { + showError("No MIDI track selected"); + return; + } + + try { + await showFolderImportDialog(currentTrackId, nodeData.data.backendId, nodeId); + } catch (err) { + console.error("Failed to import folder:", err); + showError(`Failed to import folder: ${err}`); + } + }); + } + }, 100); + } + + // Handle double-click on nodes (for VoiceAllocator template editing) + function handleNodeDoubleClick(nodeId) { + const node = editor.getNodeFromId(nodeId); + if (!node) return; + + // Only VoiceAllocator nodes can be opened for template editing + if (node.data.nodeType !== 'VoiceAllocator') return; + + // Don't allow entering templates when already editing a template + if (editingContext) { + showError("Cannot nest template editing - exit current template first"); + return; + } + + // Get the backend ID and node name + if (node.data.backendId === null) { + showError("VoiceAllocator not yet created on backend"); + return; + } + + // Enter template editing mode + const nodeName = node.name || 'VoiceAllocator'; + enterTemplate(node.data.backendId, nodeName); + } + + // Refresh the layers list for a MultiSampler node + async function refreshSampleLayersList(nodeId) { + const nodeData = editor.getNodeFromId(nodeId); + if (!nodeData || nodeData.data.backendId === null) { + return; + } + + const currentTrackId = getCurrentMidiTrack(); + if (currentTrackId === null) { + return; + } + + try { + const layers = await invoke("multi_sampler_get_layers", { + trackId: currentTrackId, + nodeId: nodeData.data.backendId + }); + + // Find the node element and query within it for the layers list + const nodeElement = document.querySelector(`#node-${nodeId}`); + if (!nodeElement) return; + + const layersList = nodeElement.querySelector('[id^="sample-layers-list-"]'); + const layersContainer = nodeElement.querySelector('[id^="sample-layers-container-"]'); + + if (!layersList) return; + + // Prevent scroll events from bubbling to canvas + if (layersContainer && !layersContainer.dataset.scrollListenerAdded) { + layersContainer.addEventListener('wheel', (e) => { + e.stopPropagation(); + }, { passive: false }); + layersContainer.dataset.scrollListenerAdded = 'true'; + } + + if (layers.length === 0) { + layersList.innerHTML = 'No layers loaded'; + } else { + layersList.innerHTML = layers.map((layer, index) => { + const filename = layer.file_path.split('/').pop().split('\\').pop(); + const keyRange = `${midiToNoteName(layer.key_min)}-${midiToNoteName(layer.key_max)}`; + const rootNote = midiToNoteName(layer.root_key); + const velRange = `${layer.velocity_min}-${layer.velocity_max}`; + + return ` + + ${filename} + ${keyRange} + ${rootNote} + ${velRange} + +
    + + +
    + + + `; + }).join(''); + + // Add event listeners for edit buttons + const editButtons = layersList.querySelectorAll('.btn-edit-layer'); + editButtons.forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const index = parseInt(btn.dataset.index); + const layer = layers[index]; + + // Show edit dialog with current values + const layerConfig = await showLayerConfigDialog(layer.file_path, { + keyMin: layer.key_min, + keyMax: layer.key_max, + rootKey: layer.root_key, + velocityMin: layer.velocity_min, + velocityMax: layer.velocity_max, + loopStart: layer.loop_start, + loopEnd: layer.loop_end, + loopMode: layer.loop_mode + }); + + if (layerConfig) { + try { + await invoke("multi_sampler_update_layer", { + trackId: currentTrackId, + nodeId: nodeData.data.backendId, + layerIndex: index, + keyMin: layerConfig.keyMin, + keyMax: layerConfig.keyMax, + rootKey: layerConfig.rootKey, + velocityMin: layerConfig.velocityMin, + velocityMax: layerConfig.velocityMax, + loopStart: layerConfig.loopStart, + loopEnd: layerConfig.loopEnd, + loopMode: layerConfig.loopMode + }); + + // Refresh the list + await refreshSampleLayersList(nodeId); + } catch (err) { + console.error("Failed to update layer:", err); + showError(`Failed to update layer: ${err}`); + } + } + }); + }); + + // Add event listeners for delete buttons + const deleteButtons = layersList.querySelectorAll('.btn-delete-layer'); + deleteButtons.forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const index = parseInt(btn.dataset.index); + const layer = layers[index]; + const filename = layer.file_path.split('/').pop().split('\\').pop(); + + if (confirm(`Delete layer "${filename}"?`)) { + try { + await invoke("multi_sampler_remove_layer", { + trackId: currentTrackId, + nodeId: nodeData.data.backendId, + layerIndex: index + }); + + // Refresh the list + await refreshSampleLayersList(nodeId); + } catch (err) { + console.error("Failed to remove layer:", err); + showError(`Failed to remove layer: ${err}`); + } + } + }); + }); + } + } catch (err) { + console.error("Failed to get layers:", err); + } + } + + // Push nodes away from a point using gaussian falloff + function pushNodesAway(centerX, centerY, maxDistance, excludeNodeId) { + const module = editor.module; + const allNodes = editor.drawflow.drawflow[module]?.data || {}; + + // Gaussian parameters + const sigma = maxDistance / 3; // Standard deviation for falloff + const maxPush = 150; // Maximum push distance at the center + + for (const [id, node] of Object.entries(allNodes)) { + const nodeIdNum = parseInt(id); + if (nodeIdNum === excludeNodeId) continue; + + // Calculate distance from center + const dx = node.pos_x - centerX; + const dy = node.pos_y - centerY; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance < maxDistance && distance > 0) { + // Calculate push strength using gaussian falloff + const falloff = Math.exp(-(distance * distance) / (2 * sigma * sigma)); + const pushStrength = maxPush * falloff; + + // Calculate push direction (normalized) + const dirX = dx / distance; + const dirY = dy / distance; + + // Calculate new position + const newX = node.pos_x + dirX * pushStrength; + const newY = node.pos_y + dirY * pushStrength; + + // Update position in the data structure + node.pos_x = newX; + node.pos_y = newY; + + // Update the DOM element position + const nodeElement = document.getElementById(`node-${nodeIdNum}`); + if (nodeElement) { + nodeElement.style.left = newX + 'px'; + nodeElement.style.top = newY + 'px'; + } + + // Trigger connection redraw + editor.updateConnectionNodes(`node-${nodeIdNum}`); + } + } + } + + // Perform the actual connection insertion + function performConnectionInsertion(nodeId, match) { + + const node = editor.getNodeFromId(nodeId); + const sourceNode = editor.getNodeFromId(match.sourceNodeId); + const targetNode = editor.getNodeFromId(match.targetNodeId); + + if (!node || !sourceNode || !targetNode) { + console.error("Missing nodes for insertion"); + return; + } + + // Position the node between source and target + const sourceElement = document.getElementById(`node-${match.sourceNodeId}`); + const targetElement = document.getElementById(`node-${match.targetNodeId}`); + + if (sourceElement && targetElement) { + const sourceRect = sourceElement.getBoundingClientRect(); + const targetRect = targetElement.getBoundingClientRect(); + + // Calculate midpoint position + const newX = (sourceNode.pos_x + sourceRect.width + targetNode.pos_x) / 2 - 80; // Approximate node half-width + const newY = (sourceNode.pos_y + targetNode.pos_y) / 2 - 50; // Approximate node half-height + + // Update node position in data structure + node.pos_x = newX; + node.pos_y = newY; + + // Update the DOM element position + const nodeElement = document.getElementById(`node-${nodeId}`); + if (nodeElement) { + nodeElement.style.left = newX + 'px'; + nodeElement.style.top = newY + 'px'; + } + + // Trigger connection redraw for this node + editor.updateConnectionNodes(`node-${nodeId}`); + + // Push surrounding nodes away with gaussian falloff + pushNodesAway(newX, newY, 400, nodeId); // 400px influence radius + } + + // Remove the old connection + suppressActionRecording = true; + editor.removeSingleConnection( + match.sourceNodeId, + match.targetNodeId, + match.sourceOutputClass, + match.targetInputClass + ); + + // Create new connections: source -> node -> target + // Connection 1: source output -> node input + setTimeout(() => { + editor.addConnection( + match.sourceNodeId, + nodeId, + match.sourceOutputClass, + `input_${match.nodeInputPort + 1}` + ); + + // Connection 2: node output -> target input + setTimeout(() => { + editor.addConnection( + nodeId, + match.targetNodeId, + `output_${match.nodeOutputPort + 1}`, + match.targetInputClass + ); + + suppressActionRecording = false; + }, 50); + }, 50); + } + + // Check if cursor position during drag is near a connection + function checkConnectionInsertionDuringDrag(dragEvent, nodeDef) { + const drawflowDiv = container.querySelector("#drawflow"); + if (!drawflowDiv || !editor) return; + + const rect = drawflowDiv.getBoundingClientRect(); + const canvasX = editor.canvas_x || 0; + const canvasY = editor.canvas_y || 0; + const zoom = editor.zoom || 1; + + // Calculate cursor position in canvas coordinates + const cursorX = (dragEvent.clientX - rect.left - canvasX) / zoom; + const cursorY = (dragEvent.clientY - rect.top - canvasY) / zoom; + + // Get all connections in the current module + const module = editor.module; + const allNodes = editor.drawflow.drawflow[module]?.data || {}; + + // Distance threshold for insertion (in pixels) + const insertionThreshold = 30; + + let bestMatch = null; + let bestDistance = insertionThreshold; + + // Check each connection + for (const [sourceNodeId, sourceNode] of Object.entries(allNodes)) { + for (const [outputKey, outputData] of Object.entries(sourceNode.outputs)) { + for (const connection of outputData.connections) { + const targetNodeId = connection.node; + const targetNode = allNodes[targetNodeId]; + + if (!targetNode) continue; + + // Get source and target positions + const sourceElement = document.getElementById(`node-${sourceNodeId}`); + const targetElement = document.getElementById(`node-${targetNodeId}`); + + if (!sourceElement || !targetElement) continue; + + const sourceRect = sourceElement.getBoundingClientRect(); + const targetRect = targetElement.getBoundingClientRect(); + + // Calculate output port position (right side of source node) + const sourceX = sourceNode.pos_x + sourceRect.width; + const sourceY = sourceNode.pos_y + sourceRect.height / 2; + + // Calculate input port position (left side of target node) + const targetX = targetNode.pos_x; + const targetY = targetNode.pos_y + targetRect.height / 2; + + // Calculate distance from cursor to connection line + const distance = distanceToLineSegment( + cursorX, cursorY, + sourceX, sourceY, + targetX, targetY + ); + + // Check if this is the closest connection + if (distance < bestDistance) { + // Check port compatibility + const sourcePortIndex = parseInt(outputKey.replace('output_', '')) - 1; + const targetPortIndex = parseInt(connection.output.replace('input_', '')) - 1; + + const sourceDef = nodeTypes[sourceNode.name]; + const targetDef = nodeTypes[targetNode.name]; + + if (!sourceDef || !targetDef) continue; + + // Get the signal type of the connection + if (sourcePortIndex >= sourceDef.outputs.length || + targetPortIndex >= targetDef.inputs.length) continue; + + const connectionType = sourceDef.outputs[sourcePortIndex].type; + + // Check if the dragged node has compatible input and output + let compatibleInputIndex = -1; + let compatibleOutputIndex = -1; + + // Find first compatible input and output + for (let i = 0; i < nodeDef.inputs.length; i++) { + if (nodeDef.inputs[i].type === connectionType) { + compatibleInputIndex = i; + break; + } + } + + for (let i = 0; i < nodeDef.outputs.length; i++) { + if (nodeDef.outputs[i].type === connectionType) { + compatibleOutputIndex = i; + break; + } + } + + if (compatibleInputIndex !== -1 && compatibleOutputIndex !== -1) { + bestDistance = distance; + bestMatch = { + sourceNodeId: parseInt(sourceNodeId), + targetNodeId: parseInt(targetNodeId), + sourcePort: sourcePortIndex, + targetPort: targetPortIndex, + nodeInputPort: compatibleInputIndex, + nodeOutputPort: compatibleOutputIndex, + connectionType: connectionType, + sourceOutputClass: outputKey, + targetInputClass: connection.output, + insertX: cursorX, + insertY: cursorY + }; + } + } + } + } + } + + // If we found a match, highlight the connection and store it + if (bestMatch) { + highlightConnectionForInsertion(bestMatch); + pendingInsertionFromDrag = bestMatch; + } else { + clearConnectionHighlights(); + pendingInsertionFromDrag = null; + } + } + + // Check if a node can be inserted into a connection + function checkConnectionInsertion(nodeId) { + const node = editor.getNodeFromId(nodeId); + if (!node) return; + + const nodeDef = nodeTypes[node.name]; + if (!nodeDef) return; + + // Check if node has any connections - skip if it does + let hasConnections = false; + for (const [inputKey, inputData] of Object.entries(node.inputs)) { + if (inputData.connections && inputData.connections.length > 0) { + hasConnections = true; + break; + } + } + if (!hasConnections) { + for (const [outputKey, outputData] of Object.entries(node.outputs)) { + if (outputData.connections && outputData.connections.length > 0) { + hasConnections = true; + break; + } + } + } + + if (hasConnections) { + clearConnectionHighlights(); + pendingNodeInsertions.delete(nodeId); + return; + } + + // Get node center position + const nodeElement = document.getElementById(`node-${nodeId}`); + if (!nodeElement) return; + + const nodeRect = nodeElement.getBoundingClientRect(); + const nodeCenterX = node.pos_x + nodeRect.width / 2; + const nodeCenterY = node.pos_y + nodeRect.height / 2; + + // Get all connections in the current module + const module = editor.module; + const allNodes = editor.drawflow.drawflow[module]?.data || {}; + + // Distance threshold for insertion (in pixels) + const insertionThreshold = 30; + + let bestMatch = null; + let bestDistance = insertionThreshold; + + // Check each connection + for (const [sourceNodeId, sourceNode] of Object.entries(allNodes)) { + if (parseInt(sourceNodeId) === nodeId) continue; // Skip the node being dragged + + for (const [outputKey, outputData] of Object.entries(sourceNode.outputs)) { + for (const connection of outputData.connections) { + const targetNodeId = connection.node; + const targetNode = allNodes[targetNodeId]; + + if (!targetNode || parseInt(targetNodeId) === nodeId) continue; + + // Get source and target positions + const sourceElement = document.getElementById(`node-${sourceNodeId}`); + const targetElement = document.getElementById(`node-${targetNodeId}`); + + if (!sourceElement || !targetElement) continue; + + const sourceRect = sourceElement.getBoundingClientRect(); + const targetRect = targetElement.getBoundingClientRect(); + + // Calculate output port position (right side of source node) + const sourceX = sourceNode.pos_x + sourceRect.width; + const sourceY = sourceNode.pos_y + sourceRect.height / 2; + + // Calculate input port position (left side of target node) + const targetX = targetNode.pos_x; + const targetY = targetNode.pos_y + targetRect.height / 2; + + // Calculate distance from node center to connection line + const distance = distanceToLineSegment( + nodeCenterX, nodeCenterY, + sourceX, sourceY, + targetX, targetY + ); + + // Check if this is the closest connection + if (distance < bestDistance) { + // Check port compatibility + const sourcePortIndex = parseInt(outputKey.replace('output_', '')) - 1; + const targetPortIndex = parseInt(connection.output.replace('input_', '')) - 1; + + const sourceDef = nodeTypes[sourceNode.name]; + const targetDef = nodeTypes[targetNode.name]; + + if (!sourceDef || !targetDef) continue; + + // Get the signal type of the connection + if (sourcePortIndex >= sourceDef.outputs.length || + targetPortIndex >= targetDef.inputs.length) continue; + + const connectionType = sourceDef.outputs[sourcePortIndex].type; + + // Check if the dragged node has compatible input and output + let hasCompatibleInput = false; + let hasCompatibleOutput = false; + let compatibleInputIndex = -1; + let compatibleOutputIndex = -1; + + // Find first compatible input and output + for (let i = 0; i < nodeDef.inputs.length; i++) { + if (nodeDef.inputs[i].type === connectionType) { + hasCompatibleInput = true; + compatibleInputIndex = i; + break; + } + } + + for (let i = 0; i < nodeDef.outputs.length; i++) { + if (nodeDef.outputs[i].type === connectionType) { + hasCompatibleOutput = true; + compatibleOutputIndex = i; + break; + } + } + + if (hasCompatibleInput && hasCompatibleOutput) { + bestDistance = distance; + bestMatch = { + sourceNodeId: parseInt(sourceNodeId), + targetNodeId: parseInt(targetNodeId), + sourcePort: sourcePortIndex, + targetPort: targetPortIndex, + nodeInputPort: compatibleInputIndex, + nodeOutputPort: compatibleOutputIndex, + connectionType: connectionType, + sourceOutputClass: outputKey, + targetInputClass: connection.output + }; + } + } + } + } + } + + // If we found a match, highlight the connection + if (bestMatch) { + highlightConnectionForInsertion(bestMatch); + // Store the match in the Map for use on mouseup + pendingNodeInsertions.set(nodeId, bestMatch); + } else { + clearConnectionHighlights(); + pendingNodeInsertions.delete(nodeId); + } + } + + // Track which connection is highlighted for insertion + let highlightedConnection = null; + let highlightInterval = null; + let pendingInsertionFromDrag = null; + + // Track pending insertions for existing nodes being dragged + const pendingNodeInsertions = new Map(); // nodeId -> insertion match + + // Apply highlight to the tracked connection + function applyConnectionHighlight() { + if (!highlightedConnection) return; + + const connectionElement = document.querySelector( + `.connection.node_in_node-${highlightedConnection.targetNodeId}.node_out_node-${highlightedConnection.sourceNodeId}` + ); + + if (connectionElement && !connectionElement.classList.contains('connection-insertion-highlight')) { + connectionElement.classList.add('connection-insertion-highlight'); + } + } + + // Highlight a connection that can receive the node + function highlightConnectionForInsertion(match) { + // Store the connection to highlight + highlightedConnection = match; + + // Clear any existing interval + if (highlightInterval) { + clearInterval(highlightInterval); + } + + // Apply highlight immediately + applyConnectionHighlight(); + + // Keep re-applying in case Drawflow redraws + highlightInterval = setInterval(applyConnectionHighlight, 50); + } + + // Clear connection insertion highlights + function clearConnectionHighlights() { + // Stop the interval + if (highlightInterval) { + clearInterval(highlightInterval); + highlightInterval = null; + } + + highlightedConnection = null; + + // Remove all highlight classes + document.querySelectorAll('.connection-insertion-highlight').forEach(el => { + el.classList.remove('connection-insertion-highlight'); + }); + } + + // Handle connection creation + function handleConnectionCreated(connection) { + console.log("handleConnectionCreated called:", connection); + const outputNode = editor.getNodeFromId(connection.output_id); + const inputNode = editor.getNodeFromId(connection.input_id); + + console.log("Output node:", outputNode, "Input node:", inputNode); + if (!outputNode || !inputNode) { + console.log("Missing node - returning"); + return; + } + + console.log("Output node name:", outputNode.name, "Input node name:", inputNode.name); + const outputDef = nodeTypes[outputNode.name]; + const inputDef = nodeTypes[inputNode.name]; + + console.log("Output def:", outputDef, "Input def:", inputDef); + if (!outputDef || !inputDef) { + console.log("Missing node definition - returning"); + return; + } + + // Extract port indices from connection class names + // Drawflow uses 1-based indexing, but our arrays are 0-based + const outputPort = parseInt(connection.output_class.replace("output_", "")) - 1; + const inputPort = parseInt(connection.input_class.replace("input_", "")) - 1; + + console.log("Port indices (0-based) - output:", outputPort, "input:", inputPort); + console.log("Output class:", connection.output_class, "Input class:", connection.input_class); + + // Validate signal types + console.log("Checking port bounds - outputPort:", outputPort, "< outputs.length:", outputDef.outputs.length, "inputPort:", inputPort, "< inputs.length:", inputDef.inputs.length); + if (outputPort < outputDef.outputs.length && inputPort < inputDef.inputs.length) { + const outputType = outputDef.outputs[outputPort].type; + const inputType = inputDef.inputs[inputPort].type; + + console.log("Signal types - output:", outputType, "input:", inputType); + + if (outputType !== inputType) { + console.log("TYPE MISMATCH! Removing connection"); + // Type mismatch - remove connection + editor.removeSingleConnection( + connection.output_id, + connection.input_id, + connection.output_class, + connection.input_class + ); + showError(`Cannot connect ${outputType} to ${inputType}`); + return; + } + + console.log("Types match - proceeding with connection"); + + // Auto-switch Oscilloscope to V/oct trigger mode when connecting to V/oct input + if (inputNode.name === 'Oscilloscope' && inputPort === 1) { + console.log(`Auto-switching Oscilloscope node ${connection.input_id} to V/oct trigger mode`); + // Set trigger_mode parameter (id: 1) to value 3 (V/oct) + const triggerModeSlider = document.querySelector(`#node-${connection.input_id} input[data-param="1"]`); + const triggerModeSpan = document.querySelector(`#trigger_mode-${connection.input_id}`); + if (triggerModeSlider) { + triggerModeSlider.value = 3; + if (triggerModeSpan) { + triggerModeSpan.textContent = 'V/oct'; + } + // Update backend parameter + if (inputNode.data.backendId !== null) { + const currentTrackId = getCurrentMidiTrack(); + if (currentTrackId !== null) { + invoke("graph_set_parameter", { + trackId: currentTrackId, + nodeId: inputNode.data.backendId, + paramId: 1, + value: 3.0 + }).catch(err => console.error("Failed to set V/oct trigger mode:", err)); + } + } + } + } + + // Style the connection based on signal type + setTimeout(() => { + const connectionElement = document.querySelector( + `.connection.node_in_node-${connection.input_id}.node_out_node-${connection.output_id}` + ); + if (connectionElement) { + connectionElement.classList.add(`connection-${outputType}`); + } + }, 10); + + // Send to backend + console.log("Backend IDs - output:", outputNode.data.backendId, "input:", inputNode.data.backendId); + if (outputNode.data.backendId !== null && inputNode.data.backendId !== null) { + const trackInfo = getCurrentTrack(); + if (trackInfo === null) return; + const currentTrackId = trackInfo.trackId; + + // Check if we're in template editing mode (dedicated view) + if (editingContext) { + // Connecting in template view + console.log(`Connecting in template ${editingContext.voiceAllocatorId}: node ${outputNode.data.backendId} port ${outputPort} -> node ${inputNode.data.backendId} port ${inputPort}`); + invoke("graph_connect_in_template", { + trackId: currentTrackId, + voiceAllocatorId: editingContext.voiceAllocatorId, + fromNode: outputNode.data.backendId, + fromPort: outputPort, + toNode: inputNode.data.backendId, + toPort: inputPort + }).then(() => { + console.log("Template connection successful"); + }).catch(err => { + console.error("Failed to connect nodes in template:", err); + showError("Template connection failed: " + err); + // Remove the connection + editor.removeSingleConnection( + connection.output_id, + connection.input_id, + connection.output_class, + connection.input_class + ); + }); + } else { + // Check if both nodes are inside the same VoiceAllocator (inline editing) + // Convert connection IDs to numbers to match Map keys + const outputId = parseInt(connection.output_id); + const inputId = parseInt(connection.input_id); + const outputParent = nodeParents.get(outputId); + const inputParent = nodeParents.get(inputId); + console.log(`Parent detection - output node ${outputId} parent: ${outputParent}, input node ${inputId} parent: ${inputParent}`); + + if (outputParent && inputParent && outputParent === inputParent) { + // Both nodes are inside the same VoiceAllocator - connect in template (inline editing) + const parentNode = editor.getNodeFromId(outputParent); + console.log(`Connecting in VoiceAllocator template ${parentNode.data.backendId}: node ${outputNode.data.backendId} port ${outputPort} -> node ${inputNode.data.backendId} port ${inputPort}`); + invoke("graph_connect_in_template", { + trackId: currentTrackId, + voiceAllocatorId: parentNode.data.backendId, + fromNode: outputNode.data.backendId, + fromPort: outputPort, + toNode: inputNode.data.backendId, + toPort: inputPort + }).then(() => { + console.log("Template connection successful"); + }).catch(err => { + console.error("Failed to connect nodes in template:", err); + showError("Template connection failed: " + err); + // Remove the connection + editor.removeSingleConnection( + connection.output_id, + connection.input_id, + connection.output_class, + connection.input_class + ); + }); + } else { + // Normal connection in main graph (skip if action is handling it) + console.log(`Connecting: node ${outputNode.data.backendId} port ${outputPort} -> node ${inputNode.data.backendId} port ${inputPort}`); + invoke("graph_connect", { + trackId: currentTrackId, + fromNode: outputNode.data.backendId, + fromPort: outputPort, + toNode: inputNode.data.backendId, + toPort: inputPort + }).then(async () => { + console.log("Connection successful"); + + // Record action for undo (only if not suppressing) + if (!suppressActionRecording) { + redoStack.length = 0; + undoStack.push({ + name: "graphAddConnection", + action: { + trackId: currentTrackId, + fromNode: outputNode.data.backendId, + fromPort: outputPort, + toNode: inputNode.data.backendId, + toPort: inputPort, + // Store frontend IDs for disconnection + frontendFromId: connection.output_id, + frontendToId: connection.input_id, + fromPortClass: connection.output_class, + toPortClass: connection.input_class + } + }); + } + + // Auto-name AutomationInput nodes when connected + await updateAutomationName( + currentTrackId, + outputNode.data.backendId, + inputNode.data.backendId, + connection.input_class + ); + + updateMenu(); + }).catch(err => { + console.error("Failed to connect nodes:", err); + showError("Connection failed: " + err); + // Remove the connection + editor.removeSingleConnection( + connection.output_id, + connection.input_id, + connection.output_class, + connection.input_class + ); + }); + } + } + } + + } else { + console.log("Port validation FAILED - ports out of bounds"); + } + } + + // Handle connection removal + function handleConnectionRemoved(connection) { + const outputNode = editor.getNodeFromId(connection.output_id); + const inputNode = editor.getNodeFromId(connection.input_id); + + if (!outputNode || !inputNode) return; + + // Drawflow uses 1-based indexing, but our arrays are 0-based + const outputPort = parseInt(connection.output_class.replace("output_", "")) - 1; + const inputPort = parseInt(connection.input_class.replace("input_", "")) - 1; + + // Auto-switch Oscilloscope back to Free mode when disconnecting V/oct input + if (inputNode.name === 'Oscilloscope' && inputPort === 1) { + console.log(`Auto-switching Oscilloscope node ${connection.input_id} back to Free trigger mode`); + const triggerModeSlider = document.querySelector(`#node-${connection.input_id} input[data-param="1"]`); + const triggerModeSpan = document.querySelector(`#trigger_mode-${connection.input_id}`); + if (triggerModeSlider) { + triggerModeSlider.value = 0; + if (triggerModeSpan) { + triggerModeSpan.textContent = 'Free'; + } + // Update backend parameter + if (inputNode.data.backendId !== null) { + const currentTrackId = getCurrentMidiTrack(); + if (currentTrackId !== null) { + invoke("graph_set_parameter", { + trackId: currentTrackId, + nodeId: inputNode.data.backendId, + paramId: 1, + value: 0.0 + }).catch(err => console.error("Failed to set Free trigger mode:", err)); + } + } + } + } + + // Send to backend + if (outputNode.data.backendId !== null && inputNode.data.backendId !== null) { + const trackInfo = getCurrentTrack(); + if (trackInfo !== null) { + invoke("graph_disconnect", { + trackId: trackInfo.trackId, + fromNode: outputNode.data.backendId, + fromPort: outputPort, + toNode: inputNode.data.backendId, + toPort: inputPort + }).then(() => { + // Record action for undo (only if not suppressing) + if (!suppressActionRecording) { + redoStack.length = 0; + undoStack.push({ + name: "graphRemoveConnection", + action: { + trackId: trackInfo.trackId, + fromNode: outputNode.data.backendId, + fromPort: outputPort, + toNode: inputNode.data.backendId, + toPort: inputPort, + // Store frontend IDs for reconnection + frontendFromId: connection.output_id, + frontendToId: connection.input_id, + fromPortClass: connection.output_class, + toPortClass: connection.input_class + } + }); + updateMenu(); + } + }).catch(err => { + console.error("Failed to disconnect nodes:", err); + }); + } + } + } + + // Show error message + function showError(message) { + const errorDiv = document.createElement("div"); + errorDiv.className = "node-editor-error"; + errorDiv.textContent = message; + container.appendChild(errorDiv); + + setTimeout(() => { + errorDiv.remove(); + }, 3000); + } + + // Function to update breadcrumb display + function updateBreadcrumb() { + const breadcrumb = header.querySelector('.context-breadcrumb'); + if (editingContext) { + // Determine main graph name based on track type + const trackInfo = getCurrentTrack(); + const mainGraphName = trackInfo?.trackType === 'audio' ? 'Effects Graph' : 'Instrument Graph'; + + breadcrumb.innerHTML = ` + ${mainGraphName} > + ${editingContext.voiceAllocatorName} Template + + `; + const exitBtn = breadcrumb.querySelector('.exit-template-btn'); + exitBtn.addEventListener('click', exitTemplate); + } else { + // Not in template mode - show main graph name based on track type + const trackInfo = getCurrentTrack(); + const graphName = trackInfo?.trackType === 'audio' ? 'Effects Graph' : + trackInfo?.trackType === 'midi' ? 'Instrument Graph' : + 'Node Graph'; + breadcrumb.textContent = graphName; + } + } + + // Function to enter template editing mode + async function enterTemplate(voiceAllocatorId, voiceAllocatorName) { + editingContext = { voiceAllocatorId, voiceAllocatorName }; + updateBreadcrumb(); + updatePalette(); + await reloadGraph(); + } + + // Function to exit template editing mode + async function exitTemplate() { + editingContext = null; + updateBreadcrumb(); + updatePalette(); + await reloadGraph(); + } + + // Function to reload graph from backend + async function reloadGraph() { + if (!editor) return; + + const trackInfo = getCurrentTrack(); + + // Clear editor first + editor.clearModuleSelected(); + editor.clear(); + + // Update UI based on track type + updateBreadcrumb(); + updatePalette(); + + // If no track selected, just leave it cleared + if (trackInfo === null) { + console.log('No track selected, editor cleared'); + return; + } + + const trackId = trackInfo.trackId; + + try { + // Get graph based on editing context + let graphJson; + if (editingContext) { + // Loading template graph + graphJson = await invoke('graph_get_template_state', { + trackId, + voiceAllocatorId: editingContext.voiceAllocatorId + }); + } else { + // Loading main graph + graphJson = await invoke('graph_get_state', { trackId }); + } + + const preset = JSON.parse(graphJson); + + // If graph is empty (no nodes), just leave cleared + if (!preset.nodes || preset.nodes.length === 0) { + console.log('Graph is empty, editor cleared'); + return; + } + + // Rebuild from preset + const nodeMap = new Map(); // Maps backend node ID to Drawflow node ID + const setupPromises = []; // Track async setup operations + + // Add all nodes + for (const serializedNode of preset.nodes) { + const nodeType = serializedNode.node_type; + const nodeDef = nodeTypes[nodeType]; + if (!nodeDef) continue; + + // Create node HTML using the node definition's getHTML function + // Use backend node ID as the nodeId for unique element IDs + const html = nodeDef.getHTML(serializedNode.id); + + // Add node to Drawflow + const drawflowId = editor.addNode( + nodeType, + nodeDef.inputs.length, + nodeDef.outputs.length, + serializedNode.position[0], + serializedNode.position[1], + nodeType, + { nodeType, backendId: serializedNode.id, parentNodeId: null }, + html, + false + ); + + nodeMap.set(serializedNode.id, drawflowId); + + // Style ports (as Promise) + setupPromises.push(new Promise(resolve => { + setTimeout(() => { + styleNodePorts(drawflowId, nodeDef); + resolve(); + }, 10); + })); + + // Wire up parameter controls and set values from preset (as Promise) + setupPromises.push(new Promise(resolve => { + setTimeout(() => { + const nodeElement = container.querySelector(`#node-${drawflowId}`); + if (!nodeElement) return; + + // Set parameter values from preset + nodeElement.querySelectorAll('input[type="range"]').forEach(slider => { + const paramId = parseInt(slider.dataset.param); + const value = serializedNode.parameters[paramId]; + if (value !== undefined) { + slider.value = value; + // Update display span + const param = nodeDef.parameters.find(p => p.id === paramId); + const displaySpan = slider.previousElementSibling?.querySelector('span'); + if (displaySpan && param) { + displaySpan.textContent = value.toFixed(param.unit === 'Hz' ? 0 : 2) + (param.unit ? ` ${param.unit}` : ''); + } + } + }); + + // Set up event handlers for buttons + + // Handle Load Sample button for SimpleSampler + const loadSampleBtn = nodeElement.querySelector(".load-sample-btn"); + if (loadSampleBtn) { + loadSampleBtn.addEventListener("mousedown", (e) => e.stopPropagation()); + loadSampleBtn.addEventListener("pointerdown", (e) => e.stopPropagation()); + loadSampleBtn.addEventListener("click", async (e) => { + e.stopPropagation(); + + const nodeData = editor.getNodeFromId(drawflowId); + if (!nodeData || nodeData.data.backendId === null) { + showError("Node not yet created on backend"); + return; + } + + const currentTrackId = getCurrentMidiTrack(); + if (currentTrackId === null) { + showError("No MIDI track selected"); + return; + } + + try { + const filePath = await openFileDialog({ + title: "Load Audio Sample", + filters: [{ + name: "Audio Files", + extensions: audioExtensions + }] + }); + + if (filePath) { + await invoke("sampler_load_sample", { + trackId: currentTrackId, + nodeId: nodeData.data.backendId, + filePath: filePath + }); + + // Update UI to show filename + const sampleInfo = nodeElement.querySelector(`#sample-info-${drawflowId}`); + if (sampleInfo) { + const filename = filePath.split('/').pop().split('\\').pop(); + sampleInfo.textContent = filename; + } + } + } catch (err) { + console.error("Failed to load sample:", err); + showError(`Failed to load sample: ${err}`); + } + }); + } + + // Handle Add Layer button for MultiSampler + const addLayerBtn = nodeElement.querySelector(".add-layer-btn"); + if (addLayerBtn) { + addLayerBtn.addEventListener("mousedown", (e) => e.stopPropagation()); + addLayerBtn.addEventListener("pointerdown", (e) => e.stopPropagation()); + addLayerBtn.addEventListener("click", async (e) => { + e.stopPropagation(); + + const nodeData = editor.getNodeFromId(drawflowId); + if (!nodeData || nodeData.data.backendId === null) { + showError("Node not yet created on backend"); + return; + } + + const currentTrackId = getCurrentMidiTrack(); + if (currentTrackId === null) { + showError("No MIDI track selected"); + return; + } + + try { + const filePath = await openFileDialog({ + title: "Add Sample Layer", + filters: [{ + name: "Audio Files", + extensions: audioExtensions + }] + }); + + if (filePath) { + // Show dialog to configure layer mapping + const layerConfig = await showLayerConfigDialog(filePath); + + if (layerConfig) { + await invoke("multi_sampler_add_layer", { + trackId: currentTrackId, + nodeId: nodeData.data.backendId, + filePath: filePath, + keyMin: layerConfig.keyMin, + keyMax: layerConfig.keyMax, + rootKey: layerConfig.rootKey, + velocityMin: layerConfig.velocityMin, + velocityMax: layerConfig.velocityMax, + loopStart: layerConfig.loopStart, + loopEnd: layerConfig.loopEnd, + loopMode: layerConfig.loopMode + }); + + // Wait a bit for the audio thread to process the add command + await new Promise(resolve => setTimeout(resolve, 100)); + + // Refresh the layers list + await refreshSampleLayersList(drawflowId); + } + } + } catch (err) { + console.error("Failed to add layer:", err); + showError(`Failed to add layer: ${err}`); + } + }); + } + + // For MultiSampler nodes, populate the layers table from preset data + if (nodeType === 'MultiSampler') { + console.log(`[reloadGraph] Found MultiSampler node ${drawflowId}, sample_data:`, serializedNode.sample_data); + if (serializedNode.sample_data) { + console.log(`[reloadGraph] sample_data.type:`, serializedNode.sample_data.type); + console.log(`[reloadGraph] sample_data keys:`, Object.keys(serializedNode.sample_data)); + } + } + + if (nodeType === 'MultiSampler' && serializedNode.sample_data && serializedNode.sample_data.type === 'multi_sampler') { + console.log(`[reloadGraph] Condition met for node ${drawflowId}, looking for layers list element`); + // Query for elements by prefix to avoid ID mismatch issues + const layersList = nodeElement.querySelector('[id^="sample-layers-list-"]'); + const layersContainer = nodeElement.querySelector('[id^="sample-layers-container-"]'); + console.log(`[reloadGraph] layersList:`, layersList); + console.log(`[reloadGraph] layersContainer:`, layersContainer); + + if (layersList) { + const layers = serializedNode.sample_data.layers || []; + console.log(`[reloadGraph] Populating ${layers.length} layers for node ${drawflowId}`); + + // Prevent scroll events from bubbling to canvas + if (layersContainer && !layersContainer.dataset.scrollListenerAdded) { + layersContainer.addEventListener('wheel', (e) => { + e.stopPropagation(); + }, { passive: false }); + layersContainer.dataset.scrollListenerAdded = 'true'; + } + + if (layers.length === 0) { + layersList.innerHTML = 'No layers loaded'; + } else { + layersList.innerHTML = layers.map((layer, index) => { + const filename = layer.file_path.split('/').pop().split('\\').pop(); + const keyRange = `${midiToNoteName(layer.key_min)}-${midiToNoteName(layer.key_max)}`; + const rootNote = midiToNoteName(layer.root_key); + const velRange = `${layer.velocity_min}-${layer.velocity_max}`; + + return ` + + ${filename} + ${keyRange} + ${rootNote} + ${velRange} + +
    + + +
    + + + `; + }).join(''); + + // Set up event handlers for edit/delete buttons + layersList.querySelectorAll('.btn-edit-layer').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const drawflowNodeId = parseInt(btn.dataset.drawflowNode); + const layerIndex = parseInt(btn.dataset.index); + const layer = layers[layerIndex]; + + // Show dialog with current layer settings + const layerConfig = await showLayerConfigDialog(layer.file_path, { + keyMin: layer.key_min, + keyMax: layer.key_max, + rootKey: layer.root_key, + velocityMin: layer.velocity_min, + velocityMax: layer.velocity_max, + loopStart: layer.loop_start, + loopEnd: layer.loop_end, + loopMode: layer.loop_mode + }); + + if (layerConfig) { + const nodeData = editor.getNodeFromId(drawflowNodeId); + const currentTrackId = getCurrentMidiTrack(); + if (nodeData && currentTrackId !== null) { + try { + await invoke("multi_sampler_update_layer", { + trackId: currentTrackId, + nodeId: nodeData.data.backendId, + layerIndex: layerIndex, + keyMin: layerConfig.keyMin, + keyMax: layerConfig.keyMax, + rootKey: layerConfig.rootKey, + velocityMin: layerConfig.velocityMin, + velocityMax: layerConfig.velocityMax, + loopStart: layerConfig.loopStart, + loopEnd: layerConfig.loopEnd, + loopMode: layerConfig.loopMode + }); + await refreshSampleLayersList(drawflowNodeId); + } catch (err) { + showError(`Failed to update layer: ${err}`); + } + } + } + }); + }); + + layersList.querySelectorAll('.btn-delete-layer').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const drawflowNodeId = parseInt(btn.dataset.drawflowNode); + const layerIndex = parseInt(btn.dataset.index); + if (confirm('Delete this sample layer?')) { + const nodeData = editor.getNodeFromId(drawflowNodeId); + const currentTrackId = getCurrentMidiTrack(); + if (nodeData && currentTrackId !== null) { + try { + await invoke("multi_sampler_remove_layer", { + trackId: currentTrackId, + nodeId: nodeData.data.backendId, + layerIndex: layerIndex + }); + await refreshSampleLayersList(drawflowNodeId); + } catch (err) { + showError(`Failed to remove layer: ${err}`); + } + } + } + }); + }); + } + } + } + + // For Oscilloscope nodes, start the visualization + if (nodeType === 'Oscilloscope' && serializedNode.id && trackId) { + startOscilloscopeVisualization(drawflowId, trackId, serializedNode.id, editor); + } + + resolve(); + }, 100); + })); + } + + // Add all connections + for (const conn of preset.connections) { + const outputDrawflowId = nodeMap.get(conn.from_node); + const inputDrawflowId = nodeMap.get(conn.to_node); + + if (outputDrawflowId && inputDrawflowId) { + // Drawflow uses 1-based port indexing + editor.addConnection( + outputDrawflowId, + inputDrawflowId, + `output_${conn.from_port + 1}`, + `input_${conn.to_port + 1}` + ); + + // Style the connection based on signal type + // We need to look up the node type and get the output port signal type + setupPromises.push(new Promise(resolve => { + setTimeout(() => { + const outputNode = editor.getNodeFromId(outputDrawflowId); + if (outputNode) { + const nodeType = outputNode.data.nodeType; + const nodeDef = nodeTypes[nodeType]; + if (nodeDef && conn.from_port < nodeDef.outputs.length) { + const signalType = nodeDef.outputs[conn.from_port].type; + const connectionElement = document.querySelector( + `.connection.node_in_node-${inputDrawflowId}.node_out_node-${outputDrawflowId}` + ); + if (connectionElement) { + connectionElement.classList.add(`connection-${signalType}`); + } + } + } + resolve(); + }, 10); + })); + } + } + + // Wait for all node setup operations to complete + await Promise.all(setupPromises); + + console.log('Graph reloaded from backend'); + } catch (error) { + console.error('Failed to reload graph:', error); + showError(`Failed to reload graph: ${error}`); + } + } + + // Store reload function in context so it can be called from preset browser + // Wrap it to track the promise + context.reloadNodeEditor = async () => { + context.reloadGraphPromise = reloadGraph(); + await context.reloadGraphPromise; + context.reloadGraphPromise = null; + }; + + // Store refreshSampleLayersList in context so it can be called from event handlers + context.refreshSampleLayersList = refreshSampleLayersList; + + // Initial load of graph + setTimeout(() => reloadGraph(), 200); + + return container; +} + +function piano() { + let piano_cvs = document.createElement("canvas"); + piano_cvs.className = "piano"; + + // Create the virtual piano widget + piano_cvs.virtualPiano = new VirtualPiano(); + + // Variable to store the last time updatePianoCanvasSize was called + let lastResizeTime = 0; + const throttleIntervalMs = 20; + + function updatePianoCanvasSize() { + const canvasStyles = window.getComputedStyle(piano_cvs); + const width = parseInt(canvasStyles.width); + const height = parseInt(canvasStyles.height); + + // Set actual size in memory (scaled for retina displays) + piano_cvs.width = width * window.devicePixelRatio; + piano_cvs.height = height * window.devicePixelRatio; + + // Normalize coordinate system to use CSS pixels + const ctx = piano_cvs.getContext("2d"); + ctx.scale(window.devicePixelRatio, window.devicePixelRatio); + + // Render the piano + piano_cvs.virtualPiano.draw(ctx, width, height); + } + + // Store references in context for global access + context.pianoWidget = piano_cvs.virtualPiano; + context.pianoCanvas = piano_cvs; + context.pianoRedraw = updatePianoCanvasSize; + + const resizeObserver = new ResizeObserver((entries) => { + const currentTime = Date.now(); + if (currentTime - lastResizeTime >= throttleIntervalMs) { + lastResizeTime = currentTime; + updatePianoCanvasSize(); + } + }); + resizeObserver.observe(piano_cvs); + + // Mouse event handlers + piano_cvs.addEventListener("mousedown", (e) => { + const rect = piano_cvs.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + const width = parseInt(window.getComputedStyle(piano_cvs).width); + const height = parseInt(window.getComputedStyle(piano_cvs).height); + piano_cvs.virtualPiano.mousedown(x, y, width, height); + updatePianoCanvasSize(); // Redraw to show pressed state + }); + + piano_cvs.addEventListener("mousemove", (e) => { + const rect = piano_cvs.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + const width = parseInt(window.getComputedStyle(piano_cvs).width); + const height = parseInt(window.getComputedStyle(piano_cvs).height); + piano_cvs.virtualPiano.mousemove(x, y, width, height); + updatePianoCanvasSize(); // Redraw to show hover state + }); + + piano_cvs.addEventListener("mouseup", (e) => { + const rect = piano_cvs.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + const width = parseInt(window.getComputedStyle(piano_cvs).width); + const height = parseInt(window.getComputedStyle(piano_cvs).height); + piano_cvs.virtualPiano.mouseup(x, y, width, height); + updatePianoCanvasSize(); // Redraw to show released state + }); + + // Prevent text selection + piano_cvs.addEventListener("selectstart", (e) => e.preventDefault()); + + // Add header controls for octave and velocity + piano_cvs.headerControls = function() { + const controls = []; + + // Octave control + const octaveLabel = document.createElement("span"); + octaveLabel.style.marginLeft = "auto"; + octaveLabel.style.marginRight = "10px"; + octaveLabel.style.fontSize = "12px"; + octaveLabel.textContent = `Octave: ${piano_cvs.virtualPiano.octaveOffset >= 0 ? '+' : ''}${piano_cvs.virtualPiano.octaveOffset} (Z/X)`; + + // Velocity control + const velocityLabel = document.createElement("span"); + velocityLabel.style.marginRight = "10px"; + velocityLabel.style.fontSize = "12px"; + velocityLabel.textContent = `Velocity: ${piano_cvs.virtualPiano.velocity} (C/V)`; + + // Update function to refresh labels + const updateLabels = () => { + octaveLabel.textContent = `Octave: ${piano_cvs.virtualPiano.octaveOffset >= 0 ? '+' : ''}${piano_cvs.virtualPiano.octaveOffset} (Z/X)`; + velocityLabel.textContent = `Velocity: ${piano_cvs.virtualPiano.velocity} (C/V)`; + }; + + // Listen for keyboard events to update labels + window.addEventListener('keydown', (e) => { + if (['z', 'x', 'c', 'v'].includes(e.key.toLowerCase())) { + // Delay slightly to let the piano widget update first + setTimeout(updateLabels, 10); + } + }); + + controls.push(octaveLabel); + controls.push(velocityLabel); + + return controls; + }; + + return piano_cvs; +} + +function pianoRoll() { + // Create container for piano roll and properties panel + let container = document.createElement("div"); + container.className = "piano-roll-container"; + container.style.position = "relative"; + container.style.width = "100%"; + container.style.height = "100%"; + container.style.display = "flex"; + + let canvas = document.createElement("canvas"); + canvas.className = "piano-roll"; + canvas.style.flex = "1"; + + // Create properties panel + let propertiesPanel = document.createElement("div"); + propertiesPanel.className = "piano-roll-properties"; + propertiesPanel.style.display = "flex"; + propertiesPanel.style.gap = "15px"; + propertiesPanel.style.padding = "10px"; + propertiesPanel.style.backgroundColor = "#1e1e1e"; + propertiesPanel.style.borderLeft = "1px solid #333"; + propertiesPanel.style.alignItems = "center"; + propertiesPanel.style.fontSize = "12px"; + propertiesPanel.style.color = "#ccc"; + + // Create property sections + const createPropertySection = (label, isEditable = false) => { + const section = document.createElement("div"); + section.style.display = "flex"; + section.style.flexDirection = "column"; + section.style.gap = "5px"; + + const labelEl = document.createElement("label"); + labelEl.textContent = label; + labelEl.style.fontSize = "11px"; + labelEl.style.color = "#999"; + section.appendChild(labelEl); + + if (isEditable) { + const inputContainer = document.createElement("div"); + inputContainer.style.display = "flex"; + inputContainer.style.gap = "5px"; + inputContainer.style.alignItems = "center"; + + const input = document.createElement("input"); + input.type = "number"; + input.style.width = "45px"; + input.style.padding = "3px"; + input.style.backgroundColor = "#2a2a2a"; + input.style.border = "1px solid #444"; + input.style.borderRadius = "3px"; + input.style.color = "#ccc"; + input.style.fontSize = "12px"; + input.style.boxSizing = "border-box"; + inputContainer.appendChild(input); + + const slider = document.createElement("input"); + slider.type = "range"; + slider.style.flex = "1"; + slider.style.minWidth = "80px"; + inputContainer.appendChild(slider); + + section.appendChild(inputContainer); + return { section, input, slider }; + } else { + const value = document.createElement("span"); + value.style.color = "#fff"; + value.textContent = "-"; + section.appendChild(value); + return { section, value }; + } + }; + + const pitchSection = createPropertySection("Pitch"); + const velocitySection = createPropertySection("Velocity", true); + const modulationSection = createPropertySection("Modulation", true); + + // Configure velocity slider + velocitySection.input.min = 1; + velocitySection.input.max = 127; + velocitySection.slider.min = 1; + velocitySection.slider.max = 127; + + // Configure modulation slider + modulationSection.input.min = 0; + modulationSection.input.max = 127; + modulationSection.slider.min = 0; + modulationSection.slider.max = 127; + + propertiesPanel.appendChild(pitchSection.section); + propertiesPanel.appendChild(velocitySection.section); + propertiesPanel.appendChild(modulationSection.section); + + container.appendChild(canvas); + container.appendChild(propertiesPanel); + + // Create the piano roll editor widget + canvas.pianoRollEditor = new PianoRollEditor(0, 0, 0, 0); + canvas.pianoRollEditor.propertiesPanel = { + pitch: pitchSection.value, + velocity: { input: velocitySection.input, slider: velocitySection.slider }, + modulation: { input: modulationSection.input, slider: modulationSection.slider } + }; + + function updateCanvasSize() { + const canvasStyles = window.getComputedStyle(canvas); + const width = parseInt(canvasStyles.width); + const height = parseInt(canvasStyles.height); + + // Update widget dimensions + canvas.pianoRollEditor.width = width; + canvas.pianoRollEditor.height = height; + + // Set actual size in memory (scaled for retina displays) + canvas.width = width * window.devicePixelRatio; + canvas.height = height * window.devicePixelRatio; + + // Normalize coordinate system to use CSS pixels + const ctx = canvas.getContext("2d"); + ctx.scale(window.devicePixelRatio, window.devicePixelRatio); + + // Render the piano roll + canvas.pianoRollEditor.draw(ctx); + + // Update properties panel layout based on aspect ratio + const containerWidth = container.offsetWidth; + const containerHeight = container.offsetHeight; + const isWide = containerWidth > containerHeight; + + if (isWide) { + // Side layout + container.style.flexDirection = "row"; + propertiesPanel.style.flexDirection = "column"; + propertiesPanel.style.width = "240px"; + propertiesPanel.style.height = "auto"; + propertiesPanel.style.borderLeft = "1px solid #333"; + propertiesPanel.style.borderTop = "none"; + propertiesPanel.style.alignItems = "stretch"; + } else { + // Bottom layout + container.style.flexDirection = "column"; + propertiesPanel.style.flexDirection = "row"; + propertiesPanel.style.width = "auto"; + propertiesPanel.style.height = "60px"; + propertiesPanel.style.borderLeft = "none"; + propertiesPanel.style.borderTop = "1px solid #333"; + } + } + + // Store references in context for global access and playback updates + context.pianoRollEditor = canvas.pianoRollEditor; + context.pianoRollCanvas = canvas; + context.pianoRollRedraw = updateCanvasSize; + + const resizeObserver = new ResizeObserver(() => { + updateCanvasSize(); + }); + resizeObserver.observe(container); + + // Pointer event handlers (works with mouse and touch) + canvas.addEventListener("pointerdown", (e) => { + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + canvas.pianoRollEditor.handleMouseEvent("mousedown", x, y); + updateCanvasSize(); + }); + + canvas.addEventListener("pointermove", (e) => { + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + canvas.pianoRollEditor.handleMouseEvent("mousemove", x, y); + + // Update cursor based on widget state + if (canvas.pianoRollEditor.cursor) { + canvas.style.cursor = canvas.pianoRollEditor.cursor; + } + + updateCanvasSize(); + }); + + canvas.addEventListener("pointerup", (e) => { + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + canvas.pianoRollEditor.handleMouseEvent("mouseup", x, y); + updateCanvasSize(); + }); + + canvas.addEventListener("wheel", (e) => { + e.preventDefault(); + canvas.pianoRollEditor.wheel(e); + updateCanvasSize(); + }); + + // Prevent text selection + canvas.addEventListener("selectstart", (e) => e.preventDefault()); + + // Add event handlers for velocity and modulation inputs/sliders + const syncInputSlider = (input, slider) => { + input.addEventListener("input", () => { + const value = parseInt(input.value); + if (!isNaN(value)) { + slider.value = value; + } + }); + slider.addEventListener("input", () => { + input.value = slider.value; + }); + }; + + syncInputSlider(velocitySection.input, velocitySection.slider); + syncInputSlider(modulationSection.input, modulationSection.slider); + + // Handle property changes + const updateNoteProperty = (property, value) => { + const clipData = canvas.pianoRollEditor.getSelectedClip(); + if (!clipData || !clipData.clip || !clipData.clip.notes) return; + + if (canvas.pianoRollEditor.selectedNotes.size === 0) return; + + for (const noteIndex of canvas.pianoRollEditor.selectedNotes) { + if (noteIndex >= 0 && noteIndex < clipData.clip.notes.length) { + const note = clipData.clip.notes[noteIndex]; + if (property === "velocity") { + note.velocity = value; + } else if (property === "modulation") { + note.modulation = value; + } + } + } + + canvas.pianoRollEditor.syncNotesToBackend(clipData); + updateCanvasSize(); + }; + + velocitySection.input.addEventListener("change", (e) => { + const value = parseInt(e.target.value); + if (!isNaN(value) && value >= 1 && value <= 127) { + updateNoteProperty("velocity", value); + } + }); + + velocitySection.slider.addEventListener("change", (e) => { + const value = parseInt(e.target.value); + updateNoteProperty("velocity", value); + }); + + modulationSection.input.addEventListener("change", (e) => { + const value = parseInt(e.target.value); + if (!isNaN(value) && value >= 0 && value <= 127) { + updateNoteProperty("modulation", value); + } + }); + + modulationSection.slider.addEventListener("change", (e) => { + const value = parseInt(e.target.value); + updateNoteProperty("modulation", value); + }); + + return container; +} + +function presetBrowser() { + const container = document.createElement("div"); + container.className = "preset-browser-pane"; + + container.innerHTML = ` +
    +

    Instrument Presets

    + +
    +
    + + +
    +
    +
    +

    Factory Presets

    +
    +
    Loading...
    +
    +
    +
    +

    User Presets

    +
    +
    No user presets yet
    +
    +
    +
    + `; + + // Load presets after DOM insertion + setTimeout(async () => { + await loadPresetList(container); + + // Set up save button handler + const saveBtn = container.querySelector('.preset-save-btn'); + if (saveBtn) { + saveBtn.addEventListener('click', () => showSavePresetDialog(container)); + } + + // Set up search and filter + const searchInput = container.querySelector('#preset-search'); + const tagFilter = container.querySelector('#preset-tag-filter'); + + if (searchInput) { + searchInput.addEventListener('input', () => filterPresets(container)); + } + if (tagFilter) { + tagFilter.addEventListener('change', () => filterPresets(container)); + } + }, 0); + + return container; +} + +async function loadPresetList(container) { + try { + const presets = await invoke('graph_list_presets'); + + const factoryList = container.querySelector('#factory-preset-list'); + const userList = container.querySelector('#user-preset-list'); + const tagFilter = container.querySelector('#preset-tag-filter'); + + // Collect all unique tags + const allTags = new Set(); + presets.forEach(preset => { + preset.tags.forEach(tag => allTags.add(tag)); + }); + + // Populate tag filter + if (tagFilter) { + allTags.forEach(tag => { + const option = document.createElement('option'); + option.value = tag; + option.textContent = tag.charAt(0).toUpperCase() + tag.slice(1); + tagFilter.appendChild(option); + }); + } + + // Separate factory and user presets + const factoryPresets = presets.filter(p => p.is_factory); + const userPresets = presets.filter(p => !p.is_factory); + + // Render factory presets + if (factoryList) { + if (factoryPresets.length === 0) { + factoryList.innerHTML = '
    No factory presets found
    '; + } else { + factoryList.innerHTML = factoryPresets.map(preset => createPresetItem(preset)).join(''); + addPresetItemHandlers(factoryList); + } + } + + // Render user presets + if (userList) { + if (userPresets.length === 0) { + userList.innerHTML = '
    No user presets yet
    '; + } else { + userList.innerHTML = userPresets.map(preset => createPresetItem(preset)).join(''); + addPresetItemHandlers(userList); + } + } + } catch (error) { + console.error('Failed to load presets:', error); + const factoryList = container.querySelector('#factory-preset-list'); + const userList = container.querySelector('#user-preset-list'); + if (factoryList) factoryList.innerHTML = '
    Failed to load presets
    '; + if (userList) userList.innerHTML = ''; + } +} + +function createPresetItem(preset) { + const tags = preset.tags.map(tag => `${tag}`).join(''); + const deleteBtn = preset.is_factory ? '' : ''; + + return ` +
    +
    + ${preset.name} + + ${deleteBtn} +
    +
    +
    ${preset.description || 'No description'}
    +
    ${tags}
    +
    by ${preset.author || 'Unknown'}
    +
    +
    + `; +} + +function addPresetItemHandlers(listElement) { + // Toggle selection on preset item click + listElement.querySelectorAll('.preset-item').forEach(item => { + item.addEventListener('click', (e) => { + // Don't trigger if clicking buttons + if (e.target.classList.contains('preset-load-btn') || + e.target.classList.contains('preset-delete-btn')) { + return; + } + + // Toggle selection + const wasSelected = item.classList.contains('selected'); + + // Deselect all presets + listElement.querySelectorAll('.preset-item').forEach(i => i.classList.remove('selected')); + + // Select this preset if it wasn't selected + if (!wasSelected) { + item.classList.add('selected'); + } + }); + }); + + // Load preset on Load button click + listElement.querySelectorAll('.preset-load-btn').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const item = btn.closest('.preset-item'); + const presetPath = item.dataset.presetPath; + await loadPreset(presetPath); + }); + }); + + // Delete preset on delete button click + listElement.querySelectorAll('.preset-delete-btn').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const item = btn.closest('.preset-item'); + const presetPath = item.dataset.presetPath; + const presetName = item.querySelector('.preset-name').textContent; + + if (confirm(`Delete preset "${presetName}"?`)) { + try { + await invoke('graph_delete_preset', { presetPath }); + // Reload preset list + const container = btn.closest('.preset-browser-pane'); + await loadPresetList(container); + } catch (error) { + alert(`Failed to delete preset: ${error}`); + } + } + }); + }); +} + +async function loadPreset(presetPath) { + const trackInfo = getCurrentTrack(); + if (trackInfo === null) { + alert('Please select a track first'); + return; + } + const trackId = trackInfo.trackId; + + try { + await invoke('graph_load_preset', { + trackId: trackId, + presetPath + }); + + // Refresh the node editor to show the loaded preset + await context.reloadNodeEditor?.(); + + console.log('Preset loaded successfully'); + } catch (error) { + alert(`Failed to load preset: ${error}`); + } +} + +function showSavePresetDialog(container) { + const trackInfo = getCurrentTrack(); + if (trackInfo === null) { + alert('Please select a track first'); + return; + } + + // Create modal dialog + const dialog = document.createElement('div'); + dialog.className = 'modal-overlay'; + dialog.innerHTML = ` + + `; + + document.body.appendChild(dialog); + + // Focus name input + setTimeout(() => dialog.querySelector('#preset-name')?.focus(), 100); + + // Handle cancel + dialog.querySelector('.btn-cancel').addEventListener('click', () => { + dialog.remove(); + }); + + // Handle save + dialog.querySelector('#save-preset-form').addEventListener('submit', async (e) => { + e.preventDefault(); + + const name = dialog.querySelector('#preset-name').value.trim(); + const description = dialog.querySelector('#preset-description').value.trim(); + const tagsInput = dialog.querySelector('#preset-tags').value.trim(); + const tags = tagsInput ? tagsInput.split(',').map(t => t.trim()).filter(t => t) : []; + + if (!name) { + alert('Please enter a preset name'); + return; + } + + try { + await invoke('graph_save_preset', { + trackId: trackInfo.trackId, + presetName: name, + description, + tags + }); + + dialog.remove(); + + // Reload preset list + await loadPresetList(container); + + alert(`Preset "${name}" saved successfully!`); + } catch (error) { + alert(`Failed to save preset: ${error}`); + } + }); + + // Close on background click + dialog.addEventListener('click', (e) => { + if (e.target === dialog) { + dialog.remove(); + } + }); +} + +// Show preferences dialog +function showPreferencesDialog() { + const dialog = document.createElement('div'); + dialog.className = 'modal-overlay'; + dialog.innerHTML = ` + + `; + + document.body.appendChild(dialog); + + // Focus first input + setTimeout(() => dialog.querySelector('#pref-bpm')?.focus(), 100); + + // Handle cancel + dialog.querySelector('.btn-cancel').addEventListener('click', () => { + dialog.remove(); + }); + + // Handle save + dialog.querySelector('#preferences-form').addEventListener('submit', async (e) => { + e.preventDefault(); + + // Update config values + config.bpm = parseInt(dialog.querySelector('#pref-bpm').value); + config.framerate = parseInt(dialog.querySelector('#pref-framerate').value); + config.fileWidth = parseInt(dialog.querySelector('#pref-width').value); + config.fileHeight = parseInt(dialog.querySelector('#pref-height').value); + config.scrollSpeed = parseFloat(dialog.querySelector('#pref-scroll-speed').value); + config.audioBufferSize = parseInt(dialog.querySelector('#pref-audio-buffer-size').value); + config.reopenLastSession = dialog.querySelector('#pref-reopen-session').checked; + config.restoreLayoutFromFile = dialog.querySelector('#pref-restore-layout').checked; + config.debug = dialog.querySelector('#pref-debug').checked; + + // Save config to localStorage + await saveConfig(); + + dialog.remove(); + + console.log('Preferences saved:', config); + }); + + // Close on background click + dialog.addEventListener('click', (e) => { + if (e.target === dialog) { + dialog.remove(); + } + }); +} + +// Helper function to convert MIDI note number to note name +function midiToNoteName(midiNote) { + const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; + const octave = Math.floor(midiNote / 12) - 1; + const noteName = noteNames[midiNote % 12]; + return `${noteName}${octave}`; +} + +// Parse note name from string (e.g., "A#3" -> 58) +function noteNameToMidi(noteName) { + const noteMap = { + 'C': 0, 'C#': 1, 'Db': 1, + 'D': 2, 'D#': 3, 'Eb': 3, + 'E': 4, + 'F': 5, 'F#': 6, 'Gb': 6, + 'G': 7, 'G#': 8, 'Ab': 8, + 'A': 9, 'A#': 10, 'Bb': 10, + 'B': 11 + }; + + // Match note + optional accidental + octave + const match = noteName.match(/^([A-G][#b]?)(-?\d+)$/i); + if (!match) return null; + + const note = match[1].toUpperCase(); + const octave = parseInt(match[2]); + + if (!(note in noteMap)) return null; + + return (octave + 1) * 12 + noteMap[note]; +} + +// Parse filename to extract note and velocity layer +function parseSampleFilename(filename) { + // Remove extension + const nameWithoutExt = filename.replace(/\.(wav|aif|aiff|flac|mp3|ogg)$/i, ''); + + // Try to find note patterns (e.g., A#3, Bb2, C4) + const notePattern = /([A-G][#b]?)(-?\d+)/gi; + const noteMatches = [...nameWithoutExt.matchAll(notePattern)]; + + if (noteMatches.length === 0) return null; + + // Use the last note match (usually most reliable) + const noteMatch = noteMatches[noteMatches.length - 1]; + const noteStr = noteMatch[1] + noteMatch[2]; + const midiNote = noteNameToMidi(noteStr); + + if (midiNote === null) return null; + + // Try to find velocity indicators + // Common patterns: v1, v2, v3, pp, p, mp, mf, f, ff, fff + const velPatterns = [ + { regex: /v(\d+)/i, type: 'numeric' }, + { regex: /\b(ppp|pp|p|mp|mf|f|ff|fff)\b/i, type: 'dynamic' } + ]; + + let velocityMarker = null; + let velocityType = null; + + for (const pattern of velPatterns) { + const match = nameWithoutExt.match(pattern.regex); + if (match) { + velocityMarker = match[1]; + velocityType = pattern.type; + break; + } + } + + return { + note: noteStr, + midiNote, + velocityMarker, + velocityType, + filename + }; +} + +// Group samples by note and velocity +function groupSamples(samples) { + const groups = {}; + const velocityLayers = new Set(); + + for (const sample of samples) { + const parsed = parseSampleFilename(sample); + if (!parsed) continue; + + const key = parsed.midiNote; + if (!groups[key]) { + groups[key] = { + note: parsed.note, + midiNote: parsed.midiNote, + layers: [] + }; + } + + groups[key].layers.push({ + filename: parsed.filename, + velocityMarker: parsed.velocityMarker, + velocityType: parsed.velocityType + }); + + if (parsed.velocityMarker) { + velocityLayers.add(parsed.velocityMarker); + } + } + + return { groups, velocityLayers: Array.from(velocityLayers).sort() }; +} + +// Show folder import dialog +async function showFolderImportDialog(trackId, nodeId, drawflowNodeId) { + // Select folder + const folderPath = await invoke("open_folder_dialog", { + title: "Select Sample Folder" + }); + + if (!folderPath) return; + + // Read files from folder + const files = await invoke("read_folder_files", { + path: folderPath + }); + + if (!files || files.length === 0) { + alert("No audio files found in folder"); + return; + } + + // Parse and group samples + const { groups, velocityLayers } = groupSamples(files); + const noteGroups = Object.values(groups).sort((a, b) => a.midiNote - b.midiNote); + + if (noteGroups.length === 0) { + alert("Could not detect note names in filenames"); + return; + } + + // Show configuration dialog + return new Promise((resolve) => { + const overlay = document.createElement('div'); + overlay.className = 'dialog-overlay'; + + const dialog = document.createElement('div'); + dialog.className = 'dialog'; + dialog.style.width = '600px'; + dialog.style.maxWidth = '90vw'; + dialog.style.maxHeight = '80vh'; + dialog.style.padding = '20px'; + dialog.style.backgroundColor = '#2a2a2a'; + dialog.style.border = '1px solid #444'; + dialog.style.borderRadius = '8px'; + dialog.style.color = '#e0e0e0'; + + let velocityMapping = {}; + + // Initialize default velocity mappings + if (velocityLayers.length > 0) { + const step = Math.floor(127 / velocityLayers.length); + velocityLayers.forEach((marker, idx) => { + velocityMapping[marker] = { + min: idx * step, + max: (idx + 1) * step - 1 + }; + }); + // Ensure last layer goes to 127 + if (velocityLayers.length > 0) { + velocityMapping[velocityLayers[velocityLayers.length - 1]].max = 127; + } + } + + dialog.innerHTML = ` +

    Import Sample Folder

    +
    + Folder: ${folderPath}
    + Found: ${noteGroups.length} notes, ${velocityLayers.length} velocity layer(s) +
    + + ${velocityLayers.length > 0 ? ` +
    + Velocity Mapping: + + + + + + + + + + ${velocityLayers.map(marker => ` + + + + + + `).join('')} + +
    MarkerMin VelocityMax Velocity
    ${marker}
    +
    + ` : ''} + +
    + Preview: +
      + ${noteGroups.slice(0, 20).map(group => ` +
    • ${group.note} (MIDI ${group.midiNote}): ${group.layers.length} sample(s) + ${group.layers.length <= 3 ? `
        ${group.layers.map(l => l.filename).join('
        ')}
      ` : ''} +
    • + `).join('')} + ${noteGroups.length > 20 ? `
    • ... and ${noteGroups.length - 20} more notes
    • ` : ''} +
    +
    + +
    + +
    + +
    + + +
    + `; + + overlay.appendChild(dialog); + document.body.appendChild(overlay); + + // Update velocity mapping when inputs change + const velInputs = dialog.querySelectorAll('.vel-min, .vel-max'); + velInputs.forEach(input => { + input.addEventListener('input', () => { + const marker = input.dataset.marker; + const isMin = input.classList.contains('vel-min'); + const value = parseInt(input.value); + + if (isMin) { + velocityMapping[marker].min = value; + } else { + velocityMapping[marker].max = value; + } + }); + }); + + dialog.querySelector('#btn-cancel').addEventListener('click', () => { + document.body.removeChild(overlay); + resolve(); + }); + + dialog.querySelector('#btn-import').addEventListener('click', async () => { + const autoKeyRanges = dialog.querySelector('#auto-key-ranges').checked; + + try { + // Build layer list + const layersToImport = []; + + for (let i = 0; i < noteGroups.length; i++) { + const group = noteGroups[i]; + + // Calculate key range + let keyMin, keyMax; + if (autoKeyRanges) { + // Split range between adjacent notes + const prevNote = i > 0 ? noteGroups[i - 1].midiNote : 0; + const nextNote = i < noteGroups.length - 1 ? noteGroups[i + 1].midiNote : 127; + + keyMin = i === 0 ? 0 : Math.ceil((prevNote + group.midiNote) / 2); + keyMax = i === noteGroups.length - 1 ? 127 : Math.floor((group.midiNote + nextNote) / 2); + } else { + keyMin = group.midiNote; + keyMax = group.midiNote; + } + + // Add each velocity layer for this note + for (const layer of group.layers) { + let velMin = 0, velMax = 127; + + if (layer.velocityMarker && velocityMapping[layer.velocityMarker]) { + velMin = velocityMapping[layer.velocityMarker].min; + velMax = velocityMapping[layer.velocityMarker].max; + } + + layersToImport.push({ + filePath: `${folderPath}/${layer.filename}`, + keyMin, + keyMax, + rootKey: group.midiNote, + velocityMin: velMin, + velocityMax: velMax + }); + } + } + + // Import all layers + dialog.querySelector('#btn-import').disabled = true; + dialog.querySelector('#btn-import').textContent = 'Importing...'; + + for (const layer of layersToImport) { + await invoke("multi_sampler_add_layer", { + trackId, + nodeId, + filePath: layer.filePath, + keyMin: layer.keyMin, + keyMax: layer.keyMax, + rootKey: layer.rootKey, + velocityMin: layer.velocityMin, + velocityMax: layer.velocityMax, + loopStart: null, + loopEnd: null, + loopMode: "Continuous" + }); + } + + // Refresh the layers list by re-fetching from backend + try { + const layers = await invoke("multi_sampler_get_layers", { + trackId, + nodeId + }); + + // Find the node element and update the layers list + const nodeElement = document.querySelector(`#node-${drawflowNodeId}`); + if (nodeElement) { + const layersList = nodeElement.querySelector('[id^="sample-layers-list-"]'); + + if (layersList) { + if (layers.length === 0) { + layersList.innerHTML = 'No layers loaded'; + } else { + layersList.innerHTML = layers.map((layer, index) => { + const filename = layer.file_path.split('/').pop().split('\\').pop(); + const keyRange = `${midiToNoteName(layer.key_min)}-${midiToNoteName(layer.key_max)}`; + const rootNote = midiToNoteName(layer.root_key); + const velRange = `${layer.velocity_min}-${layer.velocity_max}`; + + return ` + + ${filename} + ${keyRange} + ${rootNote} + ${velRange} + +
    + + +
    + + + `; + }).join(''); + } + } + } + } catch (refreshErr) { + console.error("Failed to refresh layers list:", refreshErr); + } + + document.body.removeChild(overlay); + resolve(); + } catch (err) { + alert(`Failed to import: ${err}`); + dialog.querySelector('#btn-import').disabled = false; + dialog.querySelector('#btn-import').textContent = 'Import'; + } + }); + }); +} + +// Show dialog to configure MultiSampler layer zones +function showLayerConfigDialog(filePath, existingConfig = null) { + return new Promise((resolve) => { + const filename = filePath.split('/').pop().split('\\').pop(); + const isEdit = existingConfig !== null; + + // Use existing values or defaults + const keyMin = existingConfig?.keyMin ?? 0; + const keyMax = existingConfig?.keyMax ?? 127; + const rootKey = existingConfig?.rootKey ?? 60; + const velocityMin = existingConfig?.velocityMin ?? 0; + const velocityMax = existingConfig?.velocityMax ?? 127; + const loopMode = existingConfig?.loopMode ?? 'oneshot'; + const loopStart = existingConfig?.loopStart ?? null; + const loopEnd = existingConfig?.loopEnd ?? null; + + // Create modal dialog + const dialog = document.createElement('div'); + dialog.className = 'modal-overlay'; + dialog.innerHTML = ` + + `; + + document.body.appendChild(dialog); + + // Update note names when inputs change + const keyMinInput = dialog.querySelector('#key-min'); + const keyMaxInput = dialog.querySelector('#key-max'); + const rootKeyInput = dialog.querySelector('#root-key'); + const loopModeSelect = dialog.querySelector('#loop-mode'); + const loopPointsGroup = dialog.querySelector('#loop-points-group'); + + const updateKeyMinName = () => { + const note = parseInt(keyMinInput.value) || 0; + dialog.querySelector('#key-min-name').textContent = midiToNoteName(note); + }; + + const updateKeyMaxName = () => { + const note = parseInt(keyMaxInput.value) || 127; + dialog.querySelector('#key-max-name').textContent = midiToNoteName(note); + }; + + const updateRootKeyName = () => { + const note = parseInt(rootKeyInput.value) || 60; + dialog.querySelector('#root-key-name').textContent = midiToNoteName(note); + }; + + keyMinInput.addEventListener('input', updateKeyMinName); + keyMaxInput.addEventListener('input', updateKeyMaxName); + rootKeyInput.addEventListener('input', updateRootKeyName); + + // Toggle loop points visibility based on loop mode + loopModeSelect.addEventListener('change', () => { + const isContinuous = loopModeSelect.value === 'continuous'; + loopPointsGroup.style.display = isContinuous ? 'block' : 'none'; + }); + + // Focus first input + setTimeout(() => dialog.querySelector('#key-min')?.focus(), 100); + + // Handle cancel + dialog.querySelector('.btn-cancel').addEventListener('click', () => { + dialog.remove(); + resolve(null); + }); + + // Handle submit + dialog.querySelector('#layer-config-form').addEventListener('submit', (e) => { + e.preventDefault(); + + const keyMin = parseInt(keyMinInput.value); + const keyMax = parseInt(keyMaxInput.value); + const rootKey = parseInt(rootKeyInput.value); + const velocityMin = parseInt(dialog.querySelector('#velocity-min').value); + const velocityMax = parseInt(dialog.querySelector('#velocity-max').value); + const loopMode = loopModeSelect.value; + + // Get loop points (null if empty) + const loopStartInput = dialog.querySelector('#loop-start'); + const loopEndInput = dialog.querySelector('#loop-end'); + const loopStart = loopStartInput.value ? parseInt(loopStartInput.value) : null; + const loopEnd = loopEndInput.value ? parseInt(loopEndInput.value) : null; + + // Validate ranges + if (keyMin > keyMax) { + alert('Key Min must be less than or equal to Key Max'); + return; + } + + if (velocityMin > velocityMax) { + alert('Velocity Min must be less than or equal to Velocity Max'); + return; + } + + if (rootKey < keyMin || rootKey > keyMax) { + alert('Root Key must be within the key range'); + return; + } + + // Validate loop points if both are specified + if (loopStart !== null && loopEnd !== null && loopStart >= loopEnd) { + alert('Loop Start must be less than Loop End'); + return; + } + + dialog.remove(); + resolve({ + keyMin, + keyMax, + rootKey, + velocityMin, + velocityMax, + loopMode, + loopStart, + loopEnd + }); + }); + + // Close on background click + dialog.addEventListener('click', (e) => { + if (e.target === dialog) { + dialog.remove(); + resolve(null); + } + }); + }); +} + +function filterPresets(container) { + const searchTerm = container.querySelector('#preset-search')?.value.toLowerCase() || ''; + const selectedTag = container.querySelector('#preset-tag-filter')?.value || ''; + + const allItems = container.querySelectorAll('.preset-item'); + + allItems.forEach(item => { + const name = item.querySelector('.preset-name').textContent.toLowerCase(); + const description = item.querySelector('.preset-description').textContent.toLowerCase(); + const tags = item.dataset.presetTags.split(','); + + const matchesSearch = !searchTerm || name.includes(searchTerm) || description.includes(searchTerm); + const matchesTag = !selectedTag || tags.includes(selectedTag); + + item.style.display = (matchesSearch && matchesTag) ? 'block' : 'none'; + }); +} + const panes = { stage: { name: "stage", @@ -8270,6 +11882,10 @@ const panes = { name: "toolbar", func: toolbar, }, + timelineDeprecated: { + name: "timeline-deprecated", + func: timelineDeprecated, + }, timeline: { name: "timeline", func: timeline, @@ -8282,8 +11898,101 @@ const panes = { name: "outliner", func: outliner, }, + piano: { + name: "piano", + func: piano, + }, + pianoRoll: { + name: "piano-roll", + func: pianoRoll, + }, + nodeEditor: { + name: "node-editor", + func: nodeEditor, + }, + presetBrowser: { + name: "preset-browser", + func: presetBrowser, + }, }; +/** + * Switch to a different layout + * @param {string} layoutKey - The key of the layout to switch to + */ +function switchLayout(layoutKey) { + try { + console.log(`Switching to layout: ${layoutKey}`); + + // Load the layout definition + const layoutDef = loadLayoutByKeyOrName(layoutKey); + if (!layoutDef) { + console.error(`Layout not found: ${layoutKey}`); + return; + } + + // Clear existing layout (except root element) + while (rootPane.firstChild) { + rootPane.removeChild(rootPane.firstChild); + } + + // Clear layoutElements array + layoutElements.length = 0; + + // Clear canvases array (will be repopulated when stage pane is created) + canvases.length = 0; + + // Build new layout from definition directly into rootPane + buildLayout(rootPane, layoutDef, panes, createPane, splitPane); + + // Update config + config.currentLayout = layoutKey; + saveConfig(); + + // Trigger layout update + updateAll(); + updateUI(); + updateLayers(); + updateMenu(); + + // Update metronome button visibility based on timeline format + // (especially important when switching to audioDaw layout) + if (context.metronomeGroup && context.timelineWidget?.timelineState) { + const shouldShow = context.timelineWidget.timelineState.timeFormat === 'measures'; + context.metronomeGroup.style.display = shouldShow ? '' : 'none'; + } + + console.log(`Layout switched to: ${layoutDef.name}`); + } catch (error) { + console.error(`Error switching layout:`, error); + } +} + +/** + * Switch to the next layout in the list + */ +function nextLayout() { + const layoutKeys = getLayoutNames(); + const currentIndex = layoutKeys.indexOf(config.currentLayout); + const nextIndex = (currentIndex + 1) % layoutKeys.length; + switchLayout(layoutKeys[nextIndex]); +} + +/** + * Switch to the previous layout in the list + */ +function previousLayout() { + const layoutKeys = getLayoutNames(); + const currentIndex = layoutKeys.indexOf(config.currentLayout); + const prevIndex = (currentIndex - 1 + layoutKeys.length) % layoutKeys.length; + switchLayout(layoutKeys[prevIndex]); +} + +// Make layout functions available globally for menu actions +window.switchLayout = switchLayout; +window.nextLayout = nextLayout; +window.previousLayout = previousLayout; + function _arrayBufferToBase64(buffer) { var binary = ""; var bytes = new Uint8Array(buffer); @@ -8339,32 +12048,34 @@ function getMimeType(filePath) { } } -function startToneOnUserInteraction() { - // Function to handle the first interaction (click or key press) - const startTone = () => { - Tone.start() - .then(() => { - console.log("Tone.js started!"); - }) - .catch((err) => { - console.error("Error starting Tone.js:", err); - }); - // Remove the event listeners to prevent them from firing again - document.removeEventListener("click", startTone); - document.removeEventListener("keydown", startTone); - }; +let renderInProgress = false; +let rafScheduled = false; - // Add event listeners for mouse click and key press - document.addEventListener("click", startTone); - document.addEventListener("keydown", startTone); -} -startToneOnUserInteraction(); +// FPS tracking +let lastFpsLogTime = 0; +let frameCount = 0; +let fpsHistory = []; + +async function renderAll() { + rafScheduled = false; + + // Skip if a render is already in progress (prevent stacking async calls) + if (renderInProgress) { + // Schedule another attempt if not already scheduled + if (!rafScheduled) { + rafScheduled = true; + requestAnimationFrame(renderAll); + } + return; + } + + renderInProgress = true; + const renderStartTime = performance.now(); -function renderAll() { try { if (uiDirty) { - renderUI(); + await renderUI(); uiDirty = false; } if (layersDirty) { @@ -8397,10 +12108,49 @@ function renderAll() { repeatCount = 2; } } finally { - requestAnimationFrame(renderAll); + renderInProgress = false; + + // FPS logging (only when playing) + if (context.playing) { + frameCount++; + const now = performance.now(); + const renderTime = now - renderStartTime; + + if (now - lastFpsLogTime >= 1000) { + const fps = frameCount / ((now - lastFpsLogTime) / 1000); + fpsHistory.push({ fps, renderTime }); + console.log(`[FPS] ${fps.toFixed(1)} fps | Render time: ${renderTime.toFixed(1)}ms`); + frameCount = 0; + lastFpsLogTime = now; + + // Keep only last 10 samples + if (fpsHistory.length > 10) { + fpsHistory.shift(); + } + } + } + + // Schedule next frame if not already scheduled + if (!rafScheduled) { + rafScheduled = true; + requestAnimationFrame(renderAll); + } } } +// Initialize actions module with dependencies +initializeActions({ + undoStack, + redoStack, + updateMenu, + updateLayers, + updateUI, + updateVideoFrames, + updateInfopanel, + invoke, + config +}); + renderAll(); if (window.openedFiles?.length>0) { @@ -8411,6 +12161,293 @@ if (window.openedFiles?.length>0) { } } +async function addEmptyAudioTrack() { + console.log('[addEmptyAudioTrack] BEFORE - root.frameRate:', root.frameRate); + const trackName = `Audio Track ${context.activeObject.audioTracks.length + 1}`; + const trackUuid = uuidv4(); + + try { + // Create new AudioTrack with DAW backend + const newAudioTrack = new AudioTrack(trackUuid, trackName); + + // Initialize track in backend (creates empty audio track) + await newAudioTrack.initializeTrack(); + + console.log('[addEmptyAudioTrack] After initializeTrack - root.frameRate:', root.frameRate); + + // Add track to active object + context.activeObject.audioTracks.push(newAudioTrack); + + console.log('[addEmptyAudioTrack] After push - root.frameRate:', root.frameRate); + + // Select the newly created track + context.activeObject.activeLayer = newAudioTrack; + + console.log('[addEmptyAudioTrack] After setting activeLayer - root.frameRate:', root.frameRate); + + // Update UI + updateLayers(); + if (context.timelineWidget) { + context.timelineWidget.requestRedraw(); + } + + console.log('[addEmptyAudioTrack] AFTER - root.frameRate:', root.frameRate); + console.log('Empty audio track created:', trackName, 'with ID:', newAudioTrack.audioTrackId); + } catch (error) { + console.error('Failed to create empty audio track:', error); + } +} + +async function addEmptyMIDITrack() { + console.log('[addEmptyMIDITrack] Creating new MIDI track'); + const trackName = `MIDI Track ${context.activeObject.audioTracks.filter(t => t.type === 'midi').length + 1}`; + const trackUuid = uuidv4(); + + try { + // Note: MIDI tracks now use node-based instruments via instrument_graph + + // Create new AudioTrack with type='midi' + const newMIDITrack = new AudioTrack(trackUuid, trackName, 'midi'); + + // Initialize track in backend (creates MIDI track with node graph) + await newMIDITrack.initializeTrack(); + + console.log('[addEmptyMIDITrack] After initializeTrack - track created with node graph'); + + // Add track to active object + context.activeObject.audioTracks.push(newMIDITrack); + + // Select the newly created track + context.activeObject.activeLayer = newMIDITrack; + + // Update UI + updateLayers(); + if (context.timelineWidget) { + context.timelineWidget.requestRedraw(); + } + + // Refresh node editor to show empty graph + setTimeout(() => context.reloadNodeEditor?.(), 100); + + console.log('Empty MIDI track created:', trackName, 'with ID:', newMIDITrack.audioTrackId); + } catch (error) { + console.error('Failed to create empty MIDI track:', error); + } +} + +async function addVideoLayer() { + console.log('[addVideoLayer] Creating new video layer'); + const layerName = `Video ${context.activeObject.layers.filter(l => l.type === 'video').length + 1}`; + const layerUuid = uuidv4(); + + try { + // Create new VideoLayer + const newVideoLayer = new VideoLayer(layerUuid, layerName); + + // Add layer to active object + context.activeObject.layers.push(newVideoLayer); + + // Select the newly created layer + context.activeObject.activeLayer = newVideoLayer; + + // Update UI + updateLayers(); + if (context.timelineWidget) { + context.timelineWidget.requestRedraw(); + } + + console.log('Empty video layer created:', layerName); + } catch (error) { + console.error('Failed to create video layer:', error); + } +} + +// MIDI Command Wrappers +// Note: getAvailableInstruments() removed - now using node-based instruments + +async function createMIDITrack(name, instrument) { + try { + const trackId = await invoke('audio_create_track', { name, trackType: 'midi', instrument }); + console.log('MIDI track created:', name, 'with instrument:', instrument, 'ID:', trackId); + return trackId; + } catch (error) { + console.error('Failed to create MIDI track:', error); + throw error; + } +} + +async function createMIDIClip(trackId, startTime, duration) { + try { + const clipId = await invoke('audio_create_midi_clip', { trackId, startTime, duration }); + console.log('MIDI clip created on track', trackId, 'with ID:', clipId); + return clipId; + } catch (error) { + console.error('Failed to create MIDI clip:', error); + throw error; + } +} + +async function addMIDINote(trackId, clipId, timeOffset, note, velocity, duration) { + try { + await invoke('audio_add_midi_note', { trackId, clipId, timeOffset, note, velocity, duration }); + console.log('MIDI note added:', note, 'at', timeOffset); + } catch (error) { + console.error('Failed to add MIDI note:', error); + throw error; + } +} + +async function loadMIDIFile(trackId, path, startTime) { + try { + const duration = await invoke('audio_load_midi_file', { trackId, path, startTime }); + console.log('MIDI file loaded:', path, 'duration:', duration); + return duration; + } catch (error) { + console.error('Failed to load MIDI file:', error); + throw error; + } +} + +// ========== Oscilloscope Visualization ========== + +// Store oscilloscope update intervals by node ID +const oscilloscopeIntervals = new Map(); +// Store oscilloscope time scales by node ID +const oscilloscopeTimeScales = new Map(); + +// Start oscilloscope visualization for a node +function startOscilloscopeVisualization(nodeId, trackId, backendNodeId, editorRef) { + // Clear any existing interval for this node + stopOscilloscopeVisualization(nodeId); + + // Find the canvas by traversing from the node element + const nodeElement = document.getElementById(`node-${nodeId}`); + if (!nodeElement) { + console.warn(`Node element not found for node ${nodeId}`); + return; + } + + const canvas = nodeElement.querySelector('canvas[id^="oscilloscope-canvas-"]'); + if (!canvas) { + console.warn(`Oscilloscope canvas not found in node ${nodeId}`); + return; + } + + console.log(`Found oscilloscope canvas for node ${nodeId}:`, canvas.id); + + const ctx = canvas.getContext('2d'); + const width = canvas.width; + const height = canvas.height; + + // Initialize time scale to default (100ms) + if (!oscilloscopeTimeScales.has(nodeId)) { + oscilloscopeTimeScales.set(nodeId, 100); + } + + // Update function to fetch and draw oscilloscope data + const updateOscilloscope = async () => { + try { + // Calculate samples needed based on time scale + // Assuming 48kHz sample rate + const timeScaleMs = oscilloscopeTimeScales.get(nodeId) || 100; + const sampleRate = 48000; + const samplesNeeded = Math.floor((timeScaleMs / 1000) * sampleRate); + // Cap at 2 seconds worth of samples to avoid excessive memory usage + const sampleCount = Math.min(samplesNeeded, sampleRate * 2); + + // Fetch oscilloscope data + const data = await invoke('get_oscilloscope_data', { + trackId: trackId, + nodeId: backendNodeId, + sampleCount: sampleCount + }); + + // Clear canvas + ctx.fillStyle = '#1a1a1a'; + ctx.fillRect(0, 0, width, height); + + // Draw grid lines + ctx.strokeStyle = '#2a2a2a'; + ctx.lineWidth = 1; + + // Horizontal grid lines + ctx.beginPath(); + ctx.moveTo(0, height / 2); + ctx.lineTo(width, height / 2); + ctx.stroke(); + + // Draw audio waveform + if (data && data.audio && data.audio.length > 0) { + ctx.strokeStyle = '#4CAF50'; + ctx.lineWidth = 2; + ctx.beginPath(); + + const xStep = width / data.audio.length; + for (let i = 0; i < data.audio.length; i++) { + const x = i * xStep; + // Map sample value from [-1, 1] to canvas height + const y = height / 2 - (data.audio[i] * height / 2); + + if (i === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + } + ctx.stroke(); + } + + // Draw CV trace in orange if present and CV input is connected + if (data && data.cv && data.cv.length > 0 && editorRef) { + // Check if CV input (port index 2 = input_3 in drawflow) is connected + const node = editorRef.getNodeFromId(nodeId); + const cvInput = node?.inputs?.input_3; + const isCvConnected = cvInput && cvInput.connections && cvInput.connections.length > 0; + + if (isCvConnected) { + ctx.strokeStyle = '#FF9800'; // Orange color + ctx.lineWidth = 2; + ctx.beginPath(); + + const xStep = width / data.cv.length; + for (let i = 0; i < data.cv.length; i++) { + const x = i * xStep; + // Map CV value from [-1, 1] to canvas height + const y = height / 2 - (data.cv[i] * height / 2); + + if (i === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + } + ctx.stroke(); + } + } + } catch (error) { + console.error('Failed to update oscilloscope:', error); + } + }; + + // Initial update + updateOscilloscope(); + + // Update every 50ms (20 FPS) + const interval = setInterval(updateOscilloscope, 50); + oscilloscopeIntervals.set(nodeId, interval); +} + +// Stop oscilloscope visualization for a node +function stopOscilloscopeVisualization(nodeId) { + const interval = oscilloscopeIntervals.get(nodeId); + if (interval) { + clearInterval(interval); + oscilloscopeIntervals.delete(nodeId); + } +} + +// ========== End Oscilloscope Visualization ========== + async function testAudio() { console.log("Starting rust") await init(); @@ -8441,4 +12478,4 @@ async function testAudio() { document.addEventListener("click", startCoreInterfaceAudio); document.addEventListener("keydown", startCoreInterfaceAudio); } -testAudio() \ No newline at end of file +// testAudio() \ No newline at end of file diff --git a/src/models/animation.js b/src/models/animation.js new file mode 100644 index 0000000..9da13a0 --- /dev/null +++ b/src/models/animation.js @@ -0,0 +1,634 @@ +// Animation system models: Frame, Keyframe, AnimationCurve, AnimationData + +import { context, config, pointerList, startProps } from '../state.js'; + +// Get invoke from Tauri global +const { invoke } = window.__TAURI__.core; + +// Helper function for UUID generation +function uuidv4() { + return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) => + ( + +c ^ + (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (+c / 4))) + ).toString(16), + ); +} + +class Frame { + constructor(frameType = "normal", uuid = undefined) { + this.keys = {}; + this.shapes = []; + this.frameType = frameType; + this.keyTypes = new Set() + if (!uuid) { + this.idx = uuidv4(); + } else { + this.idx = uuid; + } + pointerList[this.idx] = this; + } + get exists() { + return true; + } + saveState() { + startProps[this.idx] = structuredClone(this.keys); + } + copy(idx) { + let newFrame = new Frame( + this.frameType, + idx.slice(0, 8) + this.idx.slice(8), + ); + newFrame.keys = structuredClone(this.keys); + newFrame.shapes = []; + for (let shape of this.shapes) { + newFrame.shapes.push(shape.copy(idx)); + } + return newFrame; + } + static fromJSON(json, Shape = null) { + if (!json) { + return undefined + } + // Shape parameter passed in to avoid circular dependency + // Will be provided by the calling code that has access to both modules + const frame = new Frame(json.frameType, json.idx); + frame.keyTypes = new Set(json.keyTypes) + frame.keys = json.keys; + if (Shape) { + for (let i in json.shapes) { + const shape = json.shapes[i]; + frame.shapes.push(Shape.fromJSON(shape)); + } + } + + return frame; + } + toJSON(randomizeUuid = false) { + const json = {}; + json.type = "Frame"; + json.frameType = this.frameType; + json.keyTypes = Array.from(this.keyTypes) + if (randomizeUuid) { + json.idx = uuidv4(); + } else { + json.idx = this.idx; + } + json.keys = structuredClone(this.keys); + json.shapes = []; + for (let shape of this.shapes) { + json.shapes.push(shape.toJSON(randomizeUuid)); + } + return json; + } + addShape(shape, sendToBack) { + if (sendToBack) { + this.shapes.unshift(shape); + } else { + this.shapes.push(shape); + } + } + removeShape(shape) { + let shapeIndex = this.shapes.indexOf(shape); + if (shapeIndex >= 0) { + this.shapes.splice(shapeIndex, 1); + } + } +} + +class TempFrame { + constructor() {} + get exists() { + return false; + } + get idx() { + return "tempFrame"; + } + get keys() { + return {}; + } + get shapes() { + return []; + } + get frameType() { + return "temp"; + } + copy() { + return this; + } + addShape() {} + removeShape() {} +} + +const tempFrame = new TempFrame(); + +// Animation system classes +class Keyframe { + constructor(time, value, interpolation = "linear", uuid = undefined) { + this.time = time; + this.value = value; + this.interpolation = interpolation; // 'linear', 'bezier', 'step', 'hold' + // For bezier interpolation + this.easeIn = { x: 0.42, y: 0 }; // Default ease-in control point + this.easeOut = { x: 0.58, y: 1 }; // Default ease-out control point + if (!uuid) { + this.idx = uuidv4(); + } else { + this.idx = uuid; + } + } + + static fromJSON(json) { + const keyframe = new Keyframe(json.time, json.value, json.interpolation, json.idx); + if (json.easeIn) keyframe.easeIn = json.easeIn; + if (json.easeOut) keyframe.easeOut = json.easeOut; + return keyframe; + } + + toJSON() { + return { + idx: this.idx, + time: this.time, + value: this.value, + interpolation: this.interpolation, + easeIn: this.easeIn, + easeOut: this.easeOut + }; + } +} + +class AnimationCurve { + constructor(parameter, uuid = undefined, parentAnimationData = null) { + this.parameter = parameter; // e.g., "x", "y", "rotation", "scale_x", "exists" + this.keyframes = []; // Always kept sorted by time + this.parentAnimationData = parentAnimationData; // Reference to parent AnimationData for duration updates + if (!uuid) { + this.idx = uuidv4(); + } else { + this.idx = uuid; + } + } + + addKeyframe(keyframe) { + // Time resolution based on framerate - half a frame's duration + // This can be exposed via UI later + const framerate = context.config?.framerate || 24; + const timeResolution = (1 / framerate) / 2; + + // Check if there's already a keyframe within the time resolution + const existingKeyframe = this.getKeyframeAtTime(keyframe.time, timeResolution); + + if (existingKeyframe) { + // Update the existing keyframe's value instead of adding a new one + existingKeyframe.value = keyframe.value; + existingKeyframe.interpolation = keyframe.interpolation; + if (keyframe.easeIn) existingKeyframe.easeIn = keyframe.easeIn; + if (keyframe.easeOut) existingKeyframe.easeOut = keyframe.easeOut; + + // Sync update to backend if this is an automation curve + this._syncAutomationKeyframeToBackend(existingKeyframe); + } else { + // Add new keyframe + this.keyframes.push(keyframe); + // Keep sorted by time + this.keyframes.sort((a, b) => a.time - b.time); + } + + // Update animation duration after adding keyframe + if (this.parentAnimationData) { + this.parentAnimationData.updateDuration(); + } + + // Sync to backend if this is an automation curve + this._syncAutomationKeyframeToBackend(keyframe); + } + + removeKeyframe(keyframe) { + const index = this.keyframes.indexOf(keyframe); + if (index >= 0) { + this.keyframes.splice(index, 1); + + // Update animation duration after removing keyframe + if (this.parentAnimationData) { + this.parentAnimationData.updateDuration(); + } + + // Sync to backend if this is an automation curve + this._syncAutomationKeyframeRemovalToBackend(keyframe); + } + } + + getKeyframeAtTime(time, timeResolution = 0) { + if (this.keyframes.length === 0) return null; + + // If no tolerance, use exact match with binary search + if (timeResolution === 0) { + let left = 0; + let right = this.keyframes.length - 1; + + while (left <= right) { + const mid = Math.floor((left + right) / 2); + if (this.keyframes[mid].time === time) { + return this.keyframes[mid]; + } else if (this.keyframes[mid].time < time) { + left = mid + 1; + } else { + right = mid - 1; + } + } + return null; + } + + // With tolerance, find the closest keyframe within timeResolution + let left = 0; + let right = this.keyframes.length - 1; + let closest = null; + let closestDist = Infinity; + + // Binary search to find the insertion point + while (left <= right) { + const mid = Math.floor((left + right) / 2); + const dist = Math.abs(this.keyframes[mid].time - time); + + if (dist < closestDist) { + closestDist = dist; + closest = this.keyframes[mid]; + } + + if (this.keyframes[mid].time < time) { + left = mid + 1; + } else { + right = mid - 1; + } + } + + // Also check adjacent keyframes for closest match + if (left < this.keyframes.length) { + const dist = Math.abs(this.keyframes[left].time - time); + if (dist < closestDist) { + closestDist = dist; + closest = this.keyframes[left]; + } + } + if (right >= 0) { + const dist = Math.abs(this.keyframes[right].time - time); + if (dist < closestDist) { + closestDist = dist; + closest = this.keyframes[right]; + } + } + + return closestDist < timeResolution ? closest : null; + } + + // Find the two keyframes that bracket the given time + getBracketingKeyframes(time) { + if (this.keyframes.length === 0) return { prev: null, next: null }; + if (this.keyframes.length === 1) return { prev: this.keyframes[0], next: this.keyframes[0] }; + + // Binary search to find the last keyframe at or before time + let left = 0; + let right = this.keyframes.length - 1; + let prevIndex = -1; + + while (left <= right) { + const mid = Math.floor((left + right) / 2); + if (this.keyframes[mid].time <= time) { + prevIndex = mid; // This could be our answer + left = mid + 1; // But check if there's a better one to the right + } else { + right = mid - 1; // Time is too large, search left + } + } + + // If time is before all keyframes + if (prevIndex === -1) { + return { prev: this.keyframes[0], next: this.keyframes[0], t: 0 }; + } + + // If time is after all keyframes + if (prevIndex === this.keyframes.length - 1) { + return { prev: this.keyframes[prevIndex], next: this.keyframes[prevIndex], t: 1 }; + } + + const prev = this.keyframes[prevIndex]; + const next = this.keyframes[prevIndex + 1]; + const t = (time - prev.time) / (next.time - prev.time); + + return { prev, next, t }; + } + + interpolate(time) { + if (this.keyframes.length === 0) { + return null; + } + + const { prev, next, t } = this.getBracketingKeyframes(time); + + if (!prev || !next) { + return null; + } + if (prev === next) { + return prev.value; + } + + // Handle different interpolation types + switch (prev.interpolation) { + case "step": + case "hold": + return prev.value; + + case "linear": + // Simple linear interpolation + if (typeof prev.value === "number" && typeof next.value === "number") { + return prev.value + (next.value - prev.value) * t; + } + return prev.value; + + case "bezier": + // Cubic bezier interpolation using control points + if (typeof prev.value === "number" && typeof next.value === "number") { + // Use ease-in/ease-out control points + const easedT = this.cubicBezierEase(t, prev.easeOut, next.easeIn); + return prev.value + (next.value - prev.value) * easedT; + } + return prev.value; + + case "zero": + // Return 0 for the entire interval (used for inactive segments) + return 0; + + default: + return prev.value; + } + } + + // Cubic bezier easing function + cubicBezierEase(t, easeOut, easeIn) { + // Simplified cubic bezier for 0,0 -> easeOut -> easeIn -> 1,1 + const u = 1 - t; + return 3 * u * u * t * easeOut.y + + 3 * u * t * t * easeIn.y + + t * t * t; + } + + // Display color for this curve in timeline (based on parameter type) - Phase 4 + get displayColor() { + // Auto-determined from parameter name + if (this.parameter.endsWith('.x')) return '#7a00b3' // purple + if (this.parameter.endsWith('.y')) return '#ff00ff' // magenta + if (this.parameter.endsWith('.rotation')) return '#5555ff' // blue + if (this.parameter.endsWith('.scale_x')) return '#ffaa00' // orange + if (this.parameter.endsWith('.scale_y')) return '#ffff55' // yellow + if (this.parameter.endsWith('.exists')) return '#55ff55' // green + if (this.parameter.endsWith('.zOrder')) return '#55ffff' // cyan + if (this.parameter.endsWith('.frameNumber')) return '#ff5555' // red + return '#ffffff' // default white + } + + static fromJSON(json) { + const curve = new AnimationCurve(json.parameter, json.idx); + for (let kfJson of json.keyframes || []) { + curve.keyframes.push(Keyframe.fromJSON(kfJson)); + } + return curve; + } + + toJSON() { + return { + idx: this.idx, + parameter: this.parameter, + keyframes: this.keyframes.map(kf => kf.toJSON()) + }; + } + + // Helper method to sync keyframe additions to backend for automation curves + _syncAutomationKeyframeToBackend(keyframe) { + // Check if this is an automation curve (parameter starts with "automation.") + if (!this.parameter.startsWith('automation.')) { + return; // Not an automation curve, skip backend sync + } + + // Extract node ID from parameter (e.g., "automation.5" -> 5) + const nodeIdStr = this.parameter.split('.')[1]; + const nodeId = parseInt(nodeIdStr, 10); + if (isNaN(nodeId)) { + console.error(`Invalid automation node ID: ${nodeIdStr}`); + return; + } + + // Convert keyframe to backend format + const backendKeyframe = { + time: keyframe.time, + value: keyframe.value, + interpolation: keyframe.interpolation || 'linear', + ease_out: keyframe.easeOut ? [keyframe.easeOut.x, keyframe.easeOut.y] : [0.58, 1.0], + ease_in: keyframe.easeIn ? [keyframe.easeIn.x, keyframe.easeIn.y] : [0.42, 0.0] + }; + + // Call Tauri command (fire-and-forget) + // Note: Need to get track_id from context - for now, find it from the curve's parent + const track = window.root?.audioTracks?.find(t => + t.animationData && Object.values(t.animationData.curves).includes(this) + ); + + if (!track || track.audioTrackId === null) { + console.error('Could not find track for automation curve sync'); + return; + } + + invoke('automation_add_keyframe', { + trackId: track.audioTrackId, + nodeId: nodeId, + keyframe: backendKeyframe + }).catch(err => { + console.error(`Failed to sync automation keyframe to backend: ${err}`); + }); + } + + // Helper method to sync keyframe removals to backend for automation curves + _syncAutomationKeyframeRemovalToBackend(keyframe) { + // Check if this is an automation curve (parameter starts with "automation.") + if (!this.parameter.startsWith('automation.')) { + return; // Not an automation curve, skip backend sync + } + + // Extract node ID from parameter (e.g., "automation.5" -> 5) + const nodeIdStr = this.parameter.split('.')[1]; + const nodeId = parseInt(nodeIdStr, 10); + if (isNaN(nodeId)) { + console.error(`Invalid automation node ID: ${nodeIdStr}`); + return; + } + + // Call Tauri command (fire-and-forget) + // Note: Need to get track_id from context - for now, find it from the curve's parent + const track = window.root?.audioTracks?.find(t => + t.animationData && Object.values(t.animationData.curves).includes(this) + ); + + if (!track || track.audioTrackId === null) { + console.error('Could not find track for automation curve sync'); + return; + } + + invoke('automation_remove_keyframe', { + trackId: track.audioTrackId, + nodeId: nodeId, + time: keyframe.time + }).catch(err => { + console.error(`Failed to sync automation keyframe removal to backend: ${err}`); + }); + } +} + +class AnimationData { + constructor(parentLayer = null, uuid = undefined) { + this.curves = {}; // parameter name -> AnimationCurve + this.duration = 0; // Duration in seconds (max time of all keyframes) + this.parentLayer = parentLayer; // Reference to parent Layer for updating segment keyframes + if (!uuid) { + this.idx = uuidv4(); + } else { + this.idx = uuid; + } + } + + getCurve(parameter) { + return this.curves[parameter]; + } + + getOrCreateCurve(parameter) { + if (!this.curves[parameter]) { + this.curves[parameter] = new AnimationCurve(parameter, undefined, this); + } + return this.curves[parameter]; + } + + addKeyframe(parameter, keyframe) { + const curve = this.getOrCreateCurve(parameter); + curve.addKeyframe(keyframe); + } + + removeKeyframe(parameter, keyframe) { + const curve = this.curves[parameter]; + if (curve) { + curve.removeKeyframe(keyframe); + } + } + + removeCurve(parameter) { + delete this.curves[parameter]; + } + + setCurve(parameter, curve) { + // Set parent reference for duration tracking + curve.parentAnimationData = this; + this.curves[parameter] = curve; + // Update duration after adding curve with keyframes + this.updateDuration(); + } + + interpolate(parameter, time) { + const curve = this.curves[parameter]; + if (!curve) return null; + return curve.interpolate(time); + } + + // Get all animated values at a given time + getValuesAtTime(time) { + const values = {}; + for (let parameter in this.curves) { + values[parameter] = this.curves[parameter].interpolate(time); + } + return values; + } + + /** + * Update the duration based on all keyframes + * Called automatically when keyframes are added/removed + */ + updateDuration() { + // Calculate max time from all keyframes in all curves + let maxTime = 0; + for (let parameter in this.curves) { + const curve = this.curves[parameter]; + if (curve.keyframes && curve.keyframes.length > 0) { + const lastKeyframe = curve.keyframes[curve.keyframes.length - 1]; + maxTime = Math.max(maxTime, lastKeyframe.time); + } + } + + // Update this AnimationData's duration + this.duration = maxTime; + + // If this layer belongs to a nested group, update the segment keyframes in the parent + if (this.parentLayer && this.parentLayer.parentObject) { + this.updateParentSegmentKeyframes(); + } + } + + /** + * Update segment keyframes in parent layer when this layer's duration changes + * This ensures that nested group segments automatically resize when internal animation is added + */ + updateParentSegmentKeyframes() { + const parentObject = this.parentLayer.parentObject; + + // Get the layer that contains this nested object (parentObject.parentLayer) + if (!parentObject.parentLayer || !parentObject.parentLayer.animationData) { + return; + } + + const parentLayer = parentObject.parentLayer; + + // Get the frameNumber curve for this nested object using the correct naming convention + const curveName = `child.${parentObject.idx}.frameNumber`; + const frameNumberCurve = parentLayer.animationData.getCurve(curveName); + + if (!frameNumberCurve || frameNumberCurve.keyframes.length < 2) { + return; + } + + // Update the last keyframe to match the new duration + const lastKeyframe = frameNumberCurve.keyframes[frameNumberCurve.keyframes.length - 1]; + const newFrameValue = Math.ceil(this.duration * config.framerate) + 1; // +1 because frameNumber is 1-indexed + const newTime = this.duration; + + // Only update if the time or value actually changed + if (lastKeyframe.value !== newFrameValue || lastKeyframe.time !== newTime) { + lastKeyframe.value = newFrameValue; + lastKeyframe.time = newTime; + + // Re-sort keyframes in case the time change affects order + frameNumberCurve.keyframes.sort((a, b) => a.time - b.time); + + // Don't recursively call updateDuration to avoid infinite loop + } + } + + static fromJSON(json, parentLayer = null) { + const animData = new AnimationData(parentLayer, json.idx); + for (let param in json.curves || {}) { + const curve = AnimationCurve.fromJSON(json.curves[param]); + curve.parentAnimationData = animData; // Restore parent reference + animData.curves[param] = curve; + } + // Recalculate duration after loading all curves + animData.updateDuration(); + return animData; + } + + toJSON() { + const curves = {}; + for (let param in this.curves) { + curves[param] = this.curves[param].toJSON(); + } + return { + idx: this.idx, + curves: curves + }; + } +} + +export { Frame, TempFrame, tempFrame, Keyframe, AnimationCurve, AnimationData }; diff --git a/src/models/graphics-object.js b/src/models/graphics-object.js new file mode 100644 index 0000000..f17538a --- /dev/null +++ b/src/models/graphics-object.js @@ -0,0 +1,970 @@ +// GraphicsObject model: Main container for layers and animation + +import { context, config, pointerList, startProps } from '../state.js'; +import { VectorLayer, AudioTrack, VideoLayer } from './layer.js'; +import { TempShape } from './shapes.js'; +import { AnimationCurve, Keyframe } from './animation.js'; +import { Widget } from '../widgets.js'; + +// Helper function for UUID generation +function uuidv4() { + return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) => + ( + +c ^ + (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (+c / 4))) + ).toString(16), + ); +} + +// Forward declarations for dependencies that will be injected +let growBoundingBox = null; +let getRotatedBoundingBox = null; +let multiplyMatrices = null; +let uuidToColor = null; + +// Initialize function to be called from main.js +export function initializeGraphicsObjectDependencies(deps) { + growBoundingBox = deps.growBoundingBox; + getRotatedBoundingBox = deps.getRotatedBoundingBox; + multiplyMatrices = deps.multiplyMatrices; + uuidToColor = deps.uuidToColor; +} + +class GraphicsObject extends Widget { + constructor(uuid, initialChildType = 'layer') { + super(0, 0) + this.rotation = 0; // in radians + this.scale_x = 1; + this.scale_y = 1; + if (!uuid) { + this.idx = uuidv4(); + } else { + this.idx = uuid; + } + pointerList[this.idx] = this; + this.name = this.idx; + + this.currentFrameNum = 0; // LEGACY: kept for backwards compatibility + this._currentTime = 0; // Internal storage for currentTime + this.currentLayer = 0; + + // Make currentTime a getter/setter property + Object.defineProperty(this, 'currentTime', { + get: function() { + return this._currentTime; + }, + set: function(value) { + this._currentTime = value; + }, + enumerable: true, + configurable: true + }); + this._activeAudioTrack = null; // Reference to active audio track (if any) + + // Initialize children and audioTracks based on initialChildType + this.children = []; + this.audioTracks = []; + + if (initialChildType === 'layer') { + this.children = [new VectorLayer(uuid + "-L1", this)]; + this.currentLayer = 0; // Set first layer as active + } else if (initialChildType === 'video') { + this.children = [new VideoLayer(uuid + "-V1", "Video 1")]; + this.currentLayer = 0; // Set first video layer as active + } else if (initialChildType === 'midi') { + const midiTrack = new AudioTrack(uuid + "-M1", "MIDI 1", 'midi'); + this.audioTracks.push(midiTrack); + this._activeAudioTrack = midiTrack; // Set MIDI track as active (the object, not index) + // Initialize the MIDI track in the audio backend + midiTrack.initializeTrack().catch(err => { + console.error('Failed to initialize MIDI track:', err); + }); + } else if (initialChildType === 'audio') { + const audioTrack = new AudioTrack(uuid + "-A1", "Audio 1", 'audio'); + this.audioTracks.push(audioTrack); + this._activeAudioTrack = audioTrack; // Set audio track as active (the object, not index) + audioTrack.initializeTrack().catch(err => { + console.error('Failed to initialize audio track:', err); + }); + } + // If initialChildType is 'none' or anything else, leave both arrays empty + + this.shapes = []; + + // Parent reference for nested objects (set when added to a layer) + this.parentLayer = null + + // Timeline display settings (Phase 3) + this.showSegment = true // Show segment bar in timeline + this.curvesMode = 'keyframe' // 'segment' | 'keyframe' | 'curve' + this.curvesHeight = 150 // Height in pixels when curves are in curve view + + this._globalEvents.add("mousedown") + this._globalEvents.add("mousemove") + this._globalEvents.add("mouseup") + } + static fromJSON(json) { + const graphicsObject = new GraphicsObject(json.idx); + graphicsObject.x = json.x; + graphicsObject.y = json.y; + graphicsObject.rotation = json.rotation; + graphicsObject.scale_x = json.scale_x; + graphicsObject.scale_y = json.scale_y; + graphicsObject.name = json.name; + graphicsObject.currentFrameNum = json.currentFrameNum; + graphicsObject.currentLayer = json.currentLayer; + graphicsObject.children = []; + if (json.parent in pointerList) { + graphicsObject.parent = pointerList[json.parent] + } + for (let layer of json.layers) { + if (layer.type === 'VideoLayer') { + graphicsObject.layers.push(VideoLayer.fromJSON(layer)); + } else { + // Default to VectorLayer + graphicsObject.layers.push(VectorLayer.fromJSON(layer, graphicsObject)); + } + } + // Handle audioTracks (may not exist in older files) + if (json.audioTracks) { + for (let audioTrack of json.audioTracks) { + graphicsObject.audioTracks.push(AudioTrack.fromJSON(audioTrack)); + } + } + return graphicsObject; + } + toJSON(randomizeUuid = false) { + const json = {}; + json.type = "GraphicsObject"; + json.x = this.x; + json.y = this.y; + json.rotation = this.rotation; + json.scale_x = this.scale_x; + json.scale_y = this.scale_y; + if (randomizeUuid) { + json.idx = uuidv4(); + json.name = this.name + " copy"; + } else { + json.idx = this.idx; + json.name = this.name; + } + json.currentFrameNum = this.currentFrameNum; + json.currentLayer = this.currentLayer; + json.layers = []; + json.parent = this.parent?.idx + for (let layer of this.layers) { + json.layers.push(layer.toJSON(randomizeUuid)); + } + json.audioTracks = []; + for (let audioTrack of this.audioTracks) { + json.audioTracks.push(audioTrack.toJSON(randomizeUuid)); + } + return json; + } + get activeLayer() { + // If an audio track is active, return it instead of a visual layer + if (this._activeAudioTrack !== null) { + return this._activeAudioTrack; + } + return this.layers[this.currentLayer]; + } + set activeLayer(layer) { + // Allow setting activeLayer to an AudioTrack or a regular Layer + if (layer instanceof AudioTrack) { + this._activeAudioTrack = layer; + } else { + // It's a regular layer - find its index and set currentLayer + this._activeAudioTrack = null; + const layerIndex = this.children.indexOf(layer); + if (layerIndex !== -1) { + this.currentLayer = layerIndex; + } + } + } + // get children() { + // return this.activeLayer.children; + // } + get layers() { + return this.children + } + + /** + * Get the total duration of this GraphicsObject's animation + * Returns the maximum duration across all layers + */ + get duration() { + let maxDuration = 0; + + // Check visual layers + for (let layer of this.layers) { + // Check animation data duration + if (layer.animationData && layer.animationData.duration > maxDuration) { + maxDuration = layer.animationData.duration; + } + + // Check video layer clips (VideoLayer has clips like AudioTrack) + if (layer.type === 'video' && layer.clips) { + for (let clip of layer.clips) { + const clipEnd = clip.startTime + clip.duration; + if (clipEnd > maxDuration) { + maxDuration = clipEnd; + } + } + } + } + + // Check audio tracks + for (let audioTrack of this.audioTracks) { + for (let clip of audioTrack.clips) { + const clipEnd = clip.startTime + clip.duration; + if (clipEnd > maxDuration) { + maxDuration = clipEnd; + } + } + } + + return maxDuration; + } + get allLayers() { + return [...this.audioTracks, ...this.layers]; + } + get maxFrame() { + return ( + Math.max( + ...this.layers.map((layer) => { + return ( + layer.frames.findLastIndex((frame) => frame !== undefined) || -1 + ); + }), + ) + 1 + ); + } + get segmentColor() { + return uuidToColor(this.idx); + } + /** + * Set the current playback time in seconds + */ + setTime(time) { + time = Math.max(0, time); + this.currentTime = time; + + // Update legacy currentFrameNum for any remaining code that needs it + this.currentFrameNum = Math.floor(time * config.framerate); + + // Update layer frameNum for legacy code + for (let layer of this.layers) { + layer.frameNum = this.currentFrameNum; + } + } + + advanceFrame() { + const frameDuration = 1 / config.framerate; + this.setTime(this.currentTime + frameDuration); + } + + decrementFrame() { + const frameDuration = 1 / config.framerate; + this.setTime(Math.max(0, this.currentTime - frameDuration)); + } + bbox() { + let bbox; + + // NEW: Include shapes from AnimationData system + let currentTime = this.currentTime || 0; + for (let layer of this.layers) { + for (let shape of layer.shapes) { + // Check if shape exists at current time + let existsValue = layer.animationData.interpolate(`shape.${shape.shapeId}.exists`, currentTime); + if (existsValue !== null && existsValue > 0) { + if (!bbox) { + bbox = structuredClone(shape.boundingBox); + } else { + growBoundingBox(bbox, shape.boundingBox); + } + } + } + } + + // Include children + if (this.children.length > 0) { + if (!bbox) { + bbox = structuredClone(this.children[0].bbox()); + } + for (let child of this.children) { + growBoundingBox(bbox, child.bbox()); + } + } + + if (bbox == undefined) { + bbox = { x: { min: 0, max: 0 }, y: { min: 0, max: 0 } }; + } + bbox.x.max *= this.scale_x; + bbox.y.max *= this.scale_y; + bbox.x.min += this.x; + bbox.x.max += this.x; + bbox.y.min += this.y; + bbox.y.max += this.y; + return bbox; + } + + draw(context, calculateTransform=false) { + let ctx = context.ctx; + ctx.save(); + if (calculateTransform) { + this.transformCanvas(ctx) + } else { + ctx.translate(this.x, this.y); + ctx.rotate(this.rotation); + ctx.scale(this.scale_x, this.scale_y); + } + // if (this.currentFrameNum>=this.maxFrame) { + // this.currentFrameNum = 0; + // } + if ( + context.activeAction && + context.activeAction.selection && + this.idx in context.activeAction.selection + ) + return; + + for (let layer of this.layers) { + if (context.activeObject == this && !layer.visible) continue; + + // Handle VideoLayer differently - call its draw method + if (layer.type === 'video') { + layer.draw(context); + continue; + } + + // Draw activeShape (shape being drawn in progress) for active layer only + if (layer === context.activeLayer && layer.activeShape) { + let cxt = {...context}; + layer.activeShape.draw(cxt); + } + + // NEW: Use AnimationData system to draw shapes with shape tweening/morphing + let currentTime = this.currentTime || 0; + + // Group shapes by shapeId (multiple Shape objects can share a shapeId for tweening) + const shapesByShapeId = new Map(); + for (let shape of layer.shapes) { + if (shape instanceof TempShape) continue; + if (!shapesByShapeId.has(shape.shapeId)) { + shapesByShapeId.set(shape.shapeId, []); + } + shapesByShapeId.get(shape.shapeId).push(shape); + } + + // Process each logical shape (shapeId) and determine what to draw + let visibleShapes = []; + for (let [shapeId, shapes] of shapesByShapeId) { + // Check if this logical shape exists at current time + const existsCurveKey = `shape.${shapeId}.exists`; + let existsValue = layer.animationData.interpolate(existsCurveKey, currentTime); + + if (existsValue === null || existsValue <= 0) { + console.log(`[Widget.draw] Skipping shape ${shapeId} - not visible`); + continue; + } + + // Get z-order + let zOrder = layer.animationData.interpolate(`shape.${shapeId}.zOrder`, currentTime); + + // Get shapeIndex curve and surrounding keyframes + const shapeIndexCurve = layer.animationData.getCurve(`shape.${shapeId}.shapeIndex`); + if (!shapeIndexCurve || !shapeIndexCurve.keyframes || shapeIndexCurve.keyframes.length === 0) { + // No shapeIndex curve, just show shape with index 0 + const shape = shapes.find(s => s.shapeIndex === 0); + if (shape) { + visibleShapes.push({ + shape, + zOrder: zOrder || 0, + selected: context.shapeselection.includes(shape) + }); + } + continue; + } + + // Find surrounding keyframes using AnimationCurve's built-in method + const { prev: prevKf, next: nextKf, t: interpolationT } = shapeIndexCurve.getBracketingKeyframes(currentTime); + + // Get interpolated value + let shapeIndexValue = shapeIndexCurve.interpolate(currentTime); + if (shapeIndexValue === null) shapeIndexValue = 0; + + // Sort shape versions by shapeIndex + shapes.sort((a, b) => a.shapeIndex - b.shapeIndex); + + // Determine whether to morph based on whether interpolated value equals a keyframe value + const atPrevKeyframe = prevKf && Math.abs(shapeIndexValue - prevKf.value) < 0.001; + const atNextKeyframe = nextKf && Math.abs(shapeIndexValue - nextKf.value) < 0.001; + + if (atPrevKeyframe || atNextKeyframe) { + // No morphing - display the shape at the keyframe value + const targetValue = atNextKeyframe ? nextKf.value : prevKf.value; + const shape = shapes.find(s => s.shapeIndex === targetValue); + if (shape) { + visibleShapes.push({ + shape, + zOrder: zOrder || 0, + selected: context.shapeselection.includes(shape) + }); + } + } else if (prevKf && nextKf && prevKf.value !== nextKf.value) { + // Morph between shapes specified by surrounding keyframes + const shape1 = shapes.find(s => s.shapeIndex === prevKf.value); + const shape2 = shapes.find(s => s.shapeIndex === nextKf.value); + + if (shape1 && shape2) { + // Use the interpolated shapeIndexValue to calculate blend factor + // This respects the bezier easing curve + const t = (shapeIndexValue - prevKf.value) / (nextKf.value - prevKf.value); + console.log(`[Widget.draw] Morphing from shape ${prevKf.value} to ${nextKf.value}, shapeIndexValue=${shapeIndexValue}, t=${t}`); + const morphedShape = shape1.lerpShape(shape2, t); + visibleShapes.push({ + shape: morphedShape, + zOrder: zOrder || 0, + selected: context.shapeselection.includes(shape1) || context.shapeselection.includes(shape2) + }); + } else if (shape1) { + visibleShapes.push({ + shape: shape1, + zOrder: zOrder || 0, + selected: context.shapeselection.includes(shape1) + }); + } else if (shape2) { + visibleShapes.push({ + shape: shape2, + zOrder: zOrder || 0, + selected: context.shapeselection.includes(shape2) + }); + } + } else if (nextKf) { + // Only next keyframe exists, show that shape + const shape = shapes.find(s => s.shapeIndex === nextKf.value); + if (shape) { + visibleShapes.push({ + shape, + zOrder: zOrder || 0, + selected: context.shapeselection.includes(shape) + }); + } + } + } + + // Sort by zOrder + visibleShapes.sort((a, b) => a.zOrder - b.zOrder); + + // Draw sorted shapes + for (let { shape, selected } of visibleShapes) { + let cxt = {...context} + if (selected) { + cxt.selected = true + } + shape.draw(cxt); + } + + // Draw child objects using AnimationData curves + for (let child of layer.children) { + if (child == context.activeObject) continue; + let idx = child.idx; + + // Use AnimationData to get child's transform + let childX = layer.animationData.interpolate(`child.${idx}.x`, currentTime); + let childY = layer.animationData.interpolate(`child.${idx}.y`, currentTime); + let childRotation = layer.animationData.interpolate(`child.${idx}.rotation`, currentTime); + let childScaleX = layer.animationData.interpolate(`child.${idx}.scale_x`, currentTime); + let childScaleY = layer.animationData.interpolate(`child.${idx}.scale_y`, currentTime); + let childFrameNumber = layer.animationData.interpolate(`child.${idx}.frameNumber`, currentTime); + + if (childX !== null && childY !== null) { + child.x = childX; + child.y = childY; + child.rotation = childRotation || 0; + child.scale_x = childScaleX || 1; + child.scale_y = childScaleY || 1; + + // Set child's currentTime based on its frameNumber + // frameNumber 1 = time 0, frameNumber 2 = time 1/framerate, etc. + if (childFrameNumber !== null) { + child.currentTime = (childFrameNumber - 1) / config.framerate; + } + + ctx.save(); + child.draw(context); + ctx.restore(); + } + } + } + if (this == context.activeObject) { + // Draw selection rectangles for selected items + if (context.mode == "select") { + for (let item of context.selection) { + if (!item) continue; + ctx.save(); + ctx.strokeStyle = "#00ffff"; + ctx.lineWidth = 1; + ctx.beginPath(); + let bbox = getRotatedBoundingBox(item); + ctx.rect( + bbox.x.min, + bbox.y.min, + bbox.x.max - bbox.x.min, + bbox.y.max - bbox.y.min, + ); + ctx.stroke(); + ctx.restore(); + } + // Draw drag selection rectangle + if (context.selectionRect) { + ctx.save(); + ctx.strokeStyle = "#00ffff"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.rect( + context.selectionRect.x1, + context.selectionRect.y1, + context.selectionRect.x2 - context.selectionRect.x1, + context.selectionRect.y2 - context.selectionRect.y1, + ); + ctx.stroke(); + ctx.restore(); + } + } else if (context.mode == "transform") { + let bbox = undefined; + for (let item of context.selection) { + if (bbox == undefined) { + bbox = getRotatedBoundingBox(item); + } else { + growBoundingBox(bbox, getRotatedBoundingBox(item)); + } + } + if (bbox != undefined) { + ctx.save(); + ctx.strokeStyle = "#00ffff"; + ctx.lineWidth = 1; + ctx.beginPath(); + let xdiff = bbox.x.max - bbox.x.min; + let ydiff = bbox.y.max - bbox.y.min; + ctx.rect(bbox.x.min, bbox.y.min, xdiff, ydiff); + ctx.stroke(); + ctx.fillStyle = "#000000"; + let rectRadius = 5; + for (let i of [ + [0, 0], + [0.5, 0], + [1, 0], + [1, 0.5], + [1, 1], + [0.5, 1], + [0, 1], + [0, 0.5], + ]) { + ctx.beginPath(); + ctx.rect( + bbox.x.min + xdiff * i[0] - rectRadius, + bbox.y.min + ydiff * i[1] - rectRadius, + rectRadius * 2, + rectRadius * 2, + ); + ctx.fill(); + } + ctx.restore(); + } + } + + if (context.activeCurve) { + ctx.strokeStyle = "magenta"; + ctx.beginPath(); + ctx.moveTo( + context.activeCurve.current.points[0].x, + context.activeCurve.current.points[0].y, + ); + ctx.bezierCurveTo( + context.activeCurve.current.points[1].x, + context.activeCurve.current.points[1].y, + context.activeCurve.current.points[2].x, + context.activeCurve.current.points[2].y, + context.activeCurve.current.points[3].x, + context.activeCurve.current.points[3].y, + ); + ctx.stroke(); + } + if (context.activeVertex) { + ctx.save(); + ctx.strokeStyle = "#00ffff"; + let curves = { + ...context.activeVertex.current.startCurves, + ...context.activeVertex.current.endCurves, + }; + // I don't understand why I can't use a for...of loop here + for (let idx in curves) { + let curve = curves[idx]; + ctx.beginPath(); + ctx.moveTo(curve.points[0].x, curve.points[0].y); + ctx.bezierCurveTo( + curve.points[1].x, + curve.points[1].y, + curve.points[2].x, + curve.points[2].y, + curve.points[3].x, + curve.points[3].y, + ); + ctx.stroke(); + } + ctx.fillStyle = "#000000aa"; + ctx.beginPath(); + let vertexSize = 15 / context.zoomLevel; + ctx.rect( + context.activeVertex.current.point.x - vertexSize / 2, + context.activeVertex.current.point.y - vertexSize / 2, + vertexSize, + vertexSize, + ); + ctx.fill(); + ctx.restore(); + } + } + ctx.restore(); + } + /* + draw(ctx) { + super.draw(ctx) + if (this==context.activeObject) { + if (context.mode == "select") { + for (let item of context.selection) { + if (!item) continue; + // Check if this is a child object and if it exists at current time + if (item.idx) { + const existsValue = this.activeLayer.animationData.interpolate( + `object.${item.idx}.exists`, + this.currentTime + ); + if (existsValue === null || existsValue <= 0) continue; + } + ctx.save(); + ctx.strokeStyle = "#00ffff"; + ctx.lineWidth = 1; + ctx.beginPath(); + let bbox = getRotatedBoundingBox(item); + ctx.rect( + bbox.x.min, + bbox.y.min, + bbox.x.max - bbox.x.min, + bbox.y.max - bbox.y.min, + ); + ctx.stroke(); + ctx.restore(); + } + if (context.selectionRect) { + ctx.save(); + ctx.strokeStyle = "#00ffff"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.rect( + context.selectionRect.x1, + context.selectionRect.y1, + context.selectionRect.x2 - context.selectionRect.x1, + context.selectionRect.y2 - context.selectionRect.y1, + ); + ctx.stroke(); + ctx.restore(); + } + } else if (context.mode == "transform") { + let bbox = undefined; + for (let item of context.selection) { + if (bbox == undefined) { + bbox = getRotatedBoundingBox(item); + } else { + growBoundingBox(bbox, getRotatedBoundingBox(item)); + } + } + if (bbox != undefined) { + ctx.save(); + ctx.strokeStyle = "#00ffff"; + ctx.lineWidth = 1; + ctx.beginPath(); + let xdiff = bbox.x.max - bbox.x.min; + let ydiff = bbox.y.max - bbox.y.min; + ctx.rect(bbox.x.min, bbox.y.min, xdiff, ydiff); + ctx.stroke(); + ctx.fillStyle = "#000000"; + let rectRadius = 5; + for (let i of [ + [0, 0], + [0.5, 0], + [1, 0], + [1, 0.5], + [1, 1], + [0.5, 1], + [0, 1], + [0, 0.5], + ]) { + ctx.beginPath(); + ctx.rect( + bbox.x.min + xdiff * i[0] - rectRadius, + bbox.y.min + ydiff * i[1] - rectRadius, + rectRadius * 2, + rectRadius * 2, + ); + ctx.fill(); + } + + ctx.restore(); + } + } + } + } + */ + transformCanvas(ctx) { + if (this.parent) { + this.parent.transformCanvas(ctx) + } + ctx.translate(this.x, this.y); + ctx.scale(this.scale_x, this.scale_y); + ctx.rotate(this.rotation); + } + transformMouse(mouse) { + // Apply the transformation matrix to the mouse position + let matrix = this.generateTransformMatrix(); + let { x, y } = mouse; + + return { + x: matrix[0][0] * x + matrix[0][1] * y + matrix[0][2], + y: matrix[1][0] * x + matrix[1][1] * y + matrix[1][2] + }; + } + generateTransformMatrix() { + // Start with the parent's transform matrix if it exists + let parentMatrix = this.parent ? this.parent.generateTransformMatrix() : [[1, 0, 0], [0, 1, 0], [0, 0, 1]]; + + // Calculate the rotation matrix components + const cos = Math.cos(this.rotation); + const sin = Math.sin(this.rotation); + + // Scaling matrix + const scaleMatrix = [ + [1/this.scale_x, 0, 0], + [0, 1/this.scale_y, 0], + [0, 0, 1] + ]; + + // Rotation matrix (inverse rotation for transforming back) + const rotationMatrix = [ + [cos, -sin, 0], + [sin, cos, 0], + [0, 0, 1] + ]; + + // Translation matrix (inverse translation to adjust for object's position) + const translationMatrix = [ + [1, 0, -this.x], + [0, 1, -this.y], + [0, 0, 1] + ]; + + // Multiply translation * rotation * scaling to get the current object's final transformation matrix + let tempMatrix = multiplyMatrices(translationMatrix, rotationMatrix); + let objectMatrix = multiplyMatrices(tempMatrix, scaleMatrix); + + // Now combine with the parent's matrix (parent * object) + let finalMatrix = multiplyMatrices(parentMatrix, objectMatrix); + + return finalMatrix; + } + handleMouseEvent(eventType, x, y) { + for (let i in this.layers) { + if (i==this.currentLayer) { + this.layers[i]._globalEvents.add("mousedown") + this.layers[i]._globalEvents.add("mousemove") + this.layers[i]._globalEvents.add("mouseup") + } else { + this.layers[i]._globalEvents.delete("mousedown") + this.layers[i]._globalEvents.delete("mousemove") + this.layers[i]._globalEvents.delete("mouseup") + } + } + super.handleMouseEvent(eventType, x, y) + } + addObject(object, x = 0, y = 0, time = undefined, layer=undefined) { + if (time == undefined) { + time = this.currentTime || 0; + } + if (layer==undefined) { + layer = this.activeLayer + } + + layer.children.push(object) + object.parent = this; + object.parentLayer = layer; + object.x = x; + object.y = y; + let idx = object.idx; + + // Add animation curves for the object's position/transform in the layer + let xCurve = new AnimationCurve(`child.${idx}.x`); + xCurve.addKeyframe(new Keyframe(time, x, 'linear')); + layer.animationData.setCurve(`child.${idx}.x`, xCurve); + + let yCurve = new AnimationCurve(`child.${idx}.y`); + yCurve.addKeyframe(new Keyframe(time, y, 'linear')); + layer.animationData.setCurve(`child.${idx}.y`, yCurve); + + let rotationCurve = new AnimationCurve(`child.${idx}.rotation`); + rotationCurve.addKeyframe(new Keyframe(time, 0, 'linear')); + layer.animationData.setCurve(`child.${idx}.rotation`, rotationCurve); + + let scaleXCurve = new AnimationCurve(`child.${idx}.scale_x`); + scaleXCurve.addKeyframe(new Keyframe(time, 1, 'linear')); + layer.animationData.setCurve(`child.${idx}.scale_x`, scaleXCurve); + + let scaleYCurve = new AnimationCurve(`child.${idx}.scale_y`); + scaleYCurve.addKeyframe(new Keyframe(time, 1, 'linear')); + layer.animationData.setCurve(`child.${idx}.scale_y`, scaleYCurve); + + // Add exists curve (object visibility) + let existsCurve = new AnimationCurve(`object.${idx}.exists`); + existsCurve.addKeyframe(new Keyframe(time, 1, 'hold')); + layer.animationData.setCurve(`object.${idx}.exists`, existsCurve); + + // Initialize frameNumber curve with two keyframes defining the segment + // The segment length is based on the object's internal animation duration + let frameNumberCurve = new AnimationCurve(`child.${idx}.frameNumber`); + + // Get the object's animation duration (max time across all its layers) + const objectDuration = object.duration || 0; + const framerate = config.framerate; + + // Calculate the last frame number (frameNumber 1 = time 0, so add 1) + const lastFrameNumber = Math.max(1, Math.ceil(objectDuration * framerate) + 1); + + // Calculate the end time for the segment (minimum 1 frame duration) + const segmentDuration = Math.max(objectDuration, 1 / framerate); + const endTime = time + segmentDuration; + + // Start keyframe: frameNumber 1 at the current time, linear interpolation + frameNumberCurve.addKeyframe(new Keyframe(time, 1, 'linear')); + + // End keyframe: last frame at end time, zero interpolation (inactive after this) + frameNumberCurve.addKeyframe(new Keyframe(endTime, lastFrameNumber, 'zero')); + + layer.animationData.setCurve(`child.${idx}.frameNumber`, frameNumberCurve); + } + removeChild(childObject) { + let idx = childObject.idx; + for (let layer of this.layers) { + layer.children = layer.children.filter(child => child.idx !== idx); + for (let frame of layer.frames) { + if (frame) { + delete frame[idx]; + } + } + } + // this.children.splice(this.children.indexOf(childObject), 1); + } + + /** + * Update this object's frameNumber curve in its parent layer based on child content + * This is called when shapes/children are added/modified within this object + */ + updateFrameNumberCurve() { + // Find parent layer that contains this object + if (!this.parent || !this.parent.animationData) return; + + const parentLayer = this.parent; + const frameNumberKey = `child.${this.idx}.frameNumber`; + + // Collect all keyframe times from this object's content + let allKeyframeTimes = new Set(); + + // Check all layers in this object + for (let layer of this.layers) { + if (!layer.animationData) continue; + + // Get keyframes from all shape curves + for (let shape of layer.shapes) { + const existsKey = `shape.${shape.shapeId}.exists`; + const existsCurve = layer.animationData.curves[existsKey]; + if (existsCurve && existsCurve.keyframes) { + for (let kf of existsCurve.keyframes) { + allKeyframeTimes.add(kf.time); + } + } + } + + // Get keyframes from all child object curves + for (let child of layer.children) { + const childFrameNumberKey = `child.${child.idx}.frameNumber`; + const childFrameNumberCurve = layer.animationData.curves[childFrameNumberKey]; + if (childFrameNumberCurve && childFrameNumberCurve.keyframes) { + for (let kf of childFrameNumberCurve.keyframes) { + allKeyframeTimes.add(kf.time); + } + } + } + } + + if (allKeyframeTimes.size === 0) return; + + // Sort times + const times = Array.from(allKeyframeTimes).sort((a, b) => a - b); + const firstTime = times[0]; + const lastTime = times[times.length - 1]; + + // Calculate frame numbers (1-based) + const framerate = this.framerate || 24; + const firstFrame = Math.floor(firstTime * framerate) + 1; + const lastFrame = Math.floor(lastTime * framerate) + 1; + + // Update or create frameNumber curve in parent layer + let frameNumberCurve = parentLayer.animationData.curves[frameNumberKey]; + if (!frameNumberCurve) { + frameNumberCurve = new AnimationCurve(frameNumberKey); + parentLayer.animationData.setCurve(frameNumberKey, frameNumberCurve); + } + + // Clear existing keyframes and add new ones + frameNumberCurve.keyframes = []; + frameNumberCurve.addKeyframe(new Keyframe(firstTime, firstFrame, 'hold')); + frameNumberCurve.addKeyframe(new Keyframe(lastTime, lastFrame, 'hold')); + } + + addLayer(layer) { + this.children.push(layer); + } + removeLayer(layer) { + this.children.splice(this.children.indexOf(layer), 1); + } + saveState() { + startProps[this.idx] = { + x: this.x, + y: this.y, + rotation: this.rotation, + scale_x: this.scale_x, + scale_y: this.scale_y, + }; + } + copy(idx) { + let newGO = new GraphicsObject(idx.slice(0, 8) + this.idx.slice(8)); + newGO.x = this.x; + newGO.y = this.y; + newGO.rotation = this.rotation; + newGO.scale_x = this.scale_x; + newGO.scale_y = this.scale_y; + newGO.parent = this.parent; + pointerList[this.idx] = this; + + newGO.layers = []; + for (let layer of this.layers) { + newGO.layers.push(layer.copy(idx)); + } + for (let audioTrack of this.audioTracks) { + newGO.audioTracks.push(audioTrack.copy(idx)); + } + + return newGO; + } +} + +export { GraphicsObject }; diff --git a/src/models/layer.js b/src/models/layer.js new file mode 100644 index 0000000..3b92b73 --- /dev/null +++ b/src/models/layer.js @@ -0,0 +1,1952 @@ +// Layer models: VectorLayer, AudioTrack, and VideoLayer classes + +import { context, config, pointerList } from '../state.js'; +import { Frame, AnimationData, Keyframe, tempFrame } from './animation.js'; +import { Widget } from '../widgets.js'; +import { Bezier } from '../bezier.js'; +import { + lerp, + lerpColor, + getKeyframesSurrounding, + growBoundingBox, + floodFillRegion, + getShapeAtPoint, + generateWaveform +} from '../utils.js'; +import { frameReceiver } from '../frame-receiver.js'; + +// External libraries (globals) +const Tone = window.Tone; + +// Tauri API +const { invoke, Channel } = window.__TAURI__.core; + +// Helper function for UUID generation +function uuidv4() { + return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) => + ( + +c ^ + (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (+c / 4))) + ).toString(16), + ); +} + +// Forward declarations for circular dependencies +// These will be set by main.js after all modules are loaded +let GraphicsObject = null; +let Shape = null; +let TempShape = null; +let updateUI = null; +let updateMenu = null; +let updateLayers = null; +let vectorDist = null; +let minSegmentSize = null; +let debugQuadtree = null; +let debugCurves = null; +let debugPoints = null; +let debugPaintbucket = null; +let d3 = null; +let actions = null; + +// Initialize function to be called from main.js +export function initializeLayerDependencies(deps) { + GraphicsObject = deps.GraphicsObject; + Shape = deps.Shape; + TempShape = deps.TempShape; + updateUI = deps.updateUI; + updateMenu = deps.updateMenu; + updateLayers = deps.updateLayers; + vectorDist = deps.vectorDist; + minSegmentSize = deps.minSegmentSize; + debugQuadtree = deps.debugQuadtree; + debugCurves = deps.debugCurves; + debugPoints = deps.debugPoints; + debugPaintbucket = deps.debugPaintbucket; + d3 = deps.d3; + actions = deps.actions; +} + +class VectorLayer extends Widget { + constructor(uuid, parentObject = null) { + super(0,0) + if (!uuid) { + this.idx = uuidv4(); + } else { + this.idx = uuid; + } + this.name = "VectorLayer"; + // LEGACY: Keep frames array for backwards compatibility during migration + this.frames = [new Frame("keyframe", this.idx + "-F1")]; + this.animationData = new AnimationData(this); + this.parentObject = parentObject; // Reference to parent GraphicsObject (for nested objects) + // this.frameNum = 0; + this.visible = true; + this.audible = true; + pointerList[this.idx] = this; + this.children = [] + this.shapes = [] + } + static fromJSON(json, parentObject = null) { + const layer = new VectorLayer(json.idx, parentObject); + for (let i in json.children) { + const child = json.children[i]; + const childObject = GraphicsObject.fromJSON(child); + childObject.parentLayer = layer; + layer.children.push(childObject); + } + layer.name = json.name; + + // Load animation data if present (new system) + if (json.animationData) { + layer.animationData = AnimationData.fromJSON(json.animationData, layer); + } + + // Load shapes if present + if (json.shapes) { + layer.shapes = json.shapes.map(shape => Shape.fromJSON(shape, layer)); + } + + // Load frames if present (old system - for backwards compatibility) + if (json.frames) { + layer.frames = []; + for (let i in json.frames) { + const frame = json.frames[i]; + if (!frame) { + layer.frames.push(undefined) + continue; + } + if (frame.frameType=="keyframe") { + layer.frames.push(Frame.fromJSON(frame)); + } else { + if (layer.frames[layer.frames.length-1]) { + if (frame.frameType == "motion") { + layer.frames[layer.frames.length-1].keyTypes.add("motion") + } else if (frame.frameType == "shape") { + layer.frames[layer.frames.length-1].keyTypes.add("shape") + } + } + layer.frames.push(undefined) + } + } + } + + layer.visible = json.visible; + layer.audible = json.audible; + + return layer; + } + toJSON(randomizeUuid = false) { + const json = {}; + json.type = "VectorLayer"; + if (randomizeUuid) { + json.idx = uuidv4(); + json.name = this.name + " copy"; + } else { + json.idx = this.idx; + json.name = this.name; + } + json.children = []; + let idMap = {} + for (let child of this.children) { + let childJson = child.toJSON(randomizeUuid) + idMap[child.idx] = childJson.idx + json.children.push(childJson); + } + + // Serialize animation data (new system) + json.animationData = this.animationData.toJSON(); + + // If randomizing UUIDs, update the curve parameter keys to use new child IDs + if (randomizeUuid && json.animationData.curves) { + const newCurves = {}; + for (let paramKey in json.animationData.curves) { + // paramKey format: "childId.property" + const parts = paramKey.split('.'); + if (parts.length >= 2) { + const oldChildId = parts[0]; + const property = parts.slice(1).join('.'); + if (oldChildId in idMap) { + const newParamKey = `${idMap[oldChildId]}.${property}`; + newCurves[newParamKey] = json.animationData.curves[paramKey]; + newCurves[newParamKey].parameter = newParamKey; + } else { + newCurves[paramKey] = json.animationData.curves[paramKey]; + } + } else { + newCurves[paramKey] = json.animationData.curves[paramKey]; + } + } + json.animationData.curves = newCurves; + } + + // Serialize shapes + json.shapes = this.shapes.map(shape => shape.toJSON(randomizeUuid)); + + // Serialize frames (old system - for backwards compatibility) + if (this.frames) { + json.frames = []; + for (let frame of this.frames) { + if (frame) { + let frameJson = frame.toJSON(randomizeUuid) + for (let key in frameJson.keys) { + if (key in idMap) { + frameJson.keys[idMap[key]] = frameJson.keys[key] + } + } + json.frames.push(frameJson); + } else { + json.frames.push(undefined) + } + } + } + + json.visible = this.visible; + json.audible = this.audible; + return json; + } + // Get all animated property values for all children at a given time + getAnimatedState(time) { + const state = { + shapes: [...this.shapes], // Base shapes from layer + childStates: {} // Animated states for each child GraphicsObject + }; + + // For each child, get its animated properties at this time + for (let child of this.children) { + const childState = {}; + + // Animatable properties for GraphicsObjects + const properties = ['x', 'y', 'rotation', 'scale_x', 'scale_y', 'exists', 'shapeIndex']; + + for (let prop of properties) { + const paramKey = `${child.idx}.${prop}`; + const value = this.animationData.interpolate(paramKey, time); + + if (value !== null) { + childState[prop] = value; + } + } + + if (Object.keys(childState).length > 0) { + state.childStates[child.idx] = childState; + } + } + + return state; + } + + // Helper method to add a keyframe for a child's property + addKeyframeForChild(childId, property, time, value, interpolation = "linear") { + const paramKey = `${childId}.${property}`; + const keyframe = new Keyframe(time, value, interpolation); + this.animationData.addKeyframe(paramKey, keyframe); + return keyframe; + } + + // Helper method to remove a keyframe + removeKeyframeForChild(childId, property, keyframe) { + const paramKey = `${childId}.${property}`; + this.animationData.removeKeyframe(paramKey, keyframe); + } + + // Helper method to get all keyframes for a child's property + getKeyframesForChild(childId, property) { + const paramKey = `${childId}.${property}`; + const curve = this.animationData.getCurve(paramKey); + return curve ? curve.keyframes : []; + } + + /** + * Add a shape to this layer at the given time + * Creates AnimationData keyframes for exists, zOrder, and shapeIndex + */ + addShape(shape, time, sendToBack = false) { + // Add to shapes array + this.shapes.push(shape); + + // Determine zOrder + let zOrder; + if (sendToBack) { + zOrder = 0; + // Increment zOrder for all existing shapes at this time + for (let existingShape of this.shapes) { + if (existingShape !== shape) { + let existingZOrderCurve = this.animationData.curves[`shape.${existingShape.shapeId}.zOrder`]; + if (existingZOrderCurve) { + for (let kf of existingZOrderCurve.keyframes) { + if (kf.time === time) { + kf.value += 1; + } + } + } + } + } + } else { + zOrder = this.shapes.length - 1; + } + + // Add AnimationData keyframes + this.animationData.addKeyframe(`shape.${shape.shapeId}.exists`, new Keyframe(time, 1, "hold")); + this.animationData.addKeyframe(`shape.${shape.shapeId}.zOrder`, new Keyframe(time, zOrder, "hold")); + this.animationData.addKeyframe(`shape.${shape.shapeId}.shapeIndex`, new Keyframe(time, shape.shapeIndex, "linear")); + } + + /** + * Remove a specific shape instance from this layer + * Leaves a "hole" in shapeIndex values so the shape can be restored later + */ + removeShape(shape) { + const shapeIndex = this.shapes.indexOf(shape); + if (shapeIndex < 0) return; + + const shapeId = shape.shapeId; + const removedShapeIndex = shape.shapeIndex; + + // Remove from array + this.shapes.splice(shapeIndex, 1); + + // Get shapeIndex curve + const shapeIndexCurve = this.animationData.getCurve(`shape.${shapeId}.shapeIndex`); + if (shapeIndexCurve) { + // Remove keyframes that point to this shapeIndex + const keyframesToRemove = shapeIndexCurve.keyframes.filter(kf => kf.value === removedShapeIndex); + for (let kf of keyframesToRemove) { + shapeIndexCurve.removeKeyframe(kf); + } + // Note: We intentionally leave a "hole" at this shapeIndex value + // so the shape can be restored with the same index if undeleted + } + } + + getFrame(num) { + if (this.frames[num]) { + if (this.frames[num].frameType == "keyframe") { + return this.frames[num]; + } else if (this.frames[num].frameType == "motion") { + let frameKeys = {}; + let prevFrame = this.frames[num].prev; + let nextFrame = this.frames[num].next; + const t = + (num - this.frames[num].prevIndex) / + (this.frames[num].nextIndex - this.frames[num].prevIndex); + for (let key in prevFrame?.keys) { + frameKeys[key] = {}; + let prevKeyDict = prevFrame.keys[key]; + let nextKeyDict = nextFrame.keys[key]; + for (let prop in prevKeyDict) { + frameKeys[key][prop] = + (1 - t) * prevKeyDict[prop] + t * nextKeyDict[prop]; + } + } + let frame = new Frame("motion", "temp"); + frame.keys = frameKeys; + return frame; + } else if (this.frames[num].frameType == "shape") { + let prevFrame = this.frames[num].prev; + let nextFrame = this.frames[num].next; + const t = + (num - this.frames[num].prevIndex) / + (this.frames[num].nextIndex - this.frames[num].prevIndex); + let shapes = []; + for (let shape1 of prevFrame?.shapes) { + if (shape1.curves.length == 0) continue; + let shape2 = undefined; + for (let i of nextFrame.shapes) { + if (shape1.shapeId == i.shapeId) { + shape2 = i; + } + } + if (shape2 != undefined) { + let path1 = [ + { + type: "M", + x: shape1.curves[0].points[0].x, + y: shape1.curves[0].points[0].y, + }, + ]; + for (let curve of shape1.curves) { + path1.push({ + type: "C", + x1: curve.points[1].x, + y1: curve.points[1].y, + x2: curve.points[2].x, + y2: curve.points[2].y, + x: curve.points[3].x, + y: curve.points[3].y, + }); + } + let path2 = []; + if (shape2.curves.length > 0) { + path2.push({ + type: "M", + x: shape2.curves[0].points[0].x, + y: shape2.curves[0].points[0].y, + }); + for (let curve of shape2.curves) { + path2.push({ + type: "C", + x1: curve.points[1].x, + y1: curve.points[1].y, + x2: curve.points[2].x, + y2: curve.points[2].y, + x: curve.points[3].x, + y: curve.points[3].y, + }); + } + } + const interpolator = d3.interpolatePathCommands(path1, path2); + let current = interpolator(t); + let curves = []; + let start = current.shift(); + let { x, y } = start; + for (let curve of current) { + curves.push( + new Bezier( + x, + y, + curve.x1, + curve.y1, + curve.x2, + curve.y2, + curve.x, + curve.y, + ), + ); + x = curve.x; + y = curve.y; + } + let lineWidth = lerp(shape1.lineWidth, shape2.lineWidth, t); + let strokeStyle = lerpColor( + shape1.strokeStyle, + shape2.strokeStyle, + t, + ); + let fillStyle; + if (!shape1.fillImage) { + fillStyle = lerpColor(shape1.fillStyle, shape2.fillStyle, t); + } + shapes.push( + new TempShape( + start.x, + start.y, + curves, + shape1.lineWidth, + shape1.stroked, + shape1.filled, + strokeStyle, + fillStyle, + ), + ); + } + } + let frame = new Frame("shape", "temp"); + frame.shapes = shapes; + return frame; + } else { + for (let i = Math.min(num, this.frames.length - 1); i >= 0; i--) { + if (this.frames[i]?.frameType == "keyframe") { + let tempFrame = this.frames[i].copy("tempFrame"); + tempFrame.frameType = "normal"; + return tempFrame; + } + } + } + } else { + for (let i = Math.min(num, this.frames.length - 1); i >= 0; i--) { + // if (this.frames[i].frameType == "keyframe") { + // let tempFrame = this.frames[i].copy("tempFrame") + // tempFrame.frameType = "normal" + return tempFrame; + // } + } + } + } + getLatestFrame(num) { + for (let i = num; i >= 0; i--) { + if (this.frames[i]?.exists) { + return this.getFrame(i); + } + } + } + copy(idx) { + let newLayer = new VectorLayer(idx.slice(0, 8) + this.idx.slice(8)); + let idxMapping = {}; + for (let child of this.children) { + let newChild = child.copy(idx); + idxMapping[child.idx] = newChild.idx; + newLayer.children.push(newChild); + } + newLayer.frames = []; + for (let frame of this.frames) { + let newFrame = frame.copy(idx); + newFrame.keys = {}; + for (let key in frame.keys) { + newFrame.keys[idxMapping[key]] = structuredClone(frame.keys[key]); + } + newLayer.frames.push(newFrame); + } + return newLayer; + } + addFrame(num, frame, addedFrames) { + // let updateDest = undefined; + // if (!this.frames[num]) { + // for (const [index, idx] of Object.entries(addedFrames)) { + // if (!this.frames[index]) { + // this.frames[index] = new Frame("normal", idx); + // } + // } + // } else { + // if (this.frames[num].frameType == "motion") { + // updateDest = "motion"; + // } else if (this.frames[num].frameType == "shape") { + // updateDest = "shape"; + // } + // } + this.frames[num] = frame; + // if (updateDest) { + // this.updateFrameNextAndPrev(num - 1, updateDest); + // this.updateFrameNextAndPrev(num + 1, updateDest); + // } + } + addOrChangeFrame(num, frameType, uuid, addedFrames) { + let latestFrame = this.getLatestFrame(num); + let newKeyframe = new Frame(frameType, uuid); + for (let key in latestFrame.keys) { + newKeyframe.keys[key] = structuredClone(latestFrame.keys[key]); + } + for (let shape of latestFrame.shapes) { + newKeyframe.shapes.push(shape.copy(uuid)); + } + this.addFrame(num, newKeyframe, addedFrames); + } + deleteFrame(uuid, destinationType, replacementUuid) { + let frame = pointerList[uuid]; + let i = this.frames.indexOf(frame); + if (i != -1) { + if (destinationType == undefined) { + // Determine destination type from surrounding frames + const prevFrame = this.frames[i - 1]; + const nextFrame = this.frames[i + 1]; + const prevType = prevFrame ? prevFrame.frameType : null; + const nextType = nextFrame ? nextFrame.frameType : null; + if (prevType === "motion" || nextType === "motion") { + destinationType = "motion"; + } else if (prevType === "shape" || nextType === "shape") { + destinationType = "shape"; + } else if (prevType !== null && nextType !== null) { + destinationType = "normal"; + } else { + destinationType = "none"; + } + } + if (destinationType == "none") { + delete this.frames[i]; + } else { + this.frames[i] = this.frames[i].copy(replacementUuid); + this.frames[i].frameType = destinationType; + this.updateFrameNextAndPrev(i, destinationType); + } + } + } + updateFrameNextAndPrev(num, frameType, lastBefore, firstAfter) { + if (!this.frames[num] || this.frames[num].frameType == "keyframe") return; + if (lastBefore == undefined || firstAfter == undefined) { + let { lastKeyframeBefore, firstKeyframeAfter } = getKeyframesSurrounding( + this.frames, + num, + ); + lastBefore = lastKeyframeBefore; + firstAfter = firstKeyframeAfter; + } + for (let i = lastBefore + 1; i < firstAfter; i++) { + this.frames[i].frameType = frameType; + this.frames[i].prev = this.frames[lastBefore]; + this.frames[i].next = this.frames[firstAfter]; + this.frames[i].prevIndex = lastBefore; + this.frames[i].nextIndex = firstAfter; + } + } + toggleVisibility() { + this.visible = !this.visible; + updateUI(); + updateMenu(); + updateLayers(); + } + getFrameValue(n) { + const valueAtN = this.frames[n]; + if (valueAtN !== undefined) { + return { valueAtN, prev: null, next: null, prevIndex: null, nextIndex: null }; + } + let prev = n - 1; + let next = n + 1; + + while (prev >= 0 && this.frames[prev] === undefined) { + prev--; + } + while (next < this.frames.length && this.frames[next] === undefined) { + next++; + } + + return { + valueAtN: undefined, + prev: prev >= 0 ? this.frames[prev] : null, + next: next < this.frames.length ? this.frames[next] : null, + prevIndex: prev >= 0 ? prev : null, + nextIndex: next < this.frames.length ? next : null + }; + } + + // Get all shapes that exist at the given time + getVisibleShapes(time) { + const visibleShapes = []; + + // Calculate tolerance based on framerate (half a frame) + const halfFrameDuration = 0.5 / config.framerate; + + // Group shapes by shapeId + const shapesByShapeId = new Map(); + for (let shape of this.shapes) { + if (shape instanceof TempShape) continue; + if (!shapesByShapeId.has(shape.shapeId)) { + shapesByShapeId.set(shape.shapeId, []); + } + shapesByShapeId.get(shape.shapeId).push(shape); + } + + // For each logical shape (shapeId), determine which version to return for EDITING + for (let [shapeId, shapes] of shapesByShapeId) { + // Check if this logical shape exists at current time + let existsValue = this.animationData.interpolate(`shape.${shapeId}.exists`, time); + if (existsValue === null || existsValue <= 0) continue; + + // Get shapeIndex curve + const shapeIndexCurve = this.animationData.getCurve(`shape.${shapeId}.shapeIndex`); + + if (!shapeIndexCurve || !shapeIndexCurve.keyframes || shapeIndexCurve.keyframes.length === 0) { + // No shapeIndex curve, return shape with index 0 + const shape = shapes.find(s => s.shapeIndex === 0); + if (shape) { + visibleShapes.push(shape); + } + continue; + } + + // Find bracketing keyframes + const { prev: prevKf, next: nextKf } = shapeIndexCurve.getBracketingKeyframes(time); + + // Get interpolated shapeIndex value + let shapeIndexValue = shapeIndexCurve.interpolate(time); + if (shapeIndexValue === null) shapeIndexValue = 0; + + // Check if we're at a keyframe (within half a frame) + const atPrevKeyframe = prevKf && Math.abs(shapeIndexValue - prevKf.value) < halfFrameDuration; + const atNextKeyframe = nextKf && Math.abs(shapeIndexValue - nextKf.value) < halfFrameDuration; + + if (atPrevKeyframe) { + // At previous keyframe - return that version for editing + const shape = shapes.find(s => s.shapeIndex === prevKf.value); + if (shape) visibleShapes.push(shape); + } else if (atNextKeyframe) { + // At next keyframe - return that version for editing + const shape = shapes.find(s => s.shapeIndex === nextKf.value); + if (shape) visibleShapes.push(shape); + } else if (prevKf && prevKf.interpolation === 'hold') { + // Between keyframes but using "hold" interpolation - no morphing + // Return the previous keyframe's shape since that's what's shown + const shape = shapes.find(s => s.shapeIndex === prevKf.value); + if (shape) visibleShapes.push(shape); + } + // Otherwise: between keyframes with morphing, return nothing (can't edit a morph) + } + + return visibleShapes; + } + + draw(ctx) { + // super.draw(ctx) + if (!this.visible) return; + + let cxt = {...context} + cxt.ctx = ctx + + // Draw shapes using AnimationData curves for exists, zOrder, and shape tweening + let currentTime = context.activeObject?.currentTime || 0; + + // Group shapes by shapeId for tweening support + const shapesByShapeId = new Map(); + for (let shape of this.shapes) { + if (shape instanceof TempShape) continue; + if (!shapesByShapeId.has(shape.shapeId)) { + shapesByShapeId.set(shape.shapeId, []); + } + shapesByShapeId.get(shape.shapeId).push(shape); + } + + // Process each logical shape (shapeId) + let visibleShapes = []; + for (let [shapeId, shapes] of shapesByShapeId) { + // Check if this logical shape exists at current time + let existsValue = this.animationData.interpolate(`shape.${shapeId}.exists`, currentTime); + if (existsValue === null || existsValue <= 0) continue; + + // Get z-order + let zOrder = this.animationData.interpolate(`shape.${shapeId}.zOrder`, currentTime); + + // Get shapeIndex curve and surrounding keyframes + const shapeIndexCurve = this.animationData.getCurve(`shape.${shapeId}.shapeIndex`); + if (!shapeIndexCurve || !shapeIndexCurve.keyframes || shapeIndexCurve.keyframes.length === 0) { + // No shapeIndex curve, just show shape with index 0 + const shape = shapes.find(s => s.shapeIndex === 0); + if (shape) { + visibleShapes.push({ shape, zOrder: zOrder || 0, selected: context.shapeselection.includes(shape) }); + } + continue; + } + + // Find surrounding keyframes + const { prev: prevKf, next: nextKf } = getKeyframesSurrounding(shapeIndexCurve.keyframes, currentTime); + + // Get interpolated value + let shapeIndexValue = shapeIndexCurve.interpolate(currentTime); + if (shapeIndexValue === null) shapeIndexValue = 0; + + // Sort shape versions by shapeIndex + shapes.sort((a, b) => a.shapeIndex - b.shapeIndex); + + // Determine whether to morph based on whether interpolated value equals a keyframe value + // Check if we're at either the previous or next keyframe value (no morphing needed) + const atPrevKeyframe = prevKf && Math.abs(shapeIndexValue - prevKf.value) < 0.001; + const atNextKeyframe = nextKf && Math.abs(shapeIndexValue - nextKf.value) < 0.001; + + if (atPrevKeyframe || atNextKeyframe) { + // No morphing - display the shape at the keyframe value + const targetValue = atNextKeyframe ? nextKf.value : prevKf.value; + const shape = shapes.find(s => s.shapeIndex === targetValue); + if (shape) { + visibleShapes.push({ shape, zOrder: zOrder || 0, selected: context.shapeselection.includes(shape) }); + } + } else if (prevKf && nextKf && prevKf.value !== nextKf.value) { + // Morph between shapes specified by surrounding keyframes + const shape1 = shapes.find(s => s.shapeIndex === prevKf.value); + const shape2 = shapes.find(s => s.shapeIndex === nextKf.value); + + if (shape1 && shape2) { + // Calculate t based on time position between keyframes + const t = (currentTime - prevKf.time) / (nextKf.time - prevKf.time); + const morphedShape = shape1.lerpShape(shape2, t); + visibleShapes.push({ shape: morphedShape, zOrder: zOrder || 0, selected: context.shapeselection.includes(shape1) || context.shapeselection.includes(shape2) }); + } else if (shape1) { + visibleShapes.push({ shape: shape1, zOrder: zOrder || 0, selected: context.shapeselection.includes(shape1) }); + } else if (shape2) { + visibleShapes.push({ shape: shape2, zOrder: zOrder || 0, selected: context.shapeselection.includes(shape2) }); + } + } else if (nextKf) { + // Only next keyframe exists, show that shape + const shape = shapes.find(s => s.shapeIndex === nextKf.value); + if (shape) { + visibleShapes.push({ shape, zOrder: zOrder || 0, selected: context.shapeselection.includes(shape) }); + } + } + } + + // Sort by zOrder (lowest first = back, highest last = front) + visibleShapes.sort((a, b) => a.zOrder - b.zOrder); + + // Draw sorted shapes + for (let { shape, selected } of visibleShapes) { + cxt.selected = selected; + shape.draw(cxt); + } + + // Draw children (GraphicsObjects) using AnimationData curves + for (let child of this.children) { + // Check if child exists at current time using AnimationData + // null means no exists curve (defaults to visible) + const existsValue = this.animationData.interpolate(`object.${child.idx}.exists`, currentTime); + if (existsValue !== null && existsValue <= 0) continue; + + // Get child properties from AnimationData curves + const childX = this.animationData.interpolate(`object.${child.idx}.x`, currentTime); + const childY = this.animationData.interpolate(`object.${child.idx}.y`, currentTime); + const childRotation = this.animationData.interpolate(`object.${child.idx}.rotation`, currentTime); + const childScaleX = this.animationData.interpolate(`object.${child.idx}.scale_x`, currentTime); + const childScaleY = this.animationData.interpolate(`object.${child.idx}.scale_y`, currentTime); + + // Apply properties if they exist in AnimationData + if (childX !== null) child.x = childX; + if (childY !== null) child.y = childY; + if (childRotation !== null) child.rotation = childRotation; + if (childScaleX !== null) child.scale_x = childScaleX; + if (childScaleY !== null) child.scale_y = childScaleY; + + // Draw the child if not in objectStack + if (!context.objectStack.includes(child)) { + const transform = ctx.getTransform(); + ctx.translate(child.x, child.y); + ctx.scale(child.scale_x, child.scale_y); + ctx.rotate(child.rotation); + child.draw(ctx); + + // Draw selection outline if selected + if (context.selection.includes(child)) { + ctx.lineWidth = 1; + ctx.strokeStyle = "#00ffff"; + ctx.beginPath(); + let bbox = child.bbox(); + ctx.rect(bbox.x.min - child.x, bbox.y.min - child.y, bbox.x.max - bbox.x.min, bbox.y.max - bbox.y.min); + ctx.stroke(); + } + ctx.setTransform(transform); + } + } + // Draw activeShape regardless of whether frame exists + if (this.activeShape) { + console.log("Layer.draw: Drawing activeShape", this.activeShape); + this.activeShape.draw(cxt) + console.log("Layer.draw: Drew activeShape"); + } + } + bbox() { + let bbox = super.bbox(); + let currentTime = context.activeObject?.currentTime || 0; + + // Get visible shapes at current time using AnimationData + const visibleShapes = this.getVisibleShapes(currentTime); + + if (visibleShapes.length > 0 && bbox === undefined) { + bbox = structuredClone(visibleShapes[0].boundingBox); + } + for (let shape of visibleShapes) { + growBoundingBox(bbox, shape.boundingBox); + } + return bbox; + } + mousedown(x, y) { + console.log("Layer.mousedown called - this:", this.name, "activeLayer:", context.activeLayer?.name, "context.mode:", context.mode); + const mouse = {x: x, y: y} + if (this==context.activeLayer) { + console.log("This IS the active layer"); + switch(context.mode) { + case "rectangle": + case "ellipse": + case "draw": + console.log("Creating shape for context.mode:", context.mode); + this.clicked = true + this.activeShape = new Shape(x, y, context, this, uuidv4()) + this.lastMouse = mouse; + console.log("Shape created:", this.activeShape); + break; + case "select": + case "transform": + break; + case "paint_bucket": + debugCurves = []; + debugPoints = []; + let epsilon = context.fillGaps; + let regionPoints; + + // First, see if there's an existing shape to change the color of + let currentTime = context.activeObject?.currentTime || 0; + let visibleShapes = this.getVisibleShapes(currentTime); + let pointShape = getShapeAtPoint(mouse, visibleShapes); + + if (pointShape) { + actions.colorShape.create(pointShape, context.fillStyle); + break; + } + + // We didn't find an existing region to paintbucket, see if we can make one + try { + regionPoints = floodFillRegion( + mouse, + epsilon, + config.fileWidth, + config.fileHeight, + context, + debugPoints, + debugPaintbucket, + visibleShapes, + ); + } catch (e) { + updateUI(); + throw e; + } + if (regionPoints.length > 0 && regionPoints.length < 10) { + // probably a very small area, rerun with minimum epsilon + regionPoints = floodFillRegion( + mouse, + 1, + config.fileWidth, + config.fileHeight, + context, + debugPoints, + false, + visibleShapes, + ); + } + let points = []; + for (let point of regionPoints) { + points.push([point.x, point.y]); + } + let cxt = { + ...context, + fillShape: true, + strokeShape: false, + sendToBack: true, + }; + let shape = new Shape(regionPoints[0].x, regionPoints[0].y, cxt, this); + shape.fromPoints(points, 1); + actions.addShape.create(context.activeObject, shape, cxt); + break; + } + } + } + mousemove(x, y) { + const mouse = {x: x, y: y} + if (this==context.activeLayer) { + switch (context.mode) { + case "draw": + if (this.activeShape) { + if (vectorDist(mouse, context.lastMouse) > minSegmentSize) { + this.activeShape.addLine(x, y); + this.lastMouse = mouse; + } + } + break; + case "rectangle": + if (this.activeShape) { + this.activeShape.clear(); + this.activeShape.addLine(x, this.activeShape.starty); + this.activeShape.addLine(x, y); + this.activeShape.addLine(this.activeShape.startx, y); + this.activeShape.addLine( + this.activeShape.startx, + this.activeShape.starty, + ); + this.activeShape.update(); + } + break; + case "ellipse": + if (this.activeShape) { + let midX = (mouse.x + this.activeShape.startx) / 2; + let midY = (mouse.y + this.activeShape.starty) / 2; + let xDiff = (mouse.x - this.activeShape.startx) / 2; + let yDiff = (mouse.y - this.activeShape.starty) / 2; + let ellipseConst = 0.552284749831; // (4/3)*tan(pi/(2n)) where n=4 + this.activeShape.clear(); + this.activeShape.addCurve( + new Bezier( + midX, + this.activeShape.starty, + midX + ellipseConst * xDiff, + this.activeShape.starty, + mouse.x, + midY - ellipseConst * yDiff, + mouse.x, + midY, + ), + ); + this.activeShape.addCurve( + new Bezier( + mouse.x, + midY, + mouse.x, + midY + ellipseConst * yDiff, + midX + ellipseConst * xDiff, + mouse.y, + midX, + mouse.y, + ), + ); + this.activeShape.addCurve( + new Bezier( + midX, + mouse.y, + midX - ellipseConst * xDiff, + mouse.y, + this.activeShape.startx, + midY + ellipseConst * yDiff, + this.activeShape.startx, + midY, + ), + ); + this.activeShape.addCurve( + new Bezier( + this.activeShape.startx, + midY, + this.activeShape.startx, + midY - ellipseConst * yDiff, + midX - ellipseConst * xDiff, + this.activeShape.starty, + midX, + this.activeShape.starty, + ), + ); + } + break; + } + } + } + mouseup(x, y) { + console.log("Layer.mouseup called - context.mode:", context.mode, "activeShape:", this.activeShape); + this.clicked = false + if (this==context.activeLayer) { + switch (context.mode) { + case "draw": + if (this.activeShape) { + this.activeShape.addLine(x, y); + this.activeShape.simplify(context.simplifyMode); + } + case "rectangle": + case "ellipse": + if (this.activeShape) { + console.log("Adding shape via actions.addShape.create"); + actions.addShape.create(context.activeObject, this.activeShape); + console.log("Shape added, clearing activeShape"); + this.activeShape = undefined; + } + break; + } + } + } +} + +class AudioTrack { + constructor(uuid, name, type = 'audio') { + // ID and name + if (!uuid) { + this.idx = uuidv4(); + } else { + this.idx = uuid; + } + this.name = name || (type === 'midi' ? "MIDI" : "Audio"); + this.type = type; // 'audio' or 'midi' + this.audible = true; + this.visible = true; // For consistency with Layer (audio tracks are always "visible" in timeline) + + // AnimationData for automation curves (like Layer) + this.animationData = new AnimationData(this); + + // Read-only empty arrays for layer compatibility (audio tracks don't have shapes/children) + Object.defineProperty(this, 'shapes', { + value: Object.freeze([]), + writable: false, + enumerable: true, + configurable: false + }); + Object.defineProperty(this, 'children', { + value: Object.freeze([]), + writable: false, + enumerable: true, + configurable: false + }); + + // Reference to DAW backend track + this.audioTrackId = null; + + // Audio clips (for audio tracks) or MIDI clips (for MIDI tracks) + this.clips = []; // { clipId, poolIndex, name, startTime, duration, offset } or MIDI clip data + + // Timeline display settings (for track hierarchy) + this.collapsed = false + this.curvesMode = 'segment' // 'segment' | 'keyframe' | 'curve' + this.curvesHeight = 150 // Height in pixels when curves are in curve view + + pointerList[this.idx] = this; + } + + // Sync automation to backend using generic parameter setter + async syncAutomation(time) { + if (this.audioTrackId === null) return; + + // Get all automation parameters and sync them + const params = ['volume', 'mute', 'solo', 'pan']; + for (const param of params) { + const value = this.animationData.interpolate(`track.${param}`, time); + if (value !== null) { + await invoke('audio_set_track_parameter', { + trackId: this.audioTrackId, + parameter: param, + value + }); + } + } + } + + // Get all automation parameter names + getAutomationParameters() { + return [ + 'track.volume', + 'track.pan', + 'track.mute', + 'track.solo', + ...this.clips.flatMap(clip => [ + `clip.${clip.clipId}.gain`, + `clip.${clip.clipId}.pan` + ]) + ]; + } + + // Initialize the audio track in the DAW backend + async initializeTrack() { + if (this.audioTrackId !== null) { + console.warn('Track already initialized'); + return; + } + + try { + const params = { + name: this.name, + trackType: this.type + }; + + // Add instrument parameter for MIDI tracks + if (this.type === 'midi' && this.instrument) { + params.instrument = this.instrument; + } + + const trackId = await invoke('audio_create_track', params); + this.audioTrackId = trackId; + console.log(`${this.type === 'midi' ? 'MIDI' : 'Audio'} track created:`, this.name, 'with ID:', trackId); + } catch (error) { + console.error(`Failed to create ${this.type} track:`, error); + throw error; + } + } + + // Load an audio file and add it to the pool + // Returns metadata including: pool_index, duration, sample_rate, channels, waveform + async loadAudioFile(path) { + try { + const metadata = await invoke('audio_load_file', { + path: path + }); + console.log('Audio file loaded:', path, 'metadata:', metadata); + return metadata; + } catch (error) { + console.error('Failed to load audio file:', error); + throw error; + } + } + + // Add a clip to this track + async addClip(poolIndex, startTime, duration, offset = 0.0, name = '', waveform = null) { + if (this.audioTrackId === null) { + throw new Error('Track not initialized. Call initializeTrack() first.'); + } + + try { + await invoke('audio_add_clip', { + trackId: this.audioTrackId, + poolIndex, + startTime, + duration, + offset + }); + + // Store clip metadata locally + // Note: clipId will be assigned by backend, we'll get it via ClipAdded event + this.clips.push({ + clipId: this.clips.length, // Temporary ID + poolIndex, + name: name || `Clip ${this.clips.length + 1}`, + startTime, + duration, + offset, + waveform // Store waveform data for rendering + }); + + console.log('Clip added to track', this.audioTrackId); + } catch (error) { + console.error('Failed to add clip:', error); + throw error; + } + } + + static fromJSON(json) { + const audioTrack = new AudioTrack(json.idx, json.name, json.trackType || 'audio'); + + // Load AnimationData if present + if (json.animationData) { + audioTrack.animationData = AnimationData.fromJSON(json.animationData, audioTrack); + } + + // Load clips if present + if (json.clips) { + audioTrack.clips = json.clips.map(clip => { + const clipData = { + clipId: clip.clipId, + name: clip.name, + startTime: clip.startTime, + duration: clip.duration, + offset: clip.offset || 0, // Default to 0 if not present + }; + + // Restore audio-specific fields + if (clip.poolIndex !== undefined) { + clipData.poolIndex = clip.poolIndex; + } + + // Restore MIDI-specific fields + if (clip.notes) { + clipData.notes = clip.notes; + } + + return clipData; + }); + } + + audioTrack.audible = json.audible; + return audioTrack; + } + + toJSON(randomizeUuid = false) { + const json = { + type: "AudioTrack", + idx: randomizeUuid ? uuidv4() : this.idx, + name: randomizeUuid ? this.name + " copy" : this.name, + trackType: this.type, // 'audio' or 'midi' + audible: this.audible, + + // AnimationData (includes automation curves) + animationData: this.animationData.toJSON(), + + // Clips + clips: this.clips.map(clip => { + const clipData = { + clipId: clip.clipId, + name: clip.name, + startTime: clip.startTime, + duration: clip.duration, + }; + + // Add audio-specific fields + if (clip.poolIndex !== undefined) { + clipData.poolIndex = clip.poolIndex; + clipData.offset = clip.offset; + } + + // Add MIDI-specific fields + if (clip.notes) { + clipData.notes = clip.notes; + } + + return clipData; + }) + }; + + return json; + } + + copy(idx) { + // Serialize and deserialize with randomized UUID + const json = this.toJSON(true); + json.idx = idx.slice(0, 8) + this.idx.slice(8); + return AudioTrack.fromJSON(json); + } +} + +class VideoLayer extends Widget { + constructor(uuid, name) { + super(0, 0); + if (!uuid) { + this.idx = uuidv4(); + } else { + this.idx = uuid; + } + this.name = name || "Video"; + this.type = 'video'; + this.visible = true; + this.audible = true; + this.animationData = new AnimationData(this); + + // Empty arrays for layer compatibility + Object.defineProperty(this, 'shapes', { + value: Object.freeze([]), + writable: false, + enumerable: true, + configurable: false + }); + Object.defineProperty(this, 'children', { + value: Object.freeze([]), + writable: false, + enumerable: true, + configurable: false + }); + + // Video clips on this layer + // { clipId, poolIndex, name, startTime, duration, offset, width, height } + this.clips = []; + + // Associated audio track (if video has audio) + this.linkedAudioTrack = null; // Reference to AudioTrack + + // Performance settings + this.useJpegCompression = false; // JPEG compression adds more overhead than it saves (default: false) + this.prefetchCount = 3; // Number of frames to prefetch ahead of playhead + + // WebSocket streaming (experimental - zero-copy RGBA frames from Rust) + this.useWebSocketStreaming = true; // Use WebSocket streaming (enabled for testing) + this.wsConnected = false; // Track WebSocket connection status + + // Timeline display + this.collapsed = false; + this.curvesMode = 'segment'; + this.curvesHeight = 150; + + pointerList[this.idx] = this; + } + + async addClip(poolIndex, startTime, duration, offset = 0.0, name = '', sourceDuration = null, metadata = null) { + const poolInfo = await invoke('video_get_pool_info', { poolIndex }); + // poolInfo is [width, height, fps] tuple from Rust + const [width, height, fps] = poolInfo; + + const clip = { + clipId: this.clips.length, + poolIndex, + name: name || `Video ${this.clips.length + 1}`, + startTime, + duration, + offset, + width, + height, + sourceDuration: sourceDuration || duration, // Store original file duration + httpUrl: metadata?.http_url || null, + isBrowserCompatible: metadata?.is_browser_compatible || false, + transcoding: metadata?.transcoding || false, + videoElement: null, // Will hold HTML5 video element if using browser playback + useBrowserVideo: false, // Switch to true when video element is ready + isPlaying: false, // Track if video element is actively playing + }; + + this.clips.push(clip); + + console.log(`Video clip added: ${name}, ${width}x${height}, duration: ${duration}s, browser-compatible: ${clip.isBrowserCompatible}, http_url: ${clip.httpUrl}`); + + // If using WebSocket streaming, connect and subscribe + if (this.useWebSocketStreaming) { + // Connect to WebSocket if not already connected + if (!this.wsConnected) { + try { + await frameReceiver.connect(); + this.wsConnected = true; + console.log(`[Video] WebSocket connected for streaming`); + } catch (error) { + console.error('[Video] Failed to connect WebSocket, falling back to browser video:', error); + this.useWebSocketStreaming = false; + } + } + + // Subscribe to frames for this pool + if (this.wsConnected) { + frameReceiver.subscribe(poolIndex, (imageData, timestamp) => { + // Store received frame + clip.wsCurrentFrame = imageData; + clip.wsLastTimestamp = timestamp; + // console.log(`[Video WS] Received frame ${width}x${height} @ ${timestamp.toFixed(3)}s`); + + // Trigger UI redraw + if (updateUI) { + updateUI(); + } + }); + console.log(`[Video] Subscribed to WebSocket frames for pool ${poolIndex}`); + } + } + // Otherwise use browser video if available + else if (clip.httpUrl) { + await this._createVideoElement(clip); + clip.useBrowserVideo = true; + } + // If transcoding is in progress, start polling + else if (clip.transcoding) { + console.log(`[Video] Starting transcode polling for ${clip.name}`); + this._pollTranscodeStatus(clip); + } + } + + async _createVideoElement(clip) { + // Create hidden video element for hardware-accelerated decoding + const video = document.createElement('video'); + // Hide video element using opacity (browsers may skip decoding if off-screen) + video.style.position = 'fixed'; + video.style.bottom = '0'; + video.style.right = '0'; + video.style.width = '1px'; + video.style.height = '1px'; + video.style.opacity = '0.01'; // Nearly invisible but not 0 (some browsers optimize opacity:0) + video.style.pointerEvents = 'none'; + video.style.zIndex = '-1'; + video.preload = 'auto'; + video.muted = true; // Mute video element (audio plays separately) + video.playsInline = true; + video.autoplay = false; + video.crossOrigin = 'anonymous'; // Required for canvas drawing - prevent CORS taint + + // Add event listeners for debugging + video.addEventListener('loadedmetadata', () => { + console.log(`[Video] Loaded metadata for ${clip.name}: ${video.videoWidth}x${video.videoHeight}, duration: ${video.duration}s`); + }); + + video.addEventListener('loadeddata', () => { + console.log(`[Video] Loaded data for ${clip.name}, readyState: ${video.readyState}`); + }); + + video.addEventListener('canplay', () => { + console.log(`[Video] Can play ${clip.name}, duration: ${video.duration}s`); + // Mark video as ready for seeking once we can play AND have valid duration + if (video.duration > 0 && !isNaN(video.duration) && video.duration !== Infinity) { + clip.videoReady = true; + console.log(`[Video] Video is ready for seeking`); + } + }); + + // When seek completes, trigger UI redraw to show the new frame + video.addEventListener('seeked', () => { + if (updateUI) { + updateUI(); + } + }); + + video.addEventListener('error', (e) => { + const error = video.error; + const errorMessages = { + 1: 'MEDIA_ERR_ABORTED - Fetching aborted', + 2: 'MEDIA_ERR_NETWORK - Network error', + 3: 'MEDIA_ERR_DECODE - Decoding error', + 4: 'MEDIA_ERR_SRC_NOT_SUPPORTED - Format not supported or file not accessible' + }; + const errorMsg = errorMessages[error?.code] || 'Unknown error'; + console.error(`[Video] Error loading ${clip.name}: ${errorMsg}`, error?.message); + }); + + // Use HTTP URL from local server (supports range requests for seeking) + video.src = clip.httpUrl; + + // Try to load the video + video.load(); + + document.body.appendChild(video); + clip.videoElement = video; + + console.log(`[Video] Created video element for clip ${clip.name}: ${clip.httpUrl}`); + } + + async _pollTranscodeStatus(clip) { + // Poll transcode status every 2 seconds + const pollInterval = setInterval(async () => { + try { + const status = await invoke('video_get_transcode_status', { poolIndex: clip.poolIndex }); + + if (status && status[2]) { // [path, progress, completed, httpUrl] + // Transcode complete! + clearInterval(pollInterval); + const [outputPath, progress, completed, httpUrl] = status; + + clip.transcodedPath = outputPath; + clip.httpUrl = httpUrl; + clip.transcoding = false; + clip.useBrowserVideo = true; + + console.log(`[Video] Transcode complete for ${clip.name}, switching to browser playback: ${httpUrl}`); + + // Create video element for browser playback + await this._createVideoElement(clip); + } + } catch (error) { + console.error('Failed to poll transcode status:', error); + clearInterval(pollInterval); + } + }, 2000); + } + + // Pre-fetch frames for current time (call before draw) + async updateFrame(currentTime) { + // Prevent concurrent calls - if already updating, skip + if (this.updateInProgress) { + return; + } + this.updateInProgress = true; + + try { + for (let clip of this.clips) { + // Check if clip is active at current time + if (currentTime < clip.startTime || + currentTime >= clip.startTime + clip.duration) { + clip.currentFrame = null; + clip.wsCurrentFrame = null; + + // Pause video element if we left its time range + if (clip.videoElement && clip.isPlaying) { + clip.videoElement.pause(); + clip.isPlaying = false; + } + + continue; + } + + // If using WebSocket streaming + if (this.useWebSocketStreaming && this.wsConnected) { + const videoTime = clip.offset + (currentTime - clip.startTime); + + // Request frame via WebSocket streaming (non-blocking) + // The frame will arrive via the subscription callback and trigger a redraw + try { + await invoke('video_stream_frame', { + poolIndex: clip.poolIndex, + timestamp: videoTime + }); + } catch (error) { + console.error('[Video WS] Failed to stream frame:', error); + } + + continue; // Skip other frame fetching methods + } + + // If using browser video element + if (clip.useBrowserVideo && clip.videoElement) { + const videoTime = clip.offset + (currentTime - clip.startTime); + + // Don't do anything until video is fully ready + if (!clip.videoReady) { + if (!clip._notReadyWarned) { + console.warn(`[Video updateFrame] Video not ready yet (duration=${clip.videoElement.duration})`); + clip._notReadyWarned = true; + } + continue; + } + + // During playback: let video play naturally + if (context.playing) { + // Check if we just entered this clip (need to start playing) + if (!clip.isPlaying) { + // Start playing one frame ahead to compensate for canvas drawing lag + const frameDuration = 1 / (clip.fps || 30); // Use clip's actual framerate + const maxVideoTime = clip.sourceDuration - frameDuration; // Don't seek past end + const startTime = Math.min(videoTime + frameDuration, maxVideoTime); + console.log(`[Video updateFrame] Starting playback at ${startTime.toFixed(3)}s (compensated by ${frameDuration.toFixed(3)}s for ${clip.fps}fps)`); + clip.videoElement.currentTime = startTime; + clip.videoElement.play().catch(e => console.error('Failed to play video:', e)); + clip.isPlaying = true; + } + // Otherwise, let it play naturally - don't seek! + } + // When scrubbing (not playing): seek to exact position and pause + else { + if (clip.isPlaying) { + clip.videoElement.pause(); + clip.isPlaying = false; + } + + // Only seek if the time is actually different + if (!clip.videoElement.seeking) { + const timeDiff = Math.abs(clip.videoElement.currentTime - videoTime); + if (timeDiff > 0.016) { // ~1 frame tolerance at 60fps + clip.videoElement.currentTime = videoTime; + } + } + } + + continue; // Skip frame fetching + } + + // Use frame batching for frame-based playback + + // Initialize frame cache if needed + if (!clip.frameCache) { + clip.frameCache = new Map(); + } + + // Check if current frame is already cached + if (clip.frameCache.has(currentVideoTimestamp)) { + clip.currentFrame = clip.frameCache.get(currentVideoTimestamp); + clip.lastFetchedTimestamp = currentVideoTimestamp; + continue; + } + + // Skip if already fetching + if (clip.fetchInProgress) { + continue; + } + + clip.fetchInProgress = true; + + try { + // Calculate timestamps to prefetch (current + next N frames) + const frameDuration = 1 / 30; // Assume 30fps for now, could get from clip metadata + const timestamps = []; + for (let i = 0; i < this.prefetchCount; i++) { + const ts = currentVideoTimestamp + (i * frameDuration); + // Don't exceed clip duration + if (ts <= clip.offset + clip.sourceDuration) { + timestamps.push(ts); + } + } + + if (timestamps.length === 0) { + continue; + } + + const t_start = performance.now(); + + // Request batch of frames using IPC Channel + const batchDataPromise = new Promise((resolve, reject) => { + const channel = new Channel(); + + channel.onmessage = (data) => { + resolve(data); + }; + + invoke('video_get_frames_batch', { + poolIndex: clip.poolIndex, + timestamps: timestamps, + useJpeg: this.useJpegCompression, + channel: channel + }).catch(reject); + }); + + let batchData = await batchDataPromise; + const t_after_ipc = performance.now(); + + // Ensure data is Uint8Array + if (!(batchData instanceof Uint8Array)) { + batchData = new Uint8Array(batchData); + } + + // Unpack the batch format: [frame_count: u32][frame1_size: u32][frame1_data...][frame2_size: u32][frame2_data...]... + const view = new DataView(batchData.buffer, batchData.byteOffset, batchData.byteLength); + let offset = 0; + + // Read frame count + const frameCount = view.getUint32(offset, true); // little-endian + offset += 4; + + if (frameCount !== timestamps.length) { + console.warn(`Expected ${timestamps.length} frames, got ${frameCount}`); + } + + const t_before_conversion = performance.now(); + + // Process each frame + for (let i = 0; i < frameCount; i++) { + // Read frame size + const frameSize = view.getUint32(offset, true); + offset += 4; + + // Extract frame data + const frameData = new Uint8Array(batchData.buffer, batchData.byteOffset + offset, frameSize); + offset += frameSize; + + let imageData; + + if (this.useJpegCompression) { + // Decode JPEG using createImageBitmap + const blob = new Blob([frameData], { type: 'image/jpeg' }); + const imageBitmap = await createImageBitmap(blob); + + // Create temporary canvas to extract ImageData + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = clip.width; + tempCanvas.height = clip.height; + const tempCtx = tempCanvas.getContext('2d'); + tempCtx.drawImage(imageBitmap, 0, 0); + imageData = tempCtx.getImageData(0, 0, clip.width, clip.height); + + imageBitmap.close(); + } else { + // Raw RGBA data + const expectedSize = clip.width * clip.height * 4; + + if (frameData.length !== expectedSize) { + console.error(`Invalid frame ${i} data size: got ${frameData.length}, expected ${expectedSize}`); + continue; + } + + imageData = new ImageData( + new Uint8ClampedArray(frameData), + clip.width, + clip.height + ); + } + + // Create canvas for this frame + const frameCanvas = document.createElement('canvas'); + frameCanvas.width = clip.width; + frameCanvas.height = clip.height; + const frameCtx = frameCanvas.getContext('2d'); + frameCtx.putImageData(imageData, 0, 0); + + // Cache the frame + clip.frameCache.set(timestamps[i], frameCanvas); + + // Set as current frame if it's the first one + if (i === 0) { + clip.currentFrame = frameCanvas; + clip.lastFetchedTimestamp = timestamps[i]; + } + } + + const t_after_conversion = performance.now(); + + // Limit cache size to avoid memory issues + const maxCacheSize = this.prefetchCount * 2; + if (clip.frameCache.size > maxCacheSize) { + // Remove oldest entries (simple LRU by keeping only recent timestamps) + const sortedKeys = Array.from(clip.frameCache.keys()).sort((a, b) => a - b); + const toRemove = sortedKeys.slice(0, sortedKeys.length - maxCacheSize); + for (let key of toRemove) { + clip.frameCache.delete(key); + } + } + + // Log timing breakdown + const total_time = t_after_conversion - t_start; + const ipc_time = t_after_ipc - t_start; + const conversion_time = t_after_conversion - t_before_conversion; + const compression_mode = this.useJpegCompression ? 'JPEG' : 'RAW'; + const avg_per_frame = total_time / frameCount; + + console.log(`[JS Video Batch ${compression_mode}] Fetched ${frameCount} frames | Total: ${total_time.toFixed(1)}ms | IPC: ${ipc_time.toFixed(1)}ms (${(ipc_time/total_time*100).toFixed(0)}%) | Convert: ${conversion_time.toFixed(1)}ms | Avg/frame: ${avg_per_frame.toFixed(1)}ms | Size: ${(batchData.length/1024/1024).toFixed(2)}MB`); + } catch (error) { + console.error('Failed to get video frames batch:', error); + clip.currentFrame = null; + } finally { + clip.fetchInProgress = false; + } + } + } finally { + this.updateInProgress = false; + } + } + + // Draw cached frames (synchronous) + draw(cxt, currentTime) { + if (!this.visible) { + return; + } + + const ctx = cxt.ctx || cxt; + + // Use currentTime from context if not provided + if (currentTime === undefined) { + currentTime = cxt.activeObject?.currentTime || 0; + } + + for (let clip of this.clips) { + // Check if clip is active at current time + if (currentTime < clip.startTime || + currentTime >= clip.startTime + clip.duration) { + continue; + } + + // Debug: log what path we're taking + // if (!clip._drawPathLogged) { + // console.log(`[Video Draw] useWebSocketStreaming=${this.useWebSocketStreaming}, wsCurrentFrame=${!!clip.wsCurrentFrame}, useBrowserVideo=${clip.useBrowserVideo}, videoElement=${!!clip.videoElement}, currentFrame=${!!clip.currentFrame}`); + // clip._drawPathLogged = true; + // } + + // Prefer WebSocket streaming if available + if (this.useWebSocketStreaming && clip.wsCurrentFrame) { + try { + // Create a temporary canvas to hold the ImageData + if (!clip._wsCanvas) { + clip._wsCanvas = document.createElement('canvas'); + } + const tempCanvas = clip._wsCanvas; + + // Set temp canvas size to match ImageData dimensions + if (tempCanvas.width !== clip.wsCurrentFrame.width || tempCanvas.height !== clip.wsCurrentFrame.height) { + tempCanvas.width = clip.wsCurrentFrame.width; + tempCanvas.height = clip.wsCurrentFrame.height; + } + + // Put ImageData on temp canvas (zero-copy) + const tempCtx = tempCanvas.getContext('2d'); + tempCtx.putImageData(clip.wsCurrentFrame, 0, 0); + + // Scale to fit canvas while maintaining aspect ratio + const canvasWidth = config.fileWidth; + const canvasHeight = config.fileHeight; + const scale = Math.min( + canvasWidth / clip.width, + canvasHeight / clip.height + ); + const scaledWidth = clip.width * scale; + const scaledHeight = clip.height * scale; + const x = (canvasWidth - scaledWidth) / 2; + const y = (canvasHeight - scaledHeight) / 2; + + // Draw scaled to main canvas (GPU-accelerated) + ctx.drawImage(tempCanvas, x, y, scaledWidth, scaledHeight); + } catch (error) { + console.error('[Video WS Draw] Failed to draw WebSocket frame:', error); + } + } + // Prefer browser video element if available + else if (clip.useBrowserVideo && clip.videoElement) { + // Debug: log readyState issues + if (clip.videoElement.readyState < 2) { + if (!clip._readyStateWarned) { + console.warn(`[Video] Video not ready: readyState=${clip.videoElement.readyState}, src=${clip.videoElement.src}`); + clip._readyStateWarned = true; + } + } + + // Draw if video is ready (shows last frame while seeking, updates when seek completes) + if (clip.videoElement.readyState >= 2) { + try { + // Calculate expected video time + const expectedVideoTime = clip.offset + (currentTime - clip.startTime); + const actualVideoTime = clip.videoElement.currentTime; + const timeDiff = Math.abs(expectedVideoTime - actualVideoTime); + + // Debug: log if time is significantly different + if (timeDiff > 0.1 && (!clip._lastTimeDiffWarning || Date.now() - clip._lastTimeDiffWarning > 1000)) { + console.warn(`[Video Draw] Time mismatch: expected ${expectedVideoTime.toFixed(2)}s, actual ${actualVideoTime.toFixed(2)}s, diff=${timeDiff.toFixed(2)}s`); + clip._lastTimeDiffWarning = Date.now(); + } + + // Debug: log successful draw periodically + if (!clip._lastDrawLog || Date.now() - clip._lastDrawLog > 1000) { + console.log(`[Video Draw] Drawing at currentTime=${actualVideoTime.toFixed(2)}s (expected ${expectedVideoTime.toFixed(2)}s)`); + clip._lastDrawLog = Date.now(); + } + + // Scale to fit canvas while maintaining aspect ratio + const canvasWidth = config.fileWidth; + const canvasHeight = config.fileHeight; + const scale = Math.min( + canvasWidth / clip.videoElement.videoWidth, + canvasHeight / clip.videoElement.videoHeight + ); + const scaledWidth = clip.videoElement.videoWidth * scale; + const scaledHeight = clip.videoElement.videoHeight * scale; + const x = (canvasWidth - scaledWidth) / 2; + const y = (canvasHeight - scaledHeight) / 2; + + // Debug: draw a test rectangle to verify canvas is working + if (!clip._canvasTestDone) { + ctx.save(); + ctx.fillStyle = 'red'; + ctx.fillRect(10, 10, 100, 100); + ctx.restore(); + console.log(`[Video Draw] Drew test rectangle at (10, 10, 100, 100)`); + console.log(`[Video Draw] Canvas dimensions: ${canvasWidth}x${canvasHeight}`); + console.log(`[Video Draw] Scaled video dimensions: ${scaledWidth}x${scaledHeight} at (${x}, ${y})`); + clip._canvasTestDone = true; + } + + // Debug: Check if video element has dimensions + if (!clip._videoDimensionsLogged) { + console.log(`[Video Draw] Video element dimensions: videoWidth=${clip.videoElement.videoWidth}, videoHeight=${clip.videoElement.videoHeight}, naturalWidth=${clip.videoElement.videoWidth}, naturalHeight=${clip.videoElement.videoHeight}`); + console.log(`[Video Draw] Video element state: paused=${clip.videoElement.paused}, ended=${clip.videoElement.ended}, seeking=${clip.videoElement.seeking}, readyState=${clip.videoElement.readyState}`); + clip._videoDimensionsLogged = true; + } + + ctx.drawImage(clip.videoElement, x, y, scaledWidth, scaledHeight); + + // Debug: Sample a pixel to see if video is actually drawing + if (!clip._pixelTestDone) { + const imageData = ctx.getImageData(canvasWidth / 2, canvasHeight / 2, 1, 1); + const pixel = imageData.data; + console.log(`[Video Draw] Center pixel after drawImage: R=${pixel[0]}, G=${pixel[1]}, B=${pixel[2]}, A=${pixel[3]}`); + clip._pixelTestDone = true; + } + } catch (error) { + console.error('Failed to draw video element:', error); + } + } + } + // Fall back to cached frame if available + else if (clip.currentFrame) { + try { + // Scale to fit canvas while maintaining aspect ratio + const canvasWidth = config.fileWidth; + const canvasHeight = config.fileHeight; + const scale = Math.min( + canvasWidth / clip.width, + canvasHeight / clip.height + ); + const scaledWidth = clip.width * scale; + const scaledHeight = clip.height * scale; + const x = (canvasWidth - scaledWidth) / 2; + const y = (canvasHeight - scaledHeight) / 2; + + ctx.drawImage(clip.currentFrame, x, y, scaledWidth, scaledHeight); + } catch (error) { + console.error('Failed to draw video frame:', error); + } + } else { + // Draw placeholder if frame not loaded yet + ctx.save(); + ctx.fillStyle = '#333333'; + ctx.fillRect(0, 0, config.fileWidth, config.fileHeight); + ctx.fillStyle = '#ffffff'; + ctx.font = '24px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + const msg = clip.transcoding ? 'Transcoding...' : 'Loading...'; + ctx.fillText(msg, config.fileWidth / 2, config.fileHeight / 2); + ctx.restore(); + } + } + } + + static fromJSON(json) { + const videoLayer = new VideoLayer(json.idx, json.name); + + if (json.animationData) { + videoLayer.animationData = AnimationData.fromJSON(json.animationData, videoLayer); + } + + if (json.clips) { + videoLayer.clips = json.clips; + } + + if (json.linkedAudioTrack) { + // Will be resolved after all objects are loaded + videoLayer.linkedAudioTrack = json.linkedAudioTrack; + } + + videoLayer.visible = json.visible; + videoLayer.audible = json.audible; + + // Restore compression setting (default to true if not specified for backward compatibility) + if (json.useJpegCompression !== undefined) { + videoLayer.useJpegCompression = json.useJpegCompression; + } + + return videoLayer; + } + + toJSON(randomizeUuid = false) { + return { + type: "VideoLayer", + idx: randomizeUuid ? uuidv4() : this.idx, + name: randomizeUuid ? this.name + " copy" : this.name, + visible: this.visible, + audible: this.audible, + animationData: this.animationData.toJSON(), + clips: this.clips, + linkedAudioTrack: this.linkedAudioTrack?.idx, + useJpegCompression: this.useJpegCompression + }; + } + + copy(idx) { + const json = this.toJSON(true); + json.idx = idx.slice(0, 8) + this.idx.slice(8); + return VideoLayer.fromJSON(json); + } + + // Compatibility methods for layer interface + bbox() { + return { + x: { min: 0, max: config.fileWidth }, + y: { min: 0, max: config.fileHeight } + }; + } +} + +export { VectorLayer, AudioTrack, VideoLayer }; diff --git a/src/models/root.js b/src/models/root.js new file mode 100644 index 0000000..e7359f2 --- /dev/null +++ b/src/models/root.js @@ -0,0 +1,34 @@ +// Root object initialization +// Creates and configures the root GraphicsObject and context properties + +import { context } from '../state.js'; +import { GraphicsObject } from './graphics-object.js'; + +/** + * Creates and initializes the root GraphicsObject. + * Sets up context properties for active object and layer access. + * + * @returns {GraphicsObject} The root graphics object + */ +export function createRoot() { + const root = new GraphicsObject("root"); + + // Define getter for active object (top of stack) + Object.defineProperty(context, "activeObject", { + get: function () { + return this.objectStack.at(-1); + }, + }); + + // Define getter for active layer (active layer of top object) + Object.defineProperty(context, "activeLayer", { + get: function () { + return this.objectStack.at(-1).activeLayer; + } + }); + + // Initialize object stack with root + context.objectStack = [root]; + + return root; +} diff --git a/src/models/shapes.js b/src/models/shapes.js new file mode 100644 index 0000000..5c5f651 --- /dev/null +++ b/src/models/shapes.js @@ -0,0 +1,752 @@ +// Shape models: BaseShape, TempShape, Shape + +import { context, pointerList } from '../state.js'; +import { Bezier } from '../bezier.js'; +import { Quadtree } from '../quadtree.js'; + +// Helper function for UUID generation +function uuidv4() { + return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) => + ( + +c ^ + (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (+c / 4))) + ).toString(16), + ); +} + +// Forward declarations for dependencies that will be injected +let growBoundingBox = null; +let lerp = null; +let lerpColor = null; +let uuidToColor = null; +let simplifyPolyline = null; +let fitCurve = null; +let createMissingTexturePattern = null; +let debugQuadtree = null; +let d3 = null; + +// Initialize function to be called from main.js +export function initializeShapeDependencies(deps) { + growBoundingBox = deps.growBoundingBox; + lerp = deps.lerp; + lerpColor = deps.lerpColor; + uuidToColor = deps.uuidToColor; + simplifyPolyline = deps.simplifyPolyline; + fitCurve = deps.fitCurve; + createMissingTexturePattern = deps.createMissingTexturePattern; + debugQuadtree = deps.debugQuadtree; + d3 = deps.d3; +} + +class BaseShape { + constructor(startx, starty) { + this.startx = startx; + this.starty = starty; + this.curves = []; + this.regions = []; + this.boundingBox = { + x: { min: startx, max: starty }, + y: { min: starty, max: starty }, + }; + } + recalculateBoundingBox() { + this.boundingBox = undefined; + for (let curve of this.curves) { + if (!this.boundingBox) { + this.boundingBox = curve.bbox(); + } + growBoundingBox(this.boundingBox, curve.bbox()); + } + } + draw(context) { + let ctx = context.ctx; + ctx.lineWidth = this.lineWidth; + ctx.lineCap = "round"; + + // Create a repeating pattern for indicating selected shapes + if (!this.patternCanvas) { + this.patternCanvas = document.createElement('canvas'); + this.patternCanvas.width = 2; + this.patternCanvas.height = 2; + let patternCtx = this.patternCanvas.getContext('2d'); + // Draw the pattern: + // black, transparent, + // transparent, white + patternCtx.fillStyle = 'black'; + patternCtx.fillRect(0, 0, 1, 1); + patternCtx.clearRect(1, 0, 1, 1); + patternCtx.clearRect(0, 1, 1, 1); + patternCtx.fillStyle = 'white'; + patternCtx.fillRect(1, 1, 1, 1); + } + let pattern = ctx.createPattern(this.patternCanvas, 'repeat'); // repeat the pattern across the canvas + + if (this.filled) { + ctx.beginPath(); + if (this.fillImage && this.fillImage instanceof Element) { + let pat; + if (this.fillImage instanceof Element || + Object.keys(this.fillImage).length !== 0) { + pat = ctx.createPattern(this.fillImage, "no-repeat"); + } else { + pat = createMissingTexturePattern(ctx) + } + ctx.fillStyle = pat; + } else { + ctx.fillStyle = this.fillStyle; + } + if (context.debugColor) { + ctx.fillStyle = context.debugColor; + } + if (this.curves.length > 0) { + ctx.moveTo(this.curves[0].points[0].x, this.curves[0].points[0].y); + for (let curve of this.curves) { + ctx.bezierCurveTo( + curve.points[1].x, + curve.points[1].y, + curve.points[2].x, + curve.points[2].y, + curve.points[3].x, + curve.points[3].y, + ); + } + } + ctx.fill(); + if (context.selected) { + ctx.fillStyle = pattern + ctx.fill() + } + } + function drawCurve(curve, selected) { + ctx.strokeStyle = curve.color; + ctx.beginPath(); + ctx.moveTo(curve.points[0].x, curve.points[0].y); + ctx.bezierCurveTo( + curve.points[1].x, + curve.points[1].y, + curve.points[2].x, + curve.points[2].y, + curve.points[3].x, + curve.points[3].y, + ); + ctx.stroke(); + if (selected) { + ctx.strokeStyle = pattern + ctx.stroke() + } + } + if (this.stroked && !context.debugColor) { + for (let curve of this.curves) { + drawCurve(curve, context.selected) + + // // Debug, show curve control points + // ctx.beginPath() + // ctx.arc(curve.points[1].x,curve.points[1].y, 5, 0, 2*Math.PI) + // ctx.arc(curve.points[2].x,curve.points[2].y, 5, 0, 2*Math.PI) + // ctx.arc(curve.points[3].x,curve.points[3].y, 5, 0, 2*Math.PI) + // ctx.fill() + } + } + if (context.activeCurve && this==context.activeCurve.shape) { + drawCurve(context.activeCurve.current, true) + } + if (context.activeVertex && this==context.activeVertex.shape) { + const curves = { + ...context.activeVertex.current.startCurves, + ...context.activeVertex.current.endCurves + } + for (let i in curves) { + let curve = curves[i] + drawCurve(curve, true) + } + ctx.fillStyle = "#000000aa"; + ctx.beginPath(); + let vertexSize = 15 / context.zoomLevel; + ctx.rect( + context.activeVertex.current.point.x - vertexSize / 2, + context.activeVertex.current.point.y - vertexSize / 2, + vertexSize, + vertexSize, + ); + ctx.fill(); + } + // Debug, show quadtree + if (debugQuadtree && this.quadtree && !context.debugColor) { + this.quadtree.draw(ctx); + } + } + lerpShape(shape2, t) { + if (this.curves.length == 0) return this; + let path1 = [ + { + type: "M", + x: this.curves[0].points[0].x, + y: this.curves[0].points[0].y, + }, + ]; + for (let curve of this.curves) { + path1.push({ + type: "C", + x1: curve.points[1].x, + y1: curve.points[1].y, + x2: curve.points[2].x, + y2: curve.points[2].y, + x: curve.points[3].x, + y: curve.points[3].y, + }); + } + let path2 = []; + if (shape2.curves.length > 0) { + path2.push({ + type: "M", + x: shape2.curves[0].points[0].x, + y: shape2.curves[0].points[0].y, + }); + for (let curve of shape2.curves) { + path2.push({ + type: "C", + x1: curve.points[1].x, + y1: curve.points[1].y, + x2: curve.points[2].x, + y2: curve.points[2].y, + x: curve.points[3].x, + y: curve.points[3].y, + }); + } + } + const interpolator = d3.interpolatePathCommands(path1, path2); + let current = interpolator(t); + let curves = []; + let start = current.shift(); + let { x, y } = start; + let bezier; + for (let curve of current) { + bezier = new Bezier( + x, + y, + curve.x1, + curve.y1, + curve.x2, + curve.y2, + curve.x, + curve.y, + ) + bezier.color = lerpColor(this.strokeStyle, shape2.strokeStyle) + curves.push(bezier); + x = curve.x; + y = curve.y; + } + let lineWidth = lerp(this.lineWidth, shape2.lineWidth, t); + let strokeStyle = lerpColor( + this.strokeStyle, + shape2.strokeStyle, + t, + ); + let fillStyle; + if (!this.fillImage) { + fillStyle = lerpColor(this.fillStyle, shape2.fillStyle, t); + } + return new TempShape( + start.x, + start.y, + curves, + lineWidth, + this.stroked, + this.filled, + strokeStyle, + fillStyle, + ) + } +} + +class TempShape extends BaseShape { + constructor( + startx, + starty, + curves, + lineWidth, + stroked, + filled, + strokeStyle, + fillStyle, + ) { + super(startx, starty); + this.curves = curves; + this.lineWidth = lineWidth; + this.stroked = stroked; + this.filled = filled; + this.strokeStyle = strokeStyle; + this.fillStyle = fillStyle; + this.inProgress = false; + this.recalculateBoundingBox(); + } +} + +class Shape extends BaseShape { + constructor(startx, starty, context, parent, uuid = undefined, shapeId = undefined) { + super(startx, starty); + this.parent = parent; // Reference to parent Layer (required) + this.vertices = []; + this.triangles = []; + this.fillStyle = context.fillStyle; + this.fillImage = context.fillImage; + this.strokeStyle = context.strokeStyle; + this.lineWidth = context.lineWidth; + this.filled = context.fillShape; + this.stroked = context.strokeShape; + this.quadtree = new Quadtree( + { x: { min: 0, max: 500 }, y: { min: 0, max: 500 } }, + 4, + ); + if (!uuid) { + this.idx = uuidv4(); + } else { + this.idx = uuid; + } + if (!shapeId) { + this.shapeId = uuidv4(); + } else { + this.shapeId = shapeId; + } + this.shapeIndex = 0; // Default shape version index for tweening + pointerList[this.idx] = this; + this.regionIdx = 0; + this.inProgress = true; + + // Timeline display settings (Phase 3) + this.showSegment = true // Show segment bar in timeline + this.curvesMode = 'keyframe' // 'segment' | 'keyframe' | 'curve' + this.curvesHeight = 150 // Height in pixels when curves are in curve view + } + static fromJSON(json, parent) { + let fillImage = undefined; + if (json.fillImage && Object.keys(json.fillImage).length !== 0) { + let img = new Image(); + img.src = json.fillImage.src + fillImage = img + } else { + fillImage = {} + } + const shape = new Shape( + json.startx, + json.starty, + { + fillStyle: json.fillStyle, + fillImage: fillImage, + strokeStyle: json.strokeStyle, + lineWidth: json.lineWidth, + fillShape: json.filled, + strokeShape: json.stroked, + }, + parent, + json.idx, + json.shapeId, + ); + for (let curve of json.curves) { + shape.addCurve(Bezier.fromJSON(curve)); + } + for (let region of json.regions) { + const curves = []; + for (let curve of region.curves) { + curves.push(Bezier.fromJSON(curve)); + } + shape.regions.push({ + idx: region.idx, + curves: curves, + fillStyle: region.fillStyle, + filled: region.filled, + }); + } + // Load shapeIndex if present (for shape tweening) + if (json.shapeIndex !== undefined) { + shape.shapeIndex = json.shapeIndex; + } + return shape; + } + toJSON(randomizeUuid = false) { + const json = {}; + json.type = "Shape"; + json.startx = this.startx; + json.starty = this.starty; + json.fillStyle = this.fillStyle; + if (this.fillImage instanceof Element) { + json.fillImage = { + src: this.fillImage.src + } + } + json.strokeStyle = this.fillStyle; + json.lineWidth = this.lineWidth; + json.filled = this.filled; + json.stroked = this.stroked; + if (randomizeUuid) { + json.idx = uuidv4(); + } else { + json.idx = this.idx; + } + json.shapeId = this.shapeId; + json.shapeIndex = this.shapeIndex; // For shape tweening + json.curves = []; + for (let curve of this.curves) { + json.curves.push(curve.toJSON(randomizeUuid)); + } + json.regions = []; + for (let region of this.regions) { + const curves = []; + for (let curve of region.curves) { + curves.push(curve.toJSON(randomizeUuid)); + } + json.regions.push({ + idx: region.idx, + curves: curves, + fillStyle: region.fillStyle, + filled: region.filled, + }); + } + return json; + } + get segmentColor() { + return uuidToColor(this.idx); + } + addCurve(curve) { + if (curve.color == undefined) { + curve.color = context.strokeStyle; + } + this.curves.push(curve); + this.quadtree.insert(curve, this.curves.length - 1); + growBoundingBox(this.boundingBox, curve.bbox()); + } + addLine(x, y) { + let lastpoint; + if (this.curves.length) { + lastpoint = this.curves[this.curves.length - 1].points[3]; + } else { + lastpoint = { x: this.startx, y: this.starty }; + } + let midpoint = { x: (x + lastpoint.x) / 2, y: (y + lastpoint.y) / 2 }; + let curve = new Bezier( + lastpoint.x, + lastpoint.y, + midpoint.x, + midpoint.y, + midpoint.x, + midpoint.y, + x, + y, + ); + curve.color = context.strokeStyle; + this.quadtree.insert(curve, this.curves.length - 1); + this.curves.push(curve); + } + bbox() { + return this.boundingBox; + } + clear() { + this.curves = []; + this.quadtree.clear(); + } + copy(idx) { + let newShape = new Shape( + this.startx, + this.starty, + {}, + this.parent, + idx.slice(0, 8) + this.idx.slice(8), + this.shapeId, + ); + newShape.startx = this.startx; + newShape.starty = this.starty; + for (let curve of this.curves) { + let newCurve = new Bezier( + curve.points[0].x, + curve.points[0].y, + curve.points[1].x, + curve.points[1].y, + curve.points[2].x, + curve.points[2].y, + curve.points[3].x, + curve.points[3].y, + ); + newCurve.color = curve.color; + newShape.addCurve(newCurve); + } + // TODO + // for (let vertex of this.vertices) { + + // } + newShape.updateVertices(); + newShape.fillStyle = this.fillStyle; + if (this.fillImage instanceof Element) { + newShape.fillImage = this.fillImage.cloneNode(true) + } else { + newShape.fillImage = this.fillImage; + } + newShape.strokeStyle = this.strokeStyle; + newShape.lineWidth = this.lineWidth; + newShape.filled = this.filled; + newShape.stroked = this.stroked; + + return newShape; + } + fromPoints(points, error = 30) { + console.log(error); + this.curves = []; + let curves = fitCurve.fitCurve(points, error); + for (let curve of curves) { + let bezier = new Bezier( + curve[0][0], + curve[0][1], + curve[1][0], + curve[1][1], + curve[2][0], + curve[2][1], + curve[3][0], + curve[3][1], + ); + this.curves.push(bezier); + this.quadtree.insert(bezier, this.curves.length - 1); + } + return this; + } + simplify(mode = "corners") { + this.quadtree.clear(); + this.inProgress = false; + // Mode can be corners, smooth or auto + if (mode == "corners") { + let points = [{ x: this.startx, y: this.starty }]; + for (let curve of this.curves) { + points.push(curve.points[3]); + } + // points = points.concat(this.curves) + let newpoints = simplifyPolyline(points, 10, false); + this.curves = []; + let lastpoint = newpoints.shift(); + let midpoint; + for (let point of newpoints) { + midpoint = { + x: (lastpoint.x + point.x) / 2, + y: (lastpoint.y + point.y) / 2, + }; + let bezier = new Bezier( + lastpoint.x, + lastpoint.y, + midpoint.x, + midpoint.y, + midpoint.x, + midpoint.y, + point.x, + point.y, + ); + this.curves.push(bezier); + this.quadtree.insert(bezier, this.curves.length - 1); + lastpoint = point; + } + } else if (mode == "smooth") { + let error = 30; + let points = [[this.startx, this.starty]]; + for (let curve of this.curves) { + points.push([curve.points[3].x, curve.points[3].y]); + } + this.fromPoints(points, error); + } else if (mode == "verbatim") { + // Just keep existing shape + } + let epsilon = 0.01; + let newCurves = []; + let intersectMap = {}; + for (let i = 0; i < this.curves.length - 1; i++) { + // for (let j=i+1; j= j) continue; + let intersects = this.curves[i].intersects(this.curves[j]); + if (intersects.length) { + intersectMap[i] ||= []; + intersectMap[j] ||= []; + for (let intersect of intersects) { + let [t1, t2] = intersect.split("/"); + intersectMap[i].push(parseFloat(t1)); + intersectMap[j].push(parseFloat(t2)); + } + } + } + } + for (let lst in intersectMap) { + for (let i = 1; i < intersectMap[lst].length; i++) { + if ( + Math.abs(intersectMap[lst][i] - intersectMap[lst][i - 1]) < epsilon + ) { + intersectMap[lst].splice(i, 1); + i--; + } + } + } + for (let i = this.curves.length - 1; i >= 0; i--) { + if (i in intersectMap) { + intersectMap[i].sort().reverse(); + let remainingFraction = 1; + let remainingCurve = this.curves[i]; + for (let t of intersectMap[i]) { + let split = remainingCurve.split(t / remainingFraction); + remainingFraction = t; + newCurves.push(split.right); + remainingCurve = split.left; + } + newCurves.push(remainingCurve); + } else { + newCurves.push(this.curves[i]); + } + } + for (let curve of newCurves) { + curve.color = context.strokeStyle; + } + newCurves.reverse(); + this.curves = newCurves; + } + update() { + this.recalculateBoundingBox(); + this.updateVertices(); + if (this.curves.length) { + this.startx = this.curves[0].points[0].x; + this.starty = this.curves[0].points[0].y; + } + return [this]; + } + getClockwiseCurves(point, otherPoints) { + // Returns array of {x, y, idx, angle} + + let points = []; + for (let point of otherPoints) { + points.push({ ...this.vertices[point].point, idx: point }); + } + // Add an angle property to each point using tan(angle) = y/x + const angles = points.map(({ x, y, idx }) => { + return { + x, + y, + idx, + angle: (Math.atan2(y - point.y, x - point.x) * 180) / Math.PI, + }; + }); + // Sort your points by angle + const pointsSorted = angles.sort((a, b) => a.angle - b.angle); + return pointsSorted; + } + translate(x, y) { + this.quadtree.clear() + let j=0; + for (let curve of this.curves) { + for (let i in curve.points) { + const point = curve.points[i]; + curve.points[i] = { x: point.x + x, y: point.y + y }; + } + this.quadtree.insert(curve, j) + j++; + } + this.update(); + } + updateVertices() { + this.vertices = []; + let utils = Bezier.getUtils(); + let epsilon = 1.5; // big epsilon whoa + let tooClose; + let i = 0; + + let region = { + idx: `${this.idx}-r${this.regionIdx++}`, + curves: [], + fillStyle: context.fillStyle, + filled: context.fillShape, + }; + pointerList[region.idx] = region; + this.regions = [region]; + for (let curve of this.curves) { + this.regions[0].curves.push(curve); + } + if (this.regions[0].curves.length) { + if ( + utils.dist( + this.regions[0].curves[0].points[0], + this.regions[0].curves[this.regions[0].curves.length - 1].points[3], + ) < epsilon + ) { + this.regions[0].filled = true; + } + } + + // Generate vertices + for (let curve of this.curves) { + for (let index of [0, 3]) { + tooClose = false; + for (let vertex of this.vertices) { + if (utils.dist(curve.points[index], vertex.point) < epsilon) { + tooClose = true; + vertex[["startCurves", , , "endCurves"][index]][i] = curve; + break; + } + } + if (!tooClose) { + if (index == 0) { + this.vertices.push({ + point: curve.points[index], + startCurves: { [i]: curve }, + endCurves: {}, + }); + } else { + this.vertices.push({ + point: curve.points[index], + startCurves: {}, + endCurves: { [i]: curve }, + }); + } + } + } + i++; + } + + let shapes = [this]; + this.vertices.forEach((vertex, i) => { + for (let i = 0; i < Math.min(10, this.regions.length); i++) { + let region = this.regions[i]; + let regionVertexCurves = []; + let vertexCurves = { ...vertex.startCurves, ...vertex.endCurves }; + if (Object.keys(vertexCurves).length == 1) { + // endpoint + continue; + } else if (Object.keys(vertexCurves).length == 2) { + // path vertex, don't need to do anything + continue; + } else if (Object.keys(vertexCurves).length == 3) { + // T junction. Region doesn't change but might need to update curves? + // Skip for now. + continue; + } else if (Object.keys(vertexCurves).length == 4) { + // Intersection, split region in 2 + for (let i in vertexCurves) { + let curve = vertexCurves[i]; + if (region.curves.includes(curve)) { + regionVertexCurves.push(curve); + } + } + let start = region.curves.indexOf(regionVertexCurves[1]); + let end = region.curves.indexOf(regionVertexCurves[3]); + if (end > start) { + let newRegion = { + idx: `${this.idx}-r${this.regionIdx++}`, // TODO: generate this deterministically so that undo/redo works + curves: region.curves.splice(start, end - start), + fillStyle: region.fillStyle, + filled: true, + }; + pointerList[newRegion.idx] = newRegion; + this.regions.push(newRegion); + } + } else { + // not sure how to handle vertices with more than 4 curves + console.log( + `Unexpected vertex with ${Object.keys(vertexCurves).length} curves!`, + ); + } + } + }); + } +} + +export { BaseShape, TempShape, Shape }; diff --git a/src/newfile.js b/src/newfile.js index 95d3c07..52f5f83 100644 --- a/src/newfile.js +++ b/src/newfile.js @@ -62,6 +62,38 @@ function createNewFileDialog(newFileCallback, openFileCallback, config) { fpsInput.value = config.framerate; newFileDialog.appendChild(fpsInput); + // Create Project Type selector + const projectTypeLabel = document.createElement('label'); + projectTypeLabel.setAttribute('for', 'projectType'); + projectTypeLabel.classList.add('dialog-label'); + projectTypeLabel.textContent = 'Project Type:'; + newFileDialog.appendChild(projectTypeLabel); + + const projectTypeSelect = document.createElement('select'); + projectTypeSelect.id = 'projectType'; + projectTypeSelect.classList.add('dialog-input'); + + const projectTypes = [ + { value: 'animation', label: '🎬 Animation - Drawing tools and timeline' }, + { value: 'videoEditing', label: '🎥 Video - Clip timeline and effects' }, + { value: 'audioDaw', label: '🎵 Music - Audio tracks and mixer' }, + { value: 'scripting', label: '💻 Scripting - Code editor and console' }, + { value: 'drawingPainting', label: '🎨 Drawing - Minimal UI for sketching' }, + { value: 'threeD', label: '🧊 3D - Viewport and camera controls' } + ]; + + projectTypes.forEach(type => { + const option = document.createElement('option'); + option.value = type.value; + option.textContent = type.label; + if (type.value === config.defaultLayout) { + option.selected = true; + } + projectTypeSelect.appendChild(option); + }); + + newFileDialog.appendChild(projectTypeSelect); + // Create Create button const createButton = document.createElement('button'); createButton.textContent = 'Create'; @@ -82,8 +114,9 @@ function createNewFileDialog(newFileCallback, openFileCallback, config) { const width = parseInt(document.getElementById('width').value); const height = parseInt(document.getElementById('height').value); const fps = parseInt(document.getElementById('fps').value); - console.log(`New file created with width: ${width} and height: ${height}`); - newFileCallback(width, height, fps) + const projectType = document.getElementById('projectType').value; + console.log(`New file created with width: ${width}, height: ${height}, fps: ${fps}, layout: ${projectType}`); + newFileCallback(width, height, fps, projectType) closeDialog(); } diff --git a/src/nodeTypes.js b/src/nodeTypes.js new file mode 100644 index 0000000..a2c78f8 --- /dev/null +++ b/src/nodeTypes.js @@ -0,0 +1,1510 @@ +// Node type definitions for the audio node graph editor +// Each node type defines its inputs, outputs, parameters, and HTML template + +/** + * Signal types for node ports + * These match the backend SignalType enum + */ +export const SignalType = { + AUDIO: 'audio', // Blue circles + MIDI: 'midi', // Green squares + CV: 'cv' // Orange diamonds +}; + +/** + * Node category for organization in the palette + */ +export const NodeCategory = { + INPUT: 'input', + GENERATOR: 'generator', + EFFECT: 'effect', + UTILITY: 'utility', + OUTPUT: 'output' +}; + +/** + * Get CSS class for a port based on its signal type + */ +export function getPortClass(signalType) { + return `connector-${signalType}`; +} + +/** + * Node type catalog + * Maps node type names to their definitions + */ +export const nodeTypes = { + Oscillator: { + name: 'Oscillator', + category: NodeCategory.GENERATOR, + description: 'Oscillator with multiple waveforms and CV modulation', + inputs: [ + { name: 'V/Oct', type: SignalType.CV, index: 0 }, + { name: 'FM', type: SignalType.CV, index: 1 } + ], + outputs: [ + { name: 'Audio', type: SignalType.AUDIO, index: 0 } + ], + parameters: [ + { id: 0, name: 'frequency', label: 'Frequency', min: 20, max: 20000, default: 440, unit: 'Hz' }, + { id: 1, name: 'amplitude', label: 'Amplitude', min: 0, max: 1, default: 0.5, unit: '' }, + { id: 2, name: 'waveform', label: 'Waveform', min: 0, max: 3, default: 0, unit: '' } + ], + getHTML: (nodeId) => ` +
    +
    Oscillator
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + ` + }, + + Gain: { + name: 'Gain', + category: NodeCategory.UTILITY, + description: 'VCA (voltage-controlled amplifier) - CV multiplies gain', + inputs: [ + { name: 'Audio In', type: SignalType.AUDIO, index: 0 }, + { name: 'CV', type: SignalType.CV, index: 1 } + ], + outputs: [ + { name: 'Audio Out', type: SignalType.AUDIO, index: 0 } + ], + parameters: [ + { id: 0, name: 'gain', label: 'Gain', min: 0, max: 2, default: 1, unit: 'x' } + ], + getHTML: (nodeId) => ` +
    +
    Gain
    +
    + + +
    +
    + ` + }, + + Mixer: { + name: 'Mixer', + category: NodeCategory.UTILITY, + description: 'Mix up to 4 audio inputs with independent gain controls', + inputs: [ + { name: 'Input 1', type: SignalType.AUDIO, index: 0 }, + { name: 'Input 2', type: SignalType.AUDIO, index: 1 }, + { name: 'Input 3', type: SignalType.AUDIO, index: 2 }, + { name: 'Input 4', type: SignalType.AUDIO, index: 3 } + ], + outputs: [ + { name: 'Mixed Out', type: SignalType.AUDIO, index: 0 } + ], + parameters: [ + { id: 0, name: 'gain1', label: 'Gain 1', min: 0, max: 2, default: 1, unit: 'x' }, + { id: 1, name: 'gain2', label: 'Gain 2', min: 0, max: 2, default: 1, unit: 'x' }, + { id: 2, name: 'gain3', label: 'Gain 3', min: 0, max: 2, default: 1, unit: 'x' }, + { id: 3, name: 'gain4', label: 'Gain 4', min: 0, max: 2, default: 1, unit: 'x' } + ], + getHTML: (nodeId) => ` +
    +
    Mixer
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + ` + }, + + Filter: { + name: 'Filter', + category: NodeCategory.EFFECT, + description: 'Biquad filter with lowpass/highpass modes', + inputs: [ + { name: 'Audio In', type: SignalType.AUDIO, index: 0 }, + { name: 'Cutoff CV', type: SignalType.CV, index: 1 } + ], + outputs: [ + { name: 'Audio Out', type: SignalType.AUDIO, index: 0 } + ], + parameters: [ + { id: 0, name: 'cutoff', label: 'Cutoff', min: 20, max: 20000, default: 1000, unit: 'Hz' }, + { id: 1, name: 'resonance', label: 'Resonance', min: 0.1, max: 10, default: 0.707, unit: 'Q' }, + { id: 2, name: 'type', label: 'Type', min: 0, max: 1, default: 0, unit: '' } + ], + getHTML: (nodeId) => ` +
    +
    Filter
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + ` + }, + + ADSR: { + name: 'ADSR', + category: NodeCategory.UTILITY, + description: 'Attack-Decay-Sustain-Release envelope generator', + inputs: [ + { name: 'Gate', type: SignalType.CV, index: 0 } + ], + outputs: [ + { name: 'Envelope', type: SignalType.CV, index: 0 } + ], + parameters: [ + { id: 0, name: 'attack', label: 'Attack', min: 0.001, max: 5, default: 0.01, unit: 's' }, + { id: 1, name: 'decay', label: 'Decay', min: 0.001, max: 5, default: 0.1, unit: 's' }, + { id: 2, name: 'sustain', label: 'Sustain', min: 0, max: 1, default: 0.7, unit: '' }, + { id: 3, name: 'release', label: 'Release', min: 0.001, max: 5, default: 0.2, unit: 's' } + ], + getHTML: (nodeId) => ` +
    +
    ADSR
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + ` + }, + + MidiInput: { + name: 'MidiInput', + category: NodeCategory.INPUT, + description: 'MIDI input - receives MIDI events from track', + inputs: [], + outputs: [ + { name: 'MIDI Out', type: SignalType.MIDI, index: 0 } + ], + parameters: [], + getHTML: (nodeId) => ` +
    +
    MIDI Input
    +
    Receives MIDI from track
    +
    + ` + }, + + MidiToCV: { + name: 'MidiToCV', + category: NodeCategory.UTILITY, + description: 'Convert MIDI notes to CV signals', + inputs: [ + { name: 'MIDI In', type: SignalType.MIDI, index: 0 } + ], + outputs: [ + { name: 'V/Oct', type: SignalType.CV, index: 0 }, + { name: 'Gate', type: SignalType.CV, index: 1 }, + { name: 'Velocity', type: SignalType.CV, index: 2 } + ], + parameters: [], + getHTML: (nodeId) => ` +
    +
    MIDI→CV
    +
    Converts MIDI to CV signals
    +
    + ` + }, + + AudioToCV: { + name: 'AudioToCV', + category: NodeCategory.UTILITY, + description: 'Envelope follower - converts audio amplitude to CV', + inputs: [ + { name: 'Audio In', type: SignalType.AUDIO, index: 0 } + ], + outputs: [ + { name: 'CV Out', type: SignalType.CV, index: 0 } + ], + parameters: [ + { id: 0, name: 'attack', label: 'Attack', min: 0.001, max: 1.0, default: 0.01, unit: 's' }, + { id: 1, name: 'release', label: 'Release', min: 0.001, max: 1.0, default: 0.1, unit: 's' } + ], + getHTML: (nodeId) => ` +
    +
    Audio→CV
    +
    + + +
    +
    + + +
    +
    + ` + }, + + Oscilloscope: { + name: 'Oscilloscope', + category: NodeCategory.UTILITY, + description: 'Visual audio signal monitor (pass-through)', + inputs: [ + { name: 'Audio In', type: SignalType.AUDIO, index: 0 }, + { name: 'V/oct', type: SignalType.CV, index: 1 }, + { name: 'CV In', type: SignalType.CV, index: 2 } + ], + outputs: [ + { name: 'Audio Out', type: SignalType.AUDIO, index: 0 } + ], + parameters: [ + { id: 0, name: 'time_scale', label: 'Time Scale', min: 10, max: 1000, default: 100, unit: 'ms' }, + { id: 1, name: 'trigger_mode', label: 'Trigger', min: 0, max: 3, default: 0, unit: '' }, + { id: 2, name: 'trigger_level', label: 'Trigger Level', min: -1, max: 1, default: 0, unit: '' } + ], + getHTML: (nodeId) => ` +
    +
    Oscilloscope
    + +
    + + +
    +
    + + +
    +
    + ` + }, + + VoiceAllocator: { + name: 'VoiceAllocator', + category: NodeCategory.UTILITY, + description: 'Polyphonic voice allocator - creates N instances of internal graph', + inputs: [ + { name: 'MIDI In', type: SignalType.MIDI, index: 0 } + ], + outputs: [ + { name: 'Mixed Out', type: SignalType.AUDIO, index: 0 } + ], + parameters: [ + { id: 0, name: 'voices', label: 'Voices', min: 1, max: 16, default: 8, unit: '' } + ], + getHTML: (nodeId) => ` +
    +
    +
    Voice Allocator
    +
    + + +
    +
    Double-click to edit
    +
    + +
    + ` + }, + + AudioInput: { + name: 'AudioInput', + category: NodeCategory.INPUT, + description: 'Audio track clip input - receives audio from timeline clips', + inputs: [], + outputs: [ + { name: 'Audio Out', type: SignalType.AUDIO, index: 0 } + ], + parameters: [], + getHTML: (nodeId) => ` +
    +
    Audio Input
    +
    Audio from clips
    +
    + ` + }, + + AudioOutput: { + name: 'AudioOutput', + category: NodeCategory.OUTPUT, + description: 'Final audio output', + inputs: [ + { name: 'Audio In', type: SignalType.AUDIO, index: 0 } + ], + outputs: [ + { name: 'Audio Out', type: SignalType.AUDIO, index: 0 } + ], + parameters: [], + getHTML: (nodeId) => ` +
    +
    Audio Output
    +
    Final output to speakers
    +
    + ` + }, + + TemplateInput: { + name: 'TemplateInput', + category: NodeCategory.INPUT, + description: 'VoiceAllocator template input - receives MIDI for one voice', + inputs: [], + outputs: [ + { name: 'MIDI Out', type: SignalType.MIDI, index: 0 } + ], + parameters: [], + getHTML: (nodeId) => ` +
    +
    Template Input
    +
    MIDI for one voice
    +
    + ` + }, + + TemplateOutput: { + name: 'TemplateOutput', + category: NodeCategory.OUTPUT, + description: 'VoiceAllocator template output - sends audio to voice mixer', + inputs: [ + { name: 'Audio In', type: SignalType.AUDIO, index: 0 } + ], + outputs: [], + parameters: [], + getHTML: (nodeId) => ` +
    +
    Template Output
    +
    Audio to mixer
    +
    + ` + }, + + LFO: { + name: 'LFO', + category: NodeCategory.UTILITY, + description: 'Low frequency oscillator for modulation', + inputs: [], + outputs: [ + { name: 'CV Out', type: SignalType.CV, index: 0 } + ], + parameters: [ + { id: 0, name: 'frequency', label: 'Frequency', min: 0.01, max: 20, default: 1.0, unit: 'Hz' }, + { id: 1, name: 'amplitude', label: 'Amplitude', min: 0, max: 1, default: 1.0, unit: '' }, + { id: 2, name: 'waveform', label: 'Waveform', min: 0, max: 4, default: 0, unit: '' }, + { id: 3, name: 'phase', label: 'Phase', min: 0, max: 1, default: 0, unit: '' } + ], + getHTML: (nodeId) => ` +
    +
    LFO
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + ` + }, + + NoiseGenerator: { + name: 'NoiseGenerator', + category: NodeCategory.GENERATOR, + description: 'White and pink noise generator', + inputs: [], + outputs: [ + { name: 'Audio Out', type: SignalType.AUDIO, index: 0 } + ], + parameters: [ + { id: 0, name: 'amplitude', label: 'Amplitude', min: 0, max: 1, default: 0.5, unit: '' }, + { id: 1, name: 'color', label: 'Color', min: 0, max: 1, default: 0, unit: '' } + ], + getHTML: (nodeId) => ` +
    +
    Noise
    +
    + + +
    +
    + + +
    +
    + ` + }, + + Splitter: { + name: 'Splitter', + category: NodeCategory.UTILITY, + description: 'Split audio signal to multiple outputs for parallel routing', + inputs: [ + { name: 'Audio In', type: SignalType.AUDIO, index: 0 } + ], + outputs: [ + { name: 'Out 1', type: SignalType.AUDIO, index: 0 }, + { name: 'Out 2', type: SignalType.AUDIO, index: 1 }, + { name: 'Out 3', type: SignalType.AUDIO, index: 2 }, + { name: 'Out 4', type: SignalType.AUDIO, index: 3 } + ], + parameters: [], + getHTML: (nodeId) => ` +
    +
    Splitter
    +
    1→4 split
    +
    + ` + }, + + Pan: { + name: 'Pan', + category: NodeCategory.UTILITY, + description: 'Stereo panning with CV modulation', + inputs: [ + { name: 'Audio In', type: SignalType.AUDIO, index: 0 }, + { name: 'Pan CV', type: SignalType.CV, index: 1 } + ], + outputs: [ + { name: 'Audio Out', type: SignalType.AUDIO, index: 0 } + ], + parameters: [ + { id: 0, name: 'pan', label: 'Pan', min: -1, max: 1, default: 0, unit: '' } + ], + getHTML: (nodeId) => ` +
    +
    Pan
    +
    + + +
    +
    + ` + }, + + Delay: { + name: 'Delay', + category: NodeCategory.EFFECT, + description: 'Stereo delay with feedback', + inputs: [ + { name: 'Audio In', type: SignalType.AUDIO, index: 0 } + ], + outputs: [ + { name: 'Audio Out', type: SignalType.AUDIO, index: 0 } + ], + parameters: [ + { id: 0, name: 'delay_time', label: 'Delay Time', min: 0.001, max: 2.0, default: 0.5, unit: 's' }, + { id: 1, name: 'feedback', label: 'Feedback', min: 0, max: 0.95, default: 0.5, unit: '' }, + { id: 2, name: 'wet_dry', label: 'Wet/Dry', min: 0, max: 1, default: 0.5, unit: '' } + ], + getHTML: (nodeId) => ` +
    +
    Delay
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + ` + }, + + Reverb: { + name: 'Reverb', + category: NodeCategory.EFFECT, + description: 'Schroeder reverb with room size and damping', + inputs: [ + { name: 'Audio In', type: SignalType.AUDIO, index: 0 } + ], + outputs: [ + { name: 'Audio Out', type: SignalType.AUDIO, index: 0 } + ], + parameters: [ + { id: 0, name: 'room_size', label: 'Room Size', min: 0, max: 1, default: 0.5, unit: '' }, + { id: 1, name: 'damping', label: 'Damping', min: 0, max: 1, default: 0.5, unit: '' }, + { id: 2, name: 'wet_dry', label: 'Wet/Dry', min: 0, max: 1, default: 0.3, unit: '' } + ], + getHTML: (nodeId) => ` +
    +
    Reverb
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + ` + }, + + Chorus: { + name: 'Chorus', + category: NodeCategory.EFFECT, + description: 'Chorus effect with modulated delay', + inputs: [ + { name: 'Audio In', type: SignalType.AUDIO, index: 0 } + ], + outputs: [ + { name: 'Audio Out', type: SignalType.AUDIO, index: 0 } + ], + parameters: [ + { id: 0, name: 'rate', label: 'Rate', min: 0.1, max: 5.0, default: 1.0, unit: 'Hz' }, + { id: 1, name: 'depth', label: 'Depth', min: 0, max: 1, default: 0.5, unit: '' }, + { id: 2, name: 'wet_dry', label: 'Wet/Dry', min: 0, max: 1, default: 0.5, unit: '' } + ], + getHTML: (nodeId) => ` +
    +
    Chorus
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + ` + }, + + Flanger: { + name: 'Flanger', + category: NodeCategory.EFFECT, + description: 'Flanger effect with feedback', + inputs: [ + { name: 'Audio In', type: SignalType.AUDIO, index: 0 } + ], + outputs: [ + { name: 'Audio Out', type: SignalType.AUDIO, index: 0 } + ], + parameters: [ + { id: 0, name: 'rate', label: 'Rate', min: 0.1, max: 10.0, default: 0.5, unit: 'Hz' }, + { id: 1, name: 'depth', label: 'Depth', min: 0, max: 1, default: 0.7, unit: '' }, + { id: 2, name: 'feedback', label: 'Feedback', min: -0.95, max: 0.95, default: 0.5, unit: '' }, + { id: 3, name: 'wet_dry', label: 'Wet/Dry', min: 0, max: 1, default: 0.5, unit: '' } + ], + getHTML: (nodeId) => ` +
    +
    Flanger
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + ` + }, + + FMSynth: { + name: 'FM Synth', + category: NodeCategory.GENERATOR, + description: '4-operator FM synthesizer', + inputs: [ + { name: 'V/Oct', type: SignalType.CV, index: 0 }, + { name: 'Gate', type: SignalType.CV, index: 1 } + ], + outputs: [ + { name: 'Audio Out', type: SignalType.AUDIO, index: 0 } + ], + parameters: [ + { id: 0, name: 'algorithm', label: 'Algorithm', min: 0, max: 3, default: 0, unit: '' }, + { id: 1, name: 'op1_ratio', label: 'Op1 Ratio', min: 0.25, max: 16, default: 1.0, unit: '' }, + { id: 2, name: 'op1_level', label: 'Op1 Level', min: 0, max: 1, default: 1.0, unit: '' }, + { id: 3, name: 'op2_ratio', label: 'Op2 Ratio', min: 0.25, max: 16, default: 2.0, unit: '' }, + { id: 4, name: 'op2_level', label: 'Op2 Level', min: 0, max: 1, default: 0.8, unit: '' }, + { id: 5, name: 'op3_ratio', label: 'Op3 Ratio', min: 0.25, max: 16, default: 3.0, unit: '' }, + { id: 6, name: 'op3_level', label: 'Op3 Level', min: 0, max: 1, default: 0.6, unit: '' }, + { id: 7, name: 'op4_ratio', label: 'Op4 Ratio', min: 0.25, max: 16, default: 4.0, unit: '' }, + { id: 8, name: 'op4_level', label: 'Op4 Level', min: 0, max: 1, default: 0.4, unit: '' } + ], + getHTML: (nodeId) => ` +
    +
    FM Synth
    +
    + + +
    +
    Operator 1
    +
    + + +
    +
    + + +
    +
    Operator 2
    +
    + + +
    +
    + + +
    +
    Operator 3
    +
    + + +
    +
    + + +
    +
    Operator 4
    +
    + + +
    +
    + + +
    +
    + ` + }, + + WavetableOscillator: { + name: 'Wavetable', + category: NodeCategory.GENERATOR, + description: 'Wavetable oscillator with preset waveforms', + inputs: [ + { name: 'V/Oct', type: SignalType.CV, index: 0 } + ], + outputs: [ + { name: 'Audio Out', type: SignalType.AUDIO, index: 0 } + ], + parameters: [ + { id: 0, name: 'wavetable', label: 'Wavetable', min: 0, max: 7, default: 0, unit: '' }, + { id: 1, name: 'fine_tune', label: 'Fine Tune', min: -1, max: 1, default: 0, unit: '' }, + { id: 2, name: 'position', label: 'Position', min: 0, max: 1, default: 0, unit: '' } + ], + getHTML: (nodeId) => ` +
    +
    Wavetable
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + ` + }, + + SimpleSampler: { + name: 'Sampler', + category: NodeCategory.GENERATOR, + description: 'Simple sample playback with pitch shifting', + inputs: [ + { name: 'V/Oct', type: SignalType.CV, index: 0 }, + { name: 'Gate', type: SignalType.CV, index: 1 } + ], + outputs: [ + { name: 'Audio Out', type: SignalType.AUDIO, index: 0 } + ], + parameters: [ + { id: 0, name: 'gain', label: 'Gain', min: 0, max: 2, default: 1.0, unit: '' }, + { id: 1, name: 'loop', label: 'Loop', min: 0, max: 1, default: 0, unit: '' }, + { id: 2, name: 'pitch_shift', label: 'Pitch Shift', min: -12, max: 12, default: 0, unit: 'semi' } + ], + getHTML: (nodeId) => ` +
    +
    Sampler
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    +
    No sample loaded
    +
    + ` + }, + + MultiSampler: { + name: 'Multi Sampler', + category: NodeCategory.GENERATOR, + description: 'Multi-sample instrument with velocity layers and key zones', + inputs: [ + { name: 'MIDI In', type: SignalType.MIDI, index: 0 } + ], + outputs: [ + { name: 'Audio Out', type: SignalType.AUDIO, index: 0 } + ], + parameters: [ + { id: 0, name: 'gain', label: 'Gain', min: 0, max: 2, default: 1.0, unit: '' }, + { id: 1, name: 'attack', label: 'Attack', min: 0.001, max: 1, default: 0.01, unit: 's' }, + { id: 2, name: 'release', label: 'Release', min: 0.01, max: 5, default: 0.1, unit: 's' }, + { id: 3, name: 'transpose', label: 'Transpose', min: -24, max: 24, default: 0, unit: 'semi' } + ], + getHTML: (nodeId) => ` +
    +
    Multi Sampler
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + + + + + + + + + + + + +
    FileRangeRootVel
    No layers loaded
    +
    +
    + ` + }, + + Compressor: { + name: 'Compressor', + category: NodeCategory.EFFECT, + description: 'Dynamic range compressor with soft-knee', + inputs: [ + { name: 'Audio In', type: SignalType.AUDIO, index: 0 } + ], + outputs: [ + { name: 'Audio Out', type: SignalType.AUDIO, index: 0 } + ], + parameters: [ + { id: 0, name: 'threshold', label: 'Threshold', min: -60, max: 0, default: -20, unit: 'dB' }, + { id: 1, name: 'ratio', label: 'Ratio', min: 1, max: 20, default: 4, unit: ':1' }, + { id: 2, name: 'attack', label: 'Attack', min: 0.1, max: 100, default: 5, unit: 'ms' }, + { id: 3, name: 'release', label: 'Release', min: 10, max: 1000, default: 100, unit: 'ms' }, + { id: 4, name: 'makeup_gain', label: 'Makeup Gain', min: 0, max: 20, default: 0, unit: 'dB' }, + { id: 5, name: 'knee', label: 'Knee', min: 0, max: 12, default: 6, unit: 'dB' } + ], + getHTML: (nodeId) => ` +
    +
    Compressor
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + ` + }, + + Limiter: { + name: 'Limiter', + category: NodeCategory.EFFECT, + description: 'Peak limiter with ceiling control', + inputs: [ + { name: 'Audio In', type: SignalType.AUDIO, index: 0 } + ], + outputs: [ + { name: 'Audio Out', type: SignalType.AUDIO, index: 0 } + ], + parameters: [ + { id: 0, name: 'threshold', label: 'Threshold', min: -60, max: 0, default: -10, unit: 'dB' }, + { id: 1, name: 'release', label: 'Release', min: 10, max: 1000, default: 50, unit: 'ms' }, + { id: 2, name: 'ceiling', label: 'Ceiling', min: -20, max: 0, default: 0, unit: 'dB' } + ], + getHTML: (nodeId) => ` +
    +
    Limiter
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + ` + }, + + Distortion: { + name: 'Distortion', + category: NodeCategory.EFFECT, + description: 'Waveshaping distortion with multiple algorithms', + inputs: [ + { name: 'Audio In', type: SignalType.AUDIO, index: 0 } + ], + outputs: [ + { name: 'Audio Out', type: SignalType.AUDIO, index: 0 } + ], + parameters: [ + { id: 0, name: 'drive', label: 'Drive', min: 0.01, max: 20, default: 1, unit: '' }, + { id: 1, name: 'type', label: 'Type', min: 0, max: 3, default: 0, unit: '' }, + { id: 2, name: 'tone', label: 'Tone', min: 0, max: 1, default: 0.7, unit: '' }, + { id: 3, name: 'mix', label: 'Mix', min: 0, max: 1, default: 1, unit: '' } + ], + getHTML: (nodeId) => ` +
    +
    Distortion
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + ` + }, + + Constant: { + name: 'Constant', + category: NodeCategory.UTILITY, + description: 'Constant CV source - outputs a fixed voltage', + inputs: [], + outputs: [ + { name: 'CV Out', type: SignalType.CV, index: 0 } + ], + parameters: [ + { id: 0, name: 'value', label: 'Value', min: -10, max: 10, default: 0, unit: '' } + ], + getHTML: (nodeId) => ` +
    +
    Constant
    +
    + + + +
    +
    + ` + }, + + AutomationInput: { + name: 'AutomationInput', + category: NodeCategory.UTILITY, + description: 'Timeline automation - outputs CV signal controlled by timeline curves', + inputs: [], + outputs: [ + { name: 'CV Out', type: SignalType.CV, index: 0 } + ], + parameters: [], + getHTML: (nodeId) => ` +
    +
    Automation
    +
    + Timeline-based automation +
    +
    + Not connected +
    +
    + Edit curves in timeline +
    +
    + ` + }, + + Math: { + name: 'Math', + category: NodeCategory.UTILITY, + description: 'Mathematical and logical operations on CV signals', + inputs: [ + { name: 'CV In A', type: SignalType.CV, index: 0 }, + { name: 'CV In B', type: SignalType.CV, index: 1 } + ], + outputs: [ + { name: 'CV Out', type: SignalType.CV, index: 0 } + ], + parameters: [ + { id: 0, name: 'operation', label: 'Operation', min: 0, max: 13, default: 0, unit: '' } + ], + getHTML: (nodeId) => ` +
    +
    Math
    +
    + + +
    +
    + ` + }, + + Quantizer: { + name: 'Quantizer', + category: NodeCategory.UTILITY, + description: 'Quantize CV to musical scales', + inputs: [ + { name: 'CV In', type: SignalType.CV, index: 0 } + ], + outputs: [ + { name: 'CV Out', type: SignalType.CV, index: 0 }, + { name: 'Gate Out', type: SignalType.CV, index: 1 } + ], + parameters: [ + { id: 0, name: 'scale', label: 'Scale', min: 0, max: 10, default: 0, unit: '' }, + { id: 1, name: 'root', label: 'Root', min: 0, max: 11, default: 0, unit: '' } + ], + getHTML: (nodeId) => ` +
    +
    Quantizer
    +
    + + +
    +
    + + +
    +
    + ` + }, + + SlewLimiter: { + name: 'SlewLimiter', + category: NodeCategory.UTILITY, + description: 'Limit rate of change for portamento/glide effects', + inputs: [ + { name: 'CV In', type: SignalType.CV, index: 0 } + ], + outputs: [ + { name: 'CV Out', type: SignalType.CV, index: 0 } + ], + parameters: [ + { id: 0, name: 'rise_time', label: 'Rise Time', min: 0, max: 5, default: 0.01, unit: 's' }, + { id: 1, name: 'fall_time', label: 'Fall Time', min: 0, max: 5, default: 0.01, unit: 's' } + ], + getHTML: (nodeId) => ` +
    +
    Slew Limiter
    +
    + + +
    +
    + + +
    +
    + ` + }, + + EQ: { + name: 'EQ', + category: NodeCategory.EFFECT, + description: '3-band parametric EQ', + inputs: [ + { name: 'Audio In', type: SignalType.AUDIO, index: 0 } + ], + outputs: [ + { name: 'Audio Out', type: SignalType.AUDIO, index: 0 } + ], + parameters: [ + { id: 0, name: 'low_freq', label: 'Low Freq', min: 20, max: 500, default: 100, unit: 'Hz' }, + { id: 1, name: 'low_gain', label: 'Low Gain', min: -24, max: 24, default: 0, unit: 'dB' }, + { id: 2, name: 'mid_freq', label: 'Mid Freq', min: 200, max: 5000, default: 1000, unit: 'Hz' }, + { id: 3, name: 'mid_gain', label: 'Mid Gain', min: -24, max: 24, default: 0, unit: 'dB' }, + { id: 4, name: 'mid_q', label: 'Mid Q', min: 0.1, max: 10, default: 0.707, unit: '' }, + { id: 5, name: 'high_freq', label: 'High Freq', min: 2000, max: 20000, default: 8000, unit: 'Hz' }, + { id: 6, name: 'high_gain', label: 'High Gain', min: -24, max: 24, default: 0, unit: 'dB' } + ], + getHTML: (nodeId) => ` +
    +
    EQ
    +
    Low Band
    +
    + + +
    +
    + + +
    +
    Mid Band
    +
    + + +
    +
    + + +
    +
    + + +
    +
    High Band
    +
    + + +
    +
    + + +
    +
    + ` + }, + + SampleHold: { + name: 'Sample & Hold', + category: NodeCategory.UTILITY, + description: 'Samples CV input when gate signal goes high', + inputs: [ + { name: 'CV In', type: SignalType.CV, index: 0 }, + { name: 'Gate In', type: SignalType.CV, index: 1 } + ], + outputs: [ + { name: 'CV Out', type: SignalType.CV, index: 0 } + ], + parameters: [], + getHTML: (nodeId) => ` +
    +
    Sample & Hold
    +
    + Samples CV input
    on gate rising edge +
    +
    + ` + }, + + BpmDetector: { + name: 'BPM Detector', + category: NodeCategory.UTILITY, + description: 'Detects tempo from audio and outputs BPM as CV', + inputs: [ + { name: 'Audio In', type: SignalType.AUDIO, index: 0 } + ], + outputs: [ + { name: 'BPM CV', type: SignalType.CV, index: 0 } + ], + parameters: [ + { id: 0, name: 'smoothing', label: 'Smoothing', min: 0.0, max: 1.0, default: 0.9, unit: '' } + ], + getHTML: (nodeId) => ` +
    +
    BPM Detector
    +
    + + +
    +
    + Analyzes incoming audio and outputs detected BPM as CV signal +
    +
    + ` + }, + + EnvelopeFollower: { + name: 'Envelope Follower', + category: NodeCategory.UTILITY, + description: 'Extracts amplitude envelope from audio signal', + inputs: [ + { name: 'Audio In', type: SignalType.AUDIO, index: 0 } + ], + outputs: [ + { name: 'CV Out', type: SignalType.CV, index: 0 } + ], + parameters: [ + { id: 0, name: 'attack', label: 'Attack', min: 0.001, max: 1.0, default: 0.01, unit: 's' }, + { id: 1, name: 'release', label: 'Release', min: 0.001, max: 1.0, default: 0.1, unit: 's' } + ], + getHTML: (nodeId) => ` +
    +
    Envelope Follower
    +
    + + +
    +
    + + +
    +
    + ` + }, + + RingModulator: { + name: 'Ring Modulator', + category: NodeCategory.EFFECT, + description: 'Multiplies carrier and modulator for metallic timbres', + inputs: [ + { name: 'Carrier', type: SignalType.AUDIO, index: 0 }, + { name: 'Modulator', type: SignalType.AUDIO, index: 1 } + ], + outputs: [ + { name: 'Audio Out', type: SignalType.AUDIO, index: 0 } + ], + parameters: [ + { id: 0, name: 'mix', label: 'Mix', min: 0.0, max: 1.0, default: 1.0, unit: '' } + ], + getHTML: (nodeId) => ` +
    +
    Ring Modulator
    +
    + + +
    +
    + ` + }, + + Phaser: { + name: 'Phaser', + category: NodeCategory.EFFECT, + description: 'Sweeping all-pass filters for phase shifting effect', + inputs: [ + { name: 'Audio In', type: SignalType.AUDIO, index: 0 } + ], + outputs: [ + { name: 'Audio Out', type: SignalType.AUDIO, index: 0 } + ], + parameters: [ + { id: 0, name: 'rate', label: 'Rate', min: 0.1, max: 10.0, default: 0.5, unit: 'Hz' }, + { id: 1, name: 'depth', label: 'Depth', min: 0.0, max: 1.0, default: 0.7, unit: '' }, + { id: 2, name: 'stages', label: 'Stages', min: 2, max: 8, default: 6, unit: '' }, + { id: 3, name: 'feedback', label: 'Feedback', min: -0.95, max: 0.95, default: 0.5, unit: '' }, + { id: 4, name: 'wetdry', label: 'Wet/Dry', min: 0.0, max: 1.0, default: 0.5, unit: '' }, + { id: 5, name: 'sync', label: 'Sync to BPM', min: 0, max: 1, default: 0, unit: '' } + ], + getHTML: (nodeId) => ` +
    +
    Phaser
    +
    + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + ` + }, + + BitCrusher: { + name: 'Bit Crusher', + category: NodeCategory.EFFECT, + description: 'Lo-fi effect via bit depth and sample rate reduction', + inputs: [ + { name: 'Audio In', type: SignalType.AUDIO, index: 0 } + ], + outputs: [ + { name: 'Audio Out', type: SignalType.AUDIO, index: 0 } + ], + parameters: [ + { id: 0, name: 'bitdepth', label: 'Bit Depth', min: 1, max: 16, default: 8, unit: 'bits' }, + { id: 1, name: 'samplerate', label: 'Sample Rate', min: 100, max: 48000, default: 8000, unit: 'Hz' }, + { id: 2, name: 'mix', label: 'Mix', min: 0.0, max: 1.0, default: 1.0, unit: '' } + ], + getHTML: (nodeId) => ` +
    +
    Bit Crusher
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + ` + }, + + Vocoder: { + name: 'Vocoder', + category: NodeCategory.EFFECT, + description: 'Multi-band vocoder - modulator controls carrier spectrum', + inputs: [ + { name: 'Modulator', type: SignalType.AUDIO, index: 0 }, + { name: 'Carrier', type: SignalType.AUDIO, index: 1 } + ], + outputs: [ + { name: 'Audio Out', type: SignalType.AUDIO, index: 0 } + ], + parameters: [ + { id: 0, name: 'bands', label: 'Bands', min: 8, max: 32, default: 16, unit: '' }, + { id: 1, name: 'attack', label: 'Attack', min: 0.001, max: 0.1, default: 0.01, unit: 's' }, + { id: 2, name: 'release', label: 'Release', min: 0.001, max: 1.0, default: 0.05, unit: 's' }, + { id: 3, name: 'mix', label: 'Mix', min: 0.0, max: 1.0, default: 1.0, unit: '' } + ], + getHTML: (nodeId) => ` +
    +
    Vocoder
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + ` + } +}; + +/** + * Get all node types in a specific category + */ +export function getNodesByCategory(category) { + return Object.entries(nodeTypes) + .filter(([_, def]) => def.category === category) + .map(([type, def]) => ({ type, ...def })); +} + +/** + * Get all categories that have nodes + */ +export function getCategories() { + const categories = new Set(); + Object.values(nodeTypes).forEach(def => categories.add(def.category)); + return Array.from(categories); +} diff --git a/src/player.html b/src/player.html index 3c5576c..5635b1b 100644 --- a/src/player.html +++ b/src/player.html @@ -158,7 +158,7 @@ class GraphicsObject { this.currentFrameNum = 0; this.currentLayer = 0; this.layers = [] - this.audioLayers = [] + this.audioTracks = [] } static fromJSON(json) { const graphicsObject = new GraphicsObject(json.idx) @@ -174,8 +174,8 @@ class GraphicsObject { for (let layer of json.layers) { graphicsObject.layers.push(Layer.fromJSON(layer)) } - for (let audioLayer of json.audioLayers) { - graphicsObject.audioLayers.push(AudioLayer.fromJSON(audioLayer)) + for (let audioLayer of json.audioTracks) { + graphicsObject.audioTracks.push(AudioTrack.fromJSON(audioLayer)) } return graphicsObject } diff --git a/src/startscreen.js b/src/startscreen.js new file mode 100644 index 0000000..8d5ffdb --- /dev/null +++ b/src/startscreen.js @@ -0,0 +1,241 @@ +const { basename, dirname, join } = window.__TAURI__.path; + +let startScreenContainer; +let onProjectStartCallback; + +/** + * Creates the start screen UI + * @param {Function} callback - Called when user selects a project type or opens a file + * callback receives: { type: 'new'|'reopen'|'recent', projectFocus?: string, filePath?: string, width?: number, height?: number, fps?: number } + */ +export function createStartScreen(callback) { + onProjectStartCallback = callback; + + startScreenContainer = document.createElement('div'); + startScreenContainer.id = 'startScreen'; + startScreenContainer.className = 'start-screen'; + startScreenContainer.style.display = 'none'; // Hidden by default + + // Create welcome title + const title = document.createElement('h1'); + title.textContent = 'Welcome to Lightningbeam!'; + title.className = 'start-screen-title'; + startScreenContainer.appendChild(title); + + // Create main content container + const contentContainer = document.createElement('div'); + contentContainer.className = 'start-screen-content'; + startScreenContainer.appendChild(contentContainer); + + // Left panel - Recent files + const leftPanel = createLeftPanel(); + contentContainer.appendChild(leftPanel); + + // Right panel - New project + const rightPanel = createRightPanel(); + contentContainer.appendChild(rightPanel); + + document.body.appendChild(startScreenContainer); +} + +function createLeftPanel() { + const leftPanel = document.createElement('div'); + leftPanel.className = 'start-screen-left-panel'; + + // Reopen last session section + const reopenSection = document.createElement('div'); + reopenSection.className = 'start-screen-section'; + + const reopenTitle = document.createElement('h3'); + reopenTitle.textContent = 'Reopen last session'; + reopenTitle.className = 'start-screen-section-title'; + reopenSection.appendChild(reopenTitle); + + const lastSessionDiv = document.createElement('div'); + lastSessionDiv.id = 'lastSessionFile'; + lastSessionDiv.className = 'start-screen-file-item'; + lastSessionDiv.textContent = 'No recent session'; + reopenSection.appendChild(lastSessionDiv); + + leftPanel.appendChild(reopenSection); + + // Recent projects section + const recentSection = document.createElement('div'); + recentSection.className = 'start-screen-section'; + + const recentTitle = document.createElement('h3'); + recentTitle.textContent = 'Recent projects'; + recentTitle.className = 'start-screen-section-title'; + recentSection.appendChild(recentTitle); + + const recentList = document.createElement('ul'); + recentList.id = 'recentProjectsList'; + recentList.className = 'start-screen-recent-list'; + recentSection.appendChild(recentList); + + leftPanel.appendChild(recentSection); + + return leftPanel; +} + +function createRightPanel() { + const rightPanel = document.createElement('div'); + rightPanel.className = 'start-screen-right-panel'; + + const heading = document.createElement('h2'); + heading.textContent = 'Create a new project'; + heading.className = 'start-screen-heading'; + rightPanel.appendChild(heading); + + // Project focus options container + const focusContainer = document.createElement('div'); + focusContainer.className = 'start-screen-focus-grid'; + + const focusTypes = [ + { + name: 'Animation', + value: 'animation', + iconPath: '/assets/focus-animation.svg', + description: 'Drawing tools and timeline' + }, + { + name: 'Music', + value: 'audioDaw', + iconPath: '/assets/focus-music.svg', + description: 'Audio tracks and mixer' + }, + { + name: 'Video editing', + value: 'videoEditing', + iconPath: '/assets/focus-video.svg', + description: 'Clip timeline and effects' + } + ]; + + focusTypes.forEach(focus => { + const focusCard = createFocusCard(focus); + focusContainer.appendChild(focusCard); + }); + + rightPanel.appendChild(focusContainer); + + return rightPanel; +} + +async function loadSVG(url, targetElement) { + const response = await fetch(url); + const svgText = await response.text(); + targetElement.innerHTML = svgText; +} + +function createFocusCard(focus) { + const card = document.createElement('div'); + card.className = 'focus-card'; + + // Icon container + const iconContainer = document.createElement('div'); + iconContainer.className = 'focus-card-icon-container'; + + const iconWrapper = document.createElement('div'); + iconWrapper.className = 'focus-card-icon'; + + // Load the SVG asynchronously + loadSVG(focus.iconPath, iconWrapper); + + iconContainer.appendChild(iconWrapper); + card.appendChild(iconContainer); + + // Label + const label = document.createElement('div'); + label.textContent = focus.name; + label.className = 'focus-card-label'; + card.appendChild(label); + + // Click handler + card.addEventListener('click', () => { + onProjectStartCallback({ + type: 'new', + projectFocus: focus.value, + width: 800, + height: 600, + fps: 24 + }); + }); + + return card; +} + +/** + * Updates the recent files list and last session + */ +export async function updateStartScreen(config) { + if (!startScreenContainer) return; + + console.log('[updateStartScreen] config.recentFiles:', config.recentFiles); + + // Update last session + const lastSessionDiv = document.getElementById('lastSessionFile'); + if (lastSessionDiv) { + if (config.recentFiles && config.recentFiles.length > 0) { + const lastFile = config.recentFiles[0]; + const filename = await basename(lastFile); + lastSessionDiv.textContent = filename; + lastSessionDiv.onclick = () => { + onProjectStartCallback({ + type: 'reopen', + filePath: lastFile + }); + }; + lastSessionDiv.classList.add('clickable'); + } else { + lastSessionDiv.textContent = 'No recent session'; + lastSessionDiv.classList.remove('clickable'); + lastSessionDiv.onclick = null; + } + } + + // Update recent projects list + const recentList = document.getElementById('recentProjectsList'); + if (recentList) { + recentList.innerHTML = ''; + + if (config.recentFiles && config.recentFiles.length > 1) { + // Show up to 4 recent files (excluding the most recent which is shown as last session) + const recentFiles = config.recentFiles.slice(1, 5); + + for (const filePath of recentFiles) { + const filename = await basename(filePath); + const listItem = document.createElement('li'); + listItem.textContent = filename; + listItem.className = 'start-screen-file-item clickable'; + + listItem.onclick = () => { + onProjectStartCallback({ + type: 'recent', + filePath: filePath + }); + }; + + recentList.appendChild(listItem); + } + } + } +} + +/** + * Shows the start screen + */ +export function showStartScreen() { + if (startScreenContainer) { + startScreenContainer.style.display = 'flex'; + } +} + +/** + * Hides the start screen + */ +export function hideStartScreen() { + if (startScreenContainer) { + startScreenContainer.style.display = 'none'; + } +} diff --git a/src/state.js b/src/state.js new file mode 100644 index 0000000..2925f39 --- /dev/null +++ b/src/state.js @@ -0,0 +1,211 @@ +// Global state management for Lightningbeam +// This module centralizes all global state that was previously scattered in main.js + +import { deepMerge } from "./utils.js"; + +// Core application context +// Contains UI state, selections, tool settings, etc. +export let context = { + mouseDown: false, + mousePos: { x: 0, y: 0 }, + swatches: [ + "#000000", + "#FFFFFF", + "#FF0000", + "#FFFF00", + "#00FF00", + "#00FFFF", + "#0000FF", + "#FF00FF", + ], + lineWidth: 5, + simplifyMode: "smooth", + fillShape: false, + strokeShape: true, + fillGaps: 5, + dropperColor: "Fill color", + dragging: false, + selectionRect: undefined, + selection: [], + shapeselection: [], + oldselection: [], + oldshapeselection: [], + selectedFrames: [], + dragDirection: undefined, + zoomLevel: 1, + timelineWidget: null, // Reference to TimelineWindowV2 widget for zoom controls + config: null, // Reference to config object (set after config is initialized) + mode: "select", // Current tool mode + // Playback and recording state + playing: false, + isRecording: false, + recordingTrackId: null, + recordingClipId: null, + playPauseButton: null, // Reference to play/pause button for updating appearance + // MIDI activity indicator + lastMidiInputTime: 0, // Timestamp (Date.now()) of last MIDI input + // Metronome state + metronomeEnabled: false, + metronomeButton: null, // Reference to metronome button for updating appearance + metronomeGroup: null, // Reference to metronome button group for showing/hiding +}; + +// Application configuration +// Contains settings, shortcuts, file properties, etc. +export let config = { + shortcuts: { + playAnimation: " ", + undo: "z", + redo: "Z", + new: "n", + newWindow: "N", + save: "s", + saveAs: "S", + open: "o", + import: "i", + export: "e", + quit: "q", + copy: "c", + paste: "v", + delete: "Backspace", + selectAll: "a", + selectNone: "A", + group: "g", + addLayer: "l", + addAudioTrack: "t", + addKeyframe: "F6", + addBlankKeyframe: "F7", + zoomIn: "+", + zoomOut: "-", + resetZoom: "0", + nextLayout: "Tab", + previousLayout: "Tab", + }, + fileWidth: 800, + fileHeight: 600, + framerate: 24, + bpm: 120, + timeSignature: { numerator: 4, denominator: 4 }, + recentFiles: [], + scrollSpeed: 1, + debug: false, + reopenLastSession: false, + lastImportFilterIndex: 0, // Index of last used filter in import dialog (0=Image, 1=Audio, 2=Lightningbeam) + audioBufferSize: 256, // Audio buffer size in frames (128, 256, 512, 1024, etc. - requires restart) + minClipDuration: 0.1, // Minimum clip duration in seconds when trimming + // Layout settings + currentLayout: "animation", // Current active layout key + defaultLayout: "animation", // Default layout for new files + showStartScreen: false, // Show layout picker on startup (disabled for now) + restoreLayoutFromFile: true, // Restore layout when opening files + customLayouts: [] // User-saved custom layouts +}; + +// Object pointer registry +// Maps UUIDs to object instances for quick lookup +export let pointerList = {}; + +// Undo/redo state tracking +// Stores initial property values when starting an action +export let startProps = {}; + +// Helper function to get keyboard shortcut in platform format +export function getShortcut(shortcut) { + if (!(shortcut in config.shortcuts)) return undefined; + + let shortcutValue = config.shortcuts[shortcut].replace("", "CmdOrCtrl+"); + const key = shortcutValue.slice(-1); + + // If the last character is uppercase, prepend "Shift+" to it + return key === key.toUpperCase() && key !== key.toLowerCase() + ? shortcutValue.replace(key, `Shift+${key}`) + : shortcutValue.replace("++", "+Shift+="); // Hardcode uppercase from = to + +} + +// Configuration file management +const CONFIG_FILE_PATH = "config.json"; + +// Load configuration from localStorage +export async function loadConfig() { + try { + const configData = localStorage.getItem("lightningbeamConfig") || "{}"; + const loaded = JSON.parse(configData); + + // Merge loaded config with defaults + Object.assign(config, deepMerge({ ...config }, loaded)); + + // Ensure recentFiles is always an array (fix legacy string format) + let needsResave = false; + if (typeof config.recentFiles === 'string') { + config.recentFiles = config.recentFiles.split(',').filter(f => f.length > 0); + needsResave = true; + } else if (!Array.isArray(config.recentFiles)) { + config.recentFiles = []; + needsResave = true; + } + + // Make config accessible to widgets via context + context.config = config; + + console.log('[loadConfig] Loaded config.recentFiles:', config.recentFiles); + + // Re-save config if we had to fix the format + if (needsResave) { + console.log('[loadConfig] Re-saving config to fix array format'); + await saveConfig(); + } + + return config; + } catch (error) { + console.log("Error loading config, using defaults:", error); + context.config = config; + return config; + } +} + +// Save configuration to localStorage +export async function saveConfig() { + try { + localStorage.setItem( + "lightningbeamConfig", + JSON.stringify(config, null, 2), + ); + } catch (error) { + console.error("Error saving config:", error); + } +} + +// Add a file to recent files list +export async function addRecentFile(filePath) { + config.recentFiles = [ + filePath, + ...config.recentFiles.filter(file => file !== filePath) + ].slice(0, 10); + console.log('[addRecentFile] Added file, recentFiles now:', config.recentFiles); + await saveConfig(); +} + +// Utility to reset pointer list (useful for testing) +export function clearPointerList() { + pointerList = {}; +} + +// Utility to reset start props (useful for testing) +export function clearStartProps() { + startProps = {}; +} + +// Helper to register an object in the pointer list +export function registerObject(uuid, object) { + pointerList[uuid] = object; +} + +// Helper to unregister an object from the pointer list +export function unregisterObject(uuid) { + delete pointerList[uuid]; +} + +// Helper to get an object from the pointer list +export function getObject(uuid) { + return pointerList[uuid]; +} diff --git a/src/styles.css b/src/styles.css index 0871d04..7a04d2e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2,24 +2,110 @@ body { width: 100%; height: 100%; overflow: hidden; + touch-action: none; /* Prevent default touch gestures including pinch-zoom */ } * { user-select: none; } +/* Allow text selection in input fields and textareas */ +input, +textarea { + user-select: text; +} + .logo.vanilla:hover { filter: drop-shadow(0 0 2em #ffe21c); } :root { --lineheight: 24px; + + /* Semantic color system matching styles.js */ + --background-color: #ccc; + --foreground-color: #ddd; + --highlight: #ddd; + --shadow: #999; + --shade: #aaa; + --scrubber-color: #cc2222; + --label-color: black; + + /* Base colors */ + --white: #ffffff; + --black: #0f0f0f; + --pure-black: #000; + + /* Additional semantic colors */ + --surface: #f6f6f6; + --surface-light: #fff; + --surface-dark: #e8e8e8; + --surface-darker: #555; + + --text-primary: #0f0f0f; + --text-secondary: #666; + --text-tertiary: #999; + --text-inverse: #f6f6f6; + + --border-light: #bbb; + --border-medium: #999; + --border-dark: #555; + + /* Interactive elements */ + --button-hover: #396cd8; + --button-active: #e8e8e8; + --link-color: #646cff; + --link-hover: #535bf2; + + /* Status colors */ + --success: #4CAF50; + --success-hover: #45a049; + --success-dark: #2E7D32; + --error: #f44336; + --error-dark: #cc0000; + --info: #2196F3; + --info-dark: #1565C0; + --warning: #FF9800; + --warning-dark: #E65100; + + /* Timeline/Animation colors */ + --motion: #7a00b3; + --motion-hover: #530379; + --motion-border: #450264; + --shape: #9bff9b; + --shape-hover: #38f538; + --shape-border: #26ac26; + --keyframe: #222; + --selection: #00ffff; + + /* Audio layer colors */ + --audio-layer: #8281cc; + --audio-layer-light: #9a99db; + --audio-layer-dark: #817db9; + + /* Node editor */ + --node-bg: #2d2d2d; + --node-border: #4d4d4d; + --node-selected: #4CAF50; + --node-primary: #7c7cff; + --node-template: #9d4edd; + --node-template-light: #c77dff; + --node-child: #5a5aaa; + + /* UI specific */ + --panel-bg: #aaa; + --header-bg: #ccc; + --toolbar-bg: #ccc; + --grid-bg: #555; + --grid-hover: #666; + --horiz-break: #999; + font-family: Inter, Avenir, Helvetica, Arial, sans-serif; font-size: 16px; line-height: var(--lineheight); font-weight: 400; - color: #0f0f0f; - background-color: #f6f6f6; + color: var(--text-primary); + background-color: var(--surface); font-synthesis: none; text-rendering: optimizeLegibility; @@ -56,12 +142,12 @@ body { a { font-weight: 500; - color: #646cff; + color: var(--link-color); text-decoration: inherit; } a:hover { - color: #535bf2; + color: var(--link-hover); } h1 { @@ -77,8 +163,8 @@ button { font-size: 1em; font-weight: 500; font-family: inherit; - color: #0f0f0f; - background-color: #ffffff; + color: var(--black); + background-color: var(--white); transition: border-color 0.25s; box-shadow: 0 4px 4px rgba(0, 0, 0, 0.2); box-sizing: border-box; @@ -95,11 +181,11 @@ button { } button:hover { - border-color: #396cd8; + border-color: var(--button-hover); } button:active { - border-color: #396cd8; - background-color: #e8e8e8; + border-color: var(--button-hover); + background-color: var(--button-active); } input, @@ -115,9 +201,41 @@ button { .header { height: 60px; min-width: 100%; - background-color: #ccc; + background-color: var(--header-bg); text-align: left; z-index: 1; + display: flex; + align-items: center; + user-select: none; +} + +.header * { + user-select: none; +} + +/* Maximize button in pane headers */ +.maximize-btn { + margin-left: auto; + margin-right: 8px; + padding: 4px 8px; + background: none; + border: 1px solid var(--foreground-color); + color: var(--text-primary); + cursor: pointer; + border-radius: 3px; + font-size: 14px; +} + +.maximize-btn:hover { + background-color: var(--surface-light); +} + +.pane { + user-select: none; +} + +.vertical-grid, .horizontal-grid { + user-select: none; } .icon { @@ -134,14 +252,14 @@ button { .horizontal-grid, .vertical-grid { display: flex; - background-color: #555; + background-color: var(--grid-bg); width: 100%; height: 100%; contain: strict; } .horizontal-grid:not(.panecontainer > .horizontal-grid), .vertical-grid:not(.panecontainer > .vertical-grid) { - gap: 3px; + gap: 8px; } .horizontal-grid { flex-direction: row; @@ -151,18 +269,18 @@ button { } /* I don't fully understand this selector but it works for now */ .horizontal-grid:hover:not(:has(*:hover)):not(.panecontainer > .horizontal-grid) { - background: #666; + background: var(--grid-hover); cursor: ew-resize; } .vertical-grid:hover:not(:has(*:hover)):not(.panecontainer > .vertical-grid) { - background: #666; + background: var(--grid-hover); cursor: ns-resize } .scroll { overflow: scroll; width: 100%; height: 100%; - background-color: #555; + background-color: var(--grid-bg); } .stage { width: 100%; @@ -180,7 +298,7 @@ button { height: 300px; left: 100px; top: 100px; - border: 1px solid #00ffff; + border: 1px solid var(--selection); display: none; user-select: none; pointer-events: none; @@ -189,7 +307,7 @@ button { position: absolute; width: 10px; height: 10px; - background-color: black; + background-color: var(--label-color); transition: width 0.2s ease, height 0.2s linear; user-select: none; @@ -271,7 +389,7 @@ button { .toolbtn { width: calc( 3 * var(--lineheight) ); height: calc( 3 * var(--lineheight) ); - background-color: #ccc; + background-color: var(--toolbar-bg); } .toolbtn img { filter: invert(1); @@ -281,7 +399,7 @@ button { width: 100%; height: 5px; - background-color: #999; + background-color: var(--horiz-break); } .color-field { position: relative; @@ -297,7 +415,7 @@ button { .color-field::before { content: var(--label-text);; font-size: 16px; - color: black; + color: var(--label-color); margin-right: 10px; } @@ -324,7 +442,7 @@ button { .infopanel { width: 100%; height: 100%; - background-color: #aaa; + background-color: var(--panel-bg); display: flex; box-sizing: border-box; gap: calc( var(--lineheight) / 2 ); @@ -347,14 +465,14 @@ button { width: 50%; } .layers { - background-color: #aaa; + background-color: var(--panel-bg); display: flex; flex-direction: column; flex-wrap: nowrap; min-height: 100%; } .frames-container { - background-color: #aaa; + background-color: var(--panel-bg); display: flex; flex-direction: column; flex-wrap: nowrap; @@ -366,9 +484,9 @@ button { .layer-header { width: 100%; height: calc( 2 * var(--lineheight)); - background-color: #aaa; - border-top: 1px solid #ddd; - border-bottom: 1px solid #999; + background-color: var(--shade); + border-top: 1px solid var(--foreground-color); + border-bottom: 1px solid var(--shadow); flex-shrink: 0; display: flex; @@ -377,22 +495,22 @@ button { cursor: pointer; } .layer-header.active { - background-color: #ccc; + background-color: var(--background-color); } .layer-header.audio { - background-color: #8281cc; - border-top: 1px solid #9a99db; - border-bottom: 1px solid #817db9; + background-color: var(--audio-layer); + border-top: 1px solid var(--audio-layer-light); + border-bottom: 1px solid var(--audio-layer-dark); } .layer-name { padding-left: 1em; padding-top: 5px; display: inline-block; - color: #666; + color: var(--text-secondary); cursor: text; } .layer-header.active > .layer-name { - color: #000; + color: var(--pure-black); } /* Visibility icon positioning */ .visibility-icon { @@ -406,17 +524,17 @@ button { height: calc( 2 * var(--lineheight)); /* background: repeating-linear-gradient(to right, transparent, transparent 24px, #aaa 24px, #aaa 25px), repeating-linear-gradient(to right, #bbb, #bbb 100px, #aaa 100px, #aaa 125px); */ - background-image: + background-image: /* Layer 1: frame dividers */ - linear-gradient(to right, transparent 24px, #aaa 24px 25px), + linear-gradient(to right, transparent 24px, var(--shade) 24px 25px), /* Layer 2: highlight every 5th frame */ - linear-gradient(to right, #bbb 100px, #aaa 100px 125px); + linear-gradient(to right, var(--border-light) 100px, var(--shade) 100px 125px); background-repeat: repeat-x, repeat-x; background-size: 25px 100%, 125px 100%; display: flex; flex-direction: row; - border-top: 1px solid #bbb; - border-bottom: 1px solid #ccc; + border-top: 1px solid var(--border-light); + border-bottom: 1px solid var(--background-color); flex-shrink: 0; } .layer-track.invisible { @@ -426,17 +544,17 @@ button { width: 25px; height: 100%; - background-color: #ccc; + background-color: var(--background-color); flex-grow: 0; flex-shrink: 0; - border-right: 1px solid #bbb; - border-left: 1px solid #ddd; + border-right: 1px solid var(--border-light); + border-left: 1px solid var(--foreground-color); } .frame:hover { - background-color: #555555; + background-color: var(--surface-darker); } .frame.active { - background-color: #fff; + background-color: var(--surface-light); } .frame.keyframe { position: relative; @@ -451,26 +569,26 @@ button { height: 0; /* Initially set to 0 */ padding-bottom: 50%; /* Set padding-bottom to 50% of the div's width to create a circle */ border-radius: 50%; /* Make the shape a circle */ - background-color: #222; /* Set the color of the circle (black in this case) */ + background-color: var(--keyframe); /* Set the color of the circle (black in this case) */ margin-bottom: 5px; } .frame.motion { - background-color: #7a00b3; + background-color: var(--motion); border: none; } .frame.motion:hover, .frame.motion.active { - background-color: #530379; - border-left: 1px solid #450264; - border-right: 1px solid #450264; + background-color: var(--motion-hover); + border-left: 1px solid var(--motion-border); + border-right: 1px solid var(--motion-border); } .frame.shape { - background-color: #9bff9b; + background-color: var(--shape); border: none; } .frame.shape:hover, .frame.shape.active { - background-color: #38f538; - border-left: 1px solid #26ac26; - border-right: 1px solid #26ac26; + background-color: var(--shape-hover); + border-left: 1px solid var(--shape-border); + border-right: 1px solid var(--shape-border); } /* :nth-child(1 of .frame.motion) { background-color: blue; @@ -480,7 +598,7 @@ button { } */ .frame-highlight { - background-color: #888; + background-color: var(--text-tertiary); width: 25px; height: calc( 2 * var(--lineheight) - 2px); position: relative; @@ -508,8 +626,8 @@ button { top: 50%; left: 50%; transform: translate(-50%, -50%); - background-color: #ddd; - border: 1px solid #aaa; + background-color: var(--foreground-color); + border: 1px solid var(--shade); border-radius: 5px; box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); padding: 20px; @@ -526,21 +644,21 @@ button { width: 100%; padding: 8px; margin: 5px 0; - border: 1px solid #aaa; + border: 1px solid var(--shade); } #newFileDialog .dialog-button, #saveDialog button { width: 100%; padding: 10px; margin-top: 10px; - background-color: #007bff; - color: white; + background-color: var(--info); + color: var(--white); border: none; cursor: pointer; } #newFileDialog .dialog-button:hover { - background-color: #0056b3; + background-color: var(--info-dark); } #recentFilesList li { word-wrap: break-word; @@ -557,13 +675,13 @@ button { #recentFilesList li:hover { cursor: pointer; - background-color: #f0f0f0; + background-color: var(--surface-dark); border-radius: 5px; box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.1); } #popupMenu { - background-color: #eee; + background-color: var(--highlight); box-shadow: 0 4px 8px rgba(0,0,0,0.5); padding: 20px; border-radius: 5px; @@ -574,18 +692,18 @@ button { margin: 0px; } #popupMenu li { - color: #222; + color: var(--text-primary); list-style-type: none; display: flex; align-items: center; /* Vertically center the image and text */ padding: 5px 0; /* Add padding for better spacing */ } #popupMenu li:hover { - background-color: #fff; + background-color: var(--surface-light); cursor:pointer; } #popupMenu li:not(:last-child) { - border-bottom: 1px solid #ccc; /* Horizontal line for all li elements except the last */ + border-bottom: 1px solid var(--background-color); /* Horizontal line for all li elements except the last */ } #popupMenu li img { margin-right: 10px; /* Space between the icon and text */ @@ -615,17 +733,39 @@ button { @media (prefers-color-scheme: dark) { :root { - color: #f6f6f6; - background-color: #2f2f2f; + /* Override variables for dark mode */ + --background-color: #333; + --foreground-color: #888; + --highlight: #4f4f4f; + --shadow: #111; + --shade: #222; + --label-color: white; + + --surface: #2f2f2f; + --surface-light: #444; + --surface-dark: #555; + + --text-primary: #f6f6f6; + --text-secondary: #aaa; + --text-tertiary: #777; + + --header-bg: #3f3f3f; + --panel-bg: #222222; + --toolbar-bg: #2f2f2f; + --grid-bg: #0f0f0f; + --horiz-break: #2f2f2f; + + color: var(--text-primary); + background-color: var(--surface); } a:hover { - color: #24c8db; + color: var(--link-alt); } input, button { - color: #ffffff; + color: var(--white); background-color: #0f0f0f98; } button:active { @@ -633,53 +773,53 @@ button { } #newFileDialog, #saveDialog { - background-color: #444; - border: 1px solid #333; + background-color: var(--surface-light); + border: 1px solid var(--background-color); } #newFileDialog .dialog-input, #saveDialog input { - border: 1px solid #333; + border: 1px solid var(--background-color); } #recentFilesList li:hover { cursor: pointer; - background-color: #555; + background-color: var(--surface-dark); border-radius: 5px; box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.2); } - + #popupMenu { - background-color: #222; + background-color: var(--shade); } #popupMenu li { - color: #ccc; + color: var(--text-primary); } #popupMenu li:hover { - background-color: #444; + background-color: var(--surface-light); } #popupMenu li:not(:last-child) { - border-bottom: 1px solid #444; + border-bottom: 1px solid var(--surface-light); } .color-field::before { - color: #eee; + color: var(--highlight); } .layers { - background-color: #222222; + background-color: var(--panel-bg); } .frames-container { - background-color: #222222; + background-color: var(--panel-bg); } .layer-header { - background-color: #222; - border-top: 1px solid #4f4f4f; - border-bottom: 1px solid #111; + background-color: var(--shade); + border-top: 1px solid var(--highlight); + border-bottom: 1px solid var(--shadow); } .layer-header.active { - background-color: #444; + background-color: var(--surface-light); } .layer-name { - color: #aaa + color: var(--text-secondary); } .layer-header.active > .layer-name { - color: #fff; + color: var(--white); } .layer-header.audio { background-color: #23253b; @@ -687,40 +827,40 @@ button { border-bottom: 1px solid #1f1e24; } .layer-track { - background-image: + background-image: linear-gradient(to right, transparent 23px, #1a1a1a 23px 25px), /* Dark mode frame dividers */ linear-gradient(to right, #121212 100px, #0a0a0a 100px 125px); /* Dark mode frame highlights */ - border-top: 1px solid #222222; - border-bottom: 1px solid #3f3f3f; + border-top: 1px solid var(--shade); + border-bottom: 1px solid var(--header-bg); } .frame { - background-color: #4f4f4f; - border-right: 1px solid #3f3f3f; - border-left: 1px solid #555555; + background-color: var(--highlight); + border-right: 1px solid var(--header-bg); + border-left: 1px solid var(--surface-dark); } .frame:hover { - background-color: #555555; + background-color: var(--surface-dark); } .frame.active { - background-color: #666666; + background-color: var(--text-secondary); } .infopanel { - background-color: #3f3f3f; + background-color: var(--header-bg); } .header { - background-color: #3f3f3f; + background-color: var(--header-bg); } .horizontal-grid, .vertical-grid { - background-color: #0f0f0f; + background-color: var(--grid-bg); } .toolbtn { - background-color: #2f2f2f; + background-color: var(--toolbar-bg); } .toolbtn img { filter:none; } .horiz_break { - background-color: #2f2f2f; + background-color: var(--horiz-break); } .audioWaveform { filter: invert(1); @@ -729,3 +869,1506 @@ button { filter: invert(1); } } + +/* Playback Controls */ +.playback-controls-group { + display: inline-flex; + gap: 0; + margin: 5px; + align-items: center; + border-radius: 6px; + overflow: hidden; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.playback-btn { + width: 40px; + height: 36px; + padding: 0; + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 0; + border-right: 1px solid rgba(0, 0, 0, 0.15); +} + +.playback-btn:last-child { + border-right: none; +} + +/* Play Button - Triangle */ +.playback-btn-play::before { + content: ""; + width: 0; + height: 0; + border-style: solid; + border-width: 8px 0 8px 14px; + border-color: transparent transparent transparent var(--black); + margin-left: 2px; +} + +/* Pause Button - Two Bars */ +.playback-btn-pause::before, +.playback-btn-pause::after { + content: ""; + width: 4px; + height: 16px; + background-color: var(--black); + position: absolute; +} + +.playback-btn-pause::before { + left: 10px; +} + +.playback-btn-pause::after { + right: 10px; +} + +/* Rewind Button - Double Left Triangle */ +.playback-btn-rewind::before, +.playback-btn-rewind::after { + content: ""; + width: 0; + height: 0; + border-style: solid; + border-width: 7px 10px 7px 0; + border-color: transparent var(--black) transparent transparent; + position: absolute; +} + +.playback-btn-rewind::before { + left: 10px; +} + +.playback-btn-rewind::after { + left: 20px; +} + +/* Fast Forward Button - Double Right Triangle */ +.playback-btn-ff::before, +.playback-btn-ff::after { + content: ""; + width: 0; + height: 0; + border-style: solid; + border-width: 7px 0 7px 10px; + border-color: transparent transparent transparent var(--black); + position: absolute; +} + +.playback-btn-ff::before { + left: 10px; +} + +.playback-btn-ff::after { + left: 20px; +} + +/* Go to Start - Bar + Left Triangle */ +.playback-btn-start::before, +.playback-btn-start::after { + content: ""; + position: absolute; +} + +.playback-btn-start::before { + width: 2px; + height: 14px; + background-color: var(--black); + left: 13px; +} + +.playback-btn-start::after { + width: 0; + height: 0; + border-style: solid; + border-width: 7px 12px 7px 0; + border-color: transparent var(--black) transparent transparent; + left: 15px; +} + +/* Go to End - Right Triangle + Bar */ +.playback-btn-end::before, +.playback-btn-end::after { + content: ""; + position: absolute; +} + +.playback-btn-end::before { + width: 0; + height: 0; + border-style: solid; + border-width: 7px 0 7px 12px; + border-color: transparent transparent transparent var(--black); + left: 13px; +} + +.playback-btn-end::after { + width: 2px; + height: 14px; + background-color: var(--black); + left: 25px; +} + +/* Record Button - Circle */ +.playback-btn-record::before { + content: ""; + width: 14px; + height: 14px; + border-radius: 50%; + background-color: var(--error-dark); +} + +.playback-btn-record:disabled::before { + background-color: var(--text-secondary); +} + +/* Recording animation */ +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.playback-btn-record.recording::before { + animation: pulse 1s ease-in-out infinite; +} + +/* Metronome Button - Inline SVG with currentColor */ +.playback-btn-metronome { + color: var(--text-primary); +} + +.playback-btn-metronome svg { + width: 18px; + height: 18px; + display: block; + margin: auto; +} + +/* Active metronome state - use highlight color */ +.playback-btn-metronome.active { + background-color: var(--highlight); + border-color: var(--highlight); +} + +/* Dark mode playback button adjustments */ +@media (prefers-color-scheme: dark) { + .playback-btn { + border-right: 1px solid rgba(255, 255, 255, 0.15); + } + + .playback-btn-play::before { + border-color: transparent transparent transparent var(--text-primary); + } + + .playback-btn-pause::before, + .playback-btn-pause::after { + background-color: var(--text-primary); + } + + .playback-btn-rewind::before, + .playback-btn-rewind::after { + border-color: transparent var(--text-primary) transparent transparent; + } + + .playback-btn-ff::before, + .playback-btn-ff::after { + border-color: transparent transparent transparent var(--text-primary); + } + + .playback-btn-start::before { + background-color: var(--text-primary); + } + + .playback-btn-start::after { + border-color: transparent var(--text-primary) transparent transparent; + } + + .playback-btn-end::before { + border-color: transparent transparent transparent var(--text-primary); + } + + .playback-btn-end::after { + background-color: var(--text-primary); + } + + .playback-btn-record:disabled::before { + background-color: var(--surface-light); + } + + /* Start screen dark mode */ + .start-screen-file-item { + background: var(--surface-light); + border-color: var(--border-dark); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + } + + .start-screen-file-item.clickable:hover { + background-color: var(--surface-dark); + border-color: var(--link-hover); + } + + .focus-card { + background: var(--surface-light); + border-color: var(--border-dark); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); + } + + .focus-card:hover { + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.4); + border-color: var(--link-hover); + } + + .focus-card-icon-container { + background: var(--surface); + border-color: var(--link-color); + } + + .focus-card-icon { + color: var(--text-primary); + } +} + +/* Time Display */ +.time-display { + display: inline-flex; + align-items: center; + gap: 8px; + background: linear-gradient(to bottom, #3a3a3a, #2a2a2a); + border: 1px solid #555; + border-radius: 6px; + padding: 6px 12px; + margin-left: 10px; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3), 0 1px 0 rgba(255, 255, 255, 0.1); + transition: all 0.15s ease; +} + +.time-display:hover { + background: linear-gradient(to bottom, #4a4a4a, #3a3a3a); + border-color: #666; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3), 0 1px 0 rgba(255, 255, 255, 0.15); +} + +.time-display:active { + background: linear-gradient(to bottom, #2a2a2a, #1a1a1a); + box-shadow: inset 0 2px 3px rgba(0, 0, 0, 0.5); +} + +.time-value { + font-family: 'Courier New', monospace; + font-size: 18px; + font-weight: bold; + color: #f0f0f0; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); + min-width: 24px; + text-align: center; +} + +.time-fps-group { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.time-fps-clickable { + padding: 2px 6px; + border-radius: 3px; + transition: background-color 0.15s ease; + cursor: pointer; +} + +.time-fps-clickable:hover { + background-color: rgba(100, 150, 255, 0.3); +} + +.time-fps-clickable:hover .time-value { + text-decoration: underline; +} + +.time-label { + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #999; + margin-right: 4px; +} + +.time-label:last-child { + margin-right: 0; +} + +/* Dark mode time display adjustments */ +@media (prefers-color-scheme: dark) { + .time-display { + background: linear-gradient(to bottom, #3a3a3a, #2a2a2a); + border-color: #555; + } + + .time-value { + color: #f0f0f0; + } + + .time-label { + color: #999; + } +} + +/* ============================================ + NODE EDITOR STYLES + ============================================ */ + +/* Node editor container */ +#node-editor-container { + width: 100%; + height: 100%; + position: relative; + background: var(--node-bg); + user-select: none; +} + +/* Node editor header and breadcrumb */ +.node-editor-header { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 40px; + background: #2d2d2d; + border-bottom: 1px solid #3d3d3d; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; + z-index: 200; + user-select: none; +} + +.node-editor-header * { + user-select: none; +} + +.context-breadcrumb { + color: #ddd; + font-size: 14px; + font-weight: 500; + display: flex; + align-items: center; + gap: 8px; +} + +.template-name { + color: var(--node-primary); + font-weight: bold; +} + +.exit-template-btn { + margin-left: 12px; + padding: 4px 12px; + background: #3d3d3d; + border: 1px solid #4d4d4d; + border-radius: 3px; + color: #ddd; + font-size: 12px; + cursor: pointer; + transition: background 0.2s; +} + +.exit-template-btn:hover { + background: #4d4d4d; + border-color: #5d5d5d; +} + +.node-graph-clear-btn { + padding: 4px 12px; + background: #d32f2f; + border: 1px solid #b71c1c; + border-radius: 3px; + color: white; + font-size: 12px; + cursor: pointer; + transition: background 0.2s; +} + +.node-graph-clear-btn:hover { + background: #e53935; + border-color: #c62828; +} + +.exit-template-btn:active { + background: #5d5d5d; +} + +/* Node palette */ +.node-palette { + position: absolute; + top: 50px; + left: 10px; + background: #2d2d2d; + border: 1px solid #3d3d3d; + border-radius: 4px; + padding: 8px; + max-width: 200px; + max-height: calc(100% - 100px); + overflow-y: auto; + z-index: 100; + user-select: none; +} + +.node-palette * { + user-select: none; +} + +.node-palette h3 { + margin: 0 0 8px 0; + font-size: 12px; + color: #ccc; + text-transform: uppercase; +} + +.palette-header { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 8px; +} + +.palette-back-btn { + padding: 6px 8px; + background: #3d3d3d; + border: 1px solid #4d4d4d; + border-radius: 3px; + color: #ddd; + font-size: 12px; + cursor: pointer; + transition: background 0.2s; +} + +.palette-back-btn:hover { + background: #4d4d4d; +} + +.palette-header h3 { + margin: 0; +} + +.node-category-item { + padding: 8px 10px; + margin: 4px 0; + background: #3d3d3d; + border: 1px solid #5d5d5d; + border-radius: 3px; + cursor: pointer; + color: #ddd; + font-size: 13px; + font-weight: 500; + transition: background 0.2s, border-color 0.2s; +} + +.node-category-item:hover { + background: var(--node-border); + border-color: var(--node-primary); +} + +.node-category-item:active { + background: #5d5d5d; +} + +.node-palette-item { + padding: 6px 8px; + margin: 4px 0; + background: #3d3d3d; + border: 1px solid #4d4d4d; + border-radius: 3px; + cursor: pointer; + color: #ddd; + font-size: 13px; + transition: background 0.2s; +} + +.node-palette-item:hover { + background: #4d4d4d; +} + +.node-palette-item:active { + background: #5d5d5d; +} + +/* Palette search */ +.palette-search { + position: relative; + margin-bottom: 8px; +} + +.palette-content { + /* Content container for dynamic palette updates */ +} + +.palette-search-input { + width: 100%; + padding: 6px 28px 6px 8px; + background: var(--panel-bg); + border: 1px solid var(--node-border); + border-radius: 3px; + color: var(--text-primary); + font-size: 12px; + box-sizing: border-box; +} + +.palette-search-input:focus { + outline: none; + border-color: var(--node-primary); +} + +.palette-search-clear { + position: absolute; + right: 4px; + top: 50%; + transform: translateY(-50%); + width: 20px; + height: 20px; + background: #3d3d3d; + border: none; + border-radius: 3px; + color: var(--text-secondary); + font-size: 16px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + line-height: 1; +} + +.palette-search-clear:hover { + background: var(--node-border); + color: var(--text-primary); +} + +.no-results { + padding: 12px 8px; + color: var(--text-tertiary); + font-size: 12px; + text-align: center; + font-style: italic; +} + +.node-palette-item mark { + background: var(--success-dark); + color: var(--text-inverse); + padding: 1px 2px; + border-radius: 2px; +} + +/* Minimap */ +.node-minimap { + position: absolute; + bottom: 20px; + right: 20px; + width: 200px; + height: 150px; + background: rgba(40, 40, 40, 0.9); + border: 2px solid #555; + border-radius: 4px; + overflow: hidden; + pointer-events: all; + z-index: 100; +} + +#minimap-canvas { + width: 100%; + height: 100%; + display: block; +} + +.minimap-viewport { + position: absolute; + border: 2px solid #4CAF50; + background: rgba(76, 175, 80, 0.1); + pointer-events: none; + box-sizing: border-box; +} + +/* Node content styling */ +.node-content { + padding: 8px; + min-width: 180px; +} + +/* Wider content for nodes with sample layers */ +.node-content:has(.sample-layers-container) { + min-width: 280px; +} + +/* Wider nodes for nodes with sample layers */ +.drawflow .drawflow-node:has(.sample-layers-container) { + width: 296px !important; /* Fixed width to prevent dragging issues with table layout */ + min-width: 296px !important; /* 280px content + 8px padding on each side */ +} + +/* Expanded VoiceAllocator node */ +.drawflow .drawflow-node.expanded { + background: rgba(60, 60, 80, 0.95) !important; + border: 2px solid #7c7cff !important; + box-shadow: 0 0 20px rgba(124, 124, 255, 0.4); +} + +.drawflow .drawflow-node.expanded .node-content { + display: flex; + flex-direction: column; + height: 100%; +} + +.drawflow .drawflow-node.expanded .voice-allocator-contents { + flex: 1; + background: rgba(40, 40, 50, 0.8); + border-radius: 4px; + margin-top: 8px; + padding: 8px; + position: relative; + overflow: auto; +} + +/* Child nodes (inside VoiceAllocator) */ +.drawflow .drawflow-node.child-node { + opacity: 0.9; + border: 1px solid var(--node-child) !important; + box-shadow: 0 2px 8px rgba(90, 90, 170, 0.3); + z-index: 10; +} + +.drawflow .drawflow-node.child-node .node-title { + font-size: 11px; +} + +/* Template nodes (non-deletable I/O nodes) */ +.drawflow .drawflow-node.template-node { + border: 2px solid var(--node-template) !important; + background: rgba(157, 78, 221, 0.15) !important; + box-shadow: 0 0 12px rgba(157, 78, 221, 0.4); + pointer-events: auto; + cursor: default; +} + +.drawflow .drawflow-node.template-node .node-title { + color: var(--node-template-light); + font-weight: bold; +} + +.node-title { + font-weight: bold; + font-size: 13px; + margin-bottom: 6px; + color: #fff; + text-align: center; +} + +.node-info { + font-size: 11px; + color: #999; + text-align: center; + padding: 4px 0; +} + +.node-param { + margin: 0; + margin-top: 8px; /* Space between parameters */ +} + +.node-param:first-of-type { + margin-top: 0; /* No extra space after node title */ +} + +.node-param label { + display: block; + font-size: 10px; + color: #ccc; + margin-bottom: 2px; /* Tight spacing between label and its slider */ +} + +.node-slider { + width: calc(100% - 8px); + max-width: 140px; + height: 3px; + -webkit-appearance: none; + appearance: none; + background: #4d4d4d; + outline: none; + border-radius: 2px; +} + +.node-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 12px; + height: 12px; + background: #4CAF50; + cursor: pointer; + border-radius: 50%; +} + +.node-slider::-moz-range-thumb { + width: 12px; + height: 12px; + background: #4CAF50; + cursor: pointer; + border-radius: 50%; + border: none; +} + +/* Signal Type Connectors */ + +/* Audio ports - Blue circles (matches audio clips) */ +.drawflow .drawflow-node .input.connector-audio, +.drawflow .drawflow-node .output.connector-audio { + width: 14px !important; + height: 14px !important; + border-radius: 50% !important; + background: #2196F3 !important; + border: 2px solid #1565C0 !important; +} + +.drawflow .drawflow-node .input.connector-audio:hover, +.drawflow .drawflow-node .output.connector-audio:hover { + background: #42A5F5 !important; + border: 2px solid #1976D2 !important; + border-radius: 50% !important; +} + +/* MIDI ports - Green squares (matches MIDI clips) */ +.drawflow .drawflow-node .input.connector-midi, +.drawflow .drawflow-node .output.connector-midi { + width: 14px !important; + height: 14px !important; + border-radius: 2px !important; + background: #4CAF50 !important; + border: 2px solid #2E7D32 !important; +} + +.drawflow .drawflow-node .input.connector-midi:hover, +.drawflow .drawflow-node .output.connector-midi:hover { + background: #66BB6A !important; + border: 2px solid #388E3C !important; + border-radius: 2px !important; +} + +/* CV ports - Orange diamonds */ +.drawflow .drawflow-node .input.connector-cv, +.drawflow .drawflow-node .output.connector-cv { + width: 12px !important; + height: 12px !important; + background: #FF9800 !important; + border: 2px solid #E65100 !important; + border-radius: 0 !important; + transform: rotate(45deg) !important; +} + +.drawflow .drawflow-node .input.connector-cv:hover, +.drawflow .drawflow-node .output.connector-cv:hover { + background: #FFA726 !important; + border: 2px solid #EF6C00 !important; + border-radius: 0 !important; + transform: rotate(45deg) !important; +} + +/* Connection line styling - Override Drawflow defaults */ +.drawflow .connection .main-path { + stroke-width: 3px; +} + +.connection-audio .main-path { + stroke: #2196F3 !important; + stroke-width: 4px !important; +} + +.connection-midi .main-path { + stroke: #4CAF50 !important; + stroke-width: 3px !important; + stroke-dasharray: 8, 4 !important; +} + +.connection-cv .main-path { + stroke: #FF9800 !important; + stroke-width: 2px !important; +} + +/* Connection insertion highlight */ +.connection-insertion-highlight .main-path { + stroke: #FFD700 !important; + stroke-width: 8px !important; + stroke-dasharray: none !important; + filter: drop-shadow(0 0 12px #FFD700) !important; +} + +.connection-insertion-highlight { + z-index: 9999 !important; +} + +/* Port label text styling - position labels away from connectors */ +.drawflow .drawflow-node .input > span, +.drawflow .drawflow-node .output > span { + font-size: 9px; + color: #999; + pointer-events: none; + position: absolute; + line-height: 20px; + top: 0; +} + +/* Input labels - position to the right of the connector */ +.drawflow .drawflow-node .input > span { + left: 24px; +} + +/* Output labels - position to the left of the connector */ +.drawflow .drawflow-node .output > span { + right: 24px; +} + +/* Node styling overrides for Drawflow */ +.drawflow .drawflow-node { + background: var(--node-bg) !important; + border: 2px solid var(--node-border) !important; + border-radius: 6px !important; + color: var(--foreground-color) !important; + padding: 8px !important; + min-width: 196px !important; /* 180px content + 16px padding */ + width: auto !important; +} + +.drawflow .drawflow-node.selected { + border-color: var(--node-selected) !important; + box-shadow: 0 0 10px rgba(76, 175, 80, 0.5) !important; +} + +/* Error message styling */ +.node-editor-error { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: rgba(244, 67, 54, 0.9); + color: white; + padding: 12px 20px; + border-radius: 4px; + font-size: 14px; + z-index: 200; + animation: fadeOut 3s forwards; +} + +@keyframes fadeOut { + 0%, 70% { opacity: 1; } + 100% { opacity: 0; } +} + +/* Preset Browser Pane Styling */ +.preset-browser-pane { + display: flex; + flex-direction: column; + height: 100%; + background: #1e1e1e; + color: #ddd; + overflow: hidden; +} + +.preset-browser-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + background: #252525; + border-bottom: 1px solid #3d3d3d; +} + +.preset-browser-header h3 { + margin: 0; + font-size: 16px; + font-weight: 500; + color: #fff; +} + +.preset-btn { + background: var(--success); + color: var(--white); + border: none; + padding: 6px 12px; + border-radius: 4px; + cursor: pointer; + font-size: 13px; + display: flex; + align-items: center; + gap: 6px; + transition: background 0.2s; +} + +.preset-btn:hover { + background: var(--success-hover); +} + +.preset-btn span { + font-size: 16px; +} + +.preset-filter { + padding: 12px 16px; + background: #252525; + border-bottom: 1px solid #3d3d3d; + display: flex; + gap: 8px; +} + +.preset-filter input, +.preset-filter select { + flex: 1; + background: #1e1e1e; + color: #ddd; + border: 1px solid #3d3d3d; + padding: 6px 10px; + border-radius: 4px; + font-size: 13px; +} + +.preset-filter input:focus, +.preset-filter select:focus { + outline: none; + border-color: #4CAF50; +} + +.preset-categories { + flex: 1; + overflow-y: auto; + padding: 12px; +} + +.preset-category { + margin-bottom: 24px; +} + +.preset-category h4 { + margin: 0 0 12px 0; + font-size: 13px; + font-weight: 600; + color: #999; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.preset-list { + display: flex; + flex-direction: column; + gap: 8px; + user-select: none; +} + +.preset-item { + background: #252525; + border: 1px solid #3d3d3d; + border-radius: 4px; + padding: 8px 10px; + cursor: pointer; + transition: all 0.2s; + margin-bottom: 4px; +} + +.preset-item:hover { + background: var(--node-bg); + border-color: var(--success); +} + +.preset-item.selected { + background: var(--node-bg); + border-color: var(--node-primary); + padding: 10px 12px; +} + +.preset-item-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.preset-name { + font-size: 13px; + font-weight: 500; + color: #fff; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.preset-item.selected .preset-name { + font-size: 14px; + white-space: normal; +} + +.preset-load-btn { + background: var(--success); + border: none; + color: var(--white); + cursor: pointer; + font-size: 11px; + padding: 4px 8px; + border-radius: 3px; + transition: background 0.2s; + display: none; +} + +.preset-item.selected .preset-load-btn { + display: block; +} + +.preset-load-btn:hover { + background: var(--success-hover); +} + +.preset-delete-btn { + background: transparent; + border: none; + color: var(--error); + cursor: pointer; + font-size: 16px; + padding: 2px 6px; + border-radius: 3px; + transition: background 0.2s; + display: none; +} + +.preset-item.selected .preset-delete-btn { + display: block; +} + +.preset-delete-btn:hover { + background: rgba(244, 67, 54, 0.2); +} + +.preset-details { + display: none; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid #3d3d3d; +} + +.preset-item.selected .preset-details { + display: block; +} + +.preset-description { + font-size: 12px; + color: #999; + margin-bottom: 6px; + line-height: 1.4; +} + +.preset-tags { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-bottom: 4px; +} + +.preset-tag { + background: #3d3d3d; + color: #aaa; + font-size: 10px; + padding: 2px 6px; + border-radius: 3px; + text-transform: lowercase; +} + +.preset-author { + font-size: 11px; + color: #777; + font-style: italic; +} + +.preset-loading, +.preset-empty, +.preset-error { + padding: 20px; + text-align: center; + color: #777; + font-size: 13px; +} + +.preset-error { + color: var(--error); +} + +/* Modal Dialog for Save Preset */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; +} + +.modal-dialog { + background: #252525; + border: 1px solid #3d3d3d; + border-radius: 6px; + padding: 24px; + min-width: 400px; + max-width: 500px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); +} + +.modal-dialog h3 { + margin: 0 0 20px 0; + font-size: 18px; + color: #fff; +} + +.form-group { + margin-bottom: 16px; +} + +.form-group label { + display: block; + margin-bottom: 6px; + font-size: 13px; + color: #aaa; + font-weight: 500; +} + +.form-group input[type="text"], +.form-group input[type="number"], +.form-group textarea { + width: 100%; + background: #1e1e1e; + color: #ddd; + border: 1px solid #3d3d3d; + padding: 8px 10px; + border-radius: 4px; + font-size: 13px; + font-family: inherit; + box-sizing: border-box; +} + +.form-group input[type="checkbox"] { + width: auto; + margin-right: 8px; + cursor: pointer; +} + +.form-group label:has(input[type="checkbox"]) { + display: flex; + align-items: center; + cursor: pointer; + color: #ddd; +} + +.form-group input:focus, +.form-group textarea:focus { + outline: none; + border-color: #4CAF50; +} + +.form-group textarea { + resize: vertical; + min-height: 60px; +} + +.form-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 20px; +} + +.btn-cancel, +.btn-primary { + padding: 8px 16px; + border-radius: 4px; + font-size: 13px; + cursor: pointer; + border: none; + font-weight: 500; + transition: background 0.2s; +} + +.btn-cancel { + background: #3d3d3d; + color: #ddd; +} + +.btn-cancel:hover { + background: #4d4d4d; +} + +.btn-primary { + background: var(--success); + color: var(--white); +} + +.btn-primary:hover { + background: var(--success-hover); +} + +/* Sample layer list styles */ +.sample-layers-container { + margin-top: 4px; + max-height: 120px; + overflow-y: auto; + overflow-x: hidden; + border: 1px solid #444; + border-radius: 3px; + background: #2a2a2a; + padding-right: 4px; /* Space to prevent scrollbar overlap */ +} + +.sample-layers-table { + width: 100%; + font-size: 10px; + border-collapse: collapse; + table-layout: fixed; +} + +.sample-layers-table thead { + background: #333; + position: sticky; + top: 0; + z-index: 1; +} + +.sample-layers-table th { + padding: 4px 3px; + text-align: left; + font-weight: 600; + color: #aaa; + border-bottom: 1px solid #444; + font-size: 9px; +} + +.sample-layers-table th:nth-child(1) { width: 24%; } /* File */ +.sample-layers-table th:nth-child(2) { width: 20%; } /* Range */ +.sample-layers-table th:nth-child(3) { width: 10%; } /* Root */ +.sample-layers-table th:nth-child(4) { width: 14%; } /* Vel */ +.sample-layers-table th:nth-child(5) { width: 32%; } /* Actions - wider to avoid scrollbar */ + +.sample-layers-table td { + padding: 3px; + border-bottom: 1px solid #333; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.sample-layers-table tr:hover { + background: #3a3a3a; +} + +.sample-layer-filename { + overflow: hidden; + text-overflow: ellipsis; +} + +.sample-layer-actions { + display: flex; + gap: 3px; +} + +.btn-edit-layer, +.btn-delete-layer { + padding: 1px 6px; + font-size: 9px; + background: #555; + border: none; + border-radius: 2px; + color: white; + cursor: pointer; + white-space: nowrap; +} + +.btn-edit-layer:hover { + background: #666; +} + +.btn-delete-layer { + background: var(--error-alt); +} + +.btn-delete-layer:hover { + background: var(--error-alt-hover); +} + +.sample-layers-empty { + padding: 20px; + font-size: 10px; + color: #888; + text-align: center; +} + +.form-group-inline { + display: flex; + gap: 8px; + align-items: center; +} + +.form-group-inline > div { + flex: 1; +} + +.form-group-inline > span { + margin-top: 12px; +} + +.form-note-name { + font-size: 10px; + color: #666; + margin-top: 2px; +} + +/* Start Screen Styles */ +.start-screen { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: var(--surface); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + z-index: 10000; +} + +.start-screen-title { + color: var(--text-primary); + font-size: 3em; + margin: 40px 0; + font-weight: 600; +} + +.start-screen-content { + display: flex; + gap: 60px; + max-width: 1200px; + width: 90%; + align-items: flex-start; +} + +.start-screen-left-panel { + flex: 1; + display: flex; + flex-direction: column; + gap: 30px; +} + +.start-screen-section { + display: flex; + flex-direction: column; +} + +.start-screen-section-title { + color: var(--text-primary); + font-size: 1.3em; + margin-bottom: 10px; +} + +.start-screen-file-item { + color: var(--text-secondary); + font-size: 1.1em; + padding: 12px; + background: var(--surface-light); + border: 1px solid var(--shadow); + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + transition: all 0.2s; +} + +.start-screen-file-item.clickable { + cursor: pointer; +} + +.start-screen-file-item.clickable:hover { + background-color: var(--surface-dark); + border-color: var(--button-hover); +} + +.start-screen-recent-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.start-screen-right-panel { + flex: 2; + display: flex; + flex-direction: column; +} + +.start-screen-heading { + color: var(--text-primary); + font-size: 2em; + margin-bottom: 30px; + text-align: center; +} + +.start-screen-focus-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 30px; + justify-items: center; +} + +.focus-card { + width: 180px; + padding: 24px; + background: var(--surface-light); + border: 2px solid var(--shadow); + border-radius: 12px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + cursor: pointer; + transition: all 0.3s; + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; +} + +.focus-card:hover { + transform: translateY(-4px); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2); + border-color: var(--button-hover); +} + +.focus-card-icon-container { + width: 120px; + height: 120px; + display: flex; + align-items: center; + justify-content: center; + border: 3px solid var(--shadow); + border-radius: 8px; + background: var(--surface); +} + +.focus-card-icon { + width: 80px; + height: 80px; + color: var(--text-primary); +} + +.focus-card-label { + color: var(--text-primary); + font-size: 1.2em; + font-weight: 600; +} diff --git a/src/timeline.js b/src/timeline.js new file mode 100644 index 0000000..e1d2268 --- /dev/null +++ b/src/timeline.js @@ -0,0 +1,769 @@ +// Timeline V2 - New timeline implementation for AnimationData curve-based system + +import { backgroundColor, foregroundColor, shadow, labelColor, scrubberColor } from "./styles.js" + +/** + * TimelineState - Global state for timeline display and interaction + */ +class TimelineState { + constructor(framerate = 24, bpm = 120, timeSignature = { numerator: 4, denominator: 4 }) { + // Time format settings + this.timeFormat = 'frames' // 'frames' | 'seconds' | 'measures' + this.framerate = framerate + this.bpm = bpm // Beats per minute for measures mode + this.timeSignature = timeSignature // Time signature for measures mode (e.g., {numerator: 4, denominator: 4} or {numerator: 6, denominator: 8}) + + // Zoom and viewport + this.pixelsPerSecond = 100 // Zoom level - how many pixels per second of animation + this.viewportStartTime = 0 // Horizontal scroll position (in seconds) + + // Playhead + this.currentTime = 0 // Current time (in seconds) + + // Ruler settings + this.rulerHeight = 30 // Height of time ruler in pixels + + // Snapping (Phase 5) + this.snapToFrames = true // Whether to snap keyframes to frame boundaries (default: on) + } + + /** + * Convert time (seconds) to pixel position + */ + timeToPixel(time) { + return (time - this.viewportStartTime) * this.pixelsPerSecond + } + + /** + * Convert pixel position to time (seconds) + */ + pixelToTime(pixel) { + return (pixel / this.pixelsPerSecond) + this.viewportStartTime + } + + /** + * Convert time (seconds) to frame number + */ + timeToFrame(time) { + return Math.floor(time * this.framerate) + } + + /** + * Convert frame number to time (seconds) + */ + frameToTime(frame) { + return frame / this.framerate + } + + /** + * Convert time (seconds) to measure position + * Returns {measure, beat, tick} where tick is subdivision of beat (0-999) + */ + timeToMeasure(time) { + const beatsPerSecond = this.bpm / 60 + const totalBeats = time * beatsPerSecond + const beatsPerMeasure = this.timeSignature.numerator + const measure = Math.floor(totalBeats / beatsPerMeasure) + 1 // Measures are 1-indexed + const beat = Math.floor(totalBeats % beatsPerMeasure) + 1 // Beats are 1-indexed + const tick = Math.floor((totalBeats % 1) * 1000) // Ticks are 0-999 + return { measure, beat, tick } + } + + /** + * Convert measure position to time (seconds) + */ + measureToTime(measure, beat = 1, tick = 0) { + const beatsPerMeasure = this.timeSignature.numerator + const totalBeats = (measure - 1) * beatsPerMeasure + (beat - 1) + (tick / 1000) + const beatsPerSecond = this.bpm / 60 + return totalBeats / beatsPerSecond + } + + /** + * Calculate appropriate ruler interval based on zoom level + * Returns interval in seconds that gives ~50-100px spacing + */ + getRulerInterval() { + const targetPixelSpacing = 75 // Target pixels between major ticks + const timeSpacing = targetPixelSpacing / this.pixelsPerSecond // In seconds + + // Standard interval options (in seconds) + const intervals = [ + 0.01, 0.02, 0.05, // 10ms, 20ms, 50ms + 0.1, 0.2, 0.5, // 100ms, 200ms, 500ms + 1, 2, 5, // 1s, 2s, 5s + 10, 20, 30, 60, // 10s, 20s, 30s, 1min + 120, 300, 600 // 2min, 5min, 10min + ] + + // Find closest interval + let bestInterval = intervals[0] + let bestDiff = Math.abs(timeSpacing - bestInterval) + + for (let interval of intervals) { + const diff = Math.abs(timeSpacing - interval) + if (diff < bestDiff) { + bestDiff = diff + bestInterval = interval + } + } + + return bestInterval + } + + /** + * Calculate appropriate ruler interval for frame mode + * Returns interval in frames that gives ~50-100px spacing + */ + getRulerIntervalFrames() { + const targetPixelSpacing = 75 + const pixelsPerFrame = this.pixelsPerSecond / this.framerate + const frameSpacing = targetPixelSpacing / pixelsPerFrame + + // Standard frame intervals + const intervals = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000] + + // Find closest interval + let bestInterval = intervals[0] + let bestDiff = Math.abs(frameSpacing - bestInterval) + + for (let interval of intervals) { + const diff = Math.abs(frameSpacing - interval) + if (diff < bestDiff) { + bestDiff = diff + bestInterval = interval + } + } + + return bestInterval + } + + /** + * Calculate appropriate ruler interval for measures mode + * Returns interval in beats that gives ~50-100px spacing + */ + getRulerIntervalBeats() { + const targetPixelSpacing = 75 + const beatsPerSecond = this.bpm / 60 + const pixelsPerBeat = this.pixelsPerSecond / beatsPerSecond + const beatSpacing = targetPixelSpacing / pixelsPerBeat + + const beatsPerMeasure = this.timeSignature.numerator + // Standard beat intervals: 1 beat, 2 beats, 1 measure, 2 measures, 4 measures, etc. + const intervals = [1, 2, beatsPerMeasure, beatsPerMeasure * 2, beatsPerMeasure * 4, beatsPerMeasure * 8, beatsPerMeasure * 16] + + // Find closest interval + let bestInterval = intervals[0] + let bestDiff = Math.abs(beatSpacing - bestInterval) + + for (let interval of intervals) { + const diff = Math.abs(beatSpacing - interval) + if (diff < bestDiff) { + bestDiff = diff + bestInterval = interval + } + } + + return bestInterval + } + + /** + * Format time for display based on current format setting + */ + formatTime(time) { + if (this.timeFormat === 'frames') { + return `${this.timeToFrame(time)}` + } else if (this.timeFormat === 'seconds') { + const minutes = Math.floor(time / 60) + const seconds = Math.floor(time % 60) + const ms = Math.floor((time % 1) * 10) + + if (minutes > 0) { + return `${minutes}:${seconds.toString().padStart(2, '0')}` + } else { + return `${seconds}.${ms}s` + } + } else if (this.timeFormat === 'measures') { + const { measure, beat } = this.timeToMeasure(time) + return `${measure}.${beat}` + } + return `${time.toFixed(2)}` + } + + /** + * Zoom in (increase pixelsPerSecond) + */ + zoomIn(factor = 1.5) { + this.pixelsPerSecond *= factor + // Clamp to reasonable range + this.pixelsPerSecond = Math.min(this.pixelsPerSecond, 10000) // Max zoom + } + + /** + * Zoom out (decrease pixelsPerSecond) + */ + zoomOut(factor = 1.5) { + this.pixelsPerSecond /= factor + // Clamp to reasonable range + this.pixelsPerSecond = Math.max(this.pixelsPerSecond, 10) // Min zoom + } + + /** + * Snap time to nearest frame boundary (Phase 5) + */ + snapTime(time) { + if (!this.snapToFrames) { + return time + } + const frame = Math.round(time * this.framerate) + return frame / this.framerate + } +} + +/** + * TimeRuler - Widget for displaying time ruler with adaptive intervals + */ +class TimeRuler { + constructor(timelineState) { + this.state = timelineState + this.height = timelineState.rulerHeight + } + + /** + * Draw the time ruler + */ + draw(ctx, width) { + ctx.save() + + // Background + ctx.fillStyle = backgroundColor + ctx.fillRect(0, 0, width, this.height) + + // Calculate visible time range + const startTime = this.state.viewportStartTime + const endTime = this.state.pixelToTime(width) + + // Draw tick marks and labels based on format + if (this.state.timeFormat === 'frames') { + const interval = this.state.getRulerIntervalFrames() // In frames + this.drawFrameTicks(ctx, width, interval, startTime, endTime) + } else if (this.state.timeFormat === 'measures') { + const interval = this.state.getRulerIntervalBeats() // In beats + this.drawMeasureTicks(ctx, width, interval, startTime, endTime) + } else { + const interval = this.state.getRulerInterval() // In seconds + this.drawSecondTicks(ctx, width, interval, startTime, endTime) + } + + // Draw playhead (current time indicator) + this.drawPlayhead(ctx, width) + + ctx.restore() + } + + /** + * Draw tick marks for frame mode + */ + drawFrameTicks(ctx, width, interval, startTime, endTime) { + const startFrame = Math.floor(this.state.timeToFrame(startTime) / interval) * interval + const endFrame = Math.ceil(this.state.timeToFrame(endTime) / interval) * interval + + ctx.fillStyle = labelColor + ctx.font = '11px sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'top' + + for (let frame = startFrame; frame <= endFrame; frame += interval) { + const time = this.state.frameToTime(frame) + const x = this.state.timeToPixel(time) + + if (x < 0 || x > width) continue + + // Major tick + ctx.strokeStyle = foregroundColor + ctx.lineWidth = 1 + ctx.beginPath() + ctx.moveTo(x, this.height - 10) + ctx.lineTo(x, this.height) + ctx.stroke() + + // Label + ctx.fillText(frame.toString(), x, 2) + + // Minor ticks (subdivisions) + const minorInterval = interval / 5 + if (minorInterval >= 1) { + for (let i = 1; i < 5; i++) { + const minorFrame = frame + (minorInterval * i) + const minorTime = this.state.frameToTime(minorFrame) + const minorX = this.state.timeToPixel(minorTime) + + if (minorX < 0 || minorX > width) continue + + ctx.strokeStyle = shadow + ctx.beginPath() + ctx.moveTo(minorX, this.height - 5) + ctx.lineTo(minorX, this.height) + ctx.stroke() + } + } + } + } + + /** + * Draw tick marks for second mode + */ + drawSecondTicks(ctx, width, interval, startTime, endTime) { + const startTick = Math.floor(startTime / interval) * interval + const endTick = Math.ceil(endTime / interval) * interval + + ctx.fillStyle = labelColor + ctx.font = '11px sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'top' + + for (let time = startTick; time <= endTick; time += interval) { + const x = this.state.timeToPixel(time) + + if (x < 0 || x > width) continue + + // Major tick + ctx.strokeStyle = foregroundColor + ctx.lineWidth = 1 + ctx.beginPath() + ctx.moveTo(x, this.height - 10) + ctx.lineTo(x, this.height) + ctx.stroke() + + // Label + ctx.fillText(this.state.formatTime(time), x, 2) + + // Minor ticks (subdivisions) + const minorInterval = interval / 5 + for (let i = 1; i < 5; i++) { + const minorTime = time + (minorInterval * i) + const minorX = this.state.timeToPixel(minorTime) + + if (minorX < 0 || minorX > width) continue + + ctx.strokeStyle = shadow + ctx.beginPath() + ctx.moveTo(minorX, this.height - 5) + ctx.lineTo(minorX, this.height) + ctx.stroke() + } + } + } + + /** + * Draw tick marks for measures mode + */ + drawMeasureTicks(ctx, width, interval, startTime, endTime) { + const beatsPerSecond = this.state.bpm / 60 + const beatsPerMeasure = this.state.timeSignature.numerator + + // Always draw individual beats, regardless of interval + const startBeat = Math.floor(startTime * beatsPerSecond) + const endBeat = Math.ceil(endTime * beatsPerSecond) + + ctx.fillStyle = labelColor + ctx.font = '11px sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'top' + + // Draw all beats + for (let beat = startBeat; beat <= endBeat; beat++) { + const time = beat / beatsPerSecond + const x = this.state.timeToPixel(time) + + if (x < 0 || x > width) continue + + // Determine position within the measure + const beatInMeasure = beat % beatsPerMeasure + const isMeasureBoundary = beatInMeasure === 0 + const isEvenBeatInMeasure = (beatInMeasure % 2) === 0 + + // Determine tick style based on position + let opacity, tickHeight + if (isMeasureBoundary) { + // Measure boundary: full opacity, tallest + opacity = 1.0 + tickHeight = 12 + } else if (isEvenBeatInMeasure) { + // Even beat within measure: half opacity, medium height + opacity = 0.5 + tickHeight = 8 + } else { + // Odd beat within measure: quarter opacity, shortest + opacity = 0.25 + tickHeight = 5 + } + + // Draw tick with appropriate opacity + ctx.save() + ctx.globalAlpha = opacity + ctx.strokeStyle = foregroundColor + ctx.lineWidth = isMeasureBoundary ? 2 : 1 + ctx.beginPath() + ctx.moveTo(x, this.height - tickHeight) + ctx.lineTo(x, this.height) + ctx.stroke() + ctx.restore() + + // Determine if we're zoomed in enough to show individual beat labels + const pixelsPerBeat = this.state.pixelsPerSecond / beatsPerSecond + const beatFadeThreshold = 100 // Full opacity at 100px per beat + const beatFadeStart = 60 // Start fading in at 60px per beat + + // Calculate fade opacity for beat labels (0 to 1) + const beatLabelOpacity = Math.max(0, Math.min(1, (pixelsPerBeat - beatFadeStart) / (beatFadeThreshold - beatFadeStart))) + + // Calculate spacing-based fade for measure labels when zoomed out + const pixelsPerMeasure = pixelsPerBeat * beatsPerMeasure + + // Determine which measures to show based on spacing + const { measure: measureNumber } = this.state.timeToMeasure(time) + let showThisMeasure = false + let measureLabelOpacity = 1 + + const isEvery16th = (measureNumber - 1) % 16 === 0 + const isEvery4th = (measureNumber - 1) % 4 === 0 + + if (isEvery16th) { + // Always show every 16th measure when very zoomed out + showThisMeasure = true + if (pixelsPerMeasure < 20) { + // Fade in from 10-20px + measureLabelOpacity = Math.max(0, Math.min(1, (pixelsPerMeasure - 10) / 10)) + } else { + measureLabelOpacity = 1 + } + } else if (isEvery4th && pixelsPerMeasure >= 20) { + // Show every 4th measure when zoomed out but not too far + showThisMeasure = true + if (pixelsPerMeasure < 30) { + // Fade in from 20-30px + measureLabelOpacity = Math.max(0, Math.min(1, (pixelsPerMeasure - 20) / 10)) + } else { + measureLabelOpacity = 1 + } + } else if (pixelsPerMeasure >= 80) { + // Show all measures when zoomed in enough + showThisMeasure = true + if (pixelsPerMeasure < 100) { + // Fade in from 80-100px + measureLabelOpacity = Math.max(0, Math.min(1, (pixelsPerMeasure - 80) / 20)) + } else { + measureLabelOpacity = 1 + } + } + + // Label logic + if (isMeasureBoundary && showThisMeasure) { + // Measure boundaries: show just the measure number with fade + const { measure } = this.state.timeToMeasure(time) + ctx.save() + ctx.globalAlpha = measureLabelOpacity + ctx.fillText(measure.toString(), x, 2) + ctx.restore() + } else if (beatLabelOpacity > 0) { + // Zoomed in: show measure.beat for all beats with fade + ctx.save() + ctx.globalAlpha = beatLabelOpacity + ctx.fillText(this.state.formatTime(time), x, 2) + ctx.restore() + } + } + } + + /** + * Draw playhead (current time indicator) + */ + drawPlayhead(ctx, width) { + const x = this.state.timeToPixel(this.state.currentTime) + + // Only draw if playhead is visible + if (x < 0 || x > width) return + + ctx.strokeStyle = scrubberColor + ctx.lineWidth = 2 + ctx.beginPath() + ctx.moveTo(x, 0) + ctx.lineTo(x, this.height) + ctx.stroke() + + // Playhead handle (triangle at top) + ctx.fillStyle = scrubberColor + ctx.beginPath() + ctx.moveTo(x, 0) + ctx.lineTo(x - 6, 8) + ctx.lineTo(x + 6, 8) + ctx.closePath() + ctx.fill() + } + + /** + * Hit test for playhead dragging (no longer used, kept for potential future use) + */ + hitTestPlayhead(x, y) { + const playheadX = this.state.timeToPixel(this.state.currentTime) + const distance = Math.abs(x - playheadX) + + // 10px tolerance for hitting playhead + return distance < 10 && y >= 0 && y <= this.height + } + + /** + * Handle mouse down - start dragging playhead + */ + mousedown(x, y) { + // Clicking anywhere in the ruler moves the playhead there + this.state.currentTime = this.state.pixelToTime(x) + this.state.currentTime = Math.max(0, this.state.currentTime) + this.draggingPlayhead = true + return true + } + + /** + * Handle mouse move - drag playhead + */ + mousemove(x, y) { + if (this.draggingPlayhead) { + const newTime = this.state.pixelToTime(x); + this.state.currentTime = Math.max(0, newTime); + return true + } + return false + } + + /** + * Handle mouse up - stop dragging + */ + mouseup(x, y) { + if (this.draggingPlayhead) { + this.draggingPlayhead = false + return true + } + return false + } +} + +/** + * TrackHierarchy - Builds and manages hierarchical track structure from GraphicsObject + * Phase 2: Track hierarchy display + */ +class TrackHierarchy { + constructor() { + this.tracks = [] // Flat list of tracks for rendering + this.trackHeight = 30 // Default track height in pixels + } + + /** + * Build track list from GraphicsObject layers + * Creates a flattened list of tracks for rendering, maintaining hierarchy info + */ + buildTracks(graphicsObject) { + this.tracks = [] + + if (!graphicsObject || !graphicsObject.children) { + return + } + + // Iterate through layers (GraphicsObject.children are Layers) + for (let layer of graphicsObject.children) { + // Determine layer type - check if it's a VideoLayer + const layerType = layer.type === 'video' ? 'video' : 'layer' + + // Add layer track + const layerTrack = { + type: layerType, + object: layer, + name: layer.name || 'Layer', + indent: 0, + collapsed: layer.collapsed || false, + visible: layer.visible !== false + } + this.tracks.push(layerTrack) + + // If layer is not collapsed, add its children + if (!layerTrack.collapsed) { + // Add child GraphicsObjects (nested groups) + if (layer.children) { + for (let child of layer.children) { + this.addObjectTrack(child, 1) + } + } + + // Add shapes (grouped by shapeId for shape tweening) + if (layer.shapes) { + // Group shapes by shapeId + const shapesByShapeId = new Map(); + for (let shape of layer.shapes) { + if (!shapesByShapeId.has(shape.shapeId)) { + shapesByShapeId.set(shape.shapeId, []); + } + shapesByShapeId.get(shape.shapeId).push(shape); + } + + // Add one track per unique shapeId + for (let [shapeId, shapes] of shapesByShapeId) { + // Use the first shape as the representative for the track + this.addShapeTrack(shapes[0], 1, shapeId, shapes) + } + } + } + } + + // Add audio tracks (after visual layers) + if (graphicsObject.audioTracks) { + for (let audioTrack of graphicsObject.audioTracks) { + const audioTrackItem = { + type: 'audio', + object: audioTrack, + name: audioTrack.name || 'Audio', + indent: 0, + collapsed: audioTrack.collapsed || false, + visible: audioTrack.audible !== false + } + this.tracks.push(audioTrackItem) + } + } + } + + /** + * Recursively add object track and its children + */ + addObjectTrack(obj, indent) { + const track = { + type: 'object', + object: obj, + name: obj.name || obj.idx, + indent: indent, + collapsed: obj.trackCollapsed || false + } + this.tracks.push(track) + + // If object is not collapsed, add its children + if (!track.collapsed && obj.children) { + for (let layer of obj.children) { + // Nested object's layers + const nestedLayerTrack = { + type: 'layer', + object: layer, + name: layer.name || 'Layer', + indent: indent + 1, + collapsed: layer.collapsed || false, + visible: layer.visible !== false + } + this.tracks.push(nestedLayerTrack) + + if (!nestedLayerTrack.collapsed) { + // Add nested layer's children + if (layer.children) { + for (let child of layer.children) { + this.addObjectTrack(child, indent + 2) + } + } + if (layer.shapes) { + // Group shapes by shapeId + const shapesByShapeId = new Map(); + for (let shape of layer.shapes) { + if (!shapesByShapeId.has(shape.shapeId)) { + shapesByShapeId.set(shape.shapeId, []); + } + shapesByShapeId.get(shape.shapeId).push(shape); + } + + // Add one track per unique shapeId + for (let [shapeId, shapes] of shapesByShapeId) { + this.addShapeTrack(shapes[0], indent + 2, shapeId, shapes) + } + } + } + } + } + } + + /** + * Add shape track (grouped by shapeId for shape tweening) + */ + addShapeTrack(shape, indent, shapeId, shapes) { + const track = { + type: 'shape', + object: shape, // Representative shape for display + shapeId: shapeId, // The shared shapeId + shapes: shapes, // All shape versions with this shapeId + name: shape.constructor.name || 'Shape', + indent: indent + } + this.tracks.push(track) + } + + /** + * Calculate height for a specific track based on its curves mode (Phase 4) + */ + getTrackHeight(track) { + const baseHeight = this.trackHeight + + // Only objects, shapes, and audio tracks can have curves + if (track.type !== 'object' && track.type !== 'shape' && track.type !== 'audio') { + return baseHeight + } + + const obj = track.object + + // Calculate additional height needed for curves + if (obj.curvesMode === 'keyframe') { + // Phase 6: Minimized mode should be compact - no extra height + // Keyframes are overlaid on the segment bar + return baseHeight + } else if (obj.curvesMode === 'curve') { + // Use the object's curvesHeight property + return baseHeight + (obj.curvesHeight || 150) + 10 // +10 for padding + } + + return baseHeight + } + + /** + * Calculate total height needed for all tracks + */ + getTotalHeight() { + let totalHeight = 0 + for (let track of this.tracks) { + totalHeight += this.getTrackHeight(track) + } + return totalHeight + } + + /** + * Get track at a given Y position + */ + getTrackAtY(y) { + let currentY = 0 + for (let i = 0; i < this.tracks.length; i++) { + const track = this.tracks[i] + const trackHeight = this.getTrackHeight(track) + + if (y >= currentY && y < currentY + trackHeight) { + return track + } + + currentY += trackHeight + } + return null + } + + /** + * Get Y position for a specific track index (Phase 4) + */ + getTrackY(trackIndex) { + let y = 0 + for (let i = 0; i < trackIndex && i < this.tracks.length; i++) { + y += this.getTrackHeight(this.tracks[i]) + } + return y + } +} + +export { TimelineState, TimeRuler, TrackHierarchy } diff --git a/src/utils.js b/src/utils.js index d0c996c..9b57766 100644 --- a/src/utils.js +++ b/src/utils.js @@ -177,8 +177,9 @@ function floodFillRegion( fileHeight, context, debugPoints, - debugPaintbucket) { - + debugPaintbucket, + shapes) { + let halfEpsilon = epsilon/2 // Helper function to check if a point is near any curve in the shape @@ -201,8 +202,6 @@ function floodFillRegion( } return false; } - - const shapes = context.activeObject.currentFrame.shapes; const visited = new Set(); const stack = [startPoint]; const regionPoints = []; @@ -926,6 +925,48 @@ function deeploop(obj, callback) { } } +/** + * Calculate the shortest distance from a point to a line segment + * @param {number} px - Point x coordinate + * @param {number} py - Point y coordinate + * @param {number} x1 - Line segment start x + * @param {number} y1 - Line segment start y + * @param {number} x2 - Line segment end x + * @param {number} y2 - Line segment end y + * @returns {number} Distance from point to line segment + */ +function distanceToLineSegment(px, py, x1, y1, x2, y2) { + const A = px - x1; + const B = py - y1; + const C = x2 - x1; + const D = y2 - y1; + + const dot = A * C + B * D; + const lenSq = C * C + D * D; + let param = -1; + + if (lenSq !== 0) { + param = dot / lenSq; + } + + let xx, yy; + + if (param < 0) { + xx = x1; + yy = y1; + } else if (param > 1) { + xx = x2; + yy = y2; + } else { + xx = x1 + param * C; + yy = y1 + param * D; + } + + const dx = px - xx; + const dy = py - yy; + return Math.sqrt(dx * dx + dy * dy); +} + export { titleCase, getMousePositionFraction, @@ -960,5 +1001,6 @@ export { arraysAreEqual, getFileExtension, createModal, - deeploop + deeploop, + distanceToLineSegment }; \ No newline at end of file diff --git a/src/widgets.js b/src/widgets.js index 1664b09..176f317 100644 --- a/src/widgets.js +++ b/src/widgets.js @@ -1,5 +1,7 @@ -import { backgroundColor, foregroundColor, frameWidth, highlight, layerHeight, shade, shadow } from "./styles.js"; +import { backgroundColor, foregroundColor, frameWidth, highlight, layerHeight, shade, shadow, labelColor } from "./styles.js"; import { clamp, drawBorderedRect, drawCheckerboardBackground, hslToRgb, hsvToRgb, rgbToHex } from "./utils.js" +import { TimelineState, TimeRuler, TrackHierarchy } from "./timeline.js" +const { invoke } = window.__TAURI__.core function growBoundingBox(bboxa, bboxb) { bboxa.x.min = Math.min(bboxa.x.min, bboxb.x.min); @@ -46,7 +48,8 @@ class Widget { "mousedown", "mousemove", "mouseup", - "dblclick" + "dblclick", + "contextmenu" ] if (eventTypes.indexOf(eventType)!=-1) { if (typeof(this[eventType]) == "function") { @@ -500,7 +503,7 @@ class TimelineWindow extends ScrollableWindow { } } } - // } else if (layer instanceof AudioLayer) { + // } else if (layer instanceof AudioTrack) { } else if (layer.sounds) { // TODO: split waveform into chunks for (let i in layer.sounds) { @@ -512,10 +515,6113 @@ class TimelineWindow extends ScrollableWindow { } } mousedown(x, y) { - + } } - + +/** + * TimelineWindowV2 - New timeline widget using AnimationData curve-based system + * Phase 1: Time ruler with zoom-adaptive intervals and playhead + * Phase 2: Track hierarchy display + */ +class TimelineWindowV2 extends Widget { + constructor(x, y, context) { + super(x, y) + this.context = context + this.width = 800 + this.height = 400 + + // Track header column width (fixed on left side) + this.trackHeaderWidth = 150 + + // Create shared timeline state using config framerate + this.timelineState = new TimelineState( + context.config?.framerate || 24, + context.config?.bpm || 120, + context.config?.timeSignature || { numerator: 4, denominator: 4 } + ) + + // Create time ruler widget + this.ruler = new TimeRuler(this.timelineState) + + // Create track hierarchy manager + this.trackHierarchy = new TrackHierarchy() + + // Track if we're dragging playhead + this.draggingPlayhead = false + + // Vertical scroll offset for track hierarchy + this.trackScrollOffset = 0 + + // Phase 5: Curve interaction state + this.draggingKeyframe = null // {curve, keyframe, track} + this.selectedKeyframes = new Set() // Set of selected keyframe objects for multi-select + + // Hover state for showing keyframe values + this.hoveredKeyframe = null // {keyframe, x, y} - keyframe being hovered over and its screen position + + // Hidden curves (Phase 6) - Set of curve parameter names + this.hiddenCurves = new Set() + + // Phase 6: Segment dragging state + this.draggingSegment = null // {track, initialMouseTime, segmentStartTime, animationData} + + // Phase 6: Segment edge dragging state + this.draggingEdge = null // {track, edge: 'left'|'right', keyframe, animationData, curveName, initialTime} + + // Phase 6: Tangent handle dragging state + this.draggingTangent = null // {keyframe, handle: 'in'|'out', curve, track, initialEase} + + // Phase 6: Keyframe clipboard + this.keyframeClipboard = null // {keyframes: [{keyframe, curve, relativeTime}], baseTime} + + // Selected audio track (for recording) + this.selectedTrack = null + + // Cache for automation node names (maps "trackId:nodeId" -> friendly name) + this.automationNameCache = new Map() + } + + /** + * Quantize a time value to the nearest beat/measure division based on zoom level. + * Only applies when in measures mode and snapping is enabled. + * @param {number} time - The time value to quantize (in seconds) + * @returns {number} - The quantized time value + */ + quantizeTime(time) { + // Only quantize in measures mode with snapping enabled + if (this.timelineState.timeFormat !== 'measures' || !this.timelineState.snapToFrames) { + return time + } + + const bpm = this.timelineState.bpm || 120 + const beatsPerSecond = bpm / 60 + const beatDuration = 1 / beatsPerSecond // Duration of one beat in seconds + const beatsPerMeasure = this.timelineState.timeSignature?.numerator || 4 + + // Calculate beat width in pixels + const beatWidth = beatDuration * this.timelineState.pixelsPerSecond + + // Base threshold for zoom level detection (adjustable) + const zoomThreshold = 30 + + // Determine quantization level based on zoom (beat width in pixels) + // When zoomed out (small beat width), quantize to measures + // When zoomed in (large beat width), quantize to smaller divisions + let quantizeDuration + if (beatWidth < zoomThreshold * 0.5) { + // Very zoomed out: quantize to whole measures + quantizeDuration = beatDuration * beatsPerMeasure + } else if (beatWidth < zoomThreshold) { + // Zoomed out: quantize to half measures (2 beats in 4/4) + quantizeDuration = beatDuration * (beatsPerMeasure / 2) + } else if (beatWidth < zoomThreshold * 2) { + // Medium zoom: quantize to beats + quantizeDuration = beatDuration + } else if (beatWidth < zoomThreshold * 4) { + // Zoomed in: quantize to half beats (eighth notes in 4/4) + quantizeDuration = beatDuration / 2 + } else { + // Very zoomed in: quantize to quarter beats (sixteenth notes in 4/4) + quantizeDuration = beatDuration / 4 + } + + // Round time to nearest quantization unit + return Math.round(time / quantizeDuration) * quantizeDuration + } + + draw(ctx) { + ctx.save() + + // Update time display if it exists + if (this.context.updateTimeDisplay) { + this.context.updateTimeDisplay(); + } + + // Draw background + ctx.fillStyle = backgroundColor + ctx.fillRect(0, 0, this.width, this.height) + + // Draw time ruler at top, offset by track header width + ctx.save() + ctx.translate(this.trackHeaderWidth, 0) + this.ruler.draw(ctx, this.width - this.trackHeaderWidth) + ctx.restore() + + // Phase 2: Build and draw track hierarchy + if (this.context.activeObject) { + this.trackHierarchy.buildTracks(this.context.activeObject) + this.drawTrackHeaders(ctx) + this.drawTracks(ctx) + + // Phase 3: Draw segments + this.drawSegments(ctx) + + // Phase 4: Draw curves + this.drawCurves(ctx) + } + + // Draw curve mode button tooltip if hovering + if (this.hoveredCurveModeButton) { + const text = this.hoveredCurveModeButton.modeName + + // Measure text to size the tooltip + ctx.font = '11px sans-serif' + const textMetrics = ctx.measureText(text) + const textWidth = textMetrics.width + const tooltipPadding = 4 + const tooltipWidth = textWidth + tooltipPadding * 2 + const tooltipHeight = 16 + + // Position tooltip near mouse + let tooltipX = this.hoveredCurveModeButton.x + 10 + let tooltipY = this.hoveredCurveModeButton.y - tooltipHeight - 5 + + // Clamp to stay within bounds + if (tooltipX + tooltipWidth > this.width) { + tooltipX = this.hoveredCurveModeButton.x - tooltipWidth - 10 + } + if (tooltipY < 0) { + tooltipY = this.hoveredCurveModeButton.y + 5 + } + + // Draw tooltip background + ctx.fillStyle = backgroundColor + ctx.fillRect(tooltipX, tooltipY, tooltipWidth, tooltipHeight) + + // Draw tooltip border + ctx.strokeStyle = foregroundColor + ctx.lineWidth = 1 + ctx.strokeRect(tooltipX, tooltipY, tooltipWidth, tooltipHeight) + + // Draw text + ctx.fillStyle = labelColor + ctx.textAlign = 'left' + ctx.textBaseline = 'middle' + ctx.fillText(text, tooltipX + tooltipPadding, tooltipY + tooltipHeight / 2) + } + + ctx.restore() + } + + /** + * Draw fixed track headers on the left (names, expand/collapse) + */ + drawTrackHeaders(ctx) { + ctx.save() + ctx.translate(0, this.ruler.height) // Start below ruler + + // Clip to track header area + const trackAreaHeight = this.height - this.ruler.height + ctx.beginPath() + ctx.rect(0, 0, this.trackHeaderWidth, trackAreaHeight) + ctx.clip() + + // Apply vertical scroll offset + ctx.translate(0, this.trackScrollOffset) + + const indentSize = 20 // Pixels per indent level + + for (let i = 0; i < this.trackHierarchy.tracks.length; i++) { + const track = this.trackHierarchy.tracks[i] + const y = this.trackHierarchy.getTrackY(i) + const trackHeight = this.trackHierarchy.getTrackHeight(track) + + // Check if this track is selected + const isSelected = this.isTrackSelected(track) + + // Draw track header background + if (isSelected) { + ctx.fillStyle = highlight + } else { + ctx.fillStyle = shade + } + ctx.fillRect(0, y, this.trackHeaderWidth, trackHeight) + + // Draw border + ctx.strokeStyle = shadow + ctx.lineWidth = 1 + ctx.beginPath() + ctx.moveTo(0, y + trackHeight) + ctx.lineTo(this.trackHeaderWidth, y + trackHeight) + ctx.stroke() + + // Calculate indent + const indent = track.indent * indentSize + + // Draw expand/collapse indicator + if (track.type === 'layer' || (track.type === 'object' && track.object.children && track.object.children.length > 0)) { + const triangleX = indent + 8 + const triangleY = y + this.trackHierarchy.trackHeight / 2 // Use base height for triangle position + + ctx.fillStyle = foregroundColor + ctx.beginPath() + if (track.collapsed) { + ctx.moveTo(triangleX, triangleY - 4) + ctx.lineTo(triangleX + 6, triangleY) + ctx.lineTo(triangleX, triangleY + 4) + } else { + ctx.moveTo(triangleX - 4, triangleY - 2) + ctx.lineTo(triangleX + 4, triangleY - 2) + ctx.lineTo(triangleX, triangleY + 4) + } + ctx.closePath() + ctx.fill() + } + + // Draw track name with ellipsis if needed to avoid button overlap + ctx.fillStyle = labelColor + ctx.font = '12px sans-serif' + ctx.textAlign = 'left' + ctx.textBaseline = 'middle' + + // Calculate available width for text (leave space for buttons if present) + const textStartX = indent + 20 + let maxTextWidth = this.trackHeaderWidth - textStartX - 10 // 10px right padding + + // If this track has buttons, reserve space for them + if (track.type === 'object' || track.type === 'shape') { + const buttonSize = 14 + const twoButtonsWidth = (buttonSize * 2) + 4 + 10 // Two buttons + gap + padding + maxTextWidth = this.trackHeaderWidth - textStartX - twoButtonsWidth + } else if (track.type === 'audio') { + const buttonSize = 14 + const oneButtonWidth = buttonSize + 10 // One button (curves mode) + padding + maxTextWidth = this.trackHeaderWidth - textStartX - oneButtonWidth + } + + // Truncate text with ellipsis if needed + let displayName = track.name + let nameWidth = ctx.measureText(displayName).width + if (nameWidth > maxTextWidth) { + // Add ellipsis + while (nameWidth > maxTextWidth && displayName.length > 0) { + displayName = displayName.slice(0, -1) + nameWidth = ctx.measureText(displayName + '...').width + } + displayName += '...' + } + + ctx.fillText(displayName, textStartX, y + this.trackHierarchy.trackHeight / 2) + + // Draw type indicator (only if there's space) + ctx.fillStyle = foregroundColor + ctx.font = '10px sans-serif' + const typeText = track.type === 'layer' ? '[L]' : + track.type === 'object' ? '[G]' : + track.type === 'audio' ? '[A]' : '[S]' + const typeX = textStartX + ctx.measureText(displayName).width + 8 + const buttonSpaceNeeded = (track.type === 'object' || track.type === 'shape') ? 50 : + (track.type === 'audio') ? 25 : 10 + if (typeX + ctx.measureText(typeText).width < this.trackHeaderWidth - buttonSpaceNeeded) { + ctx.fillText(typeText, typeX, y + this.trackHierarchy.trackHeight / 2) + } + + + // Draw MIDI activity indicator for active MIDI track + if (track.type === 'audio' && track.object && track.object.type === 'midi') { + + if (this.context && this.context.lastMidiInputTime > 0) { + + // Check if this is the selected/active MIDI track + const isActiveMidiTrack = isSelected && track.object && track.object.audioTrackId !== undefined + + + if (isActiveMidiTrack) { + const elapsed = Date.now() - this.context.lastMidiInputTime + const fadeTime = 1000 // Fade out over 1 second (increased for visibility) + + if (elapsed < fadeTime) { + const alpha = Math.max(0.2, 1 - (elapsed / fadeTime)) // Minimum alpha of 0.3 for visibility + const indicatorSize = 10 + const indicatorX = this.trackHeaderWidth - 35 // Position to the left of buttons + const indicatorY = y + this.trackHierarchy.trackHeight / 2 + + + // Draw pulsing circle with border + ctx.strokeStyle = `rgba(0, 255, 0, ${alpha})` + ctx.fillStyle = `rgba(0, 255, 0, ${alpha})` + ctx.lineWidth = 2 + ctx.beginPath() + ctx.arc(indicatorX, indicatorY, indicatorSize / 2, 0, Math.PI * 2) + ctx.fill() + ctx.stroke() + } + } + } + } + + // Draw toggle buttons for object/shape/audio/midi tracks (Phase 3) + if (track.type === 'object' || track.type === 'shape' || track.type === 'audio' || track.type === 'midi') { + const buttonSize = 14 + const buttonY = y + (this.trackHierarchy.trackHeight - buttonSize) / 2 // Use base height for button position + let buttonX = this.trackHeaderWidth - 10 // Start from right edge + + // Curves mode button (rightmost) + buttonX -= buttonSize + ctx.strokeStyle = foregroundColor + ctx.lineWidth = 1 + ctx.strokeRect(buttonX, buttonY, buttonSize, buttonSize) + + // Draw symbol based on curves mode + ctx.fillStyle = foregroundColor + ctx.font = '10px sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + const curveSymbol = track.object.curvesMode === 'curve' ? '~' : + track.object.curvesMode === 'keyframe' ? '≈' : '-' + ctx.fillText(curveSymbol, buttonX + buttonSize / 2, buttonY + buttonSize / 2) + + // Segment visibility button (only for object/shape tracks, not audio/midi) + if (track.type !== 'audio' && track.type !== 'midi') { + buttonX -= (buttonSize + 4) + ctx.strokeStyle = foregroundColor + ctx.lineWidth = 1 + ctx.strokeRect(buttonX, buttonY, buttonSize, buttonSize) + + // Fill if segment is visible + if (track.object.showSegment) { + ctx.fillStyle = foregroundColor + ctx.fillRect(buttonX + 2, buttonY + 2, buttonSize - 4, buttonSize - 4) + } + } + + // Draw legend for expanded curves (Phase 6) + if (track.object.curvesMode === 'curve') { + // Get curves for this track + const curves = [] + const obj = track.object + let animationData = null + + // Find the AnimationData for this track + if (track.type === 'audio' || track.type === 'midi') { + // For audio/MIDI tracks, animation data is directly on the track object + animationData = obj.animationData + } else if (track.type === 'object') { + for (let layer of this.context.activeObject.allLayers) { + if (layer.children && layer.children.includes(obj)) { + animationData = layer.animationData + break + } + } + } else if (track.type === 'shape') { + for (let layer of this.context.activeObject.allLayers) { + if (layer.shapes && layer.shapes.some(s => s.shapeId === obj.shapeId)) { + animationData = layer.animationData + break + } + } + } + + if (animationData) { + if (track.type === 'audio' || track.type === 'midi') { + // For audio/MIDI tracks, include all automation curves + for (let curveName in animationData.curves) { + curves.push(animationData.curves[curveName]) + } + } else { + // For objects/shapes, filter by prefix + const prefix = track.type === 'object' ? `child.${obj.idx}.` : `shape.${obj.shapeId}.` + for (let curveName in animationData.curves) { + if (curveName.startsWith(prefix)) { + curves.push(animationData.curves[curveName]) + } + } + } + } + + if (curves.length > 0) { + ctx.save() + const legendPadding = 3 + const legendLineHeight = 12 + const legendHeight = curves.length * legendLineHeight + legendPadding * 2 + const legendY = y + this.trackHierarchy.trackHeight + 5 // Below track name row + + // Draw legend items (no background box) + ctx.font = '9px sans-serif' + ctx.textAlign = 'left' + ctx.textBaseline = 'top' + + for (let i = 0; i < curves.length; i++) { + const curve = curves[i] + const itemY = legendY + legendPadding + i * legendLineHeight + const isHidden = this.hiddenCurves.has(curve.parameter) + + // Draw color dot (grayed out if hidden) + ctx.fillStyle = isHidden ? foregroundColor : curve.displayColor + ctx.beginPath() + ctx.arc(10, itemY + 5, 3, 0, 2 * Math.PI) + ctx.fill() + + // Draw parameter name + ctx.fillStyle = isHidden ? foregroundColor : labelColor + let paramName = curve.parameter.split('.').pop() + + // For automation curves, fetch the friendly name from backend + if (curve.parameter.startsWith('automation.') && (track.type === 'audio' || track.type === 'midi')) { + const nodeId = parseInt(paramName, 10) + if (!isNaN(nodeId) && obj.audioTrackId !== null) { + paramName = this.getAutomationName(obj.audioTrackId, nodeId) + } + } + + const truncatedName = paramName.length > 12 ? paramName.substring(0, 10) + '...' : paramName + ctx.fillText(truncatedName, 18, itemY) + + // Draw strikethrough if hidden + if (isHidden) { + ctx.strokeStyle = foregroundColor + ctx.lineWidth = 1 + ctx.beginPath() + const textWidth = ctx.measureText(truncatedName).width + ctx.moveTo(18, itemY + 5) + ctx.lineTo(18 + textWidth, itemY + 5) + ctx.stroke() + } + } + ctx.restore() + } + } + } + } + + // Draw right border of header column + ctx.strokeStyle = shadow + ctx.lineWidth = 2 + ctx.beginPath() + ctx.moveTo(this.trackHeaderWidth, 0) + ctx.lineTo(this.trackHeaderWidth, this.trackHierarchy.getTotalHeight()) + ctx.stroke() + + ctx.restore() + } + + /** + * Draw track backgrounds in timeline area (Phase 2) + */ + // Create a cached pattern for the timeline grid + createTimelinePattern(trackHeight) { + const cacheKey = `${this.timelineState.timeFormat}_${this.timelineState.pixelsPerSecond}_${this.timelineState.framerate}_${this.timelineState.bpm}_${trackHeight}` + + // Return cached pattern if available + if (this.cachedPattern && this.cachedPatternKey === cacheKey) { + return this.cachedPattern + } + + let patternWidth, patternHeight = trackHeight + + if (this.timelineState.timeFormat === 'frames') { + // Pattern for 5 frames + const frameDuration = 1 / this.timelineState.framerate + const frameWidth = frameDuration * this.timelineState.pixelsPerSecond + patternWidth = frameWidth * 5 + } else if (this.timelineState.timeFormat === 'measures') { + // Pattern for one measure + const beatsPerSecond = this.timelineState.bpm / 60 + const beatsPerMeasure = this.timelineState.timeSignature.numerator + const beatWidth = (1 / beatsPerSecond) * this.timelineState.pixelsPerSecond + patternWidth = beatWidth * beatsPerMeasure + } else { + // Pattern for seconds - use 10 second intervals + patternWidth = this.timelineState.pixelsPerSecond * 10 + } + + // Create pattern canvas + const patternCanvas = document.createElement('canvas') + patternCanvas.width = Math.ceil(patternWidth) + patternCanvas.height = patternHeight + const pctx = patternCanvas.getContext('2d') + + // Fill background + pctx.fillStyle = shade + pctx.fillRect(0, 0, patternWidth, patternHeight) + + if (this.timelineState.timeFormat === 'frames') { + const frameDuration = 1 / this.timelineState.framerate + const frameWidth = frameDuration * this.timelineState.pixelsPerSecond + + for (let i = 0; i < 5; i++) { + const x = i * frameWidth + if (i === 0) { + // First frame in pattern (every 5th): shade it + pctx.fillStyle = shadow + pctx.fillRect(x, 0, frameWidth, patternHeight) + } else { + // Regular frame: draw edge line + pctx.strokeStyle = shadow + pctx.lineWidth = 1 + pctx.beginPath() + pctx.moveTo(x, 0) + pctx.lineTo(x, patternHeight) + pctx.stroke() + } + } + } else if (this.timelineState.timeFormat === 'measures') { + const beatsPerSecond = this.timelineState.bpm / 60 + const beatsPerMeasure = this.timelineState.timeSignature.numerator + const beatWidth = (1 / beatsPerSecond) * this.timelineState.pixelsPerSecond + + for (let i = 0; i < beatsPerMeasure; i++) { + const x = i * beatWidth + const isMeasureBoundary = i === 0 + const isEvenBeat = (i % 2) === 0 + + pctx.save() + if (isMeasureBoundary) { + pctx.globalAlpha = 1.0 + } else if (isEvenBeat) { + pctx.globalAlpha = 0.5 + } else { + pctx.globalAlpha = 0.25 + } + + pctx.strokeStyle = shadow + pctx.lineWidth = 1 + pctx.beginPath() + pctx.moveTo(x, 0) + pctx.lineTo(x, patternHeight) + pctx.stroke() + pctx.restore() + } + } else { + // Seconds mode: draw lines every second for 10 seconds + const secondWidth = this.timelineState.pixelsPerSecond + + for (let i = 0; i < 10; i++) { + const x = i * secondWidth + pctx.strokeStyle = shadow + pctx.lineWidth = 1 + pctx.beginPath() + pctx.moveTo(x, 0) + pctx.lineTo(x, patternHeight) + pctx.stroke() + } + } + + // Cache the pattern + this.cachedPatternKey = cacheKey + this.cachedPattern = pctx.createPattern(patternCanvas, 'repeat') + + return this.cachedPattern + } + + drawTracks(ctx) { + ctx.save() + ctx.translate(this.trackHeaderWidth, this.ruler.height) // Start after headers, below ruler + + // Clip to available track area + const trackAreaHeight = this.height - this.ruler.height + const trackAreaWidth = this.width - this.trackHeaderWidth + ctx.beginPath() + ctx.rect(0, 0, trackAreaWidth, trackAreaHeight) + ctx.clip() + + // Apply vertical scroll offset + ctx.translate(0, this.trackScrollOffset) + + for (let i = 0; i < this.trackHierarchy.tracks.length; i++) { + const track = this.trackHierarchy.tracks[i] + const y = this.trackHierarchy.getTrackY(i) + const trackHeight = this.trackHierarchy.getTrackHeight(track) + + // Create and apply pattern for this track + const pattern = this.createTimelinePattern(trackHeight) + + // Calculate pattern offset based on viewport start time + const visibleStartTime = this.timelineState.viewportStartTime + const patternOffsetX = -this.timelineState.timeToPixel(visibleStartTime) + + ctx.save() + ctx.translate(patternOffsetX, y) + ctx.fillStyle = pattern + ctx.fillRect(-patternOffsetX, 0, trackAreaWidth, trackHeight) + ctx.restore() + + // Draw track border + ctx.strokeStyle = shadow + ctx.lineWidth = 1 + ctx.beginPath() + ctx.moveTo(0, y + trackHeight) + ctx.lineTo(trackAreaWidth, y + trackHeight) + ctx.stroke() + } + + ctx.restore() + } + + /** + * Draw segments for shapes (Phase 3) + * Segments show the lifetime of shapes based on their exists curve keyframes + */ + drawSegments(ctx) { + ctx.save() + ctx.translate(this.trackHeaderWidth, this.ruler.height) // Start after headers, below ruler + + // Clip to available track area + const trackAreaHeight = this.height - this.ruler.height + const trackAreaWidth = this.width - this.trackHeaderWidth + ctx.beginPath() + ctx.rect(0, 0, trackAreaWidth, trackAreaHeight) + ctx.clip() + + // Apply vertical scroll offset + ctx.translate(0, this.trackScrollOffset) + + const frameDuration = 1 / this.timelineState.framerate + const minSegmentDuration = frameDuration // Minimum 1 frame + + // Iterate through tracks and draw segments + for (let i = 0; i < this.trackHierarchy.tracks.length; i++) { + const track = this.trackHierarchy.tracks[i] + + if (track.type === 'object') { + // Draw segments for GraphicsObjects (groups) using frameNumber curve + const obj = track.object + + // Skip if segment is hidden (Phase 3) + if (!obj.showSegment) continue + + const y = this.trackHierarchy.getTrackY(i) + const trackHeight = this.trackHierarchy.trackHeight // Use base height for segment + + // Find the parent layer that contains this object + let parentLayer = null + for (let layer of this.context.activeObject.allLayers) { + if (layer.children && layer.children.includes(obj)) { + parentLayer = layer + break + } + } + + if (!parentLayer || !parentLayer.animationData) continue + + // Get the frameNumber curve for this object + const frameNumberKey = `child.${obj.idx}.frameNumber` + const frameNumberCurve = parentLayer.animationData.curves[frameNumberKey] + + if (!frameNumberCurve || !frameNumberCurve.keyframes || frameNumberCurve.keyframes.length === 0) continue + + // Build segments from consecutive keyframes where frameNumber > 0 + let segmentStart = null + for (let j = 0; j < frameNumberCurve.keyframes.length; j++) { + const keyframe = frameNumberCurve.keyframes[j] + + if (keyframe.value > 0) { + // Start of a new segment or continuation + if (segmentStart === null) { + segmentStart = keyframe.time + } + + // Check if this is the last keyframe or if the next one ends the segment + const isLast = (j === frameNumberCurve.keyframes.length - 1) + const nextEndsSegment = !isLast && frameNumberCurve.keyframes[j + 1].value === 0 + + if (isLast || nextEndsSegment) { + // End of segment - draw it + const segmentEnd = nextEndsSegment ? frameNumberCurve.keyframes[j + 1].time : keyframe.time + minSegmentDuration + + const startX = this.timelineState.timeToPixel(segmentStart) + const endX = this.timelineState.timeToPixel(segmentEnd) + const segmentWidth = Math.max(endX - startX, this.timelineState.pixelsPerSecond * minSegmentDuration) + + // Draw segment with object's color + ctx.fillStyle = obj.segmentColor + ctx.fillRect( + startX, + y + 5, + segmentWidth, + trackHeight - 10 + ) + + // Draw border + ctx.strokeStyle = shadow + ctx.lineWidth = 1 + ctx.strokeRect( + startX, + y + 5, + segmentWidth, + trackHeight - 10 + ) + + // Draw object name if there's enough space + const minWidthForLabel = 40 // Minimum pixels to show label + if (segmentWidth >= minWidthForLabel) { + ctx.fillStyle = labelColor + ctx.font = '11px sans-serif' + ctx.textAlign = 'left' + ctx.textBaseline = 'middle' + + // Clip text to segment bounds + ctx.save() + ctx.beginPath() + ctx.rect(startX + 2, y + 5, segmentWidth - 4, trackHeight - 10) + ctx.clip() + + ctx.fillText(obj.name, startX + 4, y + trackHeight / 2) + ctx.restore() + } + + segmentStart = null // Reset for next segment + } + } + } + } else if (track.type === 'shape') { + const shape = track.object + + // Skip if segment is hidden (Phase 3) + if (!shape.showSegment) continue + + const y = this.trackHierarchy.getTrackY(i) + const trackHeight = this.trackHierarchy.trackHeight // Use base height for segment + + // Find the layer this shape belongs to (including nested layers in groups) + let shapeLayer = null + const findShapeLayer = (obj) => { + for (let layer of obj.children) { + if (layer.shapes && layer.shapes.includes(shape)) { + shapeLayer = layer + return true + } + // Recursively search in child objects + if (layer.children) { + for (let child of layer.children) { + if (findShapeLayer(child)) return true + } + } + } + return false + } + findShapeLayer(this.context.activeObject) + + if (!shapeLayer || !shapeLayer.animationData) continue + + // Get the exists curve for this shape (using shapeId, not idx) + const existsCurveKey = `shape.${shape.shapeId}.exists` + const existsCurve = shapeLayer.animationData.curves[existsCurveKey] + + if (!existsCurve || !existsCurve.keyframes || existsCurve.keyframes.length === 0) continue + + // Build segments from consecutive keyframes where exists > 0 + let segmentStart = null + for (let j = 0; j < existsCurve.keyframes.length; j++) { + const keyframe = existsCurve.keyframes[j] + + if (keyframe.value > 0) { + // Start of a new segment or continuation + if (segmentStart === null) { + segmentStart = keyframe.time + } + + // Check if this is the last keyframe or if the next one ends the segment + const isLast = (j === existsCurve.keyframes.length - 1) + const nextEndsSegment = !isLast && existsCurve.keyframes[j + 1].value === 0 + + if (isLast || nextEndsSegment) { + // End of segment - draw it + const segmentEnd = nextEndsSegment ? existsCurve.keyframes[j + 1].time : keyframe.time + minSegmentDuration + + const startX = this.timelineState.timeToPixel(segmentStart) + const endX = this.timelineState.timeToPixel(segmentEnd) + const segmentWidth = Math.max(endX - startX, this.timelineState.pixelsPerSecond * minSegmentDuration) + + // Draw segment with shape's color + ctx.fillStyle = shape.segmentColor + ctx.fillRect( + startX, + y + 5, + segmentWidth, + trackHeight - 10 + ) + + // Draw border + ctx.strokeStyle = shadow + ctx.lineWidth = 1 + ctx.strokeRect( + startX, + y + 5, + segmentWidth, + trackHeight - 10 + ) + + // Draw shape name (constructor name) if there's enough space + const minWidthForLabel = 50 // Minimum pixels to show label + if (segmentWidth >= minWidthForLabel) { + const shapeName = shape.constructor.name || 'Shape' + ctx.fillStyle = labelColor + ctx.font = '11px sans-serif' + ctx.textAlign = 'left' + ctx.textBaseline = 'middle' + + // Clip text to segment bounds + ctx.save() + ctx.beginPath() + ctx.rect(startX + 2, y + 5, segmentWidth - 4, trackHeight - 10) + ctx.clip() + + ctx.fillText(shapeName, startX + 4, y + trackHeight / 2) + ctx.restore() + } + + segmentStart = null // Reset for next segment + } + } + } + } else if (track.type === 'audio') { + // Draw audio clips for AudioTrack + const audioTrack = track.object + const y = this.trackHierarchy.getTrackY(i) + const trackHeight = this.trackHierarchy.trackHeight // Use base height for clips + + // Draw each clip + for (let clip of audioTrack.clips) { + const startX = this.timelineState.timeToPixel(clip.startTime) + const endX = this.timelineState.timeToPixel(clip.startTime + clip.duration) + const clipWidth = endX - startX + + // Determine clip color based on track type + const isMIDI = audioTrack.type === 'midi' + let clipColor + if (clip.loading) { + clipColor = '#666666' // Gray for loading + } else if (isMIDI) { + clipColor = '#2d5016' // Dark green background for MIDI clips + } else { + clipColor = '#4a90e2' // Blue for audio clips + } + + // Draw clip rectangle + ctx.fillStyle = clipColor + ctx.fillRect( + startX, + y + 5, + clipWidth, + trackHeight - 10 + ) + + // Draw border + ctx.strokeStyle = shadow + ctx.lineWidth = 1 + ctx.strokeRect( + startX, + y + 5, + clipWidth, + trackHeight - 10 + ) + + // Highlight selected MIDI clip + if (isMIDI && context.pianoRollEditor && clip.clipId === context.pianoRollEditor.selectedClipId) { + ctx.strokeStyle = '#6fdc6f' // Bright green for selected MIDI clip + ctx.lineWidth = 2 + ctx.strokeRect( + startX, + y + 5, + clipWidth, + trackHeight - 10 + ) + } + + // Draw clip name if there's enough space + const minWidthForLabel = 40 + if (clipWidth >= minWidthForLabel) { + ctx.fillStyle = labelColor + ctx.font = '11px sans-serif' + ctx.textAlign = 'left' + ctx.textBaseline = 'middle' + + // Clip text to clip bounds + ctx.save() + ctx.beginPath() + ctx.rect(startX + 2, y + 5, clipWidth - 4, trackHeight - 10) + ctx.clip() + + ctx.fillText(clip.name, startX + 4, y + trackHeight / 2) + ctx.restore() + } + + // Draw MIDI clip visualization (piano roll bars) or audio waveform + if (!clip.loading) { + if (isMIDI && clip.notes && clip.notes.length > 0) { + // Draw piano roll notes for MIDI clips + // Divide track height by 12 to represent chromatic notes (C, C#, D, etc.) + // Leave 2px padding at top and bottom + const verticalPadding = 2 + const availableHeight = trackHeight - 10 - (verticalPadding * 2) + const noteHeight = availableHeight / 12 + + // Get clip trim boundaries (internal_start = offset, internal_end depends on source) + const clipOffset = clip.offset || 0 + // Use stored internalDuration if available (set when trimming), otherwise calculate from notes + let internalDuration + if (clip.internalDuration !== undefined) { + internalDuration = clip.internalDuration + } else { + // Fallback: calculate from actual notes (for clips that haven't been trimmed) + let contentEndTime = clipOffset + for (const note of clip.notes) { + const noteEnd = note.start_time + note.duration + if (noteEnd > contentEndTime) { + contentEndTime = noteEnd + } + } + internalDuration = contentEndTime - clipOffset + } + const contentEndTime = clipOffset + internalDuration + // If clip.duration exceeds internal duration, we're looping + const isLooping = clip.duration > internalDuration && internalDuration > 0 + + // Calculate visible time range within the clip (in clip-local time) + const clipEndX = startX + clipWidth + const visibleStartTime = this.timelineState.pixelToTime(Math.max(startX, 0)) - clip.startTime + const visibleEndTime = this.timelineState.pixelToTime(Math.min(clipEndX, this.width)) - clip.startTime + + // Helper function to draw notes for a given loop iteration + const drawNotesForIteration = (loopOffset, opacity) => { + ctx.fillStyle = opacity < 1 ? `rgba(111, 220, 111, ${opacity})` : '#6fdc6f' + + for (let i = 0; i < clip.notes.length; i++) { + const note = clip.notes[i] + const noteEndTime = note.start_time + note.duration + + // Skip notes that are outside the trimmed region + if (noteEndTime <= clipOffset || note.start_time >= contentEndTime) { + continue + } + + // Calculate note position in this loop iteration + const noteDisplayStart = note.start_time - clipOffset + loopOffset + const noteDisplayEnd = noteEndTime - clipOffset + loopOffset + + // Skip if this iteration's note is beyond clip duration + if (noteDisplayStart >= clip.duration) { + continue + } + + // Exit early if note starts after visible range + if (noteDisplayStart > visibleEndTime) { + continue + } + + // Skip if note ends before visible range + if (noteDisplayEnd < visibleStartTime) { + continue + } + + // Calculate note position (pitch mod 12 for chromatic representation) + const pitchClass = note.note % 12 + // Invert Y so higher pitches appear at top + const noteY = y + 5 + ((11 - pitchClass) * noteHeight) + + // Calculate note timing on timeline + const noteStartX = this.timelineState.timeToPixel(clip.startTime + noteDisplayStart) + let noteEndX = this.timelineState.timeToPixel(clip.startTime + Math.min(noteDisplayEnd, clip.duration)) + + // Clip to visible bounds + const visibleStartX = Math.max(noteStartX, startX + 2) + const visibleEndX = Math.min(noteEndX, startX + clipWidth - 2) + const visibleWidth = visibleEndX - visibleStartX + + if (visibleWidth > 0) { + // Draw note rectangle + ctx.fillRect( + visibleStartX, + noteY, + visibleWidth, + noteHeight - 1 // Small gap between notes + ) + } + } + } + + // Draw primary notes at full opacity + drawNotesForIteration(0, 1.0) + + // Draw looped iterations at 50% opacity + if (isLooping) { + let loopOffset = internalDuration + while (loopOffset < clip.duration) { + drawNotesForIteration(loopOffset, 0.5) + loopOffset += internalDuration + } + } + } else if (!isMIDI && clip.waveform && clip.waveform.length > 0) { + // Draw waveform for audio clips + ctx.fillStyle = 'rgba(255, 255, 255, 0.3)' + + // Only draw waveform within visible area + const visibleStart = Math.max(startX + 2, 0) + const visibleEnd = Math.min(startX + clipWidth - 2, this.width - this.trackHeaderWidth) + + if (visibleEnd > visibleStart) { + const centerY = y + trackHeight / 2 + const waveformHeight = trackHeight - 14 // Leave padding at top/bottom + const waveformData = clip.waveform + + // Calculate the full source audio duration and pixels per peak based on that + const sourceDuration = clip.sourceDuration || clip.duration + const pixelsPerSecond = this.timelineState.pixelsPerSecond + const fullSourceWidth = sourceDuration * pixelsPerSecond + const pixelsPerPeak = fullSourceWidth / waveformData.length + + // Calculate which peak corresponds to the clip's offset (trimmed left edge) + const offsetPeakIndex = Math.floor((clip.offset / sourceDuration) * waveformData.length) + + // Calculate the range of visible peaks, accounting for offset + const firstVisiblePeak = Math.max(offsetPeakIndex, Math.floor((visibleStart - startX) / pixelsPerPeak) + offsetPeakIndex) + const lastVisiblePeak = Math.min(waveformData.length - 1, Math.ceil((visibleEnd - startX) / pixelsPerPeak) + offsetPeakIndex) + + // Draw waveform as a filled path + ctx.beginPath() + + // Trace along the max values (left to right) + for (let i = firstVisiblePeak; i <= lastVisiblePeak; i++) { + const peakX = startX + ((i - offsetPeakIndex) * pixelsPerPeak) + const peak = waveformData[i] + const maxY = centerY + (peak.max * waveformHeight * 0.5) + + if (i === firstVisiblePeak) { + ctx.moveTo(peakX, maxY) + } else { + ctx.lineTo(peakX, maxY) + } + } + + // Trace back along the min values (right to left) + for (let i = lastVisiblePeak; i >= firstVisiblePeak; i--) { + const peakX = startX + ((i - offsetPeakIndex) * pixelsPerPeak) + const peak = waveformData[i] + const minY = centerY + (peak.min * waveformHeight * 0.5) + ctx.lineTo(peakX, minY) + } + + ctx.closePath() + ctx.fill() + } + } + } + } + } else if (track.type === 'video') { + // Draw video clips for VideoLayer + const videoLayer = track.object + const y = this.trackHierarchy.getTrackY(i) + const trackHeight = this.trackHierarchy.trackHeight // Use base height for clips + + // Draw each clip + for (let clip of videoLayer.clips) { + const startX = this.timelineState.timeToPixel(clip.startTime) + const endX = this.timelineState.timeToPixel(clip.startTime + clip.duration) + const clipWidth = endX - startX + + // Video clips use purple/magenta color + const clipColor = '#9b59b6' // Purple for video clips + + // Draw clip rectangle + ctx.fillStyle = clipColor + ctx.fillRect( + startX, + y + 5, + clipWidth, + trackHeight - 10 + ) + + // Draw border + ctx.strokeStyle = shadow + ctx.lineWidth = 1 + ctx.strokeRect( + startX, + y + 5, + clipWidth, + trackHeight - 10 + ) + + // Draw clip name if there's enough space + const minWidthForLabel = 40 + if (clipWidth >= minWidthForLabel) { + ctx.fillStyle = labelColor + ctx.font = '11px sans-serif' + ctx.textAlign = 'left' + ctx.textBaseline = 'middle' + + // Clip text to clip bounds + ctx.save() + ctx.beginPath() + ctx.rect(startX + 2, y + 5, clipWidth - 4, trackHeight - 10) + ctx.clip() + + ctx.fillText(clip.name, startX + 4, y + trackHeight / 2) + ctx.restore() + } + } + } + } + + ctx.restore() + } + + /** + * Draw curves for animation parameters (Phase 4) + * Shows keyframe dots in minimized mode, full curves in expanded mode + */ + drawCurves(ctx) { + ctx.save() + ctx.translate(this.trackHeaderWidth, this.ruler.height) // Start after headers, below ruler + + // Clip to available track area + const trackAreaHeight = this.height - this.ruler.height + const trackAreaWidth = this.width - this.trackHeaderWidth + ctx.beginPath() + ctx.rect(0, 0, trackAreaWidth, trackAreaHeight) + ctx.clip() + + // Apply vertical scroll offset + ctx.translate(0, this.trackScrollOffset) + + // Iterate through tracks and draw curves + for (let i = 0; i < this.trackHierarchy.tracks.length; i++) { + const track = this.trackHierarchy.tracks[i] + + // Only draw curves for objects, shapes, audio tracks, and MIDI tracks + if (track.type !== 'object' && track.type !== 'shape' && track.type !== 'audio' && track.type !== 'midi') continue + + const obj = track.object + + // Skip if curves are hidden + if (obj.curvesMode === 'segment') continue + + const y = this.trackHierarchy.getTrackY(i) + + // Find the layer containing this object/shape to get AnimationData + let animationData = null + if (track.type === 'audio' || track.type === 'midi') { + // For audio/MIDI tracks, animation data is directly on the track object + animationData = obj.animationData + } else if (track.type === 'object') { + // For objects, get curves from parent layer + for (let layer of this.context.activeObject.allLayers) { + if (layer.children && layer.children.includes(obj)) { + animationData = layer.animationData + break + } + } + } else if (track.type === 'shape') { + // For shapes, find the layer recursively + const findShapeLayer = (searchObj) => { + for (let layer of searchObj.children) { + if (layer.shapes && layer.shapes.includes(obj)) { + animationData = layer.animationData + return true + } + if (layer.children) { + for (let child of layer.children) { + if (findShapeLayer(child)) return true + } + } + } + return false + } + findShapeLayer(this.context.activeObject) + } + + if (!animationData) continue + + // Get all curves for this object/shape/audio + const curves = [] + for (let curveName in animationData.curves) { + const curve = animationData.curves[curveName] + + // Filter to only curves for this specific object/shape/audio/MIDI + if (track.type === 'audio' || track.type === 'midi') { + // Audio/MIDI tracks: include all automation curves + curves.push(curve) + } else if (track.type === 'object' && curveName.startsWith(`child.${obj.idx}.`)) { + curves.push(curve) + } else if (track.type === 'shape' && curveName.startsWith(`shape.${obj.shapeId}.`)) { + curves.push(curve) + } + } + + if (curves.length === 0) continue + + // Draw based on curves mode + if (obj.curvesMode === 'keyframe') { + this.drawMinimizedCurves(ctx, curves, y) + } else if (obj.curvesMode === 'curve') { + this.drawExpandedCurves(ctx, curves, y) + } + } + + ctx.restore() + } + + /** + * Draw minimized curves (keyframe dots only) - Phase 6: Compact overlay mode + * All keyframes are overlaid at the same vertical position (on the segment bar) + */ + drawMinimizedCurves(ctx, curves, trackY) { + const dotRadius = 3 + const yPosition = trackY + (this.trackHierarchy.trackHeight / 2) // Center vertically in track + + // Draw keyframe dots for each curve, color-coded but overlaid + for (let curve of curves) { + ctx.fillStyle = curve.displayColor + ctx.strokeStyle = shadow + ctx.lineWidth = 1 + + for (let keyframe of curve.keyframes) { + const x = this.timelineState.timeToPixel(keyframe.time) + + // Draw with outline for better visibility when overlapping + ctx.beginPath() + ctx.arc(x, yPosition, dotRadius, 0, 2 * Math.PI) + ctx.fill() + ctx.stroke() + } + } + } + + /** + * Draw expanded curves (full Bezier visualization) + */ + drawExpandedCurves(ctx, curves, trackY) { + const curveHeight = 80 // Height allocated for curve visualization + const startY = trackY + 10 // Start below segment area + const padding = 5 + + // Calculate value range across all curves for auto-scaling + let minValue = Infinity + let maxValue = -Infinity + + for (let curve of curves) { + for (let keyframe of curve.keyframes) { + minValue = Math.min(minValue, keyframe.value) + maxValue = Math.max(maxValue, keyframe.value) + } + } + + // Add padding to the range + const valueRange = maxValue - minValue + const rangePadding = valueRange * 0.1 || 1 // 10% padding, or 1 if range is 0 + minValue -= rangePadding + maxValue += rangePadding + + // Draw background for curve area + ctx.fillStyle = shade + ctx.fillRect(0, startY, this.width - this.trackHeaderWidth, curveHeight) + + // Draw grid lines + ctx.strokeStyle = shadow + ctx.lineWidth = 1 + + // Horizontal grid lines (value axis) + for (let i = 0; i <= 4; i++) { + const y = startY + padding + (i * (curveHeight - 2 * padding) / 4) + ctx.beginPath() + ctx.moveTo(0, y) + ctx.lineTo(this.width - this.trackHeaderWidth, y) + ctx.stroke() + } + + // Helper function to convert value to Y position + const valueToY = (value) => { + const normalizedValue = (value - minValue) / (maxValue - minValue) + return startY + curveHeight - padding - (normalizedValue * (curveHeight - 2 * padding)) + } + + // Draw each curve + for (let curve of curves) { + if (curve.keyframes.length === 0) continue + if (this.hiddenCurves.has(curve.parameter)) continue // Skip hidden curves + + ctx.strokeStyle = curve.displayColor + ctx.fillStyle = curve.displayColor + ctx.lineWidth = 2 + + // Draw keyframe dots + for (let keyframe of curve.keyframes) { + const x = this.timelineState.timeToPixel(keyframe.time) + const y = valueToY(keyframe.value) + + // Draw selected keyframes 50% bigger + const isSelected = this.selectedKeyframes.has(keyframe) + const radius = isSelected ? 6 : 4 + + ctx.beginPath() + ctx.arc(x, y, radius, 0, 2 * Math.PI) + ctx.fill() + } + + // Handle single keyframe case - draw horizontal hold line + if (curve.keyframes.length === 1) { + const keyframe = curve.keyframes[0] + const keyframeX = this.timelineState.timeToPixel(keyframe.time) + const keyframeY = valueToY(keyframe.value) + + // Draw horizontal line extending to the right edge of visible area + const rightEdge = this.width - this.trackHeaderWidth + + ctx.beginPath() + ctx.moveTo(keyframeX, keyframeY) + ctx.lineTo(rightEdge, keyframeY) + ctx.stroke() + + // Optionally draw a lighter line extending to the left if keyframe is after t=0 + if (keyframe.time > 0) { + ctx.strokeStyle = curve.displayColor + '40' // More transparent + ctx.beginPath() + ctx.moveTo(0, keyframeY) + ctx.lineTo(keyframeX, keyframeY) + ctx.stroke() + + // Reset stroke style + ctx.strokeStyle = curve.displayColor + } + } + + // Draw curves between keyframes based on interpolation mode + for (let i = 0; i < curve.keyframes.length - 1; i++) { + const kf1 = curve.keyframes[i] + const kf2 = curve.keyframes[i + 1] + + const x1 = this.timelineState.timeToPixel(kf1.time) + const y1 = valueToY(kf1.value) + const x2 = this.timelineState.timeToPixel(kf2.time) + const y2 = valueToY(kf2.value) + + // Draw based on interpolation mode + ctx.beginPath() + ctx.moveTo(x1, y1) + + switch (kf1.interpolation) { + case 'linear': + // Draw straight line + ctx.lineTo(x2, y2) + ctx.stroke() + break + + case 'step': + case 'hold': + // Draw horizontal hold line then vertical jump + ctx.lineTo(x2, y1) + ctx.lineTo(x2, y2) + ctx.stroke() + break + + case 'zero': + // Draw line to zero, hold at zero, then line to next value + const zeroY = valueToY(0) + ctx.lineTo(x1, zeroY) + ctx.lineTo(x2, zeroY) + ctx.lineTo(x2, y2) + ctx.stroke() + break + + case 'bezier': + default: + // Calculate control points for Bezier curve using easeIn/easeOut + // easeIn/easeOut are like CSS cubic-bezier: {x: 0-1, y: 0-1} + const dx = x2 - x1 + const dy = y2 - y1 + + // Use default ease if not specified + const easeOut = kf1.easeOut || { x: 0.42, y: 0 } + const easeIn = kf2.easeIn || { x: 0.58, y: 1 } + + // Calculate control points + // easeOut.x controls horizontal offset from kf1, easeOut.y controls vertical + const cp1x = x1 + (easeOut.x * dx) + const cp1y = y1 + (easeOut.y * dy) + + // easeIn.x controls horizontal offset from kf2, easeIn.y controls vertical + // Note: easeIn is relative to the end point, so we subtract from x2 + const cp2x = x1 + (easeIn.x * dx) + const cp2y = y1 + (easeIn.y * dy) + + ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x2, y2) + ctx.stroke() + + // Phase 6: Draw tangent handles only for selected keyframes + const kf1Selected = this.selectedKeyframes.has(kf1) + const kf2Selected = this.selectedKeyframes.has(kf2) + + if (kf1Selected || kf2Selected) { + ctx.strokeStyle = curve.displayColor + '80' // Semi-transparent + ctx.lineWidth = 1 + + // Out tangent handle (from kf1) + if (kf1Selected) { + ctx.beginPath() + ctx.moveTo(x1, y1) + ctx.lineTo(cp1x, cp1y) + ctx.stroke() + + // Draw handle point + ctx.fillStyle = curve.displayColor + ctx.beginPath() + ctx.arc(cp1x, cp1y, 4, 0, 2 * Math.PI) + ctx.fill() + } + + // In tangent handle (to kf2) + if (kf2Selected) { + ctx.beginPath() + ctx.moveTo(x2, y2) + ctx.lineTo(cp2x, cp2y) + ctx.stroke() + + // Draw handle point + ctx.fillStyle = curve.displayColor + ctx.beginPath() + ctx.arc(cp2x, cp2y, 4, 0, 2 * Math.PI) + ctx.fill() + } + + // Reset for next curve segment + ctx.strokeStyle = curve.displayColor + ctx.lineWidth = 2 + } + break + } + } + } + + // Draw value labels on the left + ctx.fillStyle = labelColor + ctx.font = '10px sans-serif' + ctx.textAlign = 'right' + ctx.textBaseline = 'middle' + + for (let i = 0; i <= 4; i++) { + const value = minValue + (i * (maxValue - minValue) / 4) + const y = startY + curveHeight - padding - (i * (curveHeight - 2 * padding) / 4) + ctx.fillText(value.toFixed(2), -5, y) + } + + // Draw keyframe value tooltip if hovering (check if hover position is in this track's curve area) + if (this.hoveredKeyframe && this.hoveredKeyframe.trackY === trackY) { + const hoverX = this.hoveredKeyframe.x + const hoverY = this.hoveredKeyframe.y + const hoverValue = this.hoveredKeyframe.keyframe.value + + // Format the value + const valueText = hoverValue.toFixed(2) + + // Measure text to size the tooltip + ctx.font = '11px sans-serif' + const textMetrics = ctx.measureText(valueText) + const textWidth = textMetrics.width + const tooltipPadding = 4 + const tooltipWidth = textWidth + tooltipPadding * 2 + const tooltipHeight = 16 + + // Position tooltip above and to the right of keyframe + let tooltipX = hoverX + 8 + let tooltipY = hoverY - tooltipHeight - 8 + + // Clamp to stay within bounds + const maxX = this.width - this.trackHeaderWidth + if (tooltipX + tooltipWidth > maxX) { + tooltipX = hoverX - tooltipWidth - 8 // Show on left instead + } + if (tooltipY < startY) { + tooltipY = hoverY + 8 // Show below instead + } + + // Draw tooltip background + ctx.fillStyle = backgroundColor + ctx.fillRect(tooltipX, tooltipY, tooltipWidth, tooltipHeight) + + // Draw tooltip border + ctx.strokeStyle = foregroundColor + ctx.lineWidth = 1 + ctx.strokeRect(tooltipX, tooltipY, tooltipWidth, tooltipHeight) + + // Draw value text + ctx.fillStyle = labelColor + ctx.textAlign = 'left' + ctx.textBaseline = 'middle' + ctx.fillText(valueText, tooltipX + tooltipPadding, tooltipY + tooltipHeight / 2) + } + } + + mousedown(x, y) { + // Check if clicking in ruler area (after track headers) + if (y <= this.ruler.height && x >= this.trackHeaderWidth) { + // Adjust x for ruler (remove track header offset) + const rulerX = x - this.trackHeaderWidth + const hitPlayhead = this.ruler.mousedown(rulerX, y); + if (hitPlayhead) { + // Sync activeObject currentTime with the new playhead position + if (this.context.activeObject) { + this.context.activeObject.currentTime = this.timelineState.currentTime + // Sync DAW backend + invoke('audio_seek', { seconds: this.timelineState.currentTime }); + } + + // Trigger stage redraw to show animation at new time + if (this.context.updateUI) { + this.context.updateUI() + } + + this.draggingPlayhead = true + this._globalEvents.add("mousemove") + this._globalEvents.add("mouseup") + return true + } + } + + // Check if clicking in track header area + const trackY = y - this.ruler.height + if (trackY >= 0 && x < this.trackHeaderWidth) { + // Adjust for vertical scroll offset + const adjustedY = trackY - this.trackScrollOffset + const track = this.trackHierarchy.getTrackAtY(adjustedY) + if (track) { + const indentSize = 20 + const indent = track.indent * indentSize + const triangleX = indent + 8 + + // Check if clicking on expand/collapse triangle + if (x >= triangleX - 8 && x <= triangleX + 14) { + // Toggle collapsed state + if (track.type === 'layer') { + track.object.collapsed = !track.object.collapsed + } else if (track.type === 'object') { + track.object.trackCollapsed = !track.object.trackCollapsed + } + // Rebuild tracks after collapsing/expanding + this.trackHierarchy.buildTracks(this.context.activeObject) + if (this.requestRedraw) this.requestRedraw() + return true + } + + // Check if clicking on toggle buttons (Phase 3) + if (track.type === 'object' || track.type === 'shape' || track.type === 'audio' || track.type === 'midi') { + const buttonSize = 14 + const trackIndex = this.trackHierarchy.tracks.indexOf(track) + const trackY = this.trackHierarchy.getTrackY(trackIndex) + const buttonY = trackY + (this.trackHierarchy.trackHeight - buttonSize) / 2 // Use base height for button + + // Calculate button positions (same as in draw) + let buttonX = this.trackHeaderWidth - 10 + + // Curves mode button (rightmost) + const curveButtonX = buttonX - buttonSize + if (x >= curveButtonX && x <= curveButtonX + buttonSize && + adjustedY >= buttonY && adjustedY <= buttonY + buttonSize) { + // Cycle through curves modes: segment -> keyframe -> curve -> segment + if (track.object.curvesMode === 'segment') { + track.object.curvesMode = 'keyframe' + } else if (track.object.curvesMode === 'keyframe') { + track.object.curvesMode = 'curve' + } else { + track.object.curvesMode = 'segment' + } + + // Update hover tooltip with new mode name + if (this.hoveredCurveModeButton) { + const modeName = track.object.curvesMode === 'curve' ? 'Curve View' : + track.object.curvesMode === 'keyframe' ? 'Keyframe View' : 'Segment View' + this.hoveredCurveModeButton.modeName = modeName + } + + if (this.requestRedraw) this.requestRedraw() + return true + } + + // Segment visibility button + const segmentButtonX = curveButtonX - (buttonSize + 4) + if (x >= segmentButtonX && x <= segmentButtonX + buttonSize && + adjustedY >= buttonY && adjustedY <= buttonY + buttonSize) { + // Toggle segment visibility + track.object.showSegment = !track.object.showSegment + if (this.requestRedraw) this.requestRedraw() + return true + } + + // Check if clicking on legend items (Phase 6) + if (track.object.curvesMode === 'curve') { + const trackIndex = this.trackHierarchy.tracks.indexOf(track) + const trackYPos = this.trackHierarchy.getTrackY(trackIndex) + const legendPadding = 3 + const legendLineHeight = 12 + const legendY = trackYPos + this.trackHierarchy.trackHeight + 5 + + // Get curves for this track + const curves = [] + const obj = track.object + let animationData = null + + if (track.type === 'object') { + for (let layer of this.context.activeObject.allLayers) { + if (layer.children && layer.children.includes(obj)) { + animationData = layer.animationData + break + } + } + } else if (track.type === 'shape') { + for (let layer of this.context.activeObject.allLayers) { + if (layer.shapes && layer.shapes.some(s => s.shapeId === obj.shapeId)) { + animationData = layer.animationData + break + } + } + } + + if (animationData) { + const prefix = track.type === 'object' ? `child.${obj.idx}.` : `shape.${obj.shapeId}.` + for (let curveName in animationData.curves) { + if (curveName.startsWith(prefix)) { + curves.push(animationData.curves[curveName]) + } + } + } + + // Check if clicking on any legend item + for (let i = 0; i < curves.length; i++) { + const curve = curves[i] + const itemY = legendY + legendPadding + i * legendLineHeight + + // Legend items are from x=5 to x=145, height of 12px + if (x >= 5 && x <= 145 && adjustedY >= itemY && adjustedY <= itemY + legendLineHeight) { + // Toggle visibility of this curve + if (this.hiddenCurves.has(curve.parameter)) { + this.hiddenCurves.delete(curve.parameter) + console.log(`Showing curve: ${curve.parameter}`) + } else { + this.hiddenCurves.add(curve.parameter) + console.log(`Hiding curve: ${curve.parameter}`) + } + if (this.requestRedraw) this.requestRedraw() + return true + } + } + } + } + + // Clicking elsewhere on track header selects it + this.selectTrack(track) + if (this.requestRedraw) this.requestRedraw() + return true + } + } + + // Check if clicking in timeline area (segments or curves) + if (trackY >= 0 && x >= this.trackHeaderWidth) { + const adjustedY = trackY - this.trackScrollOffset + const adjustedX = x - this.trackHeaderWidth + const track = this.trackHierarchy.getTrackAtY(adjustedY) + + if (track) { + // Phase 6: Check if clicking on tangent handle (highest priority for curves) + if ((track.type === 'object' || track.type === 'shape') && track.object.curvesMode === 'curve') { + const tangentInfo = this.getTangentHandleAtPoint(track, adjustedX, adjustedY) + console.log(`Tangent handle check result:`, tangentInfo) + if (tangentInfo) { + // Start tangent dragging + this.draggingTangent = { + keyframe: tangentInfo.keyframe, + handle: tangentInfo.handle, + curve: tangentInfo.curve, + track: track, + initialEase: tangentInfo.handle === 'out' + ? { ...tangentInfo.keyframe.easeOut } + : { ...tangentInfo.keyframe.easeIn }, + adjacentKeyframe: tangentInfo.handle === 'out' + ? tangentInfo.nextKeyframe + : tangentInfo.prevKeyframe + } + + // Enable global mouse events for dragging + this._globalEvents.add("mousemove") + this._globalEvents.add("mouseup") + + console.log('Started dragging', tangentInfo.handle, 'tangent handle') + if (this.requestRedraw) this.requestRedraw() + return true + } + } + + // Phase 5: Check if clicking on expanded curves + if ((track.type === 'object' || track.type === 'shape') && track.object.curvesMode === 'curve') { + const curveClickResult = this.handleCurveClick(track, adjustedX, adjustedY) + if (curveClickResult) { + return true + } + } + + // Phase 6: Check if clicking on segment edge to start edge dragging (priority over segment dragging) + const edgeInfo = this.getSegmentEdgeAtPoint(track, adjustedX, adjustedY) + if (edgeInfo && edgeInfo.keyframe) { + // Select the track + this.selectTrack(track) + + // Start edge dragging + this.draggingEdge = { + track: track, + edge: edgeInfo.edge, + keyframe: edgeInfo.keyframe, + animationData: edgeInfo.animationData, + curveName: edgeInfo.curveName, + initialTime: edgeInfo.keyframe.time, + otherEdgeTime: edgeInfo.edge === 'left' ? edgeInfo.endTime : edgeInfo.startTime + } + + // Enable global mouse events for dragging + this._globalEvents.add("mousemove") + this._globalEvents.add("mouseup") + + console.log('Started dragging', edgeInfo.edge, 'edge at time', edgeInfo.keyframe.time) + if (this.requestRedraw) this.requestRedraw() + return true + } + + // Check if clicking on loop corner (top-right) to extend/loop clip + const loopCornerInfo = this.getAudioClipLoopCornerAtPoint(track, adjustedX, adjustedY) + if (loopCornerInfo) { + // Skip if right-clicking (button 2) + if (this.lastClickEvent?.button === 2) { + return false + } + + // Select the track + this.selectTrack(track) + + // Start loop corner dragging + this.draggingLoopCorner = { + track: track, + clip: loopCornerInfo.clip, + clipIndex: loopCornerInfo.clipIndex, + audioTrack: loopCornerInfo.audioTrack, + isMIDI: loopCornerInfo.isMIDI, + initialDuration: loopCornerInfo.clip.duration + } + + // Enable global mouse events for dragging + this._globalEvents.add("mousemove") + this._globalEvents.add("mouseup") + + console.log('Started dragging loop corner') + if (this.requestRedraw) this.requestRedraw() + return true + } + + // Check if clicking on audio clip edge to start trimming + const audioEdgeInfo = this.getAudioClipEdgeAtPoint(track, adjustedX, adjustedY) + if (audioEdgeInfo) { + // Skip if right-clicking (button 2) + if (this.lastClickEvent?.button === 2) { + return false + } + + // Select the track + this.selectTrack(track) + + // Start audio clip edge dragging + this.draggingAudioClipEdge = { + track: track, + edge: audioEdgeInfo.edge, + clip: audioEdgeInfo.clip, + clipIndex: audioEdgeInfo.clipIndex, + audioTrack: audioEdgeInfo.audioTrack, + initialClipStart: audioEdgeInfo.clip.startTime, + initialClipDuration: audioEdgeInfo.clip.duration, + initialClipOffset: audioEdgeInfo.clip.offset, + initialLinkedVideoOffset: audioEdgeInfo.clip.linkedVideoClip?.offset || 0 + } + + // Enable global mouse events for dragging + this._globalEvents.add("mousemove") + this._globalEvents.add("mouseup") + + console.log('Started dragging audio clip', audioEdgeInfo.edge, 'edge') + if (this.requestRedraw) this.requestRedraw() + return true + } + + // Check if clicking on audio clip to start dragging + const audioClipInfo = this.getAudioClipAtPoint(track, adjustedX, adjustedY) + if (audioClipInfo) { + // Skip drag if right-clicking (button 2) + if (this.lastClickEvent?.button === 2) { + return false + } + + // Select the track + this.selectTrack(track) + + // If this is a MIDI clip, update piano roll selection + if (audioClipInfo.isMIDI && context.pianoRollEditor) { + context.pianoRollEditor.selectedClipId = audioClipInfo.clip.clipId + context.pianoRollEditor.selectedNotes.clear() + // Trigger piano roll redraw to show the selection change + if (context.pianoRollRedraw) { + context.pianoRollRedraw() + } + } + + // Start audio clip dragging + const clickTime = this.timelineState.pixelToTime(adjustedX) + this.draggingAudioClip = { + track: track, + clip: audioClipInfo.clip, + clipIndex: audioClipInfo.clipIndex, + audioTrack: audioClipInfo.audioTrack, + initialMouseTime: clickTime, + initialClipStartTime: audioClipInfo.clip.startTime + } + + // Enable global mouse events for dragging + this._globalEvents.add("mousemove") + this._globalEvents.add("mouseup") + + console.log('Started dragging audio clip at time', audioClipInfo.clip.startTime) + if (this.requestRedraw) this.requestRedraw() + return true + } + + // Check if clicking on video clip edge to start trimming + const videoEdgeInfo = this.getVideoClipEdgeAtPoint(track, adjustedX, adjustedY) + if (videoEdgeInfo) { + // Skip if right-clicking (button 2) + if (this.lastClickEvent?.button === 2) { + return false + } + + // Select the track + this.selectTrack(track) + + // Start video clip edge dragging + this.draggingVideoClipEdge = { + track: track, + edge: videoEdgeInfo.edge, + clip: videoEdgeInfo.clip, + clipIndex: videoEdgeInfo.clipIndex, + videoLayer: videoEdgeInfo.videoLayer, + initialClipStart: videoEdgeInfo.clip.startTime, + initialClipDuration: videoEdgeInfo.clip.duration, + initialClipOffset: videoEdgeInfo.clip.offset, + initialLinkedAudioOffset: videoEdgeInfo.clip.linkedAudioClip?.offset || 0 + } + + // Enable global mouse events for dragging + this._globalEvents.add("mousemove") + this._globalEvents.add("mouseup") + + console.log('Started dragging video clip', videoEdgeInfo.edge, 'edge') + if (this.requestRedraw) this.requestRedraw() + return true + } + + // Check if clicking on video clip to start dragging + const videoClipInfo = this.getVideoClipAtPoint(track, adjustedX, adjustedY) + if (videoClipInfo) { + // Skip drag if right-clicking (button 2) + if (this.lastClickEvent?.button === 2) { + return false + } + + // Select the track + this.selectTrack(track) + + // Start video clip dragging + const clickTime = this.timelineState.pixelToTime(adjustedX) + this.draggingVideoClip = { + track: track, + clip: videoClipInfo.clip, + clipIndex: videoClipInfo.clipIndex, + videoLayer: videoClipInfo.videoLayer, + initialMouseTime: clickTime, + initialClipStartTime: videoClipInfo.clip.startTime + } + + // Enable global mouse events for dragging + this._globalEvents.add("mousemove") + this._globalEvents.add("mouseup") + + console.log('Started dragging video clip at time', videoClipInfo.clip.startTime) + if (this.requestRedraw) this.requestRedraw() + return true + } + + // Phase 6: Check if clicking on segment to start dragging + const segmentInfo = this.getSegmentAtPoint(track, adjustedX, adjustedY) + if (segmentInfo) { + // Select the track + this.selectTrack(track) + + // Start segment dragging + const clickTime = this.timelineState.pixelToTime(adjustedX) + this.draggingSegment = { + track: track, + initialMouseTime: clickTime, + segmentStartTime: segmentInfo.startTime, + segmentEndTime: segmentInfo.endTime, + animationData: segmentInfo.animationData, + objectIdx: track.object.idx + } + + // Enable global mouse events for dragging + this._globalEvents.add("mousemove") + this._globalEvents.add("mouseup") + + console.log('Started dragging segment at time', segmentInfo.startTime) + if (this.requestRedraw) this.requestRedraw() + return true + } + + // Fallback: clicking anywhere on track in timeline area selects it + // This is especially important for audio tracks that may not have clips yet + this.selectTrack(track) + if (this.requestRedraw) this.requestRedraw() + return true + } + } + + return false + } + + /** + * Handle click on curve area in expanded mode (Phase 5) + * Returns true if click was handled + */ + handleCurveClick(track, x, y) { + const trackIndex = this.trackHierarchy.tracks.indexOf(track) + const trackY = this.trackHierarchy.getTrackY(trackIndex) + + const curveHeight = 80 + const startY = trackY + 10 // Start below segment area + const padding = 5 + + // Check if y is within curve area + if (y < startY || y > startY + curveHeight) { + return false + } + + // Get AnimationData and curves for this track + const obj = track.object + let animationData = null + + if (track.type === 'object') { + for (let layer of this.context.activeObject.allLayers) { + if (layer.children && layer.children.includes(obj)) { + animationData = layer.animationData + break + } + } + } else if (track.type === 'shape') { + const findShapeLayer = (searchObj) => { + for (let layer of searchObj.children) { + if (layer.shapes && layer.shapes.includes(obj)) { + animationData = layer.animationData + return true + } + if (layer.children) { + for (let child of layer.children) { + if (findShapeLayer(child)) return true + } + } + } + return false + } + findShapeLayer(this.context.activeObject) + } + + if (!animationData) return false + + // Get all curves for this object/shape + const curves = [] + for (let curveName in animationData.curves) { + const curve = animationData.curves[curveName] + if (track.type === 'object' && curveName.startsWith(`child.${obj.idx}.`)) { + curves.push(curve) + } else if (track.type === 'shape' && curveName.startsWith(`shape.${obj.shapeId}.`)) { + curves.push(curve) + } + } + + if (curves.length === 0) return false + + // Calculate value range for scaling + let minValue = Infinity + let maxValue = -Infinity + for (let curve of curves) { + for (let keyframe of curve.keyframes) { + minValue = Math.min(minValue, keyframe.value) + maxValue = Math.max(maxValue, keyframe.value) + } + } + const valueRange = maxValue - minValue + const rangePadding = valueRange * 0.1 || 1 + minValue -= rangePadding + maxValue += rangePadding + + // Helper to convert Y position to value + const yToValue = (yPos) => { + const normalizedY = (startY + curveHeight - padding - yPos) / (curveHeight - 2 * padding) + return minValue + (normalizedY * (maxValue - minValue)) + } + + // Convert click position to time and value + let clickTime = this.timelineState.pixelToTime(x) + const clickValue = yToValue(y) + + // Apply snapping to click time + clickTime = this.timelineState.snapTime(clickTime) + + // Check if clicking close to an existing keyframe on ANY curve (within 8px) + // First pass: check all curves for keyframe hits + for (let curve of curves) { + // Skip hidden curves + if (this.hiddenCurves.has(curve.parameter)) continue + + for (let keyframe of curve.keyframes) { + const kfX = this.timelineState.timeToPixel(keyframe.time) + const kfY = startY + curveHeight - padding - ((keyframe.value - minValue) / (maxValue - minValue) * (curveHeight - 2 * padding)) + const distance = Math.sqrt((x - kfX) ** 2 + (y - kfY) ** 2) + + if (distance < 8) { + // Check for multi-select modifier keys from click event + const shiftKey = this.lastClickEvent?.shiftKey || false + const ctrlKey = this.lastClickEvent?.ctrlKey || this.lastClickEvent?.metaKey || false + + if (shiftKey) { + // Shift: Add to selection + this.selectedKeyframes.add(keyframe) + console.log(`Added keyframe to selection, now have ${this.selectedKeyframes.size} selected`) + } else if (ctrlKey) { + // Ctrl/Cmd: Toggle selection + if (this.selectedKeyframes.has(keyframe)) { + this.selectedKeyframes.delete(keyframe) + console.log(`Removed keyframe from selection, now have ${this.selectedKeyframes.size} selected`) + } else { + this.selectedKeyframes.add(keyframe) + console.log(`Added keyframe to selection, now have ${this.selectedKeyframes.size} selected`) + } + } else { + // No modifier: Select only this keyframe + this.selectedKeyframes.clear() + this.selectedKeyframes.add(keyframe) + console.log(`Selected single keyframe`) + } + + // Don't start dragging if this was a right-click + if (this.lastClickEvent?.button === 2) { + console.log(`Skipping drag - right-click detected (button=${this.lastClickEvent.button})`) + return true + } + + // Start dragging this keyframe (and all selected keyframes) + this.draggingKeyframe = { + curve: curve, // Use the actual curve we clicked on + keyframe: keyframe, + track: track, + initialTime: keyframe.time, + initialValue: keyframe.value, + minValue: minValue, + maxValue: maxValue, + curveHeight: curveHeight, + startY: startY, + padding: padding, + yToValue: yToValue // Store the conversion function + } + + // Enable global mouse events for dragging + this._globalEvents.add("mousemove") + this._globalEvents.add("mouseup") + + console.log('Started dragging keyframe at time', keyframe.time, 'on curve', curve.parameter) + if (this.requestRedraw) this.requestRedraw() + return true + } + } + } + + // No keyframe was clicked, so add a new one + // Find the closest curve to the click position (only visible curves) + let targetCurve = null + let minDistance = Infinity + + for (let curve of curves) { + // Skip hidden curves + if (this.hiddenCurves.has(curve.parameter)) continue + + // For each curve, find the value at this time + const curveValue = curve.interpolate(clickTime) + if (curveValue !== null) { + const curveY = startY + curveHeight - padding - ((curveValue - minValue) / (maxValue - minValue) * (curveHeight - 2 * padding)) + const distance = Math.abs(y - curveY) + + if (distance < minDistance) { + minDistance = distance + targetCurve = curve + } + } + } + + // If all curves are hidden, don't add a keyframe + if (!targetCurve) return false + + console.log('Adding keyframe at time', clickTime, 'with value', clickValue, 'to curve', targetCurve.parameter) + + // Create keyframe directly + const newKeyframe = { + time: clickTime, + value: clickValue, + interpolation: 'linear', + easeIn: { x: 0.42, y: 0 }, + easeOut: { x: 0.58, y: 1 }, + idx: this.generateUUID() + } + + targetCurve.addKeyframe(newKeyframe) + + if (this.requestRedraw) this.requestRedraw() + return true + } + + /** + * Generate UUID (Phase 5) + */ + generateUUID() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0 + const v = c === 'x' ? r : (r & 0x3 | 0x8) + return v.toString(16) + }) + } + + /** + * Check if a point (in timeline area coordinates) is inside a segment for the given track + */ + isPointInSegment(track, x, y) { + const trackIndex = this.trackHierarchy.tracks.indexOf(track) + if (trackIndex === -1) return false + + const trackY = this.trackHierarchy.getTrackY(trackIndex) + const trackHeight = this.trackHierarchy.trackHeight // Use base height for segment bounds + const segmentTop = trackY + 5 + const segmentBottom = trackY + trackHeight - 5 + + // Check if y is within segment bounds + if (y < segmentTop || y > segmentBottom) return false + + const clickTime = this.timelineState.pixelToTime(x) + const frameDuration = 1 / this.timelineState.framerate + const minSegmentDuration = frameDuration + + if (track.type === 'object') { + // Check frameNumber curve for objects + const obj = track.object + let parentLayer = null + for (let layer of this.context.activeObject.allLayers) { + if (layer.children && layer.children.includes(obj)) { + parentLayer = layer + break + } + } + + if (!parentLayer || !parentLayer.animationData) return false + + const frameNumberKey = `child.${obj.idx}.frameNumber` + const frameNumberCurve = parentLayer.animationData.curves[frameNumberKey] + + if (!frameNumberCurve || !frameNumberCurve.keyframes) return false + + // Check if clickTime is within any segment + let segmentStart = null + for (let j = 0; j < frameNumberCurve.keyframes.length; j++) { + const keyframe = frameNumberCurve.keyframes[j] + + if (keyframe.value > 0) { + if (segmentStart === null) { + segmentStart = keyframe.time + } + + const isLast = (j === frameNumberCurve.keyframes.length - 1) + const nextEndsSegment = !isLast && frameNumberCurve.keyframes[j + 1].value === 0 + + if (isLast || nextEndsSegment) { + const segmentEnd = nextEndsSegment ? frameNumberCurve.keyframes[j + 1].time : keyframe.time + minSegmentDuration + + if (clickTime >= segmentStart && clickTime <= segmentEnd) { + return true + } + segmentStart = null + } + } + } + } else if (track.type === 'shape') { + // Check exists curve for shapes + const shape = track.object + let shapeLayer = null + const findShapeLayer = (obj) => { + for (let layer of obj.children) { + if (layer.shapes && layer.shapes.includes(shape)) { + shapeLayer = layer + return true + } + if (layer.children) { + for (let child of layer.children) { + if (findShapeLayer(child)) return true + } + } + } + return false + } + findShapeLayer(this.context.activeObject) + + if (!shapeLayer || !shapeLayer.animationData) return false + + const existsCurveKey = `shape.${shape.shapeId}.exists` + const existsCurve = shapeLayer.animationData.curves[existsCurveKey] + + if (!existsCurve || !existsCurve.keyframes) return false + + // Check if clickTime is within any segment + let segmentStart = null + for (let j = 0; j < existsCurve.keyframes.length; j++) { + const keyframe = existsCurve.keyframes[j] + + if (keyframe.value > 0) { + if (segmentStart === null) { + segmentStart = keyframe.time + } + + const isLast = (j === existsCurve.keyframes.length - 1) + const nextEndsSegment = !isLast && existsCurve.keyframes[j + 1].value === 0 + + if (isLast || nextEndsSegment) { + const segmentEnd = nextEndsSegment ? existsCurve.keyframes[j + 1].time : keyframe.time + minSegmentDuration + + if (clickTime >= segmentStart && clickTime <= segmentEnd) { + return true + } + segmentStart = null + } + } + } + } + + return false + } + + /** + * Get segment information at a point (Phase 6) + * Returns {startTime, endTime, animationData} if point is in a segment, null otherwise + */ + getSegmentAtPoint(track, x, y) { + const trackIndex = this.trackHierarchy.tracks.indexOf(track) + if (trackIndex === -1) return null + + const trackY = this.trackHierarchy.getTrackY(trackIndex) + const trackHeight = this.trackHierarchy.trackHeight + const segmentTop = trackY + 5 + const segmentBottom = trackY + trackHeight - 5 + + // Check if y is within segment bounds + if (y < segmentTop || y > segmentBottom) return null + + const clickTime = this.timelineState.pixelToTime(x) + const frameDuration = 1 / this.timelineState.framerate + const minSegmentDuration = frameDuration + + if (track.type === 'object') { + // Check frameNumber curve for objects + const obj = track.object + let parentLayer = null + for (let layer of this.context.activeObject.allLayers) { + if (layer.children && layer.children.includes(obj)) { + parentLayer = layer + break + } + } + + if (!parentLayer || !parentLayer.animationData) return null + + const frameNumberKey = `child.${obj.idx}.frameNumber` + const frameNumberCurve = parentLayer.animationData.curves[frameNumberKey] + + if (!frameNumberCurve || !frameNumberCurve.keyframes) return null + + // Check if clickTime is within any segment + let segmentStart = null + for (let j = 0; j < frameNumberCurve.keyframes.length; j++) { + const keyframe = frameNumberCurve.keyframes[j] + + if (keyframe.value > 0) { + if (segmentStart === null) { + segmentStart = keyframe.time + } + + const isLast = (j === frameNumberCurve.keyframes.length - 1) + const nextEndsSegment = !isLast && frameNumberCurve.keyframes[j + 1].value === 0 + + if (isLast || nextEndsSegment) { + const segmentEnd = nextEndsSegment ? frameNumberCurve.keyframes[j + 1].time : keyframe.time + minSegmentDuration + + if (clickTime >= segmentStart && clickTime <= segmentEnd) { + return { + startTime: segmentStart, + endTime: segmentEnd, + animationData: parentLayer.animationData + } + } + segmentStart = null + } + } + } + } else if (track.type === 'shape') { + // Check exists curve for shapes + const shape = track.object + let shapeLayer = null + const findShapeLayer = (obj) => { + for (let layer of obj.children) { + if (layer.shapes && layer.shapes.includes(shape)) { + shapeLayer = layer + return true + } + if (layer.children) { + for (let child of layer.children) { + if (findShapeLayer(child)) return true + } + } + } + return false + } + findShapeLayer(this.context.activeObject) + + if (!shapeLayer || !shapeLayer.animationData) return null + + const existsCurveKey = `shape.${shape.shapeId}.exists` + const existsCurve = shapeLayer.animationData.curves[existsCurveKey] + + if (!existsCurve || !existsCurve.keyframes) return null + + // Check if clickTime is within any segment + let segmentStart = null + for (let j = 0; j < existsCurve.keyframes.length; j++) { + const keyframe = existsCurve.keyframes[j] + + if (keyframe.value > 0) { + if (segmentStart === null) { + segmentStart = keyframe.time + } + + const isLast = (j === existsCurve.keyframes.length - 1) + const nextEndsSegment = !isLast && existsCurve.keyframes[j + 1].value === 0 + + if (isLast || nextEndsSegment) { + const segmentEnd = nextEndsSegment ? existsCurve.keyframes[j + 1].time : keyframe.time + minSegmentDuration + + if (clickTime >= segmentStart && clickTime <= segmentEnd) { + return { + startTime: segmentStart, + endTime: segmentEnd, + animationData: shapeLayer.animationData + } + } + segmentStart = null + } + } + } + } + + return null + } + + /** + * Get audio clip at a point + * Returns {clip, clipIndex, audioTrack} if clicking on an audio clip + */ + getAudioClipAtPoint(track, x, y) { + if (track.type !== 'audio') return null + + const trackIndex = this.trackHierarchy.tracks.indexOf(track) + if (trackIndex === -1) return null + + const trackY = this.trackHierarchy.getTrackY(trackIndex) + const trackHeight = this.trackHierarchy.trackHeight + const clipTop = trackY + 5 + const clipBottom = trackY + trackHeight - 5 + + // Check if y is within clip bounds + if (y < clipTop || y > clipBottom) return null + + const clickTime = this.timelineState.pixelToTime(x) + const audioTrack = track.object + + // Check each clip + for (let i = 0; i < audioTrack.clips.length; i++) { + const clip = audioTrack.clips[i] + const clipStart = clip.startTime + const clipEnd = clip.startTime + clip.duration + + if (clickTime >= clipStart && clickTime <= clipEnd) { + return { + clip: clip, + clipIndex: i, + audioTrack: audioTrack, + isMIDI: audioTrack.type === 'midi' + } + } + } + + return null + } + + getAudioClipEdgeAtPoint(track, x, y) { + const clipInfo = this.getAudioClipAtPoint(track, x, y) + if (!clipInfo) return null + + const clickTime = this.timelineState.pixelToTime(x) + const edgeThreshold = 8 / this.timelineState.pixelsPerSecond // 8 pixels in time units + + const clipStart = clipInfo.clip.startTime + const clipEnd = clipInfo.clip.startTime + clipInfo.clip.duration + + // Check if near left edge + if (Math.abs(clickTime - clipStart) <= edgeThreshold) { + return { + edge: 'left', + clip: clipInfo.clip, + clipIndex: clipInfo.clipIndex, + audioTrack: clipInfo.audioTrack, + clipStart: clipStart, + clipEnd: clipEnd + } + } + + // Check if near right edge + if (Math.abs(clickTime - clipEnd) <= edgeThreshold) { + return { + edge: 'right', + clip: clipInfo.clip, + clipIndex: clipInfo.clipIndex, + audioTrack: clipInfo.audioTrack, + clipStart: clipStart, + clipEnd: clipEnd + } + } + + return null + } + + /** + * Check if hovering over the loop corner (top-right) of an audio/MIDI clip + * Returns clip info if in the loop corner zone + */ + getAudioClipLoopCornerAtPoint(track, x, y) { + if (track.type !== 'audio') return null + + const trackIndex = this.trackHierarchy.tracks.indexOf(track) + if (trackIndex === -1) return null + + const trackY = this.trackHierarchy.getTrackY(trackIndex) + const trackHeight = this.trackHierarchy.trackHeight + const clipTop = trackY + 5 + const cornerSize = 12 // Size of the corner hot zone in pixels + + // Check if y is in the top portion of the clip + if (y < clipTop || y > clipTop + cornerSize) return null + + const clickTime = this.timelineState.pixelToTime(x) + const audioTrack = track.object + + // Check each clip + for (let i = 0; i < audioTrack.clips.length; i++) { + const clip = audioTrack.clips[i] + const clipEnd = clip.startTime + clip.duration + const clipEndX = this.timelineState.timeToPixel(clipEnd) + + // Check if x is near the right edge (within corner zone) + if (x >= clipEndX - cornerSize && x <= clipEndX) { + return { + clip: clip, + clipIndex: i, + audioTrack: audioTrack, + isMIDI: audioTrack.type === 'midi' + } + } + } + + return null + } + + getVideoClipAtPoint(track, x, y) { + if (track.type !== 'video') return null + + const trackIndex = this.trackHierarchy.tracks.indexOf(track) + if (trackIndex === -1) return null + + const trackY = this.trackHierarchy.getTrackY(trackIndex) + const trackHeight = this.trackHierarchy.trackHeight + const clipTop = trackY + 5 + const clipBottom = trackY + trackHeight - 5 + + // Check if y is within clip bounds + if (y < clipTop || y > clipBottom) return null + + const clickTime = this.timelineState.pixelToTime(x) + const videoLayer = track.object + + // Check each clip + for (let i = 0; i < videoLayer.clips.length; i++) { + const clip = videoLayer.clips[i] + const clipStart = clip.startTime + const clipEnd = clip.startTime + clip.duration + + if (clickTime >= clipStart && clickTime <= clipEnd) { + return { + clip: clip, + clipIndex: i, + videoLayer: videoLayer + } + } + } + + return null + } + + getVideoClipEdgeAtPoint(track, x, y) { + const clipInfo = this.getVideoClipAtPoint(track, x, y) + if (!clipInfo) return null + + const clickTime = this.timelineState.pixelToTime(x) + const edgeThreshold = 8 / this.timelineState.pixelsPerSecond // 8 pixels in time units + + const clipStart = clipInfo.clip.startTime + const clipEnd = clipInfo.clip.startTime + clipInfo.clip.duration + + // Check if near left edge + if (Math.abs(clickTime - clipStart) <= edgeThreshold) { + return { + edge: 'left', + clip: clipInfo.clip, + clipIndex: clipInfo.clipIndex, + videoLayer: clipInfo.videoLayer, + clipStart: clipStart, + clipEnd: clipEnd + } + } + + // Check if near right edge + if (Math.abs(clickTime - clipEnd) <= edgeThreshold) { + return { + edge: 'right', + clip: clipInfo.clip, + clipIndex: clipInfo.clipIndex, + videoLayer: clipInfo.videoLayer, + clipStart: clipStart, + clipEnd: clipEnd + } + } + + return null + } + + /** + * Get segment edge at a point (Phase 6) + * Returns {edge: 'left'|'right', startTime, endTime, keyframe, animationData, curveName} if near an edge + */ + getSegmentEdgeAtPoint(track, x, y) { + const segmentInfo = this.getSegmentAtPoint(track, x, y) + if (!segmentInfo) return null + + const clickTime = this.timelineState.pixelToTime(x) + const edgeThreshold = 8 / this.timelineState.pixelsPerSecond // 8 pixels in time units + + // Determine which curve to look at + let curveName + if (track.type === 'object') { + curveName = `child.${track.object.idx}.frameNumber` + } else if (track.type === 'shape') { + curveName = `shape.${track.object.shapeId}.exists` + } else { + return null + } + + const curve = segmentInfo.animationData.curves[curveName] + if (!curve || !curve.keyframes) return null + + // Find the keyframes that define this segment's edges + let startKeyframe = null + let endKeyframe = null + + for (let keyframe of curve.keyframes) { + if (Math.abs(keyframe.time - segmentInfo.startTime) < 0.0001 && keyframe.value > 0) { + startKeyframe = keyframe + } + // For end keyframe, check both at exact endTime AND just before it (for natural segment ends) + if (Math.abs(keyframe.time - segmentInfo.endTime) < 0.0001) { + endKeyframe = keyframe + } else if (keyframe.value > 0 && keyframe.time < segmentInfo.endTime && keyframe.time >= segmentInfo.startTime) { + // Track the last positive keyframe in case segment ends naturally + if (!endKeyframe || keyframe.time > endKeyframe.time) { + endKeyframe = keyframe + } + } + } + + // Check if click is near left edge + if (Math.abs(clickTime - segmentInfo.startTime) <= edgeThreshold) { + return { + edge: 'left', + startTime: segmentInfo.startTime, + endTime: segmentInfo.endTime, + keyframe: startKeyframe, + animationData: segmentInfo.animationData, + curveName: curveName + } + } + + // Check if click is near right edge + // For natural segment ends, the endKeyframe is at an earlier time than segmentInfo.endTime + const rightEdgeTime = endKeyframe ? endKeyframe.time : segmentInfo.endTime + if (Math.abs(clickTime - rightEdgeTime) <= edgeThreshold) { + return { + edge: 'right', + startTime: segmentInfo.startTime, + endTime: segmentInfo.endTime, + keyframe: endKeyframe, + animationData: segmentInfo.animationData, + curveName: curveName + } + } + + return null + } + + /** + * Check if clicking on a tangent handle (Phase 6) + * Returns {keyframe, handle: 'in'|'out', curve, nextKeyframe|prevKeyframe} if hitting a handle + */ + getTangentHandleAtPoint(track, x, y) { + if (track.type !== 'object' && track.type !== 'shape') return null + if (track.object.curvesMode !== 'curve') return null + + const trackIndex = this.trackHierarchy.tracks.indexOf(track) + const trackY = this.trackHierarchy.getTrackY(trackIndex) + + const curveHeight = 80 + const startY = trackY + 10 + const padding = 5 + + // Check if y is within curve area + if (y < startY || y > startY + curveHeight) return null + + // Get all curves for this track + const obj = track.object + let animationData = null + + if (track.type === 'object') { + for (let layer of this.context.activeObject.allLayers) { + if (layer.children && layer.children.includes(obj)) { + animationData = layer.animationData + break + } + } + } else if (track.type === 'shape') { + const findShapeLayer = (searchObj) => { + for (let layer of searchObj.children) { + if (layer.shapes && layer.shapes.includes(obj)) { + animationData = layer.animationData + return true + } + if (layer.children) { + for (let child of layer.children) { + if (findShapeLayer(child)) return true + } + } + } + return false + } + findShapeLayer(this.context.activeObject) + } + + if (!animationData) return null + + // Get all curves + const curves = [] + for (let curveName in animationData.curves) { + const curve = animationData.curves[curveName] + if (track.type === 'object' && curveName.startsWith(`child.${obj.idx}.`)) { + curves.push(curve) + } else if (track.type === 'shape' && curveName.startsWith(`shape.${obj.shapeId}.`)) { + curves.push(curve) + } + } + + // Calculate value range + let minValue = Infinity + let maxValue = -Infinity + for (let curve of curves) { + for (let keyframe of curve.keyframes) { + minValue = Math.min(minValue, keyframe.value) + maxValue = Math.max(maxValue, keyframe.value) + } + } + const valueRange = maxValue - minValue + const rangePadding = valueRange * 0.1 || 1 + minValue -= rangePadding + maxValue += rangePadding + + const valueToY = (value) => { + const normalizedValue = (value - minValue) / (maxValue - minValue) + return startY + curveHeight - padding - (normalizedValue * (curveHeight - 2 * padding)) + } + + // Check each curve for tangent handles + for (let curve of curves) { + // Skip hidden curves + if (this.hiddenCurves.has(curve.parameter)) continue + + // Only check bezier keyframes that are selected + for (let i = 0; i < curve.keyframes.length; i++) { + const kf = curve.keyframes[i] + + // Only show handles for selected keyframes with bezier interpolation + if (!this.selectedKeyframes.has(kf) || kf.interpolation !== 'bezier') continue + + const kfX = this.timelineState.timeToPixel(kf.time) + const kfY = valueToY(kf.value) + + // Check out handle (if there's a next keyframe) + if (i < curve.keyframes.length - 1) { + const nextKf = curve.keyframes[i + 1] + const nextX = this.timelineState.timeToPixel(nextKf.time) + const nextY = valueToY(nextKf.value) + + const dx = nextX - kfX + const dy = nextY - kfY + + const easeOut = kf.easeOut || { x: 0.42, y: 0 } + const handleX = kfX + (easeOut.x * dx) + const handleY = kfY + (easeOut.y * dy) + + const distance = Math.sqrt((x - handleX) ** 2 + (y - handleY) ** 2) + if (distance < 8) { // 8px hit radius + return { + keyframe: kf, + handle: 'out', + curve: curve, + nextKeyframe: nextKf + } + } + } + + // Check in handle (if there's a previous keyframe) + if (i > 0) { + const prevKf = curve.keyframes[i - 1] + const prevX = this.timelineState.timeToPixel(prevKf.time) + const prevY = valueToY(prevKf.value) + + const dx = kfX - prevX + const dy = kfY - prevY + + const easeIn = kf.easeIn || { x: 0.58, y: 1 } + const handleX = prevX + (easeIn.x * dx) + const handleY = prevY + (easeIn.y * dy) + + const distance = Math.sqrt((x - handleX) ** 2 + (y - handleY) ** 2) + if (distance < 8) { // 8px hit radius + return { + keyframe: kf, + handle: 'in', + curve: curve, + prevKeyframe: prevKf + } + } + } + } + } + + return null + } + + /** + * Check if a track is currently selected + */ + isTrackSelected(track) { + if (track.type === 'layer') { + return this.context.activeObject.activeLayer === track.object + } else if (track.type === 'shape') { + return this.context.shapeselection?.includes(track.object) + } else if (track.type === 'object') { + return this.context.selection?.includes(track.object) + } else if (track.type === 'audio') { + // Audio tracks use activeLayer like regular layers + return this.context.activeObject.activeLayer === track.object + } + return false + } + + /** + * Select a track and update the stage selection + */ + selectTrack(track) { + // Store old selection before changing + this.context.oldselection = this.context.selection + this.context.oldshapeselection = this.context.shapeselection + + if (track.type === 'layer') { + // Set the layer as active (this will clear _activeAudioTrack) + this.context.activeObject.activeLayer = track.object + // Clear selections when selecting layer + this.context.selection = [] + this.context.shapeselection = [] + + // Clear node editor when selecting a non-audio layer + setTimeout(() => this.context.reloadNodeEditor?.(), 50); + } else if (track.type === 'shape') { + // Find the layer this shape belongs to and select it + for (let i = 0; i < this.context.activeObject.allLayers.length; i++) { + const layer = this.context.activeObject.allLayers[i] + if (layer.shapes && layer.shapes.includes(track.object)) { + // Set the layer as active (this will clear _activeAudioTrack) + this.context.activeObject.activeLayer = layer + // Set shape selection + this.context.shapeselection = [track.object] + this.context.selection = [] + break + } + } + } else if (track.type === 'object') { + // Select the GraphicsObject + this.context.selection = [track.object] + this.context.shapeselection = [] + } else if (track.type === 'audio') { + // Audio track selected - set as active layer and clear other selections + // Audio tracks can act as layers (they have animationData, shapes=[], children=[]) + this.context.activeObject.activeLayer = track.object + this.context.selection = [] + this.context.shapeselection = [] + + // Reload the node editor for both MIDI and audio tracks + if (track.object.type === 'midi' || track.object.type === 'audio') { + setTimeout(() => this.context.reloadNodeEditor?.(), 50); + } + + // Set active MIDI track for external MIDI input routing + if (track.object.type === 'midi') { + invoke('audio_set_active_midi_track', { trackId: track.object.audioTrackId }).catch(err => { + console.error('Failed to set active MIDI track:', err); + }); + } + } else { + // Non-audio track selected, clear active MIDI track + invoke('audio_set_active_midi_track', { trackId: null }).catch(err => { + console.error('Failed to clear active MIDI track:', err); + }); + } + + // Update the stage UI to reflect selection changes + if (this.context.updateUI) { + this.context.updateUI() + } + + // Update menu to enable/disable menu items based on selection + if (this.context.updateMenu) { + this.context.updateMenu() + } + } + + mousemove(x, y) { + // Check for curve mode button hover (in track header area) + const trackY = y - this.ruler.height + if (trackY >= 0 && x < this.trackHeaderWidth) { + const adjustedY = trackY - this.trackScrollOffset + const track = this.trackHierarchy.getTrackAtY(adjustedY) + + if (track && (track.type === 'object' || track.type === 'shape' || track.type === 'audio')) { + const trackIndex = this.trackHierarchy.tracks.indexOf(track) + const trackYPos = this.trackHierarchy.getTrackY(trackIndex) + const buttonSize = 16 + const buttonY = trackYPos + (this.trackHierarchy.trackHeight - buttonSize) / 2 + let buttonX = this.trackHeaderWidth - 10 - buttonSize // Rightmost button + + // Check if hovering over curve mode button + if (x >= buttonX && x <= buttonX + buttonSize && + adjustedY >= buttonY && adjustedY <= buttonY + buttonSize) { + // Get the mode name for tooltip + const modeName = track.object.curvesMode === 'curve' ? 'Curve View' : + track.object.curvesMode === 'keyframe' ? 'Keyframe View' : 'Segment View' + this.hoveredCurveModeButton = { + x: x, + y: y, + modeName: modeName + } + if (this.requestRedraw) this.requestRedraw() + } else if (this.hoveredCurveModeButton) { + this.hoveredCurveModeButton = null + if (this.requestRedraw) this.requestRedraw() + } + } else if (this.hoveredCurveModeButton) { + this.hoveredCurveModeButton = null + if (this.requestRedraw) this.requestRedraw() + } + } else if (this.hoveredCurveModeButton) { + this.hoveredCurveModeButton = null + if (this.requestRedraw) this.requestRedraw() + } + + // Update hover state for keyframe tooltips (even when not dragging) + // Clear hover if mouse is outside timeline curve areas + let foundHover = false + + if (!this.draggingKeyframe && !this.draggingPlayhead) { + const trackY = y - this.ruler.height + if (trackY >= 0 && x >= this.trackHeaderWidth) { + const adjustedY = trackY - this.trackScrollOffset + const adjustedX = x - this.trackHeaderWidth + const track = this.trackHierarchy.getTrackAtY(adjustedY) + + if (track && (track.type === 'object' || track.type === 'shape') && track.object.curvesMode === 'curve') { + const trackIndex = this.trackHierarchy.tracks.indexOf(track) + const trackYPos = this.trackHierarchy.getTrackY(trackIndex) + + const curveHeight = 80 + const startY = trackYPos + 10 + const padding = 5 + + // Check if within curve area + if (adjustedY >= startY && adjustedY <= startY + curveHeight) { + // Get AnimationData and curves for this track + const obj = track.object + let animationData = null + + if (track.type === 'object') { + for (let layer of this.context.activeObject.allLayers) { + if (layer.children && layer.children.includes(obj)) { + animationData = layer.animationData + break + } + } + } else if (track.type === 'shape') { + const findShapeLayer = (searchObj) => { + for (let layer of searchObj.children) { + if (layer.shapes && layer.shapes.includes(obj)) { + animationData = layer.animationData + return true + } + if (layer.children) { + for (let child of layer.children) { + if (findShapeLayer(child)) return true + } + } + } + return false + } + findShapeLayer(this.context.activeObject) + } + + if (animationData) { + // Get all curves for this object/shape + const curves = [] + for (let curveName in animationData.curves) { + const curve = animationData.curves[curveName] + if (track.type === 'object' && curveName.startsWith(`child.${obj.idx}.`)) { + curves.push(curve) + } else if (track.type === 'shape' && curveName.startsWith(`shape.${obj.shapeId}.`)) { + curves.push(curve) + } + } + + if (curves.length > 0) { + // Calculate value range for scaling + let minValue = Infinity + let maxValue = -Infinity + for (let curve of curves) { + for (let keyframe of curve.keyframes) { + minValue = Math.min(minValue, keyframe.value) + maxValue = Math.max(maxValue, keyframe.value) + } + } + const valueRange = maxValue - minValue + const rangePadding = valueRange * 0.1 || 1 + minValue -= rangePadding + maxValue += rangePadding + + // Check if hovering over any keyframe + for (let curve of curves) { + for (let keyframe of curve.keyframes) { + const kfX = this.timelineState.timeToPixel(keyframe.time) + const kfY = startY + curveHeight - padding - ((keyframe.value - minValue) / (maxValue - minValue) * (curveHeight - 2 * padding)) + const distance = Math.sqrt((adjustedX - kfX) ** 2 + (adjustedY - kfY) ** 2) + + if (distance < 8) { + // Found a hover! + this.hoveredKeyframe = { + keyframe: keyframe, + x: kfX, + y: kfY, + trackY: trackYPos // Store track Y for comparison in draw + } + foundHover = true + if (this.requestRedraw) this.requestRedraw() + break + } + } + if (foundHover) break + } + } + } + } + } + } + } + + // Clear hover if not found + if (!foundHover && this.hoveredKeyframe) { + this.hoveredKeyframe = null + if (this.requestRedraw) this.requestRedraw() + } + + if (this.draggingPlayhead) { + // Adjust x for ruler (remove track header offset) + const rulerX = x - this.trackHeaderWidth + this.ruler.mousemove(rulerX, y) + + // Sync GraphicsObject currentTime with timeline playhead + if (this.context.activeObject) { + this.context.activeObject.currentTime = this.timelineState.currentTime + // Sync DAW backend + invoke('audio_seek', { seconds: this.timelineState.currentTime }); + } + + // Trigger stage redraw to update object positions based on new time + if (this.context.updateUI) { + this.context.updateUI() + } + + return true + } + + // Phase 5: Handle keyframe dragging + if (this.draggingKeyframe) { + // Adjust coordinates to timeline area + const trackY = y - this.ruler.height + const adjustedX = x - this.trackHeaderWidth + const adjustedY = trackY - this.trackScrollOffset + + // Convert mouse position to time and value + const newTime = this.timelineState.pixelToTime(adjustedX) + const newValue = this.draggingKeyframe.yToValue(adjustedY) + + // Clamp time to not go negative, then apply snapping + let clampedTime = Math.max(0, newTime) + clampedTime = this.timelineState.snapTime(clampedTime) + + // Check for constrained dragging modifiers from drag event + const shiftKey = this.lastDragEvent?.shiftKey || false + const ctrlKey = this.lastDragEvent?.ctrlKey || this.lastDragEvent?.metaKey || false + + // Calculate deltas from the initial position + let timeDelta = clampedTime - this.draggingKeyframe.initialTime + let valueDelta = newValue - this.draggingKeyframe.initialValue + + // Apply constraints based on modifier keys + if (shiftKey && !ctrlKey) { + // Shift: vertical only (constrain time) + timeDelta = 0 + } else if (ctrlKey && !shiftKey) { + // Ctrl/Cmd: horizontal only (constrain value) + valueDelta = 0 + } + + // Update all selected keyframes + for (let selectedKeyframe of this.selectedKeyframes) { + // Get the initial position of this keyframe (stored when dragging started) + if (!selectedKeyframe.initialDragTime) { + selectedKeyframe.initialDragTime = selectedKeyframe.time + selectedKeyframe.initialDragValue = selectedKeyframe.value + } + + // Apply the delta + selectedKeyframe.time = Math.max(0, selectedKeyframe.initialDragTime + timeDelta) + let newValue = selectedKeyframe.initialDragValue + valueDelta + + // Special validation for shapeIndex curves: only allow values that correspond to actual shapes + if (this.draggingKeyframe.curve.parameter.endsWith('.shapeIndex')) { + // Extract shapeId from parameter name: "shape.{shapeId}.shapeIndex" + const match = this.draggingKeyframe.curve.parameter.match(/^shape\.([^.]+)\.shapeIndex$/) + if (match) { + const shapeId = match[1] + + // Find all shapes with this shapeId and get their shapeIndex values + const track = this.draggingKeyframe.track + let layer = null + + if (track.type === 'shape') { + // Find the layer containing this shape + for (let l of this.context.activeObject.allLayers) { + if (l.shapes && l.shapes.some(s => s.shapeId === shapeId)) { + layer = l + break + } + } + } + + if (layer) { + const validIndexes = layer.shapes + .filter(s => s.shapeId === shapeId) + .map(s => s.shapeIndex) + .sort((a, b) => a - b) + + if (validIndexes.length > 0) { + // Round to nearest integer first + const roundedValue = Math.round(newValue) + + // Find the closest valid index + let closestIndex = validIndexes[0] + let closestDist = Math.abs(roundedValue - closestIndex) + + for (let validIndex of validIndexes) { + const dist = Math.abs(roundedValue - validIndex) + if (dist < closestDist) { + closestDist = dist + closestIndex = validIndex + } + } + + newValue = closestIndex + } + } + } + } + + selectedKeyframe.value = newValue + } + + // Resort keyframes in all affected curves + // We need to find all unique curves that contain selected keyframes + const affectedCurves = new Set() + for (let selectedKeyframe of this.selectedKeyframes) { + // Find which curve this keyframe belongs to + // This is a bit inefficient but works + const track = this.draggingKeyframe.track + const obj = track.object + + let animationData = null + if (track.type === 'object') { + for (let layer of this.context.activeObject.allLayers) { + if (layer.children && layer.children.includes(obj)) { + animationData = layer.animationData + break + } + } + } else if (track.type === 'shape') { + const findShapeLayer = (searchObj) => { + for (let layer of searchObj.children) { + if (layer.shapes && layer.shapes.includes(obj)) { + animationData = layer.animationData + return true + } + if (layer.children) { + for (let child of layer.children) { + if (findShapeLayer(child)) return true + } + } + } + return false + } + findShapeLayer(this.context.activeObject) + } + + if (animationData) { + for (let curveName in animationData.curves) { + const curve = animationData.curves[curveName] + if (curve.keyframes.includes(selectedKeyframe)) { + affectedCurves.add(curve) + } + } + } + } + + // Resort all affected curves + for (let curve of affectedCurves) { + curve.keyframes.sort((a, b) => a.time - b.time) + } + + // Sync the activeObject's currentTime with the timeline playhead + // This ensures the stage shows the animation at the correct time + if (this.context.activeObject) { + this.context.activeObject.currentTime = this.timelineState.currentTime + // Sync DAW backend + invoke('audio_seek', { seconds: this.timelineState.currentTime }); + } + + // Trigger stage redraw to update object positions based on new keyframe values + if (this.context.updateUI) { + console.log('[Timeline] Calling updateUI() to redraw stage after keyframe drag, syncing currentTime =', this.timelineState.currentTime) + this.context.updateUI() + } + + // Trigger timeline redraw + if (this.requestRedraw) this.requestRedraw() + return true + } + + // Phase 6: Handle tangent handle dragging + if (this.draggingTangent) { + const trackY = y - this.ruler.height + const adjustedX = x - this.trackHeaderWidth + const adjustedY = trackY - this.trackScrollOffset + + // Get curve visualization parameters + const trackIndex = this.trackHierarchy.tracks.indexOf(this.draggingTangent.track) + const trackYPos = this.trackHierarchy.getTrackY(trackIndex) + const curveHeight = 80 + const startY = trackYPos + 10 + const padding = 5 + + // Calculate value range (need to get all curves for this track) + const obj = this.draggingTangent.track.object + let animationData = null + + if (this.draggingTangent.track.type === 'object') { + for (let layer of this.context.activeObject.allLayers) { + if (layer.children && layer.children.includes(obj)) { + animationData = layer.animationData + break + } + } + } else if (this.draggingTangent.track.type === 'shape') { + const findShapeLayer = (searchObj) => { + for (let layer of searchObj.children) { + if (layer.shapes && layer.shapes.includes(obj)) { + animationData = layer.animationData + return true + } + if (layer.children) { + for (let child of layer.children) { + if (findShapeLayer(child)) return true + } + } + } + return false + } + findShapeLayer(this.context.activeObject) + } + + if (animationData) { + // Get all curves for value range calculation + const curves = [] + for (let curveName in animationData.curves) { + const curve = animationData.curves[curveName] + if (this.draggingTangent.track.type === 'object' && curveName.startsWith(`child.${obj.idx}.`)) { + curves.push(curve) + } else if (this.draggingTangent.track.type === 'shape' && curveName.startsWith(`shape.${obj.shapeId}.`)) { + curves.push(curve) + } + } + + // Calculate value range + let minValue = Infinity + let maxValue = -Infinity + for (let curve of curves) { + for (let keyframe of curve.keyframes) { + minValue = Math.min(minValue, keyframe.value) + maxValue = Math.max(maxValue, keyframe.value) + } + } + const valueRange = maxValue - minValue + const rangePadding = valueRange * 0.1 || 1 + minValue -= rangePadding + maxValue += rangePadding + + // Get keyframe and adjacent keyframe positions + const kf = this.draggingTangent.keyframe + const adj = this.draggingTangent.adjacentKeyframe + + const kfX = this.timelineState.timeToPixel(kf.time) + const adjX = this.timelineState.timeToPixel(adj.time) + + const valueToY = (value) => { + const normalizedValue = (value - minValue) / (maxValue - minValue) + return startY + curveHeight - padding - (normalizedValue * (curveHeight - 2 * padding)) + } + + const kfY = valueToY(kf.value) + const adjY = valueToY(adj.value) + + // Calculate the new ease values based on mouse position + const dx = adjX - kfX + const dy = adjY - kfY + + // Prevent division by zero + if (Math.abs(dx) > 1 && Math.abs(dy) > 1) { + let newEaseX, newEaseY + + if (this.draggingTangent.handle === 'out') { + // Out handle: relative to the keyframe + newEaseX = (adjustedX - kfX) / dx + newEaseY = (adjustedY - kfY) / dy + } else { + // In handle: relative to the start of the segment (previous keyframe) + newEaseX = (adjustedX - kfX) / dx + newEaseY = (adjustedY - kfY) / dy + } + + // Clamp ease values to reasonable ranges + // X should be between 0 and 1 (time must be between the two keyframes) + newEaseX = Math.max(0, Math.min(1, newEaseX)) + // Y can be outside 0-1 for overshoot/undershoot effects + newEaseY = Math.max(-2, Math.min(3, newEaseY)) + + // Update the keyframe's ease + if (this.draggingTangent.handle === 'out') { + kf.easeOut = { x: newEaseX, y: newEaseY } + } else { + kf.easeIn = { x: newEaseX, y: newEaseY } + } + } + } + + // Trigger redraws + if (this.context.updateUI) { + this.context.updateUI() + } + if (this.requestRedraw) this.requestRedraw() + return true + } + + // Phase 6: Handle segment edge dragging + if (this.draggingEdge) { + // Convert mouse position to time + const adjustedX = x - this.trackHeaderWidth + let newTime = this.timelineState.pixelToTime(adjustedX) + + // Apply snapping + newTime = this.timelineState.snapTime(newTime) + + // Ensure time doesn't go negative + newTime = Math.max(0, newTime) + + // Get the curve to find adjacent segments + const curve = this.draggingEdge.animationData.curves[this.draggingEdge.curveName] + if (curve) { + const frameDuration = 1 / this.timelineState.framerate + const minGap = frameDuration + + // Find the index of the keyframe we're dragging + const keyframeIndex = curve.keyframes.indexOf(this.draggingEdge.keyframe) + + if (this.draggingEdge.edge === 'left') { + // Left edge constraints: + // 1. Can't go past the right edge of this segment (leave at least 1 frame gap) + newTime = Math.min(newTime, this.draggingEdge.otherEdgeTime - minGap) + + // 2. Can't go before the end of the previous segment (no gap needed) + // The previous keyframe (if it has value === 0) is the end of the previous segment + if (keyframeIndex > 0) { + const prevKeyframe = curve.keyframes[keyframeIndex - 1] + if (prevKeyframe.value === 0) { + newTime = Math.max(newTime, prevKeyframe.time) + } + } + } else { + // Right edge constraints: + // 1. Can't go before the left edge of this segment (leave at least 1 frame gap) + newTime = Math.max(newTime, this.draggingEdge.otherEdgeTime + minGap) + + // 2. Can't go past the start of the next segment (no gap needed) + // The next keyframe (if it has value > 0) is the start of the next segment + if (keyframeIndex < curve.keyframes.length - 1) { + const nextKeyframe = curve.keyframes[keyframeIndex + 1] + if (nextKeyframe.value > 0) { + newTime = Math.min(newTime, nextKeyframe.time) + } + } + } + + // Update the keyframe time + this.draggingEdge.keyframe.time = newTime + + // Resort keyframes in the curve + curve.keyframes.sort((a, b) => a.time - b.time) + } + + // Sync with animation playhead + if (this.context.activeObject) { + this.context.activeObject.currentTime = this.timelineState.currentTime + // Sync DAW backend + invoke('audio_seek', { seconds: this.timelineState.currentTime }); + } + + // Trigger stage redraw + if (this.context.updateUI) { + this.context.updateUI() + } + + // Trigger timeline redraw + if (this.requestRedraw) this.requestRedraw() + return true + } + + // Handle audio clip edge dragging (trimming) + if (this.draggingAudioClipEdge) { + const adjustedX = x - this.trackHeaderWidth + const rawTime = this.timelineState.pixelToTime(adjustedX) + const minClipDuration = this.context.config.minClipDuration + + if (this.draggingAudioClipEdge.edge === 'left') { + // Dragging left edge - adjust startTime and offset + const initialEnd = this.draggingAudioClipEdge.initialClipStart + this.draggingAudioClipEdge.initialClipDuration + const maxStartTime = initialEnd - minClipDuration + // Quantize the new start time + let newStartTime = Math.max(0, Math.min(rawTime, maxStartTime)) + newStartTime = this.quantizeTime(newStartTime) + const startTimeDelta = newStartTime - this.draggingAudioClipEdge.initialClipStart + + this.draggingAudioClipEdge.clip.startTime = newStartTime + this.draggingAudioClipEdge.clip.offset = this.draggingAudioClipEdge.initialClipOffset + startTimeDelta + this.draggingAudioClipEdge.clip.duration = this.draggingAudioClipEdge.initialClipDuration - startTimeDelta + // Also update internalDuration when trimming (this is the content length before looping) + this.draggingAudioClipEdge.clip.internalDuration = this.draggingAudioClipEdge.initialClipDuration - startTimeDelta + + // Also trim linked video clip if it exists + if (this.draggingAudioClipEdge.clip.linkedVideoClip) { + const videoClip = this.draggingAudioClipEdge.clip.linkedVideoClip + videoClip.startTime = newStartTime + videoClip.offset = (this.draggingAudioClipEdge.initialLinkedVideoOffset || 0) + startTimeDelta + videoClip.duration = this.draggingAudioClipEdge.initialClipDuration - startTimeDelta + } + } else { + // Dragging right edge - adjust duration + const minEndTime = this.draggingAudioClipEdge.initialClipStart + minClipDuration + // Quantize the new end time + let newEndTime = Math.max(minEndTime, rawTime) + newEndTime = this.quantizeTime(newEndTime) + let newDuration = newEndTime - this.draggingAudioClipEdge.clip.startTime + + // Constrain duration to not exceed source file duration minus offset (for audio clips only) + // MIDI clips don't have sourceDuration and can be extended freely + if (this.draggingAudioClipEdge.clip.sourceDuration !== undefined) { + const maxAvailableDuration = this.draggingAudioClipEdge.clip.sourceDuration - (this.draggingAudioClipEdge.clip.offset || 0) + newDuration = Math.min(newDuration, maxAvailableDuration) + } + + this.draggingAudioClipEdge.clip.duration = newDuration + // Also update internalDuration when trimming (this is the content length before looping) + this.draggingAudioClipEdge.clip.internalDuration = newDuration + + // Also trim linked video clip if it exists + if (this.draggingAudioClipEdge.clip.linkedVideoClip) { + const linkedMaxDuration = this.draggingAudioClipEdge.clip.linkedVideoClip.sourceDuration - this.draggingAudioClipEdge.clip.linkedVideoClip.offset + this.draggingAudioClipEdge.clip.linkedVideoClip.duration = Math.min(newDuration, linkedMaxDuration) + } + } + + // Trigger timeline redraw + if (this.requestRedraw) this.requestRedraw() + return true + } + + // Handle loop corner dragging (extending/looping clip) + if (this.draggingLoopCorner) { + const adjustedX = x - this.trackHeaderWidth + const newTime = this.timelineState.pixelToTime(adjustedX) + const minClipDuration = this.context.config.minClipDuration + + // Calculate new end time and quantize it + let newEndTime = Math.max(this.draggingLoopCorner.clip.startTime + minClipDuration, newTime) + newEndTime = this.quantizeTime(newEndTime) + const newDuration = newEndTime - this.draggingLoopCorner.clip.startTime + + // Update clip duration (no maximum constraint - allows looping) + this.draggingLoopCorner.clip.duration = newDuration + + // Trigger timeline redraw + if (this.requestRedraw) this.requestRedraw() + return true + } + + // Handle audio clip dragging + if (this.draggingAudioClip) { + // Adjust coordinates to timeline area + const adjustedX = x - this.trackHeaderWidth + + // Convert mouse position to time + const newTime = this.timelineState.pixelToTime(adjustedX) + + // Calculate time delta + const timeDelta = newTime - this.draggingAudioClip.initialMouseTime + + // Update clip's start time (ensure it doesn't go negative) + this.draggingAudioClip.clip.startTime = Math.max(0, this.draggingAudioClip.initialClipStartTime + timeDelta) + + // Also move linked video clip if it exists + if (this.draggingAudioClip.clip.linkedVideoClip) { + this.draggingAudioClip.clip.linkedVideoClip.startTime = this.draggingAudioClip.clip.startTime + } + + // Trigger timeline redraw + if (this.requestRedraw) this.requestRedraw() + return true + } + + // Handle video clip edge dragging (trimming) + if (this.draggingVideoClipEdge) { + const adjustedX = x - this.trackHeaderWidth + const newTime = this.timelineState.pixelToTime(adjustedX) + const minClipDuration = this.context.config.minClipDuration + + if (this.draggingVideoClipEdge.edge === 'left') { + // Dragging left edge - adjust startTime and offset + const initialEnd = this.draggingVideoClipEdge.initialClipStart + this.draggingVideoClipEdge.initialClipDuration + const maxStartTime = initialEnd - minClipDuration + const newStartTime = Math.max(0, Math.min(newTime, maxStartTime)) + const startTimeDelta = newStartTime - this.draggingVideoClipEdge.initialClipStart + + this.draggingVideoClipEdge.clip.startTime = newStartTime + this.draggingVideoClipEdge.clip.offset = this.draggingVideoClipEdge.initialClipOffset + startTimeDelta + this.draggingVideoClipEdge.clip.duration = this.draggingVideoClipEdge.initialClipDuration - startTimeDelta + + // Also trim linked audio clip if it exists + if (this.draggingVideoClipEdge.clip.linkedAudioClip) { + const audioClip = this.draggingVideoClipEdge.clip.linkedAudioClip + audioClip.startTime = newStartTime + audioClip.offset = (this.draggingVideoClipEdge.initialLinkedAudioOffset || 0) + startTimeDelta + audioClip.duration = this.draggingVideoClipEdge.initialClipDuration - startTimeDelta + } + } else { + // Dragging right edge - adjust duration + const minEndTime = this.draggingVideoClipEdge.initialClipStart + minClipDuration + const newEndTime = Math.max(minEndTime, newTime) + let newDuration = newEndTime - this.draggingVideoClipEdge.clip.startTime + + // Constrain duration to not exceed source file duration minus offset + const maxAvailableDuration = this.draggingVideoClipEdge.clip.sourceDuration - this.draggingVideoClipEdge.clip.offset + newDuration = Math.min(newDuration, maxAvailableDuration) + + this.draggingVideoClipEdge.clip.duration = newDuration + + // Also trim linked audio clip if it exists + if (this.draggingVideoClipEdge.clip.linkedAudioClip) { + const linkedMaxDuration = this.draggingVideoClipEdge.clip.linkedAudioClip.sourceDuration - this.draggingVideoClipEdge.clip.linkedAudioClip.offset + this.draggingVideoClipEdge.clip.linkedAudioClip.duration = Math.min(newDuration, linkedMaxDuration) + } + } + + // Trigger timeline redraw + if (this.requestRedraw) this.requestRedraw() + return true + } + + // Handle video clip dragging + if (this.draggingVideoClip) { + // Adjust coordinates to timeline area + const adjustedX = x - this.trackHeaderWidth + + // Convert mouse position to time + const newTime = this.timelineState.pixelToTime(adjustedX) + + // Calculate time delta + const timeDelta = newTime - this.draggingVideoClip.initialMouseTime + + // Update clip's start time (ensure it doesn't go negative) + this.draggingVideoClip.clip.startTime = Math.max(0, this.draggingVideoClip.initialClipStartTime + timeDelta) + + // Also move linked audio clip if it exists + if (this.draggingVideoClip.clip.linkedAudioClip) { + this.draggingVideoClip.clip.linkedAudioClip.startTime = this.draggingVideoClip.clip.startTime + } + + // Trigger timeline redraw + if (this.requestRedraw) this.requestRedraw() + return true + } + + // Phase 6: Handle segment dragging + if (this.draggingSegment) { + // Adjust coordinates to timeline area + const trackY = y - this.ruler.height + const adjustedX = x - this.trackHeaderWidth + + // Convert mouse position to time + const newTime = this.timelineState.pixelToTime(adjustedX) + + // Calculate time delta + const timeDelta = newTime - this.draggingSegment.initialMouseTime + + // Get all curves for this object/shape from the animationData + const prefix = this.draggingSegment.track.type === 'object' + ? `child.${this.draggingSegment.objectIdx}.` + : `shape.${this.draggingSegment.objectIdx}.` + + // Shift all keyframes by the time delta + for (let curveName in this.draggingSegment.animationData.curves) { + if (curveName.startsWith(prefix)) { + const curve = this.draggingSegment.animationData.curves[curveName] + + for (let keyframe of curve.keyframes) { + // Store initial time if not already stored + if (!keyframe.initialSegmentDragTime) { + keyframe.initialSegmentDragTime = keyframe.time + } + + // Apply delta and ensure time doesn't go negative + keyframe.time = Math.max(0, keyframe.initialSegmentDragTime + timeDelta) + } + + // Resort keyframes after time shift + curve.keyframes.sort((a, b) => a.time - b.time) + } + } + + // Sync with animation playhead + if (this.context.activeObject) { + this.context.activeObject.currentTime = this.timelineState.currentTime + // Sync DAW backend + invoke('audio_seek', { seconds: this.timelineState.currentTime }); + } + + // Trigger stage redraw + if (this.context.updateUI) { + this.context.updateUI() + } + + // Trigger timeline redraw + if (this.requestRedraw) this.requestRedraw() + return true + } + + // Update cursor based on hover position (when not dragging) + if (!this.draggingAudioClip && !this.draggingVideoClip && + !this.draggingAudioClipEdge && !this.draggingVideoClipEdge && + !this.draggingKeyframe && !this.draggingPlayhead && !this.draggingSegment && + !this.draggingLoopCorner) { + const trackY = y - this.ruler.height + if (trackY >= 0 && x >= this.trackHeaderWidth) { + const adjustedY = trackY - this.trackScrollOffset + const adjustedX = x - this.trackHeaderWidth + const track = this.trackHierarchy.getTrackAtY(adjustedY) + + if (track) { + // Check for audio/MIDI clip loop corner (top-right) - must check before edge detection + if (track.type === 'audio') { + const loopCornerInfo = this.getAudioClipLoopCornerAtPoint(track, adjustedX, adjustedY) + if (loopCornerInfo) { + // Use the same rotate cursor as the transform tool corner handles + this.cursor = "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='currentColor' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M8 3a5 5 0 1 1-4.546 2.914.5.5 0 0 0-.908-.417A6 6 0 1 0 8 2z'/%3E%3Cpath d='M8 4.466V.534a.25.25 0 0 0-.41-.192L5.23 2.308a.25.25 0 0 0 0 .384l2.36 1.966A.25.25 0 0 0 8 4.466'/%3E%3C/svg%3E\") 12 12, auto" + return false + } + } + + // Check for audio clip edge + if (track.type === 'audio') { + const audioEdgeInfo = this.getAudioClipEdgeAtPoint(track, adjustedX, adjustedY) + if (audioEdgeInfo) { + this.cursor = audioEdgeInfo.edge === 'left' ? 'w-resize' : 'e-resize' + return false + } + } + // Check for video clip edge + else if (track.type === 'video') { + const videoEdgeInfo = this.getVideoClipEdgeAtPoint(track, adjustedX, adjustedY) + if (videoEdgeInfo) { + this.cursor = videoEdgeInfo.edge === 'left' ? 'w-resize' : 'e-resize' + return false + } + } + } + } + // Reset cursor if not over an edge + this.cursor = 'default' + } + + return false + } + + mouseup(x, y) { + if (this.draggingPlayhead) { + // Let the ruler handle the mouseup + this.ruler.mouseup(x, y) + + this.draggingPlayhead = false + this._globalEvents.delete("mousemove") + this._globalEvents.delete("mouseup") + return true + } + + // Phase 5: Complete keyframe dragging + if (this.draggingKeyframe) { + console.log(`Finished dragging ${this.selectedKeyframes.size} keyframe(s)`) + + // Clean up initial drag positions from all selected keyframes + for (let selectedKeyframe of this.selectedKeyframes) { + delete selectedKeyframe.initialDragTime + delete selectedKeyframe.initialDragValue + } + + // Clean up dragging state + this.draggingKeyframe = null + this._globalEvents.delete("mousemove") + this._globalEvents.delete("mouseup") + + // Final redraw + if (this.requestRedraw) this.requestRedraw() + return true + } + + // Phase 6: Complete tangent dragging + if (this.draggingTangent) { + console.log('Finished dragging', this.draggingTangent.handle, 'tangent handle') + + // Clean up dragging state + this.draggingTangent = null + this._globalEvents.delete("mousemove") + this._globalEvents.delete("mouseup") + + // Final redraw + if (this.requestRedraw) this.requestRedraw() + return true + } + + // Phase 6: Complete edge dragging + if (this.draggingEdge) { + console.log('Finished dragging', this.draggingEdge.edge, 'edge') + + // Clean up dragging state + this.draggingEdge = null + this._globalEvents.delete("mousemove") + this._globalEvents.delete("mouseup") + + // Final redraw + if (this.requestRedraw) this.requestRedraw() + return true + } + + // Complete audio clip edge dragging (trimming) + if (this.draggingAudioClipEdge) { + console.log('Finished trimming audio clip edge') + + const clip = this.draggingAudioClipEdge.clip + const trackId = this.draggingAudioClipEdge.audioTrack.audioTrackId + const clipId = clip.clipId + + // If dragging left edge, also move the clip's timeline position + if (this.draggingAudioClipEdge.edge === 'left') { + invoke('audio_move_clip', { + trackId: trackId, + clipId: clipId, + newStartTime: clip.startTime + }).catch(error => { + console.error('Failed to move audio clip in backend:', error) + }) + } + + // Update the internal trim boundaries + // internal_start = offset, internal_end = offset + duration (content region) + invoke('audio_trim_clip', { + trackId: trackId, + clipId: clipId, + internalStart: clip.offset, + internalEnd: clip.offset + clip.duration + }).catch(error => { + console.error('Failed to trim audio clip in backend:', error) + }) + + // Also update linked video clip if it exists + if (this.draggingAudioClipEdge.clip.linkedVideoClip) { + console.log('Linked video clip also trimmed') + } + + // Clean up dragging state + this.draggingAudioClipEdge = null + this._globalEvents.delete("mousemove") + this._globalEvents.delete("mouseup") + + // Final redraw + if (this.requestRedraw) this.requestRedraw() + return true + } + + // Complete loop corner dragging (extending/looping clip) + if (this.draggingLoopCorner) { + console.log('Finished extending clip via loop corner') + + const clip = this.draggingLoopCorner.clip + const trackId = this.draggingLoopCorner.audioTrack.audioTrackId + const clipId = clip.clipId + + // Call audio_extend_clip to update the external duration in the backend + invoke('audio_extend_clip', { + trackId: trackId, + clipId: clipId, + newExternalDuration: clip.duration + }).catch(error => { + console.error('Failed to extend audio clip in backend:', error) + }) + + // Clean up dragging state + this.draggingLoopCorner = null + this._globalEvents.delete("mousemove") + this._globalEvents.delete("mouseup") + + // Final redraw + if (this.requestRedraw) this.requestRedraw() + return true + } + + // Complete video clip edge dragging (trimming) + if (this.draggingVideoClipEdge) { + console.log('Finished trimming video clip edge') + + // Update linked audio clip in backend if it exists + if (this.draggingVideoClipEdge.clip.linkedAudioClip) { + const linkedAudioClip = this.draggingVideoClipEdge.clip.linkedAudioClip + const audioTrack = this.draggingVideoClipEdge.videoLayer.linkedAudioTrack + if (audioTrack) { + const trackId = audioTrack.audioTrackId + const clipId = linkedAudioClip.clipId + + // If dragging left edge, also move the clip's timeline position + if (this.draggingVideoClipEdge.edge === 'left') { + invoke('audio_move_clip', { + trackId: trackId, + clipId: clipId, + newStartTime: linkedAudioClip.startTime + }).catch(error => { + console.error('Failed to move linked audio clip in backend:', error) + }) + } + + // Update the internal trim boundaries + invoke('audio_trim_clip', { + trackId: trackId, + clipId: clipId, + internalStart: linkedAudioClip.offset, + internalEnd: linkedAudioClip.offset + linkedAudioClip.duration + }).catch(error => { + console.error('Failed to trim linked audio clip in backend:', error) + }) + } + } + + // Clean up dragging state + this.draggingVideoClipEdge = null + this._globalEvents.delete("mousemove") + this._globalEvents.delete("mouseup") + + // Final redraw + if (this.requestRedraw) this.requestRedraw() + return true + } + + // Complete audio clip dragging + if (this.draggingAudioClip) { + console.log('Finished dragging audio clip') + + // Update backend with new clip position + invoke('audio_move_clip', { + trackId: this.draggingAudioClip.audioTrack.audioTrackId, + clipId: this.draggingAudioClip.clip.clipId, + newStartTime: this.draggingAudioClip.clip.startTime + }).catch(error => { + console.error('Failed to move clip in backend:', error) + }) + + // Also update linked video clip in backend if it exists + if (this.draggingAudioClip.clip.linkedVideoClip) { + // Video clips don't have a backend move command yet, so just log for now + console.log('Linked video clip also moved to time', this.draggingAudioClip.clip.startTime) + } + + // Clean up dragging state + this.draggingAudioClip = null + this._globalEvents.delete("mousemove") + this._globalEvents.delete("mouseup") + + // Final redraw + if (this.requestRedraw) this.requestRedraw() + return true + } + + // Complete video clip dragging + if (this.draggingVideoClip) { + console.log('Finished dragging video clip') + + // Video clips don't have a backend position yet (they're just visual) + // But we need to update the linked audio clip in the backend + if (this.draggingVideoClip.clip.linkedAudioClip) { + const linkedAudioClip = this.draggingVideoClip.clip.linkedAudioClip + // Find the audio track that contains this clip + const audioTrack = this.draggingVideoClip.videoLayer.linkedAudioTrack + if (audioTrack) { + invoke('audio_move_clip', { + trackId: audioTrack.audioTrackId, + clipId: linkedAudioClip.clipId, + newStartTime: linkedAudioClip.startTime + }).catch(error => { + console.error('Failed to move linked audio clip in backend:', error) + }) + } + } + + // Clean up dragging state + this.draggingVideoClip = null + this._globalEvents.delete("mousemove") + this._globalEvents.delete("mouseup") + + // Final redraw + if (this.requestRedraw) this.requestRedraw() + return true + } + + // Phase 6: Complete segment dragging + if (this.draggingSegment) { + console.log('Finished dragging segment') + + // Clean up initial drag times from all affected keyframes + const prefix = this.draggingSegment.track.type === 'object' + ? `child.${this.draggingSegment.objectIdx}.` + : `shape.${this.draggingSegment.objectIdx}.` + + for (let curveName in this.draggingSegment.animationData.curves) { + if (curveName.startsWith(prefix)) { + const curve = this.draggingSegment.animationData.curves[curveName] + for (let keyframe of curve.keyframes) { + delete keyframe.initialSegmentDragTime + } + } + } + + // Clean up dragging state + this.draggingSegment = null + this._globalEvents.delete("mousemove") + this._globalEvents.delete("mouseup") + + // Final redraw + if (this.requestRedraw) this.requestRedraw() + return true + } + + return false + } + + /** + * Handle right-click context menu (Phase 5/6) + * Shows menu with interpolation options and delete for keyframes + * Shift+right-click for quick delete + */ + contextmenu(x, y, event) { + // Check if right-clicking in timeline area + const trackY = y - this.ruler.height + if (trackY >= 0 && x >= this.trackHeaderWidth) { + const adjustedY = trackY - this.trackScrollOffset + const adjustedX = x - this.trackHeaderWidth + const track = this.trackHierarchy.getTrackAtY(adjustedY) + + // First check if clicking on a clip (audio or MIDI) + if (track && (track.type === 'audio')) { + const clipInfo = this.getAudioClipAtPoint(track, adjustedX, adjustedY) + if (clipInfo) { + this.showClipContextMenu(clipInfo.clip, clipInfo.audioTrack) + return true + } + } + + if (track && (track.type === 'object' || track.type === 'shape') && track.object.curvesMode === 'curve') { + // Use similar logic to handleCurveClick to find if we're clicking on a keyframe + const trackIndex = this.trackHierarchy.tracks.indexOf(track) + const trackYPos = this.trackHierarchy.getTrackY(trackIndex) + + const curveHeight = 80 + const startY = trackYPos + 10 + const padding = 5 + + // Check if y is within curve area + if (adjustedY >= startY && adjustedY <= startY + curveHeight) { + // Get AnimationData and curves for this track + const obj = track.object + let animationData = null + + if (track.type === 'object') { + for (let layer of this.context.activeObject.allLayers) { + if (layer.children && layer.children.includes(obj)) { + animationData = layer.animationData + break + } + } + } else if (track.type === 'shape') { + const findShapeLayer = (searchObj) => { + for (let layer of searchObj.children) { + if (layer.shapes && layer.shapes.includes(obj)) { + animationData = layer.animationData + return true + } + if (layer.children) { + for (let child of layer.children) { + if (findShapeLayer(child)) return true + } + } + } + return false + } + findShapeLayer(this.context.activeObject) + } + + if (!animationData) return false + + // Get all curves for this object/shape + const curves = [] + for (let curveName in animationData.curves) { + const curve = animationData.curves[curveName] + if (track.type === 'object' && curveName.startsWith(`child.${obj.idx}.`)) { + curves.push(curve) + } else if (track.type === 'shape' && curveName.startsWith(`shape.${obj.shapeId}.`)) { + curves.push(curve) + } + } + + if (curves.length === 0) return false + + // Calculate value range for scaling + let minValue = Infinity + let maxValue = -Infinity + for (let curve of curves) { + for (let keyframe of curve.keyframes) { + minValue = Math.min(minValue, keyframe.value) + maxValue = Math.max(maxValue, keyframe.value) + } + } + const valueRange = maxValue - minValue + const rangePadding = valueRange * 0.1 || 1 + minValue -= rangePadding + maxValue += rangePadding + + // Check if right-clicking on a keyframe (within 8px) + // Find the CLOSEST keyframe, not just the first one (and skip hidden curves) + let closestKeyframe = null + let closestCurve = null + let closestDistance = 8 // Maximum hit distance + + for (let curve of curves) { + // Skip hidden curves + if (this.hiddenCurves.has(curve.parameter)) continue + + for (let i = 0; i < curve.keyframes.length; i++) { + const keyframe = curve.keyframes[i] + const kfX = this.timelineState.timeToPixel(keyframe.time) + const kfY = startY + curveHeight - padding - ((keyframe.value - minValue) / (maxValue - minValue) * (curveHeight - 2 * padding)) + const distance = Math.sqrt((adjustedX - kfX) ** 2 + (adjustedY - kfY) ** 2) + + if (distance < closestDistance) { + closestDistance = distance + closestKeyframe = keyframe + closestCurve = curve + } + } + } + + if (closestKeyframe) { + const keyframe = closestKeyframe + const curve = closestCurve + + // Phase 6: Check if shift key is pressed for quick delete + const shiftPressed = event && event.shiftKey + + if (shiftPressed) { + // Shift+right-click: quick delete + if (this.selectedKeyframes.size > 1) { + // Delete all selected keyframes + const keyframesToDelete = Array.from(this.selectedKeyframes) + for (let kf of keyframesToDelete) { + for (let c of curves) { + const idx = c.keyframes.indexOf(kf) + if (idx !== -1 && c.keyframes.length > 1) { + c.keyframes.splice(idx, 1) + } + } + this.selectedKeyframes.delete(kf) + } + console.log(`Deleted ${keyframesToDelete.length} keyframes`) + } else { + // Single keyframe deletion + if (curve.keyframes.length > 1) { + console.log(`Deleting keyframe at time ${keyframe.time}`) + curve.keyframes.splice(i, 1) + this.selectedKeyframes.delete(keyframe) + } + } + if (this.requestRedraw) this.requestRedraw() + return true + } else { + // Regular right-click: show context menu + if (this.selectedKeyframes.size > 1) { + // If right-clicking on a selected keyframe, show menu for all selected + if (this.selectedKeyframes.has(keyframe)) { + this.showKeyframeContextMenu(Array.from(this.selectedKeyframes), curves) + } else { + // Right-clicking on unselected keyframe: select it and show menu + this.selectedKeyframes.clear() + this.selectedKeyframes.add(keyframe) + this.showKeyframeContextMenu([keyframe], curves, curve) + } + } else { + // No multi-selection: select this keyframe and show menu + this.selectedKeyframes.clear() + this.selectedKeyframes.add(keyframe) + this.showKeyframeContextMenu([keyframe], curves, curve) + } + return true + } + } // end if (closestKeyframe) + } + } + } + + return false + } + + /** + * Show Tauri context menu for keyframe operations (Phase 6) + * Includes interpolation type options and delete + */ + async showKeyframeContextMenu(keyframesToDelete, curves, singleCurve = null) { + const { Menu, MenuItem, Submenu } = window.__TAURI__.menu + const { PhysicalPosition, LogicalPosition } = window.__TAURI__.dpi + + // Build menu items + const items = [] + + // Phase 6: Add interpolation type submenu (only for single keyframe) + if (keyframesToDelete.length === 1 && singleCurve) { + const keyframe = keyframesToDelete[0] + const currentType = keyframe.interpolation || 'linear' + + const interpolationSubmenu = await Submenu.new({ + text: 'Interpolation', + items: [ + await MenuItem.new({ + text: currentType === 'linear' ? '✓ Linear' : 'Linear', + action: async () => { + keyframe.interpolation = 'linear' + console.log('Changed interpolation to linear') + // Keep flag set until next mousedown processes it + if (this.context.updateUI) this.context.updateUI() + if (this.requestRedraw) this.requestRedraw() + } + }), + await MenuItem.new({ + text: currentType === 'bezier' ? '✓ Bezier' : 'Bezier', + action: async () => { + keyframe.interpolation = 'bezier' + if (!keyframe.easeIn) keyframe.easeIn = { x: 0.42, y: 0 } + if (!keyframe.easeOut) keyframe.easeOut = { x: 0.58, y: 1 } + if (this.context.updateUI) this.context.updateUI() + if (this.requestRedraw) this.requestRedraw() + } + }), + await MenuItem.new({ + text: currentType === 'step' || currentType === 'hold' ? '✓ Step (Hold)' : 'Step (Hold)', + action: async () => { + keyframe.interpolation = 'step' + console.log('Changed interpolation to step') + // Keep flag set until next mousedown processes it + if (this.context.updateUI) this.context.updateUI() + if (this.requestRedraw) this.requestRedraw() + } + }), + await MenuItem.new({ + text: currentType === 'zero' ? '✓ Zero' : 'Zero', + action: async () => { + keyframe.interpolation = 'zero' + console.log('Changed interpolation to zero') + // Keep flag set until next mousedown processes it + if (this.context.updateUI) this.context.updateUI() + if (this.requestRedraw) this.requestRedraw() + } + }) + ] + }) + + items.push(interpolationSubmenu) + } + + // Add delete option + items.push(await MenuItem.new({ + text: `Delete ${keyframesToDelete.length} keyframe${keyframesToDelete.length > 1 ? 's' : ''}`, + action: async () => { + // Perform deletion + console.log(`Deleting ${keyframesToDelete.length} selected keyframes`) + + // For each keyframe to delete + for (let keyframe of keyframesToDelete) { + // Find which curve(s) contain this keyframe + for (let curve of curves) { + const index = curve.keyframes.indexOf(keyframe) + if (index !== -1) { + // Check if this is the last keyframe in this curve + if (curve.keyframes.length > 1) { + curve.keyframes.splice(index, 1) + console.log(`Deleted keyframe from curve ${curve.parameter}`) + } else { + console.log(`Skipped deleting last keyframe in curve ${curve.parameter}`) + } + break + } + } + } + + // Clear the selection + this.selectedKeyframes.clear() + + // Trigger redraw + if (this.requestRedraw) this.requestRedraw() + } + })) + + const menu = await Menu.new({ items }) + + // Show menu at mouse position (using lastEvent for clientX/clientY) + const clientX = this.lastEvent?.clientX || 0 + const clientY = this.lastEvent?.clientY || 0 + const position = new PhysicalPosition(clientX, clientY) + console.log(position) + // await menu.popup({ at: position }) + await menu.popup(position) + } + + /** + * Show context menu for audio/MIDI clips + * Currently supports: Rename + */ + async showClipContextMenu(clip, audioTrack) { + const { Menu, MenuItem } = window.__TAURI__.menu + const { PhysicalPosition } = window.__TAURI__.dpi + + const items = [] + + // Rename option + items.push(await MenuItem.new({ + text: 'Rename', + action: async () => { + const newName = prompt('Enter new name for clip:', clip.name || '') + if (newName !== null && newName.trim() !== '') { + clip.name = newName.trim() + console.log(`Renamed clip to "${clip.name}"`) + if (this.requestRedraw) this.requestRedraw() + } + } + })) + + const menu = await Menu.new({ items }) + + // Show menu at mouse position + const clientX = this.lastEvent?.clientX || 0 + const clientY = this.lastEvent?.clientY || 0 + const position = new PhysicalPosition(clientX, clientY) + await menu.popup(position) + } + + /** + * Copy selected keyframes to clipboard (Phase 6) + */ + copySelectedKeyframes() { + if (this.selectedKeyframes.size === 0) { + return false // No keyframes to copy + } + + // Find the earliest time among selected keyframes (this will be the reference point) + let minTime = Infinity + for (let keyframe of this.selectedKeyframes) { + minTime = Math.min(minTime, keyframe.time) + } + + // Build clipboard data with relative times + const clipboardData = [] + + // We need to find which curves these keyframes belong to + // Iterate through all tracks to find curves containing selected keyframes + for (let track of this.trackHierarchy.tracks) { + if (track.type !== 'object' && track.type !== 'shape') continue + + const obj = track.object + let animationData = null + + // Find animation data + if (track.type === 'object') { + for (let layer of this.context.activeObject.allLayers) { + if (layer.children && layer.children.includes(obj)) { + animationData = layer.animationData + break + } + } + } else if (track.type === 'shape') { + const findShapeLayer = (searchObj) => { + for (let layer of searchObj.children) { + if (layer.shapes && layer.shapes.includes(obj)) { + animationData = layer.animationData + return true + } + if (layer.children) { + for (let child of layer.children) { + if (findShapeLayer(child)) return true + } + } + } + return false + } + findShapeLayer(this.context.activeObject) + } + + if (!animationData) continue + + // Check all curves + for (let curveName in animationData.curves) { + const curve = animationData.curves[curveName] + const prefix = track.type === 'object' ? `child.${obj.idx}.` : `shape.${obj.shapeId}.` + + if (!curveName.startsWith(prefix)) continue + + // Check which keyframes in this curve are selected + for (let keyframe of curve.keyframes) { + if (this.selectedKeyframes.has(keyframe)) { + // Store keyframe data with relative time + clipboardData.push({ + curve: curve, + curveName: curveName, + keyframeData: { + time: keyframe.time - minTime, // Relative time + value: keyframe.value, + interpolation: keyframe.interpolation, + easeIn: keyframe.easeIn ? { ...keyframe.easeIn } : undefined, + easeOut: keyframe.easeOut ? { ...keyframe.easeOut } : undefined + } + }) + } + } + } + } + + this.keyframeClipboard = { + keyframes: clipboardData, + baseTime: minTime + } + + console.log(`Copied ${clipboardData.length} keyframe(s) to clipboard`) + return true // Successfully copied keyframes + } + + /** + * Paste keyframes from clipboard (Phase 6) + */ + pasteKeyframes() { + if (!this.keyframeClipboard || this.keyframeClipboard.keyframes.length === 0) { + return false // No keyframes in clipboard + } + + // Paste at current playhead time + const pasteTime = this.timelineState.currentTime + + // Clear current selection + this.selectedKeyframes.clear() + + // Paste each keyframe + for (let clipboardItem of this.keyframeClipboard.keyframes) { + const curve = clipboardItem.curve + const kfData = clipboardItem.keyframeData + + // Calculate absolute time for pasted keyframe + const absoluteTime = pasteTime + kfData.time + + // Create new keyframe + const newKeyframe = { + time: absoluteTime, + value: kfData.value, + interpolation: kfData.interpolation || 'linear', + easeIn: kfData.easeIn ? { ...kfData.easeIn } : { x: 0.42, y: 0 }, + easeOut: kfData.easeOut ? { ...kfData.easeOut } : { x: 0.58, y: 1 }, + idx: this.generateUUID() + } + + // Add to curve + curve.addKeyframe(newKeyframe) + + // Select the newly pasted keyframe + this.selectedKeyframes.add(newKeyframe) + } + + console.log(`Pasted ${this.keyframeClipboard.keyframes.length} keyframe(s) at time ${pasteTime}`) + + // Trigger redraws + if (this.context.updateUI) { + this.context.updateUI() + } + if (this.requestRedraw) this.requestRedraw() + + return true // Successfully pasted keyframes + } + + // Zoom controls (can be called from keyboard shortcuts) + zoomIn() { + this.timelineState.zoomIn() + } + + zoomOut() { + this.timelineState.zoomOut() + } + + // Toggle time format + toggleTimeFormat() { + if (this.timelineState.timeFormat === 'frames') { + this.timelineState.timeFormat = 'seconds' + } else if (this.timelineState.timeFormat === 'seconds') { + this.timelineState.timeFormat = 'measures' + } else { + this.timelineState.timeFormat = 'frames' + } + } + + // Fetch automation name from backend and cache it + async fetchAutomationName(trackId, nodeId) { + const cacheKey = `${trackId}:${nodeId}` + + // Return cached value if available + if (this.automationNameCache.has(cacheKey)) { + return this.automationNameCache.get(cacheKey) + } + + try { + const name = await invoke('automation_get_name', { + trackId: trackId, + nodeId: nodeId + }) + + // Cache the result + if (name && name !== '') { + this.automationNameCache.set(cacheKey, name) + return name + } + } catch (err) { + console.error(`Failed to fetch automation name for node ${nodeId}:`, err) + } + + // Fallback to node ID if fetch fails or returns empty + return `${nodeId}` + } + + // Get automation name synchronously from cache, trigger fetch if not cached + getAutomationName(trackId, nodeId) { + const cacheKey = `${trackId}:${nodeId}` + + if (this.automationNameCache.has(cacheKey)) { + return this.automationNameCache.get(cacheKey) + } + + // Trigger async fetch in background + this.fetchAutomationName(trackId, nodeId).then(() => { + // Redraw when name arrives + if (this.context.timelineWidget?.requestRedraw) { + this.context.timelineWidget.requestRedraw() + } + }) + + // Return node ID as placeholder while fetching + return `${nodeId}` + } +} + +/** + * VirtualPiano - Interactive piano keyboard for MIDI input + * Displays a piano keyboard that users can click/play + * Can be connected to MIDI tracks in the DAW backend + */ +class VirtualPiano extends Widget { + constructor() { + super(0, 0); + + // Piano configuration - width scales based on height + this.whiteKeyAspectRatio = 6.0; // White key height:width ratio (taller keys) + this.blackKeyWidthRatio = 0.6; // Black key width as ratio of white key width + this.blackKeyHeightRatio = 0.62; // Black key height as ratio of white key height + + // State + this.pressedKeys = new Set(); // Currently pressed MIDI note numbers (user input) + this.playingNotes = new Set(); // Currently playing notes (from MIDI playback) + this.hoveredKey = null; // Currently hovered key + this.visibleStartNote = 48; // C3 - will be adjusted based on pane width + this.visibleEndNote = 72; // C5 - will be adjusted based on pane width + + // Keyboard control state + this.octaveOffset = 0; // Octave transpose (-2 to +2) + this.velocity = 100; // Default velocity (0-127) + this.sustainActive = false; // Sustain pedal (Tab key) + this.activeKeyPresses = new Map(); // Map of keyboard key -> MIDI note that's currently playing + this.sustainedNotes = new Set(); // Notes being held by sustain + + // MIDI note mapping (white keys in an octave: C, D, E, F, G, A, B) + this.whiteKeysInOctave = [0, 2, 4, 5, 7, 9, 11]; // Semitones from C + // Black keys indexed by white key position (after which white key the black key appears) + // Position 0 (after C), 1 (after D), null (no black after E), 3 (after F), 4 (after G), 5 (after A), null (no black after B) + this.blackKeysInOctave = [1, 3, null, 6, 8, 10, null]; // Actual semitone values + + // Keyboard bindings matching piano layout (QWERTY) + // TODO: Auto-detect keyboard layout and generate mapping dynamically + // Black keys: W E (one group) T Y U (other group) O P (next group) + // White keys: A S D F G H J K L ; ' + this.keyboardMap = { + 'a': 60, // C4 + 'w': 61, // C#4 + 's': 62, // D4 + 'e': 63, // D#4 + 'd': 64, // E4 + 'f': 65, // F4 + 't': 66, // F#4 + 'g': 67, // G4 + 'y': 68, // G#4 + 'h': 69, // A4 + 'u': 70, // A#4 + 'j': 71, // B4 + 'k': 72, // C5 + 'o': 73, // C#5 + 'l': 74, // D5 + 'p': 75, // D#5 + ';': 76, // E5 + "'": 77, // F5 + }; + + // Reverse mapping for displaying keyboard keys on piano keys + this.noteToKeyMap = {}; + for (const [key, note] of Object.entries(this.keyboardMap)) { + this.noteToKeyMap[note] = key.toUpperCase(); + } + + // Setup keyboard event listeners + this.setupKeyboardListeners(); + } + + /** + * Setup keyboard event listeners for computer keyboard input + */ + setupKeyboardListeners() { + window.addEventListener('keydown', (e) => { + if (e.repeat) return; // Ignore key repeats + + const key = e.key.toLowerCase(); + + // Handle sustain (Tab key) + if (key === 'tab') { + this.sustainActive = true; + e.preventDefault(); + return; + } + + // Handle control keys (Z, X for octave, C, V for velocity) + if (key === 'z') { + this.octaveOffset = Math.max(-2, this.octaveOffset - 1); + // Trigger a redraw to update the visible piano range + if (window.context && window.context.pianoRedraw) { + window.context.pianoRedraw(); + } + e.preventDefault(); + return; + } + if (key === 'x') { + this.octaveOffset = Math.min(2, this.octaveOffset + 1); + // Trigger a redraw to update the visible piano range + if (window.context && window.context.pianoRedraw) { + window.context.pianoRedraw(); + } + e.preventDefault(); + return; + } + if (key === 'c') { + this.velocity = Math.max(1, this.velocity - 10); + e.preventDefault(); + return; + } + if (key === 'v') { + this.velocity = Math.min(127, this.velocity + 10); + e.preventDefault(); + return; + } + + // Handle piano keys + const baseNote = this.keyboardMap[key]; + if (baseNote !== undefined) { + // Check if this key is already pressed (prevents duplicate note-ons from OS key repeat quirks) + if (this.activeKeyPresses.has(key)) { + e.preventDefault(); + return; + } + + // Note: octave offset is applied by shifting the visible piano range + // so we play the base note directly + const note = baseNote + (this.octaveOffset * 12); + // Clamp to valid MIDI range (0-127) + if (note >= 0 && note <= 127) { + // Track which key is playing which note + this.activeKeyPresses.set(key, note); + this.noteOn(note, this.velocity); + e.preventDefault(); + } + } + }); + + window.addEventListener('keyup', (e) => { + const key = e.key.toLowerCase(); + + // Handle sustain release + if (key === 'tab') { + this.sustainActive = false; + // Release only the sustained notes that aren't currently being held by a key + const currentlyPlayingNotes = new Set(this.activeKeyPresses.values()); + for (const note of this.sustainedNotes) { + if (!currentlyPlayingNotes.has(note)) { + this.noteOff(note); + } + } + this.sustainedNotes.clear(); + e.preventDefault(); + return; + } + + // Ignore control keys on keyup + if (['z', 'x', 'c', 'v'].includes(key)) { + return; + } + + // Look up which note this key was playing + const transposedNote = this.activeKeyPresses.get(key); + if (transposedNote !== undefined) { + this.activeKeyPresses.delete(key); + + // If sustain is active, add to sustained notes instead of releasing + if (this.sustainActive) { + this.sustainedNotes.add(transposedNote); + } else { + this.noteOff(transposedNote); + } + e.preventDefault(); + } + }); + } + + /** + * Convert MIDI note number to note info + */ + getMidiNoteInfo(midiNote) { + const octave = Math.floor(midiNote / 12) - 1; + const semitone = midiNote % 12; + const isBlack = [1, 3, 6, 8, 10].includes(semitone); + const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; + return { + octave, + semitone, + isBlack, + name: noteNames[semitone] + octave + }; + } + + /** + * Calculate key position and dimensions for a given MIDI note + * @param {number} midiNote - MIDI note number + * @param {number} whiteKeyHeight - Height of white keys (full pane height) + * @param {number} whiteKeyWidth - Width of white keys (calculated from height) + * @param {number} offsetX - Horizontal offset for centering + */ + getKeyGeometry(midiNote, whiteKeyHeight, whiteKeyWidth, offsetX = 0) { + const info = this.getMidiNoteInfo(midiNote); + const blackKeyWidth = whiteKeyWidth * this.blackKeyWidthRatio; + const blackKeyHeight = whiteKeyHeight * this.blackKeyHeightRatio; + + // Count how many white keys are between visibleStartNote and this note + let whiteKeysBefore = 0; + for (let n = this.visibleStartNote; n < midiNote; n++) { + const nInfo = this.getMidiNoteInfo(n); + if (!nInfo.isBlack) { + whiteKeysBefore++; + } + } + + if (info.isBlack) { + // Black key positioning - place it at the right edge of the preceding white key + // whiteKeysBefore is the number of white keys to the left, so multiply by width + // and subtract half the black key width to center it at the gap + const x = offsetX + whiteKeysBefore * whiteKeyWidth - blackKeyWidth / 2; + + return { + x, + y: 0, + width: blackKeyWidth, + height: blackKeyHeight, + isBlack: true + }; + } else { + // White key positioning - just use the count + const x = offsetX + whiteKeysBefore * whiteKeyWidth; + + return { + x, + y: 0, + width: whiteKeyWidth, + height: whiteKeyHeight, + isBlack: false + }; + } + } + + /** + * Calculate visible range and offset based on pane width and height + */ + calculateVisibleRange(width, height) { + // Calculate white key width based on height to maintain aspect ratio + const whiteKeyWidth = height / this.whiteKeyAspectRatio; + + // Calculate how many white keys can fit in the pane (ceiling to fill space) + const whiteKeysFit = Math.ceil(width / whiteKeyWidth); + + // Keyboard-mapped range is C4 (60) to C5 (72), shifted by octave offset + // This contains 8 white keys: C, D, E, F, G, A, B, C + const keyboardCenter = 60 + (this.octaveOffset * 12); // C4 + octave shift + const keyboardWhiteKeys = 8; + + if (whiteKeysFit <= keyboardWhiteKeys) { + // Not enough space to show all keyboard keys, just center what we have + this.visibleStartNote = keyboardCenter; + this.visibleEndNote = keyboardCenter + 12; // One octave up + const totalWhiteKeyWidth = keyboardWhiteKeys * whiteKeyWidth; + const offsetX = (width - totalWhiteKeyWidth) / 2; + return { offsetX, whiteKeyWidth }; + } + + // Calculate how many extra white keys we have space for + const extraWhiteKeys = whiteKeysFit - keyboardWhiteKeys; + const leftExtra = Math.floor(extraWhiteKeys / 2); + const rightExtra = extraWhiteKeys - leftExtra; + + // Start from shifted keyboard center and go back leftExtra white keys + let startNote = keyboardCenter; + let leftCount = 0; + while (leftCount < leftExtra && startNote > 0) { + startNote--; + const info = this.getMidiNoteInfo(startNote); + if (!info.isBlack) { + leftCount++; + } + } + + // Now count forward exactly whiteKeysFit white keys from startNote + let endNote = startNote - 1; // Start one before so the first increment includes startNote + let whiteKeyCount = 0; + + while (whiteKeyCount < whiteKeysFit && endNote < 127) { + endNote++; + const info = this.getMidiNoteInfo(endNote); + if (!info.isBlack) { + whiteKeyCount++; + } + } + + this.visibleStartNote = startNote; + this.visibleEndNote = endNote; + + // No offset - keys start from left edge and fill to the right + return { offsetX: 0, whiteKeyWidth }; + } + + /** + * Find which MIDI note is at the given x, y position + */ + findKeyAtPosition(x, y, height, whiteKeyWidth, offsetX) { + // Check black keys first (they're on top) + for (let note = this.visibleStartNote; note <= this.visibleEndNote; note++) { + const info = this.getMidiNoteInfo(note); + if (!info.isBlack) continue; + + const geom = this.getKeyGeometry(note, height, whiteKeyWidth, offsetX); + if (x >= geom.x && x < geom.x + geom.width && + y >= geom.y && y < geom.y + geom.height) { + return note; + } + } + + // Then check white keys + for (let note = this.visibleStartNote; note <= this.visibleEndNote; note++) { + const info = this.getMidiNoteInfo(note); + if (info.isBlack) continue; + + const geom = this.getKeyGeometry(note, height, whiteKeyWidth, offsetX); + if (x >= geom.x && x < geom.x + geom.width && + y >= geom.y && y < geom.y + geom.height) { + return note; + } + } + + return null; + } + + /** + * Set which notes are currently playing (from MIDI playback) + */ + setPlayingNotes(notes) { + this.playingNotes = new Set(notes); + } + + /** + * Trigger a note on event + */ + noteOn(midiNote, velocity = 100) { + this.pressedKeys.add(midiNote); + + console.log(`Note ON: ${this.getMidiNoteInfo(midiNote).name} (${midiNote}) velocity: ${velocity}`); + + // Send to backend - use selected track or recording track + let trackId = 0; // Default to first track + if (typeof context !== 'undefined') { + // If recording, use the recording track + if (context.isRecording && context.recordingTrackId !== null) { + trackId = context.recordingTrackId; + } + // Otherwise use the selected track + else if (context.activeObject && context.activeObject.activeLayer && context.activeObject.activeLayer.audioTrackId !== null) { + trackId = context.activeObject.activeLayer.audioTrackId; + } + } + + invoke('audio_send_midi_note_on', { trackId: trackId, note: midiNote, velocity }).catch(error => { + console.error('Failed to send MIDI note on:', error); + }); + + // Request redraw to show the pressed key + if (typeof context !== 'undefined' && context.pianoRedraw) { + context.pianoRedraw(); + } + } + + /** + * Trigger a note off event + */ + noteOff(midiNote) { + this.pressedKeys.delete(midiNote); + + console.log(`Note OFF: ${this.getMidiNoteInfo(midiNote).name} (${midiNote})`); + + // Send to backend - use selected track or recording track + let trackId = 0; // Default to first track + if (typeof context !== 'undefined') { + // If recording, use the recording track + if (context.isRecording && context.recordingTrackId !== null) { + trackId = context.recordingTrackId; + } + // Otherwise use the selected track + else if (context.activeObject && context.activeObject.activeLayer && context.activeObject.activeLayer.audioTrackId !== null) { + trackId = context.activeObject.activeLayer.audioTrackId; + } + } + + invoke('audio_send_midi_note_off', { trackId: trackId, note: midiNote }).catch(error => { + console.error('Failed to send MIDI note off:', error); + }); + + // Request redraw to show the released key + if (typeof context !== 'undefined' && context.pianoRedraw) { + context.pianoRedraw(); + } + } + + hitTest(x, y) { + // Will be calculated in draw() based on pane width/height + return true; // Accept all events, let findKeyAtPosition handle precision + } + + mousedown(x, y, width, height) { + const { offsetX, whiteKeyWidth } = this.calculateVisibleRange(width, height); + const key = this.findKeyAtPosition(x, y, height, whiteKeyWidth, offsetX); + if (key !== null) { + this.noteOn(key, this.velocity); + } + } + + mousemove(x, y, width, height) { + const { offsetX, whiteKeyWidth } = this.calculateVisibleRange(width, height); + this.hoveredKey = this.findKeyAtPosition(x, y, height, whiteKeyWidth, offsetX); + } + + mouseup(x, y, width, height) { + // Release all pressed keys on mouse up + for (const key of this.pressedKeys) { + this.noteOff(key); + } + } + + draw(ctx, width, height) { + ctx.save(); + + // Background + ctx.fillStyle = backgroundColor; + ctx.fillRect(0, 0, width, height); + + // Calculate visible range and offset + const { offsetX, whiteKeyWidth } = this.calculateVisibleRange(width, height); + + // Draw white keys first + for (let note = this.visibleStartNote; note <= this.visibleEndNote; note++) { + const info = this.getMidiNoteInfo(note); + if (info.isBlack) continue; + + const geom = this.getKeyGeometry(note, height, whiteKeyWidth, offsetX); + + // Key color + const isPressed = this.pressedKeys.has(note); + const isPlaying = this.playingNotes.has(note); + const isHovered = this.hoveredKey === note; + + if (isPressed) { + ctx.fillStyle = highlight; // User pressed key + } else if (isPlaying) { + ctx.fillStyle = '#c8e6c9'; // Light green for MIDI playback + } else if (isHovered) { + ctx.fillStyle = '#f0f0f0'; + } else { + ctx.fillStyle = '#ffffff'; + } + + // Draw white key with rounded corners at the bottom + const radius = 3; + ctx.beginPath(); + ctx.moveTo(geom.x, geom.y); + ctx.lineTo(geom.x + geom.width, geom.y); + ctx.lineTo(geom.x + geom.width, geom.y + geom.height - radius); + ctx.arcTo(geom.x + geom.width, geom.y + geom.height, geom.x + geom.width - radius, geom.y + geom.height, radius); + ctx.lineTo(geom.x + radius, geom.y + geom.height); + ctx.arcTo(geom.x, geom.y + geom.height, geom.x, geom.y + geom.height - radius, radius); + ctx.lineTo(geom.x, geom.y); + ctx.closePath(); + ctx.fill(); + + // Key border + ctx.strokeStyle = shadow; + ctx.lineWidth = 1; + ctx.stroke(); + + // Keyboard mapping label (if exists) + // Subtract octave offset to get the base note for label lookup + const baseNote = note - (this.octaveOffset * 12); + const keyLabel = this.noteToKeyMap[baseNote]; + if (keyLabel) { + ctx.fillStyle = isPressed ? '#000000' : '#333333'; + ctx.font = 'bold 16px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(keyLabel, geom.x + geom.width / 2, geom.y + geom.height - 30); + } + + // Note name at bottom of white keys + if (info.semitone === 0) { // Only show octave number on C notes + ctx.fillStyle = labelColor; + ctx.font = '10px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'bottom'; + ctx.fillText(info.name, geom.x + geom.width / 2, geom.y + geom.height - 5); + } + } + + // Draw black keys on top + for (let note = this.visibleStartNote; note <= this.visibleEndNote; note++) { + const info = this.getMidiNoteInfo(note); + if (!info.isBlack) continue; + + const geom = this.getKeyGeometry(note, height, whiteKeyWidth, offsetX); + + // Key color + const isPressed = this.pressedKeys.has(note); + const isPlaying = this.playingNotes.has(note); + const isHovered = this.hoveredKey === note; + + if (isPressed) { + ctx.fillStyle = '#4a4a4a'; // User pressed black key + } else if (isPlaying) { + ctx.fillStyle = '#66bb6a'; // Darker green for MIDI playback on black keys + } else if (isHovered) { + ctx.fillStyle = '#2a2a2a'; + } else { + ctx.fillStyle = '#000000'; + } + + // Draw black key with rounded corners at the bottom + const blackRadius = 2; + ctx.beginPath(); + ctx.moveTo(geom.x, geom.y); + ctx.lineTo(geom.x + geom.width, geom.y); + ctx.lineTo(geom.x + geom.width, geom.y + geom.height - blackRadius); + ctx.arcTo(geom.x + geom.width, geom.y + geom.height, geom.x + geom.width - blackRadius, geom.y + geom.height, blackRadius); + ctx.lineTo(geom.x + blackRadius, geom.y + geom.height); + ctx.arcTo(geom.x, geom.y + geom.height, geom.x, geom.y + geom.height - blackRadius, blackRadius); + ctx.lineTo(geom.x, geom.y); + ctx.closePath(); + ctx.fill(); + + // Highlight on top edge + ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(geom.x, geom.y); + ctx.lineTo(geom.x + geom.width, geom.y); + ctx.stroke(); + + // Keyboard mapping label (if exists) + // Subtract octave offset to get the base note for label lookup + const baseNote = note - (this.octaveOffset * 12); + const keyLabel = this.noteToKeyMap[baseNote]; + if (keyLabel) { + ctx.fillStyle = isPressed ? '#ffffff' : 'rgba(255, 255, 255, 0.7)'; + ctx.font = 'bold 14px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(keyLabel, geom.x + geom.width / 2, geom.y + geom.height - 20); + } + } + + ctx.restore(); + } +} + +/** + * Piano Roll Editor + * MIDI note editor with piano keyboard on left and grid on right + */ +class PianoRollEditor extends Widget { + constructor(width, height, x, y) { + super(x, y) + this.width = width + this.height = height + + // Display settings + this.keyboardWidth = 60 // Width of piano keyboard on left + this.noteHeight = 16 // Height of each note row + this.pixelsPerSecond = 100 // Horizontal zoom + this.minNote = 21 // A0 + this.maxNote = 108 // C8 + this.totalNotes = this.maxNote - this.minNote + 1 + + // Scroll state + this.scrollX = 0 + this.scrollY = 0 + this.initialScrollSet = false // Track if we've set initial scroll position + + // Interaction state + this.selectedNotes = new Set() // Set of note indices + this.selectedClipId = null // Currently selected clip ID for editing + this.dragMode = null // null, 'move', 'resize', 'create', 'select' + this.dragStartX = 0 + this.dragStartY = 0 + this.creatingNote = null // Temporary note being created + this.selectionRect = null // Rectangle for multi-select {startX, startY, endX, endY} + this.isDragging = false + + // Note preview playback state + this.playingNote = null // Currently playing note number + this.playingNoteMaxDuration = null // Max duration in seconds + this.playingNoteStartTime = null // Timestamp when note started playing + + // Auto-scroll state + this.autoScrollEnabled = true // Auto-scroll to follow playhead during playback + this.lastPlayheadTime = 0 // Track last playhead position + + // Properties panel state + this.propertyInputs = {} // Will hold references to input elements + + // Start timer to check for note duration expiry + this.checkNoteDurationTimer = setInterval(() => this.checkNoteDuration(), 50) + } + + // Get the dimensions of the piano roll grid area (excluding keyboard) + // Note: Properties panel is outside the canvas now, so we don't subtract it here + getGridBounds() { + return { + left: this.keyboardWidth, + top: 0, + width: this.width - this.keyboardWidth, + height: this.height + } + } + + checkNoteDuration() { + if (this.playingNote !== null && this.playingNoteMaxDuration !== null && this.playingNoteStartTime !== null) { + const elapsed = (Date.now() - this.playingNoteStartTime) / 1000 + if (elapsed >= this.playingNoteMaxDuration) { + // Stop the note + const clipData = this.getSelectedClip() + if (clipData) { + invoke('audio_send_midi_note_off', { + trackId: clipData.trackId, + note: this.playingNote + }) + this.playingNote = null + this.playingNoteMaxDuration = null + this.playingNoteStartTime = null + } + } + } + } + + // Get all MIDI clips and the selected clip from the first MIDI track + getMidiClipsData() { + if (typeof context === 'undefined' || !context.activeObject || !context.activeObject.audioTracks) { + return null + } + + // Find the first MIDI track + for (const track of context.activeObject.audioTracks) { + if (track.type === 'midi' && track.clips && track.clips.length > 0) { + // If no clip is selected, default to the first clip + if (this.selectedClipId === null && track.clips.length > 0) { + this.selectedClipId = track.clips[0].clipId + } + + // Find the selected clip + let selectedClip = track.clips.find(c => c.clipId === this.selectedClipId) + + // If selected clip not found (maybe deleted), select first clip + if (!selectedClip && track.clips.length > 0) { + selectedClip = track.clips[0] + this.selectedClipId = selectedClip.clipId + } + + return { + allClips: track.clips, + selectedClip: selectedClip, + trackId: track.audioTrackId + } + } + } + return null + } + + // Get the currently selected MIDI clip (for backward compatibility) + getSelectedClip() { + const data = this.getMidiClipsData() + if (!data || !data.selectedClip) return null + return { clip: data.selectedClip, trackId: data.trackId } + } + + hitTest(x, y) { + return x >= 0 && x <= this.width && y >= 0 && y <= this.height + } + + // Convert screen coordinates to note/time + screenToNote(y) { + const gridY = y + this.scrollY + const noteIndex = Math.floor(gridY / this.noteHeight) + return this.maxNote - noteIndex // Invert (higher notes at top) + } + + screenToTime(x) { + const gridX = x - this.keyboardWidth + this.scrollX + return gridX / this.pixelsPerSecond + } + + // Convert note/time to screen coordinates + noteToScreenY(note) { + const noteIndex = this.maxNote - note + return noteIndex * this.noteHeight - this.scrollY + } + + timeToScreenX(time) { + return time * this.pixelsPerSecond - this.scrollX + this.keyboardWidth + } + + // Find which clip contains the given time + findClipAtTime(time) { + const clipsData = this.getMidiClipsData() + if (!clipsData || !clipsData.allClips) return null + + for (const clip of clipsData.allClips) { + const clipStart = clip.startTime || 0 + const clipEnd = clipStart + (clip.duration || 0) + if (time >= clipStart && time <= clipEnd) { + return clip + } + } + return null + } + + // Find note at screen position (only searches selected clip) + findNoteAtPosition(x, y) { + const clipData = this.getSelectedClip() + if (!clipData || !clipData.clip.notes) { + return -1 + } + + const note = this.screenToNote(y) + const time = this.screenToTime(x) + const clipStartTime = clipData.clip.startTime || 0 + const clipLocalTime = time - clipStartTime + + // Search in reverse order so we find top-most notes first + for (let i = clipData.clip.notes.length - 1; i >= 0; i--) { + const n = clipData.clip.notes[i] + const noteMatches = Math.round(n.note) === Math.round(note) + const timeInRange = clipLocalTime >= n.start_time && clipLocalTime <= (n.start_time + n.duration) + + if (noteMatches && timeInRange) { + return i + } + } + + return -1 + } + + // Check if clicking on the right edge resize handle + isOnResizeHandle(x, noteIndex) { + const clipData = this.getSelectedClip() + if (!clipData || noteIndex < 0 || noteIndex >= clipData.clip.notes.length) { + return false + } + + const note = clipData.clip.notes[noteIndex] + const clipStartTime = clipData.clip.startTime || 0 + const globalEndTime = clipStartTime + note.start_time + note.duration + const noteEndX = this.timeToScreenX(globalEndTime) + + // Consider clicking within 8 pixels of the right edge as resize + return Math.abs(x - noteEndX) < 8 + } + + mousedown(x, y) { + this._globalEvents.add("mousemove") + this._globalEvents.add("mouseup") + + this.isDragging = true + this.dragStartX = x + this.dragStartY = y + + // Check if clicking on keyboard or grid + if (x < this.keyboardWidth) { + // Clicking on keyboard - could preview note + return + } + + const note = this.screenToNote(y) + const time = this.screenToTime(x) + + // Check if clicking on a different clip and switch to it + const clickedClip = this.findClipAtTime(time) + if (clickedClip && clickedClip.clipId !== this.selectedClipId) { + this.selectedClipId = clickedClip.clipId + this.selectedNotes.clear() + // Redraw to show the new selection + if (context.timelineWidget) { + context.timelineWidget.requestRedraw() + } + // Don't start dragging/editing on the same click that switches clips + this.isDragging = false + this._globalEvents.delete("mousemove") + this._globalEvents.delete("mouseup") + return + } + + // Check if clicking on an existing note + const noteIndex = this.findNoteAtPosition(x, y) + + if (noteIndex >= 0) { + // Clicking on an existing note + const clipData = this.getSelectedClip() + if (this.isOnResizeHandle(x, noteIndex)) { + // Start resizing + this.dragMode = 'resize' + this.resizingNoteIndex = noteIndex + this.selectedNotes.clear() + this.selectedNotes.add(noteIndex) + } else { + // Start moving + this.dragMode = 'move' + this.movingStartTime = time + this.movingStartNote = note + + // Select this note (or add to selection with Ctrl/Cmd) + if (!this.selectedNotes.has(noteIndex)) { + this.selectedNotes.clear() + this.selectedNotes.add(noteIndex) + } + + // Play preview of the note + if (clipData && clipData.clip.notes[noteIndex]) { + const clickedNote = clipData.clip.notes[noteIndex] + this.playingNote = clickedNote.note + this.playingNoteMaxDuration = clickedNote.duration + this.playingNoteStartTime = Date.now() + + invoke('audio_send_midi_note_on', { + trackId: clipData.trackId, + note: clickedNote.note, + velocity: clickedNote.velocity + }) + } + } + } else { + // Clicking on empty space + const isShiftHeld = this.lastClickEvent?.shiftKey || false + + if (isShiftHeld) { + // Shift+click: Start creating a new note + this.dragMode = 'create' + this.selectedNotes.clear() + + // Create a temporary note for preview (store in clip-local time) + const clipData = this.getSelectedClip() + const clipStartTime = clipData?.clip?.startTime || 0 + const clipLocalTime = time - clipStartTime + + const newNoteValue = Math.round(note) + this.creatingNote = { + note: newNoteValue, + start_time: clipLocalTime, + duration: 0.1, // Minimum duration + velocity: 100 + } + + // Play preview of the new note + if (clipData) { + this.playingNote = newNoteValue + this.playingNoteMaxDuration = null // No max duration for creating notes + this.playingNoteStartTime = Date.now() + + invoke('audio_send_midi_note_on', { + trackId: clipData.trackId, + note: newNoteValue, + velocity: 100 + }) + } + } else { + // Regular click: Start selection rectangle + this.dragMode = 'select' + this.selectedNotes.clear() + this.selectionRect = { + startX: x, + startY: y, + endX: x, + endY: y + } + } + } + } + + mousemove(x, y) { + // Update cursor based on hover position even when not dragging + if (!this.isDragging && x >= this.keyboardWidth) { + const noteIndex = this.findNoteAtPosition(x, y) + if (noteIndex >= 0 && this.isOnResizeHandle(x, noteIndex)) { + this.cursor = 'ew-resize' + } else { + this.cursor = 'default' + } + } + + if (!this.isDragging) return + + const clipData = this.getSelectedClip() + if (!clipData) return + + if (this.dragMode === 'create') { + // Extend the note being created + if (this.creatingNote) { + const currentTime = this.screenToTime(x) + const clipStartTime = clipData.clip.startTime || 0 + const clipLocalTime = currentTime - clipStartTime + const duration = Math.max(0.1, clipLocalTime - this.creatingNote.start_time) + this.creatingNote.duration = duration + } + } else if (this.dragMode === 'move') { + // Move selected notes + const currentTime = this.screenToTime(x) + const currentNote = this.screenToNote(y) + + const deltaTime = currentTime - this.movingStartTime + const deltaNote = Math.round(currentNote - this.movingStartNote) + + // Check if pitch changed + if (deltaNote !== 0) { + const firstSelectedIndex = Array.from(this.selectedNotes)[0] + if (firstSelectedIndex >= 0 && firstSelectedIndex < clipData.clip.notes.length) { + const movedNote = clipData.clip.notes[firstSelectedIndex] + const newPitch = Math.max(0, Math.min(127, movedNote.note + deltaNote)) + + // Stop old note if one is playing + if (this.playingNote !== null) { + invoke('audio_send_midi_note_off', { + trackId: clipData.trackId, + note: this.playingNote + }) + } + + // Update playing note to new pitch + this.playingNote = newPitch + this.playingNoteMaxDuration = movedNote.duration + this.playingNoteStartTime = Date.now() + + // Play new note at new pitch + invoke('audio_send_midi_note_on', { + trackId: clipData.trackId, + note: newPitch, + velocity: movedNote.velocity + }) + } + } + + // Update positions of all selected notes + for (const noteIndex of this.selectedNotes) { + if (noteIndex >= 0 && noteIndex < clipData.clip.notes.length) { + const note = clipData.clip.notes[noteIndex] + note.start_time = Math.max(0, note.start_time + deltaTime) + note.note = Math.max(0, Math.min(127, note.note + deltaNote)) + } + } + + // Update drag start positions for next move + this.movingStartTime = currentTime + this.movingStartNote = currentNote + + // Trigger timeline redraw to show updated notes + if (context.timelineWidget) { + context.timelineWidget.requestRedraw() + } + } else if (this.dragMode === 'resize') { + // Resize the selected note + if (this.resizingNoteIndex >= 0 && this.resizingNoteIndex < clipData.clip.notes.length) { + const note = clipData.clip.notes[this.resizingNoteIndex] + const currentTime = this.screenToTime(x) + const clipStartTime = clipData.clip.startTime || 0 + const clipLocalTime = currentTime - clipStartTime + const newDuration = Math.max(0.1, clipLocalTime - note.start_time) + note.duration = newDuration + + // Trigger timeline redraw to show updated notes + if (context.timelineWidget) { + context.timelineWidget.requestRedraw() + } + } + } else if (this.dragMode === 'select') { + // Update selection rectangle + if (this.selectionRect) { + this.selectionRect.endX = x + this.selectionRect.endY = y + + // Update selected notes based on rectangle + this.updateSelectionFromRect(clipData) + } + } + } + + mouseup(x, y) { + this._globalEvents.delete("mousemove") + this._globalEvents.delete("mouseup") + + const clipData = this.getSelectedClip() + + // Check if this was a simple click (not a drag) on empty space + if (this.dragMode === 'select' && this.dragStartX !== undefined && this.dragStartY !== undefined) { + const dragDistance = Math.sqrt( + Math.pow(x - this.dragStartX, 2) + Math.pow(y - this.dragStartY, 2) + ) + + // If drag distance is minimal (< 5 pixels), treat it as a click to reposition playhead + if (dragDistance < 5) { + const time = this.screenToTime(x) + + // Set playhead position + if (context.activeObject) { + context.activeObject.currentTime = time + + // Request redraws to show the new playhead position + if (context.timelineWidget) { + context.timelineWidget.requestRedraw() + } + if (context.pianoRollRedraw) { + context.pianoRollRedraw() + } + } + } + } + + // Stop playing note + if (this.playingNote !== null && clipData) { + invoke('audio_send_midi_note_off', { + trackId: clipData.trackId, + note: this.playingNote + }) + this.playingNote = null + this.playingNoteMaxDuration = null + this.playingNoteStartTime = null + } + + // If we were creating a note, add it to the clip + if (this.dragMode === 'create' && this.creatingNote && clipData) { + if (!clipData.clip.notes) { + clipData.clip.notes = [] + } + + // Binary search to find insertion position to maintain sorted order + const newNote = { ...this.creatingNote } + let left = 0 + let right = clipData.clip.notes.length + while (left < right) { + const mid = Math.floor((left + right) / 2) + if (clipData.clip.notes[mid].start_time < newNote.start_time) { + left = mid + 1 + } else { + right = mid + } + } + clipData.clip.notes.splice(left, 0, newNote) + + // Trigger timeline redraw to show new note + if (context.timelineWidget) { + context.timelineWidget.requestRedraw() + } + + // Sync to backend + this.syncNotesToBackend(clipData) + } + + // If we moved or resized notes, sync to backend + if ((this.dragMode === 'move' || this.dragMode === 'resize') && clipData) { + if (context.timelineWidget) { + context.timelineWidget.requestRedraw() + } + + // Sync to backend + this.syncNotesToBackend(clipData) + } + + this.isDragging = false + this.dragMode = null + this.creatingNote = null + this.selectionRect = null + this.resizingNoteIndex = -1 + } + + wheel(e) { + // Support horizontal scrolling from trackpad (deltaX) or Shift+scroll (deltaY) + if (e.deltaX !== 0) { + // Trackpad horizontal scroll + this.scrollX += e.deltaX + } else if (e.shiftKey) { + // Shift+wheel for horizontal scroll + this.scrollX += e.deltaY + } else { + // Normal vertical scroll + this.scrollY += e.deltaY + } + + this.scrollX = Math.max(0, this.scrollX) + this.scrollY = Math.max(0, this.scrollY) + + // Disable auto-scroll when user manually scrolls + this.autoScrollEnabled = false + } + + keydown(e) { + // Handle delete/backspace to delete selected notes + if (e.key === 'Delete' || e.key === 'Backspace') { + if (this.selectedNotes.size > 0) { + const clipData = this.getSelectedClip() + if (clipData && clipData.clip && clipData.clip.notes) { + // Convert set to sorted array in reverse order to avoid index shifting + const indicesToDelete = Array.from(this.selectedNotes).sort((a, b) => b - a) + + for (const index of indicesToDelete) { + if (index >= 0 && index < clipData.clip.notes.length) { + clipData.clip.notes.splice(index, 1) + } + } + + // Clear selection + this.selectedNotes.clear() + + // Sync to backend + this.syncNotesToBackend(clipData) + + // Trigger redraws + if (context.timelineWidget) { + context.timelineWidget.requestRedraw() + } + if (context.pianoRollRedraw) { + context.pianoRollRedraw() + } + } + e.preventDefault() + } + } + } + + updateSelectionFromRect(clipData) { + if (!clipData || !clipData.clip || !clipData.clip.notes || !this.selectionRect) { + return + } + + const clipStartTime = clipData.clip.startTime || 0 + this.selectedNotes.clear() + + // Get rectangle bounds + const minX = Math.min(this.selectionRect.startX, this.selectionRect.endX) + const maxX = Math.max(this.selectionRect.startX, this.selectionRect.endX) + const minY = Math.min(this.selectionRect.startY, this.selectionRect.endY) + const maxY = Math.max(this.selectionRect.startY, this.selectionRect.endY) + + // Convert to time/note coordinates + const minTime = this.screenToTime(minX) + const maxTime = this.screenToTime(maxX) + const minNote = this.screenToNote(maxY) // Note: Y is inverted + const maxNote = this.screenToNote(minY) + + // Check each note + for (let i = 0; i < clipData.clip.notes.length; i++) { + const note = clipData.clip.notes[i] + const noteGlobalStart = clipStartTime + note.start_time + const noteGlobalEnd = noteGlobalStart + note.duration + + // Check if note overlaps with selection rectangle + const timeOverlaps = noteGlobalEnd >= minTime && noteGlobalStart <= maxTime + const noteOverlaps = note.note >= minNote && note.note <= maxNote + + if (timeOverlaps && noteOverlaps) { + this.selectedNotes.add(i) + } + } + } + + syncNotesToBackend(clipData) { + // Convert notes to backend format: (start_time, note, velocity, duration) + const notes = clipData.clip.notes.map(n => [ + n.start_time, + n.note, + n.velocity, + n.duration + ]) + + // Send to backend + invoke('audio_update_midi_clip_notes', { + trackId: clipData.trackId, + clipId: clipData.clip.clipId, + notes: notes + }).catch(err => { + console.error('Failed to update MIDI notes:', err) + }) + } + + draw(ctx) { + // Update dimensions + // (width/height will be set by parent container) + + // Set initial scroll position to center on G4 (MIDI note 67) on first draw + if (!this.initialScrollSet && this.height > 0) { + const g4Index = this.maxNote - 67 // G4 is MIDI note 67 + const g4Y = g4Index * this.noteHeight + // Center G4 in the viewport + this.scrollY = g4Y - (this.height / 2) + this.initialScrollSet = true + } + + // Auto-scroll to follow playhead during playback + if (this.autoScrollEnabled && context.activeObject && this.width > 0) { + const playheadTime = context.activeObject.currentTime || 0 + + // Check if playhead is moving forward (playing) + if (playheadTime > this.lastPlayheadTime) { + // Center playhead in viewport + const gridWidth = this.width - this.keyboardWidth + const playheadScreenX = playheadTime * this.pixelsPerSecond + const targetScrollX = playheadScreenX - (gridWidth / 2) + + this.scrollX = Math.max(0, targetScrollX) + } + + this.lastPlayheadTime = playheadTime + } + + // Clear + ctx.fillStyle = backgroundColor + ctx.fillRect(0, 0, this.width, this.height) + + // Draw piano keyboard + this.drawKeyboard(ctx, this.width, this.height) + + // Draw grid + this.drawGrid(ctx, this.width, this.height) + + // Draw clip boundaries + const clipsData = this.getMidiClipsData() + if (clipsData && clipsData.allClips) { + this.drawClipBoundaries(ctx, this.width, this.height, clipsData.allClips) + } + + // Draw notes for all clips in the track + if (clipsData && clipsData.allClips) { + // Draw non-selected clips first (at lower opacity) + for (const clip of clipsData.allClips) { + if (clip.clipId !== this.selectedClipId && clip.notes) { + this.drawNotes(ctx, this.width, this.height, clip, 0.3) + } + } + + // Draw selected clip on top (at full opacity) + if (clipsData.selectedClip && clipsData.selectedClip.notes) { + this.drawNotes(ctx, this.width, this.height, clipsData.selectedClip, 1.0) + } + } + + // Draw playhead + this.drawPlayhead(ctx, this.width, this.height) + + // Draw selection rectangle + if (this.selectionRect) { + this.drawSelectionRect(ctx, this.width, this.height) + } + + // Update HTML properties panel + this.updatePropertiesPanel() + } + + drawSelectionRect(ctx, width, height) { + if (!this.selectionRect) return + + const gridLeft = this.keyboardWidth + const minX = Math.max(gridLeft, Math.min(this.selectionRect.startX, this.selectionRect.endX)) + const maxX = Math.min(width, Math.max(this.selectionRect.startX, this.selectionRect.endX)) + const minY = Math.max(0, Math.min(this.selectionRect.startY, this.selectionRect.endY)) + const maxY = Math.min(height, Math.max(this.selectionRect.startY, this.selectionRect.endY)) + + ctx.save() + + // Draw filled rectangle with transparency + ctx.fillStyle = 'rgba(100, 150, 255, 0.2)' + ctx.fillRect(minX, minY, maxX - minX, maxY - minY) + + // Draw border + ctx.strokeStyle = 'rgba(100, 150, 255, 0.6)' + ctx.lineWidth = 1 + ctx.strokeRect(minX, minY, maxX - minX, maxY - minY) + + ctx.restore() + } + + updatePropertiesPanel() { + // Update the HTML properties panel with current selection + if (!this.propertiesPanel) return + + const clipData = this.getSelectedClip() + const properties = this.getSelectedNoteProperties(clipData) + + // Update pitch (display-only) + this.propertiesPanel.pitch.textContent = properties.pitch || '-' + + // Update velocity + if (properties.velocity !== null) { + this.propertiesPanel.velocity.input.value = properties.velocity + this.propertiesPanel.velocity.slider.value = properties.velocity + } else { + this.propertiesPanel.velocity.input.value = '' + this.propertiesPanel.velocity.slider.value = 64 // Default middle value + } + + // Update modulation + if (properties.modulation !== null) { + this.propertiesPanel.modulation.input.value = properties.modulation + this.propertiesPanel.modulation.slider.value = properties.modulation + } else { + this.propertiesPanel.modulation.input.value = '' + this.propertiesPanel.modulation.slider.value = 0 + } + } + + getSelectedNoteProperties(clipData) { + if (!clipData || !clipData.clip || !clipData.clip.notes || this.selectedNotes.size === 0) { + return { pitch: null, velocity: null, modulation: null } + } + + const selectedIndices = Array.from(this.selectedNotes) + const notes = selectedIndices.map(i => clipData.clip.notes[i]).filter(n => n) + + if (notes.length === 0) { + return { pitch: null, velocity: null, modulation: null } + } + + // Check if all selected notes have the same values + const firstNote = notes[0] + const allSamePitch = notes.every(n => n.note === firstNote.note) + const allSameVelocity = notes.every(n => n.velocity === firstNote.velocity) + const allSameModulation = notes.every(n => (n.modulation || 0) === (firstNote.modulation || 0)) + + // Convert MIDI note number to name + const noteName = allSamePitch ? this.midiNoteToName(firstNote.note) : null + + return { + pitch: noteName, + velocity: allSameVelocity ? firstNote.velocity : null, + modulation: allSameModulation ? (firstNote.modulation || 0) : null + } + } + + midiNoteToName(midiNote) { + const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] + const octave = Math.floor(midiNote / 12) - 1 + const noteName = noteNames[midiNote % 12] + return `${noteName}${octave} (${midiNote})` + } + + drawKeyboard(ctx, width, height) { + const keyboardWidth = this.keyboardWidth + + // Draw keyboard background + ctx.fillStyle = shade + ctx.fillRect(0, 0, keyboardWidth, height) + + // Draw keys + for (let note = this.minNote; note <= this.maxNote; note++) { + const y = this.noteToScreenY(note) + + if (y < 0 || y > height) continue + + const isBlackKey = [1, 3, 6, 8, 10].includes(note % 12) + + ctx.fillStyle = isBlackKey ? '#333' : '#fff' + ctx.fillRect(5, y, keyboardWidth - 10, this.noteHeight - 1) + + // Draw note label for C notes + if (note % 12 === 0) { + ctx.fillStyle = '#999' + ctx.font = '10px sans-serif' + ctx.textAlign = 'right' + ctx.textBaseline = 'middle' + ctx.fillText(`C${Math.floor(note / 12) - 1}`, keyboardWidth - 15, y + this.noteHeight / 2) + } + } + } + + drawGrid(ctx, width, height) { + const gridBounds = this.getGridBounds() + const gridLeft = gridBounds.left + const gridWidth = gridBounds.width + const gridHeight = gridBounds.height + + ctx.save() + ctx.beginPath() + ctx.rect(gridLeft, 0, gridWidth, gridHeight) + ctx.clip() + + // Draw background + ctx.fillStyle = backgroundColor + ctx.fillRect(gridLeft, 0, gridWidth, gridHeight) + + // Draw horizontal lines (note separators) + ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)' + ctx.lineWidth = 1 + + for (let note = this.minNote; note <= this.maxNote; note++) { + const y = this.noteToScreenY(note) + + if (y < 0 || y > height) continue + + // Highlight C notes + if (note % 12 === 0) { + ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)' + } else { + ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)' + } + + ctx.beginPath() + ctx.moveTo(gridLeft, y) + ctx.lineTo(width, y) + ctx.stroke() + } + + // Draw vertical lines (time grid) + const beatInterval = 0.5 // Half second intervals + const startTime = Math.floor(this.scrollX / this.pixelsPerSecond / beatInterval) * beatInterval + const endTime = (this.scrollX + gridWidth) / this.pixelsPerSecond + + for (let time = startTime; time <= endTime; time += beatInterval) { + const x = this.timeToScreenX(time) + + if (x < gridLeft || x > width) continue + + // Every second is brighter + if (Math.abs(time % 1.0) < 0.01) { + ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)' + } else { + ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)' + } + + ctx.beginPath() + ctx.moveTo(x, 0) + ctx.lineTo(x, height) + ctx.stroke() + } + + ctx.restore() + } + + drawClipBoundaries(ctx, width, height, clips) { + const gridLeft = this.keyboardWidth + + ctx.save() + ctx.beginPath() + ctx.rect(gridLeft, 0, width - gridLeft, height) + ctx.clip() + + // Draw background highlight for selected clip + const selectedClip = clips.find(c => c.clipId === this.selectedClipId) + if (selectedClip) { + const clipStart = selectedClip.startTime || 0 + const clipEnd = clipStart + (selectedClip.duration || 0) + const startX = Math.max(gridLeft, this.timeToScreenX(clipStart)) + const endX = Math.min(width, this.timeToScreenX(clipEnd)) + + if (endX > startX) { + ctx.fillStyle = 'rgba(111, 220, 111, 0.05)' // Very subtle green tint + ctx.fillRect(startX, 0, endX - startX, height) + } + } + + // Draw start and end lines for each clip + for (const clip of clips) { + const clipStart = clip.startTime || 0 + const clipEnd = clipStart + (clip.duration || 0) + const isSelected = clip.clipId === this.selectedClipId + + // Use brighter green for selected clip, dimmer for others + const color = isSelected ? 'rgba(111, 220, 111, 0.5)' : 'rgba(111, 220, 111, 0.2)' + const lineWidth = isSelected ? 2 : 1 + + ctx.strokeStyle = color + ctx.lineWidth = lineWidth + + // Draw clip start line + const startX = this.timeToScreenX(clipStart) + if (startX >= gridLeft && startX <= width) { + ctx.beginPath() + ctx.moveTo(startX, 0) + ctx.lineTo(startX, height) + ctx.stroke() + } + + // Draw clip end line + const endX = this.timeToScreenX(clipEnd) + if (endX >= gridLeft && endX <= width) { + ctx.beginPath() + ctx.moveTo(endX, 0) + ctx.lineTo(endX, height) + ctx.stroke() + } + } + + ctx.restore() + } + + drawNotes(ctx, width, height, clip, opacity = 1.0) { + const gridLeft = this.keyboardWidth + const clipStartTime = clip.startTime || 0 + const isSelectedClip = clip.clipId === this.selectedClipId + + ctx.save() + ctx.globalAlpha = opacity + ctx.beginPath() + ctx.rect(gridLeft, 0, width - gridLeft, height) + ctx.clip() + + // Draw existing notes at their global timeline position + for (let i = 0; i < clip.notes.length; i++) { + const note = clip.notes[i] + + // Convert note time to global timeline time + const globalTime = clipStartTime + note.start_time + const x = this.timeToScreenX(globalTime) + const y = this.noteToScreenY(note.note) + const noteWidth = note.duration * this.pixelsPerSecond + const noteHeight = this.noteHeight - 2 + + // Skip if off-screen + if (x + noteWidth < gridLeft || x > width || y + noteHeight < 0 || y > height) { + continue + } + + // Calculate brightness based on velocity (1-127) + // Map velocity to brightness range: 0.35 (min) to 1.0 (max) + const velocity = note.velocity || 100 + const brightness = 0.35 + (velocity / 127) * 0.65 + + // Highlight selected notes (only for selected clip) + if (isSelectedClip && this.selectedNotes.has(i)) { + // Selected note: brighter green with velocity-based brightness + const r = Math.round(143 * brightness) + const g = Math.round(252 * brightness) + const b = Math.round(143 * brightness) + ctx.fillStyle = `rgb(${r}, ${g}, ${b})` + } else { + // Normal note: velocity-based brightness + const r = Math.round(111 * brightness) + const g = Math.round(220 * brightness) + const b = Math.round(111 * brightness) + ctx.fillStyle = `rgb(${r}, ${g}, ${b})` + } + + ctx.fillRect(x, y, noteWidth, noteHeight) + + // Draw border + ctx.strokeStyle = 'rgba(0, 0, 0, 0.3)' + ctx.strokeRect(x, y, noteWidth, noteHeight) + } + + // Draw note being created (only for selected clip) + if (this.creatingNote && isSelectedClip) { + // Note being created is in clip-local time, convert to global + const globalTime = clipStartTime + this.creatingNote.start_time + const x = this.timeToScreenX(globalTime) + const y = this.noteToScreenY(this.creatingNote.note) + const noteWidth = this.creatingNote.duration * this.pixelsPerSecond + const noteHeight = this.noteHeight - 2 + + // Draw with a slightly transparent color to indicate it's being created + ctx.fillStyle = 'rgba(111, 220, 111, 0.7)' + ctx.fillRect(x, y, noteWidth, noteHeight) + + ctx.strokeStyle = 'rgba(0, 0, 0, 0.5)' + ctx.setLineDash([4, 4]) + ctx.strokeRect(x, y, noteWidth, noteHeight) + ctx.setLineDash([]) + } + + ctx.restore() + } + + drawPlayhead(ctx, width, height) { + // Get current playhead time from context + if (typeof context === 'undefined' || !context.activeObject) { + return + } + + const playheadTime = context.activeObject.currentTime || 0 + const gridLeft = this.keyboardWidth + + // Convert time to screen X position + const playheadX = this.timeToScreenX(playheadTime) + + // Only draw if playhead is visible + if (playheadX < gridLeft || playheadX > width) { + return + } + + ctx.save() + ctx.beginPath() + ctx.rect(gridLeft, 0, width - gridLeft, height) + ctx.clip() + + // Draw playhead line + ctx.strokeStyle = 'rgba(255, 100, 100, 0.8)' + ctx.lineWidth = 2 + ctx.beginPath() + ctx.moveTo(playheadX, 0) + ctx.lineTo(playheadX, height) + ctx.stroke() + + ctx.restore() + } +} + export { SCROLL, Widget, @@ -527,5 +6633,8 @@ export { HBox, VBox, ScrollableWindow, ScrollableWindowHeaders, - TimelineWindow + TimelineWindow, + TimelineWindowV2, + VirtualPiano, + PianoRollEditor }; \ No newline at end of file diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..cc9100e --- /dev/null +++ b/tests/README.md @@ -0,0 +1,147 @@ +# Lightningbeam UI Tests + +Automated UI tests for Lightningbeam using WebdriverIO and tauri-driver. + +## Prerequisites + +1. **Install test dependencies**: + ```bash + pnpm add -D @wdio/cli @wdio/local-runner @wdio/mocha-framework @wdio/spec-reporter @wdio/globals + ``` + + **Important**: If the `@wdio/local-runner` package hangs during installation, you must install it in your native OS environment (not in a container). The pnpm store can have conflicts when switching between different OS contexts. If you originally ran `pnpm install` on your host system, install the test dependencies there as well. + +2. **Build the application** - Tests require the release build: + ```bash + pnpm tauri build + ``` + + **Note**: The debug build (`pnpm tauri build --debug`) won't work for tests because it expects a dev server to be running. Tests use the self-contained release build. + +3. **Install tauri-driver** - Download from [tauri-apps/tauri releases](https://github.com/tauri-apps/tauri/releases): + ```bash + # Linux example + cargo install tauri-driver + # Or download binary and add to PATH + ``` + +## Running Tests + +### 1. Start tauri-driver +In a separate terminal, start tauri-driver: +```bash +tauri-driver --port 4444 +``` + +### 2. Run all tests +```bash +pnpm test +``` + +### Run tests in watch mode +```bash +pnpm test:watch +``` + +### Run specific test file +```bash +pnpm wdio run wdio.conf.js --spec tests/specs/shapes.test.js +``` + +## Test Structure + +``` +tests/ +├── helpers/ +│ ├── app.js # App lifecycle helpers +│ ├── canvas.js # Canvas interaction utilities +│ └── assertions.js # Custom assertions +├── specs/ +│ ├── shapes.test.js # Shape drawing tests +│ ├── grouping.test.js # Shape grouping tests +│ └── paint-bucket.test.js # Paint bucket tool tests +└── fixtures/ # Test data files +``` + +## Writing Tests + +### Example: Drawing a Rectangle + +```javascript +import { drawRectangle } from '../helpers/canvas.js'; +import { assertShapeExists } from '../helpers/assertions.js'; + +it('should draw a rectangle', async () => { + await drawRectangle(100, 100, 200, 150); + await assertShapeExists(200, 175, 'Rectangle should exist at center'); +}); +``` + +### Available Helpers + +#### Canvas Helpers (`canvas.js`) +- `clickCanvas(x, y)` - Click at coordinates +- `dragCanvas(fromX, fromY, toX, toY)` - Drag operation +- `drawRectangle(x, y, width, height)` - Draw rectangle +- `drawEllipse(x, y, width, height)` - Draw ellipse +- `selectTool(toolName)` - Select a tool by name +- `selectMultipleShapes(points)` - Select multiple shapes with Ctrl +- `useKeyboardShortcut(key, withCtrl)` - Use keyboard shortcuts +- `getPixelColor(x, y)` - Get color at coordinates +- `hasShapeAt(x, y)` - Check if shape exists at point + +#### Assertion Helpers (`assertions.js`) +- `assertShapeExists(x, y, message)` - Assert shape at coordinates +- `assertNoShapeAt(x, y, message)` - Assert no shape at coordinates +- `assertPixelColor(x, y, color, message)` - Assert pixel color +- `assertColorApproximately(color1, color2, tolerance)` - Fuzzy color match + +## Adding Data Attributes for Testing + +To make UI elements easier to test, add `data-tool` attributes to tool buttons in the UI: + +```javascript +// Example in main.js +const rectangleTool = document.createElement('button'); +rectangleTool.setAttribute('data-tool', 'rectangle'); +``` + +Current expected data attributes: +- `data-tool="rectangle"` - Rectangle tool button +- `data-tool="ellipse"` - Ellipse tool button +- `data-tool="dropper"` - Paint bucket/dropper tool button +- Add more as needed... + +## Platform Support + +- **Linux**: Full support with webkit2gtk +- **Windows**: Full support with WebView2 +- **macOS**: Limited support (no WKWebView driver available) + +## Troubleshooting + +### Tests fail to start +- Ensure the release build exists: `./src-tauri/target/release/lightningbeam` +- Check that `tauri-driver` is in your PATH + +### Canvas interactions don't work +- Verify that tool buttons have `data-tool` attributes +- Check that canvas element is present with `document.querySelector('canvas')` + +### Screenshots directory missing +```bash +mkdir -p tests/screenshots +``` + +## CI Integration + +See `.github/workflows/` for example GitHub Actions configuration (to be added). + +## Future Enhancements + +- [ ] Add color picker test helpers +- [ ] Add timeline/keyframe test helpers +- [ ] Add layer management test helpers +- [ ] Visual regression testing with screenshot comparison +- [ ] Performance benchmarks +- [ ] Add Tauri commands for better state inspection diff --git a/tests/helpers/app.js b/tests/helpers/app.js new file mode 100644 index 0000000..b7d86f2 --- /dev/null +++ b/tests/helpers/app.js @@ -0,0 +1,64 @@ +/** + * App helper utilities for Tauri application testing + */ + +/** + * Wait for the Lightningbeam app to be fully loaded and ready + * @param {number} timeout - Maximum time to wait in ms + */ +export async function waitForAppReady(timeout = 5000) { + await browser.waitForApp(); + + // Check for "Animation" card on start screen and click it if present + // The card has a label div with text "Animation" + const animationCard = await browser.$('.focus-card-label*=Animation'); + if (await animationCard.isExisting()) { + // Click the parent focus-card element + const card = await animationCard.parentElement(); + await card.waitForClickable({ timeout: 2000 }); + await card.click(); + await browser.pause(1000); // Wait longer for animation view to load + } else { + // Legacy: Check for "Create New File" dialog and click Create if present + const createButton = await browser.$('button*=Create'); + if (await createButton.isExisting()) { + await createButton.waitForClickable({ timeout: 2000 }); + await createButton.click(); + await browser.pause(500); // Wait for dialog to close + } + } + + // Wait for the main canvas to be present + const canvas = await browser.$('canvas'); + await canvas.waitForExist({ timeout }); + + // Additional wait for any initialization + await browser.pause(500); +} + +/** + * Get the main canvas element + * @returns {Promise} + */ +export async function getCanvas() { + return await browser.$('canvas'); +} + +/** + * Get canvas dimensions + * @returns {Promise<{width: number, height: number}>} + */ +export async function getCanvasSize() { + const canvas = await getCanvas(); + const size = await canvas.getSize(); + return size; +} + +/** + * Take a screenshot of the canvas + * @param {string} filename - Name for the screenshot file + */ +export async function takeCanvasScreenshot(filename) { + const canvas = await getCanvas(); + return await canvas.saveScreenshot(`./tests/screenshots/${filename}`); +} diff --git a/tests/helpers/assertions.js b/tests/helpers/assertions.js new file mode 100644 index 0000000..b88809d --- /dev/null +++ b/tests/helpers/assertions.js @@ -0,0 +1,75 @@ +/** + * Custom assertion helpers for Lightningbeam UI tests + */ + +import { getPixelColor, hasShapeAt } from './canvas.js'; +import { expect } from '@wdio/globals'; + +/** + * Assert that a shape exists at the given coordinates + * @param {number} x - X coordinate + * @param {number} y - Y coordinate + * @param {string} message - Custom error message + */ +export async function assertShapeExists(x, y, message = 'Expected shape to exist at coordinates') { + const shapeExists = await hasShapeAt(x, y); + expect(shapeExists).toBe(true, `${message} (${x}, ${y})`); +} + +/** + * Assert that no shape exists at the given coordinates + * @param {number} x - X coordinate + * @param {number} y - Y coordinate + * @param {string} message - Custom error message + */ +export async function assertNoShapeAt(x, y, message = 'Expected no shape at coordinates') { + const shapeExists = await hasShapeAt(x, y); + expect(shapeExists).toBe(false, `${message} (${x}, ${y})`); +} + +/** + * Assert that a pixel has a specific color + * @param {number} x - X coordinate + * @param {number} y - Y coordinate + * @param {string} expectedColor - Expected color in hex format + * @param {string} message - Custom error message + */ +export async function assertPixelColor(x, y, expectedColor, message = 'Expected pixel color to match') { + const actualColor = await getPixelColor(x, y); + expect(actualColor.toLowerCase()).toBe(expectedColor.toLowerCase(), + `${message}. Expected ${expectedColor}, got ${actualColor} at (${x}, ${y})`); +} + +/** + * Assert that a color is approximately equal to another (with tolerance) + * Useful for anti-aliasing and rendering differences + * @param {string} color1 - First color in hex format + * @param {string} color2 - Second color in hex format + * @param {number} tolerance - Tolerance per channel (0-255) + */ +export function assertColorApproximately(color1, color2, tolerance = 10) { + const rgb1 = hexToRgb(color1); + const rgb2 = hexToRgb(color2); + + const rDiff = Math.abs(rgb1.r - rgb2.r); + const gDiff = Math.abs(rgb1.g - rgb2.g); + const bDiff = Math.abs(rgb1.b - rgb2.b); + + expect(rDiff).toBeLessThanOrEqual(tolerance, `Red channel difference ${rDiff} exceeds tolerance ${tolerance}`); + expect(gDiff).toBeLessThanOrEqual(tolerance, `Green channel difference ${gDiff} exceeds tolerance ${tolerance}`); + expect(bDiff).toBeLessThanOrEqual(tolerance, `Blue channel difference ${bDiff} exceeds tolerance ${tolerance}`); +} + +/** + * Convert hex color to RGB object + * @param {string} hex - Hex color string + * @returns {{r: number, g: number, b: number}} + */ +function hexToRgb(hex) { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16) + } : null; +} diff --git a/tests/helpers/canvas.js b/tests/helpers/canvas.js new file mode 100644 index 0000000..e5b9320 --- /dev/null +++ b/tests/helpers/canvas.js @@ -0,0 +1,257 @@ +/** + * Canvas interaction utilities for UI testing + */ + +/** + * Reset canvas scroll/pan to origin + */ +export async function resetCanvasView() { + await browser.execute(function() { + if (window.context && window.context.stageWidget) { + window.context.stageWidget.offsetX = 0; + window.context.stageWidget.offsetY = 0; + // Trigger redraw to apply the reset + if (window.context.updateUI) { + window.context.updateUI(); + } + } + }); + await browser.pause(100); // Wait for canvas to reset +} + +/** + * Click at specific coordinates on the canvas + * @param {number} x - X coordinate relative to canvas + * @param {number} y - Y coordinate relative to canvas + */ +export async function clickCanvas(x, y) { + await resetCanvasView(); + await browser.clickCanvas(x, y); + await browser.pause(100); // Wait for render +} + +/** + * Drag from one point to another on the canvas + * @param {number} fromX - Starting X coordinate + * @param {number} fromY - Starting Y coordinate + * @param {number} toX - Ending X coordinate + * @param {number} toY - Ending Y coordinate + */ +export async function dragCanvas(fromX, fromY, toX, toY) { + await resetCanvasView(); + await browser.dragCanvas(fromX, fromY, toX, toY); + await browser.pause(200); // Wait for render +} + +/** + * Draw a rectangle on the canvas + * @param {number} x - Top-left X coordinate + * @param {number} y - Top-left Y coordinate + * @param {number} width - Rectangle width + * @param {number} height - Rectangle height + * @param {boolean} filled - Whether to fill the shape (default: true) + * @param {string} color - Fill color in hex format (e.g., '#ff0000') + */ +export async function drawRectangle(x, y, width, height, filled = true, color = null) { + // Select the rectangle tool + await selectTool('rectangle'); + + // Set fill option and color if provided + await browser.execute((filled, color) => { + if (window.context) { + window.context.fillShape = filled; + if (color) { + window.context.fillStyle = color; + } + } + }, filled, color); + + // Draw by dragging from start to end point + await dragCanvas(x, y, x + width, y + height); + + // Wait for shape to be created + await browser.pause(300); +} + +/** + * Draw an ellipse on the canvas + * @param {number} x - Top-left X coordinate + * @param {number} y - Top-left Y coordinate + * @param {number} width - Ellipse width + * @param {number} height - Ellipse height + * @param {boolean} filled - Whether to fill the shape (default: true) + */ +export async function drawEllipse(x, y, width, height, filled = true) { + // Select the ellipse tool + await selectTool('ellipse'); + + // Set fill option + await browser.execute((filled) => { + if (window.context) { + window.context.fillShape = filled; + } + }, filled); + + // Draw by dragging from start to end point + await dragCanvas(x, y, x + width, y + height); + + // Wait for shape to be created + await browser.pause(300); +} + +/** + * Select a tool from the toolbar + * @param {string} toolName - Name of the tool ('rectangle', 'ellipse', 'brush', etc.) + */ +export async function selectTool(toolName) { + const toolButton = await browser.$(`[data-tool="${toolName}"]`); + await toolButton.click(); + await browser.pause(100); +} + +/** + * Select multiple shapes by dragging a selection box over them + * @param {Array<{x: number, y: number}>} points - Array of points representing shapes to select + */ +export async function selectMultipleShapes(points) { + // First, make sure we're in select mode + await selectTool('select'); + + // Calculate bounding box that encompasses all points + const minX = Math.min(...points.map(p => p.x)) - 10; + const minY = Math.min(...points.map(p => p.y)) - 10; + const maxX = Math.max(...points.map(p => p.x)) + 10; + const maxY = Math.max(...points.map(p => p.y)) + 10; + + // Drag a selection box from top-left to bottom-right + await dragCanvas(minX, minY, maxX, maxY); + await browser.pause(200); +} + +/** + * Use keyboard shortcut / menu action + * Since Tauri menu shortcuts don't reach the browser, we invoke actions directly + * @param {string} key - Key to press (e.g., 'g' for group) + * @param {boolean} withCtrl - Whether to hold Ctrl (ignored, kept for compatibility) + */ +export async function useKeyboardShortcut(key, withCtrl = true) { + if (key === 'g') { + // Call group action directly without serializing the whole function + await browser.execute('window.actions.group.create()'); + } + + await browser.pause(300); // Give time for the action to process +} + +/** + * Get pixel color at specific coordinates (requires canvas access) + * @param {number} x - X coordinate + * @param {number} y - Y coordinate + * @returns {Promise} Color in hex format + */ +export async function getPixelColor(x, y) { + const result = await browser.execute(function(x, y) { + const canvas = document.querySelector('canvas.stage'); + if (!canvas) return null; + + const ctx = canvas.getContext('2d'); + const imageData = ctx.getImageData(x, y, 1, 1); + const data = imageData.data; + + // Convert to hex + const r = data[0].toString(16).padStart(2, '0'); + const g = data[1].toString(16).padStart(2, '0'); + const b = data[2].toString(16).padStart(2, '0'); + + return `#${r}${g}${b}`; + }, x, y); + + return result; +} + +/** + * Check if a shape exists at given coordinates by checking if pixel is not background + * @param {number} x - X coordinate + * @param {number} y - Y coordinate + * @param {string} backgroundColor - Expected background color (default white) + * @returns {Promise} + */ +export async function hasShapeAt(x, y, backgroundColor = '#ffffff') { + const color = await getPixelColor(x, y); + return color && color.toLowerCase() !== backgroundColor.toLowerCase(); +} + +/** + * Double-click at specific coordinates on the canvas (to enter group editing mode) + * @param {number} x - X coordinate relative to canvas + * @param {number} y - Y coordinate relative to canvas + */ +export async function doubleClickCanvas(x, y) { + const canvas = await browser.$('canvas.stage'); + const location = await canvas.getLocation(); + + // Perform double-click using performActions + await browser.performActions([{ + type: 'pointer', + id: 'mouse', + parameters: { pointerType: 'mouse' }, + actions: [ + { type: 'pointerMove', duration: 0, x: location.x + x, y: location.y + y }, + { type: 'pointerDown', button: 0 }, + { type: 'pointerUp', button: 0 }, + { type: 'pause', duration: 50 }, + { type: 'pointerDown', button: 0 }, + { type: 'pointerUp', button: 0 } + ] + }]); + + await browser.pause(300); // Wait for group to be entered +} + +/** + * Set the timeline playhead to a specific time + * @param {number} time - Time in seconds + */ +export async function setPlayheadTime(time) { + await browser.execute(function(timeValue) { + if (window.context && window.context.activeObject) { + // Set time on both the active object and timeline state + window.context.activeObject.currentTime = timeValue; + if (window.context.timelineWidget && window.context.timelineWidget.timelineState) { + window.context.timelineWidget.timelineState.currentTime = timeValue; + } + + // Trigger timeline redraw to show updated playhead position + if (window.context.timelineWidget && window.context.timelineWidget.requestRedraw) { + window.context.timelineWidget.requestRedraw(); + } + + // Trigger stage redraw to show shapes at new time + if (window.context.updateUI) { + window.context.updateUI(); + } + } + }, time); + await browser.pause(200); +} + +/** + * Get the current playhead time + * @returns {Promise} Current time in seconds + */ +export async function getPlayheadTime() { + return await browser.execute(function() { + if (window.context && window.context.activeObject) { + return window.context.activeObject.currentTime; + } + return 0; + }); +} + +/** + * Add a keyframe at the current playhead position for selected shapes/objects + */ +export async function addKeyframe() { + await browser.execute('window.addKeyframeAtPlayhead && window.addKeyframeAtPlayhead()'); + await browser.pause(200); +} diff --git a/tests/helpers/manual.js b/tests/helpers/manual.js new file mode 100644 index 0000000..d056d46 --- /dev/null +++ b/tests/helpers/manual.js @@ -0,0 +1,68 @@ +/** + * Manual testing utilities for user-in-the-loop verification + * These helpers pause execution and wait for user confirmation + */ + +/** + * Pause and wait for user to verify something visually with a confirm dialog + * @param {string} message - What the user should verify + * @param {boolean} waitForConfirm - If true, show confirm dialog and wait for user input + * @throws {Error} If user clicks Cancel to indicate verification failed + */ +export async function verifyManually(message, waitForConfirm = true) { + console.log('\n=== MANUAL VERIFICATION ==='); + console.log(message); + console.log('===========================\n'); + + if (waitForConfirm) { + // Show a confirm dialog in the browser and wait for user response + const result = await browser.execute(function(msg) { + return confirm(msg); + }, message); + + if (!result) { + console.log('User clicked Cancel - verification failed'); + throw new Error('Manual verification failed: User clicked Cancel'); + } else { + console.log('User clicked OK - verification passed'); + } + + return result; + } else { + // Just pause for observation + await browser.pause(3000); + return true; + } +} + +/** + * Add a visual marker/annotation to describe what should be visible + * @param {string} description - Description of current state + */ +export async function logStep(description) { + console.log(`\n>>> STEP: ${description}`); +} + +/** + * Extended pause with a description of what's happening + * @param {string} action - What action just occurred + * @param {number} pauseTime - How long to pause + */ +export async function pauseAndDescribe(action, pauseTime = 2000) { + console.log(`>>> ${action}`); + await browser.pause(pauseTime); +} + +/** + * Ask user a yes/no question via confirm dialog + * @param {string} question - Question to ask the user + * @returns {Promise} True if user clicked OK, false if Cancel + */ +export async function askUser(question) { + console.log(`\n>>> QUESTION: ${question}`); + const result = await browser.execute(function(msg) { + return confirm(msg); + }, question); + console.log(`User answered: ${result ? 'YES (OK)' : 'NO (Cancel)'}`); + return result; +} diff --git a/tests/specs/group-editing.test.js b/tests/specs/group-editing.test.js new file mode 100644 index 0000000..3b93b2b --- /dev/null +++ b/tests/specs/group-editing.test.js @@ -0,0 +1,157 @@ +/** + * Group editing tests for Lightningbeam + * Tests that shapes can be edited inside groups with correct relative positioning + */ + +import { describe, it, before } from 'mocha'; +import { expect } from '@wdio/globals'; +import { waitForAppReady } from '../helpers/app.js'; +import { + drawRectangle, + selectMultipleShapes, + useKeyboardShortcut, + doubleClickCanvas, + clickCanvas +} from '../helpers/canvas.js'; +import { assertShapeExists } from '../helpers/assertions.js'; +import { verifyManually, logStep } from '../helpers/manual.js'; + +describe('Group Editing', () => { + before(async () => { + await waitForAppReady(); + }); + + describe('Entering and Editing Groups', () => { + it('should maintain shape positions when editing inside a group', async () => { + // Draw a rectangle + await drawRectangle(200, 200, 100, 100); + + // Verify it exists at the expected location + await assertShapeExists(250, 250, 'Rectangle should exist at center before grouping'); + + // Select it (click on the center) + await clickCanvas(250, 250); + await browser.pause(200); + + // Group it (even though it's just one shape) + await useKeyboardShortcut('g', true); + await browser.pause(300); + + // The shape should still be visible at the same location + await assertShapeExists(250, 250, 'Rectangle should still exist at same position after grouping'); + + // Double-click on the group to enter editing mode + await doubleClickCanvas(250, 250); + + // The shape should STILL be at the same position when editing the group + await assertShapeExists(250, 250, 'Rectangle should remain at same position when editing group'); + }); + + it('should correctly position new shapes drawn inside a group', async () => { + // Draw a rectangle + await drawRectangle(100, 400, 80, 80); + + // Select and group it + await clickCanvas(140, 440); + await browser.pause(200); + await useKeyboardShortcut('g', true); + await browser.pause(300); + + // Double-click to enter group editing mode + await doubleClickCanvas(140, 440); + + // Draw another rectangle inside the group at a specific location + await drawRectangle(200, 400, 80, 80); + + // Verify the new shape is where we drew it + await assertShapeExists(240, 440, 'New shape should be at the coordinates where it was drawn'); + + // Verify the original shape still exists + await assertShapeExists(140, 440, 'Original shape should still exist'); + }); + + it('should handle nested group editing with correct positioning', async () => { + // Create first group with two shapes + await logStep('Drawing two rectangles for inner group'); + await drawRectangle(400, 100, 60, 60); + await drawRectangle(480, 100, 60, 60); + await selectMultipleShapes([ + { x: 430, y: 130 }, + { x: 510, y: 130 } + ]); + await useKeyboardShortcut('g', true); + await browser.pause(300); + + await verifyManually('VERIFY: Do you see two rectangles grouped together?\nClick OK if yes, Cancel if no'); + + // Verify both shapes exist + await assertShapeExists(430, 130, 'First shape should exist'); + await assertShapeExists(510, 130, 'Second shape should exist'); + + // Create another shape and group everything together + await logStep('Drawing third rectangle and creating nested group'); + await drawRectangle(400, 180, 60, 60); + + // Select both the group and the new shape by dragging a selection box + // We need to start from well outside the shapes to avoid hitting them + // The first group spans x=400-540, y=100-160 + // The third shape spans x=400-460, y=180-240 + await selectTool('select'); + await dragCanvas(390, 90, 550, 250); // Start from outside all shapes + await browser.pause(200); + + await useKeyboardShortcut('g', true); + await browser.pause(300); + + await verifyManually('VERIFY: All three rectangles now grouped together (nested group)?\nClick OK if yes, Cancel if no'); + + // Double-click to enter outer group + await logStep('Double-clicking to enter outer group'); + await doubleClickCanvas(470, 130); + await browser.pause(300); + + await verifyManually('VERIFY: Are we now inside the outer group?\nClick OK if yes, Cancel if no'); + + // Double-click again to enter inner group + await logStep('Double-clicking again to enter inner group'); + await doubleClickCanvas(470, 130); + await browser.pause(300); + + await verifyManually( + 'VERIFY: Are we now inside the inner group?\n' + + 'Can you see the two original rectangles at their original positions?\n' + + 'First at (430, 130), second at (510, 130)?\n\n' + + 'Click OK if yes, Cancel if no' + ); + + // All shapes should still be at their original positions + await assertShapeExists(430, 130, 'First shape should maintain position in nested group'); + await assertShapeExists(510, 130, 'Second shape should maintain position in nested group'); + }); + }); + + describe('Mouse Coordinate Transformation', () => { + it('should correctly translate mouse coordinates when drawing in groups', async () => { + // Draw and group a shape + await drawRectangle(300, 300, 50, 50); + await clickCanvas(325, 325); + await browser.pause(200); + await useKeyboardShortcut('g', true); + await browser.pause(300); + + // Enter group + await doubleClickCanvas(325, 325); + + // Draw shapes at precise coordinates to test mouse transformation + await drawRectangle(350, 300, 30, 30); + await drawRectangle(300, 350, 30, 30); + await drawRectangle(350, 350, 30, 30); + + // Verify all shapes are at expected positions + await assertShapeExists(365, 315, 'Shape to the right should be at correct position'); + await assertShapeExists(315, 365, 'Shape below should be at correct position'); + await assertShapeExists(365, 365, 'Shape diagonal should be at correct position'); + await assertShapeExists(325, 325, 'Original shape should still exist'); + }); + }); +}); diff --git a/tests/specs/grouping.test.js b/tests/specs/grouping.test.js new file mode 100644 index 0000000..171aec3 --- /dev/null +++ b/tests/specs/grouping.test.js @@ -0,0 +1,134 @@ +/** + * Shape grouping tests for Lightningbeam + */ + +import { describe, it, before, beforeEach } from 'mocha'; +import { expect } from '@wdio/globals'; +import { waitForAppReady } from '../helpers/app.js'; +import { drawRectangle, drawEllipse, selectMultipleShapes, useKeyboardShortcut, clickCanvas } from '../helpers/canvas.js'; +import { assertShapeExists } from '../helpers/assertions.js'; + +describe('Shape Grouping', () => { + before(async () => { + await waitForAppReady(); + }); + + describe('Grouping Multiple Shapes', () => { + it('should group two rectangles together', async () => { + // Draw two rectangles + await drawRectangle(100, 100, 100, 100); + await drawRectangle(250, 100, 100, 100); + + // Select both shapes (click centers with Ctrl held) + await selectMultipleShapes([ + { x: 150, y: 150 }, + { x: 300, y: 150 } + ]); + + // Group with Ctrl+G + await useKeyboardShortcut('g', true); + + // Verify both shapes still exist after grouping + await assertShapeExists(150, 150, 'First rectangle should still exist after grouping'); + await assertShapeExists(300, 150, 'Second rectangle should still exist after grouping'); + }); + + it('should group rectangle and ellipse together', async () => { + // Draw rectangle and ellipse + await drawRectangle(100, 250, 120, 80); + await drawEllipse(280, 250, 120, 80); + + // Select both shapes + await selectMultipleShapes([ + { x: 160, y: 290 }, + { x: 340, y: 290 } + ]); + + // Group them + await useKeyboardShortcut('g', true); + + // Verify shapes exist + await assertShapeExists(160, 290, 'Rectangle should exist in group'); + await assertShapeExists(340, 290, 'Ellipse should exist in group'); + }); + + it('should group three or more shapes', async () => { + // Draw three rectangles + await drawRectangle(50, 400, 80, 80); + await drawRectangle(150, 400, 80, 80); + await drawRectangle(250, 400, 80, 80); + + // Select all three + await selectMultipleShapes([ + { x: 90, y: 440 }, + { x: 190, y: 440 }, + { x: 290, y: 440 } + ]); + + // Group them + await useKeyboardShortcut('g', true); + + // Verify all shapes exist + await assertShapeExists(90, 440, 'First shape should exist in group'); + await assertShapeExists(190, 440, 'Second shape should exist in group'); + await assertShapeExists(290, 440, 'Third shape should exist in group'); + }); + }); + + describe('Group Manipulation', () => { + it('should be able to select and move a group', async () => { + // Draw two shapes + await drawRectangle(400, 100, 80, 80); + await drawRectangle(500, 100, 80, 80); + + // Select and group + await selectMultipleShapes([ + { x: 440, y: 140 }, + { x: 540, y: 140 } + ]); + await useKeyboardShortcut('g', true); + + // Click on the group to select it (click between the shapes) + await clickCanvas(490, 140); + + // Note: Moving would require drag testing which is already covered in canvas.js + // This test verifies the group can be selected + }); + }); + + describe('Nested Groups', () => { + it('should allow grouping of groups', async () => { + // Create first group + await drawRectangle(100, 100, 60, 60); + await drawRectangle(180, 100, 60, 60); + await selectMultipleShapes([ + { x: 130, y: 130 }, + { x: 210, y: 130 } + ]); + await useKeyboardShortcut('g', true); + + // Create second group + await drawRectangle(100, 200, 60, 60); + await drawRectangle(180, 200, 60, 60); + await selectMultipleShapes([ + { x: 130, y: 230 }, + { x: 210, y: 230 } + ]); + await useKeyboardShortcut('g', true); + + // Now group both groups together + // Select center of each group + await selectMultipleShapes([ + { x: 170, y: 130 }, + { x: 170, y: 230 } + ]); + await useKeyboardShortcut('g', true); + + // Verify all original shapes still exist + await assertShapeExists(130, 130, 'First group first shape should exist'); + await assertShapeExists(210, 130, 'First group second shape should exist'); + await assertShapeExists(130, 230, 'Second group first shape should exist'); + await assertShapeExists(210, 230, 'Second group second shape should exist'); + }); + }); +}); diff --git a/tests/specs/manual/timeline-manual.test.js b/tests/specs/manual/timeline-manual.test.js new file mode 100644 index 0000000..92e58c1 --- /dev/null +++ b/tests/specs/manual/timeline-manual.test.js @@ -0,0 +1,279 @@ +/** + * MANUAL Timeline Animation Tests + * Run these with visual verification - watch the app window as tests execute + * + * To run: pnpm wdio run wdio.conf.js --spec tests/specs/manual/timeline-manual.test.js + */ + +import { describe, it, before, afterEach } from 'mocha'; +import { waitForAppReady } from '../../helpers/app.js'; +import { + drawRectangle, + selectMultipleShapes, + selectTool, + dragCanvas, + clickCanvas, + setPlayheadTime, + getPlayheadTime, + addKeyframe, + useKeyboardShortcut +} from '../../helpers/canvas.js'; +import { verifyManually, logStep, pauseAndDescribe } from '../../helpers/manual.js'; + +describe('MANUAL: Timeline Animation', () => { + before(async () => { + await waitForAppReady(); + }); + + afterEach(async () => { + // Close any open dialogs by accepting them + try { + await browser.execute(function() { + // Close any open confirm/alert dialogs + // This is a no-op if no dialog is open + }); + } catch (e) { + // Ignore errors + } + + // Pause briefly to show final state before ending + await browser.pause(1000); + console.log('\n>>> Test completed. Session will restart for next test.\n'); + }); + + it('TEST 1: Group animation - draw, group, keyframe, move group', async () => { + await logStep('Drawing a RED rectangle at (100, 100) with size 100x100'); + await drawRectangle(100, 100, 100, 100, true, '#ff0000'); + await pauseAndDescribe('RED rectangle drawn', 200); + + await verifyManually( + 'VERIFY: Do you see a RED filled rectangle at the top-left area?\n' + + 'It should be centered around (150, 150)\n\n' + + 'Click OK if yes, Cancel if no' + ); + + await logStep('Selecting the RED rectangle by dragging a selection box over it'); + await selectMultipleShapes([{ x: 150, y: 150 }]); + await pauseAndDescribe('RED rectangle selected', 200); + + await verifyManually( + 'VERIFY: Is the RED rectangle now selected? (Should have selection indicators)\n\n' + + 'Click OK if yes, Cancel if no' + ); + + await logStep('Grouping the selected rectangle (Ctrl+G)'); + await useKeyboardShortcut('g', true); + await pauseAndDescribe('RED rectangle grouped', 200); + + await verifyManually( + 'VERIFY: Was the rectangle grouped? (May look similar but is now a group)\n\n' + + 'Click OK if yes, Cancel if no' + ); + + await logStep('Selecting the group by dragging a selection box over it'); + await selectMultipleShapes([{ x: 150, y: 150 }]); + await pauseAndDescribe('Group selected', 200); + + await logStep('Moving playhead to time 0.333 (frame 10 at 30fps)'); + await setPlayheadTime(0.333); + await pauseAndDescribe('Playhead moved to 0.333s - WAIT for UI to update', 300); + + await verifyManually( + 'VERIFY: Did the playhead indicator move on the timeline?\n' + + 'It should be at approximately frame 10\n\n' + + 'Click OK if yes, Cancel if no' + ); + + await logStep('Adding a keyframe at current position'); + await addKeyframe(); + await pauseAndDescribe('Keyframe added', 200); + + await verifyManually( + 'VERIFY: Was a keyframe added? (Should see a keyframe marker on timeline)\n\n' + + 'Click OK if yes, Cancel if no' + ); + + await logStep('Dragging the selected group to move it right (from x=150 to x=250)'); + await dragCanvas(150, 150, 250, 150); + await pauseAndDescribe('Group moved to the right', 300); + + await verifyManually( + 'VERIFY: Did the RED rectangle move to the right?\n' + + 'It should now be centered around (250, 150)\n\n' + + 'Click OK if yes, Cancel if no' + ); + + await logStep('Moving playhead back to time 0 (frame 1)'); + await setPlayheadTime(0); + await pauseAndDescribe('Playhead back at start', 300); + + await verifyManually( + 'VERIFY: Did the RED rectangle jump back to its original position (x=150)?\n' + + 'This confirms the group animation is working!\n\n' + + 'Click OK if yes, Cancel if no' + ); + + await logStep('Moving playhead to middle (time 0.166, frame 5)'); + await setPlayheadTime(0.166); + await pauseAndDescribe('Playhead at middle frame', 300); + + await verifyManually( + 'VERIFY: Is the RED rectangle now between the two positions?\n' + + 'It should be around x=200 (interpolated halfway)\n\n' + + 'Click OK if yes, Cancel if no' + ); + + await logStep('Moving playhead back and forth to show animation'); + await setPlayheadTime(0); + await browser.pause(300); + await setPlayheadTime(0.333); + await browser.pause(300); + await setPlayheadTime(0); + await browser.pause(300); + await setPlayheadTime(0.333); + await browser.pause(300); + + await verifyManually( + 'VERIFY: Did you see the RED rectangle animate back and forth?\n' + + 'This demonstrates the timeline animation is working!\n\n' + + 'Click OK if yes, Cancel if no' + ); + + await logStep('TEST 1 COMPLETE - Showing completion alert'); + const completionShown = await browser.execute(function() { + alert('TEST 1 COMPLETE - Click OK to finish'); + return true; + }); + await browser.pause(2000); // Wait for alert to be dismissed before ending test + }); + + it('TEST 2: Shape tween - draw shape, add keyframes, modify edges', async () => { + await logStep('Resetting playhead to time 0 at start of test'); + await setPlayheadTime(0); + await pauseAndDescribe('Playhead reset to time 0', 200); + + await logStep('Drawing a BLUE rectangle at (400, 100)'); + await drawRectangle(400, 100, 80, 80, true, '#0000ff'); + await pauseAndDescribe('BLUE rectangle drawn', 200); + + await verifyManually( + 'VERIFY: Do you see a BLUE filled rectangle?\n' + + 'It should be at (400, 100) with size 80x80\n\n' + + 'Click OK if yes, Cancel if no' + ); + + await logStep('Selecting the BLUE rectangle'); + await selectMultipleShapes([{ x: 440, y: 140 }]); + await pauseAndDescribe('BLUE rectangle selected', 200); + + await verifyManually( + 'VERIFY: Is the BLUE rectangle selected?\n' + + '(An initial keyframe should be automatically added at time 0)\n\n' + + 'Click OK if yes, Cancel if no' + ); + + await logStep('Moving playhead to time 0.5 (frame 12 at 24fps)'); + await setPlayheadTime(0.5); + await pauseAndDescribe('Playhead moved to 0.5s - WAIT for UI to update', 300); + + await verifyManually( + 'VERIFY: Did the playhead move to 0.5s on the timeline?\n\n' + + 'Click OK if yes, Cancel if no' + ); + + await logStep('Adding a keyframe at time 0.5'); + await addKeyframe(); + await pauseAndDescribe('Keyframe added at 0.5s', 200); + + await verifyManually( + 'VERIFY: Was a keyframe added at 0.5s?\n\n' + + 'Click OK if yes, Cancel if no' + ); + + await logStep('Clicking away from the shape to deselect it'); + await clickCanvas(600, 300); + await pauseAndDescribe('Shape deselected', 100); + + await verifyManually( + 'VERIFY: Is the BLUE rectangle now deselected?\n' + + '(No selection indicators around it)\n\n' + + 'Click OK if yes, Cancel if no' + ); + + await logStep('Dragging the right edge of the BLUE rectangle to curve/extend it'); + await dragCanvas(480, 140, 530, 140); + await pauseAndDescribe('Dragged right edge of BLUE rectangle', 300); + + await verifyManually( + 'VERIFY: Did the right edge of the BLUE rectangle get curved/pulled out?\n' + + 'The shape should now be modified/stretched to the right\n\n' + + 'Click OK if yes, Cancel if no' + ); + + await logStep('Moving playhead back to time 0'); + await setPlayheadTime(0); + await pauseAndDescribe('Playhead back at start', 300); + + await verifyManually( + 'VERIFY: Did the BLUE rectangle return to its original rectangular shape?\n' + + 'The edge modification should not be visible at time 0\n\n' + + 'Click OK if yes, Cancel if no' + ); + + await logStep('Moving playhead to middle between keyframes (time 0.25, frame 6)'); + await setPlayheadTime(0.25); + await pauseAndDescribe('Playhead at middle (0.25s) - halfway between frame 0 and frame 12', 300); + + await verifyManually( + 'VERIFY: Is the BLUE rectangle shape somewhere between the two versions?\n' + + 'It should be partially morphed (shape tween interpolation)\n' + + 'Halfway between the original rectangle and the curved version\n\n' + + 'Click OK if yes, Cancel if no' + ); + + await logStep('TEST 2 COMPLETE - Showing completion alert'); + const completionShown = await browser.execute(function() { + alert('TEST 2 COMPLETE - Click OK to finish'); + return true; + }); + await browser.pause(2000); // Wait for alert to be dismissed before ending test + }); + + it('TEST 3: Test dragging unselected shape edge', async () => { + await logStep('Resetting playhead to time 0'); + await setPlayheadTime(0); + await pauseAndDescribe('Playhead reset to time 0', 100); + + await logStep('Drawing a GREEN rectangle at (200, 250) WITHOUT selecting it'); + await drawRectangle(200, 250, 100, 100, true, '#00ff00'); + await pauseAndDescribe('GREEN rectangle drawn (not selected)', 100); + + await verifyManually( + 'VERIFY: GREEN rectangle should be visible but NOT selected\n' + + '(No selection indicators around it)\n\n' + + 'Click OK if yes, Cancel if no' + ); + + await logStep('Switching to select tool'); + await selectTool('select'); + await pauseAndDescribe('Select tool activated', 100); + + await logStep('Dragging from the right edge (x=300) of GREEN rectangle to extend it'); + await dragCanvas(300, 300, 350, 300); + await pauseAndDescribe('Dragged the right edge of GREEN rectangle', 200); + + await verifyManually( + 'VERIFY: What happened to the GREEN rectangle?\n\n' + + 'Expected: The right edge should be curved/pulled out to x=350\n' + + 'Did the edge get modified as expected?\n\n' + + 'Click OK if yes, Cancel if no' + ); + + await logStep('TEST 3 COMPLETE - Showing completion alert'); + const completionShown = await browser.execute(function() { + alert('TEST 3 COMPLETE - Click OK to finish'); + return true; + }); + await browser.pause(2000); // Wait for alert to be dismissed before ending test + }); +}); diff --git a/tests/specs/paint-bucket.test.js b/tests/specs/paint-bucket.test.js new file mode 100644 index 0000000..0ff2714 --- /dev/null +++ b/tests/specs/paint-bucket.test.js @@ -0,0 +1,88 @@ +/** + * Paint bucket tool tests for Lightningbeam + */ + +import { describe, it, before } from 'mocha'; +import { expect } from '@wdio/globals'; +import { waitForAppReady } from '../helpers/app.js'; +import { drawRectangle, selectTool, clickCanvas, getPixelColor } from '../helpers/canvas.js'; +import { assertPixelColor } from '../helpers/assertions.js'; + +describe('Paint Bucket Tool', () => { + before(async () => { + await waitForAppReady(); + }); + + describe('Fill Shape', () => { + it('should fill a rectangle with color', async () => { + // Draw an unfilled rectangle (outline only) + await drawRectangle(100, 100, 200, 150, false); + + // Get the color before filling (should be stroke/outline only, center is white) + const colorBefore = await getPixelColor(200, 175); + + // Select paint bucket tool + await selectTool('paint_bucket'); + + // Click inside the rectangle to fill it + await clickCanvas(200, 175); + + // Get the color after filling + const colorAfter = await getPixelColor(200, 175); + + // The color should have changed from white background to filled color + expect(colorBefore.toLowerCase()).toBe('#ffffff'); // Was white (unfilled) + expect(colorAfter.toLowerCase()).not.toBe('#ffffff'); // Now filled with a color + }); + + it('should fill only the clicked shape, not adjacent shapes', async () => { + // Draw two separate unfilled rectangles + await drawRectangle(100, 300, 100, 100, false); + await drawRectangle(250, 300, 100, 100, false); + + // Fill only the first rectangle + await selectTool('paint_bucket'); + await clickCanvas(150, 350); + + // Get colors from both shapes + const firstColor = await getPixelColor(150, 350); + const secondColor = await getPixelColor(300, 350); + + // The shapes should potentially have different colors + // (or at least we confirmed we could click them individually) + }); + }); + + describe('Fill with Different Colors', () => { + it('should respect the selected fill color when using paint bucket', async () => { + // This test would require setting a specific fill color first + // Then drawing and filling a shape + // For now, this is a placeholder structure + + await drawRectangle(400, 100, 150, 100, false); + + // TODO: Add color selection logic when color picker helpers are available + // await selectColor('#ff0000'); + + await selectTool('paint_bucket'); + await clickCanvas(475, 150); + + // Verify the fill color + const color = await getPixelColor(475, 150); + // TODO: Assert expected color when we know how to set it + }); + }); + + describe('Fill Gaps Setting', () => { + it('should handle fill gaps setting for incomplete shapes', async () => { + // This test would draw an incomplete shape and test the fillGaps parameter + // Placeholder for now - would need specific incomplete shape drawing + + // The fillGaps setting controls how the paint bucket handles gaps in shapes + // This is a more advanced test that would need: + // 1. A way to draw incomplete/open shapes + // 2. A way to set the fillGaps parameter + // 3. Verification that the fill respects the gap threshold + }); + }); +}); diff --git a/tests/specs/shapes.test.js b/tests/specs/shapes.test.js new file mode 100644 index 0000000..5a9ed77 --- /dev/null +++ b/tests/specs/shapes.test.js @@ -0,0 +1,100 @@ +/** + * Shape drawing tests for Lightningbeam + */ + +import { describe, it, before } from 'mocha'; +import { waitForAppReady } from '../helpers/app.js'; +import { drawRectangle, drawEllipse } from '../helpers/canvas.js'; +import { assertShapeExists } from '../helpers/assertions.js'; + +describe('Shape Drawing', () => { + before(async () => { + await waitForAppReady(); + }); + + describe('Rectangle Tool', () => { + it('should draw a rectangle on the canvas', async () => { + // Draw a rectangle at (100, 100) with size 200x150 + await drawRectangle(100, 100, 200, 150); + + // Verify the shape exists by checking pixels at various points + // Check center of the rectangle + await assertShapeExists(200, 175, 'Rectangle should be drawn at center'); + + // Check edges + await assertShapeExists(110, 110, 'Rectangle should exist at top-left'); + await assertShapeExists(290, 110, 'Rectangle should exist at top-right'); + await assertShapeExists(110, 240, 'Rectangle should exist at bottom-left'); + await assertShapeExists(290, 240, 'Rectangle should exist at bottom-right'); + }); + + it('should draw multiple rectangles without interference', async () => { + // Draw first rectangle + await drawRectangle(50, 50, 100, 100); + await assertShapeExists(100, 100, 'First rectangle should exist'); + + // Draw second rectangle in different location + await drawRectangle(300, 300, 100, 100); + await assertShapeExists(350, 350, 'Second rectangle should exist'); + + // Verify first rectangle still exists + await assertShapeExists(100, 100, 'First rectangle should still exist'); + }); + + it('should draw small rectangles', async () => { + // Draw a small rectangle + await drawRectangle(400, 100, 20, 20); + await assertShapeExists(410, 110, 'Small rectangle should exist'); + }); + + it('should draw large rectangles', async () => { + // Draw a large rectangle (canvas is ~350px tall, so keep within bounds) + await drawRectangle(50, 50, 400, 250); + await assertShapeExists(250, 175, 'Large rectangle should exist at center'); + }); + }); + + describe('Ellipse Tool', () => { + it('should draw an ellipse on the canvas', async () => { + // Draw an ellipse at (100, 100) with size 200x150 + await drawEllipse(100, 100, 200, 150); + + // Check center of the ellipse + await assertShapeExists(200, 175, 'Ellipse should be drawn at center'); + }); + + it('should draw a circle (equal width and height)', async () => { + // Draw a circle + await drawEllipse(300, 100, 150, 150); + + // Check center + await assertShapeExists(375, 175, 'Circle should exist at center'); + }); + + it('should draw wide ellipses', async () => { + // Draw a wide ellipse + await drawEllipse(50, 400, 300, 100); + await assertShapeExists(200, 450, 'Wide ellipse should exist'); + }); + + it('should draw tall ellipses', async () => { + // Draw a tall ellipse + await drawEllipse(500, 100, 100, 300); + await assertShapeExists(550, 250, 'Tall ellipse should exist'); + }); + }); + + describe('Mixed Shapes', () => { + it('should draw both rectangles and ellipses in the same scene', async () => { + // Draw a rectangle + await drawRectangle(100, 100, 150, 100); + + // Draw an ellipse + await drawEllipse(300, 100, 150, 100); + + // Verify both exist + await assertShapeExists(175, 150, 'Rectangle should exist'); + await assertShapeExists(375, 150, 'Ellipse should exist'); + }); + }); +}); diff --git a/tests/specs/timeline-animation.test.js b/tests/specs/timeline-animation.test.js new file mode 100644 index 0000000..adc727a --- /dev/null +++ b/tests/specs/timeline-animation.test.js @@ -0,0 +1,317 @@ +/** + * Timeline animation tests for Lightningbeam + * Tests shape and object animations across keyframes + */ + +import { describe, it, before } from 'mocha'; +import { expect } from '@wdio/globals'; +import { waitForAppReady } from '../helpers/app.js'; +import { + drawRectangle, + drawEllipse, + clickCanvas, + dragCanvas, + selectMultipleShapes, + useKeyboardShortcut, + setPlayheadTime, + getPlayheadTime, + addKeyframe, + getPixelColor +} from '../helpers/canvas.js'; +import { assertShapeExists } from '../helpers/assertions.js'; +import { verifyManually, logStep } from '../helpers/manual.js'; + +describe('Timeline Animation', () => { + before(async () => { + await waitForAppReady(); + }); + + describe('Shape Keyframe Animation', () => { + it('should animate shape position across keyframes', async () => { + // Draw a rectangle at frame 1 (time 0) + await drawRectangle(100, 100, 100, 100); + + // Select the shape by dragging a selection box over it + await selectMultipleShapes([{ x: 150, y: 150 }]); + await browser.pause(200); + + // Verify it exists at original position + await assertShapeExists(150, 150, 'Shape should exist at frame 1'); + + // Move to frame 10 (time in seconds, assuming 30fps: frame 10 = 10/30 ≈ 0.333s) + await setPlayheadTime(0.333); + + // Add a keyframe at this position + await addKeyframe(); + await browser.pause(200); + + await logStep('About to drag selected shape - dragging selected shapes does not move them yet'); + + // Shape is selected, so dragging from its center will move it + await dragCanvas(150, 150, 250, 150); + await browser.pause(300); + + await verifyManually( + 'VERIFY: Did the shape move to x=250?\n' + + 'Expected: Shape at x=250\n' + + 'Note: Dragging selected shapes is not implemented yet\n\n' + + 'Click OK if at x=250, Cancel if not' + ); + + // At frame 10, shape should be at the new position (moved 100px to the right) + await assertShapeExists(250, 150, 'Shape should be at new position at frame 10'); + + // Go back to frame 1 + await setPlayheadTime(0); + await browser.pause(200); + + await verifyManually( + 'VERIFY: Did the shape return to original position (x=150)?\n\n' + + 'Click OK if yes, Cancel if no' + ); + + // Shape should be at original position + await assertShapeExists(150, 150, 'Shape should be at original position at frame 1'); + + // Go to middle frame (frame 5, time ≈ 0.166s) + await setPlayheadTime(0.166); + await browser.pause(200); + + await verifyManually( + 'VERIFY: Is the shape interpolated at x=200 (halfway)?\n\n' + + 'Click OK if yes, Cancel if no' + ); + + // Shape should be interpolated between the two positions + // At frame 5 (halfway), shape should be around x=200 (halfway between 150 and 250) + await assertShapeExists(200, 150, 'Shape should be interpolated at frame 5'); + }); + + it('should modify shape edges when dragging edge of unselected shape', async () => { + // Draw a rectangle + await drawRectangle(400, 100, 100, 100); + + // Get color at center + const centerColorBefore = await getPixelColor(450, 150); + + // WITHOUT selecting the shape, drag the right edge + // The right edge is at x=500, so drag from there + await dragCanvas(500, 150, 550, 150); + await browser.pause(300); + + // The shape should now be modified - it's been curved/stretched + // The original center should still have the shape + await assertShapeExists(450, 150, 'Center should still have shape'); + + // And there should be shape data extended to the right + const rightColorAfter = await getPixelColor(525, 150); + // This should have color (not white) since we dragged the edge there + expect(rightColorAfter.toLowerCase()).not.toBe('#ffffff'); + }); + + it('should handle multiple keyframes on the same shape', async () => { + // Draw a shape + await drawRectangle(100, 100, 80, 80); + + // Select it + await selectMultipleShapes([{ x: 140, y: 140 }]); + await browser.pause(200); + + // Keyframe 1: time 0 (original position at x=140, y=140) + + // Keyframe 2: time 0.333 (move right) + await setPlayheadTime(0.333); + await addKeyframe(); + await browser.pause(200); + + await logStep('Dragging selected shape (not implemented yet)'); + // Shape should still be selected, drag to move + await dragCanvas(140, 140, 200, 140); + await browser.pause(300); + + await verifyManually('VERIFY: Did shape move to x=200? (probably not)\nClick OK if at x=200, Cancel if not'); + + // Keyframe 3: time 0.666 (move down) + await setPlayheadTime(0.666); + await addKeyframe(); + await browser.pause(200); + // Drag to move down + await dragCanvas(200, 140, 200, 180); + await browser.pause(300); + + await verifyManually('VERIFY: Did shape move to y=180?\nClick OK if yes, Cancel if no'); + + // Verify positions at each keyframe + await setPlayheadTime(0); + await browser.pause(200); + await verifyManually('VERIFY: Shape at original position (x=140, y=140)?\nClick OK if yes, Cancel if no'); + await assertShapeExists(140, 140, 'Shape at keyframe 1 (x=140, y=140)'); + + await setPlayheadTime(0.333); + await browser.pause(200); + await verifyManually('VERIFY: Shape at x=200, y=140?\nClick OK if yes, Cancel if no'); + await assertShapeExists(200, 140, 'Shape at keyframe 2 (x=200, y=140)'); + + await setPlayheadTime(0.666); + await browser.pause(200); + await verifyManually('VERIFY: Shape at x=200, y=180?\nClick OK if yes, Cancel if no'); + await assertShapeExists(200, 180, 'Shape at keyframe 3 (x=200, y=180)'); + + // Check interpolation between keyframe 1 and 2 (at t=0.166, halfway) + await setPlayheadTime(0.166); + await browser.pause(200); + await verifyManually('VERIFY: Shape interpolated at x=170, y=140?\nClick OK if yes, Cancel if no'); + await assertShapeExists(170, 140, 'Shape interpolated between kf1 and kf2'); + + // Check interpolation between keyframe 2 and 3 (at t=0.5, halfway) + await setPlayheadTime(0.5); + await browser.pause(200); + await verifyManually('VERIFY: Shape interpolated at x=200, y=160?\nClick OK if yes, Cancel if no'); + await assertShapeExists(200, 160, 'Shape interpolated between kf2 and kf3'); + }); + }); + + describe('Group/Object Animation', () => { + it('should animate group position across keyframes', async () => { + // Create a group with two shapes + await drawRectangle(300, 100, 60, 60); + await drawRectangle(380, 100, 60, 60); + + await selectMultipleShapes([ + { x: 330, y: 130 }, + { x: 410, y: 130 } + ]); + await useKeyboardShortcut('g', true); + await browser.pause(300); + + // Verify both shapes exist at frame 1 + await assertShapeExists(330, 130, 'First shape at frame 1'); + await assertShapeExists(410, 130, 'Second shape at frame 1'); + + // Select the group by dragging a selection box over it + await selectMultipleShapes([{ x: 370, y: 130 }]); + await browser.pause(200); + + // Move to frame 10 and add keyframe + await setPlayheadTime(0.333); + await addKeyframe(); + await browser.pause(200); + + await logStep('Dragging group down'); + // Group is selected, so dragging will move it + // Drag from center of group down + await dragCanvas(370, 130, 370, 200); + await browser.pause(300); + + await verifyManually('VERIFY: Did the group move down to y=200?\nClick OK if yes, Cancel if no'); + + // At frame 10, group should be at new position (moved down) + await assertShapeExists(330, 200, 'First shape at new position at frame 10'); + await assertShapeExists(410, 200, 'Second shape at new position at frame 10'); + + // Go to frame 1 + await setPlayheadTime(0); + await browser.pause(200); + + await verifyManually('VERIFY: Did group return to original position (y=130)?\nClick OK if yes, Cancel if no'); + + // Group should be at original position + await assertShapeExists(330, 130, 'First shape at original position at frame 1'); + await assertShapeExists(410, 130, 'Second shape at original position at frame 1'); + + // Go to frame 5 (middle, t=0.166) + await setPlayheadTime(0.166); + await browser.pause(200); + + await verifyManually('VERIFY: Is group interpolated at y=165 (halfway)?\nClick OK if yes, Cancel if no'); + + // Group should be interpolated (halfway between y=130 and y=200, so y=165) + await assertShapeExists(330, 165, 'First shape interpolated at frame 5'); + await assertShapeExists(410, 165, 'Second shape interpolated at frame 5'); + }); + + it('should maintain relative positions of shapes within animated group', async () => { + // Create a group (using safer y coordinates) + await drawRectangle(100, 250, 50, 50); + await drawRectangle(170, 250, 50, 50); + + await selectMultipleShapes([ + { x: 125, y: 275 }, + { x: 195, y: 275 } + ]); + await useKeyboardShortcut('g', true); + await browser.pause(300); + + // Select group + await selectMultipleShapes([{ x: 160, y: 275 }]); + await browser.pause(200); + + // Add keyframe and move + await setPlayheadTime(0.333); + await addKeyframe(); + await browser.pause(200); + + await dragCanvas(160, 275, 260, 275); + await browser.pause(300); + + // At both keyframes, shapes should maintain 70px horizontal distance + await setPlayheadTime(0); + await browser.pause(200); + await assertShapeExists(125, 275, 'First shape at frame 1'); + await assertShapeExists(195, 275, 'Second shape at frame 1 (70px apart)'); + + await setPlayheadTime(0.333); + await browser.pause(200); + // Both shapes moved 100px to the right + await assertShapeExists(225, 275, 'First shape at frame 10'); + await assertShapeExists(295, 275, 'Second shape at frame 10 (still 70px apart)'); + }); + }); + + describe('Interpolation', () => { + it('should smoothly interpolate between keyframes', async () => { + // Draw a simple shape + await drawRectangle(100, 100, 50, 50); + + // Select it + await selectMultipleShapes([{ x: 125, y: 125 }]); + await browser.pause(200); + + // Keyframe at start (x=125) + await setPlayheadTime(0); + await browser.pause(100); + + // Keyframe at end (1 second = frame 30, move to x=325) + await setPlayheadTime(1.0); + await addKeyframe(); + await browser.pause(200); + + await logStep('Dragging shape (selected shapes cannot be dragged yet)'); + await dragCanvas(125, 125, 325, 125); + await browser.pause(300); + + await verifyManually('VERIFY: Did shape move to x=325? (probably not)\nClick OK if at x=325, Cancel if not'); + + // Check multiple intermediate frames for smooth interpolation + // Total movement: 200px over 1 second + + // At 25% (0.25s), x should be 125 + 50 = 175 + await setPlayheadTime(0.25); + await browser.pause(200); + await verifyManually('VERIFY: Shape at x=175 (25% interpolation)?\nClick OK if yes, Cancel if no'); + await assertShapeExists(175, 125, 'Shape at 25% interpolation'); + + // At 50% (0.5s), x should be 125 + 100 = 225 + await setPlayheadTime(0.5); + await browser.pause(200); + await verifyManually('VERIFY: Shape at x=225 (50% interpolation)?\nClick OK if yes, Cancel if no'); + await assertShapeExists(225, 125, 'Shape at 50% interpolation'); + + // At 75% (0.75s), x should be 125 + 150 = 275 + await setPlayheadTime(0.75); + await browser.pause(200); + await verifyManually('VERIFY: Shape at x=275 (75% interpolation)?\nClick OK if yes, Cancel if no'); + await assertShapeExists(275, 125, 'Shape at 75% interpolation'); + }); + }); +}); diff --git a/vendor/NeuralAudio b/vendor/NeuralAudio new file mode 160000 index 0000000..1eab464 --- /dev/null +++ b/vendor/NeuralAudio @@ -0,0 +1 @@ +Subproject commit 1eab4645f7073e752314b33946b69bfe3fbc01f9