diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c190557..18c09b8 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -23,7 +23,28 @@ "Read(//c/Users/srvds/OneDrive/Documents/Studio13/**)", "Read(//c/Users/srvds/**)", "Bash(find . -name \"*.tsx\" -type f -exec grep -l \"useSortable\\\\|@dnd-kit\" {} ;)", - "WebFetch(domain:forums.steinberg.net)" + "WebFetch(domain:forums.steinberg.net)", + "Bash(export PATH=\"$PATH:/c/Program Files/NASM\")", + "Bash(cmake -B build -DCMAKE_BUILD_TYPE=Debug)", + "WebSearch", + "Bash(gh release:*)", + "Bash(find /c/Users/srvds/Documents/Codes/Studio13-v3/resources -type f -name *model* -o -name *.onnx -o -name *.ckpt)", + "Bash(find Source -type f \\\\\\( -name \"*.cpp\" -o -name \"*.h\" \\\\\\) -exec wc -l {} +)", + "Bash(find \"c:/Users/srvds/Documents/Codes/Studio13-v3/frontend/src\" -type f \\\\\\( -name \"*.tsx\" -o -name \"*.ts\" -o -name \"*.css\" \\\\\\) -exec wc -l {} +)", + "Bash(find /c/Users/srvds/Documents/Codes/Studio13-v3/Source -type f \\\\\\( -name \"*.cpp\" -o -name \"*.h\" \\\\\\) -exec wc -l {} +)", + "Bash(find /c/Users/srvds/Documents/Codes/Studio13-v3/frontend/src -type f \\\\\\( -name \"*.ts\" -o -name \"*.tsx\" \\\\\\) -exec wc -l {} +)", + "Bash(find /c/Users/srvds/Documents/Codes/Studio13-v3 -type f \\\\\\(-name *test* -o -name *spec* -o -name .github -o -name .gitlab-ci* -o -name azure-pipelines* \\\\\\))", + "Bash(du -sh /c/Users/srvds/Documents/Codes/Studio13-v3/*)", + "Bash(xargs grep:*)", + "Bash(for file:*)", + "Bash(do echo:*)", + "Read(//c/Users/srvds/Documents/Codes/Studio13-v3/**)", + "Bash(done)", + "Bash(wc -l Source/*.cpp Source/*.h)", + "Bash(grep -o \"include.*signalsmith\\\\|include.*ysfx\\\\|include.*lua\\\\|include.*onnx\\\\|include.*clap\" Source/*.h)", + "Bash(grep -r \"^[[:space:]]*\\\\/\\\\/\" Source/*.cpp)", + "Bash(grep -h \"^[[:space:]]*\\\\/\\\\*\" Source/*.cpp)", + "Bash(find /c/Users/srvds/Documents/Codes/Studio13-v3 -type d -iname *test* -o -type d -iname *spec*)" ], "additionalDirectories": [ "C:\\Users\\srvds\\OneDrive\\Documents\\Studio13" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2323b50 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,371 @@ +name: Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + version: + description: "Version to release, for example 1.0.0" + required: true + release_notes_file: + description: "Path to a markdown file in the repo for release notes" + required: false + default: "packaging/release-notes-template.md" + +jobs: + build-windows: + runs-on: windows-latest + env: + VERSION: ${{ github.event.inputs.version || github.ref_name }} + BUILD_DIR: build-release-windows + RELEASE_SITE_URL: https://openstudio.org.in + DOPPLER_TOKEN: ${{ secrets.DOPPLER_TOKEN }} + WINDOWS_CODESIGN_CERT_BASE64: ${{ secrets.WINDOWS_CODESIGN_CERT_BASE64 }} + WINDOWS_CODESIGN_CERT_PASSWORD: ${{ secrets.WINDOWS_CODESIGN_CERT_PASSWORD }} + WINDOWS_CODESIGN_CERT_THUMBPRINT: ${{ secrets.WINDOWS_CODESIGN_CERT_THUMBPRINT }} + WINDOWS_TIMESTAMP_URL: ${{ secrets.WINDOWS_TIMESTAMP_URL }} + steps: + - uses: actions/checkout@v4 + + - name: Install Doppler CLI + if: env.DOPPLER_TOKEN != '' + uses: dopplerhq/cli-action@v3 + + - name: Load release secrets from Doppler + if: env.DOPPLER_TOKEN != '' + shell: pwsh + run: | + $secrets = doppler secrets download --no-file --format json | ConvertFrom-Json -AsHashtable + foreach ($entry in $secrets.GetEnumerator()) { + if (-not [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($entry.Key))) { + continue + } + + "$($entry.Key)=$($entry.Value)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + } + + - name: Normalize version + shell: pwsh + run: | + $version = "${env:VERSION}" -replace '^v', '' + "VERSION=$version" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Build frontend + shell: pwsh + run: | + cd frontend + npm ci + npm run build + + - name: Install Inno Setup + shell: pwsh + run: choco install innosetup -y --no-progress + + - name: Configure CMake + shell: pwsh + run: | + cmake -S . -B $env:BUILD_DIR -A x64 ` + "-DOPENSTUDIO_APP_VERSION=$env:VERSION" ` + "-DOPENSTUDIO_UPDATE_MANIFEST_URL_VALUE=$env:RELEASE_SITE_URL/releases/stable/latest.json" ` + "-DOPENSTUDIO_UPDATE_APPCAST_URL_VALUE=$env:RELEASE_SITE_URL/appcast/windows-stable.xml" ` + "-DOPENSTUDIO_RELEASES_PAGE_URL_VALUE=$env:RELEASE_SITE_URL/download" ` + "-DOPENSTUDIO_UPDATE_CHANNEL_VALUE=stable" ` + "-DOPENSTUDIO_BUNDLE_STEM_RUNTIME=OFF" ` + -DFETCHCONTENT_UPDATES_DISCONNECTED=ON + + - name: Build OpenStudio + shell: pwsh + run: cmake --build $env:BUILD_DIR --config Release --target OpenStudio + + - name: Validate Windows runtime bundle + shell: pwsh + run: ./tools/validate-runtime-bundle.ps1 -Platform windows -BundlePath "$env:BUILD_DIR/OpenStudio_artefacts/Release" -ExpectedVersion $env:VERSION -EnforceLeanBundle + + - name: Prepare Windows signing certificate + shell: pwsh + run: | + if ([string]::IsNullOrWhiteSpace($env:WINDOWS_CODESIGN_CERT_BASE64)) { + Write-Host "No Windows code-signing certificate secret was provided." + exit 0 + } + + $certPath = Join-Path $env:RUNNER_TEMP "openstudio-codesign.pfx" + [IO.File]::WriteAllBytes($certPath, [Convert]::FromBase64String($env:WINDOWS_CODESIGN_CERT_BASE64)) + "WINDOWS_CODESIGN_CERT_PATH=$certPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + - name: Package Windows installer + shell: pwsh + run: | + $arguments = @("-Version", $env:VERSION, "-SourceDir", "$env:BUILD_DIR/OpenStudio_artefacts/Release") + + if (-not [string]::IsNullOrWhiteSpace($env:WINDOWS_CODESIGN_CERT_PATH)) { + $arguments += @("-CertificateFile", $env:WINDOWS_CODESIGN_CERT_PATH) + } + + if (-not [string]::IsNullOrWhiteSpace($env:WINDOWS_CODESIGN_CERT_PASSWORD)) { + $arguments += @("-CertificatePassword", $env:WINDOWS_CODESIGN_CERT_PASSWORD) + } + + if (-not [string]::IsNullOrWhiteSpace($env:WINDOWS_CODESIGN_CERT_THUMBPRINT)) { + $arguments += @("-CertificateThumbprint", $env:WINDOWS_CODESIGN_CERT_THUMBPRINT) + } + + if (-not [string]::IsNullOrWhiteSpace($env:WINDOWS_TIMESTAMP_URL)) { + $arguments += @("-TimestampUrl", $env:WINDOWS_TIMESTAMP_URL) + } + + ./tools/package-windows-release.ps1 @arguments + + - uses: actions/upload-artifact@v4 + with: + name: windows-release + path: dist/windows/OpenStudio-Setup-x64.exe + + build-macos: + runs-on: macos-latest + env: + VERSION: ${{ github.event.inputs.version || github.ref_name }} + BUILD_DIR: build-release-macos + RELEASE_SITE_URL: https://openstudio.org.in + DOPPLER_TOKEN: ${{ secrets.DOPPLER_TOKEN }} + MACOS_CERTIFICATE_BASE64: ${{ secrets.MACOS_CERTIFICATE_BASE64 }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + MACOS_KEYCHAIN_PASSWORD: ${{ secrets.MACOS_KEYCHAIN_PASSWORD }} + MACOS_CODESIGN_IDENTITY: ${{ secrets.MACOS_CODESIGN_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} + steps: + - uses: actions/checkout@v4 + + - name: Install Doppler CLI + if: env.DOPPLER_TOKEN != '' + uses: dopplerhq/cli-action@v3 + + - name: Load release secrets from Doppler + if: env.DOPPLER_TOKEN != '' + shell: pwsh + run: | + $secrets = doppler secrets download --no-file --format json | ConvertFrom-Json -AsHashtable + foreach ($entry in $secrets.GetEnumerator()) { + if (-not [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($entry.Key))) { + continue + } + + "$($entry.Key)=$($entry.Value)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + } + + - name: Normalize version + shell: bash + run: echo "VERSION=${VERSION#v}" >> "$GITHUB_ENV" + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Build frontend + shell: bash + run: | + cd frontend + npm ci + npm run build + + - name: Prepare macOS signing certificate + if: env.MACOS_CERTIFICATE_BASE64 != '' + shell: bash + run: | + CERT_PATH="$RUNNER_TEMP/openstudio-macos-signing.p12" + KEYCHAIN_PATH="$RUNNER_TEMP/openstudio-signing.keychain-db" + EXISTING_KEYCHAINS="$(security list-keychains -d user | sed 's/^[[:space:]]*//; s/^\"//; s/\"$//')" + + echo "$MACOS_CERTIFICATE_BASE64" | base64 --decode > "$CERT_PATH" + security create-keychain -p "$MACOS_KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$MACOS_KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security import "$CERT_PATH" -k "$KEYCHAIN_PATH" -P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/xcrun + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MACOS_KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security list-keychains -d user -s "$KEYCHAIN_PATH" $EXISTING_KEYCHAINS + echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV" + + - name: Configure CMake + shell: bash + run: | + cmake -S . -B "$BUILD_DIR" \ + -DOPENSTUDIO_APP_VERSION="$VERSION" \ + -DOPENSTUDIO_UPDATE_MANIFEST_URL_VALUE="$RELEASE_SITE_URL/releases/stable/latest.json" \ + -DOPENSTUDIO_UPDATE_APPCAST_URL_VALUE="$RELEASE_SITE_URL/appcast/macos-stable.xml" \ + -DOPENSTUDIO_RELEASES_PAGE_URL_VALUE="$RELEASE_SITE_URL/download" \ + -DOPENSTUDIO_UPDATE_CHANNEL_VALUE="stable" \ + -DOPENSTUDIO_BUNDLE_STEM_RUNTIME=OFF \ + -DFETCHCONTENT_UPDATES_DISCONNECTED=ON + + - name: Build OpenStudio + shell: bash + run: cmake --build "$BUILD_DIR" --config Release --target OpenStudio + + - name: Validate macOS runtime bundle + shell: pwsh + run: | + $appPath = Get-ChildItem -Path $env:BUILD_DIR -Recurse -Directory -Filter OpenStudio.app | Select-Object -First 1 + if (-not $appPath) { + throw "OpenStudio.app was not found in the build output." + } + + ./tools/validate-runtime-bundle.ps1 -Platform macos -BundlePath $appPath.FullName -ExpectedVersion $env:VERSION -EnforceLeanBundle + + - name: Package macOS DMG + shell: bash + run: | + chmod +x ./tools/package-macos-release.sh + APP_PATH="$(find "$BUILD_DIR" -type d -name 'OpenStudio.app' | head -n 1)" + if [[ -z "$APP_PATH" ]]; then + echo "OpenStudio.app was not found in the build output." >&2 + exit 1 + fi + ./tools/package-macos-release.sh "$APP_PATH" "$VERSION" + + - name: Clean up macOS signing keychain + if: always() && env.KEYCHAIN_PATH != '' + shell: bash + run: security delete-keychain "$KEYCHAIN_PATH" || true + + - uses: actions/upload-artifact@v4 + with: + name: macos-release + path: dist/macos/OpenStudio-macOS.dmg + + publish: + runs-on: ubuntu-latest + needs: + - build-windows + - build-macos + permissions: + contents: write + env: + VERSION: ${{ github.event.inputs.version || github.ref_name }} + RELEASE_NOTES_FILE: ${{ github.event.inputs.release_notes_file || 'packaging/release-notes-template.md' }} + RELEASE_SITE_URL: https://openstudio.org.in + DOPPLER_TOKEN: ${{ secrets.DOPPLER_TOKEN }} + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} + NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} + steps: + - uses: actions/checkout@v4 + + - name: Install Doppler CLI + if: env.DOPPLER_TOKEN != '' + uses: dopplerhq/cli-action@v3 + + - name: Load release secrets from Doppler + if: env.DOPPLER_TOKEN != '' + shell: pwsh + run: | + $secrets = doppler secrets download --no-file --format json | ConvertFrom-Json -AsHashtable + foreach ($entry in $secrets.GetEnumerator()) { + if (-not [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($entry.Key))) { + continue + } + + "$($entry.Key)=$($entry.Value)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + } + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Normalize version + shell: bash + run: echo "VERSION=${VERSION#v}" >> "$GITHUB_ENV" + + - uses: actions/download-artifact@v4 + with: + name: windows-release + path: dist/windows + + - uses: actions/download-artifact@v4 + with: + name: macos-release + path: dist/macos + + - name: Generate checksums and updater metadata + shell: pwsh + run: | + $tag = "${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || '' }}" + if ([string]::IsNullOrWhiteSpace($tag)) { $tag = "v$env:VERSION" } + $releaseUrl = "https://github.com/${{ github.repository }}/releases/tag/$tag" + $windowsUrl = "https://github.com/${{ github.repository }}/releases/download/$tag/OpenStudio-Setup-x64.exe" + $macosUrl = "https://github.com/${{ github.repository }}/releases/download/$tag/OpenStudio-macOS.dmg" + ./tools/generate-release-metadata.ps1 ` + -Version $env:VERSION ` + -Channel stable ` + -ReleasePageUrl $releaseUrl ` + -FullReleaseNotesUrl $releaseUrl ` + -NotesFile $env:RELEASE_NOTES_FILE ` + -WindowsAssetPath "dist/windows/OpenStudio-Setup-x64.exe" ` + -WindowsAssetUrl $windowsUrl ` + -WindowsInstallerArguments "/SP- /NOICONS" ` + -MacAssetPath "dist/macos/OpenStudio-macOS.dmg" ` + -MacAssetUrl $macosUrl + + - name: Validate release metadata + shell: pwsh + run: | + ./tools/validate-release-metadata.ps1 ` + -MetadataDir "dist/release-metadata" ` + -Channel stable ` + -WindowsAssetPath "dist/windows/OpenStudio-Setup-x64.exe" ` + -MacAssetPath "dist/macos/OpenStudio-macOS.dmg" + + - name: Prepare Netlify updater bundle + shell: pwsh + run: | + ./tools/prepare-netlify-release-site.ps1 ` + -MetadataDir "dist/release-metadata" ` + -OutputDir "dist/netlify-release-site" ` + -RepoSlug "${{ github.repository }}" + + - uses: actions/upload-artifact@v4 + with: + name: netlify-release-site + path: dist/netlify-release-site + + - name: Publish GitHub release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || format('v{0}', github.event.inputs.version) }} + name: OpenStudio ${{ env.VERSION }} + body_path: ${{ env.RELEASE_NOTES_FILE }} + files: | + dist/windows/OpenStudio-Setup-x64.exe + dist/macos/OpenStudio-macOS.dmg + dist/release-metadata/OpenStudio-checksums.txt + dist/release-metadata/releases/latest.json + dist/release-metadata/releases/stable/latest.json + dist/release-metadata/appcast/windows-stable.xml + dist/release-metadata/appcast/macos-stable.xml + + - name: Deploy updater metadata to Netlify + if: env.NETLIFY_AUTH_TOKEN != '' && env.NETLIFY_SITE_ID != '' + shell: bash + run: npx netlify-cli deploy --dir dist/netlify-release-site --site "$NETLIFY_SITE_ID" --prod + + - name: Validate published release endpoints + if: env.NETLIFY_AUTH_TOKEN != '' && env.NETLIFY_SITE_ID != '' + shell: pwsh + run: | + ./tools/validate-published-release.ps1 ` + -MetadataDir "dist/release-metadata" ` + -Channel stable ` + -ReleaseSiteUrl $env:RELEASE_SITE_URL ` + -ValidateRedirects diff --git a/.gitignore b/.gitignore index 7bac06e..cbf0e69 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # CMake /build +/build-*/ /out /cmake-build-* CMakeCache.txt @@ -21,6 +22,7 @@ cmake_install.cmake node_modules /frontend/node_modules /frontend/dist +/dist/ # JUCE /juce @@ -28,6 +30,41 @@ node_modules # Python __pycache__/ *.pyc +get-pip.py +.env +.env.local +.env.*.local +.doppler/ + +# Bundled Python environment (downloaded per-machine, not committed) +/tools/python/ + +# Release artifacts and local publish validation +/dist/netlify-release-site/ +/dist/release-metadata/ +/dist/test-release-metadata/ +/dist/published-release-test/ +/dist/remote-release-validation-*/ +/build-version-check/ + +# Signing material +*.pfx +*.p12 +*.cer +*.pvk +*.mobileprovision +*.keychain +*.keychain-db + +# Third-party SDKs & runtime binaries (downloaded per-machine) +/thirdparty/onnxruntime/ +/thirdparty/ARA_SDK/ + +# ML models (large binary files, downloaded at setup/runtime) +/resources/models/*.onnx +/resources/models/*.ckpt +/resources/models/*.bin +/resources/models/*.ort # Logs *.log diff --git a/CLAUDE.md b/CLAUDE.md index 1379e9a..2994cfb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,27 +30,58 @@ Studio13-v3/ ├── Source/ # C++ backend │ ├── Main.cpp # JUCE app entry point │ ├── MainComponent.h/cpp # Hosts WebBrowserComponent + AudioEngine, exposes native functions to JS -│ ├── AudioEngine.h/cpp # Core audio callback (juce::AudioIODeviceCallback), device management, track graph, render -│ ├── PlaybackEngine.h/cpp # Clip playback scheduling, sample-rate-aware mixing with linear interpolation +│ ├── AudioEngine.h/cpp # Core audio callback, device management, track graph, render, pitch correction entry point +│ ├── PlaybackEngine.h/cpp # Clip playback scheduling, sample-rate-aware mixing, replaceClipAudioFile │ ├── AudioRecorder.h/cpp # Thread-safe recording via juce::AudioFormatWriter::ThreadedWriter │ ├── TrackProcessor.h/cpp # Per-track juce::AudioProcessor: metering, FX chain, input monitoring -│ ├── PluginManager.h/cpp # VST3 plugin scanning and loading -│ ├── PluginWindowManager.h/cpp# Native VST3 editor window management +│ ├── PluginManager.h/cpp # VST3/CLAP/LV2 plugin scanning and loading +│ ├── PluginWindowManager.h/cpp# Native plugin editor window management +│ ├── CLAPPluginFormat.h/cpp # CLAP plugin hosting (parameter discovery, GUI, state) │ ├── MIDIManager.h/cpp # MIDI device enumeration and input routing │ ├── MIDIClip.h/cpp # MIDI note event storage and time-range queries │ ├── Metronome.h/cpp # Click track generation (BPM, time sig, accent patterns) │ ├── AudioConverter.h/cpp # Channel/sample-rate conversion utilities │ ├── PeakCache.h/cpp # REAPER-style multi-resolution peak cache (.s13peaks sidecar files) -│ └── AudioAnalyzer.h/cpp # Audio analysis utilities +│ ├── AudioAnalyzer.h/cpp # Audio analysis utilities +│ ├── BuiltInEffects.h/cpp # Built-in audio effects (EQ, compressor, etc.) +│ ├── BuiltInEffects2.h/cpp # Additional built-in effects +│ │ +│ │ # Pitch Editor / Correction Pipeline (see "Pitch Editor Subsystem" below) +│ ├── PitchAnalyzer.h/cpp # YIN monophonic pitch detection, note segmentation, pitchDrift +│ ├── PitchDetector.h/cpp # Low-level YIN pitch detection algorithm +│ ├── PitchMapper.h/cpp # Maps detected pitch to corrected pitch (scale/key snapping) +│ ├── PitchShifter.h/cpp # Phase vocoder pitch shifter (FFT 2048, hop 512, FIFO-based) +│ ├── PitchResynthesizer.h/cpp # Offline pitch correction: builds correction curve, applies via RubberBand (default), WORLD, or phase vocoder +│ ├── RubberBandShifter.h/cpp # Rubber Band Library R3 wrapper: multi-channel, per-block pitch ratios, formant preservation +│ ├── FormantPreserver.h/cpp # WORLD vocoder (DIO+StoneMask+CheapTrick+D4C) for formant-preserving pitch shift (fallback) +│ ├── PolyPitchDetector.h/cpp # Polyphonic pitch detection via Basic-Pitch ONNX model +│ ├── PolyResynthesizer.h/cpp # Polyphonic pipeline: STFT→Wiener masks→per-note shift→accumulate→ISTFT +│ ├── HarmonicMaskGenerator.h/cpp # Wiener-filter soft masks at harmonic positions for poly separation +│ ├── SpectralPitchShifter.h/cpp # Phase vocoder on masked spectrograms with cepstral formant preservation +│ ├── SpectralProcessor.h/cpp # STFT/ISTFT utilities for spectral processing +│ ├── S13PitchCorrector.h/cpp # Real-time inline pitch corrector (auto-tune style) +│ │ +│ │ # Plugin System +│ ├── S13FXProcessor.h/cpp # JSFX/Lua script-based audio processor (wraps YSFX) +│ ├── S13FXGfxEditor.h/cpp # JSFX @gfx rendering via juce::Image framebuffer at 30fps +│ ├── S13PluginEditors.h/cpp # Built-in plugin editor windows +│ ├── S13ScriptWindow.h/cpp # Lua gfx API framebuffer window +│ ├── ScriptEngine.h/cpp # Lua scripting engine (sol2) +│ │ +│ │ # Other Features +│ ├── StemSeparator.h/cpp # AI stem separation (vocals/drums/bass/other) +│ └── ARAHostController.h/cpp # ARA plugin hosting controller │ ├── frontend/ │ ├── src/ -│ │ ├── App.tsx # Main layout: MenuBar → MainToolbar → workspace(TCP + Timeline) → TransportBar → Modals +│ │ ├── App.tsx # Main layout: MenuBar → MainToolbar → workspace(TCP + Timeline) → TransportBar → LowerZone → Modals │ │ ├── main.tsx # React entry point │ │ ├── index.css # Tailwind theme with daw-* custom colors │ │ ├── store/ │ │ │ ├── useDAWStore.ts # Zustand store — all app state and actions (~3400 lines) │ │ │ ├── actionRegistry.ts # Centralized action defs for Command Palette, shortcuts, menus +│ │ │ ├── pitchEditorStore.ts # Pitch editor Zustand store: notes, viewport, tools, undo, poly mode +│ │ │ ├── automationParams.ts # Automation parameter definitions │ │ │ └── commands/ # Undo/redo: CommandManager.ts, TrackCommands.ts, ClipCommands.ts │ │ ├── services/ │ │ │ └── NativeBridge.ts # Type-safe bridge to C++ backend (with mock fallbacks for dev) @@ -61,7 +92,9 @@ Studio13-v3/ │ │ ├── TransportBar.tsx # Bottom bar: play/stop/record, BPM, time display, loop, metronome │ │ ├── MixerPanel.tsx # Horizontal mixer with ChannelStrip components │ │ ├── ChannelStrip.tsx # Track volume fader, pan, solo/mute, FX, meter +│ │ ├── ChannelStripEQModal.tsx # Per-channel EQ editor │ │ ├── TrackHeader.tsx # Track name, arm, solo, mute, input selector +│ │ ├── TrackRoutingModal.tsx # Track I/O routing configuration │ │ ├── SortableTrackHeader.tsx # @dnd-kit wrapper for track reordering + context menu │ │ ├── FXChainPanel.tsx # Plugin browser + FX chain management (input FX + track FX) │ │ ├── PianoRoll.tsx # MIDI note editor (Konva canvas) @@ -71,9 +104,29 @@ Studio13-v3/ │ │ ├── SettingsModal.tsx # Audio device configuration (driver, I/O, sample rate, buffer) │ │ ├── RenderModal.tsx # Export dialog (format, bit depth, channels, normalize, tail) │ │ ├── PreferencesModal.tsx # Tabbed prefs: General, Editing, Display, Backup +│ │ ├── ProjectSettingsModal.tsx # Project-level settings │ │ ├── KeyboardShortcutsModal.tsx # Searchable action/shortcut reference (from actionRegistry) │ │ ├── CommandPalette.tsx # Ctrl+Shift+P fuzzy action search -│ │ ├── PluginBrowser.tsx # VST3 plugin selection UI +│ │ ├── PluginBrowser.tsx # VST3/CLAP/LV2 plugin selection UI +│ │ ├── LowerZone.tsx # Bottom panel container (pitch editor, clip properties, etc.) +│ │ ├── ClipPropertiesPanel.tsx # Clip property inspector +│ │ ├── ClipLauncherView.tsx # Ableton-style clip launcher grid +│ │ │ +│ │ │ # Pitch Editor UI +│ │ ├── PitchCorrectorPanel.tsx # Real-time inline corrector (auto-tune style, key/scale/retune) +│ │ ├── PitchEditorLowerZone.tsx # Graphical pitch editor: canvas host, tools, controls, interaction handlers +│ │ ├── PitchEditorCanvas.ts # Imperative canvas renderer (60fps RAF loop): notes, contour, grid, piano keys +│ │ ├── S13PitchEditor.tsx # Pitch editor wrapper/container +│ │ ├── pitchCorrectorPresets.ts # Preset definitions for real-time pitch corrector +│ │ │ +│ │ │ # Other +│ │ ├── StemSeparationModal.tsx # AI stem separation UI +│ │ ├── EnvelopeManagerModal.tsx # Automation envelope management +│ │ ├── TimecodeSettingsPanel.tsx # Timecode display settings +│ │ ├── ThemeEditor.tsx # Theme customization + REAPER theme import +│ │ ├── GettingStartedGuide.tsx # First-run guide +│ │ ├── HelpOverlay.tsx # Contextual help overlay +│ │ ├── MasterTrackHeader.tsx # Master track control strip │ │ ├── Playhead.tsx # Timeline playhead cursor │ │ ├── PeakMeter.tsx # Audio level meters │ │ ├── icons.tsx # SVG icon components @@ -83,10 +136,12 @@ Studio13-v3/ │ ├── vite.config.ts │ └── tsconfig.json │ -├── tools/ # ffmpeg.exe, setup scripts +├── tools/ # ffmpeg.exe, stem_separator.py, setup scripts +├── resources/ # ONNX models, presets, resources ├── build/ # CMake build output -├── CMakeLists.txt # C++ build: JUCE 8.0.0, ASIO SDK, WebView2, VST3 +├── CMakeLists.txt # C++ build: JUCE 8.0.0, ASIO SDK, WebView2, VST3, ONNX Runtime ├── build.py # Python orchestrator: cmake + npm + vite dev server +├── pitch_corrector_feat_plan.md # Detailed pitch editor implementation plan (Melodyne/RePitch/VariAudio parity) └── WORKFLOWS.md # Dev workflow docs ``` @@ -161,6 +216,48 @@ For **continuous edits** (faders, knobs), use the begin/commit pattern: `beginXE - FX plugins: per-track input FX chain + track FX chain + master FX chain - Render re-prepares plugins for offline block size, restores state after +### Pitch Editor Subsystem + +The pitch editor enables vocal pitch correction with both real-time (auto-tune style) and graphical (Melodyne-style) modes. The implementation plan for reaching Melodyne/RePitch/VariAudio quality is in `pitch_corrector_feat_plan.md`. + +**Architecture**: +``` +Monophonic pipeline (default — Rubber Band): + PitchAnalyzer (YIN) → PitchNotes → PitchResynthesizer::processMultiChannel() → RubberBandShifter (R3 engine) + +Monophonic pipeline (fallback — WORLD): + PitchAnalyzer (YIN) → PitchNotes → PitchResynthesizer::process() → FormantPreserver (WORLD vocoder) + +Polyphonic pipeline: + PolyPitchDetector (Basic-Pitch ONNX) → PolyNotes → HarmonicMaskGenerator (Wiener) → SpectralPitchShifter → PolyResynthesizer + +Real-time corrector: + S13PitchCorrector (per-block, key/scale aware) → inserted as FX plugin on track +``` + +**Key data flow** (graphical editor): +1. User opens pitch editor → `pitchEditorStore.open()` → `nativeBridge.analyzePitchContourDirect()` +2. C++ `PitchAnalyzer` runs YIN detection, returns frames (per-hop pitch data) + notes (segmented note objects) +3. Frontend renders notes as blobs on canvas (`PitchEditorCanvas.ts`), user edits correctedPitch/drift/vibrato/etc. +4. On edit commit (400ms auto-apply debounce) → `nativeBridge.applyPitchCorrection(trackId, clipId, notes)` +5. C++ `AudioEngine::applyPitchCorrection()`: + - Loads original clip audio → extracts window around edited notes + - Passes all channels to `PitchResynthesizer::processMultiChannel()` with Rubber Band (native stereo) + - Crossfades corrected window back into clip at splice points (512-sample crossfade) + - Deletes old output file, writes new rotating output file → `PlaybackEngine::replaceClipAudioFile()` swaps it in (resets `clip.offset = 0`) + +**Rubber Band Library** (default pitch engine): Uses R3 (Finer) engine in RealTime mode with `OptionPitchHighQuality`, `OptionFormantPreserved`, `OptionChannelsTogether`. RealTime mode is required for per-block `setPitchScale()` calls (Offline mode rejects mid-processing pitch changes). Has latency compensation: flush with silence after input, skip first `getLatency()` output samples. Built from `single/RubberBandSingle.cpp` with `USE_KISSFFT` + `NOMINMAX`. GPL v2+ license. + +**PitchResynthesizer engines** (`PitchEngine` enum): `RubberBand` (default, native stereo, high quality), `WorldVocoder` (WORLD, mono-only, speech-oriented fallback), `PhaseVocoder` (basic, no formant preservation). + +**PitchShifter FIFO latency**: The phase vocoder has 2048-sample FIFO latency. The first fftSize output samples are zeros. `PitchResynthesizer` compensates by flushing the FIFO with silence and trimming the latency from the output. + +**WORLD vocoder** (`FormantPreserver`): Kept as fallback engine. Decomposes audio into F0 + spectral envelope + aperiodicity. Mono-only — when used via `processMultiChannel`, creates mono mix and duplicates to all channels. + +**Polyphonic detection**: Uses Spotify's Basic-Pitch ONNX model (22050 Hz, 256 hop). Thresholds are tunable: `noteThreshold=0.15`, `onsetThreshold=0.3`. Onset bypass accepts sustained notes ≥200ms even without detected onset. + +**Frontend state**: `pitchEditorStore.ts` (separate Zustand store from main DAW store) — holds notes, viewport, selectedNoteIds, tools (Select/Pitch/Drift/Vibrato/Transition), undo/redo stack, poly mode toggle, auto-apply debounce. + ### Audio Thread Safety (REAPER-inspired) - **PlaybackEngine::fillTrackBuffer()** uses `ScopedTryLock` (not `ScopedLock`) — returns silence if the message thread holds the lock (adding/removing clips). This is rare and inaudible. Never use blocking locks on the audio thread. @@ -191,6 +288,13 @@ For **continuous edits** (faders, knobs), use the begin/commit pattern: `beginXE - Semantic colors: `daw-record` (red), `daw-mute` (green), `daw-solo` (yellow), `daw-fx` (lime) - UI components in `components/ui/` use variant pattern (default, primary, success, danger, etc.) +## When You're Stuck + +**If you encounter the same error in a loop (3+ attempts) or need admin/system-level access you don't have (installing software, modifying system PATH, registry, etc.), STOP and immediately ask the user for help.** Do not keep retrying the same failing approach. Clearly explain: +1. What you're trying to do +2. What's failing and why +3. What the user needs to do (step by step) + ## Coding Preferences - Prefer targeted, minimal fixes over large refactors diff --git a/CMakeLists.txt b/CMakeLists.txt index 0774f49..e6ae726 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,11 +1,20 @@ cmake_minimum_required(VERSION 3.22) -project(Studio13_v2 VERSION 0.0.1) +set(OPENSTUDIO_APP_VERSION "0.0.1" CACHE STRING "Application version compiled into OpenStudio") -# Set ASIO SDK path for JUCE (required for ASIO support) -# Path must point to the folder containing common/, host/, driver/ subdirectories -set(JUCE_ASIOSDK_PATH "C:/SDKs/ASIO-SDK_2.3.4_2025-10-15/ASIOSDK" CACHE PATH "Path to ASIO SDK") -message(STATUS "ASIO SDK Path: ${JUCE_ASIOSDK_PATH}") +project(OpenStudio VERSION ${OPENSTUDIO_APP_VERSION}) + +option(OPENSTUDIO_BUNDLE_STEM_RUNTIME "Bundle the optional stem-separation runtime into app outputs" OFF) + +# ============================================================================== +# Phase 20.1: Cross-platform CMake guards +# ============================================================================== + +# Set ASIO SDK path for JUCE (Windows only — required for ASIO support) +if(WIN32) + set(JUCE_ASIOSDK_PATH "C:/SDKs/ASIO-SDK_2.3.4_2025-10-15/ASIOSDK" CACHE PATH "Path to ASIO SDK") + message(STATUS "ASIO SDK Path: ${JUCE_ASIOSDK_PATH}") +endif() # Check if JUCE folder exists, otherwise fetch it (or use CPM if preferred, but standard FetchContent is fine) # For this setup, we'll fetch JUCE 8.0.0 @@ -18,18 +27,92 @@ FetchContent_Declare( ) FetchContent_MakeAvailable(juce) -# Define the JUCE applicati# Add our executable -juce_add_gui_app(Studio13_v2 - PRODUCT_NAME "Studio13" - VERSION 0.0.1 +# Fetch YSFX — open-source JSFX runtime (Apache 2.0) +FetchContent_Declare( + ysfx + GIT_REPOSITORY https://github.com/jpcima/ysfx.git + GIT_TAG master + GIT_SHALLOW TRUE +) +set(YSFX_PLUGIN OFF CACHE BOOL "" FORCE) +set(YSFX_TOOLS OFF CACHE BOOL "" FORCE) +set(YSFX_TESTS OFF CACHE BOOL "" FORCE) +FetchContent_MakeAvailable(ysfx) + +# Fetch Lua 5.4 — scripting engine (MIT license) +FetchContent_Declare( + lua + URL "https://www.lua.org/ftp/lua-5.4.7.tar.gz" +) +FetchContent_MakeAvailable(lua) + +# Fetch CLAP SDK — C plugin API headers (MIT license) +FetchContent_Declare( + clap + GIT_REPOSITORY https://github.com/free-audio/clap.git + GIT_TAG 1.2.2 + GIT_SHALLOW TRUE +) +FetchContent_MakeAvailable(clap) + + +# Build Lua as a static library (it ships as raw C sources) +file(GLOB LUA_CORE_SOURCES "${lua_SOURCE_DIR}/src/*.c") +# Remove lua.c and luac.c (standalone interpreter/compiler, not needed) +list(FILTER LUA_CORE_SOURCES EXCLUDE REGEX "lua\\.c$|luac\\.c$") +add_library(lua_static STATIC ${LUA_CORE_SOURCES}) +target_include_directories(lua_static PUBLIC "${lua_SOURCE_DIR}/src") +if(MSVC) + target_compile_definitions(lua_static PRIVATE _CRT_SECURE_NO_WARNINGS) + # Suppress Lua C code warnings (third-party code) + target_compile_options(lua_static PRIVATE /W0) +else() + target_compile_options(lua_static PRIVATE -w) +endif() + +# ============================================================================== +# ONNX Runtime — pre-built binary for neural network inference (optional) +# Download CPU-only: https://github.com/microsoft/onnxruntime/releases +# Extract to thirdparty/onnxruntime/ with include/ and lib/ subdirectories +# ============================================================================== +set(ONNXRUNTIME_ROOT "${CMAKE_SOURCE_DIR}/thirdparty/onnxruntime") +if(EXISTS "${ONNXRUNTIME_ROOT}/include/onnxruntime_cxx_api.h") + set(HAS_ONNXRUNTIME ON) + message(STATUS "ONNX Runtime found at ${ONNXRUNTIME_ROOT}") +else() + set(HAS_ONNXRUNTIME OFF) + message(STATUS "ONNX Runtime not found — polyphonic pitch detection disabled") +endif() + +# ============================================================================== +# ARA SDK — Optional ARA 2 plugin hosting (for Melodyne, SpectraLayers, etc.) +# Clone from: git clone --recursive --branch releases/2.2.0 https://github.com/Celemony/ARA_SDK +# Place at: thirdparty/ARA_SDK/ +# ============================================================================== +set(ARA_SDK_ROOT "${CMAKE_SOURCE_DIR}/thirdparty/ARA_SDK") +if(EXISTS "${ARA_SDK_ROOT}/ARA_API/ARAInterface.h") + set(HAS_ARA ON) + message(STATUS "ARA SDK found at ${ARA_SDK_ROOT}") +else() + set(HAS_ARA OFF) + message(STATUS "ARA SDK not found — ARA plugin hosting disabled") +endif() + +# Define the JUCE application +juce_add_gui_app(OpenStudio + PRODUCT_NAME "OpenStudio" + VERSION ${PROJECT_VERSION} ICON_BIG "${CMAKE_CURRENT_SOURCE_DIR}/assets/icon-256x256.png" ICON_SMALL "${CMAKE_CURRENT_SOURCE_DIR}/assets/icon-16x16.png" ) # Source files -target_sources(Studio13_v2 PRIVATE +target_sources(OpenStudio PRIVATE Source/Main.cpp + Source/ApplicationLaunchState.cpp Source/MainComponent.cpp + Source/MixerWindowManager.cpp + Source/AppUpdater.cpp Source/AudioEngine.cpp Source/AudioRecorder.cpp Source/PlaybackEngine.cpp @@ -42,19 +125,71 @@ target_sources(Studio13_v2 PRIVATE Source/TrackProcessor.cpp Source/Metronome.cpp Source/AudioAnalyzer.cpp + Source/S13FXProcessor.cpp + Source/S13FXGfxEditor.cpp + Source/S13ScriptWindow.cpp + Source/ScriptEngine.cpp + Source/AutomationList.cpp + Source/MIDIRecorder.cpp + Source/CLAPPluginFormat.cpp + Source/ControlSurfaceManager.cpp + Source/TimecodeSync.cpp + Source/VideoReader.cpp + Source/DDPExporter.cpp + Source/VBAPPanner.cpp + Source/BuiltInEffects.cpp + Source/BuiltInEffects2.cpp + Source/S13PluginEditors.cpp + Source/PitchDetector.cpp + Source/PitchMapper.cpp + Source/S13PitchCorrector.cpp + Source/PitchAnalyzer.cpp + Source/PitchResynthesizer.cpp + Source/SignalsmithShifter.cpp + Source/TriggerEngine.cpp + Source/SessionInterchange.cpp + Source/PolyPitchDetector.cpp + Source/StemSeparator.cpp + Source/ARAHostController.cpp ) -# Include directories -target_include_directories(Studio13_v2 PRIVATE +# Include directories — common to all platforms +target_include_directories(OpenStudio PRIVATE Source - # ASIO SDK include paths - "C:/SDKs/ASIO-SDK_2.3.4_2025-10-15/ASIOSDK/common" - "C:/SDKs/ASIO-SDK_2.3.4_2025-10-15/ASIOSDK/host" - "C:/SDKs/ASIO-SDK_2.3.4_2025-10-15/ASIOSDK/host/pc" + # YSFX include paths + "${ysfx_SOURCE_DIR}/include" + # CLAP SDK headers + "${clap_SOURCE_DIR}/include" + # Signalsmith Stretch — header-only pitch shifting (MIT) + "${CMAKE_SOURCE_DIR}/thirdparty/signalsmith" ) -# Link against JUCE modules -target_link_libraries(Studio13_v2 PRIVATE +# ONNX Runtime include (optional) +if(HAS_ONNXRUNTIME) + target_include_directories(OpenStudio PRIVATE "${ONNXRUNTIME_ROOT}/include") +endif() + +# ARA SDK include (optional) +if(HAS_ARA) + target_include_directories(OpenStudio PRIVATE + "${ARA_SDK_ROOT}" + "${ARA_SDK_ROOT}/ARA_Library" + ) + target_compile_definitions(OpenStudio PRIVATE JUCE_PLUGINHOST_ARA=1) +endif() + +# Platform-specific include directories +if(WIN32) + target_include_directories(OpenStudio PRIVATE + # ASIO SDK include paths (Windows only) + "C:/SDKs/ASIO-SDK_2.3.4_2025-10-15/ASIOSDK/common" + "C:/SDKs/ASIO-SDK_2.3.4_2025-10-15/ASIOSDK/host" + "C:/SDKs/ASIO-SDK_2.3.4_2025-10-15/ASIOSDK/host/pc" + ) +endif() + +# Link against JUCE modules (common to all platforms) +target_link_libraries(OpenStudio PRIVATE juce::juce_core juce::juce_data_structures juce::juce_events @@ -67,65 +202,321 @@ target_link_libraries(Studio13_v2 PRIVATE juce::juce_audio_processors juce::juce_audio_utils juce::juce_dsp + juce::juce_cryptography + juce::juce_osc + ysfx + lua_static ) -# Enable WebView2 on Windows -target_compile_definitions(Studio13_v2 PRIVATE - JUCE_WEB_BROWSER=1 - JUCE_USE_WIN_WEBVIEW2=1 - JUCE_USE_WIN_WEBVIEW2_WITH_STATIC_LINKING=1 - - # Enable ASIO support for professional audio interfaces (like Audient) - JUCE_ASIO=1 - JUCE_WASAPI=1 - JUCE_DIRECTSOUND=1 - - # Enable VST3 plugin hosting +# ONNX Runtime link (optional) +if(HAS_ONNXRUNTIME) + target_link_directories(OpenStudio PRIVATE "${ONNXRUNTIME_ROOT}/lib") + target_link_libraries(OpenStudio PRIVATE onnxruntime) + target_compile_definitions(OpenStudio PRIVATE S13_HAS_ONNXRUNTIME=1) +endif() + +# ============================================================================== +# Platform-specific compile definitions +# ============================================================================== + +# Common updater/release URL defaults (override at configure time for production) +set(OPENSTUDIO_UPDATE_MANIFEST_URL_VALUE "https://openstudio.org.in/releases/stable/latest.json" CACHE STRING "Updater manifest URL compiled into OpenStudio") +set(OPENSTUDIO_RELEASES_PAGE_URL_VALUE "https://openstudio.org.in/download" CACHE STRING "Release/download page URL compiled into OpenStudio") +set(OPENSTUDIO_UPDATE_CHANNEL_VALUE "stable" CACHE STRING "Updater channel compiled into OpenStudio") + +if(WIN32) + set(_OPENSTUDIO_DEFAULT_APPCAST_URL "https://openstudio.org.in/appcast/windows-stable.xml") +elseif(APPLE) + set(_OPENSTUDIO_DEFAULT_APPCAST_URL "https://openstudio.org.in/appcast/macos-stable.xml") +else() + set(_OPENSTUDIO_DEFAULT_APPCAST_URL "") +endif() + +set(OPENSTUDIO_UPDATE_APPCAST_URL_VALUE "${_OPENSTUDIO_DEFAULT_APPCAST_URL}" CACHE STRING "Updater appcast URL compiled into OpenStudio") + +# Common definitions (all platforms) +target_compile_definitions(OpenStudio PRIVATE + # Enable plugin hosting: VST3 + LV2 JUCE_PLUGINHOST_VST3=1 - JUCE_PLUGINHOST_AU=0 + JUCE_PLUGINHOST_LV2=1 JUCE_PLUGINHOST_VST=0 + # Required for JSFX gfx_showmenu() synchronous popup menus + JUCE_MODAL_LOOPS_PERMITTED=1 + OPENSTUDIO_UPDATE_MANIFEST_URL="${OPENSTUDIO_UPDATE_MANIFEST_URL_VALUE}" + OPENSTUDIO_UPDATE_APPCAST_URL="${OPENSTUDIO_UPDATE_APPCAST_URL_VALUE}" + OPENSTUDIO_RELEASES_PAGE_URL="${OPENSTUDIO_RELEASES_PAGE_URL_VALUE}" + OPENSTUDIO_UPDATE_CHANNEL="${OPENSTUDIO_UPDATE_CHANNEL_VALUE}" ) -# Fetch WebView2 if not present (Fix for missing header) -FetchContent_Declare( - webview2 - URL "https://www.nuget.org/api/v2/package/Microsoft.Web.WebView2/1.0.1901.177" -) -FetchContent_MakeAvailable(webview2) +if(WIN32) + # Windows: WebView2, ASIO, WASAPI, DirectSound + target_compile_definitions(OpenStudio PRIVATE + JUCE_WEB_BROWSER=1 + JUCE_USE_WIN_WEBVIEW2=1 + JUCE_USE_WIN_WEBVIEW2_WITH_STATIC_LINKING=1 + JUCE_ASIO=1 + JUCE_WASAPI=1 + JUCE_DIRECTSOUND=1 + JUCE_PLUGINHOST_AU=0 + ) +elseif(APPLE) + # macOS: WebKit browser, CoreAudio, AU hosting + target_compile_definitions(OpenStudio PRIVATE + JUCE_WEB_BROWSER=1 + JUCE_USE_WIN_WEBVIEW2=0 + JUCE_ASIO=0 + JUCE_WASAPI=0 + JUCE_DIRECTSOUND=0 + JUCE_PLUGINHOST_AU=1 + ) +elseif(UNIX) + # Linux: WebKit/GTK browser, ALSA, JACK + target_compile_definitions(OpenStudio PRIVATE + JUCE_WEB_BROWSER=1 + JUCE_USE_WIN_WEBVIEW2=0 + JUCE_ASIO=0 + JUCE_WASAPI=0 + JUCE_DIRECTSOUND=0 + JUCE_PLUGINHOST_AU=0 + JUCE_ALSA=1 + JUCE_JACK=1 + ) +endif() -target_include_directories(Studio13_v2 PRIVATE - "${webview2_SOURCE_DIR}/build/native/include" -) +# ============================================================================== +# Windows-only: WebView2 SDK +# ============================================================================== +if(WIN32) + FetchContent_Declare( + webview2 + URL "https://www.nuget.org/api/v2/package/Microsoft.Web.WebView2/1.0.1901.177" + ) + FetchContent_MakeAvailable(webview2) -# Link WebView2 Static Lib + DWM API for dark title bar -if(MSVC) - target_link_libraries(Studio13_v2 PRIVATE - "${webview2_SOURCE_DIR}/build/native/x64/WebView2LoaderStatic.lib" - dwmapi + target_include_directories(OpenStudio PRIVATE + "${webview2_SOURCE_DIR}/build/native/include" + ) + + if(MSVC) + target_link_libraries(OpenStudio PRIVATE + "${webview2_SOURCE_DIR}/build/native/x64/WebView2LoaderStatic.lib" + dwmapi + ) + endif() +endif() + +# ============================================================================== +# macOS-only: Link system frameworks +# ============================================================================== +if(APPLE) + find_library(WEBKIT_FRAMEWORK WebKit) + find_library(COREAUDIO_FRAMEWORK CoreAudio) + find_library(COREMIDI_FRAMEWORK CoreMIDI) + find_library(ACCELERATE_FRAMEWORK Accelerate) + target_link_libraries(OpenStudio PRIVATE + ${WEBKIT_FRAMEWORK} + ${COREAUDIO_FRAMEWORK} + ${COREMIDI_FRAMEWORK} + ${ACCELERATE_FRAMEWORK} ) endif() +# ============================================================================== +# Linux-only: Link system libraries +# ============================================================================== +if(UNIX AND NOT APPLE) + find_package(PkgConfig REQUIRED) + pkg_check_modules(ALSA alsa) + pkg_check_modules(JACK jack) + pkg_check_modules(WEBKIT webkit2gtk-4.0) + if(ALSA_FOUND) + target_link_libraries(OpenStudio PRIVATE ${ALSA_LIBRARIES}) + target_include_directories(OpenStudio PRIVATE ${ALSA_INCLUDE_DIRS}) + endif() + if(JACK_FOUND) + target_link_libraries(OpenStudio PRIVATE ${JACK_LIBRARIES}) + target_include_directories(OpenStudio PRIVATE ${JACK_INCLUDE_DIRS}) + endif() + if(WEBKIT_FOUND) + target_link_libraries(OpenStudio PRIVATE ${WEBKIT_LIBRARIES}) + target_include_directories(OpenStudio PRIVATE ${WEBKIT_INCLUDE_DIRS}) + endif() +endif() + # Generate the JuceHeader.h -juce_generate_juce_header(Studio13_v2) +juce_generate_juce_header(OpenStudio) # Standard strict warnings if(MSVC) - target_compile_options(Studio13_v2 PRIVATE /W4) + target_compile_options(OpenStudio PRIVATE /W4) else() - target_compile_options(Studio13_v2 PRIVATE -Wall -Wextra) + target_compile_options(OpenStudio PRIVATE -Wall -Wextra) endif() -# Copy bundled FFmpeg to build output (next to the executable) -set(FFMPEG_EXE "${CMAKE_CURRENT_SOURCE_DIR}/tools/ffmpeg.exe") -if(EXISTS "${FFMPEG_EXE}") - add_custom_command(TARGET Studio13_v2 POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy_if_different +# ============================================================================== +# Post-build copy steps +# ============================================================================== + +# Copy bundled FFmpeg to build output (Windows only — .exe) +if(APPLE) + set(OPENSTUDIO_RUNTIME_ASSET_ROOT "$/Resources") +else() + set(OPENSTUDIO_RUNTIME_ASSET_ROOT "$") +endif() + +function(openstudio_copy_runtime_file source destination_relative_path copy_comment) + if(EXISTS "${source}") + add_custom_command(TARGET OpenStudio POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory + "${OPENSTUDIO_RUNTIME_ASSET_ROOT}" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${source}" + "${OPENSTUDIO_RUNTIME_ASSET_ROOT}/${destination_relative_path}" + COMMENT "${copy_comment}" + ) + endif() +endfunction() + +function(openstudio_copy_runtime_directory source destination_relative_path copy_comment) + if(EXISTS "${source}") + add_custom_command(TARGET OpenStudio POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory + "${OPENSTUDIO_RUNTIME_ASSET_ROOT}" + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${source}" + "${OPENSTUDIO_RUNTIME_ASSET_ROOT}/${destination_relative_path}" + COMMENT "${copy_comment}" + ) + endif() +endfunction() + +if(WIN32) + set(FFMPEG_EXE "${CMAKE_CURRENT_SOURCE_DIR}/tools/ffmpeg.exe") + if(EXISTS "${FFMPEG_EXE}") + openstudio_copy_runtime_file( "${FFMPEG_EXE}" - "$/ffmpeg.exe" - COMMENT "Copying bundled ffmpeg.exe to output directory" + "ffmpeg.exe" + "Copying bundled ffmpeg.exe to output directory" + ) + else() + message(WARNING "tools/ffmpeg.exe not found. Run: powershell -ExecutionPolicy Bypass -File tools/setup-ffmpeg.ps1") + endif() +elseif(APPLE OR UNIX) + # On macOS/Linux, look for ffmpeg in the tools directory (no .exe extension) + set(FFMPEG_BIN "${CMAKE_CURRENT_SOURCE_DIR}/tools/ffmpeg") + if(EXISTS "${FFMPEG_BIN}") + openstudio_copy_runtime_file( + "${FFMPEG_BIN}" + "ffmpeg" + "Copying bundled ffmpeg to output directory" + ) + endif() +endif() + +# Copy bundled JSFX effects to build output +set(EFFECTS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/effects") +if(EXISTS "${EFFECTS_DIR}") + openstudio_copy_runtime_directory( + "${EFFECTS_DIR}" + "effects" + "Copying stock JSFX effects to output directory" + ) +endif() + +# Copy bundled Lua scripts to build output +set(SCRIPTS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/scripts") +if(EXISTS "${SCRIPTS_DIR}") + openstudio_copy_runtime_directory( + "${SCRIPTS_DIR}" + "scripts" + "Copying stock Lua scripts to output directory" + ) +endif() + +# Copy ONNX Runtime DLL + models to output directory +if(HAS_ONNXRUNTIME AND WIN32) + openstudio_copy_runtime_file( + "${ONNXRUNTIME_ROOT}/lib/onnxruntime.dll" + "onnxruntime.dll" + "Copying ONNX Runtime DLL to output directory" + ) +endif() + +# Copy required core ML model(s) to output directory +set(OPENSTUDIO_MODELS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/resources/models") +set(OPENSTUDIO_CORE_MODEL_FILES + "basic_pitch_nmp.onnx" +) + +foreach(OPENSTUDIO_CORE_MODEL_FILE IN LISTS OPENSTUDIO_CORE_MODEL_FILES) + set(OPENSTUDIO_CORE_MODEL_PATH "${OPENSTUDIO_MODELS_DIR}/${OPENSTUDIO_CORE_MODEL_FILE}") + if(EXISTS "${OPENSTUDIO_CORE_MODEL_PATH}") + openstudio_copy_runtime_file( + "${OPENSTUDIO_CORE_MODEL_PATH}" + "models/${OPENSTUDIO_CORE_MODEL_FILE}" + "Copying core ML model ${OPENSTUDIO_CORE_MODEL_FILE} to output directory" + ) + endif() +endforeach() + +if(OPENSTUDIO_BUNDLE_STEM_RUNTIME) + set(OPENSTUDIO_STEM_MODEL_FILES + "BS-Roformer-SW.ckpt" + "BS-Roformer-SW.yaml" + "download_checks.json" + ) + + foreach(OPENSTUDIO_STEM_MODEL_FILE IN LISTS OPENSTUDIO_STEM_MODEL_FILES) + set(OPENSTUDIO_STEM_MODEL_PATH "${OPENSTUDIO_MODELS_DIR}/${OPENSTUDIO_STEM_MODEL_FILE}") + if(EXISTS "${OPENSTUDIO_STEM_MODEL_PATH}") + openstudio_copy_runtime_file( + "${OPENSTUDIO_STEM_MODEL_PATH}" + "models/${OPENSTUDIO_STEM_MODEL_FILE}" + "Copying bundled stem model asset ${OPENSTUDIO_STEM_MODEL_FILE} to output directory" + ) + endif() + endforeach() +endif() + +# Copy stem separator Python script to output directory +set(STEM_SCRIPT "${CMAKE_CURRENT_SOURCE_DIR}/tools/stem_separator.py") +if(EXISTS "${STEM_SCRIPT}") + openstudio_copy_runtime_file( + "${STEM_SCRIPT}" + "scripts/stem_separator.py" + "Copying stem separator script to output directory" + ) +endif() + +set(STEM_INSTALLER_SCRIPT "${CMAKE_CURRENT_SOURCE_DIR}/tools/install_ai_tools.py") +if(EXISTS "${STEM_INSTALLER_SCRIPT}") + openstudio_copy_runtime_file( + "${STEM_INSTALLER_SCRIPT}" + "scripts/install_ai_tools.py" + "Copying AI tools installer script to output directory" + ) +endif() + +# Copy bundled Python runtime only when explicitly requested +set(BUNDLED_PYTHON_DIR "${CMAKE_CURRENT_SOURCE_DIR}/tools/python") +if(OPENSTUDIO_BUNDLE_STEM_RUNTIME AND EXISTS "${BUNDLED_PYTHON_DIR}") + openstudio_copy_runtime_directory( + "${BUNDLED_PYTHON_DIR}" + "python" + "Copying bundled Python runtime to output directory" + ) +endif() + +# Copy packaged frontend assets for release/offline builds +set(FRONTEND_DIST_DIR "${CMAKE_CURRENT_SOURCE_DIR}/frontend/dist") +if(EXISTS "${FRONTEND_DIST_DIR}/index.html") + openstudio_copy_runtime_directory( + "${FRONTEND_DIST_DIR}" + "webui" + "Copying packaged frontend to output directory" ) else() - message(WARNING "tools/ffmpeg.exe not found. Run: powershell -ExecutionPolicy Bypass -File tools/setup-ffmpeg.ps1") + message(WARNING "frontend/dist not found. Run the frontend production build before packaging OpenStudio.") endif() # Prepare for BinaryData (Frontend assets) @@ -133,10 +524,9 @@ endif() # juce_add_binary_data(FrontendAssets # SOURCES # frontend/dist/index.html -# # Add other assets here or point to a directory if JUCE 8 supports it better now, +# # Add other assets here or point to a directory if JUCE 8 supports it better now, # # but typically we list files or use a helper to glob. # # For now, placeholder. # ) # Ensure binary data is linked (generated by juce_add_binary_data) -# Ensure binary data is linked (generated by juce_add_binary_data) # target_link_libraries(Studio13_v2 PRIVATE Studio13_rc_lib) diff --git a/Design screenshots/Actions-options.png b/Design screenshots/Actions-options.png deleted file mode 100644 index 3bca766..0000000 Binary files a/Design screenshots/Actions-options.png and /dev/null differ diff --git a/Design screenshots/Edit-options.png b/Design screenshots/Edit-options.png deleted file mode 100644 index f31dcaa..0000000 Binary files a/Design screenshots/Edit-options.png and /dev/null differ diff --git a/Design screenshots/File-options.png b/Design screenshots/File-options.png deleted file mode 100644 index 3faf537..0000000 Binary files a/Design screenshots/File-options.png and /dev/null differ diff --git a/Design screenshots/Help-options.png b/Design screenshots/Help-options.png deleted file mode 100644 index 8dce94b..0000000 Binary files a/Design screenshots/Help-options.png and /dev/null differ diff --git a/Design screenshots/Insert-options.png b/Design screenshots/Insert-options.png deleted file mode 100644 index 4f3556a..0000000 Binary files a/Design screenshots/Insert-options.png and /dev/null differ diff --git a/Design screenshots/Item-options.png b/Design screenshots/Item-options.png deleted file mode 100644 index 0b5236a..0000000 Binary files a/Design screenshots/Item-options.png and /dev/null differ diff --git a/Design screenshots/Options-options.png b/Design screenshots/Options-options.png deleted file mode 100644 index dcd380e..0000000 Binary files a/Design screenshots/Options-options.png and /dev/null differ diff --git a/Design screenshots/Track-options.png b/Design screenshots/Track-options.png deleted file mode 100644 index c31e7c6..0000000 Binary files a/Design screenshots/Track-options.png and /dev/null differ diff --git a/Design screenshots/View-options.png b/Design screenshots/View-options.png deleted file mode 100644 index 5fcc688..0000000 Binary files a/Design screenshots/View-options.png and /dev/null differ diff --git a/Design screenshots/midi track and clip.png b/Design screenshots/midi track and clip.png deleted file mode 100644 index ce77f0e..0000000 Binary files a/Design screenshots/midi track and clip.png and /dev/null differ diff --git a/Design screenshots/render-export.png b/Design screenshots/render-export.png deleted file mode 100644 index 8fb06fd..0000000 Binary files a/Design screenshots/render-export.png and /dev/null differ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 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 Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are 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. + + 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. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + 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 Affero 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. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + 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 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 work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero 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 Affero 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 Affero 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 Affero 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 Affero 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + 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 AGPL, see +. diff --git a/README.md b/README.md index f45d91b..fa435cc 100644 --- a/README.md +++ b/README.md @@ -1,78 +1,391 @@ -# Studio13 +

+ +

OpenStudio

+

-**Studio13** is a next-generation hybrid Digital Audio Workstation (DAW) that combines the raw performance of **C++ & JUCE** for audio processing with the modern, reactive UI capabilities of **React & Vite**. +

+ A professional-grade DAW built with a JUCE C++ audio engine and a React/TypeScript UI — shipping natively on Windows and macOS. +

-![Studio13 Design](design.png) +

+ Platform: Windows and macOS + JUCE 8 + React + TypeScript + Audio drivers + Plugin formats +

-## 🚀 Key Features +### Project and Preset Formats +- Project files save as `.osproj` and continue opening legacy `.s13` sessions +- Theme exports use `.ostheme` +- Built-in FX presets use `.ospreset` +- Peak cache files use `.ospeaks` -- **Hybrid Architecture**: Logic and DSP in C++, UI in React. -- **Audio & MIDI**: Import audio clips, record, and sequence MIDI. -- **FX Chains**: VST3 plugin support with flexible routing on tracks and master channel. -- **Non-Linear Editing**: Full timeline with clip manipulation, looping, and history (Undo/Redo). -- **Project Management**: Save and load projects (`.s13` format). -- **Smart Workflows**: Single-command development environment. +### Release Packaging +- Windows ships as a standard installer +- macOS ships as a free degraded unsigned DMG for v1 +- Automatic update metadata is served from `https://openstudio.org.in` +- Optional AI stem-separation tools install later from inside the app and are not bundled in the base download -## 🛠️ Tech Stack +--- -- **Backend**: C++20, JUCE Framework (Audio Engine, Plugin Hosting) -- **Frontend**: React, TypeScript, Vite, Tailwind CSS (UI) -- **Bridge**: Custom efficient JSON/Binary bridge between C++ and WebView. -- **Build System**: CMake & Python +## What is OpenStudio? -## 📦 Getting Started +**OpenStudio** is a hybrid DAW with a high-performance C++ audio engine and a fully hardware-accelerated React frontend rendered inside a native desktop application. It combines the audio processing power of JUCE with the flexibility and speed of modern web UI tooling — no Electron, no Chromium overhead, just a native embedded web UI on top of the audio engine. -### Prerequisites + + -Ensure you have the following installed: +--- -- [Python 3.x](https://www.python.org/) -- [Node.js](https://nodejs.org/) -- [CMake](https://cmake.org/) -- C++ Compiler (MSVC on Windows, Clang on macOS) +## Architecture -### Development Workflow +``` +C++ (JUCE) Backend React/TypeScript Frontend +┌─────────────────────────┐ ┌──────────────────────────────┐ +│ AudioEngine │◄─────►│ NativeBridge.ts │ +│ PlaybackEngine │ │ (window.__JUCE__ bridge) │ +│ AudioRecorder │ ├──────────────────────────────┤ +│ TrackProcessor │ │ useDAWStore (Zustand) │ +│ PluginManager │ ├──────────────────────────────┤ +│ MIDIManager │ │ Timeline (Konva canvas) │ +│ Metronome │ │ MixerPanel / ChannelStrip │ +│ PitchAnalyzer │ │ PitchEditorLowerZone │ +│ PitchResynthesizer │ │ PianoRoll / FXChainPanel │ +│ SignalsmithShifter │ │ TransportBar / MenuBar │ +│ PitchCorrector │ │ PluginBrowser / RenderModal │ +│ StemSeparator │ └──────────────────────────────┘ +│ ARAHostController │ +└─────────────────────────┘ +``` -We use a unified build script to manage the hybrid environment. +The **C++ backend** handles all audio I/O, recording, plugin hosting, MIDI device management, metering, pitch correction, and offline rendering. The **React frontend** handles all UI, state, canvas-based timeline, keyboard shortcuts, drag-and-drop, and project save/load. They communicate via a synchronous bridge exposed through `window.__JUCE__`. -**1. Fast Development (Hot Reload)** -Run the full environment with a single command. This starts the Vite dev server and the C++ backend. +--- -```bash -python build.py dev --run +## Flow Charts + +### Audio Signal Chain (Input → Output) + +```mermaid +flowchart LR + subgraph Input + MIC[Microphone / Line In\nASIO · WASAPI · DirectSound] + MIDI[MIDI Devices] + CLIP[Audio Clips on Disk] + end + + subgraph Per-Track Processing + direction TB + REC[AudioRecorder\nThreadedWriter] + PB[PlaybackEngine\nSample-rate-aware mixing] + IFX[Input FX Chain\nVST3 · CLAP · LV2 · JSFX] + TP[TrackProcessor\nVolume · Pan · Meter] + TFX[Track FX Chain\nVST3 · CLAP · LV2 · JSFX] + PITCH[PitchCorrector\nReal-time auto-tune] + end + + subgraph Master + MFX[Master FX Chain] + MIX[Master Mix] + end + + subgraph Output + DAC[Audio Output\nDAC / Speakers] + RENDER[Offline Render\nWAV · AIFF · FLAC] + end + + MIC --> REC --> IFX + CLIP --> PB --> IFX + MIDI --> TP + IFX --> PITCH --> TP --> TFX --> MFX --> MIX + MIX --> DAC + MIX --> RENDER ``` -- **UI changes** apply instantly (HMR). -- **Backend changes** require a restart of the script. +--- -**2. Manual Setup** -If you prefer separate terminals: +### Pitch Correction Pipeline -```bash -# Terminal 1: Start Frontend -cd frontend -npm run dev +```mermaid +flowchart TD + AUDIO[Original Clip Audio] + + subgraph Monophonic Path + direction TB + YIN[PitchAnalyzer\nYIN Detection · Note Segmentation] + NOTES[PitchNotes\npitch · formant · vibrato · drift per note] + RSYNTH[PitchResynthesizer\nbuildCorrectionCurve · buildFormantCurve] + SS[SignalsmithShifter\nper-block setTransposeFactor · setFormantFactor] + XF[Crossfade Splice\n512-sample overlap-add] + end + + subgraph Polyphonic Path + direction TB + POLY[PolyPitchDetector\nBasic-Pitch ONNX · 22050 Hz · 256 hop] + MASKS[HarmonicMaskGenerator\nWiener soft masks per note] + SPECSHIFT[SpectralPitchShifter\nper-note masked STFT shift] + end + + subgraph Real-time Path + RTFX[PitchCorrector\nper-block · key/scale-aware\nSignalsmithStretch presetCheaper] + end + + AUDIO --> YIN --> NOTES --> RSYNTH --> SS --> XF --> OUT[Corrected Audio File\nreplaceClipAudioFile] + AUDIO --> POLY --> MASKS --> SPECSHIFT --> OUT + AUDIO --> RTFX --> SPEAKERS[Live Output] +``` + +--- -# Terminal 2: Run Backend -./build/Studio13_v2_artefacts/Debug/Studio13.exe +### Frontend Component Tree + +```mermaid +flowchart TD + APP[App.tsx] + + APP --> MB[MenuBar] + APP --> MT[MainToolbar\ntransport · snap · undo/redo] + APP --> WS[Workspace] + APP --> TB[TransportBar\nBPM · timecode · loop · metronome] + APP --> LZ[LowerZone] + APP --> MOD[Modals] + + WS --> TCP[Track Control Panel\nTrackHeader · SortableTrackHeader] + WS --> TL[Timeline\nKonva canvas · waveforms · clips · playhead] + + LZ --> PE[PitchEditorLowerZone\nPitchEditorCanvas · tools · viewport] + LZ --> CP[ClipPropertiesPanel] + LZ --> PR[PianoRoll\nKonva canvas] + + MOD --> SM[SettingsModal\naudio device config] + MOD --> RM[RenderModal\nexport settings] + MOD --> FX[FXChainPanel\nplugin browser · input FX · track FX] + MOD --> MX[MixerPanel\nChannelStrip · faders · meters] + MOD --> CL[ClipLauncherView\nAbleton-style grid] + MOD --> CMD[CommandPalette\nCtrl+Shift+P] + + subgraph State + STORE[useDAWStore\nZustand · ~3400 lines] + PESTORE[pitchEditorStore\nnotes · viewport · undo] + NB[NativeBridge.ts\nwindow.__JUCE__ calls] + end + + APP -.-> STORE + PE -.-> PESTORE + STORE -.-> NB + NB -.->|bridge| CPP[C++ AudioEngine] ``` -### Production Build +--- + +### JS ↔ C++ Bridge Data Flow + +```mermaid +sequenceDiagram + participant UI as React UI + participant NB as NativeBridge.ts + participant MC as MainComponent.cpp + participant AE as AudioEngine + + UI->>NB: addTrack(id, name, type) + NB->>MC: window.__JUCE__.backend.addTrack(json) + MC->>AE: audioEngine.addTrack(id, type) + AE-->>MC: ok + MC-->>NB: return "ok" + NB-->>UI: Promise resolved + + UI->>NB: applyPitchCorrection(trackId, clipId, notes) + NB->>MC: backend.applyPitchCorrection(json) + MC->>AE: applyPitchCorrection(...) + AE->>AE: PitchResynthesizer → SignalsmithShifter + AE->>AE: replaceClipAudioFile(newPath) + AE-->>MC: newFilePath + MC-->>UI: newFilePath (React re-renders waveform) + + loop 60fps playback + AE->>MC: meterLevels via event + MC->>UI: dispatchEvent("meterUpdate") + UI->>UI: setMeterLevels() — isolated store slice + end +``` + +--- + +## Features + +### Audio Engine +- **ASIO / WASAPI / DirectSound** driver support +- Multi-track audio recording with thread-safe `ThreadedWriter` +- Sample-rate-aware clip playback with linear interpolation +- Per-track **Input FX chain** + **Track FX chain** + **Master FX chain** +- REAPER-inspired **multi-resolution peak cache** (`.ospeaks` sidecar files, with legacy `.s13peaks` support) +- Offline render/export to WAV, AIFF, FLAC at 16/24/32-bit + +### Plugin Hosting +- **VST3**, **CLAP**, and **LV2** plugin formats +- Native plugin editor window management +- ARA plugin hosting (e.g. Melodyne, RePitch) +- JSFX / Lua script-based processors with `@gfx` rendering +- Built-in effects: EQ, compressor, stereo tool, delay, reverb, and more -Create a standalone executable with embedded UI assets. +### Pitch Editor +- **Graphical pitch editor** (Melodyne/VariAudio-style) — analyze, display, and redraw pitch curves per note +- **Real-time auto-tune** corrector (built-in pitch corrector FX) — key/scale-aware, inserted as an FX plugin +- **Signalsmith Stretch** (MIT) as the default pitch engine — native stereo, formant-preserving, offline quality +- **Polyphonic pitch detection** via Spotify's Basic-Pitch ONNX model +- Formant shift, vibrato, drift, and transition controls per note + +### MIDI +- MIDI device enumeration and input routing +- Piano Roll editor with Konva canvas +- Virtual 88-key on-screen keyboard +- Per-track MIDI clip storage with time-range queries +- Metronome with BPM, time signature, and accent patterns + +### Timeline & UI +- Konva canvas-based timeline: waveforms, clips, rulers, zoom, drag, multi-selection +- Mixer panel with channel strips, faders, pan, solo/mute, metering +- AI stem separation (vocals / drums / bass / other) via optional in-app AI Tools install +- Automation lanes with envelope editing +- Clip launcher (Ableton-style grid) +- Full undo/redo via CommandManager for all data-modifying actions +- Command palette (`Ctrl+Shift+P`), keyboard shortcuts modal, action registry +- REAPER theme import/export (`.ReaperTheme` → `.ostheme`, with legacy `.s13theme` import support) + +--- + +## Built-In Effects + +| Effect | Description | +|--------|-------------| +| EQ | Per-channel graphical equalizer | +| Compressor | Dynamic range compression | +| Reverb | Algorithmic reverb | +| Delay | Stereo delay with feedback and filtering | +| Stereo Tool | Volume, pan, stereo width | +| Crusher | Bit-depth and sample-rate reduction | +| Pitch Corrector | Real-time key/scale-aware auto-tune | +| JSFX Processor | REAPER-compatible JSFX / Lua scripts | + +--- + +## Build & Run + +### Requirements + +- **Windows 10/11** (x64) for the full local toolchain documented below +- **macOS** release packaging is supported in CI/release tooling, with unsigned DMG distribution for v1 +- [CMake](https://cmake.org/) ≥ 3.22 +- [Visual Studio 2022](https://visualstudio.microsoft.com/) with C++ workload +- [Node.js](https://nodejs.org/) ≥ 18 +- [Python](https://python.org/) ≥ 3.10 +- [NASM](https://nasm.us/) (required for YSFX) — add to `PATH` + +### Quick Start + +```bash +# Clone the repo +git clone https://github.com/your-org/studio13-v3.git && cd studio13-v3 + +# Full dev build: installs deps, builds C++ Debug, starts Vite HMR, launches app +python build.py dev --run +``` + +### Partial Rebuilds ```bash +# Frontend only — no C++ rebuild needed for UI changes +cd frontend && npm run dev + +# C++ only — Debug +cmake --build build --config Debug + +# C++ only — Release +cmake --build build --config Release + +# Production — embedded frontend + Release C++ → single .exe python build.py prod ``` -The output executable will be located in `build/Studio13_v2_artefacts/Release/`. +--- + +## Tech Stack + +### C++ Backend + +| Library | Purpose | +|---------|---------| +| [JUCE 8](https://juce.com/) | Audio engine, plugin hosting, WebBrowserComponent | +| [Signalsmith Stretch](https://github.com/Signalsmith-Audio/signalsmith-stretch) | MIT — pitch shifting with formant preservation | +| [RubberBand](https://breakfastquay.com/rubberband/) | R3 engine, alternative pitch shifter | +| [YSFX](https://github.com/jpcima/ysfx) | JSFX / Lua script processor | +| [ONNX Runtime](https://onnxruntime.ai/) | Basic-Pitch polyphonic pitch detection | +| [sol2](https://github.com/ThePhD/sol2) | Lua scripting engine bindings | +| [WebView2](https://developer.microsoft.com/en-us/microsoft-edge/webview2/) | Embedded browser for the Windows React UI | +| ASIO SDK | Low-latency audio I/O | + +### React Frontend + +| Library | Purpose | +|---------|---------| +| [React 18](https://react.dev/) + TypeScript | UI framework | +| [Zustand](https://github.com/pmndrs/zustand) | Global state management | +| [react-konva](https://konvajs.org/) | Canvas-based timeline and piano roll | +| [Tailwind CSS v4](https://tailwindcss.com/) | Styling with custom `daw-*` color tokens | +| [@dnd-kit](https://dndkit.com/) | Drag-and-drop track reordering | +| [Vite](https://vitejs.dev/) | Dev server + production bundler | + +--- + +## Project Structure + +``` +repo-root/ +├── Source/ # C++ audio engine, plugin hosting, pitch pipeline +├── frontend/ # React/TypeScript UI (Vite, Tailwind, Zustand, Konva) +├── resources/ # ONNX models, presets, icons +├── tools/ # ffmpeg.exe, stem separator, setup scripts +├── build/ # CMake output (generated) +├── CMakeLists.txt # C++ build config (JUCE, ASIO, WebView2, VST3, ONNX) +└── build.py # Orchestrator: cmake + npm + Vite dev server +``` + +--- + +## Roadmap + +### In Progress +- [ ] Graphical pitch editor — Melodyne/VariAudio parity (vibrato tool, multi-note operations) +- [ ] Polyphonic pitch correction rewrite with Signalsmith Stretch +- [ ] ARA2 deep integration + +### Planned +- [ ] MIDI 2.0 support +- [ ] Score / notation view +- [ ] Linux release path +- [ ] Plugin sandboxing + +### Completed +- [x] VST3 / CLAP / LV2 plugin hosting +- [x] ARA plugin hosting controller +- [x] Signalsmith Stretch pitch engine (formant-preserving, native stereo) +- [x] Real-time auto-tune pitch corrector +- [x] Polyphonic pitch detection (Basic-Pitch ONNX) +- [x] REAPER-style multi-resolution peak cache +- [x] Optional AI stem separation install flow +- [x] JSFX / Lua `@gfx` rendering +- [x] REAPER theme import/export +- [x] Offline render (WAV / AIFF / FLAC, 16/24/32-bit) +- [x] Clip launcher (Ableton-style grid) +- [x] Automation lanes +- [x] Full undo/redo for all data-modifying actions +- [x] Command palette -## 🏗️ Architecture Note +--- -Studio13 differs from traditional DAWs by offloading the entire UI to a system WebView. This allows for rapid UI iteration and modern design patterns while keeping the audio thread 100% native for low-latency performance. +## License -| Feature | Studio13 | Traditional DAW | -| ------------- | ------------------- | ---------------------- | -| **UI** | HTML/CSS/JS (React) | Native (JuCE/Qt/Win32) | -| **Audio** | Native C++ | Native C++ | -| **Dev Speed** | ⚡ Fast (Web Tech) | 🐢 Slow (Recompile) | +OpenStudio is currently distributed in this repository under AGPLv3-compatible terms. See `LICENSE` and `THIRD_PARTY_LICENSES.md` for the current licensing and dependency notices. diff --git a/Source/ARADebug.h b/Source/ARADebug.h new file mode 100644 index 0000000..3fb97be --- /dev/null +++ b/Source/ARADebug.h @@ -0,0 +1,17 @@ +#pragma once + +#include + +inline constexpr bool kEnableARADebugDiagnostics = true; + +inline void logARADebugLine(const juce::String& msg) +{ + if (!kEnableARADebugDiagnostics) + return; + + auto logFile = juce::File::getSpecialLocation(juce::File::userDocumentsDirectory) + .getChildFile("OpenStudio").getChildFile("debug_log.txt"); + logFile.getParentDirectory().createDirectory(); + logFile.appendText(juce::Time::getCurrentTime().toString(true, true) + + ": " + msg + "\n"); +} diff --git a/Source/ARAHostController.cpp b/Source/ARAHostController.cpp new file mode 100644 index 0000000..0615a69 --- /dev/null +++ b/Source/ARAHostController.cpp @@ -0,0 +1,1115 @@ +#include "ARAHostController.h" + +ARAHostController::ARAHostController() = default; + +ARAHostController::~ARAHostController() +{ + shutdown(); +} + +#if S13_HAS_ARA + +namespace +{ +juce::String araContentTypeToString(ARA::ARAContentType type) +{ + switch (type) + { + case ARA::kARAContentTypeNotes: return "Notes"; + case ARA::kARAContentTypeTempoEntries: return "TempoEntries"; + case ARA::kARAContentTypeBarSignatures: return "BarSignatures"; + case ARA::kARAContentTypeStaticTuning: return "StaticTuning"; + case ARA::kARAContentTypeKeySignatures: return "KeySignatures"; + case ARA::kARAContentTypeSheetChords: return "SheetChords"; + default: return "Type" + juce::String(static_cast(type)); + } +} +} + +// ============================================================================= +// ARA Host Interface Implementations +// ============================================================================= + +ARA::ARAAudioReaderHostRef +ARAHostController::S13AudioAccessController::createAudioReaderForSource ( + ARA::ARAAudioSourceHostRef audioSourceHostRef, + bool use64BitSamples) noexcept +{ + auto reader = std::make_unique(); + reader->sourceHostRef = audioSourceHostRef; + reader->use64Bit = use64BitSamples; + auto ref = ReaderConverter::toHostRef (reader.get()); + auto* ptr = reader.get(); + audioReaders.emplace (ptr, std::move (reader)); + + logARADebugLine("ARA: createAudioReaderForSource called (use64Bit=" + + juce::String(use64BitSamples ? 1 : 0) + ")"); + + return ref; +} + +bool ARAHostController::S13AudioAccessController::readAudioSamples ( + ARA::ARAAudioReaderHostRef readerRef, + ARA::ARASamplePosition samplePosition, + ARA::ARASampleCount samplesPerChannel, + void* const* buffers) noexcept +{ + auto* readerEntry = ReaderConverter::fromHostRef (readerRef); + if (! readerEntry) return false; + + using SourceConverter = ARAHostModel::ConversionFunctions; + auto* sourceEntry = SourceConverter::fromHostRef (readerEntry->sourceHostRef); + if (! sourceEntry || ! sourceEntry->reader) return false; + + static int araReadCount = 0; + if (araReadCount < 5) + { + logARADebugLine("ARA readAudioSamples: pos=" + juce::String (samplePosition) + + " count=" + juce::String (samplesPerChannel) + + " ch=" + juce::String (sourceEntry->reader->numChannels) + + " len=" + juce::String (sourceEntry->reader->lengthInSamples) + + " sr=" + juce::String (sourceEntry->reader->sampleRate)); + ++araReadCount; + } + + if (readerEntry->use64Bit) + return false; + + int nativeChannels = sourceEntry->nativeChannelCount > 0 + ? sourceEntry->nativeChannelCount + : static_cast (sourceEntry->reader->numChannels); + int exposedChannels = sourceEntry->exposedChannelCount > 0 + ? sourceEntry->exposedChannelCount + : nativeChannels; + int numSamples = static_cast (samplesPerChannel); + + // Fast path: serve from pre-loaded memory buffer (no disk I/O, no heap alloc) + if (sourceEntry->preloaded) + { + const auto& audio = sourceEntry->preloadedAudio; + int srcLen = audio.getNumSamples(); + int srcCh = audio.getNumChannels(); + + // Clamp read range to available data + int startSample = static_cast (samplePosition); + int available = (startSample >= 0 && startSample < srcLen) + ? juce::jmin (numSamples, srcLen - startSample) : 0; + + if (exposedChannels == 1) + { + auto* dest = static_cast (buffers[0]); + if (srcCh <= 1) + { + if (available > 0) + std::memcpy (dest, audio.getReadPointer (0) + startSample, + static_cast (available) * sizeof (float)); + if (available < numSamples) + std::memset (dest + available, 0, + static_cast (numSamples - available) * sizeof (float)); + } + else + { + const float invCh = 1.0f / static_cast (srcCh); + for (int s = 0; s < available; ++s) + { + float mono = 0.0f; + for (int ch = 0; ch < srcCh; ++ch) + mono += audio.getSample (ch, startSample + s); + dest[s] = mono * invCh; + } + if (available < numSamples) + std::memset (dest + available, 0, + static_cast (numSamples - available) * sizeof (float)); + } + } + else + { + for (int ch = 0; ch < juce::jmin (srcCh, exposedChannels); ++ch) + { + auto* dest = static_cast (buffers[ch]); + if (available > 0) + std::memcpy (dest, audio.getReadPointer (ch) + startSample, + static_cast (available) * sizeof (float)); + if (available < numSamples) + std::memset (dest + available, 0, + static_cast (numSamples - available) * sizeof (float)); + } + } + + return true; + } + + // Fallback: disk-backed read (only used if pre-load failed) + juce::AudioBuffer tempBuffer (nativeChannels, numSamples); + sourceEntry->reader->read (&tempBuffer, 0, numSamples, + samplePosition, true, nativeChannels > 1); + + if (exposedChannels == 1) + { + auto* dest = static_cast (buffers[0]); + if (nativeChannels <= 1) + { + std::memcpy (dest, tempBuffer.getReadPointer (0), + static_cast (numSamples) * sizeof (float)); + } + else + { + const float invChannelCount = 1.0f / static_cast (nativeChannels); + for (int sample = 0; sample < numSamples; ++sample) + { + float monoSample = 0.0f; + for (int ch = 0; ch < nativeChannels; ++ch) + monoSample += tempBuffer.getSample (ch, sample); + dest[sample] = monoSample * invChannelCount; + } + } + } + else + { + for (int ch = 0; ch < juce::jmin (nativeChannels, exposedChannels); ++ch) + { + auto* dest = static_cast (buffers[ch]); + std::memcpy (dest, tempBuffer.getReadPointer (ch), + static_cast (numSamples) * sizeof (float)); + } + } + + return true; +} + +void ARAHostController::S13AudioAccessController::destroyAudioReader ( + ARA::ARAAudioReaderHostRef readerRef) noexcept +{ + audioReaders.erase (ReaderConverter::fromHostRef (readerRef)); +} + +// Archiving Controller +ARA::ARASize ARAHostController::S13ArchivingController::getArchiveSize ( + ARA::ARAArchiveReaderHostRef archiveReaderHostRef) noexcept +{ + return static_cast (ReaderConverter::fromHostRef (archiveReaderHostRef)->getSize()); +} + +bool ARAHostController::S13ArchivingController::readBytesFromArchive ( + ARA::ARAArchiveReaderHostRef archiveReaderHostRef, + ARA::ARASize position, ARA::ARASize length, + ARA::ARAByte* buffer) noexcept +{ + auto* block = ReaderConverter::fromHostRef (archiveReaderHostRef); + if (position + length <= block->getSize()) + { + std::memcpy (buffer, juce::addBytesToPointer (block->getData(), position), length); + return true; + } + return false; +} + +bool ARAHostController::S13ArchivingController::writeBytesToArchive ( + ARA::ARAArchiveWriterHostRef archiveWriterHostRef, + ARA::ARASize position, ARA::ARASize length, + const ARA::ARAByte* buffer) noexcept +{ + auto* stream = WriterConverter::fromHostRef (archiveWriterHostRef); + if (stream->setPosition (static_cast (position)) && stream->write (buffer, length)) + return true; + return false; +} + +void ARAHostController::S13ArchivingController::notifyDocumentArchivingProgress (float) noexcept {} +void ARAHostController::S13ArchivingController::notifyDocumentUnarchivingProgress (float) noexcept {} + +ARA::ARAPersistentID ARAHostController::S13ArchivingController::getDocumentArchiveID ( + ARA::ARAArchiveReaderHostRef) noexcept +{ + return "studio13-ara-archive-v1"; +} + +// Content Access Controller +bool ARAHostController::S13ContentAccessController::isMusicalContextContentAvailable ( + ARA::ARAMusicalContextHostRef, ARA::ARAContentType type) noexcept +{ + return type == ARA::kARAContentTypeTempoEntries || type == ARA::kARAContentTypeBarSignatures; +} + +ARA::ARAContentGrade ARAHostController::S13ContentAccessController::getMusicalContextContentGrade ( + ARA::ARAMusicalContextHostRef, ARA::ARAContentType) noexcept +{ + return ARA::kARAContentGradeInitial; +} + +ARA::ARAContentReaderHostRef +ARAHostController::S13ContentAccessController::createMusicalContextContentReader ( + ARA::ARAMusicalContextHostRef, ARA::ARAContentType type, + const ARA::ARAContentTimeRange*) noexcept +{ + return Converter::toHostRef (type); +} + +bool ARAHostController::S13ContentAccessController::isAudioSourceContentAvailable ( + ARA::ARAAudioSourceHostRef, ARA::ARAContentType) noexcept +{ + return false; +} + +ARA::ARAContentGrade ARAHostController::S13ContentAccessController::getAudioSourceContentGrade ( + ARA::ARAAudioSourceHostRef, ARA::ARAContentType) noexcept +{ + return ARA::kARAContentGradeInitial; +} + +ARA::ARAContentReaderHostRef +ARAHostController::S13ContentAccessController::createAudioSourceContentReader ( + ARA::ARAAudioSourceHostRef, ARA::ARAContentType, + const ARA::ARAContentTimeRange*) noexcept +{ + return nullptr; +} + +ARA::ARAInt32 ARAHostController::S13ContentAccessController::getContentReaderEventCount ( + ARA::ARAContentReaderHostRef contentReaderHostRef) noexcept +{ + auto contentType = Converter::fromHostRef (contentReaderHostRef); + if (contentType == ARA::kARAContentTypeTempoEntries || contentType == ARA::kARAContentTypeBarSignatures) + return 2; + return 0; +} + +const void* ARAHostController::S13ContentAccessController::getContentReaderDataForEvent ( + ARA::ARAContentReaderHostRef contentReaderHostRef, + ARA::ARAInt32 eventIndex) noexcept +{ + if (Converter::fromHostRef (contentReaderHostRef) == ARA::kARAContentTypeTempoEntries) + { + if (eventIndex == 0) + { + tempoEntry.timePosition = 0.0; + tempoEntry.quarterPosition = 0.0; + } + else if (eventIndex == 1) + { + tempoEntry.timePosition = 60.0 / bpm; // One beat duration + tempoEntry.quarterPosition = 1.0; + } + return &tempoEntry; + } + else if (Converter::fromHostRef (contentReaderHostRef) == ARA::kARAContentTypeBarSignatures) + { + barSignature.position = (eventIndex == 0) ? 0.0 : static_cast (timeSigNum); + barSignature.numerator = timeSigNum; + barSignature.denominator = timeSigDenom; + return &barSignature; + } + + return nullptr; +} + +void ARAHostController::S13ContentAccessController::destroyContentReader ( + ARA::ARAContentReaderHostRef) noexcept {} + +// Model Update Controller +void ARAHostController::S13ModelUpdateController::notifyAudioSourceAnalysisProgress ( + ARA::ARAAudioSourceHostRef, ARA::ARAAnalysisProgressState state, float value) noexcept +{ + analysisProgress.store (value); + owner.debugAnalysisStarted.store(true, std::memory_order_release); + owner.debugLastAnalysisProgressValue.store(value, std::memory_order_release); + + const int bucket = static_cast(value * 20.0f); + const int previousBucket = lastLoggedAnalysisBucket.exchange(bucket); + if (bucket != previousBucket || state == ARA::kARAAnalysisProgressCompleted) + { + owner.noteDebugOperation("analysis_progress_state_" + juce::String(static_cast(state)), {}); + logARADebugLine(juce::String("ARA model update: type=analysis_progress") + + " state=" + juce::String(static_cast(state)) + + " value=" + juce::String(value, 3)); + } + + if (state == ARA::kARAAnalysisProgressCompleted) + analysisComplete.store (true); +} + +void ARAHostController::S13ModelUpdateController::notifyAudioSourceContentChanged ( + ARA::ARAAudioSourceHostRef audioSourceHostRef, const ARA::ARAContentTimeRange*, ARA::ContentUpdateScopes) noexcept +{ + using SrcConverter = ARAHostModel::ConversionFunctions; + auto* entry = SrcConverter::fromHostRef(audioSourceHostRef); + owner.noteEditDrivenDebugEvent("notifyAudioSourceContentChanged", entry != nullptr ? entry->clipId : juce::String()); +} +void ARAHostController::S13ModelUpdateController::notifyAudioModificationContentChanged ( + ARA::ARAAudioModificationHostRef audioModificationHostRef, const ARA::ARAContentTimeRange*, ARA::ContentUpdateScopes) noexcept +{ + using ModConverter = ARAHostModel::ConversionFunctions; + auto* entry = ModConverter::fromHostRef(audioModificationHostRef); + owner.noteEditDrivenDebugEvent("notifyAudioModificationContentChanged", entry != nullptr ? entry->clipId : juce::String()); +} +void ARAHostController::S13ModelUpdateController::notifyPlaybackRegionContentChanged ( + ARA::ARAPlaybackRegionHostRef playbackRegionHostRef, const ARA::ARAContentTimeRange*, ARA::ContentUpdateScopes) noexcept +{ + using RegConverter = ARAHostModel::ConversionFunctions; + auto* entry = RegConverter::fromHostRef(playbackRegionHostRef); + owner.noteEditDrivenDebugEvent("notifyPlaybackRegionContentChanged", entry != nullptr ? entry->clipId : juce::String()); +} +void ARAHostController::S13ModelUpdateController::notifyDocumentDataChanged() noexcept +{ + owner.noteEditDrivenDebugEvent("notifyDocumentDataChanged", {}); +} + +// Playback Controller +void ARAHostController::S13PlaybackController::requestStartPlayback() noexcept +{ + owner.requestStartPlaybackFromPlugin(); +} + +void ARAHostController::S13PlaybackController::requestStopPlayback() noexcept +{ + owner.requestStopPlaybackFromPlugin(); +} + +void ARAHostController::S13PlaybackController::requestSetPlaybackPosition (ARA::ARATimePosition timePosition) noexcept +{ + owner.requestSetPlaybackPositionFromPlugin(static_cast (timePosition)); +} + +void ARAHostController::S13PlaybackController::requestSetCycleRange (ARA::ARATimePosition startTime, + ARA::ARATimeDuration duration) noexcept +{ + owner.requestSetCycleRangeFromPlugin(static_cast (startTime), + static_cast (duration)); +} + +void ARAHostController::S13PlaybackController::requestEnableCycle (bool enable) noexcept +{ + owner.requestEnableCycleFromPlugin(enable); +} + +// ============================================================================= +// Main ARA Host Controller Implementation +// ============================================================================= + +void ARAHostController::initializeForPlugin (juce::AudioPluginInstance* plugin, + double sampleRate, int blockSize, + std::function onComplete) +{ + if (! plugin) + { + if (onComplete) onComplete (false, false, "Invalid plugin instance."); + return; + } + + shutdown(); // Clean up any previous state + + araProcessor = plugin; + currentSampleRate = sampleRate; + currentBlockSize = blockSize; + pluginInputChannelCount = juce::jmax (1, + juce::jmax (plugin->getMainBusNumInputChannels(), plugin->getTotalNumInputChannels())); + + // Use JUCE's async factory creation to get ARA factory from plugin + juce::createARAFactoryAsync (*plugin, [this, onComplete] (juce::ARAFactoryWrapper factory) { + completeInitialization (std::move (factory), onComplete); + }); +} + +void ARAHostController::completeInitialization (juce::ARAFactoryWrapper factory, + std::function onComplete) +{ + if (! factory.get()) + { + juce::Logger::writeToLog ("ARAHostController: Plugin does not support ARA."); + if (onComplete) onComplete (false, false, {}); + return; + } + + try + { + // Create the host-side controllers (we keep raw pointers before moving ownership) + auto audioAccess = std::make_unique(); + auto archiving = std::make_unique(); + auto contentAccess = std::make_unique(); + auto modelUpdate = std::make_unique(*this); + auto playbackCtrl = std::make_unique(*this); + requestedAnalysisContentTypes.clear(); + + const auto* araFactory = factory.get(); + if (araFactory != nullptr && araFactory->analyzeableContentTypesCount > 0 && araFactory->analyzeableContentTypes != nullptr) + { + requestedAnalysisContentTypes.assign(araFactory->analyzeableContentTypes, + araFactory->analyzeableContentTypes + araFactory->analyzeableContentTypesCount); + logARADebugLine("ARA: plugin analyzeable content types=" + describeRequestedAnalysisContentTypes()); + } + else + { + logARADebugLine("ARA: plugin reports no analyzeable content types"); + } + + audioAccessControllerPtr = audioAccess.get(); + archivingControllerPtr = archiving.get(); + contentAccessControllerPtr = contentAccess.get(); + modelUpdateControllerPtr = modelUpdate.get(); + playbackControllerPtr = playbackCtrl.get(); + + // Create the JUCE ARA document controller (takes ownership of controllers) + araDocController = juce::ARAHostDocumentController::create ( + std::move (factory), + "OpenStudio Document", + std::move (audioAccess), + std::move (archiving), + std::move (contentAccess), + std::move (modelUpdate), + std::move (playbackCtrl)); + + if (! araDocController) + { + juce::Logger::writeToLog ("ARAHostController: Failed to create ARA document controller."); + if (onComplete) onComplete (false, true, "Failed to create ARA document controller."); + return; + } + + auto& dc = araDocController->getDocumentController(); + + // Create musical context + { + juce::ARAEditGuard editGuard (dc); + + auto mcProps = ARAHostModel::MusicalContext::getEmptyProperties(); + mcProps.name = "OpenStudio Musical Context"; + mcProps.orderIndex = 0; + mcProps.color = nullptr; + + using MCConverter = ARAHostModel::ConversionFunctions; + musicalContext = std::make_unique ( + MCConverter::toHostRef (this), dc, mcProps); + + // Create region sequence (represents the track) + auto rsProps = ARAHostModel::RegionSequence::getEmptyProperties(); + rsProps.name = "Track"; + rsProps.orderIndex = 0; + rsProps.musicalContextRef = musicalContext->getPluginRef(); + rsProps.color = nullptr; + + using RSConverter = ARAHostModel::ConversionFunctions; + regionSequence = std::make_unique ( + RSConverter::toHostRef (this), dc, rsProps); + } + + // Bind the document to the plugin instance with all ARA roles assigned. + // RePitch and similar ARA editors need the editor renderer + editor view + // roles, not just playback rendering, otherwise the editor can open with + // an empty document even though clip attach succeeded. + const auto allRoles = ARA::kARAPlaybackRendererRole + | ARA::kARAEditorRendererRole + | ARA::kARAEditorViewRole; + + pluginExtension = araDocController->bindDocumentToPluginInstance ( + *araProcessor, + allRoles, + allRoles); + + logARADebugLine("ARA binding: valid=" + juce::String (pluginExtension.isValid() ? 1 : 0)); + + if (pluginExtension.isValid()) + { + playbackRenderer = std::make_unique ( + pluginExtension.getPlaybackRendererInterface()); + editorRenderer = std::make_unique ( + pluginExtension.getEditorRendererInterface()); + + logARADebugLine("ARA interfaces: playbackValid=" + + juce::String (playbackRenderer != nullptr && playbackRenderer->isValid() ? 1 : 0) + + " editorValid=" + + juce::String (editorRenderer != nullptr && editorRenderer->isValid() ? 1 : 0)); + } + + // NOTE: Do NOT override the plugin's playhead here. The host (AudioEngine) + // sets the playhead via plugin->setPlayHead(this) before ARA init, providing + // full position info (BPM, PPQ, time sig, isPlaying, etc.) that ARA plugins + // need. Overriding it with a local SimplePlayHead that never gets updated + // causes the ARA renderer to always see pos=0/not-playing → empty editor. + + araActive = true; + juce::Logger::writeToLog ("ARAHostController: Initialized successfully for ARA plugin."); + if (onComplete) onComplete (true, true, {}); + } + catch (const std::exception& e) + { + juce::Logger::writeToLog ("ARAHostController: Exception during init: " + juce::String (e.what())); + if (onComplete) onComplete (false, true, juce::String (e.what())); + } +} + +juce::String ARAHostController::addAudioSource (const juce::File& audioFile, + const juce::String& clipId, + double startTimeInTrack, + double duration, + double offsetInSource) +{ + if (! araActive || ! araDocController) return {}; + + // Remove existing source with same clipId + removeAudioSource (clipId); + + auto entry = std::make_unique(); + entry->clipId = clipId; + entry->audioFile = audioFile; + + // Create audio format reader for the file + juce::AudioFormatManager fmtMgr; + fmtMgr.registerBasicFormats(); + entry->reader.reset (fmtMgr.createReaderFor (audioFile)); + if (! entry->reader) + { + juce::Logger::writeToLog ("ARAHostController: Failed to create reader for: " + audioFile.getFullPathName()); + return {}; + } + entry->nativeChannelCount = static_cast (entry->reader->numChannels); + entry->exposedChannelCount = (pluginInputChannelCount <= 1) + ? 1 + : juce::jmax (1, entry->nativeChannelCount); + + // Pre-load entire file into memory so readAudioSamples() can serve from + // RAM instead of hitting the disk on the audio thread. + { + int fileCh = entry->nativeChannelCount; + auto fileLen = static_cast (entry->reader->lengthInSamples); + entry->preloadedAudio.setSize (fileCh, fileLen); + entry->reader->read (&entry->preloadedAudio, 0, fileLen, 0, true, fileCh > 1); + entry->preloaded = true; + juce::Logger::writeToLog ("ARAHostController: pre-loaded " + juce::String (fileLen) + + " samples x " + juce::String (fileCh) + " channels (" + + juce::String (fileLen * fileCh * sizeof(float) / (1024 * 1024)) + " MB)"); + } + + entry->startTimeInTrack = startTimeInTrack; + entry->duration = duration; + entry->offsetInSource = offsetInSource; + + auto& dc = araDocController->getDocumentController(); + + auto araLog = [] (const juce::String& msg) { logARADebugLine(msg); }; + + try + { + // ===================================================================== + // Step 1: Create all ARA model objects inside edit guard + // ===================================================================== + { + juce::ARAEditGuard editGuard (dc); + + // Create audio source + auto srcProps = ARAHostModel::AudioSource::getEmptyProperties(); + srcProps.name = audioFile.getFullPathName().toRawUTF8(); + srcProps.persistentID = clipId.toRawUTF8(); + srcProps.sampleCount = entry->reader->lengthInSamples; + srcProps.sampleRate = entry->reader->sampleRate; + srcProps.channelCount = entry->exposedChannelCount; + srcProps.merits64BitSamples = false; + + using SrcConverter = ARAHostModel::ConversionFunctions; + entry->audioSource = std::make_unique ( + SrcConverter::toHostRef (entry.get()), dc, srcProps); + + // NOTE: Do NOT call enableAudioSourceSamplesAccess() here inside + // the edit guard — the ARA spec forbids it within editing brackets. + // It is called after the edit guard closes (see Step 1b below). + + // Create audio modification + auto modProps = ARAHostModel::AudioModification::getEmptyProperties(); + modProps.persistentID = (clipId + "_mod").toRawUTF8(); + + using ModConverter = ARAHostModel::ConversionFunctions; + entry->audioModification = std::make_unique ( + ModConverter::toHostRef (entry.get()), dc, + *entry->audioSource, modProps); + + // Create playback region + auto regionProps = ARAHostModel::PlaybackRegion::getEmptyProperties(); + regionProps.transformationFlags = ARA::kARAPlaybackTransformationNoChanges; + regionProps.startInModificationTime = offsetInSource; + regionProps.durationInModificationTime = duration; + regionProps.startInPlaybackTime = startTimeInTrack; + regionProps.durationInPlaybackTime = duration; + regionProps.musicalContextRef = musicalContext->getPluginRef(); + regionProps.regionSequenceRef = regionSequence->getPluginRef(); + regionProps.name = nullptr; + regionProps.color = nullptr; + + using RegConverter = ARAHostModel::ConversionFunctions; + entry->playbackRegion = std::make_unique ( + RegConverter::toHostRef (entry.get()), dc, + *entry->audioModification, regionProps); + + araLog ("ARA: created source + modification + region inside edit guard"); + } + // ===================================================================== + // Edit guard released — document is now in a stable state + // ===================================================================== + + // ===================================================================== + // Step 1b: Enable audio samples access OUTSIDE the edit guard. + // ARA spec: enableAudioSourceSamplesAccess must NOT be called within + // an editing bracket. Without this, the plugin cannot read audio and + // analysis will never start (editor stays empty). + // ===================================================================== + entry->audioSource->enableAudioSourceSamplesAccess (true); + debugSamplesAccessEnabledCount.fetch_add(1, std::memory_order_acq_rel); + araLog ("ARA: enableAudioSourceSamplesAccess(true) for " + clipId); + + // ===================================================================== + // Step 2: Add region to BOTH renderers + // Note: NOT deactivating the plugin here — in our architecture the plugin + // is in the audio graph and releaseResources() triggers device restarts + // which cascade and prevent ARA analysis from ever starting. + // ===================================================================== + if (playbackRenderer && playbackRenderer->isValid()) + playbackRenderer->add (*entry->playbackRegion); + + if (editorRenderer && editorRenderer->isValid()) + editorRenderer->add (*entry->playbackRegion); + + araLog ("ARA: added region to playbackRenderer + editorRenderer"); + + // ===================================================================== + // Step 3: Request analysis OUTSIDE the edit guard, after renderers updated + // ===================================================================== + double srcSampleRate = entry->reader->sampleRate; + int srcChannels = static_cast (entry->reader->numChannels); + auto srcLength = entry->reader->lengthInSamples; + auto* sourceEntry = entry.get(); + sources[clipId] = std::move (entry); + debugSourceCount.store(static_cast(sources.size()), std::memory_order_release); + debugPlaybackRegionCount.store(static_cast(sources.size()), std::memory_order_release); + noteDebugOperation("clip_attach", clipId); + + if (!requestedAnalysisContentTypes.empty()) + { + debugAnalysisRequested.store(true, std::memory_order_release); + debugAnalysisStarted.store(false, std::memory_order_release); + debugLastAnalysisProgressValue.store(0.0f, std::memory_order_release); + if (modelUpdateControllerPtr != nullptr) + { + modelUpdateControllerPtr->analysisProgress.store(0.0f, std::memory_order_release); + modelUpdateControllerPtr->analysisComplete.store(false, std::memory_order_release); + modelUpdateControllerPtr->lastLoggedAnalysisBucket.store(-1, std::memory_order_release); + } + dc.requestAudioSourceContentAnalysis ( + sourceEntry->audioSource->getPluginRef(), + static_cast (requestedAnalysisContentTypes.size()), + requestedAnalysisContentTypes.data()); + + araLog ("ARA: requestAudioSourceContentAnalysis called for " + clipId + + " contentTypes=" + describeRequestedAnalysisContentTypes()); + } + else + { + debugAnalysisRequested.store(false, std::memory_order_release); + debugAnalysisStarted.store(false, std::memory_order_release); + debugLastAnalysisProgressValue.store(0.0f, std::memory_order_release); + if (modelUpdateControllerPtr != nullptr) + { + modelUpdateControllerPtr->analysisProgress.store(0.0f, std::memory_order_release); + modelUpdateControllerPtr->analysisComplete.store(false, std::memory_order_release); + modelUpdateControllerPtr->lastLoggedAnalysisBucket.store(-1, std::memory_order_release); + } + araLog ("ARA: skipped analysis request for " + clipId + " because plugin exposed no analyzeable content types"); + } + + araLog ("ARA: addAudioSource complete — " + clipId + + " sr=" + juce::String (srcSampleRate) + + " ch=" + juce::String (srcChannels) + + " len=" + juce::String (srcLength) + + " file=" + audioFile.getFullPathName()); + return clipId; + } + catch (const std::exception& e) + { + araLog ("ARA: ERROR in addAudioSource: " + juce::String (e.what())); + return {}; + } +} + +void ARAHostController::removeAudioSource (const juce::String& clipId) +{ + auto it = sources.find (clipId); + if (it == sources.end()) return; + + if (araDocController) + { + // Remove from both renderers first — stops the plugin from requesting + // audio for this region during processBlock. + if (playbackRenderer && playbackRenderer->isValid() && it->second->playbackRegion) + playbackRenderer->remove (*it->second->playbackRegion); + if (editorRenderer && editorRenderer->isValid() && it->second->playbackRegion) + editorRenderer->remove (*it->second->playbackRegion); + + // Disable audio source access BEFORE destroying the source. + // This tells the plugin to stop calling readAudioSamples for this + // source. Without this, the plugin's internal threads may still be + // reading from preloadedAudio when we destroy it → crash. + if (it->second->audioSource) + it->second->audioSource->enableAudioSourceSamplesAccess (false); + + // Destroy model objects inside edit guard (reverse order) + auto& dc = araDocController->getDocumentController(); + juce::ARAEditGuard editGuard (dc); + it->second->playbackRegion.reset(); + it->second->audioModification.reset(); + it->second->audioSource.reset(); + } + + sources.erase (it); + debugSourceCount.store(static_cast(sources.size()), std::memory_order_release); + debugPlaybackRegionCount.store(static_cast(sources.size()), std::memory_order_release); + if (debugSamplesAccessEnabledCount.load(std::memory_order_acquire) > 0) + debugSamplesAccessEnabledCount.fetch_sub(1, std::memory_order_acq_rel); + noteDebugOperation("clip_remove", clipId); +} + +void ARAHostController::updatePlaybackRegion (const juce::String& clipId, + double startTimeInTrack, + double duration, + double offsetInSource) +{ + auto it = sources.find (clipId); + if (it == sources.end() || ! araDocController) return; + + auto& entry = it->second; + entry->startTimeInTrack = startTimeInTrack; + entry->duration = duration; + entry->offsetInSource = offsetInSource; + + auto regionProps = ARAHostModel::PlaybackRegion::getEmptyProperties(); + regionProps.transformationFlags = ARA::kARAPlaybackTransformationNoChanges; + regionProps.startInModificationTime = offsetInSource; + regionProps.durationInModificationTime = duration; + regionProps.startInPlaybackTime = startTimeInTrack; + regionProps.durationInPlaybackTime = duration; + regionProps.musicalContextRef = musicalContext->getPluginRef(); + regionProps.regionSequenceRef = regionSequence->getPluginRef(); + regionProps.name = nullptr; + regionProps.color = nullptr; + + entry->playbackRegion->update (regionProps); + noteEditDrivenDebugEvent("updatePlaybackRegion", clipId); +} + +void ARAHostController::processBlock (juce::AudioBuffer& buffer, int numSamples, + double playbackPositionSeconds, bool playing) +{ + // NOTE: This method is currently unused. The ARA plugin's processBlock() is + // called through the normal FX chain in TrackProcessor::processBlock() via + // safeProcessFX(). The plugin's playhead is set to AudioEngine (which provides + // full position info), so no manual playhead update is needed here. + // Kept for potential future use (e.g., custom ARA rendering paths). + if (! araActive || ! araProcessor) return; + juce::ignoreUnused (numSamples, playbackPositionSeconds, playing); +} + +juce::MemoryBlock ARAHostController::saveState() const +{ + juce::MemoryBlock stateData; + + if (! araActive || ! araDocController) return stateData; + + auto& dc = araDocController->getDocumentController(); + + // Use the ARA archiving interface to save document state + juce::MemoryOutputStream stream (stateData, false); + auto writerRef = S13ArchivingController::WriterConverter::toHostRef (&stream); + dc.storeDocumentToArchive (writerRef); + + return stateData; +} + +bool ARAHostController::restoreState (const juce::MemoryBlock& data) +{ + if (! araActive || ! araDocController || data.getSize() == 0) return false; + + auto& dc = araDocController->getDocumentController(); + + auto dataCopy = data; // Need non-const + auto readerRef = S13ArchivingController::ReaderConverter::toHostRef (&dataCopy); + bool ok = dc.beginRestoringDocumentFromArchive (readerRef); + if (ok) + ok = dc.endRestoringDocumentFromArchive (readerRef); + return ok; +} + +void ARAHostController::shutdown() +{ + if (! araActive) return; + + // Remove all sources (in reverse order of creation) + while (! sources.empty()) + { + auto it = sources.begin(); + removeAudioSource (it->first); + } + + // Destroy ARA objects in reverse order + editorRenderer.reset(); + playbackRenderer.reset(); + regionSequence.reset(); + musicalContext.reset(); + araDocController.reset(); + requestedAnalysisContentTypes.clear(); + + // Clear raw pointers (owned by araDocController, now destroyed) + audioAccessControllerPtr = nullptr; + archivingControllerPtr = nullptr; + contentAccessControllerPtr = nullptr; + modelUpdateControllerPtr = nullptr; + playbackControllerPtr = nullptr; + + araProcessor = nullptr; + araActive = false; + pluginInputChannelCount = 0; + debugAnalysisRequested.store(false, std::memory_order_release); + debugAnalysisStarted.store(false, std::memory_order_release); + debugLastAnalysisProgressValue.store(0.0f, std::memory_order_release); + debugSourceCount.store(0, std::memory_order_release); + debugPlaybackRegionCount.store(0, std::memory_order_release); + debugSamplesAccessEnabledCount.store(0, std::memory_order_release); + + juce::Logger::writeToLog ("ARAHostController: Shut down."); +} + +juce::AudioProcessorEditor* ARAHostController::createEditor() +{ + if (! araActive || ! araProcessor) return nullptr; + if (! araProcessor->hasEditor()) return nullptr; + return araProcessor->createEditor(); +} + +void ARAHostController::updateMusicalContext (double bpm, int timeSigNumerator, int timeSigDenominator) +{ + if (contentAccessControllerPtr) + { + contentAccessControllerPtr->bpm = bpm; + contentAccessControllerPtr->timeSigNum = timeSigNumerator; + contentAccessControllerPtr->timeSigDenom = timeSigDenominator; + } + + // Notify ARA that musical context has changed + if (araDocController && musicalContext) + { + auto& dc = araDocController->getDocumentController(); + juce::ARAEditGuard editGuard (dc); + + auto mcProps = ARAHostModel::MusicalContext::getEmptyProperties(); + mcProps.name = "OpenStudio Musical Context"; + mcProps.orderIndex = 0; + mcProps.color = nullptr; + musicalContext->update (mcProps); + } +} + +void ARAHostController::setPlaybackRequestHandlers(PlaybackRequestHandlers handlers) +{ + playbackRequestHandlers = std::move(handlers); +} + +float ARAHostController::getAnalysisProgress() const +{ +#if S13_HAS_ARA + return modelUpdateControllerPtr != nullptr + ? modelUpdateControllerPtr->analysisProgress.load() + : 0.0f; +#else + return 0.0f; +#endif +} + +bool ARAHostController::isAnalysisComplete() const +{ +#if S13_HAS_ARA + return modelUpdateControllerPtr != nullptr + ? modelUpdateControllerPtr->analysisComplete.load() + : false; +#else + return false; +#endif +} + +ARAHostController::DebugSnapshot ARAHostController::getDebugSnapshot() const +{ + DebugSnapshot snapshot; + snapshot.analysisProgress = getAnalysisProgress(); + snapshot.analysisComplete = isAnalysisComplete(); + snapshot.analysisRequested = debugAnalysisRequested.load(std::memory_order_acquire); + snapshot.analysisStarted = debugAnalysisStarted.load(std::memory_order_acquire); + snapshot.lastAnalysisProgressValue = debugLastAnalysisProgressValue.load(std::memory_order_acquire); + snapshot.sourceCount = debugSourceCount.load(std::memory_order_acquire); + snapshot.playbackRegionCount = debugPlaybackRegionCount.load(std::memory_order_acquire); + snapshot.audioSourceSamplesAccessEnabled = snapshot.sourceCount > 0 + && debugSamplesAccessEnabledCount.load(std::memory_order_acquire) >= snapshot.sourceCount; + snapshot.editorRendererAttached = editorRenderer != nullptr && editorRenderer->isValid(); + snapshot.playbackRendererAttached = playbackRenderer != nullptr && playbackRenderer->isValid(); + snapshot.transportPlaying = debugTransportPlaying.load(std::memory_order_acquire); + snapshot.transportPositionSeconds = debugTransportPositionSeconds.load(std::memory_order_acquire); + snapshot.lastEditTimestampMs = debugLastEditTimestampMs.load(std::memory_order_acquire); + snapshot.editGeneration = debugEditGeneration.load(std::memory_order_acquire); + const auto lastPlaybackObservedEditGeneration = debugLastPlaybackObservedEditGeneration.load(std::memory_order_acquire); + snapshot.hasPendingEditSinceLastPlay = snapshot.editGeneration > lastPlaybackObservedEditGeneration; + const auto lastPlayStartTimestampMs = debugLastPlayStartTimestampMs.load(std::memory_order_acquire); + if (lastPlayStartTimestampMs > 0.0) + snapshot.timeSinceLastPlayStartMs = juce::Time::getMillisecondCounterHiRes() - lastPlayStartTimestampMs; + + const juce::ScopedLock sl(debugStateLock); + snapshot.lastEditType = debugLastEditType; + snapshot.lastOperation = debugLastOperation; + snapshot.lastClipId = debugLastClipId; + return snapshot; +} + +void ARAHostController::updateTransportDebugState(bool playing, double positionSeconds) +{ + debugTransportPlaying.store(playing, std::memory_order_release); + debugTransportPositionSeconds.store(positionSeconds, std::memory_order_release); +} + +void ARAHostController::notePlaybackStart(double positionSeconds) +{ + debugTransportPlaying.store(true, std::memory_order_release); + debugTransportPositionSeconds.store(positionSeconds, std::memory_order_release); + debugLastPlayStartTimestampMs.store(juce::Time::getMillisecondCounterHiRes(), std::memory_order_release); + debugLastPlaybackObservedEditGeneration.store(debugEditGeneration.load(std::memory_order_acquire), std::memory_order_release); + noteDebugOperation("playback_start", {}); +} + +void ARAHostController::notePlaybackStop(double positionSeconds) +{ + debugTransportPlaying.store(false, std::memory_order_release); + debugTransportPositionSeconds.store(positionSeconds, std::memory_order_release); + noteDebugOperation("playback_stop", {}); +} + +void ARAHostController::noteDebugOperation(const juce::String& operationType, const juce::String& clipId) +{ + const juce::ScopedLock sl(debugStateLock); + debugLastOperation = operationType; + if (clipId.isNotEmpty()) + debugLastClipId = clipId; +} + +void ARAHostController::noteEditDrivenDebugEvent(const juce::String& eventType, const juce::String& clipId) +{ + const double nowMs = juce::Time::getMillisecondCounterHiRes(); + debugLastEditTimestampMs.store(nowMs, std::memory_order_release); + debugEditGeneration.fetch_add(1, std::memory_order_acq_rel); + { + const juce::ScopedLock sl(debugStateLock); + debugLastEditType = eventType; + debugLastOperation = "edit_driven_content_change"; + if (clipId.isNotEmpty()) + debugLastClipId = clipId; + } + + const bool transportPlaying = debugTransportPlaying.load(std::memory_order_acquire); + const double positionSeconds = debugTransportPositionSeconds.load(std::memory_order_acquire); + const double lastPlayStartTimestampMs = debugLastPlayStartTimestampMs.load(std::memory_order_acquire); + const double timeSinceLastPlayStartMs = lastPlayStartTimestampMs > 0.0 + ? juce::Time::getMillisecondCounterHiRes() - lastPlayStartTimestampMs + : -1.0; + + logARADebugLine("ARA model update: type=" + eventType + + " clipId=" + (clipId.isNotEmpty() ? clipId : juce::String("")) + + " transportPlaying=" + juce::String(transportPlaying ? "true" : "false") + + " transportPosition=" + juce::String(positionSeconds, 3) + + " timeSinceLastPlayStartMs=" + juce::String(timeSinceLastPlayStartMs, 2)); +} + +juce::String ARAHostController::describeRequestedAnalysisContentTypes() const +{ + juce::StringArray names; + names.ensureStorageAllocated(static_cast(requestedAnalysisContentTypes.size())); + + for (auto contentType : requestedAnalysisContentTypes) + names.add(araContentTypeToString(contentType)); + + return names.joinIntoString(","); +} + +void ARAHostController::requestStartPlaybackFromPlugin() const +{ + if (!playbackRequestHandlers.startPlayback) + return; + + juce::Logger::writeToLog("ARAHostController: Plugin requested start playback"); + auto handler = playbackRequestHandlers.startPlayback; + juce::MessageManager::callAsync([handler]() { handler(); }); +} + +void ARAHostController::requestStopPlaybackFromPlugin() const +{ + if (!playbackRequestHandlers.stopPlayback) + return; + + juce::Logger::writeToLog("ARAHostController: Plugin requested stop playback"); + auto handler = playbackRequestHandlers.stopPlayback; + juce::MessageManager::callAsync([handler]() { handler(); }); +} + +void ARAHostController::requestSetPlaybackPositionFromPlugin(double timePosition) const +{ + if (!playbackRequestHandlers.setPlaybackPosition) + return; + + juce::Logger::writeToLog("ARAHostController: Plugin requested playback position " + juce::String(timePosition)); + auto handler = playbackRequestHandlers.setPlaybackPosition; + juce::MessageManager::callAsync([handler, timePosition]() { handler(timePosition); }); +} + +void ARAHostController::requestSetCycleRangeFromPlugin(double startTime, double duration) const +{ + if (!playbackRequestHandlers.setCycleRange) + return; + + juce::Logger::writeToLog("ARAHostController: Plugin requested cycle range start=" + + juce::String(startTime) + " duration=" + juce::String(duration)); + auto handler = playbackRequestHandlers.setCycleRange; + juce::MessageManager::callAsync([handler, startTime, duration]() { handler(startTime, duration); }); +} + +void ARAHostController::requestEnableCycleFromPlugin(bool enable) const +{ + if (!playbackRequestHandlers.enableCycle) + return; + + juce::Logger::writeToLog("ARAHostController: Plugin requested cycle " + juce::String(enable ? "enable" : "disable")); + auto handler = playbackRequestHandlers.enableCycle; + juce::MessageManager::callAsync([handler, enable]() { handler(enable); }); +} + +#else // !S13_HAS_ARA + +// Stub implementations when ARA is not available + +void ARAHostController::initializeForPlugin (juce::AudioPluginInstance*, double, int, + std::function onComplete) +{ + juce::Logger::writeToLog ("ARAHostController: ARA support not compiled in."); + if (onComplete) onComplete (false, false, "ARA support not compiled in."); +} + +juce::String ARAHostController::addAudioSource (const juce::File&, const juce::String&, + double, double, double) +{ return {}; } + +void ARAHostController::removeAudioSource (const juce::String&) {} + +void ARAHostController::updatePlaybackRegion (const juce::String&, double, double, double) {} + +void ARAHostController::processBlock (juce::AudioBuffer&, int, double, bool) {} + +juce::MemoryBlock ARAHostController::saveState() const { return {}; } +bool ARAHostController::restoreState (const juce::MemoryBlock&) { return false; } + +void ARAHostController::shutdown() {} + +juce::AudioProcessorEditor* ARAHostController::createEditor() { return nullptr; } + +void ARAHostController::updateMusicalContext (double, int, int) {} + +void ARAHostController::setPlaybackRequestHandlers(PlaybackRequestHandlers) {} + +#endif // S13_HAS_ARA diff --git a/Source/ARAHostController.h b/Source/ARAHostController.h new file mode 100644 index 0000000..e8eced0 --- /dev/null +++ b/Source/ARAHostController.h @@ -0,0 +1,324 @@ +#pragma once + +#include + +// ARA hosting requires the ARA SDK and JUCE_PLUGINHOST_ARA=1 +#if JUCE_PLUGINHOST_ARA && (JUCE_MAC || JUCE_WINDOWS || JUCE_LINUX) +#define S13_HAS_ARA 1 +#else +#define S13_HAS_ARA 0 +#endif + +#include +#include "ARADebug.h" + +#if S13_HAS_ARA +#include +#include +#endif + +/** + * ARAHostController — Manages ARA 2 plugin hosting for a single track. + * + * Uses JUCE's high-level ARA hosting API (ARAHostDocumentController) to manage + * the ARA document lifecycle, audio sources, and playback regions. + * + * Usage: + * 1. Detect ARA-capable plugins via PluginManager::isARAPlugin() + * 2. Call initializeForPlugin() with the loaded AudioPluginInstance + * 3. Call addAudioSource() for each clip on the track + * 4. During playback, call processBlock() instead of normal plugin processing + * 5. Call saveState()/restoreState() for project save/load + */ +class ARAHostController +{ +public: + struct DebugSnapshot + { + float analysisProgress = 0.0f; + bool analysisComplete = false; + bool analysisRequested = false; + bool analysisStarted = false; + float lastAnalysisProgressValue = 0.0f; + int sourceCount = 0; + int playbackRegionCount = 0; + bool audioSourceSamplesAccessEnabled = false; + bool editorRendererAttached = false; + bool playbackRendererAttached = false; + bool transportPlaying = false; + double transportPositionSeconds = 0.0; + double timeSinceLastPlayStartMs = 0.0; + bool hasPendingEditSinceLastPlay = false; + double lastEditTimestampMs = 0.0; + juce::String lastEditType; + juce::String lastOperation; + juce::String lastClipId; + uint64 editGeneration = 0; + }; + + struct PlaybackRequestHandlers + { + std::function startPlayback; + std::function stopPlayback; + std::function setPlaybackPosition; + std::function setCycleRange; + std::function enableCycle; + }; + + ARAHostController(); + ~ARAHostController(); + + // Initialize ARA hosting for a loaded plugin (async — uses createARAFactoryAsync) + // Calls onComplete(true) on success, onComplete(false) on failure + void initializeForPlugin (juce::AudioPluginInstance* plugin, double sampleRate, int blockSize, + std::function onComplete = nullptr); + + // Check if ARA is currently active + bool isActive() const { return araActive; } + + // Add an audio source (clip) to the ARA document + juce::String addAudioSource (const juce::File& audioFile, + const juce::String& clipId, + double startTimeInTrack, + double duration, + double offsetInSource = 0.0); + + // Remove an audio source from the document + void removeAudioSource (const juce::String& clipId); + + // Update playback region timing (when clip is moved/resized) + void updatePlaybackRegion (const juce::String& clipId, + double startTimeInTrack, + double duration, + double offsetInSource); + + // Process audio through the ARA renderer + void processBlock (juce::AudioBuffer& buffer, int numSamples, + double playbackPositionSeconds, bool isPlaying); + + // State save/load for project persistence + juce::MemoryBlock saveState() const; + bool restoreState (const juce::MemoryBlock& data); + + // Cleanup + void shutdown(); + + // Get the ARA plugin's editor component + juce::AudioProcessorEditor* createEditor(); + + // Notify ARA of tempo/time signature changes + void updateMusicalContext (double bpm, int timeSigNumerator, int timeSigDenominator); + + void setPlaybackRequestHandlers(PlaybackRequestHandlers handlers); + float getAnalysisProgress() const; + bool isAnalysisComplete() const; + DebugSnapshot getDebugSnapshot() const; + void updateTransportDebugState(bool playing, double positionSeconds); + void notePlaybackStart(double positionSeconds); + void notePlaybackStop(double positionSeconds); + + +private: + bool araActive = false; + juce::AudioPluginInstance* araProcessor = nullptr; + double currentSampleRate = 44100.0; + int currentBlockSize = 512; + int pluginInputChannelCount = 0; + +#if S13_HAS_ARA + + // ========================================================================= + // ARA Host Interface Controllers + // ========================================================================= + + class S13AudioAccessController final : public ARA::Host::AudioAccessControllerInterface + { + public: + struct AudioReader + { + ARA::ARAAudioSourceHostRef sourceHostRef; + bool use64Bit; + }; + + using ReaderConverter = ARAHostModel::ConversionFunctions; + + ARA::ARAAudioReaderHostRef createAudioReaderForSource ( + ARA::ARAAudioSourceHostRef audioSourceHostRef, + bool use64BitSamples) noexcept override; + + bool readAudioSamples (ARA::ARAAudioReaderHostRef readerRef, + ARA::ARASamplePosition samplePosition, + ARA::ARASampleCount samplesPerChannel, + void* const* buffers) noexcept override; + + void destroyAudioReader (ARA::ARAAudioReaderHostRef readerRef) noexcept override; + + std::map> audioReaders; + }; + + class S13ArchivingController final : public ARA::Host::ArchivingControllerInterface + { + public: + using ReaderConverter = ARAHostModel::ConversionFunctions; + using WriterConverter = ARAHostModel::ConversionFunctions; + + ARA::ARASize getArchiveSize (ARA::ARAArchiveReaderHostRef archiveReaderHostRef) noexcept override; + bool readBytesFromArchive (ARA::ARAArchiveReaderHostRef archiveReaderHostRef, + ARA::ARASize position, ARA::ARASize length, + ARA::ARAByte* buffer) noexcept override; + bool writeBytesToArchive (ARA::ARAArchiveWriterHostRef archiveWriterHostRef, + ARA::ARASize position, ARA::ARASize length, + const ARA::ARAByte* buffer) noexcept override; + void notifyDocumentArchivingProgress (float value) noexcept override; + void notifyDocumentUnarchivingProgress (float value) noexcept override; + ARA::ARAPersistentID getDocumentArchiveID (ARA::ARAArchiveReaderHostRef archiveReaderHostRef) noexcept override; + }; + + class S13ContentAccessController final : public ARA::Host::ContentAccessControllerInterface + { + public: + using Converter = ARAHostModel::ConversionFunctions; + + bool isMusicalContextContentAvailable (ARA::ARAMusicalContextHostRef, ARA::ARAContentType type) noexcept override; + ARA::ARAContentGrade getMusicalContextContentGrade (ARA::ARAMusicalContextHostRef, ARA::ARAContentType) noexcept override; + ARA::ARAContentReaderHostRef createMusicalContextContentReader (ARA::ARAMusicalContextHostRef, ARA::ARAContentType type, const ARA::ARAContentTimeRange*) noexcept override; + bool isAudioSourceContentAvailable (ARA::ARAAudioSourceHostRef, ARA::ARAContentType) noexcept override; + ARA::ARAContentGrade getAudioSourceContentGrade (ARA::ARAAudioSourceHostRef, ARA::ARAContentType) noexcept override; + ARA::ARAContentReaderHostRef createAudioSourceContentReader (ARA::ARAAudioSourceHostRef, ARA::ARAContentType, const ARA::ARAContentTimeRange*) noexcept override; + ARA::ARAInt32 getContentReaderEventCount (ARA::ARAContentReaderHostRef contentReaderHostRef) noexcept override; + const void* getContentReaderDataForEvent (ARA::ARAContentReaderHostRef contentReaderHostRef, ARA::ARAInt32 eventIndex) noexcept override; + void destroyContentReader (ARA::ARAContentReaderHostRef) noexcept override; + + // Musical context data (set by host) + double bpm = 120.0; + int timeSigNum = 4; + int timeSigDenom = 4; + + private: + ARA::ARAContentTempoEntry tempoEntry {}; + ARA::ARAContentBarSignature barSignature {}; + }; + + class S13ModelUpdateController final : public ARA::Host::ModelUpdateControllerInterface + { + public: + explicit S13ModelUpdateController(ARAHostController& ownerIn) : owner(ownerIn) {} + + void notifyAudioSourceAnalysisProgress (ARA::ARAAudioSourceHostRef, ARA::ARAAnalysisProgressState, float) noexcept override; + void notifyAudioSourceContentChanged (ARA::ARAAudioSourceHostRef, const ARA::ARAContentTimeRange*, ARA::ContentUpdateScopes) noexcept override; + void notifyAudioModificationContentChanged (ARA::ARAAudioModificationHostRef, const ARA::ARAContentTimeRange*, ARA::ContentUpdateScopes) noexcept override; + void notifyPlaybackRegionContentChanged (ARA::ARAPlaybackRegionHostRef, const ARA::ARAContentTimeRange*, ARA::ContentUpdateScopes) noexcept override; + void notifyDocumentDataChanged() noexcept override; + + std::atomic analysisProgress { 0.0f }; + std::atomic analysisComplete { false }; + std::atomic lastLoggedAnalysisBucket { -1 }; + + private: + ARAHostController& owner; + }; + + class S13PlaybackController final : public ARA::Host::PlaybackControllerInterface + { + public: + explicit S13PlaybackController(ARAHostController& ownerIn) : owner(ownerIn) {} + + void requestStartPlayback() noexcept override; + void requestStopPlayback() noexcept override; + void requestSetPlaybackPosition (ARA::ARATimePosition timePosition) noexcept override; + void requestSetCycleRange (ARA::ARATimePosition startTime, ARA::ARATimeDuration duration) noexcept override; + void requestEnableCycle (bool enable) noexcept override; + + private: + ARAHostController& owner; + }; + + // ========================================================================= + // ARA Model Objects + // ========================================================================= + + struct ARASourceEntry + { + juce::String clipId; + juce::File audioFile; + std::unique_ptr reader; + int nativeChannelCount = 0; + int exposedChannelCount = 0; + + // Pre-loaded audio data — eliminates disk I/O during processBlock. + // ARA plugins call readAudioSamples() on the audio thread; serving + // from memory avoids ~300ms/block disk contention. + juce::AudioBuffer preloadedAudio; + bool preloaded = false; + + // ARA model objects (must be destroyed in reverse order of creation) + std::unique_ptr playbackRegion; + std::unique_ptr audioModification; + std::unique_ptr audioSource; + + double startTimeInTrack = 0.0; + double duration = 0.0; + double offsetInSource = 0.0; + }; + + // Raw pointers to our controllers (ownership is transferred to ARAHostDocumentController) + S13AudioAccessController* audioAccessControllerPtr = nullptr; + S13ArchivingController* archivingControllerPtr = nullptr; + S13ContentAccessController* contentAccessControllerPtr = nullptr; + S13ModelUpdateController* modelUpdateControllerPtr = nullptr; + S13PlaybackController* playbackControllerPtr = nullptr; + + // JUCE high-level ARA document controller (owns the host interface controllers) + std::unique_ptr araDocController; + + // Plugin extension instance (returned by bindDocumentToPluginInstance) + ARAHostModel::PlugInExtensionInstance pluginExtension; + std::unique_ptr playbackRenderer; + std::unique_ptr editorRenderer; + std::vector requestedAnalysisContentTypes; + + // Musical context and region sequence + std::unique_ptr musicalContext; + std::unique_ptr regionSequence; + + // Registered audio sources + std::map> sources; + + // Helper: complete initialization after factory is obtained + void completeInitialization (juce::ARAFactoryWrapper factory, + std::function onComplete); + + friend class S13AudioAccessController; + +#endif // S13_HAS_ARA + + void noteDebugOperation(const juce::String& operationType, const juce::String& clipId); + void noteEditDrivenDebugEvent(const juce::String& eventType, const juce::String& clipId); + juce::String describeRequestedAnalysisContentTypes() const; + + void requestStartPlaybackFromPlugin() const; + void requestStopPlaybackFromPlugin() const; + void requestSetPlaybackPositionFromPlugin(double timePosition) const; + void requestSetCycleRangeFromPlugin(double startTime, double duration) const; + void requestEnableCycleFromPlugin(bool enable) const; + + PlaybackRequestHandlers playbackRequestHandlers; + mutable juce::CriticalSection debugStateLock; + std::atomic debugTransportPlaying { false }; + std::atomic debugTransportPositionSeconds { 0.0 }; + std::atomic debugLastPlayStartTimestampMs { 0.0 }; + std::atomic debugLastEditTimestampMs { 0.0 }; + std::atomic debugAnalysisRequested { false }; + std::atomic debugAnalysisStarted { false }; + std::atomic debugLastAnalysisProgressValue { 0.0f }; + std::atomic debugSourceCount { 0 }; + std::atomic debugPlaybackRegionCount { 0 }; + std::atomic debugSamplesAccessEnabledCount { 0 }; + std::atomic debugEditGeneration { 0 }; + std::atomic debugLastPlaybackObservedEditGeneration { 0 }; + juce::String debugLastEditType; + juce::String debugLastOperation; + juce::String debugLastClipId; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (ARAHostController) +}; diff --git a/Source/AppUpdater.cpp b/Source/AppUpdater.cpp new file mode 100644 index 0000000..9699cc9 --- /dev/null +++ b/Source/AppUpdater.cpp @@ -0,0 +1,790 @@ +#include "AppUpdater.h" +#include + +namespace +{ +struct ParsedUpdateFeed +{ + juce::String version; + juce::String downloadUrl; + juce::String sha256; + juce::String notes; + juce::String releasePageUrl; + juce::String releaseNotesUrl; + juce::String publishedAt; + juce::String fileName; + juce::String channel; + juce::String minimumSupportedVersion; + juce::String installerArguments; + juce::String source; + juce::int64 expectedSize = 0; +}; + +juce::String getStringProperty(const juce::var& value, const juce::Identifier& property) +{ + if (auto* obj = value.getDynamicObject()) + return obj->getProperty(property).toString(); + + return {}; +} + +juce::int64 getInt64Property(const juce::var& value, const juce::Identifier& property, juce::int64 fallback = 0) +{ + if (auto* obj = value.getDynamicObject()) + { + const auto prop = obj->getProperty(property); + if (prop.isInt() || prop.isInt64() || prop.isDouble()) + return static_cast(prop); + } + + return fallback; +} + +bool getBoolProperty(const juce::var& value, const juce::Identifier& property, bool fallback = false) +{ + if (auto* obj = value.getDynamicObject()) + { + const auto prop = obj->getProperty(property); + if (prop.isBool()) + return static_cast(prop); + } + + return fallback; +} + +juce::File getOpenStudioAppDataDirectory() +{ + return juce::File::getSpecialLocation(juce::File::userApplicationDataDirectory) + .getChildFile("OpenStudio"); +} + +juce::File getLegacyStudio13AppDataDirectory() +{ + return juce::File::getSpecialLocation(juce::File::userApplicationDataDirectory) + .getChildFile("Studio13"); +} + +juce::File getPreferredUpdaterStateFile() +{ + auto openStudioDir = getOpenStudioAppDataDirectory(); + auto legacyStateFile = getLegacyStudio13AppDataDirectory().getChildFile("updater-state.json"); + auto preferredDir = (!openStudioDir.exists() && legacyStateFile.existsAsFile()) + ? getLegacyStudio13AppDataDirectory() + : openStudioDir; + + return preferredDir.getChildFile("updater-state.json"); +} + +constexpr juce::int64 kAutomaticUpdateCheckIntervalMs = 24LL * 60LL * 60LL * 1000LL; + +bool looksLikeXml(const juce::String& text) +{ + const auto trimmed = text.trimStart(); + return trimmed.startsWithChar('<'); +} + +bool xmlNameMatches(const juce::String& actualName, const juce::String& expectedLocalName) +{ + return actualName == expectedLocalName || actualName.endsWith(":" + expectedLocalName); +} + +juce::String getXmlAttribute(const juce::XmlElement& element, std::initializer_list names) +{ + for (const auto* name : names) + { + const auto value = element.getStringAttribute(name).trim(); + if (value.isNotEmpty()) + return value; + } + + return {}; +} + +juce::String getXmlChildText(const juce::XmlElement& parent, std::initializer_list names) +{ + forEachXmlChildElement (parent, child) + { + for (const auto* name : names) + { + if (xmlNameMatches(child->getTagName(), name)) + { + const auto value = child->getAllSubText().trim(); + if (value.isNotEmpty()) + return value; + } + } + } + + return {}; +} +} + +AppUpdater::AppUpdater() +{ + const auto stateFile = getPreferredUpdaterStateFile(); + if (stateFile.existsAsFile()) + { + auto parsed = juce::JSON::parse(stateFile.loadFileAsString()); + if (parsed.isObject()) + persistedState = parsed; + } + + publishStatus(makeStatus("idle", "Updater ready", {}, {}, {}, {}, {}, {}, {}, 0, {}, getCurrentChannel())); +} + +AppUpdater::~AppUpdater() = default; + +juce::String AppUpdater::getCurrentVersion() const +{ + return ProjectInfo::versionString; +} + +juce::var AppUpdater::getLastStatus() const +{ + const juce::ScopedLock lock(stateLock); + return lastStatus; +} + +void AppUpdater::setStatusCallback(StatusCallback callback) +{ + const juce::ScopedLock lock(stateLock); + statusCallback = std::move(callback); +} + +void AppUpdater::checkForUpdates(bool manual, Completion completion) +{ + if (checkInProgress.exchange(true)) + { + auto busy = makeStatus("busy", "An update check is already running.", {}, {}, {}, {}, {}, {}, {}, 0, {}, getCurrentChannel()); + publishStatus(busy); + + if (completion) + juce::MessageManager::callAsync([completion, busy]() { completion(busy); }); + + return; + } + + if (!manual && shouldSkipAutomaticCheck()) + { + checkInProgress = false; + auto skipped = makeStatus("skipped", + "Automatic update check skipped until the next scheduled window.", + {}, {}, {}, {}, {}, {}, {}, 0, {}, getCurrentChannel()); + publishStatus(skipped); + + if (completion) + juce::MessageManager::callAsync([completion, skipped]() { completion(skipped); }); + + return; + } + + publishStatus(makeStatus("checking", + manual ? "Checking for updates..." : "Checking for updates in the background...", + {}, {}, {}, {}, {}, {}, {}, 0, {}, getCurrentChannel())); + + std::thread([this, completion = std::move(completion)]() mutable + { + auto result = performUpdateCheck(); + checkInProgress = false; + + juce::MessageManager::callAsync([this, completion = std::move(completion), result]() mutable + { + publishStatus(result); + if (completion) + completion(result); + }); + }).detach(); +} + +void AppUpdater::downloadAndInstallUpdate(const juce::String& downloadUrl, + const juce::String& version, + const juce::String& expectedSha256, + const juce::String& releasePageUrl, + const juce::String& installerArguments, + juce::int64 expectedSize, + Completion completion) +{ + if (installInProgress.exchange(true)) + { + auto busy = makeStatus("busy", "An update download is already running.", version, downloadUrl, expectedSha256, {}, releasePageUrl, {}, {}, expectedSize, {}, getCurrentChannel(), false, installerArguments); + publishStatus(busy); + + if (completion) + juce::MessageManager::callAsync([completion, busy]() { completion(busy); }); + + return; + } + + publishStatus(makeStatus("downloading", "Downloading update...", version, downloadUrl, expectedSha256, {}, releasePageUrl, {}, {}, expectedSize, {}, getCurrentChannel(), false, installerArguments)); + + std::thread([this, completion = std::move(completion), downloadUrl, version, expectedSha256, releasePageUrl, installerArguments, expectedSize]() mutable + { + auto result = performDownloadAndInstall(downloadUrl, version, expectedSha256, releasePageUrl, installerArguments, expectedSize); + installInProgress = false; + + juce::MessageManager::callAsync([this, completion = std::move(completion), result]() mutable + { + publishStatus(result); + if (completion) + completion(result); + }); + }).detach(); +} + +juce::var AppUpdater::performUpdateCheck() +{ + const auto manifestUrl = getManifestUrl().trim(); + const auto appcastUrl = getAppcastUrl().trim(); + const auto currentChannel = getCurrentChannel(); + + if (manifestUrl.isEmpty() && appcastUrl.isEmpty()) + return makeStatus("error", "No update feed URL is configured.", {}, {}, {}, {}, {}, {}, {}, 0, {}, currentChannel); + + auto buildStatusFromFeed = [this, currentChannel](const ParsedUpdateFeed& feed, const juce::String& invalidFeedMessage) + { + const auto latestVersion = feed.version.trim(); + if (latestVersion.isEmpty()) + return makeStatus("error", invalidFeedMessage, {}, {}, {}, {}, feed.releasePageUrl, feed.releaseNotesUrl, feed.publishedAt, 0, {}, currentChannel, false, {}, feed.source); + + const auto feedChannel = feed.channel.trim().isNotEmpty() ? feed.channel.trim() : currentChannel; + auto releasePageUrl = feed.releasePageUrl.trim(); + if (releasePageUrl.isEmpty()) + releasePageUrl = getFallbackReleasesPageUrl(); + + const auto isMandatory = feed.minimumSupportedVersion.isNotEmpty() + && compareVersions(getCurrentVersion(), feed.minimumSupportedVersion) < 0; + + if (feedChannel != currentChannel) + return makeStatus("error", + "The update feed channel does not match this build.", + latestVersion, {}, feed.sha256, feed.notes, releasePageUrl, feed.releaseNotesUrl, feed.publishedAt, + feed.expectedSize, feed.fileName, feedChannel, isMandatory, feed.installerArguments, feed.source); + + if (feed.downloadUrl.trim().isEmpty()) + return makeStatus("error", + "The update feed does not include a download for this platform.", + latestVersion, {}, feed.sha256, feed.notes, releasePageUrl, feed.releaseNotesUrl, feed.publishedAt, + feed.expectedSize, feed.fileName, feedChannel, isMandatory, feed.installerArguments, feed.source); + + recordSuccessfulCheck(latestVersion, feed.publishedAt); + + if (compareVersions(latestVersion, getCurrentVersion()) <= 0) + return makeStatus("up-to-date", "OpenStudio is already up to date.", + latestVersion, feed.downloadUrl, feed.sha256, feed.notes, releasePageUrl, feed.releaseNotesUrl, feed.publishedAt, + feed.expectedSize, feed.fileName, feedChannel, isMandatory, feed.installerArguments, feed.source); + + return makeStatus("update-available", + isMandatory + ? "A required OpenStudio update is available." + : "OpenStudio " + latestVersion + " is available.", + latestVersion, feed.downloadUrl, feed.sha256, feed.notes, releasePageUrl, feed.releaseNotesUrl, feed.publishedAt, + feed.expectedSize, feed.fileName, feedChannel, isMandatory, feed.installerArguments, feed.source); + }; + + auto parseJsonManifest = [this, currentChannel, &buildStatusFromFeed](const juce::String& manifestText) + { + auto parsed = juce::JSON::parse(manifestText); + if (!parsed.isObject()) + return makeStatus("error", "The update manifest is invalid JSON.", {}, {}, {}, {}, {}, {}, {}, 0, {}, currentChannel); + + const auto schemaVersion = getInt64Property(parsed, "schemaVersion", 1); + if (schemaVersion > 1) + return makeStatus("error", "This OpenStudio build cannot read the published update manifest format yet.", {}, {}, {}, {}, {}, {}, {}, 0, {}, currentChannel); + + ParsedUpdateFeed feed; + feed.source = "json-manifest"; + feed.version = getStringProperty(parsed, "version").trim(); + feed.notes = getStringProperty(parsed, "notes"); + feed.publishedAt = getStringProperty(parsed, "publishedAt"); + feed.releasePageUrl = getStringProperty(parsed, "releasePageUrl").trim(); + feed.releaseNotesUrl = getStringProperty(parsed, "fullReleaseNotesUrl").trim(); + feed.channel = getStringProperty(parsed, "channel").trim(); + feed.minimumSupportedVersion = getStringProperty(parsed, "minimumSupportedVersion").trim(); + + const auto platformKey = getPlatformKey(); + juce::var platformNode; + + if (auto* manifestObj = parsed.getDynamicObject()) + { + auto platforms = manifestObj->getProperty("platforms"); + if (auto* platformsObj = platforms.getDynamicObject()) + platformNode = platformsObj->getProperty(platformKey); + + if (platformNode.isVoid()) + platformNode = manifestObj->getProperty(platformKey); + } + + feed.downloadUrl = getStringProperty(platformNode, "url").trim(); + feed.sha256 = getStringProperty(platformNode, "sha256").trim(); + feed.fileName = getStringProperty(platformNode, "fileName").trim(); + feed.expectedSize = getInt64Property(platformNode, "size", 0); + feed.installerArguments = getStringProperty(platformNode, "installerArguments").trim(); + + return buildStatusFromFeed(feed, "The update manifest does not include a version."); + }; + + auto parseAppcast = [this, currentChannel, &buildStatusFromFeed](const juce::String& xmlText) + { + std::unique_ptr xml(juce::XmlDocument::parse(xmlText)); + if (xml == nullptr) + return makeStatus("error", "The update appcast is invalid XML.", {}, {}, {}, {}, {}, {}, {}, 0, {}, currentChannel); + + juce::XmlElement* channel = nullptr; + if (xmlNameMatches(xml->getTagName(), "rss")) + channel = xml->getChildByName("channel"); + else if (xmlNameMatches(xml->getTagName(), "channel")) + channel = xml.get(); + + if (channel == nullptr) + return makeStatus("error", "The update appcast does not contain a channel.", {}, {}, {}, {}, {}, {}, {}, 0, {}, currentChannel); + + juce::XmlElement* item = nullptr; + forEachXmlChildElement (*channel, child) + { + if (xmlNameMatches(child->getTagName(), "item")) + { + item = child; + break; + } + } + + if (item == nullptr) + return makeStatus("error", "The update appcast does not contain a release item.", {}, {}, {}, {}, {}, {}, {}, 0, {}, currentChannel); + + auto* enclosure = item->getChildByName("enclosure"); + if (enclosure == nullptr) + return makeStatus("error", "The update appcast does not contain a downloadable enclosure.", {}, {}, {}, {}, {}, {}, {}, 0, {}, currentChannel); + + ParsedUpdateFeed feed; + feed.source = "appcast"; + feed.version = getXmlAttribute(*enclosure, { "sparkle:shortVersionString", "sparkle:version", "version" }).trim(); + feed.downloadUrl = getXmlAttribute(*enclosure, { "url" }).trim(); + feed.sha256 = getXmlAttribute(*enclosure, { "openstudio:sha256", "sha256" }).trim(); + feed.fileName = getXmlAttribute(*enclosure, { "openstudio:fileName", "fileName" }).trim(); + feed.channel = getXmlAttribute(*enclosure, { "openstudio:channel", "channel" }).trim(); + feed.minimumSupportedVersion = getXmlAttribute(*enclosure, { "openstudio:minimumSupportedVersion", "minimumSupportedVersion" }).trim(); + feed.installerArguments = getXmlAttribute(*enclosure, { "sparkle:installerArguments", "openstudio:installerArguments", "installerArguments" }).trim(); + feed.expectedSize = getXmlAttribute(*enclosure, { "length" }).getLargeIntValue(); + feed.notes = getXmlChildText(*item, { "description" }); + feed.releasePageUrl = getXmlChildText(*channel, { "link" }).trim(); + feed.releaseNotesUrl = getXmlChildText(*item, { "releaseNotesLink" }).trim(); + feed.publishedAt = getXmlChildText(*item, { "pubDate" }).trim(); + + if (feed.fileName.isEmpty() && feed.downloadUrl.isNotEmpty()) + feed.fileName = getDownloadFileName(juce::URL(feed.downloadUrl), feed.version); + + return buildStatusFromFeed(feed, "The update appcast does not include a version."); + }; + + auto tryFeedUrl = [&](const juce::String& feedUrl, bool forceXml) + { + const auto trimmedUrl = feedUrl.trim(); + if (trimmedUrl.isEmpty()) + return makeStatus("error", "No update feed URL is configured.", {}, {}, {}, {}, {}, {}, {}, 0, {}, currentChannel); + + auto feedText = juce::URL(trimmedUrl).readEntireTextStream(false).trim(); + if (feedText.isEmpty()) + return makeStatus("error", "Could not reach the update server.", {}, {}, {}, {}, {}, {}, {}, 0, {}, currentChannel); + + if (forceXml || looksLikeXml(feedText)) + return parseAppcast(feedText); + + return parseJsonManifest(feedText); + }; + + juce::var lastFailure = makeStatus("error", "Could not reach the update server.", {}, {}, {}, {}, {}, {}, {}, 0, {}, currentChannel); + + if (manifestUrl.isNotEmpty()) + { + auto manifestResult = tryFeedUrl(manifestUrl, false); + if (getStringProperty(manifestResult, "status") != "error") + return manifestResult; + lastFailure = manifestResult; + } + + if (appcastUrl.isNotEmpty() && appcastUrl != manifestUrl) + { + auto appcastResult = tryFeedUrl(appcastUrl, true); + if (getStringProperty(appcastResult, "status") != "error") + return appcastResult; + lastFailure = appcastResult; + } + + return lastFailure; +} + +juce::var AppUpdater::performDownloadAndInstall(const juce::String& downloadUrl, + const juce::String& version, + const juce::String& expectedSha256, + const juce::String& releasePageUrl, + const juce::String& installerArguments, + juce::int64 expectedSize) const +{ + const auto currentChannel = getCurrentChannel(); + + if (downloadUrl.trim().isEmpty()) + { + if (releasePageUrl.isNotEmpty()) + { + juce::URL(releasePageUrl).launchInDefaultBrowser(); + return makeStatus("release-page-opened", + "Opened the release page in your browser.", + version, {}, expectedSha256, {}, releasePageUrl, {}, {}, expectedSize, {}, currentChannel, false, installerArguments); + } + + return makeStatus("error", "No download URL was provided for this update.", version, {}, expectedSha256, {}, {}, {}, {}, expectedSize, {}, currentChannel, false, installerArguments); + } + + const juce::URL url(downloadUrl); + const auto fileName = getDownloadFileName(url, version); + auto updateDir = juce::File::getSpecialLocation(juce::File::tempDirectory) + .getChildFile("OpenStudio") + .getChildFile("updates"); + updateDir.createDirectory(); + + const auto installerFile = updateDir.getChildFile(fileName); + juce::String error; + + if (!downloadToFile(url, installerFile, error)) + { + if (releasePageUrl.isNotEmpty()) + juce::URL(releasePageUrl).launchInDefaultBrowser(); + + return makeStatus("error", + error.isNotEmpty() ? error : "The update download failed.", + version, downloadUrl, expectedSha256, {}, releasePageUrl, {}, {}, expectedSize, installerFile.getFileName(), currentChannel, false, installerArguments); + } + + if (!verifyDownloadedFileSize(installerFile, expectedSize, error)) + return makeStatus("error", + error.isNotEmpty() ? error : "The downloaded update did not match the published size.", + version, downloadUrl, expectedSha256, {}, releasePageUrl, {}, {}, expectedSize, installerFile.getFileName(), currentChannel, false, installerArguments); + + if (!verifyDownloadedFileSha256(installerFile, expectedSha256, error)) + return makeStatus("error", + error.isNotEmpty() ? error : "The downloaded update did not match the published checksum.", + version, downloadUrl, expectedSha256, {}, releasePageUrl, {}, {}, expectedSize, installerFile.getFileName(), currentChannel, false, installerArguments); + + if (!launchDownloadedInstaller(installerFile, installerArguments, error)) + return makeStatus("error", + error.isNotEmpty() ? error : "The update downloaded, but the installer could not be opened.", + version, downloadUrl, expectedSha256, {}, releasePageUrl, {}, {}, expectedSize, installerFile.getFileName(), currentChannel, false, installerArguments); + + #if JUCE_MAC + const auto successMessage = "The update DMG has been opened. Drag OpenStudio to Applications, then use right-click > Open if macOS warns about an unidentified developer."; + #elif JUCE_WINDOWS + const auto successMessage = "The update installer has been opened."; + #else + const auto successMessage = "The update package has been opened."; + #endif + + return makeStatus("install-started", + successMessage, + version, downloadUrl, expectedSha256, {}, releasePageUrl, {}, {}, expectedSize, installerFile.getFileName(), currentChannel, false, installerArguments); +} + +void AppUpdater::publishStatus(const juce::var& status) +{ + StatusCallback callbackCopy; + + { + const juce::ScopedLock lock(stateLock); + lastStatus = status; + callbackCopy = statusCallback; + } + + if (callbackCopy) + callbackCopy(status); +} + +juce::String AppUpdater::getManifestUrl() +{ + #ifdef OPENSTUDIO_UPDATE_MANIFEST_URL + return juce::String(OPENSTUDIO_UPDATE_MANIFEST_URL); + #else + return {}; + #endif +} + +juce::String AppUpdater::getAppcastUrl() +{ + #ifdef OPENSTUDIO_UPDATE_APPCAST_URL + return juce::String(OPENSTUDIO_UPDATE_APPCAST_URL); + #else + return {}; + #endif +} + +juce::String AppUpdater::getFallbackReleasesPageUrl() +{ + #ifdef OPENSTUDIO_RELEASES_PAGE_URL + return juce::String(OPENSTUDIO_RELEASES_PAGE_URL); + #else + return {}; + #endif +} + +juce::String AppUpdater::getPlatformKey() +{ + #if JUCE_WINDOWS + return "windows"; + #elif JUCE_MAC + return "macos"; + #else + return "unsupported"; + #endif +} + +juce::String AppUpdater::getCurrentChannel() +{ + #ifdef OPENSTUDIO_UPDATE_CHANNEL + return juce::String(OPENSTUDIO_UPDATE_CHANNEL); + #else + return "stable"; + #endif +} + +bool AppUpdater::shouldSkipAutomaticCheck() const +{ + const juce::ScopedLock lock(stateLock); + const auto lastSuccessfulCheckAtMs = getInt64Property(persistedState, "lastSuccessfulCheckAtMs", 0); + + if (lastSuccessfulCheckAtMs <= 0) + return false; + + const auto elapsedMs = juce::Time::getCurrentTime().toMilliseconds() - lastSuccessfulCheckAtMs; + return elapsedMs >= 0 && elapsedMs < kAutomaticUpdateCheckIntervalMs; +} + +void AppUpdater::recordSuccessfulCheck(const juce::String& latestVersion, const juce::String& publishedAt) +{ + const juce::ScopedLock lock(stateLock); + + auto* stateObject = persistedState.getDynamicObject(); + if (stateObject == nullptr) + { + persistedState = juce::var(new juce::DynamicObject()); + stateObject = persistedState.getDynamicObject(); + } + + stateObject->setProperty("lastSuccessfulCheckAtMs", juce::Time::getCurrentTime().toMilliseconds()); + stateObject->setProperty("lastSeenVersion", latestVersion); + stateObject->setProperty("lastPublishedAt", publishedAt); + stateObject->setProperty("channel", getCurrentChannel()); + savePersistedState(); +} + +void AppUpdater::savePersistedState() const +{ + const auto stateFile = getPreferredUpdaterStateFile(); + stateFile.getParentDirectory().createDirectory(); + stateFile.replaceWithText(juce::JSON::toString(persistedState)); +} + +int AppUpdater::compareVersions(const juce::String& lhs, const juce::String& rhs) +{ + const auto left = tokenizeVersion(lhs); + const auto right = tokenizeVersion(rhs); + const auto count = juce::jmax(left.size(), right.size()); + + for (int i = 0; i < count; ++i) + { + const auto leftValue = i < left.size() ? left[i].getIntValue() : 0; + const auto rightValue = i < right.size() ? right[i].getIntValue() : 0; + + if (leftValue < rightValue) + return -1; + if (leftValue > rightValue) + return 1; + } + + return 0; +} + +juce::StringArray AppUpdater::tokenizeVersion(const juce::String& version) +{ + auto cleaned = version.retainCharacters("0123456789."); + if (cleaned.isEmpty()) + cleaned = "0"; + + juce::StringArray tokens; + tokens.addTokens(cleaned, ".", {}); + tokens.removeEmptyStrings(); + + if (tokens.isEmpty()) + tokens.add("0"); + + return tokens; +} + +juce::String AppUpdater::getDownloadFileName(const juce::URL& url, const juce::String& version) +{ + auto path = url.toString(false); + auto slash = path.lastIndexOfChar('/'); + auto candidate = slash >= 0 ? path.substring(slash + 1) : path; + + if (candidate.containsChar('?')) + candidate = candidate.upToFirstOccurrenceOf("?", false, false); + + if (candidate.isNotEmpty()) + return candidate; + + #if JUCE_WINDOWS + return "OpenStudio-" + version + "-Setup.exe"; + #elif JUCE_MAC + return "OpenStudio-" + version + ".dmg"; + #else + return "OpenStudio-" + version; + #endif +} + +bool AppUpdater::downloadToFile(const juce::URL& url, const juce::File& targetFile, juce::String& error) +{ + const auto tempFile = targetFile.getSiblingFile(targetFile.getFileName() + ".download"); + tempFile.deleteFile(); + + auto input = url.createInputStream( + juce::URL::InputStreamOptions(juce::URL::ParameterHandling::inAddress) + .withConnectionTimeoutMs(15000) + .withNumRedirectsToFollow(5)); + + if (input == nullptr) + { + error = "Could not connect to the update download URL."; + return false; + } + + juce::FileOutputStream output(tempFile); + if (!output.openedOk()) + { + error = "Could not create a temporary installer file."; + return false; + } + + if (output.writeFromInputStream(*input, -1) <= 0) + { + error = "The installer download was incomplete."; + tempFile.deleteFile(); + return false; + } + + output.flush(); + + if (targetFile.existsAsFile() && !targetFile.deleteFile()) + { + error = "Could not replace the previous downloaded installer."; + tempFile.deleteFile(); + return false; + } + + if (!tempFile.moveFileTo(targetFile)) + { + error = "Could not move the downloaded installer into place."; + tempFile.deleteFile(); + return false; + } + + return true; +} + +bool AppUpdater::verifyDownloadedFileSize(const juce::File& targetFile, juce::int64 expectedSize, juce::String& error) +{ + if (expectedSize <= 0) + return true; + + if (!targetFile.existsAsFile()) + { + error = "The downloaded installer file could not be found for size verification."; + return false; + } + + if (targetFile.getSize() == expectedSize) + return true; + + error = "The downloaded update failed size verification."; + return false; +} + +bool AppUpdater::verifyDownloadedFileSha256(const juce::File& targetFile, const juce::String& expectedSha256, juce::String& error) +{ + const auto normalizedExpected = expectedSha256.trim().toLowerCase(); + if (normalizedExpected.isEmpty()) + return true; + + if (!targetFile.existsAsFile()) + { + error = "The downloaded installer file could not be found for checksum verification."; + return false; + } + + juce::FileInputStream input(targetFile); + if (!input.openedOk()) + { + error = "OpenStudio could not read the downloaded update to verify it."; + return false; + } + + const auto actual = juce::SHA256(input).toHexString().toLowerCase(); + if (actual == normalizedExpected) + return true; + + error = "The downloaded update failed checksum verification."; + return false; +} + +bool AppUpdater::launchDownloadedInstaller(const juce::File& installerFile, + const juce::String& installerArguments, + juce::String& error) +{ + if (!installerFile.existsAsFile()) + { + error = "The downloaded installer file could not be found."; + return false; + } + + if (installerFile.startAsProcess(installerArguments.trim())) + return true; + + if (juce::URL(installerFile).launchInDefaultBrowser()) + return true; + + error = "The update was downloaded, but OpenStudio could not open it automatically."; + return false; +} + +juce::var AppUpdater::makeStatus(const juce::String& status, + const juce::String& message, + const juce::String& version, + const juce::String& downloadUrl, + const juce::String& sha256, + const juce::String& notes, + const juce::String& releasePageUrl, + const juce::String& releaseNotesUrl, + const juce::String& publishedAt, + juce::int64 expectedSize, + const juce::String& fileName, + const juce::String& channel, + bool mandatory, + const juce::String& installerArguments, + const juce::String& updateSource) +{ + auto* obj = new juce::DynamicObject(); + obj->setProperty("status", status); + obj->setProperty("message", message); + obj->setProperty("version", version); + obj->setProperty("downloadUrl", downloadUrl); + obj->setProperty("sha256", sha256); + obj->setProperty("notes", notes); + obj->setProperty("releasePageUrl", releasePageUrl); + obj->setProperty("releaseNotesUrl", releaseNotesUrl); + obj->setProperty("publishedAt", publishedAt); + obj->setProperty("size", expectedSize); + obj->setProperty("fileName", fileName); + obj->setProperty("channel", channel.isNotEmpty() ? channel : getCurrentChannel()); + obj->setProperty("mandatory", mandatory); + obj->setProperty("installerArguments", installerArguments); + obj->setProperty("updateSource", updateSource); + obj->setProperty("currentVersion", ProjectInfo::versionString); + obj->setProperty("platform", getPlatformKey()); + return juce::var(obj); +} diff --git a/Source/AppUpdater.h b/Source/AppUpdater.h new file mode 100644 index 0000000..e78a85a --- /dev/null +++ b/Source/AppUpdater.h @@ -0,0 +1,81 @@ +#pragma once + +#include +#include +#include + +class AppUpdater +{ +public: + using Completion = std::function; + using StatusCallback = std::function; + + AppUpdater(); + ~AppUpdater(); + + juce::String getCurrentVersion() const; + juce::var getLastStatus() const; + + void setStatusCallback(StatusCallback callback); + void checkForUpdates(bool manual, Completion completion = {}); + void downloadAndInstallUpdate(const juce::String& downloadUrl, + const juce::String& version, + const juce::String& expectedSha256, + const juce::String& releasePageUrl, + const juce::String& installerArguments = {}, + juce::int64 expectedSize = 0, + Completion completion = {}); + +private: + juce::var performUpdateCheck(); + juce::var performDownloadAndInstall(const juce::String& downloadUrl, + const juce::String& version, + const juce::String& expectedSha256, + const juce::String& releasePageUrl, + const juce::String& installerArguments, + juce::int64 expectedSize) const; + + void publishStatus(const juce::var& status); + bool shouldSkipAutomaticCheck() const; + void recordSuccessfulCheck(const juce::String& latestVersion, const juce::String& publishedAt); + void savePersistedState() const; + + static juce::String getManifestUrl(); + static juce::String getAppcastUrl(); + static juce::String getFallbackReleasesPageUrl(); + static juce::String getPlatformKey(); + static juce::String getCurrentChannel(); + static int compareVersions(const juce::String& lhs, const juce::String& rhs); + static juce::StringArray tokenizeVersion(const juce::String& version); + static juce::String getDownloadFileName(const juce::URL& url, const juce::String& version); + static bool downloadToFile(const juce::URL& url, const juce::File& targetFile, juce::String& error); + static bool verifyDownloadedFileSize(const juce::File& targetFile, juce::int64 expectedSize, juce::String& error); + static bool verifyDownloadedFileSha256(const juce::File& targetFile, const juce::String& expectedSha256, juce::String& error); + static bool launchDownloadedInstaller(const juce::File& installerFile, + const juce::String& installerArguments, + juce::String& error); + static juce::var makeStatus(const juce::String& status, + const juce::String& message, + const juce::String& version = {}, + const juce::String& downloadUrl = {}, + const juce::String& sha256 = {}, + const juce::String& notes = {}, + const juce::String& releasePageUrl = {}, + const juce::String& releaseNotesUrl = {}, + const juce::String& publishedAt = {}, + juce::int64 expectedSize = 0, + const juce::String& fileName = {}, + const juce::String& channel = {}, + bool mandatory = false, + const juce::String& installerArguments = {}, + const juce::String& updateSource = {}); + + mutable juce::CriticalSection stateLock; + juce::var lastStatus; + juce::var persistedState; + StatusCallback statusCallback; + std::atomic checkInProgress { false }; + std::atomic installInProgress { false }; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(AppUpdater) +}; diff --git a/Source/ApplicationLaunchState.cpp b/Source/ApplicationLaunchState.cpp new file mode 100644 index 0000000..3ea62f0 --- /dev/null +++ b/Source/ApplicationLaunchState.cpp @@ -0,0 +1,96 @@ +#include "ApplicationLaunchState.h" + +namespace +{ +juce::CriticalSection& getLaunchStateLock() +{ + static juce::CriticalSection lock; + return lock; +} + +juce::String& getPendingProjectPathStorage() +{ + static juce::String pendingProjectPath; + return pendingProjectPath; +} + +bool isSupportedProjectPath(const juce::String& candidate) +{ + const auto trimmed = candidate.trim(); + if (trimmed.isEmpty()) + return false; + + const auto lower = trimmed.toLowerCase(); + return lower.endsWith(".osproj") || lower.endsWith(".s13"); +} + +void addCandidate(juce::StringArray& candidates, const juce::String& candidate) +{ + const auto normalized = candidate.trim().unquoted(); + if (normalized.isNotEmpty()) + candidates.addIfNotAlreadyThere(normalized); +} +} + +juce::String OpenStudioLaunchState::normalisePendingProjectPath(const juce::String& commandLineOrPath) +{ + juce::StringArray candidates; + const auto trimmed = commandLineOrPath.trim(); + + addCandidate(candidates, trimmed); + + juce::StringArray tokens; + tokens.addTokens(trimmed, " ", "\""); + for (const auto& token : tokens) + addCandidate(candidates, token); + + auto remaining = trimmed; + while (remaining.containsChar('"')) + { + const auto firstQuote = remaining.indexOfChar('"'); + if (firstQuote < 0) + break; + + remaining = remaining.substring(firstQuote + 1); + const auto secondQuote = remaining.indexOfChar('"'); + if (secondQuote < 0) + break; + + addCandidate(candidates, remaining.substring(0, secondQuote)); + remaining = remaining.substring(secondQuote + 1); + } + + for (const auto& candidate : candidates) + { + if (!isSupportedProjectPath(candidate)) + continue; + + const juce::File file(candidate); + if (file.existsAsFile()) + return file.getFullPathName(); + } + + for (const auto& candidate : candidates) + if (isSupportedProjectPath(candidate)) + return candidate; + + return {}; +} + +void OpenStudioLaunchState::setPendingProjectPath(const juce::String& projectPath) +{ + const auto normalized = normalisePendingProjectPath(projectPath); + if (normalized.isEmpty()) + return; + + const juce::ScopedLock lock(getLaunchStateLock()); + getPendingProjectPathStorage() = normalized; +} + +juce::String OpenStudioLaunchState::consumePendingProjectPath() +{ + const juce::ScopedLock lock(getLaunchStateLock()); + auto result = getPendingProjectPathStorage(); + getPendingProjectPathStorage().clear(); + return result; +} diff --git a/Source/ApplicationLaunchState.h b/Source/ApplicationLaunchState.h new file mode 100644 index 0000000..4607907 --- /dev/null +++ b/Source/ApplicationLaunchState.h @@ -0,0 +1,10 @@ +#pragma once + +#include + +namespace OpenStudioLaunchState +{ +juce::String normalisePendingProjectPath(const juce::String& commandLineOrPath); +void setPendingProjectPath(const juce::String& projectPath); +juce::String consumePendingProjectPath(); +} diff --git a/Source/AudioAnalyzer.cpp b/Source/AudioAnalyzer.cpp index 9be75d1..19d8627 100644 --- a/Source/AudioAnalyzer.cpp +++ b/Source/AudioAnalyzer.cpp @@ -198,6 +198,119 @@ std::vector AudioAnalyzer::detectTransients(const juce::String& filePath return transients; } +// ============================================================================= +// Phase 3.12: Strip Silence — Detect Silent Regions +// ============================================================================= + +std::vector AudioAnalyzer::detectSilentRegions( + const juce::String& filePath, + double thresholdDb, + double minSilenceMs, + double minSoundMs, + double preAttackMs, + double postReleaseMs) +{ + std::vector regions; + + juce::File inputFile(filePath); + if (!inputFile.existsAsFile()) + return regions; + + std::unique_ptr reader( + formatManager.createReaderFor(inputFile)); + if (!reader) + return regions; + + auto totalSamples = reader->lengthInSamples; + auto sr = reader->sampleRate; + int numChannels = (int)reader->numChannels; + + if (totalSamples <= 0 || sr <= 0) + return regions; + + // Convert parameters from time to samples + float thresholdLinear = juce::Decibels::decibelsToGain((float)thresholdDb); + juce::int64 minSilenceSamples = (juce::int64)(minSilenceMs * sr / 1000.0); + juce::int64 minSoundSamples = (juce::int64)(minSoundMs * sr / 1000.0); + juce::int64 preAttackSamples = (juce::int64)(preAttackMs * sr / 1000.0); + juce::int64 postReleaseSamples = (juce::int64)(postReleaseMs * sr / 1000.0); + + // Process in chunks to handle large files + const int chunkSize = 65536; + juce::AudioBuffer buffer(numChannels, chunkSize); + + bool inSound = false; + juce::int64 soundStart = 0; + juce::int64 silenceStart = 0; + + for (juce::int64 pos = 0; pos < totalSamples; pos += chunkSize) + { + int samplesToRead = (int)std::min((juce::int64)chunkSize, totalSamples - pos); + reader->read(&buffer, 0, samplesToRead, pos, true, true); + + for (int i = 0; i < samplesToRead; ++i) + { + // Get peak across all channels + float peak = 0.0f; + for (int ch = 0; ch < numChannels; ++ch) + peak = std::max(peak, std::abs(buffer.getSample(ch, i))); + + bool isAboveThreshold = peak >= thresholdLinear; + juce::int64 samplePos = pos + i; + + if (!inSound) + { + if (isAboveThreshold) + { + // Transition to sound + inSound = true; + soundStart = samplePos; + silenceStart = 0; + } + } + else + { + if (!isAboveThreshold) + { + if (silenceStart == 0) + silenceStart = samplePos; + + // Check if silence duration exceeds minimum + if ((samplePos - silenceStart) >= minSilenceSamples) + { + // End of sound region at silence start + juce::int64 regionStart = std::max((juce::int64)0, soundStart - preAttackSamples); + juce::int64 regionEnd = std::min(totalSamples, silenceStart + postReleaseSamples); + + if ((regionEnd - regionStart) >= minSoundSamples) + regions.push_back({ regionStart, regionEnd }); + + inSound = false; + silenceStart = 0; + } + } + else + { + // Still in sound — reset silence counter + silenceStart = 0; + } + } + } + } + + // Handle trailing sound region + if (inSound) + { + juce::int64 regionStart = std::max((juce::int64)0, soundStart - preAttackSamples); + juce::int64 regionEnd = totalSamples; + + if ((regionEnd - regionStart) >= minSoundSamples) + regions.push_back({ regionStart, regionEnd }); + } + + return regions; +} + // ============================================================================= // Phase 9D: LUFS Measurement (ITU-R BS.1770-4) // ============================================================================= diff --git a/Source/AudioAnalyzer.h b/Source/AudioAnalyzer.h index 84ac67f..5af3ce1 100644 --- a/Source/AudioAnalyzer.h +++ b/Source/AudioAnalyzer.h @@ -47,6 +47,26 @@ class AudioAnalyzer double startTime = 0.0, double endTime = 0.0); + // ===== Strip Silence / Detect Silent Regions (Phase 3.12) ===== + struct SoundRegion { + juce::int64 startSample; + juce::int64 endSample; + }; + + // Scans audio file for non-silent regions. + // thresholdDb: amplitude threshold (e.g., -48) + // minSilenceMs: minimum silence gap to split on + // minSoundMs: minimum sound region to keep + // preAttackMs: include audio before detected transient + // postReleaseMs: include audio after sound ends + // Returns list of non-silent regions as sample ranges. + std::vector detectSilentRegions(const juce::String& filePath, + double thresholdDb, + double minSilenceMs, + double minSoundMs, + double preAttackMs, + double postReleaseMs); + private: juce::AudioFormatManager formatManager; diff --git a/Source/AudioEngine.cpp b/Source/AudioEngine.cpp index a415f5d..ed21608 100644 --- a/Source/AudioEngine.cpp +++ b/Source/AudioEngine.cpp @@ -1,2740 +1,9329 @@ #include "AudioEngine.h" +#include "S13FXProcessor.h" +#include "BuiltInEffects.h" +#include "BuiltInEffects2.h" +#include "S13PitchCorrector.h" +#include "PitchAnalyzer.h" +#include "PitchResynthesizer.h" + +namespace +{ +juce::File getOpenStudioDocumentsDirectory() +{ + auto documentsDir = juce::File::getSpecialLocation(juce::File::userDocumentsDirectory); + return documentsDir.getChildFile("OpenStudio"); +} + +juce::File getLegacyStudio13DocumentsDirectory() +{ + auto documentsDir = juce::File::getSpecialLocation(juce::File::userDocumentsDirectory); + return documentsDir.getChildFile("Studio13"); +} + +juce::File getOpenStudioApplicationDataDirectory() +{ + auto appDataDir = juce::File::getSpecialLocation(juce::File::userApplicationDataDirectory); + return appDataDir.getChildFile("OpenStudio"); +} + +juce::File getLegacyStudio13ApplicationDataDirectory() +{ + auto appDataDir = juce::File::getSpecialLocation(juce::File::userApplicationDataDirectory); + return appDataDir.getChildFile("Studio13"); +} + +juce::File getPreferredAppDataDirectory() +{ + auto openStudioDir = getOpenStudioDocumentsDirectory(); + auto legacyDir = getLegacyStudio13DocumentsDirectory(); + + if (!openStudioDir.exists() && legacyDir.exists()) + return legacyDir; + + return openStudioDir; +} + +juce::File getPreferredApplicationDataDirectory() +{ + auto openStudioDir = getOpenStudioApplicationDataDirectory(); + auto legacyDir = getLegacyStudio13ApplicationDataDirectory(); + + if (!openStudioDir.exists() && legacyDir.exists()) + return legacyDir; + + return openStudioDir; +} +} // Debug logging — always active for FX diagnostics static void logToDisk(const juce::String& msg) { - auto f = juce::File::getSpecialLocation(juce::File::userDocumentsDirectory) - .getChildFile("Studio13").getChildFile("debug_log.txt"); + auto f = getPreferredAppDataDirectory().getChildFile("debug_log.txt"); + f.getParentDirectory().createDirectory(); f.appendText(juce::Time::getCurrentTime().toString(true, true) + ": " + msg + "\n"); } -AudioEngine::AudioEngine() +#if JUCE_DEBUG +static constexpr bool kPitchEditorFormantDebugLogs = true; +#else +static constexpr bool kPitchEditorFormantDebugLogs = false; +#endif + +static void logPitchEditorFormant(const juce::String& msg) { - // Initialize graphs and managers BEFORE opening the audio device. - // addAudioCallback() (below) triggers audioDeviceAboutToStart() synchronously - // on an already-running device. audioDeviceAboutToStart() checks - // `if (mainProcessorGraph)` before doing any setup, so the graph must exist - // by the time addAudioCallback() is called — otherwise the null check - // short-circuits and reusableTrackBuffer/metronome/IO nodes are never set up. - mainProcessorGraph = std::make_unique(); - masterFXChain = std::make_unique(); - monitoringFXChain = std::make_unique(); + if (kPitchEditorFormantDebugLogs) + juce::Logger::writeToLog("[pitchEditor.formant] " + msg); +} - // Initialize MIDI Manager (Phase 2) - midiManager = std::make_unique(); - midiManager->setMessageCallback([this](const juce::String& deviceName, int channel, const juce::MidiMessage& message) { - handleMIDIMessage(deviceName, channel, message); - }); +#if JUCE_DEBUG +static constexpr bool kAudioPathDebugLogs = true; +#else +static constexpr bool kAudioPathDebugLogs = false; +#endif - // Now open the audio device and register the callback. This is done last so - // that all members above are valid when audioDeviceAboutToStart() fires. - loadDeviceSettings(); - deviceManager.addAudioCallback (this); +static void logAudioTransport(const juce::String& msg) +{ + if (kAudioPathDebugLogs) + juce::Logger::writeToLog("[audio.transport] " + msg); +} - juce::Logger::writeToLog("AudioEngine: MIDI Manager initialized"); +static void logAudioPlayback(const juce::String& msg) +{ + if (kAudioPathDebugLogs) + juce::Logger::writeToLog("[audio.playback] " + msg); } -AudioEngine::~AudioEngine() +static void logAudioRecord(const juce::String& msg) { - deviceManager.removeAudioCallback (this); + if (kAudioPathDebugLogs) + juce::Logger::writeToLog("[audio.record] " + msg); } -juce::File AudioEngine::getDeviceSettingsFile() const +static void logAudioDevice(const juce::String& msg) { - return juce::File::getSpecialLocation(juce::File::userApplicationDataDirectory) - .getChildFile("Studio13") - .getChildFile("audio_device_settings.xml"); + if (kAudioPathDebugLogs) + juce::Logger::writeToLog("[audio.device] " + msg); } -void AudioEngine::saveDeviceSettings() +static float peakFromFloatChannels(const float* const* channelData, int numChannels, int numSamples) { - auto xml = deviceManager.createStateXml(); - if (xml) + float peak = 0.0f; + for (int ch = 0; ch < numChannels; ++ch) { - auto settingsFile = getDeviceSettingsFile(); - settingsFile.getParentDirectory().createDirectory(); - xml->writeTo(settingsFile); - juce::Logger::writeToLog("AudioEngine: Device settings saved to " + settingsFile.getFullPathName()); + if (channelData[ch] == nullptr) + continue; + + auto range = juce::FloatVectorOperations::findMinAndMax(channelData[ch], numSamples); + peak = juce::jmax(peak, juce::jmax(std::abs(range.getStart()), std::abs(range.getEnd()))); } + return peak; } -void AudioEngine::loadDeviceSettings() +static float peakFromFloatBuffer(const juce::AudioBuffer& buffer, int numSamples) { - auto settingsFile = getDeviceSettingsFile(); - if (settingsFile.existsAsFile()) + float peak = 0.0f; + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) { - auto xml = juce::XmlDocument::parse(settingsFile); - if (xml) - { - auto error = deviceManager.initialise(2, 2, xml.get(), true); - if (error.isEmpty()) - { - juce::Logger::writeToLog("AudioEngine: Restored device settings from " + settingsFile.getFullPathName()); - return; - } - juce::Logger::writeToLog("AudioEngine: Failed to restore settings: " + error + " - using defaults"); - } + auto range = juce::FloatVectorOperations::findMinAndMax(buffer.getReadPointer(ch), numSamples); + peak = juce::jmax(peak, juce::jmax(std::abs(range.getStart()), std::abs(range.getEnd()))); } - - // No saved settings or failed to load - use defaults - deviceManager.initialiseWithDefaultDevices(2, 2); + return peak; } -void AudioEngine::audioDeviceAboutToStart (juce::AudioIODevice* device) +static float peakFromDoubleBuffer(const juce::AudioBuffer& buffer, int numChannels, int numSamples) { - logToDisk("AudioEngine: Device About To Start"); - if (device == nullptr) + float peak = 0.0f; + for (int ch = 0; ch < numChannels; ++ch) { - juce::Logger::writeToLog("AudioEngine ERROR: audioDeviceAboutToStart called with nullptr device!"); + const auto* data = buffer.getReadPointer(ch); + double localPeak = 0.0; + for (int sample = 0; sample < numSamples; ++sample) + localPeak = juce::jmax(localPeak, std::abs(data[sample])); + peak = juce::jmax(peak, static_cast(localPeak)); + } + return peak; +} + +static std::unique_ptr createBuiltInEffect(const juce::String& name); + +static void prepareHostedProcessorForPrecision(juce::AudioProcessor* proc, double sampleRate, + int blockSize, ProcessingPrecisionMode precisionMode) +{ + if (proc == nullptr) return; + + const juce::ScopedLock callbackLock(proc->getCallbackLock()); + + if (proc->supportsDoublePrecisionProcessing()) + { + proc->setProcessingPrecision( + precisionMode == ProcessingPrecisionMode::Hybrid64 + ? juce::AudioProcessor::doublePrecision + : juce::AudioProcessor::singlePrecision); } - currentSampleRate = device->getCurrentSampleRate(); - currentBlockSize = device->getCurrentBufferSizeSamples(); - inputLatencySamples = device->getInputLatencyInSamples(); - logToDisk("Input latency: " + juce::String(inputLatencySamples) + " samples (" - + juce::String(inputLatencySamples / currentSampleRate * 1000.0, 1) + " ms)"); - if (mainProcessorGraph) + + proc->prepareToPlay(sampleRate, blockSize); +} + +static void prepareHostedProcessorPreservingLayout(juce::AudioProcessor* proc, double sampleRate, + int blockSize, ProcessingPrecisionMode precisionMode) +{ + if (proc == nullptr) + return; + + const juce::ScopedLock callbackLock(proc->getCallbackLock()); + + if (proc->supportsDoublePrecisionProcessing()) { - // ... (keep existing config logic) - mainProcessorGraph->setPlayConfigDetails (device->getActiveInputChannels().countNumberOfSetBits(), - device->getActiveOutputChannels().countNumberOfSetBits(), - device->getCurrentSampleRate(), - device->getCurrentBufferSizeSamples()); - mainProcessorGraph->prepareToPlay (device->getCurrentSampleRate(), - device->getCurrentBufferSizeSamples()); + proc->setProcessingPrecision( + precisionMode == ProcessingPrecisionMode::Hybrid64 + ? juce::AudioProcessor::doublePrecision + : juce::AudioProcessor::singlePrecision); + } - // Pre-allocate reusable buffers (avoids malloc on audio thread) - reusableTrackBuffer.setSize (2, device->getCurrentBufferSizeSamples()); - reusableMasterBuffer.setSize (device->getActiveOutputChannels().countNumberOfSetBits(), - device->getCurrentBufferSizeSamples()); + auto savedLayout = proc->getBusesLayout(); + proc->prepareToPlay(sampleRate, blockSize); - // Metronome Init - metronome.prepareToPlay(device->getCurrentSampleRate(), device->getCurrentBufferSizeSamples()); - metronome.setBpm(tempo); - metronome.setTimeSignature(timeSigNumerator, timeSigDenominator); - - // Propagate this AudioEngine as the AudioPlayHead to all existing plugins - // so they receive tempo/position during processBlock. - for (const auto& id : trackOrder) - { - auto it = trackMap.find (id); - if (it != trackMap.end()) - propagatePlayHead (it->second); - } - for (auto& node : masterFXNodes) - if (node && node->getProcessor()) - node->getProcessor()->setPlayHead (this); + if (proc->getBusesLayout() != savedLayout) + { + proc->setBusesLayout(savedLayout); + proc->prepareToPlay(sampleRate, blockSize); + } +} - // mainProcessorGraph->clear(); // COMMENTED OUT TO DEBUG - This wipes tracks! +static void prepareHostedProcessorForPrecisionOverride(juce::AudioProcessor* proc, double sampleRate, + int blockSize, ProcessingPrecisionMode precisionMode, + bool forceFloat) +{ + prepareHostedProcessorForPrecision(proc, sampleRate, blockSize, + forceFloat ? ProcessingPrecisionMode::Float32 : precisionMode); +} - // Add IO Nodes only if missing - if (!audioInputNode) { - logToDisk("Adding IO Nodes..."); - audioInputNode = mainProcessorGraph->addNode (std::make_unique (juce::AudioProcessorGraph::AudioGraphIOProcessor::audioInputNode)); - audioOutputNode = mainProcessorGraph->addNode (std::make_unique (juce::AudioProcessorGraph::AudioGraphIOProcessor::audioOutputNode)); - - // Connect any existing tracks that were added before device init - if (audioOutputNode) { - logToDisk("Connecting existing tracks to output..."); - int connectedCount = 0; - - for (auto* node : mainProcessorGraph->getNodes()) { - // Skip IO nodes - if (node == audioInputNode.get() || node == audioOutputNode.get()) - continue; - - // Check if this is a TrackProcessor - if (dynamic_cast(node->getProcessor())) { - // Connect stereo channels - mainProcessorGraph->addConnection ({ { node->nodeID, 0 }, { audioOutputNode->nodeID, 0 } }); - mainProcessorGraph->addConnection ({ { node->nodeID, 1 }, { audioOutputNode->nodeID, 1 } }); - connectedCount++; - } - } - - logToDisk("Connected " + juce::String(connectedCount) + " existing tracks."); - } - } +static void copyFloatBufferToDoubleBuffer(const juce::AudioBuffer& source, + juce::AudioBuffer& destination, + int numChannels, int numSamples) +{ + for (int ch = 0; ch < numChannels; ++ch) + { + auto* dest = destination.getWritePointer(ch); + auto* src = source.getReadPointer(ch); + for (int sample = 0; sample < numSamples; ++sample) + dest[sample] = static_cast(src[sample]); } } -void AudioEngine::audioDeviceStopped() +static void copyDoubleBufferToFloatBuffer(const juce::AudioBuffer& source, + juce::AudioBuffer& destination, + int numChannels, int numSamples) { - logToDisk("AudioEngine: Device Stopped"); - if (mainProcessorGraph) + for (int ch = 0; ch < numChannels; ++ch) { - mainProcessorGraph->releaseResources(); - // mainProcessorGraph->clear(); // Prevent wiping tracks on stop - // audioInputNode = nullptr; - // audioOutputNode = nullptr; + auto* dest = destination.getWritePointer(ch); + auto* src = source.getReadPointer(ch); + for (int sample = 0; sample < numSamples; ++sample) + dest[sample] = static_cast(src[sample]); } } -void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputChannelData, - int numInputChannels, - float* const* outputChannelData, - int numOutputChannels, - int numSamples, - const juce::AudioIODeviceCallbackContext& context) +static void copyDoubleBufferToOutput(const juce::AudioBuffer& source, + float* const* outputChannelData, + int numChannels, int numSamples) { - juce::ignoreUnused(context); - // Clear outputs first - for (int i = 0; i < numOutputChannels; ++i) - juce::FloatVectorOperations::clear (outputChannelData[i], numSamples); + for (int ch = 0; ch < numChannels; ++ch) + { + auto* src = source.getReadPointer(ch); + auto* dest = outputChannelData[ch]; + for (int sample = 0; sample < numSamples; ++sample) + dest[sample] = static_cast(src[sample]); + } +} - // During offline rendering, skip ALL processing to avoid sharing FX plugin - // instances between the audio callback and the render thread - if (isRendering.load()) +static void computePanLawGains(PanLaw panLaw, float pan, float volumeGain, + float& leftGain, float& rightGain) +{ + const float clampedPan = juce::jlimit(-1.0f, 1.0f, pan); + const float normalizedPan = (clampedPan + 1.0f) * 0.5f; + const float panAngle = (clampedPan + 1.0f) * juce::MathConstants::pi / 4.0f; + + switch (panLaw) + { + case PanLaw::Minus4_5dB: + { + const float cpL = std::cos(panAngle); + const float cpR = std::sin(panAngle); + const float linL = 1.0f - normalizedPan; + const float linR = normalizedPan; + leftGain = (cpL + linL) * 0.5f * volumeGain; + rightGain = (cpR + linR) * 0.5f * volumeGain; + break; + } + case PanLaw::Minus6dB: + { + leftGain = (1.0f - normalizedPan) * volumeGain; + rightGain = normalizedPan * volumeGain; + break; + } + case PanLaw::Linear: + { + leftGain = juce::jmin(1.0f, 1.0f - clampedPan) * volumeGain; + rightGain = juce::jmin(1.0f, 1.0f + clampedPan) * volumeGain; + break; + } + case PanLaw::ConstantPower: + default: + { + leftGain = std::cos(panAngle) * volumeGain; + rightGain = std::sin(panAngle) * volumeGain; + break; + } + } +} + +static void applyGainToDoubleBuffer(juce::AudioBuffer& buffer, + int numChannels, int numSamples, double gain) +{ + if (gain == 1.0) return; - // Acquire the callback lock to safely access trackOrder, trackMap, masterFXNodes. - // Use TryLock so we never block the audio thread - if the lock is held - // (e.g. during addTrack/removeTrack), we skip this buffer (~5ms silence). - // This prevents crackling from data races when the graph is being modified. - const juce::ScopedTryLock sl(mainProcessorGraph->getCallbackLock()); - if (!sl.isLocked()) - return; // Graph is being modified, output silence for this block + for (int ch = 0; ch < numChannels; ++ch) + { + auto* data = buffer.getWritePointer(ch); + for (int sample = 0; sample < numSamples; ++sample) + data[sample] *= gain; + } +} - // NOTE: currentSamplePosition is advanced AFTER all processing (metronome, - // playback, recording) so that everything in this callback uses the correct - // position for the samples being output right now. The increment is at the - // very end of the callback (search "Advance transport position"). +static void applyStereoPanToDoubleBuffer(juce::AudioBuffer& buffer, int numSamples, + double leftGain, double rightGain) +{ + if (buffer.getNumChannels() < 2) + return; - // Mix Metronome (if enabled and transport is running) - if (isPlaying || isRecordMode) + auto* left = buffer.getWritePointer(0); + auto* right = buffer.getWritePointer(1); + for (int sample = 0; sample < numSamples; ++sample) { - juce::AudioBuffer outputBuffer(const_cast(outputChannelData), numOutputChannels, numSamples); - metronome.getNextAudioBlock(outputBuffer, currentSamplePosition); + left[sample] *= leftGain; + right[sample] *= rightGain; } +} - // Use cached solo state (updated when solo changes, avoids scanning every callback) - bool anySoloed = cachedAnySoloed.load(); +static void downmixDoubleBufferToMono(juce::AudioBuffer& buffer, int numSamples) +{ + if (buffer.getNumChannels() < 2) + return; - // Process each track - for (size_t i = 0; i < trackOrder.size(); ++i) + auto* left = buffer.getWritePointer(0); + auto* right = buffer.getWritePointer(1); + for (int sample = 0; sample < numSamples; ++sample) { - const auto& trackId = trackOrder[i]; - auto trackIt = trackMap.find(trackId); // Single lookup, reuse iterator - if (trackIt == trackMap.end()) continue; + const double mono = (left[sample] + right[sample]) * 0.5; + left[sample] = mono; + right[sample] = mono; + } +} - auto* track = trackIt->second; - if (!track) - continue; +static float findPeakInDoubleBuffer(const juce::AudioBuffer& buffer, int numChannels, int numSamples) +{ + double peak = 0.0; + for (int ch = 0; ch < numChannels; ++ch) + { + auto* data = buffer.getReadPointer(ch); + for (int sample = 0; sample < numSamples; ++sample) + peak = juce::jmax(peak, std::abs(data[sample])); + } - // Solo logic: if any track is soloed, skip non-soloed tracks entirely - // (recording still works because record-armed tracks should also be soloed, - // and in practice users don't solo-off a track they're actively recording) - if (anySoloed && !track->getSolo()) - { - track->resetRMS(); - continue; - } + return static_cast(peak); +} - // Monitor hardware input when track is armed and not muted - bool shouldMonitor = track->getRecordArmed() && !track->getMute(); - +AudioEngine::AudioEngine() +{ + // Initialize graphs and managers BEFORE opening the audio device. + // addAudioCallback() (below) triggers audioDeviceAboutToStart() synchronously + // on an already-running device. audioDeviceAboutToStart() checks + // `if (mainProcessorGraph)` before doing any setup, so the graph must exist + // by the time addAudioCallback() is called — otherwise the null check + // short-circuits and reusableTrackBuffer/metronome/IO nodes are never set up. + mainProcessorGraph = std::make_unique(); - - // Get track's input configuration - int startChan = track->getInputStartChannel(); - int numChans = track->getInputChannelCount(); - - if (shouldMonitor) - { + // Initialize MIDI Manager (Phase 2) + midiManager = std::make_unique(); + midiManager->setMessageCallback([this](const juce::String& deviceName, int channel, const juce::MidiMessage& message) { + handleMIDIMessage(deviceName, channel, message); + }); - - // Safety check - if (startChan < 0 || startChan >= numInputChannels) - continue; - } - - // Skip this track if it's not playing clips and not monitoring - // This prevents unarmed tracks from passing through input audio - if (!isPlaying && !shouldMonitor) - { - track->resetRMS(); // Clear meter so it drops to zero when idle - continue; - } - - // Non-owning view of the pre-allocated buffer — avoids heap alloc on audio thread. - // ALWAYS 2 channels (stereo) for proper pan support. - // - // IMPORTANT: getWritePointer() is used (not getArrayOfWritePointers()) because - // it sets isClear=false on reusableTrackBuffer as a side effect. Without this, - // JUCE 8's isClear optimisation makes clear() a no-op when a previous iteration - // left isClear=true, and makes applyGain/getRMSLevel skip processing (they also - // guard on isClear), causing stale signal to accumulate across track iterations. - // - // Emergency guard: if the pre-allocated buffer is somehow undersized (shouldn't - // happen on ASIO but guards against unusual driver behaviour), resize it here. - // This heap-allocates only in that exceptional case. - if (reusableTrackBuffer.getNumSamples() < numSamples || reusableTrackBuffer.getNumChannels() < 2) - reusableTrackBuffer.setSize (2, numSamples, false, true); + // Initialize Control Surface Manager (Phase 3.10) + controlSurfaceManager.setCallback(this); - float* trackChans[2] = { reusableTrackBuffer.getWritePointer (0), - reusableTrackBuffer.getWritePointer (1) }; - juce::AudioBuffer trackBuffer (trackChans, 2, numSamples); - trackBuffer.clear(); - - // PLAYBACK MODE: Read from clips if transport is playing - if (isPlaying) - { - // Fill buffer with playback audio from clips - // Fill buffer with playback audio from clips - playbackEngine.fillTrackBuffer( - trackId, - trackBuffer, - currentSamplePosition / currentSampleRate, - numSamples, - currentSampleRate - ); - } - - // MONITORING/RECORDING MODE: Mix in input audio if monitoring - if (shouldMonitor) - { + // Now open the audio device and register the callback. This is done last so + // that all members above are valid when audioDeviceAboutToStart() fires. + loadDeviceSettings(); + deviceManager.addAudioCallback (this); - - // Copy hardware inputs to track buffer (mix with playback if both active) - // If input is mono, copy to both L/R channels for proper pan support - for (int ch = 0; ch < numChans && (startChan + ch) < numInputChannels; ++ch) - { - if (isPlaying) - { - // Mix input with playback (overdub mode) - juce::FloatVectorOperations::add( - trackBuffer.getWritePointer(ch), - inputChannelData[startChan + ch], - numSamples - ); - } - else - { - // Just copy input (monitoring only) - trackBuffer.copyFrom(ch, 0, inputChannelData[startChan + ch], numSamples); - } - } - - // If input is mono but trackBuffer is stereo, duplicate to right channel - if (numChans == 1 && trackBuffer.getNumChannels() >= 2) - { - trackBuffer.copyFrom(1, 0, trackBuffer, 0, 0, numSamples); - } - } - - // ========== RECORD RAW AUDIO (BEFORE FX) ========== - // Write to recorder if transport is playing AND in record mode - // This captures the raw input BEFORE any FX processing - if (isPlaying && isRecordMode && audioRecorder.isRecording(trackId)) - { - // Capture recording start time on the audio thread (race-free). - // The message thread sets pendingRecordStartCapture; we read it once - // here and stamp every active recording with the current position - // minus input latency. - if (pendingRecordStartCapture.exchange(false, std::memory_order_acq_rel)) - { - double latencyComp = inputLatencySamples / currentSampleRate; - double startPos = (currentSamplePosition / currentSampleRate) - latencyComp; - if (startPos < 0.0) startPos = 0.0; - // Set for all active recordings (they all started at the same point) - for (auto const& [recId, recTrack] : trackMap) - { - juce::ignoreUnused(recTrack); - if (audioRecorder.isRecording(recId)) - audioRecorder.setRecordingStartTime(recId, startPos); - } - } + // Initialize Lua scripting engine (runs on message thread only) + scriptEngine.registerAPI(*this); - audioRecorder.writeBlock(trackId, trackBuffer, numSamples); - } - - // Process through track (applies volume, pan, FX) - juce::MidiBuffer midiMessages; - track->processBlock(trackBuffer, midiMessages); - - // Mix track output to device outputs - // Tracks output stereo, so mix channels 0->0, 1->1 - for (int ch = 0; ch < std::min(trackBuffer.getNumChannels(), numOutputChannels); ++ch) - { - juce::FloatVectorOperations::add( - outputChannelData[ch], - trackBuffer.getReadPointer(ch), - numSamples - ); - } - - // Recording already done above (before FX) - this section removed - } - - // ========== Process Master FX Chain ========== - // Process master FX if any plugins are loaded - if (!masterFXNodes.empty()) - { - // Non-owning view of the pre-allocated master buffer — avoids heap alloc on audio thread. - // masterChans ensures we never ask for more channels than were pre-allocated. - const int masterChans = juce::jmin (numOutputChannels, reusableMasterBuffer.getNumChannels()); - juce::AudioBuffer masterBuffer (reusableMasterBuffer.getArrayOfWritePointers(), masterChans, numSamples); + realtimeTrackSnapshot = std::make_shared(); + realtimeMasterFXSnapshot = std::make_shared(); + realtimeMonitoringFXSnapshot = std::make_shared(); - // Copy current mixed output to master buffer - for (int ch = 0; ch < masterChans; ++ch) - masterBuffer.copyFrom (ch, 0, outputChannelData[ch], numSamples); + juce::Logger::writeToLog("AudioEngine: MIDI Manager initialized"); + juce::Logger::writeToLog("AudioEngine: Lua ScriptEngine initialized"); +} - // Process through each FX plugin in sequence - juce::MidiBuffer dummyMidi; - for (auto& node : masterFXNodes) - { - if (node && node->getProcessor()) - node->getProcessor()->processBlock (masterBuffer, dummyMidi); - } +AudioEngine::~AudioEngine() +{ + deviceManager.removeAudioCallback (this); +} - // Copy processed audio back to output - for (int ch = 0; ch < masterChans; ++ch) - juce::FloatVectorOperations::copy (outputChannelData[ch], masterBuffer.getReadPointer (ch), numSamples); - } - - // ========== Apply Master Pan ========== - // Uses pre-computed gains (updated in setMasterPan) — no trig on audio thread - if (numOutputChannels >= 2) - { - const float leftGain = cachedMasterPanL.load(std::memory_order_relaxed); - const float rightGain = cachedMasterPanR.load(std::memory_order_relaxed); +juce::String AudioEngine::serialiseProcessorStateToBase64(juce::AudioProcessor* processor) const +{ + if (processor == nullptr) + return {}; - juce::FloatVectorOperations::multiply(outputChannelData[0], leftGain, numSamples); - juce::FloatVectorOperations::multiply(outputChannelData[1], rightGain, numSamples); + juce::MemoryBlock stateData; + { + const juce::ScopedLock processorLock(processor->getCallbackLock()); + processor->getStateInformation(stateData); } - - // ========== Calculate Master Output Metering ========== - // REAPER-style: use findMinAndMax (SIMD, no sqrt) instead of manual RMS loops. - // Visuals only need ~30Hz; we accumulate across callbacks and write to - // masterOutputLevel once per MASTER_METER_UPDATE_SAMPLES block. + return stateData.toBase64Encoding(); +} + +bool AudioEngine::applyBase64StateToProcessor(juce::AudioProcessor* processor, const juce::String& base64State) const +{ + if (processor == nullptr || base64State.isEmpty()) + return processor != nullptr; + + juce::MemoryBlock stateData; + if (!stateData.fromBase64Encoding(base64State)) + return false; + + processor->setStateInformation(stateData.getData(), static_cast(stateData.getSize())); + return true; +} + +AudioEngine::ActiveFXStageSlot* AudioEngine::findActiveStageSlot(std::shared_ptr& stage, int slotId) +{ + if (!stage) + return nullptr; + + for (auto& slot : stage->slots) + if (slot.slotId == slotId) + return &slot; + + return nullptr; +} + +const AudioEngine::ActiveFXStageSlot* AudioEngine::findActiveStageSlot(const std::shared_ptr& stage, int slotId) const +{ + if (!stage) + return nullptr; + + for (const auto& slot : stage->slots) + if (slot.slotId == slotId) + return &slot; + + return nullptr; +} + +const AudioEngine::DesiredFXStageSlot* AudioEngine::findDesiredStageSlot(const DesiredFXStageSpec& spec, int index) const +{ + if (index < 0 || index >= static_cast(spec.slots.size())) + return nullptr; + + return &spec.slots[static_cast(index)]; +} + +AudioEngine::DesiredFXStageSlot* AudioEngine::findDesiredStageSlot(DesiredFXStageSpec& spec, int index) +{ + if (index < 0 || index >= static_cast(spec.slots.size())) + return nullptr; + + return &spec.slots[static_cast(index)]; +} + +void AudioEngine::syncStageSpecStateFromActive(DesiredFXStageSpec& spec, const std::shared_ptr& activeStage) +{ + if (!activeStage) + return; + + for (auto& slot : spec.slots) { - float peakL = 0.0f, peakR = 0.0f; - if (numOutputChannels >= 1) + if (const auto* activeSlot = findActiveStageSlot(activeStage, slot.slotId)) { - auto r = juce::FloatVectorOperations::findMinAndMax (outputChannelData[0], numSamples); - peakL = juce::jmax (-r.getStart(), r.getEnd()); - } - if (numOutputChannels >= 2) - { - auto r = juce::FloatVectorOperations::findMinAndMax (outputChannelData[1], numSamples); - peakR = juce::jmax (-r.getStart(), r.getEnd()); + if (activeSlot->processor) + slot.serializedState = serialiseProcessorStateToBase64(activeSlot->processor.get()); } + } +} - masterMeterPeakAccum = juce::jmax (masterMeterPeakAccum, juce::jmax (peakL, peakR)); - masterMeterSampleCount += numSamples; - if (masterMeterSampleCount >= MASTER_METER_UPDATE_SAMPLES) +std::unique_ptr AudioEngine::createProcessorForStageSlot(const DesiredFXStageSlot& slot, + double sampleRate, + int preparedBlockSize, + ProcessingPrecisionMode precisionMode, + juce::String& errorMessage) +{ + std::unique_ptr processor; + + if (slot.type == "builtin") + { + processor = createBuiltInEffect(slot.name); + } + else if (slot.type == "s13fx") + { + auto s13fx = std::make_unique(); + if (!s13fx->loadScript(slot.pluginPath)) { - masterOutputLevel.store (masterMeterPeakAccum, std::memory_order_relaxed); - masterMeterPeakAccum = 0.0f; - masterMeterSampleCount = 0; + errorMessage = "Failed to load S13FX script: " + slot.pluginPath; + return nullptr; } + processor = std::move(s13fx); } - - // ========== Apply Master Volume ========== - // Apply master volume to all output channels - for (int ch = 0; ch < numOutputChannels; ++ch) + else { - juce::FloatVectorOperations::multiply( - outputChannelData[ch], - masterVolume, - numSamples - ); + processor = pluginManager.loadPluginFromFile(slot.pluginPath, sampleRate, preparedBlockSize); } - // ========== Advance transport position ========== - // IMPORTANT: This is intentionally AFTER all processing (metronome, playback, - // recording) so they use the position corresponding to the samples being - // output in this callback. Previously this was at the top of the callback, - // causing a systematic one-buffer-length delay. - if (isPlaying) + if (!processor) { - currentSamplePosition += numSamples; + if (errorMessage.isEmpty()) + errorMessage = "Failed to create processor for slot: " + slot.name; + return nullptr; } -} -juce::String AudioEngine::addTrack(const juce::String& explicitId) -{ - logToDisk("AudioEngine: Adding Track..."); - - if (! mainProcessorGraph) - return ""; - - // In a real app, use a proper lock - const juce::ScopedLock sl (mainProcessorGraph->getCallbackLock()); + processor->setPlayHead(this); + prepareHostedProcessorForPrecisionOverride(processor.get(), sampleRate, preparedBlockSize, + precisionMode, slot.forceFloat); - // Guard: if an explicit ID is provided and already exists, return it without - // creating a duplicate. Duplicate IDs in trackOrder cause the same - // TrackProcessor to be iterated (and its FX plugins called) twice per audio - // callback, producing severe distortion from the second processing pass. - if (explicitId.isNotEmpty() && trackMap.count(explicitId) > 0) + if (!slot.serializedState.isEmpty() && !applyBase64StateToProcessor(processor.get(), slot.serializedState)) { - logToDisk("WARNING: addTrack called with already-existing ID " + explicitId + " — ignoring duplicate."); - return explicitId; + errorMessage = "Failed to restore processor state for slot: " + slot.name; + return nullptr; } - auto newTrack = std::make_unique(); - auto* rawTrackPtr = newTrack.get(); // Keep raw pointer for metering (owned by graph) + processor->reset(); + return processor; +} - // Prepare the TrackProcessor with the current device parameters before handing - // it to the graph. AudioProcessorGraph::addNode() does NOT call prepareToPlay() - // on newly-added nodes when the graph is already running, so we must do it here. - // Use jmax(currentBlockSize, 512) as the block-size hint — same rationale as - // for FX plugins (ASIO can use blocks as small as 32; preparing at that size - // forces plugins to resize convolution/FFT for tiny blocks → crackling). - if (currentSampleRate > 0 && currentBlockSize > 0) - rawTrackPtr->prepareToPlay (currentSampleRate, juce::jmax (currentBlockSize, 512)); +std::shared_ptr AudioEngine::buildActiveFXStage(const DesiredFXStageSpec& spec, + double sampleRate, + int preparedBlockSize, + ProcessingPrecisionMode precisionMode, + bool monitoringStage, + juce::String& errorMessage) +{ + juce::ignoreUnused(monitoringStage); + auto stage = std::make_shared(); + stage->sampleRate = sampleRate; + stage->preparedBlockSize = preparedBlockSize; + stage->precisionMode = precisionMode; + stage->slots.reserve(spec.slots.size()); + + for (const auto& desiredSlot : spec.slots) + { + auto processor = createProcessorForStageSlot(desiredSlot, sampleRate, preparedBlockSize, precisionMode, errorMessage); + if (!processor) + return nullptr; + + ActiveFXStageSlot activeSlot; + activeSlot.slotId = desiredSlot.slotId; + activeSlot.name = desiredSlot.name; + activeSlot.type = desiredSlot.type; + activeSlot.pluginPath = desiredSlot.pluginPath; + activeSlot.pluginFormat = desiredSlot.pluginFormat; + activeSlot.bypassed = desiredSlot.bypassed; + activeSlot.forceFloat = desiredSlot.forceFloat; + activeSlot.supportsDouble = processor->supportsDoublePrecisionProcessing(); + activeSlot.processor = std::shared_ptr(std::move(processor)); + stage->slots.push_back(std::move(activeSlot)); + } - auto trackNode = mainProcessorGraph->addNode (std::move (newTrack)); + return stage; +} - if (trackNode) - { - juce::String trackId = explicitId.isNotEmpty() ? explicitId : juce::Uuid().toString(); +void AudioEngine::rebindStageEditors(const std::shared_ptr& oldStage, + const std::shared_ptr& newStage, + bool monitoringStage) +{ + if (!oldStage) + return; - trackMap[trackId] = rawTrackPtr; - trackOrder.push_back(trackId); + struct EditorRebindRequest + { + juce::AudioProcessor* oldProcessor = nullptr; + juce::AudioProcessor* newProcessor = nullptr; + juce::String title; + int slotId = 0; + }; - logToDisk("Track added. ID: " + trackId + " Total: " + juce::String((int)trackOrder.size())); + std::vector requests; + for (const auto& oldSlot : oldStage->slots) + { + if (!oldSlot.processor || !pluginWindowManager.isEditorOpen(oldSlot.processor.get())) + continue; - if (audioOutputNode) - { - mainProcessorGraph->addConnection ({ { trackNode->nodeID, 0 }, { audioOutputNode->nodeID, 0 } }); - mainProcessorGraph->addConnection ({ { trackNode->nodeID, 1 }, { audioOutputNode->nodeID, 1 } }); - logToDisk("Connected track to output."); - } - else + EditorRebindRequest request; + request.oldProcessor = oldSlot.processor.get(); + if (const auto* newSlot = findActiveStageSlot(newStage, oldSlot.slotId)) { - logToDisk("WARNING: audioOutputNode is null. Track not connected."); + if (newSlot->processor) + { + request.newProcessor = newSlot->processor.get(); + request.title = newSlot->name; + request.slotId = newSlot->slotId; + } } - return trackId; + requests.push_back(std::move(request)); } - else + + if (requests.empty()) + return; + + // Capture oldStage by value to keep old processors alive until the async lambda runs. + // Without this, raw AudioProcessor* pointers in 'requests' could dangle if the old + // stage's shared_ptr drops before the lambda executes. + juce::MessageManager::callAsync([this, requests = std::move(requests), monitoringStage, + keepAlive = oldStage]() mutable { - logToDisk("ERROR: Failed to add track node to graph."); - return ""; - } + for (const auto& request : requests) + { + pluginWindowManager.closeEditor(request.oldProcessor); + if (request.newProcessor != nullptr) + { + PluginWindowManager::PluginEditorTarget target; + target.scope = monitoringStage + ? PluginWindowManager::PluginEditorTarget::Scope::MonitoringFX + : PluginWindowManager::PluginEditorTarget::Scope::MasterFX; + const DesiredFXStageSpec& desiredSpec = monitoringStage ? desiredMonitoringStageSpec + : desiredMasterStageSpec; + for (int i = 0; i < static_cast(desiredSpec.slots.size()); ++i) + { + if (desiredSpec.slots[static_cast(i)].slotId == request.slotId) + { + target.fxIndex = i; + break; + } + } + pluginWindowManager.openEditor(request.newProcessor, request.title, target); + } + } + }); } -bool AudioEngine::removeTrack(const juce::String& trackId) +bool AudioEngine::publishMasterStageSpec(const DesiredFXStageSpec& spec) { - const juce::ScopedLock sl (mainProcessorGraph->getCallbackLock()); - - auto it = trackMap.find(trackId); - if (it == trackMap.end()) return false; - - auto* track = it->second; - - // Find unit in graph to remove - juce::AudioProcessorGraph::NodeID nodeID; - bool found = false; - for (auto& node : mainProcessorGraph->getNodes()) { - if (node->getProcessor() == track) { - nodeID = node->nodeID; - found = true; - break; - } + const double sr = currentSampleRate > 0.0 ? currentSampleRate : 44100.0; + const int preparedBlockSize = currentBlockSize > 0 ? currentBlockSize : 512; + const double buildStart = juce::Time::getMillisecondCounterHiRes(); + juce::String errorMessage; + auto stage = buildActiveFXStage(spec, sr, preparedBlockSize, processingPrecisionMode, false, errorMessage); + masterStageLastBuildMs.store(juce::Time::getMillisecondCounterHiRes() - buildStart, std::memory_order_release); + + if (!stage) + { + masterStageBuildFailureCount.fetch_add(1, std::memory_order_relaxed); + juce::Logger::writeToLog("AudioEngine: Failed to publish master stage: " + errorMessage); + return false; } - - if (found) { - mainProcessorGraph->removeNode(nodeID); + + stage->generation = masterStageGeneration.fetch_add(1, std::memory_order_relaxed) + 1; + auto oldStage = std::atomic_load_explicit(&realtimeMasterFXSnapshot, std::memory_order_acquire); + + { + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + desiredMasterStageSpec = spec; + std::atomic_store_explicit(&realtimeMasterFXSnapshot, + std::static_pointer_cast(stage), + std::memory_order_release); } - - trackMap.erase(it); - trackOrder.erase(std::remove(trackOrder.begin(), trackOrder.end(), trackId), trackOrder.end()); - + + rebindStageEditors(oldStage, std::static_pointer_cast(stage), false); return true; } -bool AudioEngine::reorderTrack(const juce::String& trackId, int newPosition) +bool AudioEngine::publishMonitoringStageSpec(const DesiredFXStageSpec& spec) { - const juce::ScopedLock sl (mainProcessorGraph->getCallbackLock()); - - auto it = std::find(trackOrder.begin(), trackOrder.end(), trackId); - if (it == trackOrder.end()) return false; - - // Clamp new position - if (newPosition < 0) newPosition = 0; - if (newPosition >= (int)trackOrder.size()) newPosition = (int)trackOrder.size() - 1; - - if (std::distance(trackOrder.begin(), it) == newPosition) return true; - - trackOrder.erase(it); - trackOrder.insert(trackOrder.begin() + newPosition, trackId); - - return true; -} + const double sr = currentSampleRate > 0.0 ? currentSampleRate : 44100.0; + const int preparedBlockSize = currentBlockSize > 0 ? currentBlockSize : 512; + const double buildStart = juce::Time::getMillisecondCounterHiRes(); + juce::String errorMessage; + auto stage = buildActiveFXStage(spec, sr, preparedBlockSize, processingPrecisionMode, true, errorMessage); + monitoringStageLastBuildMs.store(juce::Time::getMillisecondCounterHiRes() - buildStart, std::memory_order_release); + + if (!stage) + { + monitoringStageBuildFailureCount.fetch_add(1, std::memory_order_relaxed); + juce::Logger::writeToLog("AudioEngine: Failed to publish monitoring stage: " + errorMessage); + return false; + } -int AudioEngine::getTrackIndex(const juce::String& trackId) const -{ - auto it = std::find(trackOrder.begin(), trackOrder.end(), trackId); - if (it != trackOrder.end()) - return (int)std::distance(trackOrder.begin(), it); - return -1; + stage->generation = monitoringStageGeneration.fetch_add(1, std::memory_order_relaxed) + 1; + auto oldStage = std::atomic_load_explicit(&realtimeMonitoringFXSnapshot, std::memory_order_acquire); + + { + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + desiredMonitoringStageSpec = spec; + std::atomic_store_explicit(&realtimeMonitoringFXSnapshot, + std::static_pointer_cast(stage), + std::memory_order_release); + } + + rebindStageEditors(oldStage, std::static_pointer_cast(stage), true); + return true; } -juce::var AudioEngine::getMeterLevels() +void AudioEngine::rebuildRealtimeProcessingSnapshots() { - // Return an object with track IDs as keys for robust matching - juce::DynamicObject::Ptr meterObj (new juce::DynamicObject()); + RealtimeTrackSnapshot trackSnapshot; + trackSnapshot.reserve(trackOrder.size()); for (const auto& trackId : trackOrder) { - auto it = trackMap.find(trackId); - if (it != trackMap.end() && it->second) - meterObj->setProperty(juce::Identifier(trackId), it->second->getRMSLevel()); + auto nodeIt = trackNodeMap.find(trackId); + if (nodeIt == trackNodeMap.end() || !nodeIt->second) + continue; + + RealtimeTrackEntry entry; + entry.id = trackId; + entry.node = nodeIt->second; + + auto sidechainIt = sidechainOutputBuffers.find(trackId); + if (sidechainIt != sidechainOutputBuffers.end()) + entry.sidechainOutputBuffer = sidechainIt->second; + + auto sendIt = sendAccumBuffers.find(trackId); + if (sendIt != sendAccumBuffers.end()) + entry.sendAccumBuffer = sendIt->second; + + if (auto* track = dynamic_cast(entry.node->getProcessor())) + { + entry.sidechainSourceIds = track->getSidechainSourceSnapshot(); + entry.sends = track->getRealtimeSendSnapshot(); + } + + trackSnapshot.push_back(std::move(entry)); } - return juce::var (meterObj.get()); + std::atomic_store_explicit(&realtimeTrackSnapshot, + std::make_shared(std::move(trackSnapshot)), + std::memory_order_release); } -//============================================================================== -// MIDI Device Management (Phase 2) +juce::File AudioEngine::getDeviceSettingsFile() const +{ + return getPreferredApplicationDataDirectory().getChildFile("audio_device_settings.xml"); +} -juce::var AudioEngine::getMIDIInputDevices() +void AudioEngine::saveDeviceSettings() { - if (!midiManager) - return juce::var(); - - auto devices = midiManager->getAvailableDevices(); - juce::var result; - juce::Array deviceArray; - - for (const auto& device : devices) + auto xml = deviceManager.createStateXml(); + if (xml) { - deviceArray.add(device); + auto settingsFile = getDeviceSettingsFile(); + settingsFile.getParentDirectory().createDirectory(); + xml->writeTo(settingsFile); + juce::Logger::writeToLog("AudioEngine: Device settings saved to " + settingsFile.getFullPathName()); } - - result = deviceArray; - return result; } -bool AudioEngine::openMIDIDevice(const juce::String& deviceName) +void AudioEngine::loadDeviceSettings() { - if (!midiManager) - return false; - - bool success = midiManager->openDevice(deviceName); - if (success) + auto settingsFile = getDeviceSettingsFile(); + if (settingsFile.existsAsFile()) { - juce::Logger::writeToLog("AudioEngine: Opened MIDI device: " + deviceName); + auto xml = juce::XmlDocument::parse(settingsFile); + if (xml) + { + auto error = deviceManager.initialise(2, 2, xml.get(), true); + if (error.isEmpty()) + { + // Patch: expand active channels to all available. + // Existing XML may have been saved with only 2 channels active. + // JUCE caps the bitmask at the device's actual channel count. + juce::AudioDeviceManager::AudioDeviceSetup patchSetup; + deviceManager.getAudioDeviceSetup (patchSetup); + patchSetup.useDefaultInputChannels = false; + patchSetup.inputChannels.setRange (0, 32, true); + patchSetup.useDefaultOutputChannels = false; + patchSetup.outputChannels.setRange (0, 32, true); + deviceManager.setAudioDeviceSetup (patchSetup, true); + juce::Logger::writeToLog("AudioEngine: Restored device settings from " + settingsFile.getFullPathName()); + return; + } + juce::Logger::writeToLog("AudioEngine: Failed to restore settings: " + error + " - using defaults"); + } } - return success; -} -void AudioEngine::closeMIDIDevice(const juce::String& deviceName) -{ - if (midiManager) + // No saved settings or failed to load - use defaults + deviceManager.initialiseWithDefaultDevices(2, 2); + // Patch: activate all channels on the default device too { - midiManager->closeDevice(deviceName); - juce::Logger::writeToLog("AudioEngine: Closed MIDI device: " + deviceName); + juce::AudioDeviceManager::AudioDeviceSetup patchSetup; + deviceManager.getAudioDeviceSetup (patchSetup); + patchSetup.useDefaultInputChannels = false; + patchSetup.inputChannels.setRange (0, 32, true); + patchSetup.useDefaultOutputChannels = false; + patchSetup.outputChannels.setRange (0, 32, true); + deviceManager.setAudioDeviceSetup (patchSetup, true); } } -juce::var AudioEngine::getOpenMIDIDevices() +void AudioEngine::audioDeviceAboutToStart (juce::AudioIODevice* device) { - if (!midiManager) - return juce::var(); - - auto devices = midiManager->getOpenDevices(); - juce::var result; - juce::Array deviceArray; - - for (const auto& device : devices) + logToDisk("AudioEngine: Device About To Start"); + if (device == nullptr) { - deviceArray.add(device); + juce::Logger::writeToLog("AudioEngine ERROR: audioDeviceAboutToStart called with nullptr device!"); + return; } - - result = deviceArray; - return result; -} + currentSampleRate = device->getCurrentSampleRate(); + currentBlockSize = device->getCurrentBufferSizeSamples(); + inputLatencySamples = device->getInputLatencyInSamples(); + lastActiveOutputChannels.store(device->getActiveOutputChannels().countNumberOfSetBits(), std::memory_order_relaxed); + logToDisk("Input latency: " + juce::String(inputLatencySamples) + " samples (" + + juce::String(inputLatencySamples / currentSampleRate * 1000.0, 1) + " ms)"); + logAudioDevice("audioDeviceAboutToStart device=" + device->getName() + + " sampleRate=" + juce::String(currentSampleRate, 2) + + " blockSize=" + juce::String(currentBlockSize) + + " activeInputs=" + juce::String(device->getActiveInputChannels().countNumberOfSetBits()) + + " activeOutputs=" + juce::String(device->getActiveOutputChannels().countNumberOfSetBits())); + if (mainProcessorGraph) + { + // ... (keep existing config logic) + mainProcessorGraph->setPlayConfigDetails (device->getActiveInputChannels().countNumberOfSetBits(), + device->getActiveOutputChannels().countNumberOfSetBits(), + device->getCurrentSampleRate(), + device->getCurrentBufferSizeSamples()); + mainProcessorGraph->prepareToPlay (device->getCurrentSampleRate(), + device->getCurrentBufferSizeSamples()); -//============================================================================== -// Track Type Management (Phase 2) + // Pre-allocate reusable buffers (avoids malloc on audio thread) + reusableTrackBuffer.setSize (2, device->getCurrentBufferSizeSamples()); + reusableMasterBuffer.setSize (device->getActiveOutputChannels().countNumberOfSetBits(), + device->getCurrentBufferSizeSamples()); + reusableMasterBufferDouble.setSize (device->getActiveOutputChannels().countNumberOfSetBits(), + device->getCurrentBufferSizeSamples()); + masterFXFallbackBuffer.setSize(device->getActiveOutputChannels().countNumberOfSetBits(), + device->getCurrentBufferSizeSamples()); + monitoringFXFallbackBuffer.setSize(device->getActiveOutputChannels().countNumberOfSetBits(), + device->getCurrentBufferSizeSamples()); -void AudioEngine::setTrackType(const juce::String& trackId, const juce::String& type) -{ - if (trackMap.find(trackId) == trackMap.end()) - return; - - TrackType trackType = TrackType::Audio; - - if (type == "midi") - trackType = TrackType::MIDI; - else if (type == "instrument") - trackType = TrackType::Instrument; - - trackMap[trackId]->setTrackType(trackType); - juce::Logger::writeToLog("AudioEngine: Track " + trackId + " type set to: " + type); + // Pre-allocate sidechain output buffers for all existing tracks + for (const auto& id : trackOrder) + { + auto& buffer = sidechainOutputBuffers[id]; + if (buffer == nullptr) + buffer = std::make_shared>(); + buffer->setSize(2, device->getCurrentBufferSizeSamples()); + } + + // Pre-allocate send accumulation buffers for all existing tracks + for (const auto& id : trackOrder) + { + auto& buffer = sendAccumBuffers[id]; + if (buffer == nullptr) + buffer = std::make_shared>(); + buffer->setSize(2, device->getCurrentBufferSizeSamples()); + } + + // Metronome Init + metronome.prepareToPlay(device->getCurrentSampleRate(), device->getCurrentBufferSizeSamples()); + metronome.setBpm(tempo); + metronome.setTimeSignature(timeSigNumerator, timeSigDenominator); + + // Propagate this AudioEngine as the AudioPlayHead to all existing plugins + // so they receive tempo/position during processBlock. + for (const auto& id : trackOrder) + { + auto it = trackMap.find (id); + if (it != trackMap.end()) + propagatePlayHead (it->second); + } + rebuildRealtimeProcessingSnapshots(); + publishMasterStageSpec(desiredMasterStageSpec); + publishMonitoringStageSpec(desiredMonitoringStageSpec); + + // mainProcessorGraph->clear(); // COMMENTED OUT TO DEBUG - This wipes tracks! + + // Add IO Nodes only if missing + if (!audioInputNode) { + logToDisk("Adding IO Nodes..."); + audioInputNode = mainProcessorGraph->addNode (std::make_unique (juce::AudioProcessorGraph::AudioGraphIOProcessor::audioInputNode)); + audioOutputNode = mainProcessorGraph->addNode (std::make_unique (juce::AudioProcessorGraph::AudioGraphIOProcessor::audioOutputNode)); + + // Connect any existing tracks that were added before device init + if (audioOutputNode) { + logToDisk("Connecting existing tracks to output..."); + int connectedCount = 0; + + for (auto* node : mainProcessorGraph->getNodes()) { + // Skip IO nodes + if (node == audioInputNode.get() || node == audioOutputNode.get()) + continue; + + // Check if this is a TrackProcessor + if (dynamic_cast(node->getProcessor())) { + // Connect stereo channels + mainProcessorGraph->addConnection ({ { node->nodeID, 0 }, { audioOutputNode->nodeID, 0 } }); + mainProcessorGraph->addConnection ({ { node->nodeID, 1 }, { audioOutputNode->nodeID, 1 } }); + connectedCount++; + } + } + + logToDisk("Connected " + juce::String(connectedCount) + " existing tracks."); + } + } + } } -void AudioEngine::setTrackMIDIInput(const juce::String& trackId, const juce::String& deviceName, int channel) +void AudioEngine::audioDeviceStopped() { - if (trackMap.find(trackId) == trackMap.end()) - return; - - trackMap[trackId]->setMIDIInputDevice(deviceName); - trackMap[trackId]->setMIDIChannel(channel); - - juce::Logger::writeToLog("AudioEngine: Track " + trackId + - " MIDI input: " + deviceName + - " channel: " + juce::String(channel)); + logToDisk("AudioEngine: Device Stopped"); + lastAudioBlockWallTimeMs.store(0.0, std::memory_order_release); + lastAudioBlockDurationMs.store(0.0, std::memory_order_release); + if (mainProcessorGraph) + { + mainProcessorGraph->releaseResources(); + // Do NOT call clear() — that deletes all track nodes and orphans trackMap. + // Only null IO node pointers so audioDeviceAboutToStart() re-creates them. + audioInputNode = nullptr; + audioOutputNode = nullptr; + } } -bool AudioEngine::loadInstrument(const juce::String& trackId, const juce::String& vstPath) +// =========================================================================== +// Audio callback helpers — extracted from audioDeviceIOCallbackWithContext +// =========================================================================== + +void AudioEngine::updateMasterMetering (const float* const* outputChannelData, + int numOutputChannels, int numSamples) { - if (trackMap.find(trackId) == trackMap.end()) - return false; - - // Load the VST instrument using PluginManager with actual device rate - double sr = currentSampleRate > 0 ? currentSampleRate : 44100.0; - int bs = currentBlockSize > 0 ? currentBlockSize : 512; - auto plugin = pluginManager.loadPluginFromFile(vstPath, sr, bs); - if (!plugin) + float peakL = 0.0f; + float peakR = 0.0f; + if (numOutputChannels >= 1) { - juce::Logger::writeToLog("AudioEngine: Failed to load instrument: " + vstPath); - return false; + auto r = juce::FloatVectorOperations::findMinAndMax (outputChannelData[0], numSamples); + peakL = juce::jmax (-r.getStart(), r.getEnd()); } - - // Cast to AudioPluginInstance - auto* pluginInstance = dynamic_cast(plugin.get()); - if (!pluginInstance) + if (numOutputChannels >= 2) { - juce::Logger::writeToLog("AudioEngine: Plugin is not an AudioPluginInstance: " + vstPath); - return false; + auto r = juce::FloatVectorOperations::findMinAndMax (outputChannelData[1], numSamples); + peakR = juce::jmax (-r.getStart(), r.getEnd()); } - - // Create a unique_ptr with the correct type - std::unique_ptr instrumentPtr(pluginInstance); - plugin.release(); // Release ownership from the AudioProcessor unique_ptr - // Provide tempo/position info to the instrument plugin - instrumentPtr->setPlayHead (this); + const float masterPeak = juce::jmax (peakL, peakR); + if (masterPeak > 1.0f) + masterClipLatched.store (true, std::memory_order_relaxed); - // Set the instrument on the track - trackMap[trackId]->setInstrument(std::move(instrumentPtr)); - trackMap[trackId]->setTrackType(TrackType::Instrument); - - juce::Logger::writeToLog("AudioEngine: Loaded instrument on track " + trackId + ": " + vstPath); - return true; + masterMeterPeakAccum = juce::jmax (masterMeterPeakAccum, masterPeak); + masterMeterSampleCount += numSamples; + if (masterMeterSampleCount >= MASTER_METER_UPDATE_SAMPLES) + { + masterOutputLevel.store (masterMeterPeakAccum, std::memory_order_relaxed); + masterMeterPeakAccum = 0.0f; + masterMeterSampleCount = 0; + } } -//============================================================================== -// MIDI Message Routing (Phase 2) - -void AudioEngine::handleMIDIMessage(const juce::String& deviceName, int channel, const juce::MidiMessage& message) +void AudioEngine::updatePhaseCorrelation (const float* const* outputChannelData, + int numOutputChannels, int numSamples) { - // Route MIDI to appropriate tracks - for (auto const& [id, track] : trackMap) + if (numOutputChannels < 2) + return; + + for (int i = 0; i < numSamples; ++i) { - if (!track) continue; - - // Check if track accepts MIDI - if (track->getTrackType() != TrackType::MIDI && track->getTrackType() != TrackType::Instrument) - continue; - - // Check if track is listening to this device - if (track->getMIDIInputDevice() != deviceName) - continue; - - // Check channel filtering (0 = all channels, 1-16 = specific channel) - int trackChannel = track->getMIDIChannel(); - if (trackChannel != 0 && trackChannel != channel) - continue; - - // Real-time monitoring: send MIDI to instrument for playback - if (track->getInputMonitoring() && track->getTrackType() == TrackType::Instrument) + double l = static_cast (outputChannelData[0][i]); + double r = static_cast (outputChannelData[1][i]); + phaseCorr_sumLR += l * r; + phaseCorr_sumLL += l * l; + phaseCorr_sumRR += r * r; + } + phaseCorrSampleCount += numSamples; + + if (phaseCorrSampleCount >= PHASE_CORR_UPDATE_SAMPLES) + { + double denom = std::sqrt (phaseCorr_sumLL * phaseCorr_sumRR); + float corr = (denom > 1e-20) ? static_cast (phaseCorr_sumLR / denom) : 0.0f; + corr = juce::jlimit (-1.0f, 1.0f, corr); + phaseCorrelationValue.store (corr, std::memory_order_relaxed); + + phaseCorr_sumLR = 0.0; + phaseCorr_sumLL = 0.0; + phaseCorr_sumRR = 0.0; + phaseCorrSampleCount = 0; + } +} + +void AudioEngine::updateSpectrumAnalyzer (const float* const* outputChannelData, + int numOutputChannels, int numSamples) +{ + for (int i = 0; i < numSamples; ++i) + { + float monoSample = 0.0f; + for (int ch = 0; ch < numOutputChannels; ++ch) + monoSample += outputChannelData[ch][i]; + if (numOutputChannels > 1) + monoSample /= static_cast (numOutputChannels); + + spectrumInputBuffer[spectrumWritePos] = monoSample; + spectrumWritePos++; + + if (spectrumWritePos >= FFT_SIZE) { - auto* instrument = track->getInstrument(); - if (instrument) + spectrumWritePos = 0; + + float fftData[FFT_SIZE * 2] = {}; + std::memcpy (fftData, spectrumInputBuffer, sizeof (float) * static_cast (FFT_SIZE)); + spectrumWindow.multiplyWithWindowingTable (fftData, static_cast (FFT_SIZE)); + spectrumFFT.performFrequencyOnlyForwardTransform (fftData); + { - // Create MIDI buffer with this message - juce::MidiBuffer midiBuffer; - midiBuffer.addEvent(message, 0); - - // Note: In a production system, this would need to be processed - // in the audio callback thread with proper synchronization - juce::Logger::writeToLog("MIDI Monitor: " + message.getDescription() + - " routed to instrument on device: " + deviceName); + const juce::ScopedLock specLock (spectrumLock); + std::memcpy (spectrumOutputBuffer, fftData, sizeof (float) * static_cast (FFT_SIZE)); + spectrumReady = true; } } - - // If recording and armed, log the MIDI event - // TODO: Store in thread-safe recording buffer for actual MIDI clip creation - if (isPlaying && isRecordMode && track->getRecordArmed()) - { - double timestamp = currentSamplePosition / currentSampleRate; - juce::Logger::writeToLog("MIDI Recorded: " + message.getDescription() + - " at time: " + juce::String(timestamp, 3) + - " on device: " + deviceName); - } } } -float AudioEngine::getMasterLevel() const +void AudioEngine::processMasterFXChain (const std::shared_ptr& rtMasterFX, + float* const* outputChannelData, int numOutputChannels, + int numSamples, bool useHybrid64Summing) { - // Use the actual measured output level (computed in the audio callback after FX and pan) - return masterOutputLevel.load(); -} + if (rtMasterFX == nullptr || rtMasterFX->slots.empty()) + return; + const int masterChans = juce::jmin (numOutputChannels, reusableMasterBuffer.getNumChannels()); + juce::AudioBuffer masterBuffer (reusableMasterBuffer.getArrayOfWritePointers(), masterChans, numSamples); + juce::AudioBuffer masterBufferDouble ( + reusableMasterBufferDouble.getArrayOfWritePointers(), + juce::jmin (numOutputChannels, reusableMasterBufferDouble.getNumChannels()), + numSamples); + if (!useHybrid64Summing) + { + for (int ch = 0; ch < masterChans; ++ch) + masterBuffer.copyFrom (ch, 0, outputChannelData[ch], numSamples); + } + juce::MidiBuffer dummyMidi; + for (const auto& slot : rtMasterFX->slots) + { + if (slot.bypassed || !slot.processor) + continue; -juce::var AudioEngine::getAudioDeviceSetup() -{ - juce::Logger::writeToLog("AudioEngine: getAudioDeviceSetup() called"); - - juce::DynamicObject* data = new juce::DynamicObject(); + auto* proc = slot.processor.get(); + const bool canProcessDouble = useHybrid64Summing + && !slot.forceFloat + && slot.supportsDouble + && proc->getTotalNumInputChannels() <= masterBufferDouble.getNumChannels() + && proc->getTotalNumOutputChannels() <= masterBufferDouble.getNumChannels(); - // 1. Current Setup - auto* currentSetup = new juce::DynamicObject(); - auto currentDeviceType = deviceManager.getCurrentAudioDeviceType(); - - juce::Logger::writeToLog("AudioEngine: Current device type: " + currentDeviceType); - - // Handle case where no device is open yet - auto* device = deviceManager.getCurrentAudioDevice(); - - currentSetup->setProperty("audioDeviceType", currentDeviceType); - if (device) - { - juce::Logger::writeToLog("AudioEngine: Current device: " + device->getName()); - currentSetup->setProperty("inputDevice", device->getName()); - currentSetup->setProperty("outputDevice", device->getName()); // Basic assumption for now - currentSetup->setProperty("sampleRate", device->getCurrentSampleRate()); - currentSetup->setProperty("bufferSize", device->getCurrentBufferSizeSamples()); - - // Get ALL channel names from the device (not just active) - juce::StringArray inputNames = device->getInputChannelNames(); - juce::StringArray outputNames = device->getOutputChannelNames(); - - // Total available channels (this is what matters for routing selection) - int numTotalInputs = inputNames.size(); - int numTotalOutputs = outputNames.size(); - - juce::Logger::writeToLog("AudioEngine: Device has " + juce::String(numTotalInputs) + " total input channels"); - - currentSetup->setProperty("numInputChannels", numTotalInputs); - currentSetup->setProperty("numOutputChannels", numTotalOutputs); - - // Build input channel names array (ALL channels) - juce::Array inputChannelNamesArray; - for (int i = 0; i < inputNames.size(); ++i) + if (canProcessDouble) { - inputChannelNamesArray.add(inputNames[i]); - juce::Logger::writeToLog("AudioEngine: Input channel " + juce::String(i) + ": " + inputNames[i]); + proc->processBlock (masterBufferDouble, dummyMidi); } - currentSetup->setProperty("inputChannelNames", inputChannelNamesArray); - - // Build output channel names array (ALL channels) - juce::Array outputChannelNamesArray; - for (int i = 0; i < outputNames.size(); ++i) + else { - outputChannelNamesArray.add(outputNames[i]); + if (useHybrid64Summing) + { + for (int ch = 0; ch < masterChans; ++ch) + { + auto* src = masterBufferDouble.getReadPointer (ch); + auto* dest = masterBuffer.getWritePointer (ch); + for (int sample = 0; sample < numSamples; ++sample) + dest[sample] = static_cast (src[sample]); + } + } + + proc->processBlock (masterBuffer, dummyMidi); + + if (useHybrid64Summing) + { + for (int ch = 0; ch < masterChans; ++ch) + { + auto* src = masterBuffer.getReadPointer (ch); + auto* dest = masterBufferDouble.getWritePointer (ch); + for (int sample = 0; sample < numSamples; ++sample) + dest[sample] = static_cast (src[sample]); + } + } + } + } + + if (masterFXFallbackBuffer.getNumChannels() < masterChans || masterFXFallbackBuffer.getNumSamples() < numSamples) + masterFXFallbackBuffer.setSize (masterChans, numSamples, false, false, true); + + if (!useHybrid64Summing) + { + for (int ch = 0; ch < masterChans; ++ch) + { + juce::FloatVectorOperations::copy (outputChannelData[ch], masterBuffer.getReadPointer (ch), numSamples); + masterFXFallbackBuffer.copyFrom (ch, 0, masterBuffer, ch, 0, numSamples); } - currentSetup->setProperty("outputChannelNames", outputChannelNamesArray); - - // Also provide active channel info separately (can be used for metering, etc) - currentSetup->setProperty("numActiveInputChannels", device->getActiveInputChannels().countNumberOfSetBits()); - currentSetup->setProperty("numActiveOutputChannels", device->getActiveOutputChannels().countNumberOfSetBits()); } else { - juce::Logger::writeToLog("AudioEngine: WARNING - No audio device currently active!"); - // Provide defaults - currentSetup->setProperty("inputDevice", ""); - currentSetup->setProperty("outputDevice", ""); - currentSetup->setProperty("sampleRate", 44100.0); - currentSetup->setProperty("bufferSize", 512); - currentSetup->setProperty("numInputChannels", 2); - currentSetup->setProperty("numOutputChannels", 2); - currentSetup->setProperty("inputChannelNames", juce::Array()); - currentSetup->setProperty("outputChannelNames", juce::Array()); + for (int ch = 0; ch < masterChans; ++ch) + { + auto* src = masterBufferDouble.getReadPointer (ch); + auto* dest = masterFXFallbackBuffer.getWritePointer (ch); + for (int sample = 0; sample < numSamples; ++sample) + dest[sample] = static_cast (src[sample]); + } } - - data->setProperty("current", currentSetup); +} - // 2. Available Device Types - juce::Array types; - for (auto& type : deviceManager.getAvailableDeviceTypes()) +void AudioEngine::processMonitoringFXChain (const std::shared_ptr& rtMonitoringFX, + float* const* outputChannelData, int numOutputChannels, + int numSamples, bool hybrid64PostChainActive) +{ + if (rtMonitoringFX == nullptr || rtMonitoringFX->slots.empty() || isRendering.load()) + return; + + const int monChans = juce::jmin (numOutputChannels, reusableMasterBuffer.getNumChannels()); + juce::AudioBuffer monBuffer (reusableMasterBuffer.getArrayOfWritePointers(), monChans, numSamples); + juce::AudioBuffer monBufferDouble ( + reusableMasterBufferDouble.getArrayOfWritePointers(), + juce::jmin (numOutputChannels, reusableMasterBufferDouble.getNumChannels()), + numSamples); + + if (!hybrid64PostChainActive) { - auto typeName = type->getTypeName(); - types.add(typeName); - juce::Logger::writeToLog("AudioEngine: Available type: " + typeName); + for (int ch = 0; ch < monChans; ++ch) + monBuffer.copyFrom (ch, 0, outputChannelData[ch], numSamples); } - data->setProperty("availableTypes", types); - - // 3. Available Devices for Current Type - juce::Array inputList; - juce::Array outputList; - - if (auto* type = deviceManager.getCurrentDeviceTypeObject()) + + juce::MidiBuffer dummyMidi; + for (const auto& slot : rtMonitoringFX->slots) { - juce::Logger::writeToLog("AudioEngine: Enumerating devices for type: " + type->getTypeName()); - - for (auto& name : type->getDeviceNames(true)) // Inputs + if (slot.bypassed || !slot.processor) + continue; + + auto* proc = slot.processor.get(); + const bool canProcessDouble = hybrid64PostChainActive + && !slot.forceFloat + && slot.supportsDouble + && proc->getTotalNumInputChannels() <= monBufferDouble.getNumChannels() + && proc->getTotalNumOutputChannels() <= monBufferDouble.getNumChannels(); + + if (canProcessDouble) { - inputList.add(name); - juce::Logger::writeToLog("AudioEngine: Input device: " + name); + proc->processBlock (monBufferDouble, dummyMidi); } - - for (auto& name : type->getDeviceNames(false)) // Outputs + else { - outputList.add(name); - juce::Logger::writeToLog("AudioEngine: Output device: " + name); + if (hybrid64PostChainActive) + copyDoubleBufferToFloatBuffer (monBufferDouble, monBuffer, monChans, numSamples); + + proc->processBlock (monBuffer, dummyMidi); + + if (hybrid64PostChainActive) + copyFloatBufferToDoubleBuffer (monBuffer, monBufferDouble, monChans, numSamples); } } - data->setProperty("inputs", inputList); - data->setProperty("outputs", outputList); - - - // 4. Sample Rates & 5. Buffer Sizes - juce::Array rates; - juce::Array buffers; - - if (device) + + if (monitoringFXFallbackBuffer.getNumChannels() < monChans || monitoringFXFallbackBuffer.getNumSamples() < numSamples) + monitoringFXFallbackBuffer.setSize (monChans, numSamples, false, false, true); + + if (hybrid64PostChainActive) { - // Device is open - get its actual capabilities - for (auto sr : device->getAvailableSampleRates()) - rates.add(sr); - for (auto bs : device->getAvailableBufferSizes()) - buffers.add(bs); - juce::Logger::writeToLog("AudioEngine: Got " + juce::String(rates.size()) + " sample rates from open device"); + copyDoubleBufferToFloatBuffer (monBufferDouble, monitoringFXFallbackBuffer, monChans, numSamples); } else { - // No device open - provide common professional audio defaults - // These work with most ASIO/WASAPI devices - juce::Logger::writeToLog("AudioEngine: No device open, using default capabilities"); - - rates.add(44100.0); - rates.add(48000.0); - rates.add(88200.0); - rates.add(96000.0); - rates.add(176400.0); - rates.add(192000.0); - - buffers.add(32); - buffers.add(64); - buffers.add(128); - buffers.add(256); - buffers.add(512); - buffers.add(1024); - buffers.add(2048); + for (int ch = 0; ch < monChans; ++ch) + { + juce::FloatVectorOperations::copy (outputChannelData[ch], monBuffer.getReadPointer (ch), numSamples); + monitoringFXFallbackBuffer.copyFrom (ch, 0, monBuffer, ch, 0, numSamples); + } } - - data->setProperty("sampleRates", rates); - data->setProperty("bufferSizes", buffers); - - juce::Logger::writeToLog("AudioEngine: getAudioDeviceSetup() returning data"); - return data; } -void AudioEngine::setAudioDeviceSetup(const juce::String& type, const juce::String& input, const juce::String& output, double sampleRate, int bufferSize) +void AudioEngine::applyMasterGainPanMono (float* const* outputChannelData, + int numOutputChannels, int numSamples, + double samplePosition, bool hybrid64PostChainActive) { - juce::Logger::writeToLog("AudioEngine: Setting Audio Device..."); - - // 1. Change Type if needed - if (deviceManager.getCurrentAudioDeviceType() != type) + // Master Pan (with automation) + if (numOutputChannels >= 2) { - deviceManager.setCurrentAudioDeviceType(type, true); - } - - // 2. Setup Config - juce::AudioDeviceManager::AudioDeviceSetup setup; - deviceManager.getAudioDeviceSetup(setup); - - setup.inputDeviceName = input; - setup.outputDeviceName = output; - setup.sampleRate = sampleRate; - setup.bufferSize = bufferSize; - - // Apply (treat errors softly by logging) - auto error = deviceManager.setAudioDeviceSetup(setup, true); - if (error.isNotEmpty()) - juce::Logger::writeToLog("AudioEngine: Error setting device: " + error); - else - saveDeviceSettings(); -} + float leftGain = cachedMasterPanL.load (std::memory_order_relaxed); + float rightGain = cachedMasterPanR.load (std::memory_order_relaxed); -//============================================================================== -// Track Control (Phase 1) + if (masterPanAutomation.shouldPlayback()) + { + float autoPan = masterPanAutomation.eval (samplePosition); + computePanLawGains (currentPanLaw, autoPan, 1.0f, leftGain, rightGain); + } -void AudioEngine::setTrackRecordArm(const juce::String& trackId, bool armed) -{ - if (trackMap.find(trackId) != trackMap.end()) - { - auto* track = trackMap[trackId]; - if (track) + if (hybrid64PostChainActive) + applyStereoPanToDoubleBuffer (reusableMasterBufferDouble, numSamples, leftGain, rightGain); + else { - track->setRecordArmed(armed); - juce::Logger::writeToLog("AudioEngine: Track " + trackId + - " record arm: " + (armed ? "ON" : "OFF")); + juce::FloatVectorOperations::multiply (outputChannelData[0], leftGain, numSamples); + juce::FloatVectorOperations::multiply (outputChannelData[1], rightGain, numSamples); } } -} -void AudioEngine::setTrackInputMonitoring(const juce::String& trackId, bool enabled) -{ - if (trackMap.find(trackId) != trackMap.end()) + // Master Volume (with automation) { - auto* track = trackMap[trackId]; - if (track) + float effectiveMasterVol = masterVolume; + if (masterVolumeAutomation.shouldPlayback()) { - track->setInputMonitoring(enabled); - juce::Logger::writeToLog("AudioEngine: Track " + trackId + - " monitoring: " + (enabled ? "ON" : "OFF")); + float autoDb = masterVolumeAutomation.eval (samplePosition); + effectiveMasterVol = (autoDb <= -60.0f) ? 0.0f : std::pow (10.0f, autoDb / 20.0f); + } + if (hybrid64PostChainActive) + applyGainToDoubleBuffer (reusableMasterBufferDouble, numOutputChannels, numSamples, effectiveMasterVol); + else + { + for (int ch = 0; ch < numOutputChannels; ++ch) + juce::FloatVectorOperations::multiply (outputChannelData[ch], effectiveMasterVol, numSamples); } } -} -void AudioEngine::setTrackInputChannels(const juce::String& trackId, int startChannel, int numChannels) -{ - if (trackMap.find(trackId) != trackMap.end()) + // Mono downmix + if (masterMono.load (std::memory_order_relaxed) && numOutputChannels >= 2) { - auto* track = trackMap[trackId]; - if (track) + if (hybrid64PostChainActive) + downmixDoubleBufferToMono (reusableMasterBufferDouble, numSamples); + else { - track->setInputChannels(startChannel, numChannels); + for (int i = 0; i < numSamples; ++i) + { + float mono = (outputChannelData[0][i] + outputChannelData[1][i]) * 0.5f; + outputChannelData[0][i] = mono; + outputChannelData[1][i] = mono; + } } } -} -//============================================================================== -// Volume/Pan/Mute/Solo (Phase 1) + // Copy 64-bit buffer back to hardware output + if (hybrid64PostChainActive) + copyDoubleBufferToOutput (reusableMasterBufferDouble, outputChannelData, numOutputChannels, numSamples); +} -void AudioEngine::setTrackVolume(const juce::String& trackId, float volumeDB) +void AudioEngine::buildSidechainProcessingOrder (const std::vector& rtTracks, + int processedOrder[], int& orderCount, int maxTracks) { - if (trackMap.find(trackId) != trackMap.end()) + bool trackProcessed[64]; + int numTracks = juce::jmin (static_cast (rtTracks.size()), maxTracks); + orderCount = 0; + + for (int t = 0; t < numTracks; ++t) + trackProcessed[t] = false; + + // Fast path: skip sorting if no track has sidechain routing + bool anySidechainRouting = false; + for (int t = 0; t < numTracks && !anySidechainRouting; ++t) { - auto* track = trackMap[trackId]; - if (track) - { - track->setVolume(volumeDB); - juce::Logger::writeToLog("AudioEngine: Track " + trackId + - " volume: " + juce::String(volumeDB) + " dB"); - } + if (!rtTracks[static_cast (t)].sidechainSourceIds.empty()) + anySidechainRouting = true; } -} -void AudioEngine::setTrackPan(const juce::String& trackId, float pan) -{ - if (trackMap.find(trackId) != trackMap.end()) + if (anySidechainRouting) { - auto* track = trackMap[trackId]; - if (track) + int maxPasses = numTracks + 1; + for (int pass = 0; pass < maxPasses && orderCount < numTracks; ++pass) { - track->setPan(pan); - juce::Logger::writeToLog("AudioEngine: Track " + trackId + - " pan: " + juce::String(pan)); + for (int t = 0; t < numTracks; ++t) + { + if (trackProcessed[t]) continue; + + const auto& entry = rtTracks[static_cast (t)]; + auto* trk = dynamic_cast (entry.node != nullptr ? entry.node->getProcessor() : nullptr); + if (trk == nullptr) + { + trackProcessed[t] = true; + processedOrder[orderCount++] = t; + continue; + } + + bool depsReady = true; + for (const auto& srcId : entry.sidechainSourceIds) + { + if (srcId.isEmpty()) + continue; + + for (int s = 0; s < numTracks; ++s) + { + if (rtTracks[static_cast (s)].id == srcId) + { + if (!trackProcessed[s]) + depsReady = false; + break; + } + } + } + + if (depsReady) + { + trackProcessed[t] = true; + processedOrder[orderCount++] = t; + } + } } - } -} -void AudioEngine::setTrackMute(const juce::String& trackId, bool muted) -{ - if (trackMap.find(trackId) != trackMap.end()) - { - auto* track = trackMap[trackId]; - if (track) + // Remaining tracks (circular deps) — add at end + for (int t = 0; t < numTracks; ++t) { - track->setMute(muted); - juce::Logger::writeToLog("AudioEngine: Track " + trackId + - " mute: " + (muted ? "ON" : "OFF")); + if (!trackProcessed[t]) + processedOrder[orderCount++] = t; } } + else + { + for (int t = 0; t < numTracks; ++t) + processedOrder[t] = t; + orderCount = numTracks; + } } -void AudioEngine::setTrackSolo(const juce::String& trackId, bool soloed) +void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputChannelData, + int numInputChannels, + float* const* outputChannelData, + int numOutputChannels, + int numSamples, + const juce::AudioIODeviceCallbackContext& context) { - if (trackMap.find(trackId) != trackMap.end()) - { - auto* track = trackMap[trackId]; - if (track) - { - track->setSolo(soloed); - juce::Logger::writeToLog("AudioEngine: Track " + trackId + - " solo: " + (soloed ? "ON" : "OFF")); + juce::ignoreUnused(context); + const uint64 callbackCounter = audioCallbackCounter.fetch_add(1, std::memory_order_acq_rel) + 1; + lastAudioCallbackCounter.store(callbackCounter, std::memory_order_relaxed); + const bool firstCallbackAfterTransportStart = firstCallbackAfterTransportStartPending.exchange(false, std::memory_order_acq_rel); + const double callbackStartWallTimeMs = juce::Time::getMillisecondCounterHiRes(); + lastAudioBlockWallTimeMs.store(callbackStartWallTimeMs, std::memory_order_release); + lastCallbackInputChannels.store(numInputChannels, std::memory_order_relaxed); + lastCallbackOutputChannels.store(numOutputChannels, std::memory_order_relaxed); + lastActiveOutputChannels.store(numOutputChannels, std::memory_order_relaxed); + if (currentSampleRate > 0.0) + lastAudioBlockDurationMs.store((static_cast(numSamples) / currentSampleRate) * 1000.0, + std::memory_order_release); - // Update cached solo state so audio thread doesn't need to scan - bool anyNowSoloed = false; - for (const auto& pair : trackMap) - if (pair.second && pair.second->getSolo()) { anyNowSoloed = true; break; } - cachedAnySoloed.store(anyNowSoloed); - } + // Clear outputs first + for (int i = 0; i < numOutputChannels; ++i) + juce::FloatVectorOperations::clear (outputChannelData[i], numSamples); + + // During offline rendering, skip ALL processing to avoid sharing FX plugin + // instances between the audio callback and the render thread + if (isRendering.load()) + return; + + auto rtTracks = std::atomic_load_explicit(&realtimeTrackSnapshot, std::memory_order_acquire); + auto rtMasterFX = std::atomic_load_explicit(&realtimeMasterFXSnapshot, std::memory_order_acquire); + auto rtMonitoringFX = std::atomic_load_explicit(&realtimeMonitoringFXSnapshot, std::memory_order_acquire); + const bool shouldLogCallbackSummary = kAudioPathDebugLogs + && (((callbackCounter % 50) == 1) || ((isPlaying || isRecordMode) && firstCallbackAfterTransportStart)); + float postTrackPlaybackPeak = 0.0f; + float postMonitoringInputPeak = 0.0f; + const float preTrackPeak = peakFromFloatChannels(outputChannelData, numOutputChannels, numSamples); + if (shouldLogCallbackSummary) + { + logAudioPlayback("callback start #" + juce::String(static_cast(callbackCounter)) + + " isPlaying=" + juce::String(isPlaying.load() ? "true" : "false") + + " isRecordMode=" + juce::String(isRecordMode.load() ? "true" : "false") + + " inputChannels=" + juce::String(numInputChannels) + + " outputChannels=" + juce::String(numOutputChannels) + + " sampleRate=" + juce::String(currentSampleRate, 2) + + " blockSize=" + juce::String(numSamples) + + " masterVolume=" + juce::String(masterVolume, 3) + + " masterPan=" + juce::String(masterPan, 3) + + " masterMono=" + juce::String(masterMono.load(std::memory_order_relaxed) ? "true" : "false") + + " preTrackPeak=" + juce::String(preTrackPeak, 4)); } -} -//============================================================================== -// Transport Control (Phase 2) + // NOTE: currentSamplePosition is advanced AFTER all processing (metronome, + // playback, recording) so that everything in this callback uses the correct + // position for the samples being output right now. The increment is at the + // very end of the callback (search "Advance transport position"). -void AudioEngine::setTransportPlaying(bool playing) -{ - logToDisk("setTransportPlaying(" + juce::String(playing ? "true" : "false") + ") called. Current: " + juce::String(isPlaying ? "true" : "false")); - - if (isPlaying == playing) - return; // No change - - isPlaying = playing; - - if (playing) + // Update metronome BPM from tempo map at current position { - // Update sample rate from device - auto* device = deviceManager.getCurrentAudioDevice(); - if (device) - currentSampleRate = device->getCurrentSampleRate(); - - logToDisk("Transport PLAY. SampleRate: " + juce::String(currentSampleRate) + - " Position: " + juce::String(currentSamplePosition / currentSampleRate) + "s"); + double currentBpm = getTempoAtTime(currentSamplePosition / currentSampleRate); + metronome.setBpm(currentBpm); } - else + + // Mix Metronome (if enabled and transport is running) + if (isPlaying || isRecordMode) { - logToDisk("Transport STOP at position: " + juce::String(currentSamplePosition / currentSampleRate) + "s"); - // Don't reset position here - let the stop() action control that + juce::AudioBuffer outputBuffer(const_cast(outputChannelData), numOutputChannels, numSamples); + metronome.getNextAudioBlock(outputBuffer, currentSamplePosition); } -} -void AudioEngine::setTransportRecording(bool recording) -{ - logToDisk("setTransportRecording(" + juce::String(recording ? "true" : "false") + ") called. Current: " + juce::String(isRecordMode ? "true" : "false")); - - if (isRecordMode == recording) - return; // No change - - isRecordMode = recording; - - if (recording) + const bool useHybrid64Summing = processingPrecisionMode == ProcessingPrecisionMode::Hybrid64 + && reusableMasterBufferDouble.getNumChannels() >= numOutputChannels + && reusableMasterBufferDouble.getNumSamples() >= numSamples; + bool hybrid64PostChainActive = useHybrid64Summing; + if (useHybrid64Summing) { - // Use standard documents folder (resolves to OneDrive if configured) - auto docsDir = juce::File::getSpecialLocation(juce::File::userDocumentsDirectory); - projectAudioFolder = docsDir.getChildFile("Studio13").getChildFile("Audio"); - - logToDisk("Target Audio Folder: " + projectAudioFolder.getFullPathName()); - - if (!projectAudioFolder.exists()) + for (int ch = 0; ch < numOutputChannels; ++ch) { - bool created = projectAudioFolder.createDirectory(); - logToDisk("Folder created status: " + juce::String(created ? "SUCCESS" : "FAIL")); + auto* dest = reusableMasterBufferDouble.getWritePointer(ch); + for (int sample = 0; sample < numSamples; ++sample) + dest[sample] = static_cast(outputChannelData[ch][sample]); + + juce::FloatVectorOperations::clear(outputChannelData[ch], numSamples); } - - // Start recording for each armed track - logToDisk("Checking " + juce::String((int)trackMap.size()) + " tracks for arming..."); - - for (auto const& [trackId, track] : trackMap) + } + + if (pendingRecordStartCapture.exchange(false, std::memory_order_acq_rel)) + { + double latencyComp = inputLatencySamples / currentSampleRate; + double startPos = (currentSamplePosition / currentSampleRate) - latencyComp; + if (startPos < 0.0) startPos = 0.0; + + for (const auto& entry : *rtTracks) { - if (!track) continue; - - bool isArmed = track->getRecordArmed(); - - if (isArmed) - { - logToDisk("Track " + trackId + " IS ARMED. Starting record..."); - - // Generate filename: Track_ID_Take_timestamp.wav - auto timestamp = juce::Time::getCurrentTime().toMilliseconds(); - auto filename = "Track_" + trackId + "_Take_" + juce::String(timestamp) + ".wav"; - auto outputFile = projectAudioFolder.getChildFile(filename); - - // Use the sample rate that was set in audioDeviceAboutToStart - // (currentSampleRate is updated when device starts and should match device's actual rate) - logToDisk("Recording at sample rate: " + juce::String(currentSampleRate) + " Hz"); - - int numChannels = track->getInputChannelCount(); - bool started = audioRecorder.startRecording(trackId, outputFile, currentSampleRate, numChannels); - - if (started) { - // Defer start-time capture to the audio thread to avoid race - // conditions — the message thread's view of currentSamplePosition - // may be stale by the time the audio callback processes the first - // buffer. The audio thread sets the correct start time (with - // input latency compensation) on the first write. - pendingRecordStartCapture.store(true, std::memory_order_release); - } - - logToDisk("Start Recording Track " + trackId + " -> " + (started ? "SUCCESS" : "FAIL to " + outputFile.getFullPathName())); - } + auto* recTrack = dynamic_cast(entry.node != nullptr ? entry.node->getProcessor() : nullptr); + if (recTrack == nullptr) + continue; + + const auto& recId = entry.id; + if (audioRecorder.isRecording(recId)) + audioRecorder.setRecordingStartTime(recId, startPos); + if (midiRecorder.isRecording(recId)) + midiRecorder.setRecordingStartTime(recId, startPos); } } - else + + // Use cached solo state (updated when solo changes, avoids scanning every callback) + bool anySoloed = cachedAnySoloed.load(); + + // ========== Determine sidechain-aware processing order ========== + constexpr int MAX_TRACKS = 64; + int processedOrder[MAX_TRACKS]; + int orderCount = 0; + buildSidechainProcessingOrder (*rtTracks, processedOrder, orderCount, MAX_TRACKS); + + // Process each track in sidechain-aware order + bool anyActiveARAInCallback = false; + for (int orderIdx = 0; orderIdx < orderCount; ++orderIdx) { - // Stop all recordings and collect clip info - logToDisk("Record mode OFF - stopping recordings"); - lastCompletedClips = audioRecorder.stopAllRecordings(currentSampleRate); - logToDisk("Recordings stopped. Completed " + juce::String(lastCompletedClips.size()) + " clips."); + int trackIdx = processedOrder[orderIdx]; + const auto& trackEntry = (*rtTracks)[static_cast(trackIdx)]; + const auto& trackId = trackEntry.id; + auto* track = dynamic_cast(trackEntry.node != nullptr ? trackEntry.node->getProcessor() : nullptr); + if (!track) + continue; - // Generate peak caches for recorded files in background (REAPER-style). - // The onComplete callback fires on the message thread; MainComponent uses - // it to emit a "peaksReady" JS event so the Timeline refreshes waveforms. - for (const auto& clip : lastCompletedClips) + track->setCurrentAudioCallbackDebugInfo({ callbackCounter, firstCallbackAfterTransportStart, callbackStartWallTimeMs }); + if (track->hasActiveARA()) + anyActiveARAInCallback = true; + + // Solo logic: if any track is soloed, skip non-soloed tracks entirely + // (recording still works because record-armed tracks should also be soloed, + // and in practice users don't solo-off a track they're actively recording) + if (anySoloed && !track->getSolo()) { - juce::String filePath = clip.file.getFullPathName(); - peakCache.generateAsync(clip.file, [this, filePath]() - { - if (onPeaksReady) - onPeaksReady(filePath); - }); + track->resetRMS(); + continue; } - } -} + const bool isMidiTrack = track->getTrackType() == TrackType::MIDI + || track->getTrackType() == TrackType::Instrument; + const bool shouldReadHardwareInput = !isMidiTrack + && !track->getMute() + && (track->getRecordArmed() + || track->getInputMonitoring() + || audioRecorder.isRecording(trackId)); + const double blockStartTimeSeconds = currentSamplePosition / currentSampleRate; + const bool shouldProcessMidi = track->needsProcessing(blockStartTimeSeconds, numSamples, + currentSampleRate, isPlaying.load()); + + // Get track's input configuration + int startChan = track->getInputStartChannel(); + int numChans = track->getInputChannelCount(); + + if (shouldReadHardwareInput) + { + // Safety check + if (startChan < 0 || startChan >= numInputChannels) + continue; + } + + // Skip this track if it's not playing clips and not eligible for live input. + // Armed audio tracks should still be processed while stopped so hardware + // monitoring works before recording starts. + if (!isPlaying && !shouldReadHardwareInput && !shouldProcessMidi) + { + track->resetRMS(); // Clear meter so it drops to zero when idle + continue; + } + + // Non-owning view of the pre-allocated buffer — avoids heap alloc on audio thread. + // ALWAYS 2 channels (stereo) for proper pan support. + // + // IMPORTANT: getWritePointer() is used (not getArrayOfWritePointers()) because + // it sets isClear=false on reusableTrackBuffer as a side effect. Without this, + // JUCE 8's isClear optimisation makes clear() a no-op when a previous iteration + // left isClear=true, and makes applyGain/getRMSLevel skip processing (they also + // guard on isClear), causing stale signal to accumulate across track iterations. + // + // Emergency guard: if the pre-allocated buffer is somehow undersized (shouldn't + // happen on ASIO but guards against unusual driver behaviour), resize it here. + // This heap-allocates only in that exceptional case. + if (reusableTrackBuffer.getNumSamples() < numSamples || reusableTrackBuffer.getNumChannels() < 2) + reusableTrackBuffer.setSize (2, numSamples, false, true); + + float* trackChans[2] = { reusableTrackBuffer.getWritePointer (0), + reusableTrackBuffer.getWritePointer (1) }; + juce::AudioBuffer trackBuffer (trackChans, 2, numSamples); + trackBuffer.clear(); + + // ========== MIX IN ACCUMULATED SENDS from other tracks ========== + if (trackEntry.sendAccumBuffer != nullptr && trackEntry.sendAccumBuffer->getNumSamples() >= numSamples) + { + auto& accumBuf = *trackEntry.sendAccumBuffer; + for (int ch = 0; ch < juce::jmin(2, accumBuf.getNumChannels()); ++ch) + juce::FloatVectorOperations::add( + trackBuffer.getWritePointer(ch), + accumBuf.getReadPointer(ch), + numSamples); + accumBuf.clear(); + } + + // PLAYBACK MODE: Read from clips if transport is playing. + // Skip for tracks with an active ARA plugin — the ARA playback renderer + // reads audio directly from its audio source via readAudioSamples(). + // PlaybackEngine output would be overwritten by the plugin anyway, and + // the redundant disk I/O causes severe contention (~300ms per block). + if (isPlaying && !track->hasActiveARA()) + { + // Apply per-track playback offset (ms → seconds) + double trackOffsetSec = track->getPlaybackOffset() / 1000.0; + double adjustedTime = (currentSamplePosition / currentSampleRate) - trackOffsetSec; + + // Fill buffer with playback audio from clips + playbackEngine.fillTrackBuffer( + trackId, + trackBuffer, + adjustedTime, + numSamples, + currentSampleRate + ); + postTrackPlaybackPeak = juce::jmax(postTrackPlaybackPeak, peakFromFloatBuffer(trackBuffer, numSamples)); + } + + // MONITORING/RECORDING MODE: Mix in input audio if monitoring + if (shouldReadHardwareInput) + { + // Copy hardware inputs to track buffer (mix with playback if both active). + // Preserve mono input as mono here and let the plugin chain widen it if needed. + for (int ch = 0; ch < numChans && (startChan + ch) < numInputChannels; ++ch) + { + if (isPlaying) + { + // Mix input with playback (overdub mode) + juce::FloatVectorOperations::add( + trackBuffer.getWritePointer(ch), + inputChannelData[startChan + ch], + numSamples + ); + } + else + { + // Just copy input (monitoring only) + trackBuffer.copyFrom(ch, 0, inputChannelData[startChan + ch], numSamples); + } + } + + postMonitoringInputPeak = juce::jmax(postMonitoringInputPeak, peakFromFloatBuffer(trackBuffer, numSamples)); + } + + // ========== RECORD RAW AUDIO (BEFORE FX) ========== + // Write to recorder if transport is playing AND in record mode + // This captures the raw input BEFORE any FX processing + if (isPlaying && isRecordMode && audioRecorder.isRecording(trackId)) + { + // Punch recording: only write audio within punch range + bool shouldWrite = true; + if (punchEnabled.load(std::memory_order_acquire)) + { + double posSeconds = currentSamplePosition / currentSampleRate; + double punchStart = punchStartTime.load(std::memory_order_relaxed); + double punchEnd = punchEndTime.load(std::memory_order_relaxed); + shouldWrite = (posSeconds >= punchStart && posSeconds < punchEnd); + } + + if (shouldWrite) + audioRecorder.writeBlock(trackId, trackBuffer, numSamples); + } + + // Tell the track where we are on the timeline so automation can evaluate + track->setCurrentBlockPosition(currentSamplePosition, currentSampleRate); + + // ========== SIDECHAIN: provide source track's output to this track ========== + // If this track has any sidechain-routed FX plugins, find the first sidechain + // source track and point the track's sidechainInputBuffer to its stored output. + if (!trackEntry.sidechainSourceIds.empty()) + { + // Find the first sidechain source track ID (all SC FX on this track + // share the same sidechain input buffer for simplicity — the source is + // determined by the first configured sidechain FX) + const juce::AudioBuffer* scBuffer = nullptr; + for (const auto& srcId : trackEntry.sidechainSourceIds) + { + if (srcId.isNotEmpty()) + { + for (const auto& candidate : *rtTracks) + { + if (candidate.id == srcId && candidate.sidechainOutputBuffer != nullptr) + { + scBuffer = candidate.sidechainOutputBuffer.get(); + break; + } + } + if (scBuffer != nullptr) + break; + } + } + track->setSidechainBuffer(scBuffer); + } + else + { + track->setSidechainBuffer(nullptr); + } + + // Process through track (applies volume, pan, FX, automation) + juce::MidiBuffer midiMessages = buildTrackMidiBlock(trackId, blockStartTimeSeconds, + numSamples, currentSampleRate, + isPlaying.load()); + int prevMidiMax = midiMaxEventsPerBlock.load(std::memory_order_relaxed); + while (midiMessages.getNumEvents() > prevMidiMax + && !midiMaxEventsPerBlock.compare_exchange_weak(prevMidiMax, midiMessages.getNumEvents(), + std::memory_order_relaxed)) + { + } + if (!track->tryProcessBlock(trackBuffer, midiMessages)) + { + track->resetRMS(); + continue; + } + const float trackBufferPeak = peakFromFloatBuffer(trackBuffer, numSamples); + if (shouldLogCallbackSummary && isPlaying.load()) + { + logAudioPlayback("track stage track=" + trackId + + " muted=" + juce::String(track->getMute() ? "true" : "false") + + " solo=" + juce::String(track->getSolo() ? "true" : "false") + + " masterSend=" + juce::String(track->getMasterSendEnabled() ? "true" : "false") + + " shouldReadHardwareInput=" + juce::String(shouldReadHardwareInput ? "true" : "false") + + " clipPlaybackRequested=" + juce::String((isPlaying && !track->hasActiveARA()) ? "true" : "false") + + " trackBufferPeak=" + juce::String(trackBufferPeak, 4)); + } + + // ========== SIDECHAIN: store this track's output for downstream tracks ========== + // Copy the processed output into the pre-allocated sidechain buffer so that + // tracks processed later can use it as sidechain input. + if (trackEntry.sidechainOutputBuffer != nullptr) + { + auto& scOut = *trackEntry.sidechainOutputBuffer; + if (scOut.getNumSamples() < numSamples) + scOut.setSize(2, numSamples, false, false, true); + + for (int ch = 0; ch < juce::jmin(2, trackBuffer.getNumChannels()); ++ch) + scOut.copyFrom(ch, 0, trackBuffer, ch, 0, numSamples); + } + + // ========== SEND MIXING: fill destination track send accum buffers ========== + if (!trackEntry.sends.empty()) + { + const auto& preFaderBuffer = track->getPreFaderBuffer(); + for (const auto& send : trackEntry.sends) + { + if (!send.enabled || send.level <= 0.0f || send.destTrackId.isEmpty()) + continue; + + for (const auto& destEntry : *rtTracks) + { + if (destEntry.id != send.destTrackId || destEntry.sendAccumBuffer == nullptr) + continue; + + auto& destBuf = *destEntry.sendAccumBuffer; + if (destBuf.getNumSamples() >= numSamples) + { + const auto& srcBuf = send.preFader ? preFaderBuffer : trackBuffer; + const int srcChannels = srcBuf.getNumChannels(); + const int destChannels = destBuf.getNumChannels(); + const float level = send.level; + const float phaseMultiplier = send.phaseInvert ? -1.0f : 1.0f; + const float panAngle = (send.pan + 1.0f) * juce::MathConstants::pi / 4.0f; + const float leftGain = std::cos(panAngle) * level * phaseMultiplier; + const float rightGain = std::sin(panAngle) * level * phaseMultiplier; + + if (destChannels >= 2 && srcChannels >= 2) + { + for (int sample = 0; sample < numSamples; ++sample) + { + destBuf.getWritePointer(0)[sample] += srcBuf.getReadPointer(0)[sample] * leftGain; + destBuf.getWritePointer(1)[sample] += srcBuf.getReadPointer(1)[sample] * rightGain; + } + } + else if (destChannels >= 1 && srcChannels >= 1) + { + for (int sample = 0; sample < numSamples; ++sample) + destBuf.getWritePointer(0)[sample] += srcBuf.getReadPointer(0)[sample] * level; + } + } + + break; + } + } + } + + // ========== MIDI OUTPUT: route MIDI to hardware output ========== + if (!midiMessages.isEmpty() && track->getMIDIOutputDeviceName().isNotEmpty()) + track->sendMIDIToOutput(midiMessages); + + // Mix track output to device outputs (only if master send is enabled) + if (track->getMasterSendEnabled()) + { + int outStart = track->getOutputStartChannel(); + int outCount = track->getOutputChannelCount(); + for (int ch = 0; ch < std::min(trackBuffer.getNumChannels(), outCount); ++ch) + { + int destCh = outStart + ch; + if (destCh < numOutputChannels) + { + if (useHybrid64Summing) + { + auto* dest = reusableMasterBufferDouble.getWritePointer(destCh); + auto* src = trackBuffer.getReadPointer(ch); + for (int sample = 0; sample < numSamples; ++sample) + dest[sample] += static_cast(src[sample]); + } + else + { + juce::FloatVectorOperations::add( + outputChannelData[destCh], + trackBuffer.getReadPointer(ch), + numSamples + ); + } + } + } + } + } + + // ========== FX Chains (extracted helpers) ========== + lastPostTrackPlaybackPeak.store(postTrackPlaybackPeak, std::memory_order_relaxed); + lastPostMonitoringInputPeak.store(postMonitoringInputPeak, std::memory_order_relaxed); + processMasterFXChain (rtMasterFX, outputChannelData, numOutputChannels, numSamples, useHybrid64Summing); + const float postMasterFxPeak = hybrid64PostChainActive + ? peakFromDoubleBuffer(reusableMasterBufferDouble, numOutputChannels, numSamples) + : peakFromFloatChannels(outputChannelData, numOutputChannels, numSamples); + lastPostMasterFXPeak.store(postMasterFxPeak, std::memory_order_relaxed); + processMonitoringFXChain (rtMonitoringFX, outputChannelData, numOutputChannels, numSamples, hybrid64PostChainActive); + const float postMonitoringFxPeak = hybrid64PostChainActive + ? peakFromDoubleBuffer(reusableMasterBufferDouble, numOutputChannels, numSamples) + : peakFromFloatChannels(outputChannelData, numOutputChannels, numSamples); + lastPostMonitoringFXPeak.store(postMonitoringFxPeak, std::memory_order_relaxed); + + // ========== Master Gain, Pan, Mono, and Precision Conversion ========== + applyMasterGainPanMono (outputChannelData, numOutputChannels, numSamples, + currentSamplePosition, hybrid64PostChainActive); + const float finalOutputPeak = peakFromFloatChannels(outputChannelData, numOutputChannels, numSamples); + lastFinalOutputPeak.store(finalOutputPeak, std::memory_order_relaxed); + if (shouldLogCallbackSummary || (((isPlaying || isRecordMode) && finalOutputPeak <= 0.0001f) + && ((callbackCounter % 15) == 1))) + { + logAudioPlayback("callback peaks #" + juce::String(static_cast(callbackCounter)) + + " preTrackPeak=" + juce::String(preTrackPeak, 4) + + " postTrackPlaybackPeak=" + juce::String(postTrackPlaybackPeak, 4) + + " postMonitoringInputPeak=" + juce::String(postMonitoringInputPeak, 4) + + " postMasterFXPeak=" + juce::String(postMasterFxPeak, 4) + + " postMonitoringFXPeak=" + juce::String(postMonitoringFxPeak, 4) + + " finalOutputPeak=" + juce::String(finalOutputPeak, 4)); + } + + // ========== Metering & Analysis (extracted helpers) ========== + updateMasterMetering (outputChannelData, numOutputChannels, numSamples); + updatePhaseCorrelation (outputChannelData, numOutputChannels, numSamples); + updateSpectrumAnalyzer (outputChannelData, numOutputChannels, numSamples); + + // ========== Timecode / Sync Output (Phase 3.9) ========== + timecodeSyncManager.processBlock(numSamples, currentSampleRate, tempo, + currentSamplePosition / currentSampleRate, + isPlaying.load()); + + // ========== Clip Launch / Trigger Engine (Phase 4.1) ========== + { + double currentBeat = (currentSamplePosition / currentSampleRate) * (tempo / 60.0); + triggerEngine.processBlock(numSamples, currentSampleRate, tempo, currentBeat); + } + + // ========== Advance transport position ========== + // IMPORTANT: This is intentionally AFTER all processing (metronome, playback, + // recording) so they use the position corresponding to the samples being + // output in this callback. Previously this was at the top of the callback, + // causing a systematic one-buffer-length delay. + if (isPlaying) + { + // Loop recording detection (Phase 3.2): if position jumped backward + // (frontend wrapped the loop), finalize current takes and start new ones. + // setTransportPosition() may have been called from the message thread + // between callbacks, causing currentSamplePosition to jump backward. + if (isRecordMode && isLooping && currentSamplePosition < prevSamplePosition - numSamples) + { + // Position jumped backward — loop wrap detected + loopTakeCounter++; + queueAllNotesOffForAllTracks(); + + // Finalize current audio recordings and start new takes + auto completedTakes = audioRecorder.stopAllRecordings(currentSampleRate); + for (auto& take : completedTakes) + lastCompletedClips.push_back(std::move(take)); + + // Finalize MIDI recordings + auto completedMIDITakes = midiRecorder.stopAllRecordings(projectAudioFolder, tempo); + for (auto& take : completedMIDITakes) + lastCompletedMIDIClips.push_back(std::move(take)); + + // Start new recordings for next loop pass + for (auto const& [tId, tTrack] : trackMap) + { + if (!tTrack || !tTrack->getRecordArmed()) continue; + + auto trackType = tTrack->getTrackType(); + if (trackType == TrackType::MIDI || trackType == TrackType::Instrument) + { + midiRecorder.startRecording(tId, currentSampleRate); + midiRecorder.setRecordingStartTime(tId, currentSamplePosition / currentSampleRate); + } + else + { + auto timestamp = juce::Time::getCurrentTime().toMilliseconds(); + auto filename = "Track_" + tId + "_Take_" + juce::String(timestamp) + + "_L" + juce::String(loopTakeCounter) + ".wav"; + auto outputFile = projectAudioFolder.getChildFile(filename); + audioRecorder.startRecording(tId, outputFile, currentSampleRate, tTrack->getInputChannelCount()); + audioRecorder.setRecordingStartTime(tId, currentSamplePosition / currentSampleRate); + } + } + } + + prevSamplePosition = currentSamplePosition; + currentSamplePosition += numSamples; + } + + const double callbackDurationMs = juce::Time::getMillisecondCounterHiRes() - callbackStartWallTimeMs; + if (kEnableARADebugDiagnostics && anyActiveARAInCallback && callbackDurationMs > 10.0) + { + logToDisk("Audio callback slow: callback=" + juce::String(static_cast(callbackCounter)) + + " firstCallbackAfterTransportStart=" + juce::String(firstCallbackAfterTransportStart ? "true" : "false") + + " totalMs=" + juce::String(callbackDurationMs, 2) + + " tracksProcessed=" + juce::String(orderCount) + + " numSamples=" + juce::String(numSamples) + + " transportPlaying=" + juce::String(isPlaying.load() ? "true" : "false") + + " currentPositionSeconds=" + juce::String(currentSampleRate > 0.0 ? currentSamplePosition / currentSampleRate : 0.0, 3)); + } +} + +juce::String AudioEngine::addTrack(const juce::String& explicitId) +{ + logToDisk("AudioEngine: Adding Track..."); + + if (! mainProcessorGraph) + return ""; + + // In a real app, use a proper lock + const juce::ScopedLock sl (mainProcessorGraph->getCallbackLock()); + + // Guard: if an explicit ID is provided and already exists, return it without + // creating a duplicate. Duplicate IDs in trackOrder cause the same + // TrackProcessor to be iterated (and its FX plugins called) twice per audio + // callback, producing severe distortion from the second processing pass. + if (explicitId.isNotEmpty() && trackMap.count(explicitId) > 0) + { + logToDisk("WARNING: addTrack called with already-existing ID " + explicitId + " — ignoring duplicate."); + return explicitId; + } + + auto newTrack = std::make_unique(); + auto* rawTrackPtr = newTrack.get(); // Keep raw pointer for metering (owned by graph) + rawTrackPtr->setARAPlaybackRequestHandlers({ + [this]() + { + if (!isPlaying.load()) + setTransportPlaying(true); + }, + [this]() + { + if (isPlaying.load()) + setTransportPlaying(false); + }, + [this](double timePositionSeconds) + { + setTransportPosition(timePositionSeconds); + }, + [this](double startTimeSeconds, double durationSeconds) + { + juce::ignoreUnused(startTimeSeconds, durationSeconds); + setLoopMode(durationSeconds > 0.0); + }, + [this](bool enable) + { + setLoopMode(enable); + } + }); + + // Prepare the TrackProcessor with the current device parameters before handing + // it to the graph. AudioProcessorGraph::addNode() does NOT call prepareToPlay() + // on newly-added nodes when the graph is already running, so we must do it here. + // Use jmax(currentBlockSize, 512) as the block-size hint — same rationale as + // for FX plugins (ASIO can use blocks as small as 32; preparing at that size + // forces plugins to resize convolution/FFT for tiny blocks → crackling). + if (currentSampleRate > 0 && currentBlockSize > 0) + rawTrackPtr->prepareToPlay (currentSampleRate, currentBlockSize); + rawTrackPtr->setProcessingPrecisionMode(processingPrecisionMode); + + auto trackNode = mainProcessorGraph->addNode (std::move (newTrack)); + + if (trackNode) + { + juce::String trackId = explicitId.isNotEmpty() ? explicitId : juce::Uuid().toString(); + + trackMap[trackId] = rawTrackPtr; + trackNodeMap[trackId] = trackNode; + trackOrder.push_back(trackId); + + // Pre-allocate sidechain output buffer for this track (avoids heap alloc on audio thread) + int scBlockSize = currentBlockSize > 0 ? currentBlockSize : 512; + auto sidechainBuffer = std::make_shared>(); + sidechainBuffer->setSize(2, scBlockSize); + sidechainOutputBuffers[trackId] = sidechainBuffer; + + // Pre-allocate send accumulation buffer for this track + auto sendBuffer = std::make_shared>(); + sendBuffer->setSize(2, scBlockSize); + sendAccumBuffers[trackId] = sendBuffer; + + rebuildRealtimeProcessingSnapshots(); + + logToDisk("Track added. ID: " + trackId + " Total: " + juce::String((int)trackOrder.size())); + + if (audioOutputNode) + { + mainProcessorGraph->addConnection ({ { trackNode->nodeID, 0 }, { audioOutputNode->nodeID, 0 } }); + mainProcessorGraph->addConnection ({ { trackNode->nodeID, 1 }, { audioOutputNode->nodeID, 1 } }); + logToDisk("Connected track to output."); + } + else + { + logToDisk("WARNING: audioOutputNode is null. Track not connected."); + } + return trackId; + } + else + { + logToDisk("ERROR: Failed to add track node to graph."); + return ""; + } +} + +bool AudioEngine::removeTrack(const juce::String& trackId) +{ + // --- Phase 1: Close all plugin editor windows for this track BEFORE + // acquiring the callback lock. Window destruction must happen on + // the message thread and must complete before the TrackProcessor + // (and its owned plugins) are deleted by removeNode(). + { + auto it = trackMap.find(trackId); + if (it == trackMap.end()) return false; + + auto* track = it->second; + if (track) + { + std::vector processors; + for (int i = 0; i < track->getNumInputFX(); ++i) + if (auto* p = track->getInputFXProcessor(i)) + processors.push_back(p); + for (int i = 0; i < track->getNumTrackFX(); ++i) + if (auto* p = track->getTrackFXProcessor(i)) + processors.push_back(p); + if (auto* inst = track->getInstrument()) + processors.push_back(inst); + + if (!processors.empty()) + pluginWindowManager.closeEditorsForTrack(processors); + } + } + + // --- Phase 2: Acquire lock and remove the track from the graph. + const juce::ScopedLock sl (mainProcessorGraph->getCallbackLock()); + + auto it = trackMap.find(trackId); + if (it == trackMap.end()) return false; + + auto nodeIt = trackNodeMap.find(trackId); + if (nodeIt != trackNodeMap.end() && nodeIt->second) + mainProcessorGraph->removeNode(nodeIt->second->nodeID); + + trackMap.erase(it); + trackNodeMap.erase(trackId); + trackOrder.erase(std::remove(trackOrder.begin(), trackOrder.end(), trackId), trackOrder.end()); + sidechainOutputBuffers.erase(trackId); + sendAccumBuffers.erase(trackId); + rebuildRealtimeProcessingSnapshots(); + + return true; +} + +bool AudioEngine::reorderTrack(const juce::String& trackId, int newPosition) +{ + const juce::ScopedLock sl (mainProcessorGraph->getCallbackLock()); + + auto it = std::find(trackOrder.begin(), trackOrder.end(), trackId); + if (it == trackOrder.end()) return false; + + // Clamp new position + if (newPosition < 0) newPosition = 0; + if (newPosition >= (int)trackOrder.size()) newPosition = (int)trackOrder.size() - 1; + + if (std::distance(trackOrder.begin(), it) == newPosition) return true; + + trackOrder.erase(it); + trackOrder.insert(trackOrder.begin() + newPosition, trackId); + rebuildRealtimeProcessingSnapshots(); + + return true; +} + +int AudioEngine::getTrackIndex(const juce::String& trackId) const +{ + auto it = std::find(trackOrder.begin(), trackOrder.end(), trackId); + if (it != trackOrder.end()) + return (int)std::distance(trackOrder.begin(), it); + return -1; +} + +juce::var AudioEngine::getMeterLevels() +{ + // Return an object with track IDs as keys for robust matching + juce::DynamicObject::Ptr meterObj (new juce::DynamicObject()); + + for (const auto& trackId : trackOrder) + { + auto it = trackMap.find(trackId); + if (it != trackMap.end() && it->second) + meterObj->setProperty(juce::Identifier(trackId), it->second->getRMSLevel()); + } + + return juce::var (meterObj.get()); +} + +juce::var AudioEngine::getMeterClipStates() +{ + juce::DynamicObject::Ptr clipObj(new juce::DynamicObject()); + + for (const auto& trackId : trackOrder) + { + auto it = trackMap.find(trackId); + if (it != trackMap.end() && it->second) + clipObj->setProperty(juce::Identifier(trackId), it->second->isClipLatched()); + } + + return juce::var(clipObj.get()); +} + +juce::var AudioEngine::getMeteringData() +{ + // Return an array with one entry per track (used by ScriptEngine for track count etc.) + juce::Array result; + for (const auto& trackId : trackOrder) + { + auto it = trackMap.find(trackId); + if (it != trackMap.end() && it->second) + { + juce::DynamicObject::Ptr obj (new juce::DynamicObject()); + obj->setProperty("id", trackId); + obj->setProperty("rms", it->second->getRMSLevel()); + result.add(juce::var(obj.get())); + } + } + return juce::var(result); +} + +//============================================================================== +// MIDI Device Management (Phase 2) + +juce::var AudioEngine::getMIDIInputDevices() +{ + if (!midiManager) + return juce::var(); + + auto devices = midiManager->getAvailableDevices(); + juce::var result; + juce::Array deviceArray; + + for (const auto& device : devices) + { + deviceArray.add(device); + } + + result = deviceArray; + return result; +} + +juce::var AudioEngine::getMIDIOutputDevices() +{ + juce::Array deviceArray; + for (const auto& device : juce::MidiOutput::getAvailableDevices()) + deviceArray.add(device.name); + return deviceArray; +} + +bool AudioEngine::openMIDIDevice(const juce::String& deviceName) +{ + if (!midiManager) + return false; + + bool success = midiManager->openDevice(deviceName); + if (success) + { + juce::Logger::writeToLog("AudioEngine: Opened MIDI device: " + deviceName); + } + return success; +} + +void AudioEngine::closeMIDIDevice(const juce::String& deviceName) +{ + if (midiManager) + { + midiManager->closeDevice(deviceName); + juce::Logger::writeToLog("AudioEngine: Closed MIDI device: " + deviceName); + } +} + +juce::var AudioEngine::getOpenMIDIDevices() +{ + if (!midiManager) + return juce::var(); + + auto devices = midiManager->getOpenDevices(); + juce::var result; + juce::Array deviceArray; + + for (const auto& device : devices) + { + deviceArray.add(device); + } + + result = deviceArray; + return result; +} + +//============================================================================== +// Track Type Management (Phase 2) + +void AudioEngine::setTrackType(const juce::String& trackId, const juce::String& type) +{ + if (trackMap.find(trackId) == trackMap.end()) + return; + + TrackType trackType = TrackType::Audio; + + if (type == "midi") + trackType = TrackType::MIDI; + else if (type == "instrument") + trackType = TrackType::Instrument; + + trackMap[trackId]->setTrackType(trackType); + juce::Logger::writeToLog("AudioEngine: Track " + trackId + " type set to: " + type); +} + +void AudioEngine::setTrackMIDIInput(const juce::String& trackId, const juce::String& deviceName, int channel) +{ + if (trackMap.find(trackId) == trackMap.end()) + return; + + trackMap[trackId]->setMIDIInputDevice(deviceName); + trackMap[trackId]->setMIDIChannel(channel); + + juce::Logger::writeToLog("AudioEngine: Track " + trackId + + " MIDI input: " + deviceName + + " channel: " + juce::String(channel)); +} + +void AudioEngine::setTrackMIDIClips(const juce::String& trackId, const juce::String& clipsJSON) +{ + auto it = trackMap.find(trackId); + if (it == trackMap.end() || !it->second) + return; + + std::vector clips; + auto parsed = juce::JSON::parse(clipsJSON); + if (auto* clipsArray = parsed.getArray()) + { + clips.reserve(static_cast(clipsArray->size())); + for (const auto& clipVar : *clipsArray) + { + auto* clipObj = clipVar.getDynamicObject(); + if (!clipObj) + continue; + + TrackProcessor::ScheduledMIDIClip clip; + clip.clipId = clipObj->getProperty("id").toString(); + clip.startTime = static_cast(clipObj->getProperty("startTime")); + clip.duration = static_cast(clipObj->getProperty("duration")); + + if (auto* eventsArray = clipObj->getProperty("events").getArray()) + { + clip.events.reserve(static_cast(eventsArray->size())); + for (const auto& eventVar : *eventsArray) + { + auto* eventObj = eventVar.getDynamicObject(); + if (!eventObj) + continue; + + const juce::String eventType = eventObj->getProperty("type").toString(); + const double timestamp = static_cast(eventObj->getProperty("timestamp")); + const int channel = eventObj->hasProperty("channel") + ? juce::jlimit(1, 16, static_cast(eventObj->getProperty("channel"))) + : 1; + + juce::MidiMessage message; + if (eventType == "noteOn") + { + const int note = static_cast(eventObj->getProperty("note")); + const int velocity = static_cast(eventObj->getProperty("velocity")); + message = juce::MidiMessage::noteOn(channel, note, + static_cast(juce::jlimit(0, 127, velocity))); + } + else if (eventType == "noteOff") + { + const int note = static_cast(eventObj->getProperty("note")); + message = juce::MidiMessage::noteOff(channel, note); + } + else if (eventType == "cc") + { + const int controller = static_cast(eventObj->getProperty("controller")); + const int value = static_cast(eventObj->getProperty("value")); + message = juce::MidiMessage::controllerEvent(channel, controller, + juce::jlimit(0, 127, value)); + } + else if (eventType == "pitchBend") + { + const int value = eventObj->hasProperty("value") + ? static_cast(eventObj->getProperty("value")) + : 8192; + message = juce::MidiMessage::pitchWheel(channel, juce::jlimit(0, 16383, value)); + } + else + { + continue; + } + + TrackProcessor::ScheduledMIDIEvent scheduledEvent; + scheduledEvent.timestampSeconds = timestamp; + scheduledEvent.message = message; + clip.events.push_back(std::move(scheduledEvent)); + } + } + + std::sort(clip.events.begin(), clip.events.end(), + [] (const auto& a, const auto& b) { return a.timestampSeconds < b.timestampSeconds; }); + clips.push_back(std::move(clip)); + } + } + + it->second->setScheduledMIDIClips(std::move(clips)); +} + +bool AudioEngine::sendMidiNote(const juce::String& trackId, int note, int velocity, bool isNoteOn) +{ + auto it = trackMap.find(trackId); + if (it == trackMap.end() || !it->second) + return false; + + auto* track = it->second; + const int channel = track->getMIDIChannel() > 0 ? track->getMIDIChannel() : 1; + int sampleOffset = 0; + if (currentSampleRate > 0.0 && currentBlockSize > 0) + { + const double blockStartMs = lastAudioBlockWallTimeMs.load(std::memory_order_acquire); + const double blockDurationMs = lastAudioBlockDurationMs.load(std::memory_order_acquire); + if (blockStartMs > 0.0 && blockDurationMs > 0.0) + { + double elapsedMs = juce::Time::getMillisecondCounterHiRes() - blockStartMs; + if (elapsedMs < 0.0) + elapsedMs = 0.0; + if (elapsedMs > blockDurationMs) + { + midiLateEventCount.fetch_add(1, std::memory_order_relaxed); + elapsedMs = blockDurationMs; + } + + sampleOffset = juce::jlimit( + 0, + juce::jmax(0, currentBlockSize - 1), + static_cast(std::round((elapsedMs / 1000.0) * currentSampleRate))); + midiLastComputedSampleOffset.store(sampleOffset, std::memory_order_relaxed); + } + } + + juce::MidiMessage message = isNoteOn + ? juce::MidiMessage::noteOn(channel, note, static_cast(juce::jlimit(0, 127, velocity))) + : juce::MidiMessage::noteOff(channel, note); + + const bool queued = track->enqueueMidiMessage(message, sampleOffset); + if (queued && isPlaying && isRecordMode && track->getRecordArmed()) + midiRecorder.recordEvent(trackId, currentSamplePosition / currentSampleRate, message); + + return queued; +} + +bool AudioEngine::loadInstrument(const juce::String& trackId, const juce::String& vstPath) +{ + if (trackMap.find(trackId) == trackMap.end()) + return false; + + // Load the VST instrument using PluginManager with actual device rate + double sr = currentSampleRate > 0 ? currentSampleRate : 44100.0; + int bs = currentBlockSize > 0 ? currentBlockSize : 512; + auto plugin = pluginManager.loadPluginFromFile(vstPath, sr, bs); + if (!plugin) + { + juce::Logger::writeToLog("AudioEngine: Failed to load instrument: " + vstPath); + return false; + } + + // Cast to AudioPluginInstance + auto* pluginInstance = dynamic_cast(plugin.get()); + if (!pluginInstance) + { + juce::Logger::writeToLog("AudioEngine: Plugin is not an AudioPluginInstance: " + vstPath); + return false; + } + + // Create a unique_ptr with the correct type + std::unique_ptr instrumentPtr(pluginInstance); + plugin.release(); // Release ownership from the AudioProcessor unique_ptr + + // Provide tempo/position info to the instrument plugin + instrumentPtr->setPlayHead (this); + + // Set the instrument on the track + trackMap[trackId]->setInstrument(std::move(instrumentPtr), sr, bs); + trackMap[trackId]->setTrackType(TrackType::Instrument); + + juce::Logger::writeToLog("AudioEngine: Loaded instrument on track " + trackId + ": " + vstPath); + return true; +} + +//============================================================================== +// MIDI Message Routing (Phase 2) + +void AudioEngine::handleMIDIMessage(const juce::String& deviceName, int channel, const juce::MidiMessage& message) +{ + // MIDI Learn: if active and we receive a CC, create a mapping + if (midiLearnActive.load() && message.isController()) + { + const juce::ScopedLock sl(midiLearnLock); + int ccNum = message.getControllerNumber(); + + // Remove existing mapping for this CC + midiLearnMappings.erase( + std::remove_if(midiLearnMappings.begin(), midiLearnMappings.end(), + [ccNum](const MIDILearnMapping& m) { return m.ccNumber == ccNum; }), + midiLearnMappings.end()); + + MIDILearnMapping mapping; + mapping.ccNumber = ccNum; + mapping.trackId = midiLearnTrackId; + mapping.pluginIndex = midiLearnPluginIndex; + mapping.paramIndex = midiLearnParamIndex; + midiLearnMappings.push_back(mapping); + + midiLearnActive.store(false); + juce::Logger::writeToLog("MIDI Learn: Mapped CC " + juce::String(ccNum) + + " -> track=" + mapping.trackId + + " plugin=" + juce::String(mapping.pluginIndex) + + " param=" + juce::String(mapping.paramIndex)); + } + + // Apply MIDI CC mappings: route incoming CC values to mapped plugin parameters + if (message.isController()) + { + const juce::ScopedLock sl(midiLearnLock); + int ccNum = message.getControllerNumber(); + float normalizedValue = message.getControllerValue() / 127.0f; + + for (const auto& mapping : midiLearnMappings) + { + if (mapping.ccNumber == ccNum) + { + auto it = trackMap.find(mapping.trackId); + if (it != trackMap.end() && it->second) + { + auto* processor = it->second->getTrackFXProcessor(mapping.pluginIndex); + if (processor) + { + const auto& params = processor->getParameters(); + if (params.size() > mapping.paramIndex) + params[mapping.paramIndex]->setValueNotifyingHost(normalizedValue); + } + } + } + } + } + + int sampleOffset = 0; + if (currentSampleRate > 0.0 && currentBlockSize > 0) + { + const double blockStartMs = lastAudioBlockWallTimeMs.load(std::memory_order_acquire); + const double blockDurationMs = lastAudioBlockDurationMs.load(std::memory_order_acquire); + if (blockStartMs > 0.0 && blockDurationMs > 0.0) + { + double elapsedMs = juce::Time::getMillisecondCounterHiRes() - blockStartMs; + if (elapsedMs < 0.0) + elapsedMs = 0.0; + if (elapsedMs > blockDurationMs) + { + midiLateEventCount.fetch_add(1, std::memory_order_relaxed); + elapsedMs = blockDurationMs; + } + + sampleOffset = juce::jlimit( + 0, + juce::jmax(0, currentBlockSize - 1), + static_cast(std::round((elapsedMs / 1000.0) * currentSampleRate))); + midiLastComputedSampleOffset.store(sampleOffset, std::memory_order_relaxed); + } + } + + // Route MIDI to appropriate tracks + for (auto const& [id, track] : trackMap) + { + if (!track) continue; + + // Check if track accepts MIDI + if (track->getTrackType() != TrackType::MIDI && track->getTrackType() != TrackType::Instrument) + continue; + + // Check if track is listening to this device. + // Instrument tracks with no explicit device accept MIDI from any device + // (omni mode) so the user gets sound immediately after loading an instrument. + const auto trackDevice = track->getMIDIInputDevice(); + if (!trackDevice.isEmpty() && trackDevice != deviceName) + continue; + + // Check channel filtering (0 = all channels, 1-16 = specific channel) + int trackChannel = track->getMIDIChannel(); + if (trackChannel != 0 && trackChannel != channel) + continue; + + // Instrument tracks always accept live MIDI (they exist to be played). + // Audio/MIDI tracks require explicit input monitoring toggle. + if (track->getInputMonitoring() + || track->getTrackType() == TrackType::Instrument) + track->enqueueMidiMessage(message, sampleOffset); + + // Record MIDI event if armed and in record mode + if (isPlaying && isRecordMode && track->getRecordArmed()) + { + double timestamp = currentSamplePosition / currentSampleRate; + midiRecorder.recordEvent(id, timestamp, message); + } + } +} + +juce::MidiBuffer AudioEngine::buildTrackMidiBlock(const juce::String& trackId, double blockStartTimeSeconds, + int numSamples, double sampleRate, bool playing) +{ + juce::MidiBuffer midiMessages; + auto it = trackMap.find(trackId); + if (it != trackMap.end() && it->second) + it->second->buildMidiBuffer(midiMessages, blockStartTimeSeconds, numSamples, sampleRate, playing); + return midiMessages; +} + +void AudioEngine::queueAllNotesOffForTrack(TrackProcessor& track) +{ + if (track.getTrackType() == TrackType::MIDI || track.getTrackType() == TrackType::Instrument) + track.queueAllNotesOff(); +} + +void AudioEngine::queueAllNotesOffForAllTracks() +{ + for (const auto& [trackId, track] : trackMap) + { + juce::ignoreUnused(trackId); + if (track) + queueAllNotesOffForTrack(*track); + } +} + +void AudioEngine::applyProcessingPrecisionToTrack(TrackProcessor& track) +{ + track.setProcessingPrecisionMode(processingPrecisionMode); +} + +float AudioEngine::getMasterLevel() const +{ + // Use the actual measured output level (computed in the audio callback after FX and pan) + return masterOutputLevel.load(); +} + +bool AudioEngine::getMasterClipLatched() const +{ + return masterClipLatched.load(std::memory_order_relaxed); +} + +void AudioEngine::resetMeterClip(const juce::String& trackId) +{ + if (trackId == "master") + { + masterClipLatched.store(false, std::memory_order_relaxed); + return; + } + + auto it = trackMap.find(trackId); + if (it != trackMap.end() && it->second != nullptr) + it->second->resetClipLatch(); +} + + + + +juce::var AudioEngine::getAudioDeviceSetup() +{ + juce::Logger::writeToLog("AudioEngine: getAudioDeviceSetup() called"); + + juce::DynamicObject* data = new juce::DynamicObject(); + + // 1. Current Setup + auto* currentSetup = new juce::DynamicObject(); + auto currentDeviceType = deviceManager.getCurrentAudioDeviceType(); + + juce::Logger::writeToLog("AudioEngine: Current device type: " + currentDeviceType); + + // Handle case where no device is open yet + auto* device = deviceManager.getCurrentAudioDevice(); + + currentSetup->setProperty("audioDeviceType", currentDeviceType); + if (device) + { + juce::Logger::writeToLog("AudioEngine: Current device: " + device->getName()); + currentSetup->setProperty("inputDevice", device->getName()); + currentSetup->setProperty("outputDevice", device->getName()); // Basic assumption for now + currentSetup->setProperty("sampleRate", device->getCurrentSampleRate()); + currentSetup->setProperty("bufferSize", device->getCurrentBufferSizeSamples()); + + // Get ALL channel names from the device (not just active) + juce::StringArray inputNames = device->getInputChannelNames(); + juce::StringArray outputNames = device->getOutputChannelNames(); + + // Total available channels (this is what matters for routing selection) + int numTotalInputs = inputNames.size(); + int numTotalOutputs = outputNames.size(); + + juce::Logger::writeToLog("AudioEngine: Device has " + juce::String(numTotalInputs) + " total input channels"); + + currentSetup->setProperty("numInputChannels", numTotalInputs); + currentSetup->setProperty("numOutputChannels", numTotalOutputs); + + // Build input channel names array (ALL channels) + juce::Array inputChannelNamesArray; + for (int i = 0; i < inputNames.size(); ++i) + { + inputChannelNamesArray.add(inputNames[i]); + juce::Logger::writeToLog("AudioEngine: Input channel " + juce::String(i) + ": " + inputNames[i]); + } + currentSetup->setProperty("inputChannelNames", inputChannelNamesArray); + + // Build output channel names array (ALL channels) + juce::Array outputChannelNamesArray; + for (int i = 0; i < outputNames.size(); ++i) + { + outputChannelNamesArray.add(outputNames[i]); + } + currentSetup->setProperty("outputChannelNames", outputChannelNamesArray); + + // Also provide active channel info separately (can be used for metering, etc) + currentSetup->setProperty("numActiveInputChannels", device->getActiveInputChannels().countNumberOfSetBits()); + currentSetup->setProperty("numActiveOutputChannels", device->getActiveOutputChannels().countNumberOfSetBits()); + } + else + { + juce::Logger::writeToLog("AudioEngine: WARNING - No audio device currently active!"); + // Provide defaults + currentSetup->setProperty("inputDevice", ""); + currentSetup->setProperty("outputDevice", ""); + currentSetup->setProperty("sampleRate", 44100.0); + currentSetup->setProperty("bufferSize", 512); + currentSetup->setProperty("numInputChannels", 2); + currentSetup->setProperty("numOutputChannels", 2); + currentSetup->setProperty("inputChannelNames", juce::Array()); + currentSetup->setProperty("outputChannelNames", juce::Array()); + } + + data->setProperty("current", currentSetup); + + // 2. Available Device Types + juce::Array types; + for (auto& type : deviceManager.getAvailableDeviceTypes()) + { + auto typeName = type->getTypeName(); + types.add(typeName); + juce::Logger::writeToLog("AudioEngine: Available type: " + typeName); + } + data->setProperty("availableTypes", types); + + // 3. Available Devices for Current Type + juce::Array inputList; + juce::Array outputList; + + if (auto* type = deviceManager.getCurrentDeviceTypeObject()) + { + juce::Logger::writeToLog("AudioEngine: Enumerating devices for type: " + type->getTypeName()); + + for (auto& name : type->getDeviceNames(true)) // Inputs + { + inputList.add(name); + juce::Logger::writeToLog("AudioEngine: Input device: " + name); + } + + for (auto& name : type->getDeviceNames(false)) // Outputs + { + outputList.add(name); + juce::Logger::writeToLog("AudioEngine: Output device: " + name); + } + } + data->setProperty("inputs", inputList); + data->setProperty("outputs", outputList); + + + // 4. Sample Rates & 5. Buffer Sizes + juce::Array rates; + juce::Array buffers; + + if (device) + { + // Device is open - get its actual capabilities + for (auto sr : device->getAvailableSampleRates()) + rates.add(sr); + for (auto bs : device->getAvailableBufferSizes()) + buffers.add(bs); + juce::Logger::writeToLog("AudioEngine: Got " + juce::String(rates.size()) + " sample rates from open device"); + } + else + { + // No device open - provide common professional audio defaults + // These work with most ASIO/WASAPI devices + juce::Logger::writeToLog("AudioEngine: No device open, using default capabilities"); + + rates.add(44100.0); + rates.add(48000.0); + rates.add(88200.0); + rates.add(96000.0); + rates.add(176400.0); + rates.add(192000.0); + + buffers.add(32); + buffers.add(64); + buffers.add(128); + buffers.add(256); + buffers.add(512); + buffers.add(1024); + buffers.add(2048); + } + + data->setProperty("sampleRates", rates); + data->setProperty("bufferSizes", buffers); + + juce::Logger::writeToLog("AudioEngine: getAudioDeviceSetup() returning data"); + return data; +} + +void AudioEngine::setAudioDeviceSetup(const juce::String& type, const juce::String& input, const juce::String& output, double sampleRate, int bufferSize) +{ + juce::Logger::writeToLog("AudioEngine: Setting Audio Device..."); + + // 1. Change Type if needed + if (deviceManager.getCurrentAudioDeviceType() != type) + { + deviceManager.setCurrentAudioDeviceType(type, true); + } + + // 2. Setup Config + juce::AudioDeviceManager::AudioDeviceSetup setup; + deviceManager.getAudioDeviceSetup(setup); + + setup.inputDeviceName = input; + setup.outputDeviceName = output; + setup.sampleRate = sampleRate; + setup.bufferSize = bufferSize; + // Activate all channels the device supports (not just the default 2). + // JUCE caps this bitmask at the device's actual channel count. + setup.useDefaultInputChannels = false; + setup.inputChannels.setRange (0, 32, true); + setup.useDefaultOutputChannels = false; + setup.outputChannels.setRange (0, 32, true); + + // Apply (treat errors softly by logging) + auto error = deviceManager.setAudioDeviceSetup(setup, true); + if (error.isNotEmpty()) + juce::Logger::writeToLog("AudioEngine: Error setting device: " + error); + else + saveDeviceSettings(); +} + +//============================================================================== +// Track Control (Phase 1) + +void AudioEngine::setTrackRecordArm(const juce::String& trackId, bool armed) +{ + if (trackMap.find(trackId) != trackMap.end()) + { + auto* track = trackMap[trackId]; + if (track) + { + track->setRecordArmed(armed); + juce::Logger::writeToLog("AudioEngine: Track " + trackId + + " record arm: " + (armed ? "ON" : "OFF")); + } + } +} + +void AudioEngine::setTrackInputMonitoring(const juce::String& trackId, bool enabled) +{ + if (trackMap.find(trackId) != trackMap.end()) + { + auto* track = trackMap[trackId]; + if (track) + { + if (!enabled) + { + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + queueAllNotesOffForTrack(*track); + } + track->setInputMonitoring(enabled); + juce::Logger::writeToLog("AudioEngine: Track " + trackId + + " monitoring: " + (enabled ? "ON" : "OFF")); + } + } +} + +void AudioEngine::setTrackInputChannels(const juce::String& trackId, int startChannel, int numChannels) +{ + if (trackMap.find(trackId) != trackMap.end()) + { + auto* track = trackMap[trackId]; + if (track) + { + track->setInputChannels(startChannel, numChannels); + } + } +} + +//============================================================================== +// Volume/Pan/Mute/Solo (Phase 1) + +void AudioEngine::setTrackVolume(const juce::String& trackId, float volumeDB) +{ + if (trackMap.find(trackId) != trackMap.end()) + { + auto* track = trackMap[trackId]; + if (track) + { + track->setVolume(volumeDB); + juce::Logger::writeToLog("AudioEngine: Track " + trackId + + " volume: " + juce::String(volumeDB) + " dB"); + } + } +} + +void AudioEngine::setTrackPan(const juce::String& trackId, float pan) +{ + if (trackMap.find(trackId) != trackMap.end()) + { + auto* track = trackMap[trackId]; + if (track) + { + track->setPan(pan); + juce::Logger::writeToLog("AudioEngine: Track " + trackId + + " pan: " + juce::String(pan)); + } + } +} + +void AudioEngine::setTrackMute(const juce::String& trackId, bool muted) +{ + if (trackMap.find(trackId) != trackMap.end()) + { + auto* track = trackMap[trackId]; + if (track) + { + if (muted) + { + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + queueAllNotesOffForTrack(*track); + } + track->setMute(muted); + juce::Logger::writeToLog("AudioEngine: Track " + trackId + + " mute: " + (muted ? "ON" : "OFF")); + } + } +} + +void AudioEngine::setTrackSolo(const juce::String& trackId, bool soloed) +{ + if (trackMap.find(trackId) != trackMap.end()) + { + auto* track = trackMap[trackId]; + if (track) + { + track->setSolo(soloed); + juce::Logger::writeToLog("AudioEngine: Track " + trackId + + " solo: " + (soloed ? "ON" : "OFF")); + + // Update cached solo state so audio thread doesn't need to scan + bool anyNowSoloed = false; + for (const auto& pair : trackMap) + if (pair.second && pair.second->getSolo()) { anyNowSoloed = true; break; } + cachedAnySoloed.store(anyNowSoloed); + + if (anyNowSoloed) + { + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + for (const auto& pair : trackMap) + { + if (pair.second && !pair.second->getSolo()) + queueAllNotesOffForTrack(*pair.second); + } + } + } + } +} + +//============================================================================== +// Punch In/Out (Phase 3.1) + +void AudioEngine::setPunchRange(double startTime, double endTime, bool enabled) +{ + punchStartTime.store(startTime, std::memory_order_relaxed); + punchEndTime.store(endTime, std::memory_order_relaxed); + punchEnabled.store(enabled, std::memory_order_release); + juce::Logger::writeToLog("AudioEngine: Punch range " + juce::String(enabled ? "enabled" : "disabled") + + " [" + juce::String(startTime, 3) + "s - " + juce::String(endTime, 3) + "s]"); +} + +//============================================================================== +// Record-Safe (Phase 3.3) + +void AudioEngine::setTrackRecordSafe(const juce::String& trackId, bool safe) +{ + auto it = trackMap.find(trackId); + if (it != trackMap.end() && it->second) + { + it->second->setRecordSafe(safe); + juce::Logger::writeToLog("AudioEngine: Track " + trackId + " record-safe: " + (safe ? "ON" : "OFF")); + } +} + +bool AudioEngine::getTrackRecordSafe(const juce::String& trackId) const +{ + auto it = trackMap.find(trackId); + if (it != trackMap.end() && it->second) + return it->second->getRecordSafe(); + return false; +} + +//============================================================================== +// Transport Control (Phase 2) + +void AudioEngine::setTransportPlaying(bool playing) +{ + logToDisk("setTransportPlaying(" + juce::String(playing ? "true" : "false") + ") called. Current: " + juce::String(isPlaying ? "true" : "false")); + logAudioTransport("setTransportPlaying playing=" + juce::String(playing ? "true" : "false") + + " current=" + juce::String(isPlaying.load() ? "true" : "false") + + " clipCount=" + juce::String(playbackEngine.getNumClips()) + + " activeOutputs=" + juce::String(lastActiveOutputChannels.load(std::memory_order_relaxed))); + + if (isPlaying == playing) + return; // No change + + isPlaying = playing; + + if (playing) + { + playbackEngine.commitAllDeferredClipAudioFiles(); + // Update sample rate from device + auto* device = deviceManager.getCurrentAudioDevice(); + if (device) + currentSampleRate = device->getCurrentSampleRate(); + + firstCallbackAfterTransportStartPending.store(true, std::memory_order_release); + + logToDisk("Transport PLAY. SampleRate: " + juce::String(currentSampleRate) + + " Position: " + juce::String(currentSamplePosition / currentSampleRate) + "s"); + logAudioTransport("playback snapshot totalClips=" + juce::String(playbackEngine.getNumClips()) + + " sampleRate=" + juce::String(currentSampleRate, 2) + + " blockSize=" + juce::String(currentBlockSize) + + " activeOutputs=" + juce::String(lastActiveOutputChannels.load(std::memory_order_relaxed))); + } + else + { + logToDisk("Transport STOP at position: " + juce::String(currentSamplePosition / currentSampleRate) + "s"); + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + queueAllNotesOffForAllTracks(); + playbackEngine.commitAllDeferredClipAudioFiles(); + // Don't reset position here - let the stop() action control that + } + + const double currentPositionSeconds = currentSampleRate > 0.0 + ? (currentSamplePosition / currentSampleRate) + : 0.0; + const auto focusedEditorTarget = pluginWindowManager.getFocusedEditorTarget(); + for (auto const& [trackId, track] : trackMap) + { + if (track != nullptr) + { + bool editorFocusedAtPlayStart = false; + if (playing && focusedEditorTarget.has_value()) + { + editorFocusedAtPlayStart = + focusedEditorTarget->scope == PluginWindowManager::PluginEditorTarget::Scope::TrackFX + && focusedEditorTarget->trackId == trackId + && focusedEditorTarget->fxIndex == track->getARAFXIndex(); + } + + track->noteARATransportPlaybackStateChanged(trackId, playing, currentPositionSeconds, + editorFocusedAtPlayStart); + } + } +} + +void AudioEngine::setTransportPosition(double seconds) +{ + logToDisk("setTransportPosition(" + juce::String(seconds) + ") called. Current=" + + juce::String(currentSampleRate > 0.0 ? currentSamplePosition / currentSampleRate : 0.0) + + "s isPlaying=" + juce::String(isPlaying.load() ? "true" : "false")); + logAudioTransport("setTransportPosition seconds=" + juce::String(seconds, 3) + + " isPlaying=" + juce::String(isPlaying.load() ? "true" : "false")); + playbackEngine.commitAllDeferredClipAudioFiles(); + currentSamplePosition = seconds * currentSampleRate; + + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + queueAllNotesOffForAllTracks(); +} + +bool AudioEngine::hasAnyActiveARA() const +{ + for (auto const& [id, track] : trackMap) + if (track != nullptr && track->hasActiveARA()) + return true; + return false; +} + +void AudioEngine::setTransportRecording(bool recording) +{ + logToDisk("setTransportRecording(" + juce::String(recording ? "true" : "false") + ") called. Current: " + juce::String(isRecordMode ? "true" : "false")); + logAudioRecord("setTransportRecording recording=" + juce::String(recording ? "true" : "false") + + " current=" + juce::String(isRecordMode.load() ? "true" : "false")); + + if (isRecordMode == recording) + return; // No change + + isRecordMode = recording; + + if (recording) + { + loopTakeCounter = 0; // Reset loop take counter for new recording session + prevSamplePosition = currentSamplePosition; // Initialize prev position tracking + + projectAudioFolder = getPreferredAppDataDirectory().getChildFile("Audio"); + + logToDisk("Target Audio Folder: " + projectAudioFolder.getFullPathName()); + + if (!projectAudioFolder.exists()) + { + bool created = projectAudioFolder.createDirectory(); + logToDisk("Folder created status: " + juce::String(created ? "SUCCESS" : "FAIL")); + } + + // Start recording for each armed track + logToDisk("Checking " + juce::String((int)trackMap.size()) + " tracks for arming..."); + juce::StringArray armedTrackIds; + + bool anyAudioStarted = false; + for (auto const& [trackId, track] : trackMap) + { + if (!track) continue; + + bool isArmed = track->getRecordArmed(); + + if (isArmed) + { + armedTrackIds.add(trackId); + auto trackType = track->getTrackType(); + + if (trackType == TrackType::MIDI || trackType == TrackType::Instrument) + { + // MIDI recording — in-memory accumulation + logToDisk("Track " + trackId + " IS ARMED (MIDI). Starting MIDI record..."); + midiRecorder.startRecording(trackId, currentSampleRate); + anyAudioStarted = true; // Reuse flag for pending start capture + } + else + { + // Audio recording — disk I/O via ThreadedWriter + logToDisk("Track " + trackId + " IS ARMED. Starting record..."); + + auto timestamp = juce::Time::getCurrentTime().toMilliseconds(); + auto filename = "Track_" + trackId + "_Take_" + juce::String(timestamp) + ".wav"; + auto outputFile = projectAudioFolder.getChildFile(filename); + + logToDisk("Recording at sample rate: " + juce::String(currentSampleRate) + " Hz"); + + int numChannels = track->getInputChannelCount(); + bool started = audioRecorder.startRecording(trackId, outputFile, currentSampleRate, numChannels); + + if (started) + anyAudioStarted = true; + + logToDisk("Start Recording Track " + trackId + " -> " + (started ? "SUCCESS" : "FAIL to " + outputFile.getFullPathName())); + } + } + } + logAudioRecord("record start armedTracks=" + armedTrackIds.joinIntoString(",") + + " anyStarted=" + juce::String(anyAudioStarted ? "true" : "false")); + + if (anyAudioStarted) + { + // Defer start-time capture to the audio thread to avoid race + // conditions — the message thread's view of currentSamplePosition + // may be stale by the time the audio callback processes the first + // buffer. The audio thread sets the correct start time (with + // input latency compensation) on the first write. + pendingRecordStartCapture.store(true, std::memory_order_release); + } + } + else + { + // Stop all recordings and collect clip info + logToDisk("Record mode OFF - stopping recordings"); + lastCompletedClips = audioRecorder.stopAllRecordings(currentSampleRate); + logToDisk("Recordings stopped. Completed " + juce::String(lastCompletedClips.size()) + " audio clips."); + + // Stop MIDI recordings + lastCompletedMIDIClips = midiRecorder.stopAllRecordings(projectAudioFolder, tempo); + logToDisk("MIDI recordings stopped. Completed " + juce::String(lastCompletedMIDIClips.size()) + " MIDI clips."); + + // Generate peak caches for recorded audio files in background (REAPER-style). + // The onComplete callback fires on the message thread; MainComponent uses + // it to emit a "peaksReady" JS event so the Timeline refreshes waveforms. + for (const auto& clip : lastCompletedClips) + { + juce::String filePath = clip.file.getFullPathName(); + peakCache.generateAsync(clip.file, [this, filePath]() + { + if (onPeaksReady) + onPeaksReady(filePath); + }); + } + logAudioRecord("record stop completedAudio=" + juce::String(static_cast(lastCompletedClips.size())) + + " completedMidi=" + juce::String(static_cast(lastCompletedMIDIClips.size()))); + } +} + + +//============================================================================== +// FX Management (Phase 3) + +void AudioEngine::scanForPlugins() +{ + juce::Logger::writeToLog("AudioEngine: Scanning for plugins..."); + pluginManager.scanForPlugins(); +} + +juce::var AudioEngine::getAvailablePlugins() +{ + auto plugins = pluginManager.getAvailablePlugins(); + + juce::Array pluginList; + for (const auto& plugin : plugins) + { + auto* pluginObj = new juce::DynamicObject(); + pluginObj->setProperty("name", plugin.name); + pluginObj->setProperty("manufacturer", plugin.manufacturerName); + pluginObj->setProperty("category", plugin.category); + pluginObj->setProperty("fileOrIdentifier", plugin.fileOrIdentifier); + pluginObj->setProperty("isInstrument", plugin.isInstrument); + pluginObj->setProperty("hasARA", plugin.hasARAExtension); + pluginObj->setProperty("pluginFormatName", plugin.pluginFormatName); + pluginObj->setProperty("pluginFormat", plugin.pluginFormatName.toLowerCase()); + pluginObj->setProperty("producesMidi", false); + pluginObj->setProperty("supportsDoublePrecision", false); + + // VST3 Snapshot lookup: check bundle for Contents/Resources/Snapshots/*.png + juce::File pluginPath(plugin.fileOrIdentifier); + if (pluginPath.isDirectory() && pluginPath.getFileExtension() == ".vst3") + { + juce::File snapshotsDir = pluginPath.getChildFile("Contents") + .getChildFile("Resources") + .getChildFile("Snapshots"); + if (snapshotsDir.isDirectory()) + { + juce::Array pngFiles; + snapshotsDir.findChildFiles(pngFiles, juce::File::findFiles, false, "*.png"); + if (pngFiles.size() > 0) + { + juce::MemoryBlock imageData; + if (pngFiles[0].loadFileAsData(imageData)) + { + juce::String base64 = juce::Base64::toBase64(imageData.getData(), + imageData.getSize()); + pluginObj->setProperty("snapshot", + juce::String("data:image/png;base64,") + base64); + } + } + } + } + + pluginList.add(juce::var(pluginObj)); + } + + juce::Logger::writeToLog("AudioEngine: Returning " + juce::String(pluginList.size()) + " plugins"); + return pluginList; +} + +juce::var AudioEngine::getPluginCapabilities(const juce::String& pluginPath) +{ + auto* obj = new juce::DynamicObject(); + obj->setProperty("fileOrIdentifier", pluginPath); + + double sr = currentSampleRate > 0 ? currentSampleRate : 44100.0; + int bs = currentBlockSize > 0 ? currentBlockSize : 512; + auto plugin = pluginManager.loadPluginFromFile(pluginPath, sr, bs); + if (!plugin) + { + obj->setProperty("success", false); + return juce::var(obj); + } + + obj->setProperty("success", true); + obj->setProperty("name", plugin->getName()); + obj->setProperty("acceptsMidi", plugin->acceptsMidi()); + obj->setProperty("producesMidi", plugin->producesMidi()); + obj->setProperty("isMidiEffect", plugin->isMidiEffect()); + obj->setProperty("supportsDoublePrecision", plugin->supportsDoublePrecisionProcessing()); + obj->setProperty("inputChannels", plugin->getTotalNumInputChannels()); + obj->setProperty("outputChannels", plugin->getTotalNumOutputChannels()); + obj->setProperty("inputBuses", plugin->getBusCount(true)); + obj->setProperty("outputBuses", plugin->getBusCount(false)); + + if (auto* instance = dynamic_cast(plugin.get())) + { + juce::PluginDescription desc; + instance->fillInPluginDescription(desc); + obj->setProperty("pluginFormat", desc.pluginFormatName.toLowerCase()); + obj->setProperty("isInstrument", desc.isInstrument); + } + else + { + obj->setProperty("pluginFormat", juce::String()); + obj->setProperty("isInstrument", false); + } + + return juce::var(obj); +} + +juce::var AudioEngine::getPluginCompatibilityMatrix() +{ + juce::Array rows; + auto plugins = pluginManager.getAvailablePlugins(); + + for (const auto& plugin : plugins) + { + auto caps = getPluginCapabilities(plugin.fileOrIdentifier); + if (auto* obj = caps.getDynamicObject()) + { + obj->setProperty("scanName", plugin.name); + obj->setProperty("scanManufacturer", plugin.manufacturerName); + obj->setProperty("scanCategory", plugin.category); + obj->setProperty("scanHasARA", plugin.hasARAExtension); + } + rows.add(caps); + } + + auto* root = new juce::DynamicObject(); + root->setProperty("plugins", rows); + root->setProperty("processingPrecision", getProcessingPrecision()); + root->setProperty("count", rows.size()); + root->setProperty("hasMidiDiagnostics", true); + root->setProperty("hasBenchmarks", true); + root->setProperty("hybrid64Enabled", processingPrecisionMode == ProcessingPrecisionMode::Hybrid64); + return juce::var(root); +} + +juce::var AudioEngine::runEngineBenchmarks() +{ + auto* root = new juce::DynamicObject(); + + struct PluginStateBackup + { + juce::AudioProcessor* processor = nullptr; + juce::MemoryBlock state; + }; + + std::vector backups; + auto backupProcessor = [&backups](juce::AudioProcessor* proc) + { + if (proc == nullptr) + return; + + PluginStateBackup backup; + backup.processor = proc; + { + const juce::ScopedLock processorLock(proc->getCallbackLock()); + proc->getStateInformation(backup.state); + } + backups.push_back(std::move(backup)); + }; + + const juce::String originalPrecision = getProcessingPrecision(); + const double sr = currentSampleRate > 0.0 ? currentSampleRate : 44100.0; + const std::array benchmarkBlockSizes { 32, 64, 256 }; + const int iterations = 16; + DesiredFXStageSpec masterSpec; + { + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + for (const auto& [trackId, track] : trackMap) + { + juce::ignoreUnused(trackId); + if (!track) + continue; + + for (int fx = 0; fx < track->getNumInputFX(); ++fx) + backupProcessor(track->getInputFXProcessor(fx)); + for (int fx = 0; fx < track->getNumTrackFX(); ++fx) + backupProcessor(track->getTrackFXProcessor(fx)); + backupProcessor(track->getInstrument()); + } + + masterSpec = desiredMasterStageSpec; + } + syncStageSpecStateFromActive(masterSpec, std::atomic_load_explicit(&realtimeMasterFXSnapshot, std::memory_order_acquire)); + + auto runMode = [&](const juce::String& mode) -> juce::var + { + setProcessingPrecision(mode); + auto* result = new juce::DynamicObject(); + result->setProperty("mode", mode); + result->setProperty("sampleRate", sr); + result->setProperty("trackCount", static_cast(trackOrder.size())); + result->setProperty("masterFxCount", static_cast(masterSpec.slots.size())); + result->setProperty("iterationsPerRun", iterations); + + juce::Array runResults; + double totalElapsedMs = 0.0; + + for (int benchmarkBlockSize : benchmarkBlockSizes) + { + const int preparedBlockSize = juce::jmax(benchmarkBlockSize, 512); + for (const auto& [trackId, track] : trackMap) + { + juce::ignoreUnused(trackId); + if (track != nullptr) + track->prepareToPlay(sr, preparedBlockSize); + } + + juce::String stageError; + auto benchmarkMasterStage = buildActiveFXStage(masterSpec, sr, preparedBlockSize, processingPrecisionMode, false, stageError); + if (!benchmarkMasterStage) + { + result->setProperty("stageBuildError", stageError); + break; + } + + juce::AudioBuffer trackBuffer(2, benchmarkBlockSize); + juce::AudioBuffer masterBuffer(2, benchmarkBlockSize); + juce::AudioBuffer masterBufferDouble(2, benchmarkBlockSize); + juce::MidiBuffer midi; + const bool benchmarkHybrid64 = processingPrecisionMode == ProcessingPrecisionMode::Hybrid64; + + auto start = juce::Time::getMillisecondCounterHiRes(); + for (int iteration = 0; iteration < iterations; ++iteration) + { + masterBuffer.clear(); + masterBufferDouble.clear(); + + for (const auto& trackId : trackOrder) + { + auto it = trackMap.find(trackId); + if (it == trackMap.end() || !it->second) + continue; + + auto* track = it->second; + trackBuffer.clear(); + midi.clear(); + track->setCurrentBlockPosition(static_cast(iteration * benchmarkBlockSize), sr); + track->buildMidiBuffer(midi, 0.0, benchmarkBlockSize, sr, false); + track->processBlock(trackBuffer, midi); + if (benchmarkHybrid64) + { + for (int ch = 0; ch < 2; ++ch) + { + auto* dest = masterBufferDouble.getWritePointer(ch); + auto* src = trackBuffer.getReadPointer(ch); + for (int sample = 0; sample < benchmarkBlockSize; ++sample) + dest[sample] += static_cast(src[sample]); + } + } + else + { + masterBuffer.addFrom(0, 0, trackBuffer, 0, 0, benchmarkBlockSize); + masterBuffer.addFrom(1, 0, trackBuffer, 1, 0, benchmarkBlockSize); + } + } + + juce::MidiBuffer dummyMidi; + for (const auto& slot : benchmarkMasterStage->slots) + { + if (!slot.processor || slot.bypassed) + continue; + + auto* proc = slot.processor.get(); + const bool useDoublePrecision = benchmarkHybrid64 + && !slot.forceFloat + && slot.supportsDouble + && proc->getTotalNumInputChannels() <= masterBufferDouble.getNumChannels() + && proc->getTotalNumOutputChannels() <= masterBufferDouble.getNumChannels(); + + if (useDoublePrecision) + { + proc->processBlock(masterBufferDouble, dummyMidi); + } + else + { + if (benchmarkHybrid64) + copyDoubleBufferToFloatBuffer(masterBufferDouble, masterBuffer, 2, benchmarkBlockSize); + + proc->processBlock(masterBuffer, dummyMidi); + + if (benchmarkHybrid64) + copyFloatBufferToDoubleBuffer(masterBuffer, masterBufferDouble, 2, benchmarkBlockSize); + } + } + } + auto elapsedMs = juce::Time::getMillisecondCounterHiRes() - start; + totalElapsedMs += elapsedMs; + + auto* run = new juce::DynamicObject(); + run->setProperty("blockSize", benchmarkBlockSize); + run->setProperty("preparedBlockSize", preparedBlockSize); + run->setProperty("elapsedMs", elapsedMs); + run->setProperty("avgMsPerBlock", elapsedMs / static_cast(iterations)); + run->setProperty("peakMagnitude", benchmarkHybrid64 + ? findPeakInDoubleBuffer(masterBufferDouble, 2, benchmarkBlockSize) + : juce::jmax(masterBuffer.getMagnitude(0, 0, benchmarkBlockSize), + masterBuffer.getMagnitude(1, 0, benchmarkBlockSize))); + runResults.add(juce::var(run)); + } + + result->setProperty("runs", runResults); + result->setProperty("elapsedMs", totalElapsedMs); + result->setProperty("avgMsPerBlock", totalElapsedMs / static_cast(iterations * benchmarkBlockSizes.size())); + return juce::var(result); + }; + + juce::Array results; + results.add(runMode("float32")); + results.add(runMode("hybrid64")); + + for (auto& backup : backups) + { + if (backup.processor) + { + const juce::ScopedLock processorLock(backup.processor->getCallbackLock()); + backup.processor->setStateInformation(backup.state.getData(), + static_cast(backup.state.getSize())); + backup.processor->reset(); + } + } + + setProcessingPrecision(originalPrecision); + + root->setProperty("results", results); + root->setProperty("pluginCount", static_cast(pluginManager.getAvailablePlugins().size())); + root->setProperty("processingPrecision", getProcessingPrecision()); + root->setProperty("midiLateEventCount", midiLateEventCount.load(std::memory_order_relaxed)); + root->setProperty("midiMaxEventsPerBlock", midiMaxEventsPerBlock.load(std::memory_order_relaxed)); + return juce::var(root); +} + +void AudioEngine::setProcessingPrecision(const juce::String& precisionModeString) +{ + ProcessingPrecisionMode newMode = precisionModeString == "hybrid64" + ? ProcessingPrecisionMode::Hybrid64 + : ProcessingPrecisionMode::Float32; + + processingPrecisionMode = newMode; + + for (const auto& [trackId, track] : trackMap) + { + juce::ignoreUnused(trackId); + if (track != nullptr) + applyProcessingPrecisionToTrack(*track); + } + + publishMasterStageSpec(desiredMasterStageSpec); + publishMonitoringStageSpec(desiredMonitoringStageSpec); +} + +juce::String AudioEngine::getProcessingPrecision() const +{ + return processingPrecisionMode == ProcessingPrecisionMode::Hybrid64 ? "hybrid64" : "float32"; +} + +bool AudioEngine::setTrackPluginPrecisionOverride(const juce::String& trackId, int fxIndex, bool isInputFX, const juce::String& mode) +{ + auto it = trackMap.find(trackId); + if (it == trackMap.end() || !it->second) + return false; + + const bool forceFloat = mode == "float32"; + if (isInputFX) + it->second->setInputFXPrecisionOverride(fxIndex, forceFloat); + else + it->second->setTrackFXPrecisionOverride(fxIndex, forceFloat); + return true; +} + +bool AudioEngine::setInstrumentPrecisionOverride(const juce::String& trackId, const juce::String& mode) +{ + auto it = trackMap.find(trackId); + if (it == trackMap.end() || !it->second) + return false; + + it->second->setInstrumentPrecisionOverride(mode == "float32"); + return true; +} + +bool AudioEngine::setMasterFXPrecisionOverride(int fxIndex, const juce::String& mode) +{ + DesiredFXStageSpec specCopy; + { + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + specCopy = desiredMasterStageSpec; + } + + syncStageSpecStateFromActive(specCopy, std::atomic_load_explicit(&realtimeMasterFXSnapshot, std::memory_order_acquire)); + auto* slot = findDesiredStageSlot(specCopy, fxIndex); + if (slot == nullptr) + return false; + + slot->forceFloat = (mode == "float32"); + return publishMasterStageSpec(specCopy); +} + +bool AudioEngine::setMonitoringFXPrecisionOverride(int fxIndex, const juce::String& mode) +{ + DesiredFXStageSpec specCopy; + { + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + specCopy = desiredMonitoringStageSpec; + } + + syncStageSpecStateFromActive(specCopy, std::atomic_load_explicit(&realtimeMonitoringFXSnapshot, std::memory_order_acquire)); + auto* slot = findDesiredStageSlot(specCopy, fxIndex); + if (slot == nullptr) + return false; + + slot->forceFloat = (mode == "float32"); + return publishMonitoringStageSpec(specCopy); +} + +juce::var AudioEngine::runReleaseGuardrails() +{ + auto midiDiagnostics = getMidiDiagnostics(); + auto compatibility = getPluginCompatibilityMatrix(); + auto benchmarks = runEngineBenchmarks(); + + auto* root = new juce::DynamicObject(); + juce::Array checks; + bool overallPass = true; + + auto addCheck = [&checks, &overallPass](const juce::String& id, bool pass, const juce::String& detail) + { + auto* obj = new juce::DynamicObject(); + obj->setProperty("id", id); + obj->setProperty("pass", pass); + obj->setProperty("detail", detail); + checks.add(juce::var(obj)); + if (!pass) + overallPass = false; + }; + + if (auto* midiObj = midiDiagnostics.getDynamicObject()) + { + const int lateEvents = static_cast(midiObj->getProperty("lateEventCount")); + const int maxEvents = static_cast(midiObj->getProperty("maxEventsPerBlock")); + const int masterFallbacks = static_cast(midiObj->getProperty("masterFXFallbackReuseCount")); + const int monitoringFallbacks = static_cast(midiObj->getProperty("monitoringFXFallbackReuseCount")); + const int masterBusySkips = static_cast(midiObj->getProperty("masterFXBusySkipCount")); + const int monitoringBusySkips = static_cast(midiObj->getProperty("monitoringFXBusySkipCount")); + int trackFallbacks = 0; + int trackCount = 0; + if (auto tracks = midiObj->getProperty("tracks"); tracks.isArray()) + { + trackCount = tracks.getArray()->size(); + for (const auto& track : *tracks.getArray()) + { + if (auto* trackObj = track.getDynamicObject()) + trackFallbacks += static_cast(trackObj->getProperty("realtimeFallbackReuseCount")); + } + } + addCheck("midi_diagnostics_available", true, "MIDI diagnostics API returned data"); + addCheck("midi_late_events_clear", lateEvents == 0, + "lateEventCount=" + juce::String(lateEvents) + ", maxEventsPerBlock=" + juce::String(maxEvents)); + addCheck("realtime_track_fallbacks_clear", trackFallbacks == 0, + "trackFallbackReuseCount=" + juce::String(trackFallbacks) + ", trackCount=" + juce::String(trackCount)); + addCheck("realtime_master_fallbacks_clear", masterFallbacks == 0, + "masterFXFallbackReuseCount=" + juce::String(masterFallbacks)); + addCheck("realtime_monitoring_fallbacks_clear", monitoringFallbacks == 0, + "monitoringFXFallbackReuseCount=" + juce::String(monitoringFallbacks)); + addCheck("realtime_master_busy_skips_clear", masterBusySkips == 0, + "masterFXBusySkipCount=" + juce::String(masterBusySkips)); + addCheck("realtime_monitoring_busy_skips_clear", monitoringBusySkips == 0, + "monitoringFXBusySkipCount=" + juce::String(monitoringBusySkips)); + } + else + { + addCheck("midi_diagnostics_available", false, "MIDI diagnostics payload missing"); + } + + if (auto* compatObj = compatibility.getDynamicObject()) + { + auto plugins = compatObj->getProperty("plugins"); + int pluginCount = plugins.isArray() ? plugins.getArray()->size() : 0; + addCheck("compatibility_matrix_available", pluginCount >= 0, + "pluginCount=" + juce::String(pluginCount)); + bool capabilityFieldsPresent = true; + if (plugins.isArray()) + { + for (const auto& plugin : *plugins.getArray()) + { + auto* pluginObj = plugin.getDynamicObject(); + if (pluginObj == nullptr + || !pluginObj->hasProperty("pluginFormat") + || !pluginObj->hasProperty("supportsDoublePrecision") + || !pluginObj->hasProperty("isInstrument") + || !pluginObj->hasProperty("producesMidi")) + { + capabilityFieldsPresent = false; + break; + } + } + } + addCheck("compatibility_capabilities_complete", capabilityFieldsPresent, + "pluginsChecked=" + juce::String(pluginCount)); + } + else + { + addCheck("compatibility_matrix_available", false, "Compatibility matrix payload missing"); + addCheck("compatibility_capabilities_complete", false, "Compatibility matrix payload missing"); + } + + if (auto* benchObj = benchmarks.getDynamicObject()) + { + auto results = benchObj->getProperty("results"); + int resultCount = results.isArray() ? results.getArray()->size() : 0; + addCheck("benchmark_matrix_available", resultCount >= 2, + "modeCount=" + juce::String(resultCount)); + bool benchmarkBlockCoverage = true; + bool benchmarkValuesFinite = true; + if (results.isArray()) + { + for (const auto& result : *results.getArray()) + { + auto* resultObj = result.getDynamicObject(); + if (resultObj == nullptr) + { + benchmarkBlockCoverage = false; + benchmarkValuesFinite = false; + continue; + } + + bool has32 = false; + bool has64 = false; + bool has256 = false; + auto runs = resultObj->getProperty("runs"); + if (!runs.isArray()) + { + benchmarkBlockCoverage = false; + benchmarkValuesFinite = false; + continue; + } + + for (const auto& run : *runs.getArray()) + { + auto* runObj = run.getDynamicObject(); + if (runObj == nullptr) + { + benchmarkValuesFinite = false; + continue; + } + + const int blockSize = static_cast(runObj->getProperty("blockSize")); + const double avgMs = static_cast(runObj->getProperty("avgMsPerBlock")); + const double peakMagnitude = static_cast(runObj->getProperty("peakMagnitude")); + has32 = has32 || blockSize == 32; + has64 = has64 || blockSize == 64; + has256 = has256 || blockSize == 256; + if (!std::isfinite(avgMs) || avgMs < 0.0 || !std::isfinite(peakMagnitude)) + benchmarkValuesFinite = false; + } + + benchmarkBlockCoverage = benchmarkBlockCoverage && has32 && has64 && has256; + } + } + addCheck("benchmark_block_coverage_complete", benchmarkBlockCoverage, + "expectedBlockSizes=32,64,256"); + addCheck("benchmark_values_finite", benchmarkValuesFinite, + "checkedModeCount=" + juce::String(resultCount)); + } + else + { + addCheck("benchmark_matrix_available", false, "Benchmark payload missing"); + addCheck("benchmark_block_coverage_complete", false, "Benchmark payload missing"); + addCheck("benchmark_values_finite", false, "Benchmark payload missing"); + } + + root->setProperty("overallPass", overallPass); + root->setProperty("processingPrecision", getProcessingPrecision()); + root->setProperty("checks", checks); + root->setProperty("midiDiagnostics", midiDiagnostics); + root->setProperty("compatibility", compatibility); + root->setProperty("benchmarks", benchmarks); + return juce::var(root); +} + +juce::var AudioEngine::runAutomatedRegressionSuite() +{ + auto releaseGuardrails = runReleaseGuardrails(); + auto* root = new juce::DynamicObject(); + juce::Array suites; + bool overallPass = true; + + auto addSuite = [&suites, &overallPass](const juce::String& id, bool pass, const juce::String& detail) + { + auto* obj = new juce::DynamicObject(); + obj->setProperty("id", id); + obj->setProperty("pass", pass); + obj->setProperty("detail", detail); + suites.add(juce::var(obj)); + if (!pass) + overallPass = false; + }; + + if (auto* guardrailObj = releaseGuardrails.getDynamicObject()) + { + const bool guardrailsPass = static_cast(guardrailObj->getProperty("overallPass")); + addSuite("release_guardrails", guardrailsPass, + "overallPass=" + juce::String(guardrailsPass ? "true" : "false")); + + auto benchmarks = guardrailObj->getProperty("benchmarks"); + if (auto* benchObj = benchmarks.getDynamicObject()) + { + auto results = benchObj->getProperty("results"); + bool modesComparable = true; + double floatAvg = -1.0; + double hybridAvg = -1.0; + if (results.isArray()) + { + for (const auto& result : *results.getArray()) + { + auto* resultObj = result.getDynamicObject(); + if (resultObj == nullptr) + { + modesComparable = false; + continue; + } + + const juce::String mode = resultObj->getProperty("mode").toString(); + const double avgMs = static_cast(resultObj->getProperty("avgMsPerBlock")); + if (!std::isfinite(avgMs) || avgMs < 0.0) + { + modesComparable = false; + continue; + } + + if (mode == "float32") + floatAvg = avgMs; + else if (mode == "hybrid64") + hybridAvg = avgMs; + } + } + else + { + modesComparable = false; + } + + modesComparable = modesComparable && floatAvg >= 0.0 && hybridAvg >= 0.0; + addSuite("precision_benchmark_matrix", modesComparable, + "float32AvgMs=" + juce::String(floatAvg) + ", hybrid64AvgMs=" + juce::String(hybridAvg)); + } + else + { + addSuite("precision_benchmark_matrix", false, "Benchmark payload missing"); + } + + auto compatibility = guardrailObj->getProperty("compatibility"); + if (auto* compatObj = compatibility.getDynamicObject()) + { + auto plugins = compatObj->getProperty("plugins"); + bool metadataConsistent = true; + int pluginCount = 0; + int doubleCapableCount = 0; + if (plugins.isArray()) + { + pluginCount = plugins.getArray()->size(); + for (const auto& plugin : *plugins.getArray()) + { + auto* pluginObj = plugin.getDynamicObject(); + if (pluginObj == nullptr) + { + metadataConsistent = false; + continue; + } + + const bool supportsDouble = static_cast(pluginObj->getProperty("supportsDoublePrecision")); + const juce::String pluginFormat = pluginObj->getProperty("pluginFormat").toString(); + if (pluginFormat.isEmpty()) + metadataConsistent = false; + if (supportsDouble) + ++doubleCapableCount; + } + } + else + { + metadataConsistent = false; + } + + addSuite("plugin_capability_matrix", metadataConsistent, + "pluginCount=" + juce::String(pluginCount) + + ", doubleCapable=" + juce::String(doubleCapableCount)); + } + else + { + addSuite("plugin_capability_matrix", false, "Compatibility payload missing"); + } + } + else + { + addSuite("release_guardrails", false, "Guardrail payload missing"); + addSuite("precision_benchmark_matrix", false, "Guardrail payload missing"); + addSuite("plugin_capability_matrix", false, "Guardrail payload missing"); + } + + root->setProperty("overallPass", overallPass); + root->setProperty("processingPrecision", getProcessingPrecision()); + root->setProperty("suites", suites); + root->setProperty("releaseGuardrails", releaseGuardrails); + return juce::var(root); +} + +bool AudioEngine::addTrackInputFX(const juce::String& trackId, const juce::String& pluginPath, bool openEditor) +{ + // Load plugin BEFORE acquiring the lock (can take hundreds of ms). + // Use actual device sample rate so the plugin initialises at the correct rate. + // IMPORTANT: Use at least 512 for the max-block-size hint — ASIO buffers can be + // as small as 32 samples, but prepareToPlay(sr, 32) forces plugins like Amplitube + // to resize their internal DSP (FFT/convolution/cab sim) for tiny blocks, producing + // crackling and distortion. The plugin can still process 32-sample blocks fine when + // prepared with a larger maximumExpectedSamplesPerBlock. + double sr = currentSampleRate > 0 ? currentSampleRate : 44100.0; + int bs = currentBlockSize > 0 ? currentBlockSize : 512; + auto plugin = pluginManager.loadPluginFromFile(pluginPath, sr, bs); + if (!plugin) + return false; + + // Provide tempo/position info to the plugin + plugin->setPlayHead (this); + + bool success = false; + int fxIndex = -1; + { + // Hold the callback lock while modifying the FX node vectors + // (audio thread iterates these in processBlock) + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + auto it = trackMap.find(trackId); + if (it != trackMap.end() && it->second) + { + success = it->second->addInputFX(std::move(plugin), sr, bs); + if (success) + fxIndex = it->second->getNumInputFX() - 1; + } + } + + if (success && fxIndex >= 0) + { + if (openEditor) + openPluginEditor(trackId, fxIndex, true); + juce::Logger::writeToLog("AudioEngine: Added input FX" + juce::String(openEditor ? " and opened editor" : "")); + recalculatePDC(); + } + return success; +} + +bool AudioEngine::addTrackFX(const juce::String& trackId, const juce::String& pluginPath, bool openEditor) +{ + // Load plugin BEFORE acquiring the lock (can take hundreds of ms). + // Use actual device sample rate so the plugin initialises at the correct rate. + // IMPORTANT: Clamp max-block-size to at least 512 — same rationale as addTrackInputFX. + double sr = currentSampleRate > 0 ? currentSampleRate : 44100.0; + int bs = currentBlockSize > 0 ? currentBlockSize : 512; + + logToDisk("AudioEngine::addTrackFX DIAGNOSTIC"); + logToDisk(" currentSampleRate=" + juce::String(currentSampleRate) + + " currentBlockSize=" + juce::String(currentBlockSize)); + logToDisk(" creating plugin at sr=" + juce::String(sr) + " bs=" + juce::String(bs)); + + auto plugin = pluginManager.loadPluginFromFile(pluginPath, sr, bs); + if (!plugin) + return false; + + logToDisk(" plugin created: " + plugin->getName() + + " inCh=" + juce::String(plugin->getTotalNumInputChannels()) + + " outCh=" + juce::String(plugin->getTotalNumOutputChannels()) + + " pluginSr=" + juce::String(plugin->getSampleRate()) + + " pluginBs=" + juce::String(plugin->getBlockSize())); + + // Provide tempo/position info to the plugin + plugin->setPlayHead (this); + + bool success = false; + int fxIndex = -1; + { + // Hold the callback lock while modifying the FX node vectors + // (audio thread iterates these in processBlock) + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + auto it = trackMap.find(trackId); + if (it != trackMap.end() && it->second) + { + success = it->second->addTrackFX(std::move(plugin), sr, bs); + if (success) + fxIndex = it->second->getNumTrackFX() - 1; + } + } + + if (success && fxIndex >= 0) + { + recalculatePDC(); + + // Try ARA initialization for any VST3 plugin. ARA factory creation + // succeeds for ARA plugins and fails gracefully for non-ARA ones. + // Don't rely on hasARAExtension — cached scan data may lack the flag. + // + // IMPORTANT: For ARA plugins, do NOT open the editor here. + // The frontend must add clips to the ARA document BEFORE the editor opens, + // otherwise the editor launches with an empty document and won't show notes. + // Non-ARA plugins open the editor immediately via the callback. + auto trackIdCopy = trackId; + int fxIndexCopy = fxIndex; + bool openEditorCopy = openEditor; + + auto it2 = trackMap.find(trackId); + if (it2 != trackMap.end() && it2->second) + { + logToDisk(" Trying ARA init for plugin at index " + juce::String(fxIndex)); + it2->second->initializeARA(fxIndex, currentSampleRate > 0 ? currentSampleRate : 44100.0, + currentBlockSize > 0 ? currentBlockSize : 512, + [this, trackIdCopy, fxIndexCopy, openEditorCopy] (bool araSuccess, bool pluginSupportsARA, const juce::String& errorMessage) { + if (araSuccess) + { + logToDisk(" ARA initialized OK for FX " + juce::String(fxIndexCopy) + + " — editor will be opened by frontend"); + // Re-propagate AudioEngine playhead to all plugins on this track. + // ARA init may have changed the plugin's playhead reference; + // ensure all plugins use AudioEngine's full-featured playhead. + auto araIt = trackMap.find(trackIdCopy); + if (araIt != trackMap.end()) + propagatePlayHead(araIt->second); + } + else + { + logToDisk(" ARA init result for FX " + juce::String(fxIndexCopy) + + " supportsARA=" + juce::String(pluginSupportsARA ? 1 : 0) + + " error=" + errorMessage); + if (openEditorCopy && !pluginSupportsARA) + { + juce::MessageManager::callAsync([this, trackIdCopy, fxIndexCopy] { + openPluginEditor(trackIdCopy, fxIndexCopy, false); + }); + } + } + }); + } + else + { + logToDisk(" Track not found for ARA check, opening editor directly"); + if (openEditor) + openPluginEditor(trackId, fxIndex, false); + } + + logToDisk(" addTrackFX complete (ARA check pending)"); + } + return success; +} + +//============================================================================== +// Built-in Effects (Phase 4.3) + +static std::unique_ptr createBuiltInEffect(const juce::String& name) +{ + if (name == "S13 EQ" || name == "OpenStudio EQ") return std::make_unique(); + if (name == "S13 Compressor" || name == "OpenStudio Compressor") return std::make_unique(); + if (name == "S13 Gate" || name == "OpenStudio Gate") return std::make_unique(); + if (name == "S13 Limiter" || name == "OpenStudio Limiter") return std::make_unique(); + if (name == "S13 Delay" || name == "OpenStudio Delay") return std::make_unique(); + if (name == "S13 Reverb" || name == "OpenStudio Reverb") return std::make_unique(); + if (name == "S13 Chorus" || name == "OpenStudio Chorus") return std::make_unique(); + if (name == "S13 Saturator" || name == "OpenStudio Saturator") return std::make_unique(); + if (name == "S13 Pitch Correct" || name == "OpenStudio Pitch Correct") return std::make_unique(); + return nullptr; +} + +bool AudioEngine::addTrackBuiltInFX(const juce::String& trackId, const juce::String& effectName, bool isInputFX) +{ + auto plugin = createBuiltInEffect(effectName); + if (!plugin) + { + juce::Logger::writeToLog("AudioEngine: Unknown built-in effect: " + effectName); + return false; + } + + double sr = currentSampleRate > 0 ? currentSampleRate : 44100.0; + int bs = currentBlockSize > 0 ? currentBlockSize : 512; + plugin->setPlayHead(this); + prepareHostedProcessorForPrecision(plugin.get(), sr, bs, processingPrecisionMode); + + bool success = false; + int fxIndex = -1; + { + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + auto it = trackMap.find(trackId); + if (it != trackMap.end() && it->second) + { + if (isInputFX) + { + success = it->second->addInputFX(std::move(plugin), sr, bs); + if (success) + fxIndex = it->second->getNumInputFX() - 1; + } + else + { + success = it->second->addTrackFX(std::move(plugin), sr, bs); + if (success) + fxIndex = it->second->getNumTrackFX() - 1; + } + } + } + + if (success) + { + juce::Logger::writeToLog("AudioEngine: Added built-in FX '" + effectName + "' to track " + trackId); + recalculatePDC(); + } + return success; +} + +bool AudioEngine::addMasterBuiltInFX(const juce::String& effectName) +{ + auto plugin = createBuiltInEffect(effectName); + if (!plugin) + return false; + + double sr = currentSampleRate > 0 ? currentSampleRate : 44100.0; + int bs = currentBlockSize > 0 ? currentBlockSize : 512; + plugin->setPlayHead(this); + prepareHostedProcessorForPrecision(plugin.get(), sr, bs, processingPrecisionMode); + + DesiredFXStageSpec specCopy; + { + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + specCopy = desiredMasterStageSpec; + } + + DesiredFXStageSlot slot; + slot.slotId = nextMasterStageSlotId++; + slot.name = plugin->getName(); + slot.type = "builtin"; + slot.pluginFormat = "Built-in"; + slot.serializedState = serialiseProcessorStateToBase64(plugin.get()); + specCopy.slots.push_back(std::move(slot)); + + if (!publishMasterStageSpec(specCopy)) + return false; + + juce::Logger::writeToLog("AudioEngine: Added built-in master FX '" + effectName + "'"); + return true; +} + +juce::var AudioEngine::getAvailableBuiltInFX() +{ + juce::Array list; + const char* names[] = { "OpenStudio EQ", "OpenStudio Compressor", "OpenStudio Gate", "OpenStudio Limiter", + "OpenStudio Delay", "OpenStudio Reverb", "OpenStudio Chorus", "OpenStudio Saturator", + "OpenStudio Pitch Correct" }; + for (auto& n : names) + { + juce::DynamicObject::Ptr obj = new juce::DynamicObject(); + obj->setProperty("name", juce::String(n)); + obj->setProperty("category", "Built-in"); + list.add(juce::var(obj.get())); + } + return juce::var(list); +} + +//============================================================================== +// S13FX (JSFX) Management + +bool AudioEngine::addTrackS13FX(const juce::String& trackId, const juce::String& scriptPath, bool isInputFX) +{ + // Create S13FXProcessor and load script BEFORE acquiring the lock + auto s13fx = std::make_unique(); + if (!s13fx->loadScript(scriptPath)) + { + juce::Logger::writeToLog("AudioEngine: Failed to load S13FX script: " + scriptPath); + return false; + } + + // Provide tempo/position info + s13fx->setPlayHead(this); + + double sr = currentSampleRate > 0 ? currentSampleRate : 44100.0; + int bs = currentBlockSize > 0 ? currentBlockSize : 512; + + bool success = false; + { + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + auto it = trackMap.find(trackId); + if (it != trackMap.end() && it->second) + { + if (isInputFX) + success = it->second->addInputFX(std::move(s13fx), sr, bs); + else + success = it->second->addTrackFX(std::move(s13fx), sr, bs); + } + } + + if (success) + { + juce::Logger::writeToLog("AudioEngine: Added S13FX to " + juce::String(isInputFX ? "input" : "track") + " chain: " + scriptPath); + recalculatePDC(); + } + + return success; +} + +bool AudioEngine::addMasterS13FX(const juce::String& scriptPath) +{ + auto s13fx = std::make_unique(); + if (!s13fx->loadScript(scriptPath)) + { + juce::Logger::writeToLog("AudioEngine: Failed to load S13FX for master: " + scriptPath); + return false; + } + + s13fx->setPlayHead(this); + + double sr = currentSampleRate > 0 ? currentSampleRate : 44100.0; + int bs = currentBlockSize > 0 ? currentBlockSize : 512; + + prepareHostedProcessorForPrecision(s13fx.get(), sr, bs, processingPrecisionMode); + + DesiredFXStageSpec specCopy; + { + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + specCopy = desiredMasterStageSpec; + } + + DesiredFXStageSlot slot; + slot.slotId = nextMasterStageSlotId++; + slot.name = s13fx->getName(); + slot.type = "s13fx"; + slot.pluginPath = scriptPath; + slot.pluginFormat = "S13FX"; + slot.serializedState = serialiseProcessorStateToBase64(s13fx.get()); + specCopy.slots.push_back(std::move(slot)); + + if (!publishMasterStageSpec(specCopy)) + return false; + + juce::Logger::writeToLog("AudioEngine: Added S13FX to master chain: " + scriptPath); + return true; +} + +juce::var AudioEngine::getS13FXSliders(const juce::String& trackId, int fxIndex, bool isInputFX) +{ + juce::Array sliderList; + + auto it = trackMap.find(trackId); + if (it == trackMap.end() || !it->second) + return sliderList; + + auto* track = it->second; + juce::AudioProcessor* proc = isInputFX + ? track->getInputFXProcessor(fxIndex) + : track->getTrackFXProcessor(fxIndex); + + if (!proc) + return sliderList; + + auto* s13fx = dynamic_cast(proc); + if (!s13fx) + return sliderList; + + auto sliders = s13fx->getSliders(); + for (const auto& s : sliders) + { + auto* obj = new juce::DynamicObject(); + obj->setProperty("index", static_cast(s.index)); + obj->setProperty("name", s.name); + obj->setProperty("min", s.min); + obj->setProperty("max", s.max); + obj->setProperty("def", s.def); + obj->setProperty("inc", s.inc); + obj->setProperty("value", s.value); + obj->setProperty("isEnum", s.isEnum); + + if (s.isEnum) + { + juce::Array names; + for (const auto& n : s.enumNames) + names.add(n); + obj->setProperty("enumNames", names); + } + + sliderList.add(juce::var(obj)); + } + + return sliderList; +} + +bool AudioEngine::setS13FXSlider(const juce::String& trackId, int fxIndex, bool isInputFX, int sliderIndex, double value) +{ + auto it = trackMap.find(trackId); + if (it == trackMap.end() || !it->second) + return false; + + auto* track = it->second; + juce::AudioProcessor* proc = isInputFX + ? track->getInputFXProcessor(fxIndex) + : track->getTrackFXProcessor(fxIndex); + + if (!proc) + return false; + + auto* s13fx = dynamic_cast(proc); + if (!s13fx) + return false; + + return s13fx->setSliderValue(static_cast(sliderIndex), value); +} + +bool AudioEngine::reloadS13FX(const juce::String& trackId, int fxIndex, bool isInputFX) +{ + auto it = trackMap.find(trackId); + if (it == trackMap.end() || !it->second) + return false; + + auto* track = it->second; + juce::AudioProcessor* proc = isInputFX + ? track->getInputFXProcessor(fxIndex) + : track->getTrackFXProcessor(fxIndex); + + if (!proc) + return false; + + auto* s13fx = dynamic_cast(proc); + if (!s13fx) + return false; + + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + return s13fx->reloadScript(); +} + +juce::var AudioEngine::getAvailableS13FX() +{ + pluginManager.scanForS13FX(); + + juce::Array list; + for (const auto& info : pluginManager.getAvailableS13FX()) + { + auto* obj = new juce::DynamicObject(); + obj->setProperty("name", info.name); + obj->setProperty("filePath", info.filePath); + obj->setProperty("author", info.author); + obj->setProperty("isStock", info.isStock); + obj->setProperty("type", "s13fx"); + + juce::Array tags; + for (const auto& tag : info.tags) + tags.add(tag); + obj->setProperty("tags", tags); + + list.add(juce::var(obj)); + } + + return list; +} + +//============================================================================== +// Lua Scripting (S13Script) + +juce::var AudioEngine::runScript(const juce::String& scriptPath) +{ + auto* result = new juce::DynamicObject(); + bool success = scriptEngine.loadAndRun(scriptPath); + result->setProperty("success", success); + result->setProperty("output", scriptEngine.getLastOutput()); + if (!success) + result->setProperty("error", scriptEngine.getLastError()); + + // Start defer timer if script registered a deferred callback + if (scriptEngine.hasDeferredCallback() && !isTimerRunning()) + startTimerHz(30); + + return juce::var(result); +} + +juce::var AudioEngine::runScriptCode(const juce::String& luaCode) +{ + auto* result = new juce::DynamicObject(); + bool success = scriptEngine.executeString(luaCode); + result->setProperty("success", success); + result->setProperty("output", scriptEngine.getLastOutput()); + if (!success) + result->setProperty("error", scriptEngine.getLastError()); + + // Start defer timer if script registered a deferred callback + if (scriptEngine.hasDeferredCallback() && !isTimerRunning()) + startTimerHz(30); + + return juce::var(result); +} + +void AudioEngine::timerCallback() +{ + if (!scriptEngine.runDeferredCallback()) + { + // No more deferred callbacks — stop the timer + stopTimer(); + } +} + +juce::String AudioEngine::getScriptDirectory() +{ + return ScriptEngine::getUserScriptsDirectory().getFullPathName(); +} + +juce::var AudioEngine::listScripts() +{ + auto scripts = scriptEngine.listAvailableScripts(); + juce::Array list; + + for (const auto& info : scripts) + { + auto* obj = new juce::DynamicObject(); + obj->setProperty("name", info.name); + obj->setProperty("filePath", info.filePath); + obj->setProperty("description", info.description); + obj->setProperty("isStock", info.isStock); + list.add(juce::var(obj)); + } + + return list; +} + +//============================================================================== +// Plugin Editor Windows (Phase 3) + +void AudioEngine::setPluginWindowOwnerComponent(juce::Component* component) +{ + pluginWindowManager.setMainWindowComponent(component); +} + +void AudioEngine::setPluginWindowShortcutForwardCallback(PluginWindowManager::ShortcutForwardCallback callback) +{ + pluginWindowManager.setShortcutForwardCallback(std::move(callback)); +} + +void AudioEngine::openPluginEditor(const juce::String& trackId, int fxIndex, bool isInputFX) +{ + if (trackMap.find(trackId) == trackMap.end()) + return; + + auto* track = trackMap[trackId]; + if (!track) + return; + + juce::AudioProcessor* processor = nullptr; + juce::String windowTitle; + + // Calculate display index (1-based) + int displayIndex = getTrackIndex(trackId) + 1; + + if (isInputFX) + { + if (fxIndex >= 0 && fxIndex < track->getNumInputFX()) + { + processor = track->getInputFXProcessor(fxIndex); + windowTitle = "Track " + juce::String(displayIndex) + " - " + processor->getName(); + } + } + else + { + if (fxIndex >= 0 && fxIndex < track->getNumTrackFX()) + { + processor = track->getTrackFXProcessor(fxIndex); + windowTitle = "Track " + juce::String(displayIndex) + " - " + processor->getName(); + } + } + + if (processor) + { + // Defer window creation to next message-loop cycle — creating a native + // DocumentWindow from inside a WebView2 NativeFunction callback can cause + // re-entrancy crashes with the Windows message pump. + PluginWindowManager::PluginEditorTarget target; + target.scope = isInputFX + ? PluginWindowManager::PluginEditorTarget::Scope::TrackInputFX + : PluginWindowManager::PluginEditorTarget::Scope::TrackFX; + target.trackId = trackId; + target.fxIndex = fxIndex; + auto* proc = processor; + auto title = windowTitle; + juce::MessageManager::callAsync([this, proc, title, target]() + { + pluginWindowManager.openEditor(proc, title, target); + }); + } + else + { + juce::Logger::writeToLog("AudioEngine::openPluginEditor - No processor found for track " + trackId + + " fxIndex=" + juce::String(fxIndex) + " isInputFX=" + juce::String(isInputFX ? "true" : "false")); + } +} + +void AudioEngine::openInstrumentEditor(const juce::String& trackId) +{ + if (trackMap.find(trackId) == trackMap.end()) + return; + + auto* track = trackMap[trackId]; + if (!track) + return; + + auto* instrument = track->getInstrument(); + if (instrument) + { + int displayIndex = getTrackIndex(trackId) + 1; + juce::String windowTitle = "Track " + juce::String(displayIndex) + " - " + instrument->getName(); + auto* proc = instrument; + auto title = windowTitle; + PluginWindowManager::PluginEditorTarget target; + target.scope = PluginWindowManager::PluginEditorTarget::Scope::Instrument; + target.trackId = trackId; + juce::MessageManager::callAsync([this, proc, title, target]() + { + pluginWindowManager.openEditor(proc, title, target); + }); + } +} + +void AudioEngine::closePluginEditor(const juce::String& trackId, int fxIndex, bool isInputFX) +{ + if (trackMap.find(trackId) == trackMap.end()) + return; + + auto* track = trackMap[trackId]; + if (!track) + return; + + juce::AudioProcessor* processor = nullptr; + + if (isInputFX) + { + if (fxIndex >= 0 && fxIndex < track->getNumInputFX()) + processor = track->getInputFXProcessor(fxIndex); + } + else + { + if (fxIndex >= 0 && fxIndex < track->getNumTrackFX()) + processor = track->getTrackFXProcessor(fxIndex); + } + + if (processor) + { + pluginWindowManager.closeEditor(processor); + juce::Logger::writeToLog("AudioEngine: Closed plugin editor"); + } +} + +void AudioEngine::closeAllPluginWindows() +{ + pluginWindowManager.closeAllEditorsSync(); +} + +//============================================================================== +// Built-in FX Preset System + +static juce::File getPresetsDir(const juce::String& pluginName) +{ + auto appDir = juce::File::getSpecialLocation(juce::File::currentApplicationFile).getParentDirectory(); + return appDir.getChildFile("presets").getChildFile(pluginName.replace(" ", "_")); +} + +juce::var AudioEngine::getBuiltInFXPresets(const juce::String& pluginName) +{ + juce::Array presetList; + + auto dir = getPresetsDir(pluginName); + if (!dir.exists()) + return presetList; + + auto files = dir.findChildFiles(juce::File::findFiles, false, "*.ospreset"); + files.addArray(dir.findChildFiles(juce::File::findFiles, false, "*.s13preset")); + files.sort(); + + for (auto& file : files) + { + juce::DynamicObject::Ptr preset = new juce::DynamicObject(); + preset->setProperty("name", file.getFileNameWithoutExtension()); + preset->setProperty("path", file.getFullPathName()); + presetList.add(juce::var(preset.get())); + } + + return presetList; +} + +bool AudioEngine::saveBuiltInFXPreset(const juce::String& trackId, int fxIndex, bool isInputFX, + const juce::String& presetName, bool isFactory) +{ + juce::ignoreUnused(isFactory); + + // Find the processor + if (trackMap.find(trackId) == trackMap.end()) + return false; + + auto* track = trackMap[trackId]; + if (!track) + return false; + + juce::AudioProcessor* processor = isInputFX + ? track->getInputFXProcessor(fxIndex) + : track->getTrackFXProcessor(fxIndex); + + if (!processor) + return false; + + // Get state information + juce::MemoryBlock stateData; + processor->getStateInformation(stateData); + + // Save to file + auto dir = getPresetsDir(processor->getName()); + dir.createDirectory(); + + auto file = dir.getChildFile(presetName + ".ospreset"); + return file.replaceWithData(stateData.getData(), stateData.getSize()); +} + +bool AudioEngine::loadBuiltInFXPreset(const juce::String& trackId, int fxIndex, bool isInputFX, + const juce::String& presetName) +{ + if (trackMap.find(trackId) == trackMap.end()) + return false; + + auto* track = trackMap[trackId]; + if (!track) + return false; + + juce::AudioProcessor* processor = isInputFX + ? track->getInputFXProcessor(fxIndex) + : track->getTrackFXProcessor(fxIndex); + + if (!processor) + return false; + + auto dir = getPresetsDir(processor->getName()); + auto file = dir.getChildFile(presetName + ".ospreset"); + if (!file.existsAsFile()) + file = dir.getChildFile(presetName + ".s13preset"); + + if (!file.existsAsFile()) + return false; + + juce::MemoryBlock stateData; + if (!file.loadFileAsData(stateData)) + return false; + + processor->setStateInformation(stateData.getData(), static_cast(stateData.getSize())); + return true; +} + +bool AudioEngine::deleteBuiltInFXPreset(const juce::String& pluginName, const juce::String& presetName) +{ + auto dir = getPresetsDir(pluginName); + auto file = dir.getChildFile(presetName + ".ospreset"); + if (!file.existsAsFile()) + file = dir.getChildFile(presetName + ".s13preset"); + + if (file.existsAsFile()) + return file.deleteFile(); + + return false; +} + +//============================================================================== +// Get loaded plugins info + +juce::var AudioEngine::getTrackInputFX(const juce::String& trackId) +{ + juce::Array fxList; + + if (trackMap.find(trackId) == trackMap.end()) + return fxList; + + auto* track = trackMap[trackId]; + if (!track) + return fxList; + + auto processors = track->getInputFXSnapshot(); + auto bypassSnapshot = track->getInputFXBypassSnapshot(); + auto precisionSnapshot = track->getInputFXPrecisionOverrideSnapshot(); + if (!processors) + return fxList; + + for (int i = 0; i < static_cast(processors->size()); ++i) + { + auto processor = processors->at(static_cast(i)); + if (processor) + { + juce::DynamicObject::Ptr fxInfo = new juce::DynamicObject(); + fxInfo->setProperty("index", i); + fxInfo->setProperty("name", processor->getName()); + const bool bypassed = bypassSnapshot != nullptr && bypassSnapshot->count(i) > 0 && bypassSnapshot->at(i); + const bool forceFloat = precisionSnapshot != nullptr && precisionSnapshot->count(i) > 0 && precisionSnapshot->at(i); + fxInfo->setProperty("bypassed", bypassed); + fxInfo->setProperty("precisionOverride", forceFloat ? "float32" : "auto"); + + // Check type: built-in > S13FX > VST3 + auto* processorPtr = processor.get(); + if (dynamic_cast(processorPtr) + || dynamic_cast(processorPtr) + || dynamic_cast(processorPtr) + || dynamic_cast(processorPtr) + || dynamic_cast(processorPtr) + || dynamic_cast(processorPtr)) + { + fxInfo->setProperty("type", "builtin"); + } + else if (auto* s13fx = dynamic_cast(processorPtr)) + { + fxInfo->setProperty("type", "s13fx"); + fxInfo->setProperty("pluginPath", s13fx->getScriptPath()); + } + else if (auto* pluginInstance = dynamic_cast(processorPtr)) + { + auto desc = pluginInstance->getPluginDescription(); + fxInfo->setProperty("type", desc.pluginFormatName == "CLAP" ? "clap" : "vst3"); + fxInfo->setProperty("pluginPath", desc.fileOrIdentifier); + } + fxList.add(juce::var(fxInfo.get())); + } + } + + return fxList; +} + +juce::var AudioEngine::getTrackFX(const juce::String& trackId) +{ + juce::Array fxList; + + if (trackMap.find(trackId) == trackMap.end()) + return fxList; + + auto* track = trackMap[trackId]; + if (!track) + return fxList; + + auto processors = track->getTrackFXSnapshot(); + auto bypassSnapshot = track->getTrackFXBypassSnapshot(); + auto precisionSnapshot = track->getTrackFXPrecisionOverrideSnapshot(); + if (!processors) + return fxList; + + for (int i = 0; i < static_cast(processors->size()); ++i) + { + auto processor = processors->at(static_cast(i)); + if (processor) + { + juce::DynamicObject::Ptr fxInfo = new juce::DynamicObject(); + fxInfo->setProperty("index", i); + fxInfo->setProperty("name", processor->getName()); + const bool bypassed = bypassSnapshot != nullptr && bypassSnapshot->count(i) > 0 && bypassSnapshot->at(i); + const bool forceFloat = precisionSnapshot != nullptr && precisionSnapshot->count(i) > 0 && precisionSnapshot->at(i); + fxInfo->setProperty("bypassed", bypassed); + fxInfo->setProperty("precisionOverride", forceFloat ? "float32" : "auto"); + + // Check type: built-in > S13FX > VST3 + auto* processorPtr = processor.get(); + if (dynamic_cast(processorPtr) + || dynamic_cast(processorPtr) + || dynamic_cast(processorPtr) + || dynamic_cast(processorPtr) + || dynamic_cast(processorPtr) + || dynamic_cast(processorPtr)) + { + fxInfo->setProperty("type", "builtin"); + } + else if (auto* s13fx = dynamic_cast(processorPtr)) + { + fxInfo->setProperty("type", "s13fx"); + fxInfo->setProperty("pluginPath", s13fx->getScriptPath()); + } + else if (auto* pluginInstance = dynamic_cast(processorPtr)) + { + auto desc = pluginInstance->getPluginDescription(); + fxInfo->setProperty("type", desc.pluginFormatName == "CLAP" ? "clap" : "vst3"); + fxInfo->setProperty("pluginPath", desc.fileOrIdentifier); + } + fxList.add(juce::var(fxInfo.get())); + } + } + + return fxList; +} + +juce::var AudioEngine::getPluginParameters(const juce::String& trackId, int fxIndex, bool isInputFX) +{ + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + juce::Array paramList; + + auto it = trackMap.find(trackId); + if (it == trackMap.end() || !it->second) + return paramList; + + auto* track = it->second; + juce::AudioProcessor* processor = nullptr; + + if (isInputFX) + { + if (fxIndex >= 0 && fxIndex < track->getNumInputFX()) + processor = track->getInputFXProcessor(fxIndex); + } + else + { + if (fxIndex >= 0 && fxIndex < track->getNumTrackFX()) + processor = track->getTrackFXProcessor(fxIndex); + } + + if (!processor) + return paramList; + + // Use the modern JUCE parameter API — skip internal MIDI CC parameters + // (plugins like Amplitube expose CC 0-127 x 16 channels = 2048 internal params) + const juce::ScopedLock processorLock(processor->getCallbackLock()); + const auto& params = processor->getParameters(); + for (int i = 0; i < params.size(); ++i) + { + auto* param = params[i]; + auto name = param->getName(128); + + // Filter out MIDI CC / internal mapping parameters (Reaper hides these too) + auto nameLower = name.toLowerCase(); + if (nameLower.startsWith("midi cc") || nameLower.startsWith("cc #") + || nameLower.contains("midi cc ") || nameLower.contains("midi ch")) + continue; + + juce::DynamicObject::Ptr paramInfo = new juce::DynamicObject(); + paramInfo->setProperty("index", i); + paramInfo->setProperty("name", name); + paramInfo->setProperty("value", param->getValue()); + paramInfo->setProperty("text", param->getCurrentValueAsText()); + paramList.add(juce::var(paramInfo.get())); + } + + return paramList; +} + +void AudioEngine::removeTrackInputFX(const juce::String& trackId, int fxIndex) +{ + // Phase 1: Close editor window and release resources BEFORE acquiring lock. + { + auto it = trackMap.find(trackId); + if (it != trackMap.end() && it->second) + { + if (auto* proc = it->second->getInputFXProcessor(fxIndex)) + { + pluginWindowManager.closeEditorSync(proc); + proc->releaseResources(); + } + } + } + + // Phase 2: Acquire lock and remove. + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + auto it = trackMap.find(trackId); + if (it != trackMap.end() && it->second) + { + it->second->removeInputFX(fxIndex); + juce::Logger::writeToLog("AudioEngine: Removed input FX " + juce::String(fxIndex) + " from track " + trackId); + } + recalculatePDC(); +} + +void AudioEngine::removeTrackFX(const juce::String& trackId, int fxIndex) +{ + // Phase 1: Close editor window and release resources BEFORE acquiring lock. + // Must happen outside the callback lock on the message thread to avoid deadlock + // if the plugin's release path posts to the message thread. + { + auto it = trackMap.find(trackId); + if (it != trackMap.end() && it->second) + { + if (auto* proc = it->second->getTrackFXProcessor(fxIndex)) + { + pluginWindowManager.closeEditorSync(proc); + proc->releaseResources(); + } + } + } + + // Phase 2: Acquire lock and remove the plugin from the chain. + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + auto it = trackMap.find(trackId); + if (it != trackMap.end() && it->second) + { + it->second->removeTrackFX(fxIndex); + juce::Logger::writeToLog("AudioEngine: Removed track FX " + juce::String(fxIndex) + " from track " + trackId); + } + recalculatePDC(); +} + +void AudioEngine::bypassTrackInputFX(const juce::String& trackId, int fxIndex, bool bypassed) +{ + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + auto it = trackMap.find(trackId); + if (it != trackMap.end() && it->second) + { + it->second->bypassInputFX(fxIndex, bypassed); + juce::Logger::writeToLog("AudioEngine: " + juce::String(bypassed ? "Bypassed" : "Unbypassed") + + " input FX " + juce::String(fxIndex) + " on track " + trackId); + } + recalculatePDC(); +} + +void AudioEngine::bypassTrackFX(const juce::String& trackId, int fxIndex, bool bypassed) +{ + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + auto it = trackMap.find(trackId); + if (it != trackMap.end() && it->second) + { + it->second->bypassTrackFX(fxIndex, bypassed); + juce::Logger::writeToLog("AudioEngine: " + juce::String(bypassed ? "Bypassed" : "Unbypassed") + + " track FX " + juce::String(fxIndex) + " on track " + trackId); + } + recalculatePDC(); +} + +bool AudioEngine::reorderTrackInputFX(const juce::String& trackId, int fromIndex, int toIndex) +{ + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + auto it = trackMap.find(trackId); + if (it == trackMap.end() || !it->second) + return false; + + bool success = it->second->reorderInputFX(fromIndex, toIndex); + if (success) + { + juce::Logger::writeToLog("AudioEngine: Reordered input FX on track " + trackId + + " from " + juce::String(fromIndex) + " to " + juce::String(toIndex)); + } + return success; +} + +bool AudioEngine::reorderTrackFX(const juce::String& trackId, int fromIndex, int toIndex) +{ + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + auto it = trackMap.find(trackId); + if (it == trackMap.end() || !it->second) + return false; + + bool success = it->second->reorderTrackFX(fromIndex, toIndex); + if (success) + { + juce::Logger::writeToLog("AudioEngine: Reordered track FX on track " + trackId + + " from " + juce::String(fromIndex) + " to " + juce::String(toIndex)); + } + return success; +} + +//============================================================================== +// Master FX Management + +bool AudioEngine::addMasterFX(const juce::String& pluginPath) +{ + juce::Logger::writeToLog("AudioEngine: addMasterFX called with: " + pluginPath); + + // Load the plugin with actual device sample rate & block size + double sr = currentSampleRate > 0 ? currentSampleRate : 44100.0; + int bs = currentBlockSize > 0 ? currentBlockSize : 512; + auto plugin = pluginManager.loadPluginFromFile(pluginPath, sr, bs); + if (!plugin) + { + juce::Logger::writeToLog("AudioEngine: Failed to load plugin for master FX"); + return false; + } + + // Provide tempo/position info to the plugin + plugin->setPlayHead (this); + + // Plugin was already created at the correct rate by loadPluginFromFile, + // but re-prepare in case the device has changed since then. + // Clamp max-block-size to at least 512 (same rationale as track FX). + auto* device = deviceManager.getCurrentAudioDevice(); + if (device) + { + prepareHostedProcessorForPrecision( + plugin.get(), + device->getCurrentSampleRate(), + device->getCurrentBufferSizeSamples(), + processingPrecisionMode); + } + + DesiredFXStageSpec specCopy; + { + const juce::ScopedLock lockSl(mainProcessorGraph->getCallbackLock()); + specCopy = desiredMasterStageSpec; + } + + DesiredFXStageSlot slot; + slot.slotId = nextMasterStageSlotId++; + slot.name = plugin->getName(); + if (auto* pluginInstance = dynamic_cast(plugin.get())) + { + auto desc = pluginInstance->getPluginDescription(); + slot.type = desc.pluginFormatName == "CLAP" ? "clap" : "vst3"; + slot.pluginPath = desc.fileOrIdentifier; + slot.pluginFormat = desc.pluginFormatName; + } + else + { + slot.type = "plugin"; + slot.pluginPath = pluginPath; + slot.pluginFormat = "Plugin"; + } + slot.serializedState = serialiseProcessorStateToBase64(plugin.get()); + specCopy.slots.push_back(std::move(slot)); + + if (!publishMasterStageSpec(specCopy)) + { + juce::Logger::writeToLog("AudioEngine: Failed to publish master stage after adding plugin"); + return false; + } + + juce::Logger::writeToLog("AudioEngine: Added plugin to master FX chain (total: " + juce::String((int)specCopy.slots.size()) + ")"); + return true; +} + +juce::var AudioEngine::getMasterFX() +{ + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + juce::Array fxList; + for (int i = 0; i < static_cast(desiredMasterStageSpec.slots.size()); ++i) + { + const auto& slot = desiredMasterStageSpec.slots[static_cast(i)]; + juce::DynamicObject::Ptr fxInfo = new juce::DynamicObject(); + fxInfo->setProperty("index", i); + fxInfo->setProperty("name", slot.name); + fxInfo->setProperty("bypassed", slot.bypassed); + fxInfo->setProperty("precisionOverride", slot.forceFloat ? "float32" : "auto"); + fxInfo->setProperty("type", slot.type); + if (!slot.pluginPath.isEmpty()) + fxInfo->setProperty("pluginPath", slot.pluginPath); + fxList.add(juce::var(fxInfo.get())); + } + return fxList; +} + +void AudioEngine::removeMasterFX(int fxIndex) +{ + DesiredFXStageSpec specCopy; + { + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + specCopy = desiredMasterStageSpec; + } + + if (fxIndex < 0 || fxIndex >= static_cast(specCopy.slots.size())) + return; + + // Close editor for the processor being removed BEFORE publishing new spec. + { + int slotId = specCopy.slots[static_cast(fxIndex)].slotId; + auto activeStage = std::atomic_load_explicit(&realtimeMasterFXSnapshot, std::memory_order_acquire); + if (activeStage) + { + if (const auto* activeSlot = findActiveStageSlot(activeStage, slotId)) + { + if (activeSlot->processor) + { + pluginWindowManager.closeEditorSync(activeSlot->processor.get()); + activeSlot->processor->releaseResources(); + } + } + } + } + + syncStageSpecStateFromActive(specCopy, std::atomic_load_explicit(&realtimeMasterFXSnapshot, std::memory_order_acquire)); + specCopy.slots.erase(specCopy.slots.begin() + fxIndex); + if (publishMasterStageSpec(specCopy)) + juce::Logger::writeToLog("AudioEngine: Removed master FX " + juce::String(fxIndex)); +} + +void AudioEngine::openMasterFXEditor(int fxIndex) +{ + int slotId = 0; + { + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + if (const auto* slot = findDesiredStageSlot(desiredMasterStageSpec, fxIndex)) + slotId = slot->slotId; + } + + if (slotId == 0) + return; + + auto activeStage = std::atomic_load_explicit(&realtimeMasterFXSnapshot, std::memory_order_acquire); + if (const auto* activeSlot = findActiveStageSlot(activeStage, slotId)) + { + if (activeSlot->processor) + { + auto processor = activeSlot->processor; + auto title = activeSlot->name; + PluginWindowManager::PluginEditorTarget target; + target.scope = PluginWindowManager::PluginEditorTarget::Scope::MasterFX; + target.fxIndex = fxIndex; + juce::MessageManager::callAsync([this, processor, title, target]() + { + if (processor) + pluginWindowManager.openEditor(processor.get(), title, target); + }); + } + } +} + +void AudioEngine::bypassMasterFX(int fxIndex, bool bypassed) +{ + DesiredFXStageSpec specCopy; + { + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + specCopy = desiredMasterStageSpec; + } + + syncStageSpecStateFromActive(specCopy, std::atomic_load_explicit(&realtimeMasterFXSnapshot, std::memory_order_acquire)); + if (auto* slot = findDesiredStageSlot(specCopy, fxIndex)) + { + slot->bypassed = bypassed; + if (publishMasterStageSpec(specCopy)) + { + juce::Logger::writeToLog("AudioEngine: Master FX " + juce::String(fxIndex) + + (bypassed ? " bypassed" : " enabled")); + } + } +} + +void AudioEngine::setMasterVolume(float volume) +{ + masterVolume = juce::jlimit(0.0f, juce::Decibels::decibelsToGain(12.0f), volume); + juce::Logger::writeToLog("Master volume set to: " + juce::String(volume)); +} + +void AudioEngine::setMasterPan(float pan) +{ + masterPan = juce::jlimit(-1.0f, 1.0f, pan); + + float leftGain = 1.0f; + float rightGain = 1.0f; + computePanLawGains(currentPanLaw, masterPan, 1.0f, leftGain, rightGain); + cachedMasterPanL.store(leftGain, std::memory_order_relaxed); + cachedMasterPanR.store(rightGain, std::memory_order_relaxed); +} + +bool AudioEngine::addMonitoringFX(const juce::String& pluginPath) +{ + juce::Logger::writeToLog("AudioEngine: addMonitoringFX called with: " + pluginPath); + + double sr = currentSampleRate > 0 ? currentSampleRate : 44100.0; + int bs = currentBlockSize > 0 ? currentBlockSize : 512; + auto plugin = pluginManager.loadPluginFromFile(pluginPath, sr, bs); + if (!plugin) + { + juce::Logger::writeToLog("AudioEngine: Failed to load plugin for monitoring FX"); + return false; + } + + plugin->setPlayHead(this); + + auto* device = deviceManager.getCurrentAudioDevice(); + if (device) + { + prepareHostedProcessorForPrecision( + plugin.get(), + device->getCurrentSampleRate(), + device->getCurrentBufferSizeSamples(), + processingPrecisionMode); + } + + DesiredFXStageSpec specCopy; + { + const juce::ScopedLock lockSl(mainProcessorGraph->getCallbackLock()); + specCopy = desiredMonitoringStageSpec; + } + + DesiredFXStageSlot slot; + slot.slotId = nextMonitoringStageSlotId++; + slot.name = plugin->getName(); + if (auto* pluginInstance = dynamic_cast(plugin.get())) + { + auto desc = pluginInstance->getPluginDescription(); + slot.type = desc.pluginFormatName == "CLAP" ? "clap" : "vst3"; + slot.pluginPath = desc.fileOrIdentifier; + slot.pluginFormat = desc.pluginFormatName; + } + else + { + slot.type = "plugin"; + slot.pluginPath = pluginPath; + slot.pluginFormat = "Plugin"; + } + slot.serializedState = serialiseProcessorStateToBase64(plugin.get()); + specCopy.slots.push_back(std::move(slot)); + + if (!publishMonitoringStageSpec(specCopy)) + { + juce::Logger::writeToLog("AudioEngine: Failed to publish monitoring stage after adding plugin"); + return false; + } + + juce::Logger::writeToLog("AudioEngine: Added plugin to monitoring FX chain (total: " + + juce::String((int)specCopy.slots.size()) + ")"); + return true; +} + +juce::var AudioEngine::getMonitoringFX() +{ + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + juce::Array fxList; + for (int i = 0; i < static_cast(desiredMonitoringStageSpec.slots.size()); ++i) + { + const auto& slot = desiredMonitoringStageSpec.slots[static_cast(i)]; + juce::DynamicObject::Ptr fxInfo = new juce::DynamicObject(); + fxInfo->setProperty("index", i); + fxInfo->setProperty("name", slot.name); + fxInfo->setProperty("bypassed", slot.bypassed); + fxInfo->setProperty("precisionOverride", slot.forceFloat ? "float32" : "auto"); + fxInfo->setProperty("type", slot.type); + if (!slot.pluginPath.isEmpty()) + fxInfo->setProperty("pluginPath", slot.pluginPath); + fxList.add(juce::var(fxInfo.get())); + } + return fxList; +} + +void AudioEngine::removeMonitoringFX(int fxIndex) +{ + DesiredFXStageSpec specCopy; + { + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + specCopy = desiredMonitoringStageSpec; + } + + if (fxIndex < 0 || fxIndex >= static_cast(specCopy.slots.size())) + return; + + // Close editor for the processor being removed BEFORE publishing new spec. + { + int slotId = specCopy.slots[static_cast(fxIndex)].slotId; + auto activeStage = std::atomic_load_explicit(&realtimeMonitoringFXSnapshot, std::memory_order_acquire); + if (activeStage) + { + if (const auto* activeSlot = findActiveStageSlot(activeStage, slotId)) + { + if (activeSlot->processor) + { + pluginWindowManager.closeEditorSync(activeSlot->processor.get()); + activeSlot->processor->releaseResources(); + } + } + } + } + + syncStageSpecStateFromActive(specCopy, std::atomic_load_explicit(&realtimeMonitoringFXSnapshot, std::memory_order_acquire)); + specCopy.slots.erase(specCopy.slots.begin() + fxIndex); + if (publishMonitoringStageSpec(specCopy)) + juce::Logger::writeToLog("AudioEngine: Removed monitoring FX " + juce::String(fxIndex)); +} + +void AudioEngine::openMonitoringFXEditor(int fxIndex) +{ + int slotId = 0; + { + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + if (const auto* slot = findDesiredStageSlot(desiredMonitoringStageSpec, fxIndex)) + slotId = slot->slotId; + } + + if (slotId == 0) + return; + + auto activeStage = std::atomic_load_explicit(&realtimeMonitoringFXSnapshot, std::memory_order_acquire); + if (const auto* activeSlot = findActiveStageSlot(activeStage, slotId)) + { + if (activeSlot->processor) + { + auto processor = activeSlot->processor; + auto title = activeSlot->name; + PluginWindowManager::PluginEditorTarget target; + target.scope = PluginWindowManager::PluginEditorTarget::Scope::MonitoringFX; + target.fxIndex = fxIndex; + juce::MessageManager::callAsync([this, processor, title, target]() + { + if (processor) + pluginWindowManager.openEditor(processor.get(), title, target); + }); + } + } +} + +void AudioEngine::bypassMonitoringFX(int fxIndex, bool bypassed) +{ + DesiredFXStageSpec specCopy; + { + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + specCopy = desiredMonitoringStageSpec; + } + + syncStageSpecStateFromActive(specCopy, std::atomic_load_explicit(&realtimeMonitoringFXSnapshot, std::memory_order_acquire)); + if (auto* slot = findDesiredStageSlot(specCopy, fxIndex)) + { + slot->bypassed = bypassed; + if (publishMonitoringStageSpec(specCopy)) + { + juce::Logger::writeToLog("AudioEngine: Monitoring FX " + juce::String(fxIndex) + + (bypassed ? " bypassed" : " enabled")); + } + } +} + +std::vector AudioEngine::getLastCompletedClips() +{ + auto clips = lastCompletedClips; + lastReturnedRecordingClipCount.store(static_cast(clips.size()), std::memory_order_relaxed); + logAudioRecord("getLastCompletedClips returning count=" + juce::String(static_cast(clips.size()))); + for (const auto& clip : clips) + { + logAudioRecord("getLastCompletedClips clip track=" + clip.trackId + + " file=" + clip.file.getFullPathName() + + " startTime=" + juce::String(clip.startTime, 3) + + " duration=" + juce::String(clip.duration, 3)); + } + lastCompletedClips.clear(); // Clear after reading + return clips; +} + +std::vector AudioEngine::getLastCompletedMIDIClips() +{ + auto clips = std::move(lastCompletedMIDIClips); + lastCompletedMIDIClips.clear(); + return clips; +} + +juce::var AudioEngine::getActiveRecordingMIDIPreviews(const juce::var& requests) +{ + std::vector parsedRequests; + + if (auto* requestArray = requests.getArray()) + { + parsedRequests.reserve(static_cast(requestArray->size())); + for (const auto& requestVar : *requestArray) + { + auto* requestObj = requestVar.getDynamicObject(); + if (requestObj == nullptr) + continue; + + const juce::String trackId = requestObj->getProperty("trackId").toString(); + if (trackId.isEmpty()) + continue; + + MIDIRecorder::LivePreviewRequest request; + request.trackId = trackId; + const auto generationVar = requestObj->getProperty("generation"); + request.generation = generationVar.isVoid() + ? 0 + : static_cast(static_cast(generationVar)); + request.knownEventCount = static_cast(requestObj->getProperty("knownEventCount")); + parsedRequests.push_back(std::move(request)); + } + } + + auto snapshots = midiRecorder.getLivePreviewSnapshots(parsedRequests); + juce::Array snapshotArray; + + for (const auto& snapshot : snapshots) + { + auto* snapshotObj = new juce::DynamicObject(); + snapshotObj->setProperty("trackId", snapshot.trackId); + snapshotObj->setProperty("generation", static_cast(snapshot.generation)); + snapshotObj->setProperty("recordingStartTime", snapshot.recordingStartTime); + snapshotObj->setProperty("totalEventCount", snapshot.totalEventCount); + + juce::Array eventsArray; + for (const auto& evt : snapshot.deltaEvents) + { + if (!evt.message.isNoteOn() && !evt.message.isNoteOff()) + continue; + + auto* evtObj = new juce::DynamicObject(); + evtObj->setProperty("timestamp", evt.timestamp); + evtObj->setProperty("type", evt.message.isNoteOn() ? "noteOn" : "noteOff"); + evtObj->setProperty("note", evt.message.getNoteNumber()); + evtObj->setProperty("velocity", evt.message.isNoteOn() ? evt.message.getVelocity() : 0); + evtObj->setProperty("channel", evt.message.getChannel()); + eventsArray.add(evtObj); + } + snapshotObj->setProperty("deltaEvents", eventsArray); + + juce::Array activeNotesArray; + for (const auto& activeNote : snapshot.activeNotes) + { + auto* activeNoteObj = new juce::DynamicObject(); + activeNoteObj->setProperty("note", activeNote.note); + activeNoteObj->setProperty("startTimestamp", activeNote.startTimestamp); + activeNotesArray.add(activeNoteObj); + } + snapshotObj->setProperty("activeNotes", activeNotesArray); + + snapshotArray.add(snapshotObj); + } + + return snapshotArray; +} + +//============================================================================== +// Playback Clip Management + +// Playback Clip Management + +void AudioEngine::addPlaybackClip(const juce::String& trackId, const juce::String& filePath, double startTime, double duration, + double offset, double volumeDB, double fadeIn, double fadeOut, const juce::String& clipId, + const juce::String& pitchCorrectionSourceFilePath, double pitchCorrectionSourceOffset) +{ + juce::File audioFile(filePath); + juce::File sourceAudioFile(pitchCorrectionSourceFilePath); + playbackEngine.addClip(audioFile, startTime, duration, trackId, offset, volumeDB, fadeIn, fadeOut, clipId, + sourceAudioFile, pitchCorrectionSourceOffset); + juce::Logger::writeToLog("AudioEngine: Added playback clip to track " + trackId + + " (offset=" + juce::String(offset) + "s, vol=" + juce::String(volumeDB) + "dB)"); + logAudioPlayback("addPlaybackClip track=" + trackId + + " clipId=" + clipId + + " file=" + filePath + + " start=" + juce::String(startTime, 3) + + " duration=" + juce::String(duration, 3) + + " offset=" + juce::String(offset, 3) + + " totalClips=" + juce::String(playbackEngine.getNumClips())); +} + +void AudioEngine::addPlaybackClipsBatch(const juce::String& clipsJSON) +{ + auto parsed = juce::JSON::parse (clipsJSON); + if (auto* arr = parsed.getArray()) + { + logAudioPlayback("addPlaybackClipsBatch incomingCount=" + juce::String(static_cast(arr->size()))); + for (const auto& item : *arr) + { + juce::String trackId = item["trackId"].toString(); + juce::String fp = item["filePath"].toString(); + juce::String cId = item.hasProperty ("clipId") ? item["clipId"].toString() : juce::String(); + double startTime = static_cast (item["startTime"]); + double dur = static_cast (item["duration"]); + double off = item.hasProperty ("offset") ? static_cast (item["offset"]) : 0.0; + double vol = item.hasProperty ("volumeDB") ? static_cast (item["volumeDB"]) : 0.0; + double fi = item.hasProperty ("fadeIn") ? static_cast (item["fadeIn"]) : 0.0; + double fo = item.hasProperty ("fadeOut") ? static_cast (item["fadeOut"]) : 0.0; + juce::String sourcePath = item.hasProperty ("pitchCorrectionSourceFilePath") + ? item["pitchCorrectionSourceFilePath"].toString() + : juce::String(); + double sourceOffset = item.hasProperty ("pitchCorrectionSourceOffset") + ? static_cast (item["pitchCorrectionSourceOffset"]) + : -1.0; + + juce::File audioFile (fp); + juce::File sourceAudioFile (sourcePath); + playbackEngine.addClip (audioFile, startTime, dur, trackId, off, vol, fi, fo, cId, + sourceAudioFile, sourceOffset); + logAudioPlayback("addPlaybackClipsBatch clip track=" + trackId + + " clipId=" + cId + + " file=" + fp + + " start=" + juce::String(startTime, 3) + + " duration=" + juce::String(dur, 3) + + " offset=" + juce::String(off, 3)); + } + juce::Logger::writeToLog ("AudioEngine: Batch-added " + juce::String (arr->size()) + " playback clips"); + logAudioPlayback("addPlaybackClipsBatch totalClipsAfter=" + juce::String(playbackEngine.getNumClips())); + } +} + +void AudioEngine::removePlaybackClip(const juce::String& trackId, const juce::String& filePath) +{ + playbackEngine.removeClip(trackId, filePath); + juce::Logger::writeToLog("AudioEngine: Removed playback clip from track " + trackId); +} + +void AudioEngine::clearPlaybackClips() +{ + playbackEngine.clearAllClips(); + juce::Logger::writeToLog("AudioEngine: Cleared all playback clips"); + logAudioPlayback("clearPlaybackClips totalClipsAfter=" + juce::String(playbackEngine.getNumClips())); +} + +void AudioEngine::clearTrackPlaybackClips(const juce::String& trackId) +{ + playbackEngine.clearTrackClips(trackId); + juce::Logger::writeToLog("AudioEngine: Cleared clips for track " + trackId); +} + + + +juce::var AudioEngine::getWaveformPeaks(const juce::String& filePath, int samplesPerPixel, int startSample, int numPixels) +{ + // REAPER-inspired: read from pre-computed peak cache (.s13peaks) instead of audio file. + // If cache doesn't exist, kick off async generation and return empty. + // Frontend will re-request on next render after cache is ready. + juce::File audioFile(filePath); + if (!audioFile.existsAsFile()) + { + juce::Logger::writeToLog("getWaveformPeaks: File not found: " + filePath); + return juce::Array(); + } + + // Generate peak cache if it doesn't exist — async to avoid blocking the UI thread. + // Returns empty array now; emits peaksReady event when generation completes so + // the frontend Timeline can re-fetch waveform data. + if (!peakCache.hasCachedPeaks(audioFile)) + { + juce::Logger::writeToLog("getWaveformPeaks: Generating peak cache (async) for: " + audioFile.getFileName()); + peakCache.generateAsync(audioFile, [this, filePath]() { + juce::Logger::writeToLog("getWaveformPeaks: Peak cache ready for: " + filePath); + if (onPeaksReady) + onPeaksReady(filePath); + }); + return juce::Array(); + } + + // Read from peak cache (instant — memory-cached mipmap lookup) + return peakCache.getPeaks(audioFile, samplesPerPixel, startSample, numPixels); +} + +juce::var AudioEngine::getRecordingPeaks(const juce::String& trackId, int samplesPerPixel, int numPixels) +{ + logAudioRecord("getRecordingPeaks track=" + trackId + + " samplesPerPixel=" + juce::String(samplesPerPixel) + + " numPixels=" + juce::String(numPixels)); + return audioRecorder.getRecordingPeaks(trackId, samplesPerPixel, numPixels); +} + +//============================================================================== +// Metronome & Transport Control (Phase 3) + +void AudioEngine::setTempo(double bpm) +{ + tempo = bpm; + metronome.setBpm(bpm); +} + +void AudioEngine::setMetronomeEnabled(bool enabled) +{ + metronome.setEnabled(enabled); +} + +void AudioEngine::setMetronomeVolume(float volume) +{ + metronome.setVolume(volume); +} + +bool AudioEngine::isMetronomeEnabled() const +{ + return metronome.isEnabled(); +} + +void AudioEngine::setMetronomeAccentBeats(const std::vector& accents) +{ + metronome.setAccentBeats(accents); +} + +void AudioEngine::setTimeSignature(int numerator, int denominator) +{ + timeSigNumerator = numerator; + timeSigDenominator = denominator; + metronome.setTimeSignature(numerator, denominator); +} + +void AudioEngine::getTimeSignature(int& numerator, int& denominator) const +{ + numerator = timeSigNumerator; + denominator = timeSigDenominator; +} + +//============================================================================== +// AudioPlayHead — provides tempo/position info to hosted VST3 plugins + +juce::Optional AudioEngine::getPosition() const +{ + PositionInfo info; + + double timeInSeconds = (currentSampleRate > 0) + ? currentSamplePosition / currentSampleRate + : 0.0; + + // Use tempo map if available, otherwise fall back to global BPM + double currentBpm = getTempoAtTime(timeInSeconds); + info.setBpm (currentBpm); + + juce::AudioPlayHead::TimeSignature timeSig; + timeSig.numerator = timeSigNumerator; + timeSig.denominator = timeSigDenominator; + info.setTimeSignature (timeSig); + info.setIsPlaying (isPlaying.load()); + info.setIsRecording (isRecordMode.load()); + info.setIsLooping (isLooping); + + info.setTimeInSamples (static_cast (currentSamplePosition)); + info.setTimeInSeconds (timeInSeconds); + + // PPQ = position in quarter notes = seconds * (BPM / 60) + // NOTE: With a tempo map, PPQ should integrate over tempo changes. + // For now, use instantaneous BPM (acceptable for step-wise tempo map). + double ppqPosition = timeInSeconds * (currentBpm / 60.0); + info.setPpqPosition (ppqPosition); + + // Bar start in PPQ: quarter notes per bar depends on time signature + double quarterNotesPerBar = timeSigNumerator * (4.0 / timeSigDenominator); + if (quarterNotesPerBar > 0.0) + info.setPpqPositionOfLastBarStart (std::floor (ppqPosition / quarterNotesPerBar) * quarterNotesPerBar); + + return info; +} + +void AudioEngine::propagatePlayHead (TrackProcessor* track) +{ + if (!track) return; + + for (int i = 0; i < track->getNumInputFX(); ++i) + if (auto* proc = track->getInputFXProcessor (i)) + proc->setPlayHead (this); + + for (int i = 0; i < track->getNumTrackFX(); ++i) + if (auto* proc = track->getTrackFXProcessor (i)) + proc->setPlayHead (this); + + if (auto* inst = track->getInstrument()) + inst->setPlayHead (this); +} + +// Custom metronome sounds (Phase 9C) +bool AudioEngine::setMetronomeClickSound(const juce::String& filePath) +{ + return metronome.setClickSound(filePath); +} + +bool AudioEngine::setMetronomeAccentSound(const juce::String& filePath) +{ + return metronome.setAccentSound(filePath); +} + +void AudioEngine::resetMetronomeSounds() +{ + metronome.resetToDefaultSounds(); +} + +//============================================================================== +// Plugin Delay Compensation (PDC) + +void AudioEngine::recalculatePDC() +{ + // Find maximum chain latency across all tracks + int maxLatency = 0; + for (const auto& id : trackOrder) + { + auto it = trackMap.find(id); + if (it != trackMap.end() && it->second) + { + int lat = it->second->getChainLatency(); + if (lat > maxLatency) + maxLatency = lat; + } + } + + // Set PDC delay for each track: maxLatency - trackLatency + for (const auto& id : trackOrder) + { + auto it = trackMap.find(id); + if (it != trackMap.end() && it->second) + { + int trackLat = it->second->getChainLatency(); + int delay = maxLatency - trackLat; + it->second->setPDCDelay(delay); + } + } + + juce::Logger::writeToLog("AudioEngine: PDC recalculated - maxLatency=" + juce::String(maxLatency) + " samples"); +} + +//============================================================================== +// Pan Law + +void AudioEngine::setPanLaw(const juce::String& law) +{ + PanLaw newLaw = PanLaw::Linear; + if (law == "constant_power" || law == "minus3dB") + newLaw = PanLaw::ConstantPower; + else if (law == "minus4_5dB") + newLaw = PanLaw::Minus4_5dB; + else if (law == "minus6dB") + newLaw = PanLaw::Minus6dB; + else if (law == "linear") + newLaw = PanLaw::Linear; + + currentPanLaw = newLaw; + + // Apply to all tracks + for (const auto& id : trackOrder) + { + auto it = trackMap.find(id); + if (it != trackMap.end() && it->second) + it->second->setPanLaw(newLaw); + } + + setMasterPan(masterPan); + + juce::Logger::writeToLog("AudioEngine: Pan law set to " + law); +} + +juce::String AudioEngine::getPanLaw() const +{ + switch (currentPanLaw) + { + case PanLaw::ConstantPower: return "constant_power"; + case PanLaw::Minus4_5dB: return "minus4_5dB"; + case PanLaw::Minus6dB: return "minus6dB"; + case PanLaw::Linear: return "linear"; + default: return "constant_power"; + } +} + +//============================================================================== +// DC Offset per track + +void AudioEngine::setTrackDCOffset(const juce::String& trackId, bool enabled) +{ + auto it = trackMap.find(trackId); + if (it != trackMap.end() && it->second) + { + it->second->setDCOffsetRemoval(enabled); + juce::Logger::writeToLog("AudioEngine: DC offset removal " + + juce::String(enabled ? "enabled" : "disabled") + + " on track " + trackId); + } +} + +//============================================================================== +// Sidechain Routing (Phase 4.4) + +void AudioEngine::setSidechainSource(const juce::String& destTrackId, int pluginIndex, const juce::String& sourceTrackId) +{ + auto it = trackMap.find(destTrackId); + if (it != trackMap.end() && it->second) + { + it->second->setSidechainSource(pluginIndex, sourceTrackId); + rebuildRealtimeProcessingSnapshots(); + juce::Logger::writeToLog("AudioEngine: Set sidechain for track " + destTrackId + + " FX[" + juce::String(pluginIndex) + "] = " + sourceTrackId); + } +} + +void AudioEngine::clearSidechainSource(const juce::String& destTrackId, int pluginIndex) +{ + auto it = trackMap.find(destTrackId); + if (it != trackMap.end() && it->second) + { + it->second->clearSidechainSource(pluginIndex); + rebuildRealtimeProcessingSnapshots(); + juce::Logger::writeToLog("AudioEngine: Cleared sidechain for track " + destTrackId + + " FX[" + juce::String(pluginIndex) + "]"); + } +} + +juce::String AudioEngine::getSidechainSource(const juce::String& destTrackId, int pluginIndex) +{ + auto it = trackMap.find(destTrackId); + if (it != trackMap.end() && it->second) + return it->second->getSidechainSource(pluginIndex); + return {}; +} + +//============================================================================== +// Send/Bus Routing (Phase 11) + +int AudioEngine::addTrackSend(const juce::String& sourceTrackId, const juce::String& destTrackId) +{ + if (trackMap.find(sourceTrackId) == trackMap.end()) return -1; + int sendIndex = trackMap[sourceTrackId]->addSend(destTrackId); + rebuildRealtimeProcessingSnapshots(); + return sendIndex; +} + +void AudioEngine::removeTrackSend(const juce::String& sourceTrackId, int sendIndex) +{ + if (trackMap.find(sourceTrackId) != trackMap.end()) + { + trackMap[sourceTrackId]->removeSend(sendIndex); + rebuildRealtimeProcessingSnapshots(); + } +} + +void AudioEngine::setTrackSendLevel(const juce::String& sourceTrackId, int sendIndex, float level) +{ + if (trackMap.find(sourceTrackId) != trackMap.end()) + { + trackMap[sourceTrackId]->setSendLevel(sendIndex, level); + rebuildRealtimeProcessingSnapshots(); + } +} + +void AudioEngine::setTrackSendPan(const juce::String& sourceTrackId, int sendIndex, float pan) +{ + if (trackMap.find(sourceTrackId) != trackMap.end()) + { + trackMap[sourceTrackId]->setSendPan(sendIndex, pan); + rebuildRealtimeProcessingSnapshots(); + } +} + +void AudioEngine::setTrackSendEnabled(const juce::String& sourceTrackId, int sendIndex, bool enabled) +{ + if (trackMap.find(sourceTrackId) != trackMap.end()) + { + trackMap[sourceTrackId]->setSendEnabled(sendIndex, enabled); + rebuildRealtimeProcessingSnapshots(); + } +} + +void AudioEngine::setTrackSendPreFader(const juce::String& sourceTrackId, int sendIndex, bool preFader) +{ + if (trackMap.find(sourceTrackId) != trackMap.end()) + { + trackMap[sourceTrackId]->setSendPreFader(sendIndex, preFader); + rebuildRealtimeProcessingSnapshots(); + } +} + +void AudioEngine::setTrackSendPhaseInvert(const juce::String& sourceTrackId, int sendIndex, bool invert) +{ + if (trackMap.find(sourceTrackId) != trackMap.end()) + { + trackMap[sourceTrackId]->setSendPhaseInvert(sendIndex, invert); + rebuildRealtimeProcessingSnapshots(); + } +} + +juce::var AudioEngine::getTrackSends(const juce::String& trackId) +{ + juce::Array result; + if (trackMap.find(trackId) == trackMap.end()) return result; + + auto* track = trackMap[trackId]; + for (int i = 0; i < track->getNumSends(); ++i) + { + auto* obj = new juce::DynamicObject(); + obj->setProperty("destTrackId", track->getSendDestination(i)); + obj->setProperty("level", track->getSendLevel(i)); + obj->setProperty("pan", track->getSendPan(i)); + obj->setProperty("enabled", track->getSendEnabled(i)); + obj->setProperty("preFader", track->getSendPreFader(i)); + obj->setProperty("phaseInvert", track->getSendPhaseInvert(i)); + result.add(obj); + } + return result; +} + +//============================================================================== +// Track Routing Features + +void AudioEngine::setTrackPhaseInvert(const juce::String& trackId, bool invert) +{ + auto it = trackMap.find(trackId); + if (it != trackMap.end()) it->second->setPhaseInvert(invert); +} + +bool AudioEngine::getTrackPhaseInvert(const juce::String& trackId) const +{ + auto it = trackMap.find(trackId); + return it != trackMap.end() ? it->second->getPhaseInvert() : false; +} + +void AudioEngine::setTrackStereoWidth(const juce::String& trackId, float widthPercent) +{ + auto it = trackMap.find(trackId); + if (it != trackMap.end()) it->second->setStereoWidth(widthPercent); +} + +float AudioEngine::getTrackStereoWidth(const juce::String& trackId) const +{ + auto it = trackMap.find(trackId); + return it != trackMap.end() ? it->second->getStereoWidth() : 100.0f; +} + +void AudioEngine::setTrackMasterSendEnabled(const juce::String& trackId, bool enabled) +{ + auto it = trackMap.find(trackId); + if (it != trackMap.end()) it->second->setMasterSendEnabled(enabled); +} + +bool AudioEngine::getTrackMasterSendEnabled(const juce::String& trackId) const +{ + auto it = trackMap.find(trackId); + return it != trackMap.end() ? it->second->getMasterSendEnabled() : true; +} + +void AudioEngine::setTrackOutputChannels(const juce::String& trackId, int startChannel, int numChannels) +{ + auto it = trackMap.find(trackId); + if (it != trackMap.end()) it->second->setOutputChannels(startChannel, numChannels); +} + +void AudioEngine::setTrackPlaybackOffset(const juce::String& trackId, double offsetMs) +{ + auto it = trackMap.find(trackId); + if (it != trackMap.end()) it->second->setPlaybackOffset(offsetMs); +} + +double AudioEngine::getTrackPlaybackOffset(const juce::String& trackId) const +{ + auto it = trackMap.find(trackId); + return it != trackMap.end() ? it->second->getPlaybackOffset() : 0.0; +} + +void AudioEngine::setTrackChannelCount(const juce::String& trackId, int numChannels) +{ + auto it = trackMap.find(trackId); + if (it != trackMap.end()) it->second->setTrackChannelCount(numChannels); +} + +int AudioEngine::getTrackChannelCount(const juce::String& trackId) const +{ + auto it = trackMap.find(trackId); + return it != trackMap.end() ? it->second->getTrackChannelCount() : 2; +} + +void AudioEngine::setTrackMIDIOutput(const juce::String& trackId, const juce::String& deviceName) +{ + auto it = trackMap.find(trackId); + if (it != trackMap.end()) it->second->setMIDIOutputDevice(deviceName); +} + +juce::String AudioEngine::getTrackMIDIOutput(const juce::String& trackId) const +{ + auto it = trackMap.find(trackId); + return it != trackMap.end() ? it->second->getMIDIOutputDeviceName() : juce::String(); +} + +juce::var AudioEngine::getTrackRoutingInfo(const juce::String& trackId) +{ + auto it = trackMap.find(trackId); + if (it == trackMap.end()) return juce::var(); + + auto* track = it->second; + auto* obj = new juce::DynamicObject(); + + obj->setProperty("phaseInverted", track->getPhaseInvert()); + obj->setProperty("stereoWidth", track->getStereoWidth()); + obj->setProperty("masterSendEnabled", track->getMasterSendEnabled()); + obj->setProperty("outputStartChannel", track->getOutputStartChannel()); + obj->setProperty("outputChannelCount", track->getOutputChannelCount()); + obj->setProperty("playbackOffsetMs", track->getPlaybackOffset()); + obj->setProperty("trackChannelCount", track->getTrackChannelCount()); + obj->setProperty("midiOutputDevice", track->getMIDIOutputDeviceName()); + + return juce::var(obj); +} + +juce::var AudioEngine::getMidiDiagnostics() const +{ + juce::Array tracksArray; + for (const auto& [trackId, track] : trackMap) + { + if (!track) + continue; + + auto* trackObj = new juce::DynamicObject(); + trackObj->setProperty("trackId", trackId); + trackObj->setProperty("trackType", track->getTrackType() == TrackType::Instrument + ? "instrument" + : (track->getTrackType() == TrackType::MIDI ? "midi" : "audio")); + trackObj->setProperty("midiOverflowCount", track->getMidiOverflowCount()); + trackObj->setProperty("lastBuiltMidiEventCount", track->getLastBuiltMidiEventCount()); + trackObj->setProperty("maxBuiltMidiEventCount", track->getMaxBuiltMidiEventCount()); + trackObj->setProperty("realtimeFallbackReuseCount", track->getRealtimeFallbackReuseCount()); + trackObj->setProperty("monitoring", track->getInputMonitoring()); + trackObj->setProperty("recordArmed", track->getRecordArmed()); + tracksArray.add(juce::var(trackObj)); + } + + auto* root = new juce::DynamicObject(); + root->setProperty("tracks", tracksArray); + root->setProperty("bufferSize", currentBlockSize); + root->setProperty("sampleRate", currentSampleRate); + root->setProperty("lateEventCount", midiLateEventCount.load(std::memory_order_relaxed)); + root->setProperty("maxEventsPerBlock", midiMaxEventsPerBlock.load(std::memory_order_relaxed)); + root->setProperty("lastComputedSampleOffset", midiLastComputedSampleOffset.load(std::memory_order_relaxed)); + root->setProperty("masterFXFallbackReuseCount", masterFXFallbackReuseCount.load(std::memory_order_relaxed)); + root->setProperty("monitoringFXFallbackReuseCount", monitoringFXFallbackReuseCount.load(std::memory_order_relaxed)); + root->setProperty("masterFXBusySkipCount", masterFXBusySkipCount.load(std::memory_order_relaxed)); + root->setProperty("monitoringFXBusySkipCount", monitoringFXBusySkipCount.load(std::memory_order_relaxed)); + root->setProperty("masterStageGeneration", static_cast(masterStageGeneration.load(std::memory_order_relaxed))); + root->setProperty("monitoringStageGeneration", static_cast(monitoringStageGeneration.load(std::memory_order_relaxed))); + root->setProperty("masterStageBuildFailureCount", masterStageBuildFailureCount.load(std::memory_order_relaxed)); + root->setProperty("monitoringStageBuildFailureCount", monitoringStageBuildFailureCount.load(std::memory_order_relaxed)); + root->setProperty("masterStageLastBuildMs", masterStageLastBuildMs.load(std::memory_order_relaxed)); + root->setProperty("monitoringStageLastBuildMs", monitoringStageLastBuildMs.load(std::memory_order_relaxed)); + root->setProperty("processingPrecision", getProcessingPrecision()); + return juce::var(root); +} + +juce::var AudioEngine::getAudioDebugSnapshot() const +{ + juce::Array playbackTracks; + for (const auto& [trackId, track] : trackMap) + { + juce::ignoreUnused(track); + auto* trackObj = new juce::DynamicObject(); + trackObj->setProperty("trackId", trackId); + trackObj->setProperty("clipCount", playbackEngine.getNumClipsForTrack(trackId)); + playbackTracks.add(juce::var(trackObj)); + } + + auto* root = new juce::DynamicObject(); + root->setProperty("transportPlaying", isPlaying.load()); + root->setProperty("transportRecording", isRecordMode.load()); + root->setProperty("transportPosition", getTransportPosition()); + root->setProperty("sampleRate", currentSampleRate); + root->setProperty("blockSize", currentBlockSize); + root->setProperty("playbackClipCount", playbackEngine.getNumClips()); + root->setProperty("activeOutputChannels", lastActiveOutputChannels.load(std::memory_order_relaxed)); + root->setProperty("callbackInputChannels", lastCallbackInputChannels.load(std::memory_order_relaxed)); + root->setProperty("callbackOutputChannels", lastCallbackOutputChannels.load(std::memory_order_relaxed)); + root->setProperty("lastCallbackCounter", static_cast(lastAudioCallbackCounter.load(std::memory_order_relaxed))); + root->setProperty("postTrackPlaybackPeak", lastPostTrackPlaybackPeak.load(std::memory_order_relaxed)); + root->setProperty("postMonitoringInputPeak", lastPostMonitoringInputPeak.load(std::memory_order_relaxed)); + root->setProperty("postMasterFxPeak", lastPostMasterFXPeak.load(std::memory_order_relaxed)); + root->setProperty("postMonitoringFxPeak", lastPostMonitoringFXPeak.load(std::memory_order_relaxed)); + root->setProperty("finalOutputPeak", lastFinalOutputPeak.load(std::memory_order_relaxed)); + root->setProperty("lastRecordingClipCountReturned", lastReturnedRecordingClipCount.load(std::memory_order_relaxed)); + root->setProperty("playbackTryLockFailureCount", playbackEngine.getTryLockFailureCount()); + root->setProperty("playbackMissingReaderCount", playbackEngine.getMissingReaderCount()); + root->setProperty("lastOverlappingClipCount", playbackEngine.getLastOverlappingClipCount()); + root->setProperty("lastMixedClipCount", playbackEngine.getLastMixedClipCount()); + root->setProperty("lastTrackPlaybackPeak", playbackEngine.getLastTrackPlaybackPeak()); + root->setProperty("playbackTracks", playbackTracks); + return juce::var(root); +} + +juce::String AudioEngine::renderMetronomeToFile(double startTime, double endTime) +{ + // Create output file in the preferred OpenStudio audio directory, while + // still following the legacy Studio13 location if an existing install uses it. + auto audioFolder = getPreferredAppDataDirectory().getChildFile("Audio"); + + if (!audioFolder.exists()) + audioFolder.createDirectory(); + + auto timestamp = juce::Time::getCurrentTime().toMilliseconds(); + auto filename = "Metronome_" + juce::String(timestamp) + ".wav"; + auto outputFile = audioFolder.getChildFile(filename); + + // Create a dedicated Metronome instance for offline rendering + // (to avoid interfering with the real-time metronome) + Metronome renderMetronome; + renderMetronome.prepareToPlay(currentSampleRate, 512); + renderMetronome.setBpm(tempo); + renderMetronome.setTimeSignature(timeSigNumerator, timeSigDenominator); + renderMetronome.setVolume(metronome.getVolume()); + renderMetronome.setAccentBeats(metronome.getAccentBeats()); + + bool success = renderMetronome.renderToFile(outputFile, startTime, endTime); + + if (success) + { + juce::Logger::writeToLog("AudioEngine: Rendered metronome to: " + outputFile.getFullPathName()); + return outputFile.getFullPathName(); + } + + juce::Logger::writeToLog("AudioEngine: Failed to render metronome"); + return {}; +} + +//============================================================================== +// Plugin State Serialization (F2 - Project Save/Load) + +juce::String AudioEngine::getPluginState(const juce::String& trackId, int fxIndex, bool isInputFX) +{ + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + if (trackMap.find(trackId) == trackMap.end()) + return {}; + + auto* track = trackMap[trackId]; + if (!track) + return {}; + + juce::AudioProcessor* processor = nullptr; + + if (isInputFX) + { + if (fxIndex >= 0 && fxIndex < track->getNumInputFX()) + processor = track->getInputFXProcessor(fxIndex); + } + else + { + if (fxIndex >= 0 && fxIndex < track->getNumTrackFX()) + processor = track->getTrackFXProcessor(fxIndex); + } + + if (!processor) + return {}; + + // Get plugin state as memory block + juce::MemoryBlock stateData; + { + const juce::ScopedLock processorLock(processor->getCallbackLock()); + processor->getStateInformation(stateData); + } + + // Convert to Base64 string + return stateData.toBase64Encoding(); +} + +bool AudioEngine::setPluginState(const juce::String& trackId, int fxIndex, bool isInputFX, const juce::String& base64State) +{ + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + if (trackMap.find(trackId) == trackMap.end()) + return false; + + auto* track = trackMap[trackId]; + if (!track) + return false; + + juce::AudioProcessor* processor = nullptr; + + if (isInputFX) + { + if (fxIndex >= 0 && fxIndex < track->getNumInputFX()) + processor = track->getInputFXProcessor(fxIndex); + } + else + { + if (fxIndex >= 0 && fxIndex < track->getNumTrackFX()) + processor = track->getTrackFXProcessor(fxIndex); + } + + if (!processor) + return false; + + // Decode Base64 to memory block + juce::MemoryBlock stateData; + if (!stateData.fromBase64Encoding(base64State)) + return false; + + // Set plugin state + { + const juce::ScopedLock processorLock(processor->getCallbackLock()); + processor->setStateInformation(stateData.getData(), static_cast(stateData.getSize())); + } + + juce::Logger::writeToLog("AudioEngine: Restored plugin state for track " + trackId + + " FX " + juce::String(fxIndex) + " (isInput: " + (isInputFX ? "true" : "false") + ")"); + return true; +} + +juce::String AudioEngine::getMasterPluginState(int fxIndex) +{ + int slotId = 0; + juce::String cachedState; + { + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + if (const auto* slot = findDesiredStageSlot(desiredMasterStageSpec, fxIndex)) + { + slotId = slot->slotId; + cachedState = slot->serializedState; + } + } + + if (slotId == 0) + return {}; + + auto activeStage = std::atomic_load_explicit(&realtimeMasterFXSnapshot, std::memory_order_acquire); + if (const auto* activeSlot = findActiveStageSlot(activeStage, slotId)) + return serialiseProcessorStateToBase64(activeSlot->processor.get()); + + return cachedState; +} + +bool AudioEngine::setMasterPluginState(int fxIndex, const juce::String& base64State) +{ + juce::MemoryBlock stateData; + if (!stateData.fromBase64Encoding(base64State)) + return false; + + DesiredFXStageSpec specCopy; + { + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + specCopy = desiredMasterStageSpec; + } + + syncStageSpecStateFromActive(specCopy, std::atomic_load_explicit(&realtimeMasterFXSnapshot, std::memory_order_acquire)); + auto* slot = findDesiredStageSlot(specCopy, fxIndex); + if (slot == nullptr) + return false; + + slot->serializedState = base64State; + + if (!publishMasterStageSpec(specCopy)) + return false; + + juce::Logger::writeToLog("AudioEngine: Restored master plugin state for FX " + juce::String(fxIndex)); + return true; +} + +//============================================================================== +// FFmpeg Helpers + +juce::File AudioEngine::findFFmpegExe() const +{ + // Search for ffmpeg.exe in common locations relative to the executable + auto exeDir = juce::File::getSpecialLocation(juce::File::currentExecutableFile).getParentDirectory(); + + // 1. Same directory as executable + auto ffmpeg = exeDir.getChildFile("ffmpeg.exe"); + if (ffmpeg.existsAsFile()) return ffmpeg; + + // 2. tools/ subdirectory + ffmpeg = exeDir.getChildFile("tools").getChildFile("ffmpeg.exe"); + if (ffmpeg.existsAsFile()) return ffmpeg; + + // 3. ../tools/ (one level up) + ffmpeg = exeDir.getParentDirectory().getChildFile("tools").getChildFile("ffmpeg.exe"); + if (ffmpeg.existsAsFile()) return ffmpeg; + + // 4. ../../tools/ (two levels up, for build/Studio13_artefacts/Release/) + ffmpeg = exeDir.getParentDirectory().getParentDirectory().getChildFile("tools").getChildFile("ffmpeg.exe"); + if (ffmpeg.existsAsFile()) return ffmpeg; + + // 5. ../../../tools/ (three levels up, for deeper build paths) + ffmpeg = exeDir.getParentDirectory().getParentDirectory().getParentDirectory().getChildFile("tools").getChildFile("ffmpeg.exe"); + if (ffmpeg.existsAsFile()) return ffmpeg; + + return juce::File(); // Not found +} + +bool AudioEngine::convertWithFFmpeg(const juce::File& inputFile, const juce::File& outputFile, + const juce::String& format, double targetSampleRate, int quality) const +{ + auto ffmpeg = findFFmpegExe(); + if (!ffmpeg.existsAsFile()) + { + logToDisk("convertWithFFmpeg: FAIL - ffmpeg.exe not found"); + return false; + } + + // Build ffmpeg command + juce::StringArray args; + args.add(ffmpeg.getFullPathName()); + args.add("-y"); // Overwrite output + args.add("-i"); + args.add(inputFile.getFullPathName()); // Input file + + // Sample rate conversion (if target differs from source) + if (targetSampleRate > 0) + { + args.add("-ar"); + args.add(juce::String((int)targetSampleRate)); + } + + juce::String formatLower = format.toLowerCase(); + + if (formatLower == "mp3") + { + args.add("-codec:a"); + args.add("libmp3lame"); + // quality = bitrate in kbps (128, 192, 256, 320) + int bitrate = (quality > 0) ? quality : 320; + args.add("-b:a"); + args.add(juce::String(bitrate) + "k"); + } + else if (formatLower == "ogg") + { + args.add("-codec:a"); + args.add("libvorbis"); + // quality = vorbis quality level (0-10, default 6) + int q = (quality > 0) ? quality : 6; + args.add("-q:a"); + args.add(juce::String(q)); + } + else + { + // WAV/AIFF/FLAC sample rate conversion only — keep format as-is + // ffmpeg auto-detects output format from extension + } + + args.add(outputFile.getFullPathName()); // Output file + + logToDisk("convertWithFFmpeg: Running: " + args.joinIntoString(" ")); + + // Run ffmpeg as child process + juce::ChildProcess process; + if (!process.start(args)) + { + logToDisk("convertWithFFmpeg: FAIL - could not start ffmpeg process"); + return false; + } + + // Wait for completion (up to 5 minutes for large files) + if (!process.waitForProcessToFinish(300000)) + { + logToDisk("convertWithFFmpeg: FAIL - ffmpeg timed out after 5 minutes"); + process.kill(); + return false; + } + + int exitCode = process.getExitCode(); + if (exitCode != 0) + { + juce::String errOutput = process.readAllProcessOutput(); + logToDisk("convertWithFFmpeg: FAIL - ffmpeg exit code " + juce::String(exitCode) + " output: " + errOutput); + return false; + } + + if (!outputFile.existsAsFile()) + { + logToDisk("convertWithFFmpeg: FAIL - output file not created"); + return false; + } + + logToDisk("convertWithFFmpeg: SUCCESS - " + outputFile.getFullPathName() + + " (" + juce::String(outputFile.getSize() / 1024) + " KB)"); + return true; +} + +//============================================================================== +// Offline Render/Export + +bool AudioEngine::renderProject(const juce::String& source, double startTime, double endTime, + const juce::String& filePath, const juce::String& format, + double renderSampleRate, int bitDepth, int numChannels, + bool normalize, bool addTail, double tailLengthMs) +{ + logToDisk("renderProject: START - file=" + filePath + " format=" + format + + " range=" + juce::String(startTime) + "-" + juce::String(endTime) + + " sr=" + juce::String(renderSampleRate) + " bits=" + juce::String(bitDepth) + + " ch=" + juce::String(numChannels) + + " normalize=" + juce::String(normalize ? "true" : "false") + + " addTail=" + juce::String(addTail ? "true" : "false") + + " tailMs=" + juce::String(tailLengthMs)); + + // ========== 1. Validate inputs ========== + if (endTime <= startTime) + { + logToDisk("renderProject: FAIL - endTime (" + juce::String(endTime) + + ") <= startTime (" + juce::String(startTime) + ")"); + return false; + } + // Use requested sample rate for render if provided, otherwise fall back to device rate. + // PlaybackEngine::fillTrackBuffer() handles per-file SR conversion automatically, + // so rendering at a different rate than the device is fully supported. + double actualSampleRate = (renderSampleRate > 0) ? renderSampleRate : currentSampleRate; + if (actualSampleRate <= 0) actualSampleRate = 44100.0; + if (bitDepth != 16 && bitDepth != 24 && bitDepth != 32) bitDepth = 24; + if (numChannels < 1 || numChannels > 2) numChannels = 2; + + // Determine if we need post-processing (lossy encoding only — SR conversion + // is now handled natively by rendering at the target rate) + juce::String formatLower = format.toLowerCase(); + bool isLossyFormat = (formatLower == "mp3" || formatLower == "ogg"); + bool needsFFmpegPostProcess = isLossyFormat; + + // For lossy formats, bitDepth holds codec quality: + // MP3: bitrate in kbps (128, 192, 256, 320) + // OGG: quality level (1-10) + int codecQuality = isLossyFormat ? bitDepth : 0; + if (isLossyFormat) bitDepth = 24; // Render intermediate WAV at 24-bit + + // Parse stem track filter from source (e.g., "stem:trackId123") + juce::String stemTrackId; + bool isStemRender = source.startsWith("stem:"); + if (isStemRender) + stemTrackId = source.substring(5); // After "stem:" + + logToDisk("renderProject: Using actualSampleRate=" + juce::String(actualSampleRate) + + (actualSampleRate != currentSampleRate ? " (differs from device rate " + juce::String(currentSampleRate) + ")" : " (device rate)")); + if (isLossyFormat) + logToDisk("renderProject: Lossy format=" + formatLower + " codecQuality=" + juce::String(codecQuality)); + if (isStemRender) + logToDisk("renderProject: Stem render for trackId=" + stemTrackId); + + // ========== 2. Stop real-time playback and block audio callback ========== + bool wasPlaying = isPlaying.load(); + bool wasRecording = isRecordMode.load(); + if (wasPlaying) isPlaying = false; + if (wasRecording) isRecordMode = false; + isRendering = true; // Block audio callback from processing FX plugins + juce::Thread::sleep(100); // Let audio callback finish current block + + // ========== 3. Snapshot clip data ========== + auto clipSnapshot = playbackEngine.getClipSnapshot(); + logToDisk("renderProject: Clip snapshot has " + juce::String((int)clipSnapshot.size()) + " clips"); + + if (clipSnapshot.empty()) + { + logToDisk("renderProject: WARNING - No clips in playback engine! Render will be silent."); + } + + // Log each clip for debugging + for (size_t i = 0; i < clipSnapshot.size(); ++i) + { + const auto& clip = clipSnapshot[i]; + logToDisk(" Clip " + juce::String((int)i) + ": track=" + clip.trackId + + " file=" + clip.audioFile.getFileName() + + " start=" + juce::String(clip.startTime) + + " dur=" + juce::String(clip.duration) + + " offset=" + juce::String(clip.offset) + + " active=" + juce::String(clip.isActive ? "true" : "false")); + } + + // ========== 4. Snapshot track params ========== + struct TrackSnapshot { + juce::String id; + float volumeDB; + float pan; + bool muted; + bool soloed; + }; + std::vector trackSnapshots; + bool anySoloed = false; + { + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + for (const auto& trackId : trackOrder) + { + auto it = trackMap.find(trackId); + if (it == trackMap.end() || !it->second) continue; + auto* track = it->second; + TrackSnapshot snap; + snap.id = trackId; + snap.volumeDB = track->getVolume(); + snap.pan = track->getPan(); + snap.muted = track->getMute(); + snap.soloed = track->getSolo(); + if (snap.soloed) anySoloed = true; + trackSnapshots.push_back(snap); + logToDisk(" Track " + trackId + ": vol=" + juce::String(snap.volumeDB) + + "dB pan=" + juce::String(snap.pan) + + " mute=" + juce::String(snap.muted ? "true" : "false") + + " solo=" + juce::String(snap.soloed ? "true" : "false")); + } + } + + logToDisk("renderProject: " + juce::String((int)trackSnapshots.size()) + " tracks, anySoloed=" + juce::String(anySoloed ? "true" : "false")); + + // ========== 5. Create format writer ========== + // For lossy formats (mp3/ogg) or sample rate conversion, render to temp WAV first + juce::File outputFile(filePath); + juce::File renderFile = outputFile; // File we actually write to (may be temp WAV) + + if (needsFFmpegPostProcess) + { + // Create temp WAV file next to the output + renderFile = outputFile.getParentDirectory().getChildFile( + outputFile.getFileNameWithoutExtension() + "_temp_render.wav"); + } + + std::unique_ptr audioFormat; + juce::String renderFormatLower = needsFFmpegPostProcess ? "wav" : formatLower; + + if (renderFormatLower == "wav") + audioFormat = std::make_unique(); + else if (renderFormatLower == "aiff" || renderFormatLower == "aif") + audioFormat = std::make_unique(); + else if (renderFormatLower == "flac") + { + audioFormat = std::make_unique(); + if (bitDepth > 24) bitDepth = 24; // FLAC max 24-bit + } + else + { + logToDisk("renderProject: FAIL - unsupported format: " + format); + isRendering = false; + return false; + } + + renderFile.getParentDirectory().createDirectory(); + if (renderFile.existsAsFile()) + renderFile.deleteFile(); + if (outputFile.existsAsFile()) + outputFile.deleteFile(); + + auto fileStream = std::make_unique(renderFile); + if (fileStream->failedToOpen()) + { + logToDisk("renderProject: FAIL - could not open output file: " + renderFile.getFullPathName()); + isRendering = false; + return false; + } + + // Determine writer channels (always render in stereo internally, downmix to mono if needed) + int writerChannels = numChannels; + + std::unique_ptr writer( + audioFormat->createWriterFor(fileStream.get(), + actualSampleRate, + writerChannels, + bitDepth, + {}, // metadata + 0)); // quality + if (!writer) + { + logToDisk("renderProject: FAIL - could not create writer (sr=" + + juce::String(actualSampleRate) + " bits=" + juce::String(bitDepth) + + " ch=" + juce::String(writerChannels) + ")"); + isRendering = false; + return false; + } + fileStream.release(); // Writer owns the stream now + + // ========== 6. Calculate total samples ========== + double tailSeconds = addTail ? (tailLengthMs / 1000.0) : 0.0; + double totalDuration = (endTime - startTime) + tailSeconds; + juce::int64 totalSamples = (juce::int64)(totalDuration * actualSampleRate); + const int blockSize = 2048; // Larger block size for faster offline rendering + + logToDisk("renderProject: totalDuration=" + juce::String(totalDuration) + + "s totalSamples=" + juce::String(totalSamples) + + " blockSize=" + juce::String(blockSize)); + + // ========== 6b. Save plugin state & prepare plugins for offline rendering ========== + // Plugins were prepared for the device's buffer size (e.g. 128/256). The render + // uses 512-sample blocks. We must re-prepare all FX plugins with the render block + // size, otherwise plugins like Amplitube overflow internal buffers → noise/crash. + // We also reset() to clear stale state from real-time playback. + + // Collect all FX processors that will be used during render + struct PluginStateBackup { + juce::AudioProcessor* processor; + juce::MemoryBlock savedState; + }; + std::vector pluginBackups; + + // Lambda to save state, prepare, and reset a processor for render + auto prepareProcessorForRender = [&](juce::AudioProcessor* proc) { + if (!proc) return; + // Save current state + PluginStateBackup backup; + backup.processor = proc; + proc->getStateInformation(backup.savedState); + pluginBackups.push_back(std::move(backup)); + // Re-prepare for render block size + prepareHostedProcessorForPrecision(proc, actualSampleRate, blockSize, processingPrecisionMode); + proc->reset(); + }; + + // Prepare track FX plugins + for (const auto& snap : trackSnapshots) + { + auto it = trackMap.find(snap.id); + if (it == trackMap.end() || !it->second) continue; + auto* track = it->second; + for (int fx = 0; fx < track->getNumInputFX(); ++fx) + prepareProcessorForRender(track->getInputFXProcessor(fx)); + for (int fx = 0; fx < track->getNumTrackFX(); ++fx) + prepareProcessorForRender(track->getTrackFXProcessor(fx)); + } + + DesiredFXStageSpec renderMasterSpec; + { + const juce::ScopedLock stageLock(mainProcessorGraph->getCallbackLock()); + renderMasterSpec = desiredMasterStageSpec; + } + syncStageSpecStateFromActive(renderMasterSpec, std::atomic_load_explicit(&realtimeMasterFXSnapshot, std::memory_order_acquire)); + + juce::String renderStageError; + auto renderMasterStage = buildActiveFXStage(renderMasterSpec, actualSampleRate, blockSize, processingPrecisionMode, false, renderStageError); + if (!renderMasterStage && !renderMasterSpec.slots.empty()) + { + logToDisk("renderProject: FAIL - could not build master stage: " + renderStageError); + isRendering = false; + return false; + } + + if (renderMasterStage) + { + for (const auto& slot : renderMasterStage->slots) + prepareProcessorForRender(slot.processor.get()); + } + + logToDisk("renderProject: Saved & prepared " + juce::String((int)pluginBackups.size()) + " FX plugins for render"); + + // ========== 7 & 8. Render loop (with optional 2-pass normalization) ========== + int numPasses = normalize ? 2 : 1; + float normGain = 1.0f; + float peakLevel = 0.0f; + + // Dither state (0 = off, 1 = TPDF, 2 = noise-shaped) + int ditherMode = pendingDitherMode_.load(); + pendingDitherMode_ = 0; // Reset for next render call + juce::Random ditherRng; + float ditherErrorState[2] = { 0.0f, 0.0f }; // Per-channel error feedback for noise shaping + + // Check if metronome was enabled for render + bool renderMetronomeAudio = metronome.isEnabled(); + + for (int pass = 0; pass < numPasses; ++pass) + { + logToDisk("renderProject: Pass " + juce::String(pass + 1) + " of " + juce::String(numPasses)); + + // Reset all FX plugins at the start of each pass so they begin from a clean state. + // This is critical for 2-pass normalization: pass 2 must produce identical output + // to pass 1, which requires identical initial plugin state. + for (auto& backup : pluginBackups) + { + if (backup.processor) + { + backup.processor->setStateInformation(backup.savedState.getData(), + (int)backup.savedState.getSize()); + backup.processor->reset(); + } + } + + // Create fresh offline playback engine for each pass (deterministic reads) + PlaybackEngine passPlayback; + passPlayback.setRenderMode(true); // Use Lagrange interpolation for high-quality resampling + for (const auto& clip : clipSnapshot) + { + passPlayback.addClip(clip.audioFile, clip.startTime, clip.duration, + clip.trackId, clip.offset, clip.volumeDB, + clip.fadeIn, clip.fadeOut); + } + + // Create fresh metronome for each pass + Metronome renderMet; + if (renderMetronomeAudio) + { + renderMet.prepareToPlay(actualSampleRate, blockSize); + renderMet.setBpm(tempo); + renderMet.setTimeSignature(timeSigNumerator, timeSigDenominator); + renderMet.setVolume(metronome.getVolume()); + renderMet.setAccentBeats(metronome.getAccentBeats()); + renderMet.setEnabled(true); + } + + if (pass == 1) + { + // Second pass: calculate normalization gain + if (peakLevel > 0.0f) + normGain = 1.0f / peakLevel; + else + normGain = 1.0f; + logToDisk("renderProject: Normalize gain = " + juce::String(normGain) + + " (peak was " + juce::String(peakLevel) + ")"); + } + + juce::int64 samplesRemaining = totalSamples; + double currentTimeSeconds = startTime; + double samplePositionForMetronome = startTime * actualSampleRate; + float passPeak = 0.0f; // Track peak for this pass + + while (samplesRemaining > 0) + { + int samplesThisBlock = (int)std::min((juce::int64)blockSize, samplesRemaining); + + // Master buffer (always stereo internally) + juce::AudioBuffer masterBuffer(2, samplesThisBlock); + masterBuffer.clear(); + juce::AudioBuffer masterBufferDouble(2, samplesThisBlock); + masterBufferDouble.clear(); + const bool renderHybrid64 = processingPrecisionMode == ProcessingPrecisionMode::Hybrid64; + + // Add metronome if enabled + if (renderMetronomeAudio) + { + renderMet.getNextAudioBlock(masterBuffer, samplePositionForMetronome); + if (renderHybrid64) + { + for (int ch = 0; ch < 2; ++ch) + { + auto* src = masterBuffer.getReadPointer(ch); + auto* dest = masterBufferDouble.getWritePointer(ch); + for (int sample = 0; sample < samplesThisBlock; ++sample) + dest[sample] = static_cast(src[sample]); + } + } + } + + // Process each track + for (const auto& snap : trackSnapshots) + { + // Stem rendering: only process the specified track + if (isStemRender && snap.id != stemTrackId) continue; + + // Skip muted tracks (unless stem render — always render the target track) + if (!isStemRender && snap.muted) continue; + // If any track is soloed, skip non-soloed tracks (unless stem render) + if (!isStemRender && anySoloed && !snap.soloed) continue; + + // Fill track buffer from clips + juce::AudioBuffer trackBuffer(2, samplesThisBlock); + trackBuffer.clear(); + passPlayback.fillTrackBuffer(snap.id, trackBuffer, currentTimeSeconds, + samplesThisBlock, actualSampleRate); + + // Process track FX chain BEFORE volume/pan (matches real-time signal flow) + // Channel-safe: expand buffer if plugin needs more channels than our stereo buffer + { + auto it = trackMap.find(snap.id); + if (it != trackMap.end() && it->second) + { + auto* track = it->second; + juce::MidiBuffer midiMessages = buildTrackMidiBlock(snap.id, currentTimeSeconds, + samplesThisBlock, actualSampleRate, true); + if (auto* instrument = track->getInstrument()) + instrument->setPlayHead(this); + int numInputFX = track->getNumInputFX(); + int numTrackFX = track->getNumTrackFX(); + const bool hasInstrument = track->getTrackType() == TrackType::Instrument + && track->getInstrument() != nullptr; + if (numInputFX > 0 || numTrackFX > 0 || hasInstrument) + { + auto safeRenderFX = [&](juce::AudioProcessor* proc) { + int pluginCh = juce::jmax(proc->getTotalNumInputChannels(), + proc->getTotalNumOutputChannels()); + const bool useDoublePrecision = renderHybrid64 + && proc->supportsDoublePrecisionProcessing(); + if (useDoublePrecision) + { + juce::AudioBuffer doubleBuffer(pluginCh, samplesThisBlock); + doubleBuffer.clear(); + for (int ch = 0; ch < juce::jmin(trackBuffer.getNumChannels(), pluginCh); ++ch) + { + auto* src = trackBuffer.getReadPointer(ch); + auto* dest = doubleBuffer.getWritePointer(ch); + for (int sample = 0; sample < samplesThisBlock; ++sample) + dest[sample] = static_cast(src[sample]); + } + + proc->processBlock(doubleBuffer, midiMessages); + + const int availableDoubleChannels = juce::jmax(1, pluginCh); + for (int ch = 0; ch < trackBuffer.getNumChannels(); ++ch) + { + auto* dest = trackBuffer.getWritePointer(ch); + auto* src = doubleBuffer.getReadPointer(juce::jmin(ch, availableDoubleChannels - 1)); + for (int sample = 0; sample < samplesThisBlock; ++sample) + dest[sample] = static_cast(src[sample]); + } + } + else if (pluginCh <= trackBuffer.getNumChannels()) + { + proc->processBlock(trackBuffer, midiMessages); + // Mono plugin on stereo track: duplicate output to all channels + int outCh = proc->getTotalNumOutputChannels(); + if (outCh > 0 && outCh < trackBuffer.getNumChannels()) + { + for (int ch = outCh; ch < trackBuffer.getNumChannels(); ++ch) + trackBuffer.copyFrom (ch, 0, trackBuffer, 0, 0, samplesThisBlock); + } + } + else + { + // Expand buffer for this plugin (render thread — allocation OK) + juce::AudioBuffer expanded(pluginCh, samplesThisBlock); + expanded.clear(); + for (int ch = 0; ch < trackBuffer.getNumChannels(); ++ch) + expanded.copyFrom(ch, 0, trackBuffer, ch, 0, samplesThisBlock); + proc->processBlock(expanded, midiMessages); + for (int ch = 0; ch < trackBuffer.getNumChannels(); ++ch) + trackBuffer.copyFrom(ch, 0, expanded, ch, 0, samplesThisBlock); + } + }; + for (int fx = 0; fx < numInputFX; ++fx) + { + auto* proc = track->getInputFXProcessor(fx); + if (proc) safeRenderFX(proc); + } + if (hasInstrument) + safeRenderFX(track->getInstrument()); + for (int fx = 0; fx < numTrackFX; ++fx) + { + auto* proc = track->getTrackFXProcessor(fx); + if (proc) safeRenderFX(proc); + } + } + } + } + + // Apply per-track volume/pan AFTER FX (matches real-time signal flow) + float volumeGain = juce::Decibels::decibelsToGain(snap.volumeDB); + float leftGain = 1.0f; + float rightGain = 1.0f; + computePanLawGains(currentPanLaw, snap.pan, volumeGain, leftGain, rightGain); + + juce::FloatVectorOperations::multiply(trackBuffer.getWritePointer(0), leftGain, samplesThisBlock); + juce::FloatVectorOperations::multiply(trackBuffer.getWritePointer(1), rightGain, samplesThisBlock); + + // Mix into master buffer + for (int ch = 0; ch < 2; ++ch) + { + if (renderHybrid64) + { + auto* dest = masterBufferDouble.getWritePointer(ch); + auto* src = trackBuffer.getReadPointer(ch); + for (int sample = 0; sample < samplesThisBlock; ++sample) + dest[sample] += static_cast(src[sample]); + } + else + { + masterBuffer.addFrom(ch, 0, trackBuffer, ch, 0, samplesThisBlock); + } + } + } + + // Process master FX chain (channel-safe, render thread — allocation OK) + // Skip master FX for stem renders (export raw track output) + if (!isStemRender && renderMasterStage && !renderMasterStage->slots.empty()) + { + juce::MidiBuffer dummyMidi; + for (const auto& slot : renderMasterStage->slots) + { + if (!slot.processor || slot.bypassed) + continue; + + auto* proc = slot.processor.get(); + int pluginCh = juce::jmax(proc->getTotalNumInputChannels(), + proc->getTotalNumOutputChannels()); + const bool useDoublePrecision = renderHybrid64 + && !slot.forceFloat + && slot.supportsDouble; + if (useDoublePrecision) + { + juce::AudioBuffer expanded(juce::jmax(2, pluginCh), samplesThisBlock); + expanded.clear(); + for (int ch = 0; ch < juce::jmin(masterBufferDouble.getNumChannels(), expanded.getNumChannels()); ++ch) + { + auto* src = masterBufferDouble.getReadPointer(ch); + auto* dest = expanded.getWritePointer(ch); + for (int sample = 0; sample < samplesThisBlock; ++sample) + dest[sample] = src[sample]; + } + + proc->processBlock(expanded, dummyMidi); + + for (int ch = 0; ch < masterBufferDouble.getNumChannels(); ++ch) + { + auto* dest = masterBufferDouble.getWritePointer(ch); + auto* src = expanded.getReadPointer(juce::jmin(ch, expanded.getNumChannels() - 1)); + for (int sample = 0; sample < samplesThisBlock; ++sample) + dest[sample] = src[sample]; + } + } + else if (pluginCh <= masterBuffer.getNumChannels()) + { + if (renderHybrid64) + copyDoubleBufferToFloatBuffer(masterBufferDouble, masterBuffer, masterBuffer.getNumChannels(), samplesThisBlock); + proc->processBlock(masterBuffer, dummyMidi); + if (renderHybrid64) + copyFloatBufferToDoubleBuffer(masterBuffer, masterBufferDouble, masterBuffer.getNumChannels(), samplesThisBlock); + } + else + { + if (renderHybrid64) + copyDoubleBufferToFloatBuffer(masterBufferDouble, masterBuffer, masterBuffer.getNumChannels(), samplesThisBlock); + juce::AudioBuffer expanded(pluginCh, samplesThisBlock); + expanded.clear(); + for (int ch = 0; ch < masterBuffer.getNumChannels(); ++ch) + expanded.copyFrom(ch, 0, masterBuffer, ch, 0, samplesThisBlock); + proc->processBlock(expanded, dummyMidi); + for (int ch = 0; ch < masterBuffer.getNumChannels(); ++ch) + masterBuffer.copyFrom(ch, 0, expanded, ch, 0, samplesThisBlock); + if (renderHybrid64) + copyFloatBufferToDoubleBuffer(masterBuffer, masterBufferDouble, masterBuffer.getNumChannels(), samplesThisBlock); + } + } + } + + // Apply master pan (constant power law) — skip for stem renders + if (!isStemRender) + { + float leftGain = 1.0f; + float rightGain = 1.0f; + computePanLawGains(currentPanLaw, masterPan, 1.0f, leftGain, rightGain); + + if (renderHybrid64) + applyStereoPanToDoubleBuffer(masterBufferDouble, samplesThisBlock, leftGain, rightGain); + else + { + juce::FloatVectorOperations::multiply(masterBuffer.getWritePointer(0), leftGain, samplesThisBlock); + juce::FloatVectorOperations::multiply(masterBuffer.getWritePointer(1), rightGain, samplesThisBlock); + } + } + + // Apply master volume — skip for stem renders + if (!isStemRender) + { + if (renderHybrid64) + { + applyGainToDoubleBuffer(masterBufferDouble, 2, samplesThisBlock, masterVolume); + } + else + { + for (int ch = 0; ch < 2; ++ch) + juce::FloatVectorOperations::multiply(masterBuffer.getWritePointer(ch), masterVolume, samplesThisBlock); + } + } + + // Apply normalization gain (pass 2 only) + if (pass == 1 && normGain != 1.0f) + { + if (renderHybrid64) + { + applyGainToDoubleBuffer(masterBufferDouble, 2, samplesThisBlock, normGain); + } + else + { + for (int ch = 0; ch < 2; ++ch) + juce::FloatVectorOperations::multiply(masterBuffer.getWritePointer(ch), normGain, samplesThisBlock); + } + } + + if (!isStemRender && masterMono.load(std::memory_order_relaxed)) + { + if (renderHybrid64) + downmixDoubleBufferToMono(masterBufferDouble, samplesThisBlock); + else + { + for (int sample = 0; sample < samplesThisBlock; ++sample) + { + float mono = (masterBuffer.getSample(0, sample) + masterBuffer.getSample(1, sample)) * 0.5f; + masterBuffer.setSample(0, sample, mono); + masterBuffer.setSample(1, sample, mono); + } + } + } + + // Measure peak level + if (renderHybrid64) + { + passPeak = juce::jmax(passPeak, findPeakInDoubleBuffer(masterBufferDouble, 2, samplesThisBlock)); + } + else + { + for (int ch = 0; ch < 2; ++ch) + { + auto range = masterBuffer.findMinMax(ch, 0, samplesThisBlock); + float chPeak = std::max(std::abs(range.getStart()), std::abs(range.getEnd())); + if (chPeak > passPeak) passPeak = chPeak; + } + } + + if (pass == 0 && normalize) + { + // Pass 1: accumulate peak for normalization + if (passPeak > peakLevel) peakLevel = passPeak; + } + + // Write to file (final pass only) + bool isFinalPass = (pass == numPasses - 1); + if (isFinalPass) + { + if (renderHybrid64) + copyDoubleBufferToFloatBuffer(masterBufferDouble, masterBuffer, 2, samplesThisBlock); + + // Apply dither if requested (before bit-depth truncation by the writer) + if (ditherMode > 0 && bitDepth < 32) + { + // ditherMode 1 = TPDF, 2 = noise-shaped (first-order high-pass) + float ditherAmp = 1.0f / static_cast(1 << (bitDepth - 1)); // 1 LSB + for (int ch = 0; ch < masterBuffer.getNumChannels(); ++ch) + { + float* data = masterBuffer.getWritePointer(ch); + for (int s = 0; s < samplesThisBlock; ++s) + { + // TPDF: two uniform randoms → triangular PDF + float r1 = (ditherRng.nextFloat() * 2.0f - 1.0f) * ditherAmp; + float r2 = (ditherRng.nextFloat() * 2.0f - 1.0f) * ditherAmp; + float noise = r1 + r2; + + if (ditherMode == 2) + { + // First-order noise shaping: subtract previous quantization error + float shaped = noise - ditherErrorState[ch]; + float original = data[s]; + data[s] = original + shaped; + // Quantize to calculate error for next sample + float scale = static_cast(1 << (bitDepth - 1)); + float quantized = std::round(data[s] * scale) / scale; + ditherErrorState[ch] = quantized - original; + } + else + { + data[s] += noise; + } + } + } + } + + if (numChannels == 1) + { + // Mono downmix: average L+R + juce::AudioBuffer monoBuffer(1, samplesThisBlock); + monoBuffer.clear(); + monoBuffer.addFrom(0, 0, masterBuffer, 0, 0, samplesThisBlock, 0.5f); + monoBuffer.addFrom(0, 0, masterBuffer, 1, 0, samplesThisBlock, 0.5f); + writer->writeFromAudioSampleBuffer(monoBuffer, 0, samplesThisBlock); + } + else + { + writer->writeFromAudioSampleBuffer(masterBuffer, 0, samplesThisBlock); + } + } + + currentTimeSeconds += (double)samplesThisBlock / actualSampleRate; + samplePositionForMetronome += samplesThisBlock; + samplesRemaining -= samplesThisBlock; + } + + logToDisk("renderProject: Pass " + juce::String(pass + 1) + " complete. Peak level: " + juce::String(passPeak)); + } + + // Flush and close + writer.reset(); + + // ========== 9. FFmpeg post-processing (lossy encoding only) ========== + if (needsFFmpegPostProcess) + { + logToDisk("renderProject: Starting FFmpeg post-processing (lossy encoding)..."); + // SR conversion is already handled natively — no need to pass targetSR here + bool ffmpegOk = convertWithFFmpeg(renderFile, outputFile, formatLower, 0, codecQuality); + + // Clean up temp file + renderFile.deleteFile(); + + if (!ffmpegOk) + { + logToDisk("renderProject: FAIL - FFmpeg post-processing failed"); + // Restore plugins before returning (clamp max-block to at least 512) + for (auto& backup : pluginBackups) + { + if (backup.processor) + { + backup.processor->prepareToPlay(currentSampleRate, juce::jmax(currentBlockSize, 512)); + backup.processor->setStateInformation(backup.savedState.getData(), + (int)backup.savedState.getSize()); + backup.processor->reset(); + } + } + isRendering = false; + return false; + } + } + + // ========== 10. Restore plugin state for real-time playback ========== + // Re-prepare all FX plugins for the device's buffer size and restore their + // saved state so real-time playback continues as if render never happened. + // Clamp max-block to at least 512 (same rationale as track FX preparation). + for (auto& backup : pluginBackups) + { + if (backup.processor) + { + backup.processor->prepareToPlay(currentSampleRate, juce::jmax(currentBlockSize, 512)); + backup.processor->setStateInformation(backup.savedState.getData(), + (int)backup.savedState.getSize()); + backup.processor->reset(); + } + } + logToDisk("renderProject: Restored " + juce::String((int)pluginBackups.size()) + " FX plugins for real-time"); + + // Re-enable audio callback + isRendering = false; + + logToDisk("renderProject: SUCCESS - " + outputFile.getFullPathName() + + " (" + juce::String(outputFile.getSize() / 1024) + " KB)"); + + return true; +} + +bool AudioEngine::renderProjectWithDither(const juce::String& source, double startTime, double endTime, + const juce::String& filePath, const juce::String& format, + double renderSampleRate, int bitDepth, int numChannels, + bool normalize, bool addTail, double tailLengthMs, + const juce::String& ditherType) +{ + // Map dither type string to mode: "tpdf" → 1, "shaped" → 2, else → 0 + if (ditherType == "tpdf") + pendingDitherMode_ = 1; + else if (ditherType == "shaped") + pendingDitherMode_ = 2; + else + pendingDitherMode_ = 0; + + return renderProject(source, startTime, endTime, filePath, format, + renderSampleRate, bitDepth, numChannels, + normalize, addTail, tailLengthMs); +} //============================================================================== -// FX Management (Phase 3) +// Automation (Phase 1.1) -void AudioEngine::scanForPlugins() +static AutomationList* getAutomationListForParam(TrackProcessor* track, const juce::String& parameterId) { - juce::Logger::writeToLog("AudioEngine: Scanning for plugins..."); - pluginManager.scanForPlugins(); + if (parameterId == "volume") + return &track->getVolumeAutomation(); + if (parameterId == "pan") + return &track->getPanAutomation(); + // Future: plugin parameter automation would go here + return nullptr; } -juce::var AudioEngine::getAvailablePlugins() +static AutomationMode parseAutomationMode(const juce::String& modeStr) { - auto plugins = pluginManager.getAvailablePlugins(); + if (modeStr == "read") return AutomationMode::Read; + if (modeStr == "write") return AutomationMode::Write; + if (modeStr == "touch") return AutomationMode::Touch; + if (modeStr == "latch") return AutomationMode::Latch; + return AutomationMode::Off; +} - juce::Array pluginList; - for (const auto& plugin : plugins) +static juce::String automationModeToString(AutomationMode mode) +{ + switch (mode) { - auto* pluginObj = new juce::DynamicObject(); - pluginObj->setProperty("name", plugin.name); - pluginObj->setProperty("manufacturer", plugin.manufacturerName); - pluginObj->setProperty("category", plugin.category); - pluginObj->setProperty("fileOrIdentifier", plugin.fileOrIdentifier); - pluginObj->setProperty("isInstrument", plugin.isInstrument); - - // VST3 Snapshot lookup: check bundle for Contents/Resources/Snapshots/*.png - juce::File pluginPath(plugin.fileOrIdentifier); - if (pluginPath.isDirectory() && pluginPath.getFileExtension() == ".vst3") - { - juce::File snapshotsDir = pluginPath.getChildFile("Contents") - .getChildFile("Resources") - .getChildFile("Snapshots"); - if (snapshotsDir.isDirectory()) - { - juce::Array pngFiles; - snapshotsDir.findChildFiles(pngFiles, juce::File::findFiles, false, "*.png"); - if (pngFiles.size() > 0) - { - juce::MemoryBlock imageData; - if (pngFiles[0].loadFileAsData(imageData)) - { - juce::String base64 = juce::Base64::toBase64(imageData.getData(), - imageData.getSize()); - pluginObj->setProperty("snapshot", - juce::String("data:image/png;base64,") + base64); - } - } - } - } - - pluginList.add(juce::var(pluginObj)); + case AutomationMode::Read: return "read"; + case AutomationMode::Write: return "write"; + case AutomationMode::Touch: return "touch"; + case AutomationMode::Latch: return "latch"; + case AutomationMode::Off: + default: return "off"; } - - juce::Logger::writeToLog("AudioEngine: Returning " + juce::String(pluginList.size()) + " plugins"); - return pluginList; } -bool AudioEngine::addTrackInputFX(const juce::String& trackId, const juce::String& pluginPath, bool openEditor) +void AudioEngine::setAutomationPoints(const juce::String& trackId, const juce::String& parameterId, + const juce::String& pointsJSON) { - // Load plugin BEFORE acquiring the lock (can take hundreds of ms). - // Use actual device sample rate so the plugin initialises at the correct rate. - // IMPORTANT: Use at least 512 for the max-block-size hint — ASIO buffers can be - // as small as 32 samples, but prepareToPlay(sr, 32) forces plugins like Amplitube - // to resize their internal DSP (FFT/convolution/cab sim) for tiny blocks, producing - // crackling and distortion. The plugin can still process 32-sample blocks fine when - // prepared with a larger maximumExpectedSamplesPerBlock. - double sr = currentSampleRate > 0 ? currentSampleRate : 44100.0; - int bs = juce::jmax(currentBlockSize > 0 ? currentBlockSize : 512, 512); - auto plugin = pluginManager.loadPluginFromFile(pluginPath, sr, bs); - if (!plugin) - return false; - - // Provide tempo/position info to the plugin - plugin->setPlayHead (this); - - bool success = false; - int fxIndex = -1; + // Master automation special case + AutomationList* list = nullptr; + if (trackId == "master") + { + if (parameterId == "volume") list = &masterVolumeAutomation; + else if (parameterId == "pan") list = &masterPanAutomation; + if (!list) return; + } + else { - // Hold the callback lock while modifying the FX node vectors - // (audio thread iterates these in processBlock) - const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); auto it = trackMap.find(trackId); - if (it != trackMap.end() && it->second) - { - success = it->second->addInputFX(std::move(plugin), sr, bs); - if (success) - fxIndex = it->second->getNumInputFX() - 1; - } + if (it == trackMap.end() || !it->second) + return; + list = getAutomationListForParam(it->second, parameterId); + if (!list) + return; } - if (success && fxIndex >= 0) + // Parse JSON array of { time: , value: } + auto parsed = juce::JSON::parse(pointsJSON); + if (!parsed.isArray()) + return; + + auto* arr = parsed.getArray(); + std::vector points; + points.reserve(static_cast(arr->size())); + + for (const auto& item : *arr) { - if (openEditor) - openPluginEditor(trackId, fxIndex, true); - juce::Logger::writeToLog("AudioEngine: Added input FX" + juce::String(openEditor ? " and opened editor" : "")); + double timeSec = item.getProperty("time", 0.0); + float value = static_cast(static_cast(item.getProperty("value", 0.0))); + // Convert seconds to samples for the audio thread + double timeSamples = timeSec * currentSampleRate; + points.push_back({ timeSamples, value }); } - return success; + + list->setPoints(std::move(points)); + + juce::Logger::writeToLog("AudioEngine: Set " + juce::String(static_cast(points.size())) + + " automation points for track " + trackId + " param " + parameterId); } -bool AudioEngine::addTrackFX(const juce::String& trackId, const juce::String& pluginPath, bool openEditor) +void AudioEngine::setAutomationMode(const juce::String& trackId, const juce::String& parameterId, + const juce::String& modeStr) { - // Load plugin BEFORE acquiring the lock (can take hundreds of ms). - // Use actual device sample rate so the plugin initialises at the correct rate. - // IMPORTANT: Clamp max-block-size to at least 512 — same rationale as addTrackInputFX. - double sr = currentSampleRate > 0 ? currentSampleRate : 44100.0; - int bs = juce::jmax(currentBlockSize > 0 ? currentBlockSize : 512, 512); + AutomationList* list = nullptr; + if (trackId == "master") + { + if (parameterId == "volume") list = &masterVolumeAutomation; + else if (parameterId == "pan") list = &masterPanAutomation; + if (!list) return; - logToDisk("AudioEngine::addTrackFX DIAGNOSTIC"); - logToDisk(" currentSampleRate=" + juce::String(currentSampleRate) + - " currentBlockSize=" + juce::String(currentBlockSize)); - logToDisk(" creating plugin at sr=" + juce::String(sr) + " bs=" + juce::String(bs)); + auto mode = parseAutomationMode(modeStr); + list->setMode(mode); - auto plugin = pluginManager.loadPluginFromFile(pluginPath, sr, bs); - if (!plugin) - return false; + if (mode != AutomationMode::Off) + { + if (parameterId == "volume") + list->setDefaultValue(masterVolume); + else if (parameterId == "pan") + list->setDefaultValue(masterPan); + } + } + else + { + auto it = trackMap.find(trackId); + if (it == trackMap.end() || !it->second) + return; - logToDisk(" plugin created: " + plugin->getName() + - " inCh=" + juce::String(plugin->getTotalNumInputChannels()) + - " outCh=" + juce::String(plugin->getTotalNumOutputChannels()) + - " pluginSr=" + juce::String(plugin->getSampleRate()) + - " pluginBs=" + juce::String(plugin->getBlockSize())); + list = getAutomationListForParam(it->second, parameterId); + if (!list) + return; - // Provide tempo/position info to the plugin - plugin->setPlayHead (this); + auto mode = parseAutomationMode(modeStr); + list->setMode(mode); - bool success = false; - int fxIndex = -1; - { - // Hold the callback lock while modifying the FX node vectors - // (audio thread iterates these in processBlock) - const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); - auto it = trackMap.find(trackId); - if (it != trackMap.end() && it->second) + if (mode != AutomationMode::Off) { - success = it->second->addTrackFX(std::move(plugin), sr, bs); - if (success) - fxIndex = it->second->getNumTrackFX() - 1; + if (parameterId == "volume") + list->setDefaultValue(it->second->getVolume()); + else if (parameterId == "pan") + list->setDefaultValue(it->second->getPan()); } } - if (success && fxIndex >= 0) - { - if (openEditor) - openPluginEditor(trackId, fxIndex, false); - juce::Logger::writeToLog("AudioEngine: Added track FX" + juce::String(openEditor ? " and opened editor" : "")); - } - return success; + juce::Logger::writeToLog("AudioEngine: Set automation mode for track " + trackId + + " param " + parameterId + " = " + modeStr); } -//============================================================================== -// Plugin Editor Windows (Phase 3) - -void AudioEngine::openPluginEditor(const juce::String& trackId, int fxIndex, bool isInputFX) +juce::String AudioEngine::getAutomationMode(const juce::String& trackId, const juce::String& parameterId) { - if (trackMap.find(trackId) == trackMap.end()) - return; - - auto* track = trackMap[trackId]; - if (!track) - return; - - juce::AudioProcessor* processor = nullptr; - juce::String windowTitle; - - // Calculate display index (1-based) - int displayIndex = getTrackIndex(trackId) + 1; - - if (isInputFX) + AutomationList* list = nullptr; + if (trackId == "master") { - if (fxIndex >= 0 && fxIndex < track->getNumInputFX()) - { - processor = track->getInputFXProcessor(fxIndex); - windowTitle = "Track " + juce::String(displayIndex) + " - Input FX " + juce::String(fxIndex + 1); - } + if (parameterId == "volume") list = &masterVolumeAutomation; + else if (parameterId == "pan") list = &masterPanAutomation; } else { - if (fxIndex >= 0 && fxIndex < track->getNumTrackFX()) - { - processor = track->getTrackFXProcessor(fxIndex); - windowTitle = "Track " + juce::String(displayIndex) + " - Track FX " + juce::String(fxIndex + 1); - } - } - - if (processor) - { - pluginWindowManager.openEditor(processor, windowTitle); + auto it = trackMap.find(trackId); + if (it == trackMap.end() || !it->second) + return "off"; + list = getAutomationListForParam(it->second, parameterId); } + if (!list) + return "off"; + + return automationModeToString(list->getMode()); } -void AudioEngine::openInstrumentEditor(const juce::String& trackId) +void AudioEngine::clearAutomation(const juce::String& trackId, const juce::String& parameterId) { - if (trackMap.find(trackId) == trackMap.end()) - return; - - auto* track = trackMap[trackId]; - if (!track) - return; - - auto* instrument = track->getInstrument(); - if (instrument) + AutomationList* list = nullptr; + if (trackId == "master") { - int displayIndex = getTrackIndex(trackId) + 1; - juce::String windowTitle = "Track " + juce::String(displayIndex) + " - " + instrument->getName(); - pluginWindowManager.openEditor(instrument, windowTitle); + if (parameterId == "volume") list = &masterVolumeAutomation; + else if (parameterId == "pan") list = &masterPanAutomation; + } + else + { + auto it = trackMap.find(trackId); + if (it == trackMap.end() || !it->second) return; + list = getAutomationListForParam(it->second, parameterId); } + if (list) + list->clear(); } -void AudioEngine::closePluginEditor(const juce::String& trackId, int fxIndex, bool isInputFX) +void AudioEngine::beginTouchAutomation(const juce::String& trackId, const juce::String& parameterId) { - if (trackMap.find(trackId) == trackMap.end()) - return; - - auto* track = trackMap[trackId]; - if (!track) - return; - - juce::AudioProcessor* processor = nullptr; - - if (isInputFX) + AutomationList* list = nullptr; + if (trackId == "master") { - if (fxIndex >= 0 && fxIndex < track->getNumInputFX()) - processor = track->getInputFXProcessor(fxIndex); + if (parameterId == "volume") list = &masterVolumeAutomation; + else if (parameterId == "pan") list = &masterPanAutomation; } else { - if (fxIndex >= 0 && fxIndex < track->getNumTrackFX()) - processor = track->getTrackFXProcessor(fxIndex); + auto it = trackMap.find(trackId); + if (it == trackMap.end() || !it->second) return; + list = getAutomationListForParam(it->second, parameterId); } - - if (processor) + if (list) + list->beginTouch(); +} + +void AudioEngine::endTouchAutomation(const juce::String& trackId, const juce::String& parameterId) +{ + AutomationList* list = nullptr; + if (trackId == "master") { - pluginWindowManager.closeEditor(processor); - juce::Logger::writeToLog("AudioEngine: Closed plugin editor"); + if (parameterId == "volume") list = &masterVolumeAutomation; + else if (parameterId == "pan") list = &masterPanAutomation; + } + else + { + auto it = trackMap.find(trackId); + if (it == trackMap.end() || !it->second) return; + list = getAutomationListForParam(it->second, parameterId); } + if (list) + list->endTouch(); } //============================================================================== -// Get loaded plugins info +// Tempo Map (Phase 1.2) -juce::var AudioEngine::getTrackInputFX(const juce::String& trackId) +void AudioEngine::setTempoMarkers(const juce::String& markersJSON) { - juce::Array fxList; - - if (trackMap.find(trackId) == trackMap.end()) - return fxList; - - auto* track = trackMap[trackId]; - if (!track) - return fxList; + auto parsed = juce::JSON::parse(markersJSON); + if (!parsed.isArray()) + return; - int numFX = track->getNumInputFX(); - for (int i = 0; i < numFX; ++i) + std::vector newMarkers; + auto* arr = parsed.getArray(); + for (const auto& item : *arr) { - auto* processor = track->getInputFXProcessor(i); - if (processor) + if (auto* obj = item.getDynamicObject()) { - juce::DynamicObject::Ptr fxInfo = new juce::DynamicObject(); - fxInfo->setProperty("index", i); - fxInfo->setProperty("name", processor->getName()); - // Include plugin file path for save/restore - if (auto* pluginInstance = dynamic_cast(processor)) - { - auto desc = pluginInstance->getPluginDescription(); - fxInfo->setProperty("pluginPath", desc.fileOrIdentifier); - } - fxList.add(juce::var(fxInfo.get())); + double t = obj->getProperty("time"); + double b = obj->getProperty("tempo"); + if (b > 0.0) + newMarkers.push_back({ t, b }); } } - return fxList; + // Sort by time + std::sort(newMarkers.begin(), newMarkers.end(), + [](const TempoMarker& a, const TempoMarker& b) { return a.timeSeconds < b.timeSeconds; }); + + { + const juce::ScopedLock sl(tempoMapLock); + tempoMarkers = std::move(newMarkers); + } } -juce::var AudioEngine::getTrackFX(const juce::String& trackId) +double AudioEngine::getTempoAtTime(double timeSeconds) const { - juce::Array fxList; + const juce::ScopedTryLock stl(tempoMapLock); + if (!stl.isLocked() || tempoMarkers.empty()) + return tempo; // Fallback to global BPM - if (trackMap.find(trackId) == trackMap.end()) - return fxList; + // Binary search for the last marker at or before timeSeconds + // std::upper_bound gives first marker AFTER timeSeconds, so decrement by 1 + auto it = std::upper_bound(tempoMarkers.begin(), tempoMarkers.end(), timeSeconds, + [](double t, const TempoMarker& m) { return t < m.timeSeconds; }); - auto* track = trackMap[trackId]; - if (!track) - return fxList; + if (it == tempoMarkers.begin()) + return tempo; // Before first marker — use global BPM - int numFX = track->getNumTrackFX(); - for (int i = 0; i < numFX; ++i) - { - auto* processor = track->getTrackFXProcessor(i); - if (processor) - { - juce::DynamicObject::Ptr fxInfo = new juce::DynamicObject(); - fxInfo->setProperty("index", i); - fxInfo->setProperty("name", processor->getName()); - // Include plugin file path for save/restore - if (auto* pluginInstance = dynamic_cast(processor)) - { - auto desc = pluginInstance->getPluginDescription(); - fxInfo->setProperty("pluginPath", desc.fileOrIdentifier); - } - fxList.add(juce::var(fxInfo.get())); - } - } + --it; + return it->bpm; +} + +void AudioEngine::clearTempoMarkers() +{ + const juce::ScopedLock sl(tempoMapLock); + tempoMarkers.clear(); +} + +//============================================================================== +// Control Surface Callbacks (Phase 3.10) + +void AudioEngine::onControlSurfaceTrackVolume(const juce::String& trackId, float value01) +{ + // Convert 0..1 to dB: -60 to +6 dB range + float db = (value01 <= 0.0f) ? -60.0f : (value01 * 66.0f - 60.0f); + auto it = trackMap.find(trackId); + if (it != trackMap.end() && it->second) + it->second->setVolume(db); +} - return fxList; +void AudioEngine::onControlSurfaceTrackPan(const juce::String& trackId, float valueMinus1To1) +{ + auto it = trackMap.find(trackId); + if (it != trackMap.end() && it->second) + it->second->setPan(valueMinus1To1); } -void AudioEngine::removeTrackInputFX(const juce::String& trackId, int fxIndex) +void AudioEngine::onControlSurfaceTrackMute(const juce::String& trackId, bool muted) { - const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); auto it = trackMap.find(trackId); if (it != trackMap.end() && it->second) - { - it->second->removeInputFX(fxIndex); - juce::Logger::writeToLog("AudioEngine: Removed input FX " + juce::String(fxIndex) + " from track " + trackId); - } + it->second->setMute(muted); } -void AudioEngine::removeTrackFX(const juce::String& trackId, int fxIndex) +void AudioEngine::onControlSurfaceTrackSolo(const juce::String& trackId, bool soloed) { - const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); auto it = trackMap.find(trackId); if (it != trackMap.end() && it->second) { - it->second->removeTrackFX(fxIndex); - juce::Logger::writeToLog("AudioEngine: Removed track FX " + juce::String(fxIndex) + " from track " + trackId); + it->second->setSolo(soloed); + // Recalculate cachedAnySoloed + bool anySoloed = false; + for (const auto& pair : trackMap) + if (pair.second && pair.second->getSolo()) anySoloed = true; + cachedAnySoloed = anySoloed; } } -void AudioEngine::bypassTrackInputFX(const juce::String& trackId, int fxIndex, bool bypassed) +void AudioEngine::onControlSurfaceTrackRecordArm(const juce::String& trackId, bool armed) { - const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); auto it = trackMap.find(trackId); if (it != trackMap.end() && it->second) - { - it->second->bypassInputFX(fxIndex, bypassed); - juce::Logger::writeToLog("AudioEngine: " + juce::String(bypassed ? "Bypassed" : "Unbypassed") + - " input FX " + juce::String(fxIndex) + " on track " + trackId); - } + it->second->setRecordArmed(armed); } -void AudioEngine::bypassTrackFX(const juce::String& trackId, int fxIndex, bool bypassed) +void AudioEngine::onControlSurfaceTransportPlay() +{ + isPlaying = true; +} + +void AudioEngine::onControlSurfaceTransportStop() +{ + isPlaying = false; +} + +void AudioEngine::onControlSurfaceTransportRecord() +{ + isRecordMode = !isRecordMode.load(); +} + +void AudioEngine::onControlSurfaceMasterVolume(float value01) +{ + float db = (value01 <= 0.0f) ? -60.0f : (value01 * 66.0f - 60.0f); + masterVolume = juce::Decibels::decibelsToGain(db); +} + +float AudioEngine::getTrackVolume01(const juce::String& trackId) const { - const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); auto it = trackMap.find(trackId); if (it != trackMap.end() && it->second) { - it->second->bypassTrackFX(fxIndex, bypassed); - juce::Logger::writeToLog("AudioEngine: " + juce::String(bypassed ? "Bypassed" : "Unbypassed") + - " track FX " + juce::String(fxIndex) + " on track " + trackId); + float db = it->second->getVolume(); + return (db + 60.0f) / 66.0f; // Map -60..+6 to 0..1 } + return 0.0f; } -bool AudioEngine::reorderTrackInputFX(const juce::String& trackId, int fromIndex, int toIndex) +float AudioEngine::getTrackPan(const juce::String& trackId) const { - const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); auto it = trackMap.find(trackId); - if (it == trackMap.end() || !it->second) - return false; + if (it != trackMap.end() && it->second) + return it->second->getPan(); + return 0.0f; +} - bool success = it->second->reorderInputFX(fromIndex, toIndex); - if (success) - { - juce::Logger::writeToLog("AudioEngine: Reordered input FX on track " + trackId + - " from " + juce::String(fromIndex) + " to " + juce::String(toIndex)); - } - return success; +bool AudioEngine::getTrackMuted(const juce::String& trackId) const +{ + auto it = trackMap.find(trackId); + if (it != trackMap.end() && it->second) + return it->second->getMute(); + return false; } -bool AudioEngine::reorderTrackFX(const juce::String& trackId, int fromIndex, int toIndex) +bool AudioEngine::getTrackSoloed(const juce::String& trackId) const { - const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); auto it = trackMap.find(trackId); - if (it == trackMap.end() || !it->second) - return false; + if (it != trackMap.end() && it->second) + return it->second->getSolo(); + return false; +} - bool success = it->second->reorderTrackFX(fromIndex, toIndex); - if (success) +std::vector AudioEngine::getTrackIds() const +{ + return trackOrder; +} + +//============================================================================== +// Phase 3.12: Strip Silence +//============================================================================== + +juce::var AudioEngine::detectSilentRegions(const juce::String& filePath, double thresholdDb, + double minSilenceMs, double minSoundMs, + double preAttackMs, double postReleaseMs) +{ + auto regions = audioAnalyzer.detectSilentRegions(filePath, thresholdDb, + minSilenceMs, minSoundMs, + preAttackMs, postReleaseMs); + + // Get sample rate for time conversion + std::unique_ptr reader( + juce::AudioFormatManager().createReaderFor(juce::File(filePath))); + + // Need a fresh format manager for this + juce::AudioFormatManager fmgr; + fmgr.registerBasicFormats(); + reader.reset(fmgr.createReaderFor(juce::File(filePath))); + + double sr = reader ? reader->sampleRate : 44100.0; + + juce::Array result; + for (const auto& r : regions) { - juce::Logger::writeToLog("AudioEngine: Reordered track FX on track " + trackId + - " from " + juce::String(fromIndex) + " to " + juce::String(toIndex)); + auto* obj = new juce::DynamicObject(); + obj->setProperty("startTime", r.startSample / sr); + obj->setProperty("endTime", r.endSample / sr); + obj->setProperty("startSample", (juce::int64)r.startSample); + obj->setProperty("endSample", (juce::int64)r.endSample); + result.add(juce::var(obj)); } - return success; + return juce::var(result); } //============================================================================== -// Master FX Management +// Phase 3.13: Freeze Track +//============================================================================== -bool AudioEngine::addMasterFX(const juce::String& pluginPath) +juce::var AudioEngine::freezeTrack(const juce::String& trackId) { - juce::Logger::writeToLog("AudioEngine: addMasterFX called with: " + pluginPath); - - // Load the plugin with actual device sample rate & block size - double sr = currentSampleRate > 0 ? currentSampleRate : 44100.0; - int bs = currentBlockSize > 0 ? currentBlockSize : 512; - auto plugin = pluginManager.loadPluginFromFile(pluginPath, sr, bs); - if (!plugin) + auto* resultObj = new juce::DynamicObject(); + + auto it = trackMap.find(trackId); + if (it == trackMap.end() || !it->second) { - juce::Logger::writeToLog("AudioEngine: Failed to load plugin for master FX"); - return false; + resultObj->setProperty("success", false); + resultObj->setProperty("error", "Track not found"); + return juce::var(resultObj); } - // Provide tempo/position info to the plugin - plugin->setPlayHead (this); + TrackProcessor* track = it->second; + + // Use the playback engine's clips for this track to determine the range + double startTime = 0.0; + double endTime = 0.0; + auto allClips = playbackEngine.getClipSnapshot(); + std::vector clipList; + for (const auto& c : allClips) + if (c.trackId == trackId) + clipList.push_back(c); + if (clipList.empty()) + { + resultObj->setProperty("success", false); + resultObj->setProperty("error", "No clips on track"); + return juce::var(resultObj); + } - // Plugin was already created at the correct rate by loadPluginFromFile, - // but re-prepare in case the device has changed since then. - // Clamp max-block-size to at least 512 (same rationale as track FX). - auto* device = deviceManager.getCurrentAudioDevice(); - if (device) + // Find the time range of all clips on this track + startTime = std::numeric_limits::max(); + endTime = 0.0; + for (const auto& clip : clipList) { - plugin->prepareToPlay(device->getCurrentSampleRate(), - juce::jmax(device->getCurrentBufferSizeSamples(), 512)); + startTime = std::min(startTime, clip.startTime); + endTime = std::max(endTime, clip.startTime + clip.duration); } - // Add to master FX chain graph - auto node = masterFXChain->addNode(std::move(plugin)); - if (node != nullptr) + // Add tail for FX (reverb, delay) + double tailMs = 2000.0; // 2 seconds of tail + endTime += tailMs / 1000.0; + + // Create freeze file in project audio folder + juce::File freezeDir = projectAudioFolder.isDirectory() + ? projectAudioFolder + : juce::File::getSpecialLocation(juce::File::tempDirectory); + freezeDir.createDirectory(); + + juce::String freezeFileName = "freeze_" + trackId + "_" + juce::String(juce::Time::currentTimeMillis()) + ".wav"; + juce::File freezeFile = freezeDir.getChildFile(freezeFileName); + + // Render this single track offline + double renderRate = currentSampleRate; + int renderBlockSize = 512; + juce::int64 totalSamples = (juce::int64)((endTime - startTime) * renderRate); + + if (totalSamples <= 0) { - // Acquire callback lock to safely modify masterFXNodes - // (audio callback reads this vector under the same lock) - const juce::ScopedLock lockSl(mainProcessorGraph->getCallbackLock()); - masterFXNodes.push_back(node); - juce::Logger::writeToLog("AudioEngine: Added plugin to master FX chain (total: " + juce::String((int)masterFXNodes.size()) + ")"); - return true; + resultObj->setProperty("success", false); + resultObj->setProperty("error", "Empty render range"); + return juce::var(resultObj); } - - juce::Logger::writeToLog("AudioEngine: Failed to add node to master FX chain"); - return false; -} -juce::var AudioEngine::getMasterFX() -{ - juce::Array fxList; - for (int i = 0; i < static_cast(masterFXNodes.size()); ++i) + // Prepare the track's FX for offline rendering + track->prepareToPlay(renderRate, renderBlockSize); + + // Create output file + juce::WavAudioFormat wavFormat; + auto outputStream = std::make_unique(freezeFile); + if (outputStream->failedToOpen()) { - auto& node = masterFXNodes[i]; - if (node && node->getProcessor()) - { - juce::DynamicObject::Ptr fxInfo = new juce::DynamicObject(); - fxInfo->setProperty("index", i); - fxInfo->setProperty("name", node->getProcessor()->getName()); - // Include plugin file path for save/restore - if (auto* pluginInstance = dynamic_cast(node->getProcessor())) - { - auto desc = pluginInstance->getPluginDescription(); - fxInfo->setProperty("pluginPath", desc.fileOrIdentifier); - } - fxList.add(juce::var(fxInfo.get())); - } + resultObj->setProperty("success", false); + resultObj->setProperty("error", "Failed to create freeze file"); + return juce::var(resultObj); } - return fxList; -} -void AudioEngine::removeMasterFX(int fxIndex) -{ - const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); - if (fxIndex >= 0 && fxIndex < static_cast(masterFXNodes.size())) + std::unique_ptr writer( + wavFormat.createWriterFor(outputStream.get(), renderRate, 2, 24, {}, 0)); + if (!writer) { - auto node = masterFXNodes[fxIndex]; - masterFXNodes.erase(masterFXNodes.begin() + fxIndex); - if (node) - masterFXChain->removeNode(node.get()); - juce::Logger::writeToLog("AudioEngine: Removed master FX " + juce::String(fxIndex)); + resultObj->setProperty("success", false); + resultObj->setProperty("error", "Failed to create WAV writer"); + return juce::var(resultObj); } -} + outputStream.release(); // writer owns it now -void AudioEngine::openMasterFXEditor(int fxIndex) -{ - if (fxIndex >= 0 && fxIndex < static_cast(masterFXNodes.size())) + // Render loop + juce::AudioBuffer renderBuffer(2, renderBlockSize); + juce::AudioBuffer trackBuffer(2, renderBlockSize); + juce::int64 samplesRendered = 0; + double samplePos = startTime * renderRate; + + while (samplesRendered < totalSamples) { - auto& node = masterFXNodes[fxIndex]; - if (node && node->getProcessor()) - { - auto* pluginInstance = dynamic_cast(node->getProcessor()); - if (pluginInstance) - pluginWindowManager.openEditor(pluginInstance, pluginInstance->getName()); - } + int blockSamples = (int)std::min((juce::int64)renderBlockSize, totalSamples - samplesRendered); + renderBuffer.clear(); + trackBuffer.clear(); + + // Fill track buffer from playback engine + playbackEngine.fillTrackBuffer(trackId, trackBuffer, samplePos / renderRate, blockSamples, renderRate); + + // Process through FX chain + track->setCurrentBlockPosition(samplePos, renderRate); + juce::MidiBuffer midiMessages = buildTrackMidiBlock(trackId, samplePos / renderRate, + blockSamples, renderRate, true); + juce::AudioBuffer fxBuffer(trackBuffer.getArrayOfWritePointers(), 2, blockSamples); + track->processBlock(fxBuffer, midiMessages); + + // Write to output + writer->writeFromAudioSampleBuffer(fxBuffer, 0, blockSamples); + + samplePos += blockSamples; + samplesRendered += blockSamples; } -} -void AudioEngine::setMasterVolume(float volume) -{ - masterVolume = juce::jlimit(0.0f, 1.0f, volume); - juce::Logger::writeToLog("Master volume set to: " + juce::String(volume)); -} + writer.reset(); // Flush -void AudioEngine::setMasterPan(float pan) -{ - masterPan = juce::jlimit(-1.0f, 1.0f, pan); + // Re-prepare track for live playback + track->prepareToPlay(currentSampleRate, currentBlockSize); + + double duration = endTime - startTime; - // Pre-compute pan gains so the audio callback uses cheap loads instead of trig - const float angle = (masterPan + 1.0f) * juce::MathConstants::pi / 4.0f; - cachedMasterPanL.store(std::cos(angle), std::memory_order_relaxed); - cachedMasterPanR.store(std::sin(angle), std::memory_order_relaxed); + resultObj->setProperty("success", true); + resultObj->setProperty("filePath", freezeFile.getFullPathName()); + resultObj->setProperty("duration", duration); + resultObj->setProperty("sampleRate", renderRate); + resultObj->setProperty("startTime", startTime); + + juce::Logger::writeToLog("FreezeTrack: " + trackId + " -> " + freezeFile.getFullPathName() + + " (" + juce::String(duration, 2) + "s)"); + return juce::var(resultObj); } -bool AudioEngine::addMonitoringFX(const juce::String& pluginPath) +bool AudioEngine::unfreezeTrack(const juce::String& trackId) { - // TODO: Implement monitoring FX chain when needed - juce::Logger::writeToLog("addMonitoringFX called with: " + pluginPath); - - // Load the plugin with actual device rate - double sr = currentSampleRate > 0 ? currentSampleRate : 44100.0; - int bs = currentBlockSize > 0 ? currentBlockSize : 512; - auto plugin = pluginManager.loadPluginFromFile(pluginPath, sr, bs); - if (!plugin) - { - juce::Logger::writeToLog("Failed to load plugin for monitoring FX"); + auto it = trackMap.find(trackId); + if (it == trackMap.end() || !it->second) return false; - } - - juce::Logger::writeToLog("Monitoring FX plugin loaded successfully (not yet connected to chain)"); + + // Re-prepare track FX for live playback (in case they were in frozen bypass state) + it->second->prepareToPlay(currentSampleRate, currentBlockSize); + + juce::Logger::writeToLog("UnfreezeTrack: " + trackId); return true; } -std::vector AudioEngine::getLastCompletedClips() +// ============================================================================= +// Phase 18.10: Clip Gain Envelope +// ============================================================================= + +void AudioEngine::setClipGainEnvelope(const juce::String& trackId, const juce::String& clipId, + const juce::String& pointsJSON) { - auto clips = lastCompletedClips; - lastCompletedClips.clear(); // Clear after reading - return clips; -} + // Parse JSON array of {time, gain} points + auto parsed = juce::JSON::parse(pointsJSON); + std::vector points; -//============================================================================== -// Playback Clip Management + if (auto* arr = parsed.getArray()) + { + for (const auto& item : *arr) + { + if (auto* obj = item.getDynamicObject()) + { + PlaybackEngine::GainEnvelopePoint pt; + pt.time = static_cast(obj->getProperty("time")); + pt.gain = static_cast(static_cast(obj->getProperty("gain"))); + points.push_back(pt); + } + } + } -// Playback Clip Management + // Sort by time + std::sort(points.begin(), points.end(), + [](const PlaybackEngine::GainEnvelopePoint& a, const PlaybackEngine::GainEnvelopePoint& b) { + return a.time < b.time; + }); -void AudioEngine::addPlaybackClip(const juce::String& trackId, const juce::String& filePath, double startTime, double duration, - double offset, double volumeDB, double fadeIn, double fadeOut) -{ - juce::File audioFile(filePath); - playbackEngine.addClip(audioFile, startTime, duration, trackId, offset, volumeDB, fadeIn, fadeOut); - juce::Logger::writeToLog("AudioEngine: Added playback clip to track " + trackId + - " (offset=" + juce::String(offset) + "s, vol=" + juce::String(volumeDB) + "dB)"); + playbackEngine.setClipGainEnvelope(trackId, clipId, points); + juce::Logger::writeToLog("setClipGainEnvelope: track=" + trackId + " clip=" + clipId + + " points=" + juce::String(static_cast(points.size()))); } -void AudioEngine::removePlaybackClip(const juce::String& trackId, const juce::String& filePath) +// ============================================================================= +// Phase 19.7: MIDI Learn +// ============================================================================= + +void AudioEngine::startMIDILearnForPlugin(const juce::String& trackId, int pluginIndex, int paramIndex) { - playbackEngine.removeClip(trackId, filePath); - juce::Logger::writeToLog("AudioEngine: Removed playback clip from track " + trackId); + const juce::ScopedLock sl(midiLearnLock); + midiLearnTrackId = trackId; + midiLearnPluginIndex = pluginIndex; + midiLearnParamIndex = paramIndex; + midiLearnActive.store(true); + juce::Logger::writeToLog("MIDI Learn: Started for track=" + trackId + + " plugin=" + juce::String(pluginIndex) + + " param=" + juce::String(paramIndex)); } -void AudioEngine::clearPlaybackClips() +void AudioEngine::stopMIDILearnMode() { - playbackEngine.clearAllClips(); - juce::Logger::writeToLog("AudioEngine: Cleared all playback clips"); + midiLearnActive.store(false); + juce::Logger::writeToLog("MIDI Learn: Stopped"); } -void AudioEngine::clearTrackPlaybackClips(const juce::String& trackId) +void AudioEngine::clearMIDILearnMapping(int ccNumber) { - playbackEngine.clearTrackClips(trackId); - juce::Logger::writeToLog("AudioEngine: Cleared clips for track " + trackId); + const juce::ScopedLock sl(midiLearnLock); + midiLearnMappings.erase( + std::remove_if(midiLearnMappings.begin(), midiLearnMappings.end(), + [ccNumber](const MIDILearnMapping& m) { return m.ccNumber == ccNumber; }), + midiLearnMappings.end()); + juce::Logger::writeToLog("MIDI Learn: Cleared mapping for CC " + juce::String(ccNumber)); } - - -juce::var AudioEngine::getWaveformPeaks(const juce::String& filePath, int samplesPerPixel, int numPixels) +juce::var AudioEngine::getMIDILearnMappings() { - // REAPER-inspired: read from pre-computed peak cache (.s13peaks) instead of audio file. - // If cache doesn't exist, generate it synchronously (first-time cost, then instant). - juce::File audioFile(filePath); - if (!audioFile.existsAsFile()) - { - juce::Logger::writeToLog("getWaveformPeaks: File not found: " + filePath); - return juce::Array(); - } + const juce::ScopedLock sl(midiLearnLock); + juce::Array result; - // Generate peak cache if it doesn't exist (first time only) - if (!peakCache.hasCachedPeaks(audioFile)) + for (const auto& m : midiLearnMappings) { - juce::Logger::writeToLog("getWaveformPeaks: Generating peak cache for: " + audioFile.getFileName()); - peakCache.generateSync(audioFile); + auto* obj = new juce::DynamicObject(); + obj->setProperty("ccNumber", m.ccNumber); + obj->setProperty("trackId", m.trackId); + obj->setProperty("pluginIndex", m.pluginIndex); + obj->setProperty("paramIndex", m.paramIndex); + result.add(juce::var(obj)); } - // Read from peak cache (instant — memory-cached mipmap lookup) - return peakCache.getPeaks(audioFile, samplesPerPixel, numPixels); + return juce::var(result); } -juce::var AudioEngine::getRecordingPeaks(const juce::String& trackId, int samplesPerPixel, int numPixels) +// ============================================================================= +// Phase 19.9: MIDI Import/Export +// ============================================================================= + +juce::var AudioEngine::importMIDIFile(const juce::String& filePath) { - return audioRecorder.getRecordingPeaks(trackId, samplesPerPixel, numPixels); -} + juce::File file(filePath); + if (!file.existsAsFile()) + return juce::var(); -//============================================================================== -// Metronome & Transport Control (Phase 3) + juce::FileInputStream stream(file); + if (stream.failedToOpen()) + return juce::var(); -void AudioEngine::setTempo(double bpm) -{ - tempo = bpm; - metronome.setBpm(bpm); -} + juce::MidiFile midiFile; + if (!midiFile.readFrom(stream)) + return juce::var(); -void AudioEngine::setMetronomeEnabled(bool enabled) -{ - metronome.setEnabled(enabled); -} + // Convert to tick-based time, then convert to seconds + midiFile.convertTimestampTicksToSeconds(); + + auto* result = new juce::DynamicObject(); + result->setProperty("numTracks", midiFile.getNumTracks()); + result->setProperty("ticksPerQuarterNote", midiFile.getTimeFormat()); + + juce::Array tracksArray; + + for (int t = 0; t < midiFile.getNumTracks(); ++t) + { + const auto* midiTrack = midiFile.getTrack(t); + if (!midiTrack) continue; + + auto* trackObj = new juce::DynamicObject(); + juce::Array eventsArray; + + for (int i = 0; i < midiTrack->getNumEvents(); ++i) + { + const auto* holder = midiTrack->getEventPointer(i); + const auto& msg = holder->message; + + auto* evtObj = new juce::DynamicObject(); + evtObj->setProperty("timestamp", msg.getTimeStamp()); + + if (msg.isNoteOn()) + { + evtObj->setProperty("type", "noteOn"); + evtObj->setProperty("note", msg.getNoteNumber()); + evtObj->setProperty("velocity", msg.getVelocity()); + evtObj->setProperty("channel", msg.getChannel()); + } + else if (msg.isNoteOff()) + { + evtObj->setProperty("type", "noteOff"); + evtObj->setProperty("note", msg.getNoteNumber()); + evtObj->setProperty("velocity", 0); + evtObj->setProperty("channel", msg.getChannel()); + } + else if (msg.isController()) + { + evtObj->setProperty("type", "cc"); + evtObj->setProperty("controller", msg.getControllerNumber()); + evtObj->setProperty("value", msg.getControllerValue()); + evtObj->setProperty("channel", msg.getChannel()); + } + else if (msg.isPitchWheel()) + { + evtObj->setProperty("type", "pitchBend"); + evtObj->setProperty("value", msg.getPitchWheelValue()); + evtObj->setProperty("channel", msg.getChannel()); + } + else if (msg.isTempoMetaEvent()) + { + evtObj->setProperty("type", "tempo"); + evtObj->setProperty("bpm", 60000000.0 / msg.getTempoSecondsPerQuarterNote() / 1000000.0); + } + else + { + continue; + } -void AudioEngine::setMetronomeVolume(float volume) -{ - metronome.setVolume(volume); -} + eventsArray.add(juce::var(evtObj)); + } -bool AudioEngine::isMetronomeEnabled() const -{ - return metronome.isEnabled(); -} + trackObj->setProperty("events", eventsArray); + tracksArray.add(juce::var(trackObj)); + } -void AudioEngine::setMetronomeAccentBeats(const std::vector& accents) -{ - metronome.setAccentBeats(accents); + result->setProperty("tracks", tracksArray); + juce::Logger::writeToLog("importMIDIFile: " + filePath + " - " + + juce::String(midiFile.getNumTracks()) + " tracks"); + return juce::var(result); } -void AudioEngine::setTimeSignature(int numerator, int denominator) +bool AudioEngine::exportMIDIFile(const juce::String& trackId, const juce::String& clipId, + const juce::String& eventsJSON, const juce::String& outputPath, + double clipTempo) { - timeSigNumerator = numerator; - timeSigDenominator = denominator; - metronome.setTimeSignature(numerator, denominator); -} + juce::ignoreUnused(trackId, clipId); -void AudioEngine::getTimeSignature(int& numerator, int& denominator) const -{ - numerator = timeSigNumerator; - denominator = timeSigDenominator; -} + if (clipTempo <= 0.0) clipTempo = 120.0; -//============================================================================== -// AudioPlayHead — provides tempo/position info to hosted VST3 plugins + juce::MidiFile midiFile; + midiFile.setTicksPerQuarterNote(480); -juce::Optional AudioEngine::getPosition() const -{ - PositionInfo info; + juce::MidiMessageSequence sequence; - info.setBpm (tempo); + // Add tempo event + sequence.addEvent(juce::MidiMessage::tempoMetaEvent( + static_cast(60000000.0 / clipTempo)), 0.0); - juce::AudioPlayHead::TimeSignature timeSig; - timeSig.numerator = timeSigNumerator; - timeSig.denominator = timeSigDenominator; - info.setTimeSignature (timeSig); - info.setIsPlaying (isPlaying.load()); - info.setIsRecording (isRecordMode.load()); - info.setIsLooping (isLooping); + // Parse events JSON + auto parsed = juce::JSON::parse(eventsJSON); + if (auto* arr = parsed.getArray()) + { + for (const auto& item : *arr) + { + auto* obj = item.getDynamicObject(); + if (!obj) continue; - double timeInSeconds = (currentSampleRate > 0) - ? currentSamplePosition / currentSampleRate - : 0.0; - info.setTimeInSamples (static_cast (currentSamplePosition)); - info.setTimeInSeconds (timeInSeconds); + juce::String eventType = obj->getProperty("type").toString(); + double timestamp = static_cast(obj->getProperty("timestamp")); + // Convert seconds to ticks: ticks = seconds * (BPM / 60) * ticksPerQN + double ticks = timestamp * (clipTempo / 60.0) * 480.0; - // PPQ = position in quarter notes = seconds * (BPM / 60) - double ppqPosition = timeInSeconds * (tempo / 60.0); - info.setPpqPosition (ppqPosition); + if (eventType == "noteOn") + { + int note = static_cast(obj->getProperty("note")); + int velocity = static_cast(obj->getProperty("velocity")); + int channel = obj->hasProperty("channel") ? static_cast(obj->getProperty("channel")) : 1; + sequence.addEvent(juce::MidiMessage::noteOn(channel, note, static_cast(velocity)), ticks); + } + else if (eventType == "noteOff") + { + int note = static_cast(obj->getProperty("note")); + int channel = obj->hasProperty("channel") ? static_cast(obj->getProperty("channel")) : 1; + sequence.addEvent(juce::MidiMessage::noteOff(channel, note), ticks); + } + else if (eventType == "cc") + { + int controller = static_cast(obj->getProperty("controller")); + int value = static_cast(obj->getProperty("value")); + int channel = obj->hasProperty("channel") ? static_cast(obj->getProperty("channel")) : 1; + sequence.addEvent(juce::MidiMessage::controllerEvent(channel, controller, value), ticks); + } + } + } - // Bar start in PPQ: quarter notes per bar depends on time signature - double quarterNotesPerBar = timeSigNumerator * (4.0 / timeSigDenominator); - if (quarterNotesPerBar > 0.0) - info.setPpqPositionOfLastBarStart (std::floor (ppqPosition / quarterNotesPerBar) * quarterNotesPerBar); + sequence.updateMatchedPairs(); + midiFile.addTrack(sequence); - return info; + juce::File outFile(outputPath); + outFile.deleteFile(); + std::unique_ptr outputStream(outFile.createOutputStream()); + if (!outputStream) + return false; + + bool success = midiFile.writeTo(*outputStream); + juce::Logger::writeToLog("exportMIDIFile: " + outputPath + " success=" + juce::String(success ? "true" : "false")); + return success; } -void AudioEngine::propagatePlayHead (TrackProcessor* track) +// ============================================================================= +// Phase 19.14: Plugin Presets +// ============================================================================= + +juce::var AudioEngine::getPluginPresets(const juce::String& trackId, int fxIndex, bool isInputFX) { - if (!track) return; + juce::ignoreUnused(trackId, fxIndex, isInputFX); - for (int i = 0; i < track->getNumInputFX(); ++i) - if (auto* proc = track->getInputFXProcessor (i)) - proc->setPlayHead (this); + // List preset files from the presets directory + juce::File presetsDir = juce::File::getSpecialLocation(juce::File::userApplicationDataDirectory) + .getChildFile("OpenStudio").getChildFile("Presets"); - for (int i = 0; i < track->getNumTrackFX(); ++i) - if (auto* proc = track->getTrackFXProcessor (i)) - proc->setPlayHead (this); + juce::Array result; + if (presetsDir.isDirectory()) + { + auto files = presetsDir.findChildFiles(juce::File::findFiles, true, "*.ospreset"); + files.addArray(presetsDir.findChildFiles(juce::File::findFiles, true, "*.s13preset")); + for (const auto& file : files) + { + auto* obj = new juce::DynamicObject(); + obj->setProperty("name", file.getFileNameWithoutExtension()); + obj->setProperty("path", file.getFullPathName()); + result.add(juce::var(obj)); + } + } - if (auto* inst = track->getInstrument()) - inst->setPlayHead (this); + return juce::var(result); } -// Custom metronome sounds (Phase 9C) -bool AudioEngine::setMetronomeClickSound(const juce::String& filePath) +bool AudioEngine::loadPluginPreset(const juce::String& trackId, int fxIndex, bool isInputFX, + const juce::String& presetPath) { - return metronome.setClickSound(filePath); -} + juce::File presetFile(presetPath); + if (!presetFile.existsAsFile()) + return false; -bool AudioEngine::setMetronomeAccentSound(const juce::String& filePath) -{ - return metronome.setAccentSound(filePath); + auto xml = juce::XmlDocument::parse(presetFile); + if (!xml) + return false; + + juce::String base64State = xml->getStringAttribute("state"); + if (base64State.isEmpty()) + return false; + + return setPluginState(trackId, fxIndex, isInputFX, base64State); } -void AudioEngine::resetMetronomeSounds() +bool AudioEngine::savePluginPreset(const juce::String& trackId, int fxIndex, bool isInputFX, + const juce::String& presetPath, const juce::String& presetName) { - metronome.resetToDefaultSounds(); -} + juce::String base64State = getPluginState(trackId, fxIndex, isInputFX); + if (base64State.isEmpty()) + return false; -//============================================================================== -// Send/Bus Routing (Phase 11) + // Create XML structure + juce::XmlElement xml("S13Preset"); + xml.setAttribute("name", presetName); + xml.setAttribute("state", base64State); + xml.setAttribute("version", "1.0"); -int AudioEngine::addTrackSend(const juce::String& sourceTrackId, const juce::String& destTrackId) -{ - if (trackMap.find(sourceTrackId) == trackMap.end()) return -1; - return trackMap[sourceTrackId]->addSend(destTrackId); -} + juce::File outFile(presetPath); + outFile.getParentDirectory().createDirectory(); -void AudioEngine::removeTrackSend(const juce::String& sourceTrackId, int sendIndex) -{ - if (trackMap.find(sourceTrackId) != trackMap.end()) - trackMap[sourceTrackId]->removeSend(sendIndex); + bool success = xml.writeTo(outFile); + juce::Logger::writeToLog("savePluginPreset: " + presetPath + " success=" + juce::String(success ? "true" : "false")); + return success; } -void AudioEngine::setTrackSendLevel(const juce::String& sourceTrackId, int sendIndex, float level) -{ - if (trackMap.find(sourceTrackId) != trackMap.end()) - trackMap[sourceTrackId]->setSendLevel(sendIndex, level); -} +// ============================================================================= +// Phase 19.16: A/B Comparison +// ============================================================================= -void AudioEngine::setTrackSendPan(const juce::String& sourceTrackId, int sendIndex, float pan) +bool AudioEngine::storePluginABState(const juce::String& trackId, int fxIndex, bool isInputFX, + const juce::String& slot) { - if (trackMap.find(sourceTrackId) != trackMap.end()) - trackMap[sourceTrackId]->setSendPan(sendIndex, pan); -} + juce::String base64State = getPluginState(trackId, fxIndex, isInputFX); + if (base64State.isEmpty()) + return false; -void AudioEngine::setTrackSendEnabled(const juce::String& sourceTrackId, int sendIndex, bool enabled) -{ - if (trackMap.find(sourceTrackId) != trackMap.end()) - trackMap[sourceTrackId]->setSendEnabled(sendIndex, enabled); -} + juce::String key = trackId + ":" + juce::String(fxIndex) + ":" + juce::String(isInputFX ? 1 : 0) + ":" + slot; + pluginABStates[key] = base64State; -void AudioEngine::setTrackSendPreFader(const juce::String& sourceTrackId, int sendIndex, bool preFader) -{ - if (trackMap.find(sourceTrackId) != trackMap.end()) - trackMap[sourceTrackId]->setSendPreFader(sendIndex, preFader); + juce::String slotKey = trackId + ":" + juce::String(fxIndex) + ":" + juce::String(isInputFX ? 1 : 0); + pluginActiveSlots[slotKey] = slot; + + juce::Logger::writeToLog("storePluginABState: " + key); + return true; } -juce::var AudioEngine::getTrackSends(const juce::String& trackId) +bool AudioEngine::loadPluginABState(const juce::String& trackId, int fxIndex, bool isInputFX, + const juce::String& slot) { - juce::Array result; - if (trackMap.find(trackId) == trackMap.end()) return result; + juce::String key = trackId + ":" + juce::String(fxIndex) + ":" + juce::String(isInputFX ? 1 : 0) + ":" + slot; + auto it = pluginABStates.find(key); + if (it == pluginABStates.end()) + return false; - auto* track = trackMap[trackId]; - for (int i = 0; i < track->getNumSends(); ++i) + bool ok = setPluginState(trackId, fxIndex, isInputFX, it->second); + if (ok) { - auto* obj = new juce::DynamicObject(); - obj->setProperty("destTrackId", track->getSendDestination(i)); - obj->setProperty("level", track->getSendLevel(i)); - obj->setProperty("pan", track->getSendPan(i)); - obj->setProperty("enabled", track->getSendEnabled(i)); - obj->setProperty("preFader", track->getSendPreFader(i)); - result.add(obj); + juce::String slotKey = trackId + ":" + juce::String(fxIndex) + ":" + juce::String(isInputFX ? 1 : 0); + pluginActiveSlots[slotKey] = slot; } - return result; + + juce::Logger::writeToLog("loadPluginABState: " + key + " ok=" + juce::String(ok ? "true" : "false")); + return ok; } -juce::String AudioEngine::renderMetronomeToFile(double startTime, double endTime) +juce::String AudioEngine::getPluginActiveSlot(const juce::String& trackId, int fxIndex, bool isInputFX) { - // Create output file in Studio13/Audio directory - auto docsDir = juce::File::getSpecialLocation(juce::File::userDocumentsDirectory); - auto audioFolder = docsDir.getChildFile("Studio13").getChildFile("Audio"); + juce::String slotKey = trackId + ":" + juce::String(fxIndex) + ":" + juce::String(isInputFX ? 1 : 0); + auto it = pluginActiveSlots.find(slotKey); + return (it != pluginActiveSlots.end()) ? it->second : juce::String("A"); +} - if (!audioFolder.exists()) - audioFolder.createDirectory(); +// ============================================================================= +// Phase 20.5: Session Archive +// ============================================================================= - auto timestamp = juce::Time::getCurrentTime().toMilliseconds(); - auto filename = "Metronome_" + juce::String(timestamp) + ".wav"; - auto outputFile = audioFolder.getChildFile(filename); +bool AudioEngine::archiveSession(const juce::String& projectJsonPath, const juce::String& outputZipPath) +{ + juce::File projectFile(projectJsonPath); + if (!projectFile.existsAsFile()) + return false; - // Create a dedicated Metronome instance for offline rendering - // (to avoid interfering with the real-time metronome) - Metronome renderMetronome; - renderMetronome.prepareToPlay(currentSampleRate, 512); - renderMetronome.setBpm(tempo); - renderMetronome.setTimeSignature(timeSigNumerator, timeSigDenominator); - renderMetronome.setVolume(metronome.getVolume()); - renderMetronome.setAccentBeats(metronome.getAccentBeats()); + juce::File zipFile(outputZipPath); + zipFile.deleteFile(); - bool success = renderMetronome.renderToFile(outputFile, startTime, endTime); + auto outputStream = std::make_unique(zipFile); + if (outputStream->failedToOpen()) + return false; - if (success) - { - juce::Logger::writeToLog("AudioEngine: Rendered metronome to: " + outputFile.getFullPathName()); - return outputFile.getFullPathName(); - } + juce::ZipFile::Builder builder; - juce::Logger::writeToLog("AudioEngine: Failed to render metronome"); - return {}; -} + // Add the project JSON file + builder.addFile(projectFile, 9, projectFile.getFileName()); -//============================================================================== -// Plugin State Serialization (F2 - Project Save/Load) + // Parse project JSON to find referenced media files + juce::String jsonContent = projectFile.loadFileAsString(); + auto projectData = juce::JSON::parse(jsonContent); -juce::String AudioEngine::getPluginState(const juce::String& trackId, int fxIndex, bool isInputFX) -{ - if (trackMap.find(trackId) == trackMap.end()) - return {}; - - auto* track = trackMap[trackId]; - if (!track) - return {}; - - juce::AudioProcessor* processor = nullptr; - - if (isInputFX) + // Collect all file paths from clip data + std::set mediaFiles; + if (auto* tracksArr = projectData.getProperty("tracks", juce::var()).getArray()) { - if (fxIndex >= 0 && fxIndex < track->getNumInputFX()) - processor = track->getInputFXProcessor(fxIndex); + for (const auto& trackVar : *tracksArr) + { + if (auto* clipsArr = trackVar.getProperty("clips", juce::var()).getArray()) + { + for (const auto& clipVar : *clipsArr) + { + juce::String fp = clipVar.getProperty("filePath", "").toString(); + if (fp.isNotEmpty()) + mediaFiles.insert(fp); + } + } + } } - else + + // Add media files to archive + juce::File projectDir = projectFile.getParentDirectory(); + for (const auto& mediaPath : mediaFiles) { - if (fxIndex >= 0 && fxIndex < track->getNumTrackFX()) - processor = track->getTrackFXProcessor(fxIndex); + juce::File mediaFile(mediaPath); + if (mediaFile.existsAsFile()) + { + // Store relative to project directory if possible + juce::String relativePath = mediaFile.getRelativePathFrom(projectDir); + builder.addFile(mediaFile, 9, "media/" + relativePath); + } } - - if (!processor) - return {}; - - // Get plugin state as memory block - juce::MemoryBlock stateData; - processor->getStateInformation(stateData); - - // Convert to Base64 string - return stateData.toBase64Encoding(); + + bool success = builder.writeToStream(*outputStream, nullptr); + outputStream.reset(); + + juce::Logger::writeToLog("archiveSession: " + outputZipPath + + " success=" + juce::String(success ? "true" : "false") + + " files=" + juce::String(static_cast(mediaFiles.size()) + 1)); + return success; } -bool AudioEngine::setPluginState(const juce::String& trackId, int fxIndex, bool isInputFX, const juce::String& base64State) +bool AudioEngine::unarchiveSession(const juce::String& zipPath, const juce::String& outputDir) { - if (trackMap.find(trackId) == trackMap.end()) - return false; - - auto* track = trackMap[trackId]; - if (!track) - return false; - - juce::AudioProcessor* processor = nullptr; - - if (isInputFX) - { - if (fxIndex >= 0 && fxIndex < track->getNumInputFX()) - processor = track->getInputFXProcessor(fxIndex); - } - else - { - if (fxIndex >= 0 && fxIndex < track->getNumTrackFX()) - processor = track->getTrackFXProcessor(fxIndex); - } - - if (!processor) + juce::File zipFile(zipPath); + if (!zipFile.existsAsFile()) return false; - - // Decode Base64 to memory block - juce::MemoryBlock stateData; - if (!stateData.fromBase64Encoding(base64State)) + + juce::File outDir(outputDir); + outDir.createDirectory(); + + juce::ZipFile zip(zipFile); + if (zip.getNumEntries() == 0) return false; - - // Set plugin state - processor->setStateInformation(stateData.getData(), static_cast(stateData.getSize())); - - juce::Logger::writeToLog("AudioEngine: Restored plugin state for track " + trackId + - " FX " + juce::String(fxIndex) + " (isInput: " + (isInputFX ? "true" : "false") + ")"); - return true; + + auto result = zip.uncompressTo(outDir); + bool success = (result.wasOk()); + + juce::Logger::writeToLog("unarchiveSession: " + zipPath + " -> " + outputDir + + " entries=" + juce::String(zip.getNumEntries()) + + " success=" + juce::String(success ? "true" : "false")); + return success; } -juce::String AudioEngine::getMasterPluginState(int fxIndex) +// ============================================================================= +// Phase 20.11: Spectrum Analyzer +// ============================================================================= + +juce::var AudioEngine::getSpectrumData() { - if (fxIndex < 0 || fxIndex >= static_cast(masterFXNodes.size())) - return {}; - - auto& node = masterFXNodes[fxIndex]; - if (!node || !node->getProcessor()) - return {}; - - juce::MemoryBlock stateData; - node->getProcessor()->getStateInformation(stateData); - - return stateData.toBase64Encoding(); + const juce::ScopedLock sl(spectrumLock); + + juce::Array result; + if (!spectrumReady) + return juce::var(result); + + // Return first half of FFT (positive frequencies only): FFT_SIZE/2 + 1 bins + int numBins = FFT_SIZE / 2 + 1; + for (int i = 0; i < numBins; ++i) + result.add(static_cast(spectrumOutputBuffer[i])); + + return juce::var(result); } -bool AudioEngine::setMasterPluginState(int fxIndex, const juce::String& base64State) +// ============================================================================= +// Phase 20.12: Built-in FX Oversampling +// ============================================================================= + +bool AudioEngine::setBuiltInFXOversampling(const juce::String& trackId, int fxIndex, bool isInputFX, bool enabled) { - if (fxIndex < 0 || fxIndex >= static_cast(masterFXNodes.size())) + auto it = trackMap.find(trackId); + if (it == trackMap.end()) return false; - auto& node = masterFXNodes[fxIndex]; - if (!node || !node->getProcessor()) - return false; + auto* track = it->second; + juce::AudioProcessor* proc = nullptr; - juce::MemoryBlock stateData; - if (!stateData.fromBase64Encoding(base64State)) + if (isInputFX) + proc = track->getInputFXProcessor(fxIndex); + else + proc = track->getTrackFXProcessor(fxIndex); + + if (!proc) return false; - node->getProcessor()->setStateInformation(stateData.getData(), static_cast(stateData.getSize())); + // Check if it's a built-in effect that inherits from S13BuiltInEffect + if (auto* builtIn = dynamic_cast(proc)) + { + builtIn->setOversamplingEnabled(enabled); + juce::Logger::writeToLog("setBuiltInFXOversampling: " + trackId + " fx=" + juce::String(fxIndex) + + " isInput=" + juce::String(isInputFX ? "true" : "false") + + " enabled=" + juce::String(enabled ? "true" : "false")); + return true; + } - juce::Logger::writeToLog("AudioEngine: Restored master plugin state for FX " + juce::String(fxIndex)); - return true; + // Check if it's an S13Saturator (doesn't inherit from S13BuiltInEffect) + if (auto* saturator = dynamic_cast(proc)) + { + saturator->setOversamplingEnabled(enabled); + juce::Logger::writeToLog("setBuiltInFXOversampling (Saturator): " + trackId + " fx=" + juce::String(fxIndex) + + " enabled=" + juce::String(enabled ? "true" : "false")); + return true; + } + + return false; } -//============================================================================== -// FFmpeg Helpers +// ========== Channel Strip EQ (Phase 19.18) ========== -juce::File AudioEngine::findFFmpegExe() const +void AudioEngine::setChannelStripEQEnabled(const juce::String& trackId, bool enabled) { - // Search for ffmpeg.exe in common locations relative to the executable - auto exeDir = juce::File::getSpecialLocation(juce::File::currentExecutableFile).getParentDirectory(); + auto it = trackMap.find(trackId); + if (it != trackMap.end() && it->second) + it->second->setChannelStripEQEnabled(enabled); +} - // 1. Same directory as executable - auto ffmpeg = exeDir.getChildFile("ffmpeg.exe"); - if (ffmpeg.existsAsFile()) return ffmpeg; +void AudioEngine::setChannelStripEQParam(const juce::String& trackId, int paramIndex, float value) +{ + auto it = trackMap.find(trackId); + if (it != trackMap.end() && it->second) + it->second->setChannelStripEQParam(paramIndex, value); +} - // 2. tools/ subdirectory - ffmpeg = exeDir.getChildFile("tools").getChildFile("ffmpeg.exe"); - if (ffmpeg.existsAsFile()) return ffmpeg; +float AudioEngine::getChannelStripEQParam(const juce::String& trackId, int paramIndex) +{ + auto it = trackMap.find(trackId); + if (it != trackMap.end() && it->second) + return it->second->getChannelStripEQParam(paramIndex); + return 0.0f; +} - // 3. ../tools/ (one level up) - ffmpeg = exeDir.getParentDirectory().getChildFile("tools").getChildFile("ffmpeg.exe"); - if (ffmpeg.existsAsFile()) return ffmpeg; +// ============================================================================ +// Pitch Corrector bridge methods +// ============================================================================ - // 4. ../../tools/ (two levels up, for build/Studio13_artefacts/Release/) - ffmpeg = exeDir.getParentDirectory().getParentDirectory().getChildFile("tools").getChildFile("ffmpeg.exe"); - if (ffmpeg.existsAsFile()) return ffmpeg; +// (findPitchCorrector defined inline in methods below using trackMap) - // 5. ../../../tools/ (three levels up, for deeper build paths) - ffmpeg = exeDir.getParentDirectory().getParentDirectory().getParentDirectory().getChildFile("tools").getChildFile("ffmpeg.exe"); - if (ffmpeg.existsAsFile()) return ffmpeg; +juce::var AudioEngine::getPitchCorrectorData(const juce::String& trackId, int fxIndex) +{ + auto it = trackMap.find(trackId); + if (it == trackMap.end() || !it->second) return juce::var(); + juce::AudioProcessor* proc = (fxIndex >= 0 && fxIndex < it->second->getNumTrackFX()) + ? it->second->getTrackFXProcessor(fxIndex) : nullptr; + auto* pc = dynamic_cast(proc); + if (!pc) return juce::var(); + + auto data = pc->getCurrentPitchData(); + auto& mapper = pc->getMapper(); + + juce::DynamicObject::Ptr obj = new juce::DynamicObject(); + obj->setProperty("detectedPitch", static_cast(data.detectedHz)); + obj->setProperty("correctedPitch", static_cast(data.correctedHz)); + obj->setProperty("confidence", static_cast(data.confidence)); + obj->setProperty("centsDeviation", static_cast(data.centsDeviation)); + obj->setProperty("noteName", data.noteName); + + // Include current parameter values for UI sync + obj->setProperty("key", mapper.getKey()); + obj->setProperty("scale", static_cast(mapper.getScale())); + obj->setProperty("retuneSpeed", static_cast(mapper.getRetuneSpeed())); + obj->setProperty("humanize", static_cast(mapper.getHumanize())); + obj->setProperty("transpose", mapper.getTranspose()); + obj->setProperty("correctionStrength", static_cast(mapper.getCorrectionStrength())); + obj->setProperty("formantCorrection", mapper.getFormantCorrection()); + obj->setProperty("formantShift", static_cast(mapper.getFormantShift())); + obj->setProperty("mix", static_cast(pc->mix.load())); + obj->setProperty("midiOutput", pc->midiOutputEnabled.load() > 0.5f); + obj->setProperty("midiChannel", static_cast(pc->midiOutputChannel.load())); + + // Note enables + juce::Array noteEnables; + for (int i = 0; i < 12; ++i) + noteEnables.add(mapper.isNoteEnabled(i)); + obj->setProperty("noteEnables", noteEnables); + + return juce::var(obj.get()); +} - return juce::File(); // Not found +void AudioEngine::setPitchCorrectorParam(const juce::String& trackId, int fxIndex, + const juce::String& param, float value) +{ + auto it = trackMap.find(trackId); + if (it == trackMap.end() || !it->second) return; + juce::AudioProcessor* proc = (fxIndex >= 0 && fxIndex < it->second->getNumTrackFX()) + ? it->second->getTrackFXProcessor(fxIndex) : nullptr; + auto* pc = dynamic_cast(proc); + if (!pc) return; + + auto& mapper = pc->getMapper(); + + if (param == "key") mapper.setKey(static_cast(value)); + else if (param == "scale") mapper.setScale(static_cast(static_cast(value))); + else if (param == "retuneSpeed") mapper.setRetuneSpeed(value); + else if (param == "humanize") mapper.setHumanize(value); + else if (param == "transpose") mapper.setTranspose(static_cast(value)); + else if (param == "correctionStrength") mapper.setCorrectionStrength(value); + else if (param == "formantCorrection") mapper.setFormantCorrection(value > 0.5f); + else if (param == "formantShift") mapper.setFormantShift(value); + else if (param == "mix") pc->mix.store(juce::jlimit(0.0f, 1.0f, value)); + else if (param == "bypass") pc->bypass.store(value > 0.5f ? 1.0f : 0.0f); + else if (param == "sensitivity") pc->sensitivity.store(value); + else if (param == "midiOutput") pc->midiOutputEnabled.store(value > 0.5f ? 1.0f : 0.0f); + else if (param == "midiChannel") pc->midiOutputChannel.store(juce::jlimit(1.0f, 16.0f, value)); + else if (param.startsWith("noteEnable_")) + { + int noteIdx = param.substring(11).getIntValue(); + mapper.setNoteEnabled(noteIdx, value > 0.5f); + } } -bool AudioEngine::convertWithFFmpeg(const juce::File& inputFile, const juce::File& outputFile, - const juce::String& format, double targetSampleRate, int quality) const +juce::var AudioEngine::getPitchHistory(const juce::String& trackId, int fxIndex, int numFrames) { - auto ffmpeg = findFFmpegExe(); - if (!ffmpeg.existsAsFile()) + auto it = trackMap.find(trackId); + if (it == trackMap.end() || !it->second) return juce::Array(); + juce::AudioProcessor* proc = (fxIndex >= 0 && fxIndex < it->second->getNumTrackFX()) + ? it->second->getTrackFXProcessor(fxIndex) : nullptr; + auto* pc = dynamic_cast(proc); + if (!pc) return juce::Array(); + + auto frames = pc->getPitchHistory(numFrames); + + juce::Array result; + for (const auto& f : frames) { - logToDisk("convertWithFFmpeg: FAIL - ffmpeg.exe not found"); - return false; + juce::DynamicObject::Ptr frame = new juce::DynamicObject(); + frame->setProperty("detected", static_cast(f.detectedMidi)); + frame->setProperty("corrected", static_cast(f.correctedMidi)); + frame->setProperty("confidence", static_cast(f.confidence)); + result.add(juce::var(frame.get())); } + return result; +} - // Build ffmpeg command - juce::StringArray args; - args.add(ffmpeg.getFullPathName()); - args.add("-y"); // Overwrite output - args.add("-i"); - args.add(inputFile.getFullPathName()); // Input file +// ============================================================================ +// Pitch Corrector — Graphical Mode bridge methods +// ============================================================================ - // Sample rate conversion (if target differs from source) - if (targetSampleRate > 0) +juce::var AudioEngine::analyzePitchContour(const juce::String& trackId, const juce::String& clipId) +{ + // Find the clip's audio file + auto clips = playbackEngine.getClipSnapshot(); + const PlaybackEngine::ClipInfo* foundClip = nullptr; + + for (const auto& clip : clips) { - args.add("-ar"); - args.add(juce::String((int)targetSampleRate)); + if (clip.trackId == trackId && clip.clipId == clipId) + { + foundClip = &clip; + break; + } } - juce::String formatLower = format.toLowerCase(); + if (!foundClip || !foundClip->audioFile.existsAsFile()) + return juce::var(); - if (formatLower == "mp3") + // Read audio file + juce::AudioFormatManager fmtMgr; + fmtMgr.registerBasicFormats(); + std::unique_ptr reader(fmtMgr.createReaderFor(foundClip->audioFile)); + if (!reader) return juce::var(); + + int startSample = static_cast(foundClip->offset * reader->sampleRate); + int numSamples = static_cast(foundClip->duration * reader->sampleRate); + numSamples = std::min(numSamples, static_cast(reader->lengthInSamples) - startSample); + if (numSamples <= 0) return juce::var(); + + // Read and mix to mono + juce::AudioBuffer buffer(static_cast(reader->numChannels), numSamples); + reader->read(&buffer, 0, numSamples, static_cast(startSample), true, true); + + juce::AudioBuffer mono(1, numSamples); + mono.clear(); + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + mono.addFrom(0, 0, buffer, ch, 0, numSamples, 1.0f / static_cast(buffer.getNumChannels())); + + // Run analysis + PitchAnalyzer analyzer; + auto result = analyzer.analyzeClip(mono.getReadPointer(0), numSamples, + reader->sampleRate, clipId); + + return PitchAnalyzer::resultToJSON(result); +} + +juce::var AudioEngine::analyzePitchContourDirect(const juce::String& filePath, double offset, double duration, const juce::String& clipId) +{ + juce::File audioFile(filePath); + if (!audioFile.existsAsFile()) { - args.add("-codec:a"); - args.add("libmp3lame"); - // quality = bitrate in kbps (128, 192, 256, 320) - int bitrate = (quality > 0) ? quality : 320; - args.add("-b:a"); - args.add(juce::String(bitrate) + "k"); + juce::Logger::writeToLog("analyzePitchContourDirect: File not found: " + filePath); + return juce::var(); } - else if (formatLower == "ogg") + + juce::AudioFormatManager fmtMgr; + fmtMgr.registerBasicFormats(); + std::unique_ptr reader(fmtMgr.createReaderFor(audioFile)); + if (!reader) return juce::var(); + + int startSample = static_cast(offset * reader->sampleRate); + int numSamples = static_cast(duration * reader->sampleRate); + numSamples = std::min(numSamples, static_cast(reader->lengthInSamples) - startSample); + if (numSamples <= 0) return juce::var(); + + juce::AudioBuffer buffer(static_cast(reader->numChannels), numSamples); + reader->read(&buffer, 0, numSamples, static_cast(startSample), true, true); + + juce::AudioBuffer mono(1, numSamples); + mono.clear(); + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + mono.addFrom(0, 0, buffer, ch, 0, numSamples, 1.0f / static_cast(buffer.getNumChannels())); + + PitchAnalyzer analyzer; + auto result = analyzer.analyzeClip(mono.getReadPointer(0), numSamples, + reader->sampleRate, clipId); + + return PitchAnalyzer::resultToJSON(result); +} + +juce::var AudioEngine::applyPitchCorrection(const juce::String& trackId, const juce::String& clipId, + const juce::var& notesJson, const juce::var& framesJson, + float globalFormantSemitones, + std::optional windowStartSecOverride, + std::optional windowEndSecOverride, + const juce::String& renderMode, + std::function shouldCancel) +{ + logPitchEditorFormant("AudioEngine::applyPitchCorrection begin clip=" + clipId + + " track=" + trackId + + " globalFormantSt=" + juce::String(globalFormantSemitones, 3) + + " renderMode=" + renderMode + + " windowStartOverride=" + juce::String(windowStartSecOverride.has_value() ? *windowStartSecOverride : -1.0, 3) + + " windowEndOverride=" + juce::String(windowEndSecOverride.has_value() ? *windowEndSecOverride : -1.0, 3)); + auto buildCancelledResult = [&clipId, &renderMode]() { + juce::DynamicObject::Ptr resultObj = new juce::DynamicObject(); + resultObj->setProperty("success", false); + resultObj->setProperty("cancelled", true); + resultObj->setProperty("clipId", clipId); + resultObj->setProperty("renderMode", renderMode); + return juce::var(resultObj.get()); + }; + if (shouldCancel && shouldCancel()) + return buildCancelledResult(); + auto clips = playbackEngine.getClipSnapshot(); + const PlaybackEngine::ClipInfo* foundClip = nullptr; + for (const auto& clip : clips) { - args.add("-codec:a"); - args.add("libvorbis"); - // quality = vorbis quality level (0-10, default 6) - int q = (quality > 0) ? quality : 6; - args.add("-q:a"); - args.add(juce::String(q)); + if (clip.trackId == trackId && clip.clipId == clipId) + { + foundClip = &clip; + break; + } } - else + if (!foundClip) { - // WAV/AIFF/FLAC sample rate conversion only — keep format as-is - // ffmpeg auto-detects output format from extension + logPitchEditorFormant("clip not found in playback engine clip=" + clipId); + juce::Logger::writeToLog("applyPitchCorrection: clip not found in playback engine for clipId=" + clipId); + return false; } - args.add(outputFile.getFullPathName()); // Output file - - logToDisk("convertWithFFmpeg: Running: " + args.joinIntoString(" ")); + // Always read from the ORIGINAL audio file so successive edits don't compound. + // originalAudioFile is set once in addClip and never changed by replaceClipAudioFile. + juce::File sourceFile = foundClip->originalAudioFile.existsAsFile() + ? foundClip->originalAudioFile + : foundClip->audioFile; - // Run ffmpeg as child process - juce::ChildProcess process; - if (!process.start(args)) + if (!sourceFile.existsAsFile()) { - logToDisk("convertWithFFmpeg: FAIL - could not start ffmpeg process"); + logPitchEditorFormant("source file missing clip=" + clipId + " path=" + sourceFile.getFullPathName()); + juce::Logger::writeToLog("applyPitchCorrection: source file not found: " + sourceFile.getFullPathName()); return false; } - // Wait for completion (up to 5 minutes for large files) - if (!process.waitForProcessToFinish(300000)) + juce::AudioFormatManager fmtMgr; + fmtMgr.registerBasicFormats(); + std::unique_ptr reader(fmtMgr.createReaderFor(sourceFile)); + if (!reader) { - logToDisk("convertWithFFmpeg: FAIL - ffmpeg timed out after 5 minutes"); - process.kill(); + logPitchEditorFormant("failed to create reader clip=" + clipId + " path=" + sourceFile.getFullPathName()); return false; } - int exitCode = process.getExitCode(); - if (exitCode != 0) + // Use originalOffset (not offset) when reading from the original file. + // After replaceClipAudioFile, offset is reset to 0 (for the corrected file), + // but originalOffset preserves where the clip data starts in the original file. + double effectiveOffset = (sourceFile == foundClip->originalAudioFile) + ? foundClip->originalOffset + : foundClip->offset; + int clipStartSample = static_cast(effectiveOffset * reader->sampleRate); + int clipNumSamples = static_cast(foundClip->duration * reader->sampleRate); + clipNumSamples = std::min(clipNumSamples, static_cast(reader->lengthInSamples) - clipStartSample); + if (clipNumSamples <= 0) { - juce::String errOutput = process.readAllProcessOutput(); - logToDisk("convertWithFFmpeg: FAIL - ffmpeg exit code " + juce::String(exitCode) + " output: " + errOutput); + logPitchEditorFormant("clip had no readable samples clip=" + clipId); return false; } - if (!outputFile.existsAsFile()) + const double sr = reader->sampleRate; + const double clipDuration = static_cast(clipNumSamples) / sr; + const bool hasGlobalFormant = std::abs(globalFormantSemitones) > 0.01f; + + // Parse notes first so we can limit processing to only the edited region. + auto editedNotes = PitchAnalyzer::notesFromJSON(notesJson); + + // Find the time range covered by ACTUALLY EDITED notes only (correctedPitch != detectedPitch). + // WORLD vocoder is NOT transparent at ratio=1.0 — it degrades audio quality even when + // no pitch change is requested. By limiting the window to edited notes only, we avoid + // resynthesizing the entire clip through WORLD when only one note was changed. + const float editThreshold = 0.01f; // semitones + double notesStartSec = clipDuration; + double notesEndSec = 0.0; + bool anyNoteEdited = false; + int pitchEditCount = 0; + int noteFormantEditCount = 0; + int gainEditCount = 0; + int driftEditCount = 0; + int vibratoEditCount = 0; + for (const auto& n : editedNotes) { - logToDisk("convertWithFFmpeg: FAIL - output file not created"); - return false; + const bool pitchEdited = std::abs(n.correctedPitch - n.detectedPitch) > editThreshold; + const bool gainEdited = std::abs(n.gain) > 0.01f; + const bool noteFormantEdited = std::abs(n.formantShift) > 0.01f; + const bool driftEdited = n.driftCorrectionAmount > 0.01f; + const bool vibratoEdited = std::abs(n.vibratoDepth - 1.0f) > 0.01f; + + if (pitchEdited) ++pitchEditCount; + if (gainEdited) ++gainEditCount; + if (noteFormantEdited) ++noteFormantEditCount; + if (driftEdited) ++driftEditCount; + if (vibratoEdited) ++vibratoEditCount; + + if (pitchEdited || gainEdited || noteFormantEdited || driftEdited || vibratoEdited) + { + notesStartSec = std::min(notesStartSec, static_cast(n.startTime)); + notesEndSec = std::max(notesEndSec, static_cast(n.endTime)); + anyNoteEdited = true; + } + } + const bool anyEdited = anyNoteEdited || hasGlobalFormant; + juce::String requestMode = "none"; + const bool hasPitchEdits = pitchEditCount > 0; + const bool hasNoteFormantEdits = noteFormantEditCount > 0; + const bool hasOtherEdits = gainEditCount > 0 || driftEditCount > 0 || vibratoEditCount > 0; + if (hasPitchEdits && !hasGlobalFormant && !hasNoteFormantEdits && !hasOtherEdits) + requestMode = "pitch-only"; + else if (!hasPitchEdits && (hasGlobalFormant || hasNoteFormantEdits) && !hasOtherEdits) + requestMode = "formant-only"; + else if (anyEdited) + requestMode = "mixed"; + + logPitchEditorFormant("request summary clip=" + clipId + + " noteCount=" + juce::String(static_cast(editedNotes.size())) + + " pitchEdits=" + juce::String(pitchEditCount) + + " noteFormantEdits=" + juce::String(noteFormantEditCount) + + " gainEdits=" + juce::String(gainEditCount) + + " driftEdits=" + juce::String(driftEditCount) + + " vibratoEdits=" + juce::String(vibratoEditCount) + + " globalFormant=" + juce::String(hasGlobalFormant ? "true" : "false") + + " mode=" + requestMode); + + // If no notes were actually edited, restore the original audio file + // (in case a previous correction was applied and the user moved notes back). + if (!anyEdited) + { + logPitchEditorFormant("no edits remain, restoring original clip=" + clipId); + if (foundClip->originalAudioFile.existsAsFile() + && foundClip->audioFile != foundClip->originalAudioFile) + { + playbackEngine.replaceClipAudioFile(clipId, foundClip->originalAudioFile); + } + juce::DynamicObject::Ptr resultObj = new juce::DynamicObject(); + resultObj->setProperty("outputFile", foundClip->originalAudioFile.getFullPathName()); + resultObj->setProperty("success", true); + resultObj->setProperty("restored", true); + return juce::var(resultObj.get()); } - logToDisk("convertWithFFmpeg: SUCCESS - " + outputFile.getFullPathName() + - " (" + juce::String(outputFile.getSize() / 1024) + " KB)"); - return true; -} + logPitchEditorFormant("source selection clip=" + clipId + + " sourceFile=" + sourceFile.getFullPathName() + + " originalFile=" + foundClip->originalAudioFile.getFullPathName() + + " clipSamples=" + juce::String(clipNumSamples) + + " clipDuration=" + juce::String(clipDuration, 3)); -//============================================================================== -// Offline Render/Export + const int numChannels = static_cast(reader->numChannels); -bool AudioEngine::renderProject(const juce::String& source, double startTime, double endTime, - const juce::String& filePath, const juce::String& format, - double renderSampleRate, int bitDepth, int numChannels, - bool normalize, bool addTail, double tailLengthMs) -{ - logToDisk("renderProject: START - file=" + filePath + " format=" + format + - " range=" + juce::String(startTime) + "-" + juce::String(endTime) + - " sr=" + juce::String(renderSampleRate) + " bits=" + juce::String(bitDepth) + - " ch=" + juce::String(numChannels) + - " normalize=" + juce::String(normalize ? "true" : "false") + - " addTail=" + juce::String(addTail ? "true" : "false") + - " tailMs=" + juce::String(tailLengthMs)); + // Read the FULL clip (all channels) — we will stitch the corrected window back in-place. + juce::AudioBuffer clipBuffer(numChannels, clipNumSamples); + reader->read(&clipBuffer, 0, clipNumSamples, static_cast(clipStartSample), true, true); + if (shouldCancel && shouldCancel()) + return buildCancelledResult(); - // ========== 1. Validate inputs ========== - if (endTime <= startTime) + // Use frontend analysis frames if provided (eliminates re-analysis mismatch). + // Fall back to local analysis only if frames weren't sent. + PitchAnalyzer::AnalysisResult analysis; + analysis.clipId = clipId; + analysis.sampleRate = sr; + + if (framesJson.getDynamicObject() != nullptr) { - logToDisk("renderProject: FAIL - endTime (" + juce::String(endTime) + - ") <= startTime (" + juce::String(startTime) + ")"); + analysis.frames = PitchAnalyzer::framesFromJSON(framesJson); + // Frontend frames use clip-relative times. Adjust to window-relative below. + // The hopSize isn't critical here — estimate from frame spacing. + if (analysis.frames.size() >= 2) + analysis.hopSize = static_cast((analysis.frames[1].time - analysis.frames[0].time) * sr); + else + analysis.hopSize = 512; + logPitchEditorFormant("using frontend analysis frames count=" + juce::String(static_cast(analysis.frames.size())) + + " clip=" + clipId); + } + else + { + // Fallback: create mono mix and re-analyze (legacy path) + juce::AudioBuffer monoBuffer(1, clipNumSamples); + monoBuffer.clear(); + for (int ch = 0; ch < numChannels; ++ch) + monoBuffer.addFrom(0, 0, clipBuffer, ch, 0, clipNumSamples, 1.0f / static_cast(numChannels)); + + PitchAnalyzer analyzer; + analysis = analyzer.analyzeClip(monoBuffer.getReadPointer(0), + clipNumSamples, sr, clipId); + logPitchEditorFormant("no frontend frames supplied, re-analyzed locally clip=" + clipId); + } + + juce::AudioBuffer renderedOutputBuffer (clipBuffer); + int renderedOutputSamples = clipNumSamples; + double previewCoverageStartSec = 0.0; + double previewCoverageEndSec = clipDuration; + bool previewSegmentRender = false; + bool fullClipHQRender = false; + bool swapDeferred = false; + + // Process only the EDITED WINDOW (with padding), or a whole-clip render for explicit formants. + { + // Build window bounds around the edited region. + const bool hasWindowOverride = windowStartSecOverride.has_value() && windowEndSecOverride.has_value(); + const bool hasAnyFormantEdits = hasGlobalFormant || hasNoteFormantEdits; + previewSegmentRender = renderMode == "preview_segment"; + fullClipHQRender = renderMode == "full_clip_hq"; + const bool processFullClip = hasAnyFormantEdits && !previewSegmentRender; + // Preview segments must sound like the final formant result, not like a + // watered-down proxy. Using the HQ formant path on 10-second staged + // renders keeps preview character aligned with the final clip while + // still avoiding a full-clip wait. + const auto renderQuality = PitchResynthesizer::RenderQuality::FinalHQ; + const double kPaddingSec = previewSegmentRender ? 0.75 : 1.0; + double requestedWindowStartSec = processFullClip ? 0.0 : std::max (0.0, notesStartSec - kPaddingSec); + double requestedWindowEndSec = processFullClip ? clipDuration : std::min (clipDuration, notesEndSec + kPaddingSec); + double windowStartSec = processFullClip ? 0.0 : requestedWindowStartSec; + double windowEndSec = processFullClip ? clipDuration : requestedWindowEndSec; + if (hasWindowOverride && processFullClip) + { + logPitchEditorFormant ("ignoring window override because explicit formant edits require full-clip render clip=" + + clipId); + } + if (hasWindowOverride && ! processFullClip) + { + requestedWindowStartSec = juce::jlimit (0.0, clipDuration, *windowStartSecOverride); + requestedWindowEndSec = juce::jlimit (requestedWindowStartSec, clipDuration, *windowEndSecOverride); + windowStartSec = previewSegmentRender + ? std::max (0.0, requestedWindowStartSec - kPaddingSec) + : requestedWindowStartSec; + windowEndSec = previewSegmentRender + ? std::min (clipDuration, requestedWindowEndSec + kPaddingSec) + : requestedWindowEndSec; + } + int windowStartSample = static_cast (windowStartSec * sr); + int windowNumSamples = std::min (clipNumSamples - windowStartSample, + static_cast ((windowEndSec - windowStartSec) * sr)); + previewCoverageStartSec = requestedWindowStartSec; + previewCoverageEndSec = requestedWindowEndSec; + + logPitchEditorFormant (juce::String("processing scope=") + + (processFullClip ? "full-clip" : (previewSegmentRender ? "preview-segment" : (hasWindowOverride ? "playhead-window" : "edited-window"))) + + " clip=" + clipId + + " renderMode=" + renderMode + + " requested=[" + juce::String (requestedWindowStartSec, 3) + "s - " + juce::String (requestedWindowEndSec, 3) + "s]" + + " window=[" + juce::String (windowStartSec, 3) + "s - " + juce::String (windowEndSec, 3) + "s]" + + " windowSamples=" + juce::String (windowNumSamples)); + juce::Logger::writeToLog ("applyPitchCorrection: window [" + + juce::String (windowStartSec, 3) + "s - " + juce::String (windowEndSec, 3) + "s], " + + juce::String (windowNumSamples) + " samples (clip=" + juce::String (clipNumSamples) + ")"); + + if (windowNumSamples > 256) + { + // Extract the window into its own buffer. + juce::AudioBuffer windowBuffer (numChannels, windowNumSamples); + for (int ch = 0; ch < numChannels; ++ch) + windowBuffer.copyFrom (ch, 0, clipBuffer, ch, windowStartSample, windowNumSamples); + + // Filter analysis frames to the window and shift their times to window-relative. + PitchAnalyzer::AnalysisResult windowAnalysis = analysis; + windowAnalysis.frames.clear(); + for (const auto& f : analysis.frames) + { + double ft = static_cast (f.time); + if (ft >= windowStartSec - 0.5 && ft <= windowEndSec + 0.5) + { + auto wf = f; + wf.time = static_cast (ft - windowStartSec); + windowAnalysis.frames.push_back (wf); + } + } + + // Shift note times to window-relative. + auto windowNotes = editedNotes; + for (auto& n : windowNotes) + { + n.startTime = static_cast (static_cast (n.startTime) - windowStartSec); + n.endTime = static_cast (static_cast (n.endTime) - windowStartSec); + } + + PitchResynthesizer resynth; + std::vector channelPtrs (static_cast (numChannels)); + for (int ch = 0; ch < numChannels; ++ch) + channelPtrs[static_cast (ch)] = windowBuffer.getReadPointer (ch); + + auto correctedWindow = resynth.processMultiChannel ( + channelPtrs.data(), numChannels, windowNumSamples, sr, + windowAnalysis.frames, windowNotes, + PitchResynthesizer::PitchEngine::Signalsmith, + globalFormantSemitones, + renderQuality, + shouldCancel); + if (shouldCancel && shouldCancel()) + return buildCancelledResult(); + + logPitchEditorFormant ("resynth complete clip=" + clipId + + " correctedSamples=" + juce::String (correctedWindow.empty() ? 0 : static_cast (correctedWindow[0].size())) + + " windowSamples=" + juce::String (windowNumSamples)); + juce::Logger::writeToLog ("applyPitchCorrection: processMultiChannel returned " + + juce::String (correctedWindow.empty() ? 0 : static_cast (correctedWindow[0].size())) + + " samples (window=" + juce::String (windowNumSamples) + ")"); + + // RMS comparison: log input vs output amplitude to diagnose any scaling issues + if (!correctedWindow.empty() && windowNumSamples > 0) + { + float inputRMS = 0.0f, outputRMS = 0.0f, outputPeak = 0.0f; + int ch0Samples = std::min (windowNumSamples, static_cast (correctedWindow[0].size())); + for (int i = 0; i < windowNumSamples; ++i) + inputRMS += windowBuffer.getSample (0, i) * windowBuffer.getSample (0, i); + for (int i = 0; i < ch0Samples; ++i) + { + float s = correctedWindow[0][static_cast (i)]; + outputRMS += s * s; + outputPeak = std::max (outputPeak, std::abs (s)); + } + inputRMS = std::sqrt (inputRMS / static_cast (windowNumSamples)); + outputRMS = std::sqrt (outputRMS / static_cast (ch0Samples)); + juce::Logger::writeToLog ("applyPitchCorrection: window RMS in=" + + juce::String (inputRMS, 4) + " out=" + juce::String (outputRMS, 4) + + " outPeak=" + juce::String (outputPeak, 4) + + " ratio=" + juce::String (inputRMS > 1e-8f ? outputRMS / inputRMS : 0.0f, 3)); + } + + if (previewSegmentRender) + { + const int trimStart = juce::jlimit (0, windowNumSamples, + static_cast ((requestedWindowStartSec - windowStartSec) * sr)); + const int segmentSamples = std::max (0, std::min ( + static_cast ((requestedWindowEndSec - requestedWindowStartSec) * sr), + windowNumSamples - trimStart)); + renderedOutputBuffer.setSize (numChannels, segmentSamples); + renderedOutputSamples = segmentSamples; + const int xfadeLen = std::min (384, segmentSamples / 6); + for (int ch = 0; ch < numChannels; ++ch) + { + if (static_cast (ch) >= correctedWindow.size()) continue; + const auto& corrected = correctedWindow[static_cast (ch)]; + for (int i = 0; i < segmentSamples; ++i) + { + const int sourceIndex = trimStart + i; + float blend = 1.0f; + if (xfadeLen > 0 && i < xfadeLen) + blend = 0.5f * (1.0f - std::cos (juce::MathConstants::pi + * static_cast (i) / static_cast (xfadeLen))); + const int distFromEnd = segmentSamples - 1 - i; + if (xfadeLen > 0 && distFromEnd < xfadeLen) + { + float fadeOutBlend = 0.5f * (1.0f - std::cos (juce::MathConstants::pi + * static_cast (distFromEnd) / static_cast (xfadeLen))); + blend = std::min (blend, fadeOutBlend); + } + const float orig = windowBuffer.getSample (ch, sourceIndex); + const float corr = sourceIndex < static_cast (corrected.size()) + ? corrected[static_cast (sourceIndex)] + : orig; + renderedOutputBuffer.setSample (ch, i, orig * (1.0f - blend) + corr * blend); + } + } + } + else + { + // Blend corrected window into full clip buffer with cosine crossfade at splice points. + const int xfadeLen = std::min (512, windowNumSamples / 4); + for (int ch = 0; ch < numChannels; ++ch) + { + if (static_cast (ch) >= correctedWindow.size()) continue; + const auto& corrected = correctedWindow[static_cast (ch)]; + int copyLen = std::min (windowNumSamples, static_cast (corrected.size())); + for (int i = 0; i < copyLen; ++i) + { + float blend = 1.0f; + if (i < xfadeLen && windowStartSample > 0) + blend = 0.5f * (1.0f - std::cos (juce::MathConstants::pi + * static_cast (i) / static_cast (xfadeLen))); + int distFromEnd = copyLen - 1 - i; + if (distFromEnd < xfadeLen && (windowStartSample + copyLen) < clipNumSamples) + { + float fo = 0.5f * (1.0f - std::cos (juce::MathConstants::pi + * static_cast (distFromEnd) / static_cast (xfadeLen))); + blend = std::min (blend, fo); + } + float orig = clipBuffer.getSample (ch, windowStartSample + i); + float corr = corrected[static_cast (i)]; + clipBuffer.setSample (ch, windowStartSample + i, orig * (1.0f - blend) + corr * blend); + } + } + renderedOutputBuffer = clipBuffer; + renderedOutputSamples = clipNumSamples; + } + } + } + + // Write the FULL clip (with corrected window stitched in) to a unique output file. + // Using a fixed name caused the playback engine's reader to read from a file being + // truncated by a concurrent job — producing garbled audio. + // A monotonic counter ensures each job writes to a different file slot. + static std::atomic s_pitchCorrSeq { 0 }; + int seq = s_pitchCorrSeq.fetch_add(1) & 0x1F; // 32 rotating slots + juce::File outputFile = sourceFile.getSiblingFile( + sourceFile.getFileNameWithoutExtension() + + (renderMode == "preview_segment" ? "_pcseg" : renderMode == "full_clip_hq" ? "_pcfinal" : "_pc") + + juce::String(seq) + ".wav"); + + // Delete the old file first to prevent stale data if the write partially fails. + // Without this, createOutputStream may fail to fully overwrite on some OS/filesystem combos. + if (outputFile.existsAsFile()) + outputFile.deleteFile(); + + juce::WavAudioFormat wavFormat; + std::unique_ptr fileStream(outputFile.createOutputStream()); + if (!fileStream) + { + logPitchEditorFormant("failed to open output stream clip=" + clipId + " outputFile=" + outputFile.getFullPathName()); return false; } - // For V1, always render at the device sample rate (= rate audio was recorded at). - // Sample rate conversion is not yet implemented, so ignore renderSampleRate. - double actualSampleRate = currentSampleRate; - if (actualSampleRate <= 0) actualSampleRate = 44100.0; - if (bitDepth != 16 && bitDepth != 24 && bitDepth != 32) bitDepth = 24; - if (numChannels < 1 || numChannels > 2) numChannels = 2; - // Determine if we need post-processing (lossy encoding or sample rate conversion) - juce::String formatLower = format.toLowerCase(); - bool isLossyFormat = (formatLower == "mp3" || formatLower == "ogg"); - bool needsSampleRateConversion = (renderSampleRate > 0 && renderSampleRate != actualSampleRate); - bool needsFFmpegPostProcess = isLossyFormat || needsSampleRateConversion; + std::unique_ptr writer( + wavFormat.createWriterFor(fileStream.get(), reader->sampleRate, + static_cast(numChannels), 32, {}, 0)); + if (!writer) + { + logPitchEditorFormant("failed to create wav writer clip=" + clipId + " outputFile=" + outputFile.getFullPathName()); + return false; + } + fileStream.release(); // writer takes ownership - // For lossy formats, bitDepth holds codec quality: - // MP3: bitrate in kbps (128, 192, 256, 320) - // OGG: quality level (1-10) - int codecQuality = isLossyFormat ? bitDepth : 0; - if (isLossyFormat) bitDepth = 24; // Render intermediate WAV at 24-bit + writer->writeFromAudioSampleBuffer(renderedOutputBuffer, 0, renderedOutputSamples); + writer.reset(); + if (shouldCancel && shouldCancel()) + return buildCancelledResult(); - // Parse stem track filter from source (e.g., "stem:trackId123") - juce::String stemTrackId; - bool isStemRender = source.startsWith("stem:"); - if (isStemRender) - stemTrackId = source.substring(5); // After "stem:" + if (previewSegmentRender) + { + playbackEngine.setClipRenderedPreviewSegment(clipId, outputFile, previewCoverageStartSec, previewCoverageEndSec); + } + else if (fullClipHQRender && isPlaying.load()) + { + playbackEngine.queueDeferredClipAudioFile(clipId, outputFile, false); + swapDeferred = true; + } + else + { + playbackEngine.replaceClipAudioFile(clipId, outputFile); + } + logPitchEditorFormant("wrote corrected file clip=" + clipId + + " outputFile=" + outputFile.getFullPathName() + + " swapDeferred=" + juce::String (swapDeferred ? "true" : "false")); + + juce::DynamicObject::Ptr resultObj = new juce::DynamicObject(); + resultObj->setProperty("outputFile", outputFile.getFullPathName()); + resultObj->setProperty("success", true); + resultObj->setProperty("renderMode", renderMode); + resultObj->setProperty("swapDeferred", swapDeferred); + return juce::var(resultObj.get()); +} - logToDisk("renderProject: Using actualSampleRate=" + juce::String(actualSampleRate) + - (needsSampleRateConversion ? " (will convert to " + juce::String(renderSampleRate) + " via ffmpeg)" : " (device rate)")); - if (isLossyFormat) - logToDisk("renderProject: Lossy format=" + formatLower + " codecQuality=" + juce::String(codecQuality)); - if (isStemRender) - logToDisk("renderProject: Stem render for trackId=" + stemTrackId); +juce::var AudioEngine::previewPitchCorrection(const juce::String& trackId, const juce::String& clipId, + const juce::var& notesJson) +{ + return applyPitchCorrection(trackId, clipId, notesJson, juce::var(), 0.0f); +} - // ========== 2. Stop real-time playback and block audio callback ========== - bool wasPlaying = isPlaying.load(); - bool wasRecording = isRecordMode.load(); - if (wasPlaying) isPlaying = false; - if (wasRecording) isRecordMode = false; - isRendering = true; // Block audio callback from processing FX plugins - juce::Thread::sleep(100); // Let audio callback finish current block +// ============================================================================= +// Phase 6: Polyphonic Pitch Detection +// ============================================================================= - // ========== 3. Snapshot clip data ========== - auto clipSnapshot = playbackEngine.getClipSnapshot(); - logToDisk("renderProject: Clip snapshot has " + juce::String((int)clipSnapshot.size()) + " clips"); +bool AudioEngine::isPolyphonicDetectionAvailable() const +{ +#if S13_HAS_ONNXRUNTIME + return true; +#else + return false; +#endif +} - if (clipSnapshot.empty()) +juce::var AudioEngine::analyzePolyphonic(const juce::String& trackId, const juce::String& clipId) +{ + // Lazy-load the ONNX model on first use + if (! polyModelLoadAttempted) { - logToDisk("renderProject: WARNING - No clips in playback engine! Render will be silent."); + polyModelLoadAttempted = true; + auto exeDir = juce::File::getSpecialLocation(juce::File::currentApplicationFile).getParentDirectory(); + auto modelFile = exeDir.getChildFile("models").getChildFile("basic_pitch_nmp.onnx"); + if (! modelFile.existsAsFile()) + { + juce::Logger::writeToLog("analyzePolyphonic: Model not found at " + modelFile.getFullPathName()); + // Try alternative location + modelFile = exeDir.getChildFile("basic_pitch_nmp.onnx"); + } + polyPitchDetector.loadModel(modelFile); } - // Log each clip for debugging - for (size_t i = 0; i < clipSnapshot.size(); ++i) + if (! polyPitchDetector.isModelLoaded()) { - const auto& clip = clipSnapshot[i]; - logToDisk(" Clip " + juce::String((int)i) + ": track=" + clip.trackId + - " file=" + clip.audioFile.getFileName() + - " start=" + juce::String(clip.startTime) + - " dur=" + juce::String(clip.duration) + - " offset=" + juce::String(clip.offset) + - " active=" + juce::String(clip.isActive ? "true" : "false")); + auto errObj = std::make_unique(); + errObj->setProperty("error", "Polyphonic model not loaded. Place basic_pitch_nmp.onnx in the models/ directory."); + errObj->setProperty("clipId", clipId); + errObj->setProperty("notes", juce::Array()); + return juce::var(errObj.release()); } - // ========== 4. Snapshot track params ========== - struct TrackSnapshot { - juce::String id; - float volumeDB; - float pan; - bool muted; - bool soloed; - }; - std::vector trackSnapshots; - bool anySoloed = false; + // Find the clip's audio file + auto clips = playbackEngine.getClipSnapshot(); + const PlaybackEngine::ClipInfo* foundClip = nullptr; + for (const auto& clip : clips) { - const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); - for (const auto& trackId : trackOrder) + if (clip.trackId == trackId && clip.clipId == clipId) { - auto it = trackMap.find(trackId); - if (it == trackMap.end() || !it->second) continue; - auto* track = it->second; - TrackSnapshot snap; - snap.id = trackId; - snap.volumeDB = track->getVolume(); - snap.pan = track->getPan(); - snap.muted = track->getMute(); - snap.soloed = track->getSolo(); - if (snap.soloed) anySoloed = true; - trackSnapshots.push_back(snap); - logToDisk(" Track " + trackId + ": vol=" + juce::String(snap.volumeDB) + - "dB pan=" + juce::String(snap.pan) + - " mute=" + juce::String(snap.muted ? "true" : "false") + - " solo=" + juce::String(snap.soloed ? "true" : "false")); + foundClip = &clip; + break; } } - logToDisk("renderProject: " + juce::String((int)trackSnapshots.size()) + " tracks, anySoloed=" + juce::String(anySoloed ? "true" : "false")); + if (! foundClip || ! foundClip->audioFile.existsAsFile()) + return juce::var(); - // ========== 5. Create format writer ========== - // For lossy formats (mp3/ogg) or sample rate conversion, render to temp WAV first - juce::File outputFile(filePath); - juce::File renderFile = outputFile; // File we actually write to (may be temp WAV) + // Read audio file + juce::AudioFormatManager fmtMgr; + fmtMgr.registerBasicFormats(); + std::unique_ptr reader(fmtMgr.createReaderFor(foundClip->audioFile)); + if (! reader) return juce::var(); - if (needsFFmpegPostProcess) + int startSample = static_cast(foundClip->offset * reader->sampleRate); + int numSamples = static_cast(foundClip->duration * reader->sampleRate); + numSamples = std::min(numSamples, static_cast(reader->lengthInSamples) - startSample); + if (numSamples <= 0) return juce::var(); + + // Read and mix to mono + juce::AudioBuffer buffer(static_cast(reader->numChannels), numSamples); + reader->read(&buffer, 0, numSamples, static_cast(startSample), true, true); + + juce::AudioBuffer mono(1, numSamples); + mono.clear(); + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + mono.addFrom(0, 0, buffer, ch, 0, numSamples, 1.0f / static_cast(buffer.getNumChannels())); + + // Run polyphonic analysis + auto result = polyPitchDetector.analyze(mono.getReadPointer(0), numSamples, + reader->sampleRate, clipId); + + // Cache result for reuse in polyphonic editing + polyAnalysisCache[clipId] = result; + + return PolyPitchDetector::resultToJSON(result); +} + +juce::var AudioEngine::extractMidiFromAudio(const juce::String& trackId, const juce::String& clipId) +{ + // First run polyphonic analysis + auto analysisJson = analyzePolyphonic(trackId, clipId); + if (! analysisJson.isObject()) + return juce::var(); + + // Return the analysis result — the frontend will create the MIDI track/clip + // from the note data (since track creation is a frontend-managed operation) + return analysisJson; +} + +// ============================================================================= +// Phase 7: Polyphonic Pitch Editing +// ============================================================================= + +juce::var AudioEngine::applyPolyPitchCorrection(const juce::String& trackId, + const juce::String& clipId, + const juce::var& editedNotesJson) +{ + // Check if we have a cached analysis for this clip + auto cacheIt = polyAnalysisCache.find(clipId); + if (cacheIt == polyAnalysisCache.end()) { - // Create temp WAV file next to the output - renderFile = outputFile.getParentDirectory().getChildFile( - outputFile.getFileNameWithoutExtension() + "_temp_render.wav"); + // Need to run analysis first + analyzePolyphonic(trackId, clipId); + cacheIt = polyAnalysisCache.find(clipId); + if (cacheIt == polyAnalysisCache.end()) + return PolyResynthesizer::resultToJSON("", false); } - std::unique_ptr audioFormat; - juce::String renderFormatLower = needsFFmpegPostProcess ? "wav" : formatLower; + const auto& analysisResult = cacheIt->second; - if (renderFormatLower == "wav") - audioFormat = std::make_unique(); - else if (renderFormatLower == "aiff" || renderFormatLower == "aif") - audioFormat = std::make_unique(); - else if (renderFormatLower == "flac") + // Find the clip's audio file + auto clips = playbackEngine.getClipSnapshot(); + const PlaybackEngine::ClipInfo* foundClip = nullptr; + for (const auto& clip : clips) { - audioFormat = std::make_unique(); - if (bitDepth > 24) bitDepth = 24; // FLAC max 24-bit + if (clip.trackId == trackId && clip.clipId == clipId) + { + foundClip = &clip; + break; + } } - else + if (!foundClip || !foundClip->audioFile.existsAsFile()) + return PolyResynthesizer::resultToJSON("", false); + + // Read audio + juce::AudioFormatManager fmtMgr; + fmtMgr.registerBasicFormats(); + std::unique_ptr reader(fmtMgr.createReaderFor(foundClip->audioFile)); + if (!reader) return PolyResynthesizer::resultToJSON("", false); + + int startSample = static_cast(foundClip->offset * reader->sampleRate); + int numSamples = static_cast(foundClip->duration * reader->sampleRate); + numSamples = std::min(numSamples, static_cast(reader->lengthInSamples) - startSample); + if (numSamples <= 0) return PolyResynthesizer::resultToJSON("", false); + + // Read and mix to mono + juce::AudioBuffer buffer(static_cast(reader->numChannels), numSamples); + reader->read(&buffer, 0, numSamples, static_cast(startSample), true, true); + + juce::AudioBuffer mono(1, numSamples); + mono.clear(); + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + mono.addFrom(0, 0, buffer, ch, 0, numSamples, 1.0f / static_cast(buffer.getNumChannels())); + + // Parse edited notes from JSON + std::vector editedNotes; + if (auto* arr = editedNotesJson.getArray()) { - logToDisk("renderProject: FAIL - unsupported format: " + format); - isRendering = false; - return false; + for (const auto& noteVar : *arr) + { + PolyResynthesizer::EditedNote edit; + edit.id = noteVar.getProperty("id", "").toString(); + edit.originalPitch = static_cast(static_cast(noteVar.getProperty("originalPitch", 0.0))); + edit.correctedPitch = static_cast(static_cast(noteVar.getProperty("correctedPitch", 0.0))); + edit.formantShift = static_cast(static_cast(noteVar.getProperty("formantShift", 0.0))); + edit.gain = static_cast(static_cast(noteVar.getProperty("gain", 0.0))); + editedNotes.push_back(edit); + } } - renderFile.getParentDirectory().createDirectory(); - if (renderFile.existsAsFile()) - renderFile.deleteFile(); - if (outputFile.existsAsFile()) - outputFile.deleteFile(); + // Run polyphonic resynthesis + auto corrected = polyResynthesizer.process( + mono.getReadPointer(0), numSamples, reader->sampleRate, + analysisResult.notes, editedNotes); - auto fileStream = std::make_unique(renderFile); - if (fileStream->failedToOpen()) + if (corrected.empty()) + return PolyResynthesizer::resultToJSON("", false); + + // Write corrected audio to a new file + juce::File outputFile = foundClip->audioFile.getSiblingFile( + foundClip->audioFile.getFileNameWithoutExtension() + "_polycorrected.wav"); + + juce::WavAudioFormat wavFormat; + std::unique_ptr fileStream(outputFile.createOutputStream()); + if (!fileStream) return PolyResynthesizer::resultToJSON("", false); + + std::unique_ptr writer( + wavFormat.createWriterFor(fileStream.get(), reader->sampleRate, 1, 32, {}, 0)); + if (!writer) return PolyResynthesizer::resultToJSON("", false); + fileStream.release(); // writer takes ownership + + juce::AudioBuffer outBuffer(1, numSamples); + outBuffer.copyFrom(0, 0, corrected.data(), numSamples); + writer->writeFromAudioSampleBuffer(outBuffer, 0, numSamples); + writer.reset(); + + return PolyResynthesizer::resultToJSON(outputFile.getFullPathName(), true); +} + +juce::var AudioEngine::soloPolyNote(const juce::String& trackId, + const juce::String& clipId, + const juce::String& noteId) +{ + // Check cache + auto cacheIt = polyAnalysisCache.find(clipId); + if (cacheIt == polyAnalysisCache.end()) { - logToDisk("renderProject: FAIL - could not open output file: " + renderFile.getFullPathName()); - isRendering = false; - return false; + analyzePolyphonic(trackId, clipId); + cacheIt = polyAnalysisCache.find(clipId); + if (cacheIt == polyAnalysisCache.end()) + return PolyResynthesizer::resultToJSON("", false); } - // Determine writer channels (always render in stereo internally, downmix to mono if needed) - int writerChannels = numChannels; + const auto& analysisResult = cacheIt->second; - std::unique_ptr writer( - audioFormat->createWriterFor(fileStream.get(), - actualSampleRate, - writerChannels, - bitDepth, - {}, // metadata - 0)); // quality - if (!writer) + // Find clip audio + auto clips = playbackEngine.getClipSnapshot(); + const PlaybackEngine::ClipInfo* foundClip = nullptr; + for (const auto& clip : clips) { - logToDisk("renderProject: FAIL - could not create writer (sr=" + - juce::String(actualSampleRate) + " bits=" + juce::String(bitDepth) + - " ch=" + juce::String(writerChannels) + ")"); - isRendering = false; - return false; + if (clip.trackId == trackId && clip.clipId == clipId) + { + foundClip = &clip; + break; + } } - fileStream.release(); // Writer owns the stream now + if (!foundClip || !foundClip->audioFile.existsAsFile()) + return PolyResynthesizer::resultToJSON("", false); - // ========== 6. Calculate total samples ========== - double tailSeconds = addTail ? (tailLengthMs / 1000.0) : 0.0; - double totalDuration = (endTime - startTime) + tailSeconds; - juce::int64 totalSamples = (juce::int64)(totalDuration * actualSampleRate); - const int blockSize = 512; + juce::AudioFormatManager fmtMgr; + fmtMgr.registerBasicFormats(); + std::unique_ptr reader(fmtMgr.createReaderFor(foundClip->audioFile)); + if (!reader) return PolyResynthesizer::resultToJSON("", false); - logToDisk("renderProject: totalDuration=" + juce::String(totalDuration) + - "s totalSamples=" + juce::String(totalSamples) + - " blockSize=" + juce::String(blockSize)); + int startSample = static_cast(foundClip->offset * reader->sampleRate); + int numSamples = static_cast(foundClip->duration * reader->sampleRate); + numSamples = std::min(numSamples, static_cast(reader->lengthInSamples) - startSample); + if (numSamples <= 0) return PolyResynthesizer::resultToJSON("", false); - // ========== 6b. Save plugin state & prepare plugins for offline rendering ========== - // Plugins were prepared for the device's buffer size (e.g. 128/256). The render - // uses 512-sample blocks. We must re-prepare all FX plugins with the render block - // size, otherwise plugins like Amplitube overflow internal buffers → noise/crash. - // We also reset() to clear stale state from real-time playback. + juce::AudioBuffer buffer(static_cast(reader->numChannels), numSamples); + reader->read(&buffer, 0, numSamples, static_cast(startSample), true, true); - // Collect all FX processors that will be used during render - struct PluginStateBackup { - juce::AudioProcessor* processor; - juce::MemoryBlock savedState; - }; - std::vector pluginBackups; + juce::AudioBuffer mono(1, numSamples); + mono.clear(); + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + mono.addFrom(0, 0, buffer, ch, 0, numSamples, 1.0f / static_cast(buffer.getNumChannels())); - // Lambda to save state, prepare, and reset a processor for render - auto prepareProcessorForRender = [&](juce::AudioProcessor* proc) { - if (!proc) return; - // Save current state - PluginStateBackup backup; - backup.processor = proc; - proc->getStateInformation(backup.savedState); - pluginBackups.push_back(std::move(backup)); - // Re-prepare for render block size - proc->prepareToPlay(actualSampleRate, blockSize); - proc->reset(); - }; + auto soloAudio = polyResynthesizer.soloNote( + mono.getReadPointer(0), numSamples, reader->sampleRate, + analysisResult.notes, noteId); - // Prepare track FX plugins - for (const auto& snap : trackSnapshots) + if (soloAudio.empty()) + return PolyResynthesizer::resultToJSON("", false); + + // Write solo audio to temp file + juce::File outputFile = foundClip->audioFile.getSiblingFile( + foundClip->audioFile.getFileNameWithoutExtension() + "_solo_" + noteId.substring(0, 8) + ".wav"); + + juce::WavAudioFormat wavFormat; + std::unique_ptr fileStream(outputFile.createOutputStream()); + if (!fileStream) return PolyResynthesizer::resultToJSON("", false); + + std::unique_ptr writer( + wavFormat.createWriterFor(fileStream.get(), reader->sampleRate, 1, 32, {}, 0)); + if (!writer) return PolyResynthesizer::resultToJSON("", false); + fileStream.release(); + + juce::AudioBuffer outBuffer(1, numSamples); + outBuffer.copyFrom(0, 0, soloAudio.data(), numSamples); + writer->writeFromAudioSampleBuffer(outBuffer, 0, numSamples); + writer.reset(); + + return PolyResynthesizer::resultToJSON(outputFile.getFullPathName(), true); +} + +// ============================================================================= +// Phase 8: Source Separation +// ============================================================================= + +bool AudioEngine::isStemSeparationAvailable() const +{ + return stemSeparator.isAvailable(); +} + +juce::var AudioEngine::getAiToolsStatus() +{ + return stemSeparator.getAiToolsStatus(); +} + +juce::var AudioEngine::installAiTools() +{ + return stemSeparator.installAiTools(); +} + +juce::var AudioEngine::separateStems(const juce::String& trackId, const juce::String& clipId) +{ + juce::ignoreUnused (trackId, clipId); + // Synchronous separation removed — use separateStemsAsync() instead + return StemSeparator::resultToJSON ({}, false, "Use async separation."); +} + +// ============================================================================= +// Phase 10: Async Stem Separation + Progress +// ============================================================================= + +juce::var AudioEngine::separateStemsAsync(const juce::String& trackId, const juce::String& clipId, + const juce::String& optionsJSON) +{ + juce::ignoreUnused (clipId); + auto result = std::make_unique(); + + auto aiToolsStatus = stemSeparator.getAiToolsStatus(); + if (auto* aiToolsStatusObject = aiToolsStatus.getDynamicObject()) { - auto it = trackMap.find(snap.id); - if (it == trackMap.end() || !it->second) continue; - auto* track = it->second; - for (int fx = 0; fx < track->getNumInputFX(); ++fx) - prepareProcessorForRender(track->getInputFXProcessor(fx)); - for (int fx = 0; fx < track->getNumTrackFX(); ++fx) - prepareProcessorForRender(track->getTrackFXProcessor(fx)); + const auto available = static_cast(aiToolsStatusObject->getProperty("available")); + if (! available) + { + const auto statusMessage = aiToolsStatusObject->getProperty("message").toString(); + const auto errorMessage = aiToolsStatusObject->getProperty("error").toString(); + result->setProperty("started", false); + result->setProperty("error", errorMessage.isNotEmpty() ? errorMessage : statusMessage); + return juce::var(result.release()); + } } - // Prepare master FX plugins - for (auto& node : masterFXNodes) + // If already running, ignore + if (stemSeparator.isRunning()) { - if (node && node->getProcessor()) - prepareProcessorForRender(node->getProcessor()); + result->setProperty("started", false); + result->setProperty("error", "Separation already in progress."); + return juce::var(result.release()); } - logToDisk("renderProject: Saved & prepared " + juce::String((int)pluginBackups.size()) + " FX plugins for render"); + // Parse options + auto options = juce::JSON::parse(optionsJSON); + juce::StringArray requestedStems; + if (auto* stemsArray = options.getProperty("stems", {}).getArray()) + for (const auto& s : *stemsArray) + requestedStems.add(s.toString()); - // ========== 7 & 8. Render loop (with optional 2-pass normalization) ========== - int numPasses = normalize ? 2 : 1; - float normGain = 1.0f; - float peakLevel = 0.0f; + if (requestedStems.isEmpty()) + requestedStems = StemSeparator::getAllStemNames(); - // Check if metronome was enabled for render - bool renderMetronomeAudio = metronome.isEnabled(); + bool useGPU = options.getProperty("gpu", false); - for (int pass = 0; pass < numPasses; ++pass) - { - logToDisk("renderProject: Pass " + juce::String(pass + 1) + " of " + juce::String(numPasses)); + // Find audio file — prefer filePath from options (frontend knows the clip's file), + // fall back to searching PlaybackEngine clips by trackId + juce::File audioFile; + juce::String filePathFromOptions = options.getProperty("filePath", "").toString(); - // Reset all FX plugins at the start of each pass so they begin from a clean state. - // This is critical for 2-pass normalization: pass 2 must produce identical output - // to pass 1, which requires identical initial plugin state. - for (auto& backup : pluginBackups) + if (filePathFromOptions.isNotEmpty()) + { + audioFile = juce::File(filePathFromOptions); + } + else + { + // Legacy fallback: search PlaybackEngine clips + auto clips = playbackEngine.getClipSnapshot(); + for (const auto& clip : clips) { - if (backup.processor) + if (clip.trackId == trackId) { - backup.processor->setStateInformation(backup.savedState.getData(), - (int)backup.savedState.getSize()); - backup.processor->reset(); + audioFile = clip.audioFile; + break; } } + } - // Create fresh offline playback engine for each pass (deterministic reads) - PlaybackEngine passPlayback; - for (const auto& clip : clipSnapshot) - { - passPlayback.addClip(clip.audioFile, clip.startTime, clip.duration, - clip.trackId, clip.offset, clip.volumeDB, - clip.fadeIn, clip.fadeOut); - } + if (! audioFile.existsAsFile()) + { + juce::Logger::writeToLog("StemSeparator: Audio file not found: " + audioFile.getFullPathName()); + result->setProperty("started", false); + result->setProperty("error", "Audio file not found: " + audioFile.getFullPathName()); + return juce::var(result.release()); + } - // Create fresh metronome for each pass - Metronome renderMet; - if (renderMetronomeAudio) - { - renderMet.prepareToPlay(actualSampleRate, blockSize); - renderMet.setBpm(tempo); - renderMet.setTimeSignature(timeSigNumerator, timeSigDenominator); - renderMet.setVolume(metronome.getVolume()); - renderMet.setAccentBeats(metronome.getAccentBeats()); - renderMet.setEnabled(true); - } + // Cache check + juce::String cacheKey = audioFile.getFullPathName(); + auto cacheIt = stemFileCache.find(cacheKey); + if (cacheIt != stemFileCache.end()) + { + bool allExist = true; + for (const auto& key : cacheIt->second.getAllKeys()) + if (! juce::File(cacheIt->second[key]).existsAsFile()) { allExist = false; break; } - if (pass == 1) + if (allExist) { - // Second pass: calculate normalization gain - if (peakLevel > 0.0f) - normGain = 1.0f / peakLevel; - else - normGain = 1.0f; - logToDisk("renderProject: Normalize gain = " + juce::String(normGain) + - " (peak was " + juce::String(peakLevel) + ")"); + // Already separated — no need to re-run + juce::Logger::writeToLog("StemSeparator: Using cached stems."); + result->setProperty("started", false); + result->setProperty("cached", true); + return juce::var(result.release()); } + stemFileCache.erase(cacheIt); + } - juce::int64 samplesRemaining = totalSamples; - double currentTimeSeconds = startTime; - double samplePositionForMetronome = startTime * actualSampleRate; - float passPeak = 0.0f; // Track peak for this pass + // Create output directory alongside the audio file + auto outputDir = audioFile.getParentDirectory() + .getChildFile(audioFile.getFileNameWithoutExtension() + "_stems"); - while (samplesRemaining > 0) - { - int samplesThisBlock = (int)std::min((juce::int64)blockSize, samplesRemaining); + // Start Python subprocess + if (! stemSeparator.startSeparation(audioFile, outputDir, requestedStems, useGPU)) + { + juce::Logger::writeToLog("StemSeparator: Failed to start separation."); + result->setProperty("started", false); + result->setProperty("error", "Failed to start Python process. Check that Python and audio-separator are installed."); + return juce::var(result.release()); + } - // Master buffer (always stereo internally) - juce::AudioBuffer masterBuffer(2, samplesThisBlock); - masterBuffer.clear(); + result->setProperty("started", true); + return juce::var(result.release()); +} - // Add metronome if enabled - if (renderMetronomeAudio) - { - renderMet.getNextAudioBlock(masterBuffer, samplePositionForMetronome); - } +juce::var AudioEngine::getStemSeparationProgress() +{ + auto obj = std::make_unique(); - // Process each track - for (const auto& snap : trackSnapshots) - { - // Stem rendering: only process the specified track - if (isStemRender && snap.id != stemTrackId) continue; + auto progress = stemSeparator.pollProgress(); - // Skip muted tracks (unless stem render — always render the target track) - if (!isStemRender && snap.muted) continue; - // If any track is soloed, skip non-soloed tracks (unless stem render) - if (!isStemRender && anySoloed && !snap.soloed) continue; + obj->setProperty("state", progress.state); + obj->setProperty("progress", static_cast(progress.progress)); - // Fill track buffer from clips - juce::AudioBuffer trackBuffer(2, samplesThisBlock); - trackBuffer.clear(); - passPlayback.fillTrackBuffer(snap.id, trackBuffer, currentTimeSeconds, - samplesThisBlock, actualSampleRate); + if (progress.state == "done" && progress.stemFiles.size() > 0) + { + juce::Array stems; + for (const auto& key : progress.stemFiles.getAllKeys()) + { + auto stemObj = std::make_unique(); + stemObj->setProperty("name", key); + stemObj->setProperty("filePath", progress.stemFiles[key]); + stems.add(juce::var(stemObj.release())); + } + obj->setProperty("stemFiles", stems); - // Process track FX chain BEFORE volume/pan (matches real-time signal flow) - // Channel-safe: expand buffer if plugin needs more channels than our stereo buffer - { - auto it = trackMap.find(snap.id); - if (it != trackMap.end() && it->second) - { - auto* track = it->second; - int numInputFX = track->getNumInputFX(); - int numTrackFX = track->getNumTrackFX(); - if (numInputFX > 0 || numTrackFX > 0) - { - juce::MidiBuffer midiMessages; - auto safeRenderFX = [&](juce::AudioProcessor* proc) { - int pluginCh = juce::jmax(proc->getTotalNumInputChannels(), - proc->getTotalNumOutputChannels()); - if (pluginCh <= trackBuffer.getNumChannels()) - { - proc->processBlock(trackBuffer, midiMessages); - } - else - { - // Expand buffer for this plugin (render thread — allocation OK) - juce::AudioBuffer expanded(pluginCh, samplesThisBlock); - expanded.clear(); - for (int ch = 0; ch < trackBuffer.getNumChannels(); ++ch) - expanded.copyFrom(ch, 0, trackBuffer, ch, 0, samplesThisBlock); - proc->processBlock(expanded, midiMessages); - for (int ch = 0; ch < trackBuffer.getNumChannels(); ++ch) - trackBuffer.copyFrom(ch, 0, expanded, ch, 0, samplesThisBlock); - } - }; - for (int fx = 0; fx < numInputFX; ++fx) - { - auto* proc = track->getInputFXProcessor(fx); - if (proc) safeRenderFX(proc); - } - for (int fx = 0; fx < numTrackFX; ++fx) - { - auto* proc = track->getTrackFXProcessor(fx); - if (proc) safeRenderFX(proc); - } - } - } - } + // Generate peak caches for stem files + for (const auto& key : progress.stemFiles.getAllKeys()) + { + juce::File stemFile(progress.stemFiles[key]); + if (stemFile.existsAsFile()) + { + peakCache.generateAsync(stemFile, [stemFile]() { + juce::Logger::writeToLog("PeakCache: Generated peaks for stem " + stemFile.getFileName()); + }); + } + } + } - // Apply per-track volume/pan AFTER FX (matches real-time signal flow) - float volumeGain = juce::Decibels::decibelsToGain(snap.volumeDB); - const float pi = juce::MathConstants::pi; - float panAngle = (snap.pan + 1.0f) * pi / 4.0f; - float leftGain = std::cos(panAngle) * volumeGain; - float rightGain = std::sin(panAngle) * volumeGain; + if (progress.state == "error") + obj->setProperty("error", progress.error); - juce::FloatVectorOperations::multiply(trackBuffer.getWritePointer(0), leftGain, samplesThisBlock); - juce::FloatVectorOperations::multiply(trackBuffer.getWritePointer(1), rightGain, samplesThisBlock); + return juce::var(obj.release()); +} - // Mix into master buffer - for (int ch = 0; ch < 2; ++ch) - { - masterBuffer.addFrom(ch, 0, trackBuffer, ch, 0, samplesThisBlock); - } - } +void AudioEngine::cancelStemSeparation() +{ + stemSeparator.cancel(); +} - // Process master FX chain (channel-safe, render thread — allocation OK) - // Skip master FX for stem renders (export raw track output) - if (!isStemRender && !masterFXNodes.empty()) - { - juce::MidiBuffer dummyMidi; - for (auto& node : masterFXNodes) - { - if (node && node->getProcessor()) - { - auto* proc = node->getProcessor(); - int pluginCh = juce::jmax(proc->getTotalNumInputChannels(), - proc->getTotalNumOutputChannels()); - if (pluginCh <= masterBuffer.getNumChannels()) - { - proc->processBlock(masterBuffer, dummyMidi); - } - else - { - juce::AudioBuffer expanded(pluginCh, samplesThisBlock); - expanded.clear(); - for (int ch = 0; ch < masterBuffer.getNumChannels(); ++ch) - expanded.copyFrom(ch, 0, masterBuffer, ch, 0, samplesThisBlock); - proc->processBlock(expanded, dummyMidi); - for (int ch = 0; ch < masterBuffer.getNumChannels(); ++ch) - masterBuffer.copyFrom(ch, 0, expanded, ch, 0, samplesThisBlock); - } - } - } - } +void AudioEngine::cancelAiToolsInstall() +{ + stemSeparator.cancelAiToolsInstall(); +} - // Apply master pan (constant power law) — skip for stem renders - if (!isStemRender) - { - const float pi = juce::MathConstants::pi; - float panAngle = (masterPan + 1.0f) * pi / 4.0f; - float leftGain = std::cos(panAngle); - float rightGain = std::sin(panAngle); +// ============================================================================= +// Phase 9: ARA Plugin Hosting +// ============================================================================= - juce::FloatVectorOperations::multiply(masterBuffer.getWritePointer(0), leftGain, samplesThisBlock); - juce::FloatVectorOperations::multiply(masterBuffer.getWritePointer(1), rightGain, samplesThisBlock); - } +juce::var AudioEngine::initializeARAForTrack(const juce::String& trackId, int fxIndex) +{ + auto obj = std::make_unique(); - // Apply master volume — skip for stem renders - if (!isStemRender) - { - for (int ch = 0; ch < 2; ++ch) - { - juce::FloatVectorOperations::multiply(masterBuffer.getWritePointer(ch), masterVolume, samplesThisBlock); - } - } + auto it = trackMap.find(trackId); + if (it == trackMap.end()) + { + obj->setProperty("success", false); + obj->setProperty("error", "Track not found: " + trackId); + return juce::var(obj.release()); + } - // Apply normalization gain (pass 2 only) - if (pass == 1 && normGain != 1.0f) - { - for (int ch = 0; ch < 2; ++ch) - { - juce::FloatVectorOperations::multiply(masterBuffer.getWritePointer(ch), normGain, samplesThisBlock); - } - } + auto* track = it->second; + bool success = track->initializeARA(fxIndex, currentSampleRate, currentBlockSize); - // Measure peak level - for (int ch = 0; ch < 2; ++ch) - { - auto range = masterBuffer.findMinMax(ch, 0, samplesThisBlock); - float chPeak = std::max(std::abs(range.getStart()), std::abs(range.getEnd())); - if (chPeak > passPeak) passPeak = chPeak; - } + obj->setProperty("success", success); + if (!success) + obj->setProperty("error", "Failed to initialize ARA. Plugin may not support ARA."); - if (pass == 0 && normalize) - { - // Pass 1: accumulate peak for normalization - if (passPeak > peakLevel) peakLevel = passPeak; - } + return juce::var(obj.release()); +} - // Write to file (final pass only) - bool isFinalPass = (pass == numPasses - 1); - if (isFinalPass) - { - if (numChannels == 1) - { - // Mono downmix: average L+R - juce::AudioBuffer monoBuffer(1, samplesThisBlock); - monoBuffer.clear(); - monoBuffer.addFrom(0, 0, masterBuffer, 0, 0, samplesThisBlock, 0.5f); - monoBuffer.addFrom(0, 0, masterBuffer, 1, 0, samplesThisBlock, 0.5f); - writer->writeFromAudioSampleBuffer(monoBuffer, 0, samplesThisBlock); - } - else - { - writer->writeFromAudioSampleBuffer(masterBuffer, 0, samplesThisBlock); - } - } +juce::var AudioEngine::addARAClip(const juce::String& trackId, const juce::String& clipId) +{ + auto obj = std::make_unique(); - currentTimeSeconds += (double)samplesThisBlock / actualSampleRate; - samplePositionForMetronome += samplesThisBlock; - samplesRemaining -= samplesThisBlock; + auto it = trackMap.find(trackId); + if (it == trackMap.end() || !it->second->hasActiveARA()) + { + obj->setProperty("success", false); + obj->setProperty("error", "Track not found or ARA not active."); + return juce::var(obj.release()); + } + + // Find the clip's audio file + auto clips = playbackEngine.getClipSnapshot(); + const PlaybackEngine::ClipInfo* foundClip = nullptr; + for (const auto& clip : clips) + { + if (clip.trackId == trackId && clip.clipId == clipId) + { + foundClip = &clip; + break; } + } - logToDisk("renderProject: Pass " + juce::String(pass + 1) + " complete. Peak level: " + juce::String(passPeak)); + if (!foundClip || !foundClip->audioFile.existsAsFile()) + { + obj->setProperty("success", false); + obj->setProperty("error", "Clip audio file not found."); + return juce::var(obj.release()); } - // Flush and close - writer.reset(); + logToDisk("addARAClip: file=" + foundClip->audioFile.getFullPathName() + + " exists=" + juce::String(foundClip->audioFile.existsAsFile() ? 1 : 0) + + " start=" + juce::String(foundClip->startTime) + + " dur=" + juce::String(foundClip->duration) + + " offset=" + juce::String(foundClip->offset)); - // ========== 9. FFmpeg post-processing (lossy encoding / sample rate conversion) ========== - if (needsFFmpegPostProcess) + auto* ara = it->second->getARAController(); + + // Deactivate the plugin while holding the callback lock. + // ARA requires the plugin to be in an unprepared state when adding + // PlaybackRegions to renderers. Without the lock, releaseResources() + // triggers an audio device restart cascade. + int araFxIdx = it->second->getARAFXIndex(); + auto* araPlugin = (araFxIdx >= 0) ? it->second->getTrackFXProcessor(araFxIdx) : nullptr; + + if (araPlugin != nullptr) { - logToDisk("renderProject: Starting FFmpeg post-processing..."); - double targetSR = needsSampleRateConversion ? renderSampleRate : 0; - bool ffmpegOk = convertWithFFmpeg(renderFile, outputFile, formatLower, targetSR, codecQuality); + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + araPlugin->releaseResources(); + logToDisk("addARAClip: plugin deactivated (releaseResources)"); + } - // Clean up temp file - renderFile.deleteFile(); + auto sourceId = ara->addAudioSource(foundClip->audioFile, clipId, + foundClip->startTime, foundClip->duration, + foundClip->offset); - if (!ffmpegOk) + // Reactivate the plugin after regions are added + if (araPlugin != nullptr) + { + const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); + double sr = currentSampleRate > 0 ? currentSampleRate : 44100.0; + int bs = currentBlockSize > 0 ? currentBlockSize : 512; + const bool forceFloat = it->second->getTrackFXPrecisionOverride(araFxIdx); + prepareHostedProcessorPreservingLayout(araPlugin, sr, bs, + forceFloat ? ProcessingPrecisionMode::Float32 : processingPrecisionMode); + logToDisk("addARAClip: plugin reactivated (prepareToPlay sr=" + juce::String(sr) + " bs=" + juce::String(bs) + ")"); + + // Warmup: call processBlock with isPlaying=true to initialize the ARA + // renderer's internal state. Without this, editing notes before the first + // play causes ~300ms/block because the renderer hasn't been initialized. { - logToDisk("renderProject: FAIL - FFmpeg post-processing failed"); - // Restore plugins before returning (clamp max-block to at least 512) - for (auto& backup : pluginBackups) + struct WarmupPlayHead : juce::AudioPlayHead { - if (backup.processor) + double sr = 44100.0; + double pos = 0.0; + int blockSz = 512; + + juce::Optional getPosition() const override { - backup.processor->prepareToPlay(currentSampleRate, juce::jmax(currentBlockSize, 512)); - backup.processor->setStateInformation(backup.savedState.getData(), - (int)backup.savedState.getSize()); - backup.processor->reset(); + PositionInfo info; + info.setIsPlaying (true); + info.setTimeInSeconds (pos); + info.setTimeInSamples (static_cast (pos * sr)); + info.setBpm (120.0); + juce::AudioPlayHead::TimeSignature ts; + ts.numerator = 4; + ts.denominator = 4; + info.setTimeSignature (ts); + return info; } + }; + + WarmupPlayHead warmupHead; + warmupHead.sr = sr; + warmupHead.blockSz = bs; + + araPlugin->setPlayHead (&warmupHead); + + juce::AudioBuffer warmupBuf (2, bs); + juce::MidiBuffer warmupMidi; + constexpr int warmupBlocks = 8; + + for (int i = 0; i < warmupBlocks; ++i) + { + warmupBuf.clear(); + araPlugin->processBlock (warmupBuf, warmupMidi); + warmupHead.pos += static_cast (bs) / sr; } - isRendering = false; - return false; + + // Restore AudioEngine as playhead + araPlugin->setPlayHead (this); + logToDisk ("addARAClip: renderer warmup complete (" + juce::String (warmupBlocks) + " blocks)"); } } - // ========== 10. Restore plugin state for real-time playback ========== - // Re-prepare all FX plugins for the device's buffer size and restore their - // saved state so real-time playback continues as if render never happened. - // Clamp max-block to at least 512 (same rationale as track FX preparation). - for (auto& backup : pluginBackups) + logToDisk("addARAClip: result=" + juce::String(sourceId.isNotEmpty() ? "OK" : "FAILED")); + + obj->setProperty("success", sourceId.isNotEmpty()); + if (sourceId.isEmpty()) + obj->setProperty("error", "Failed to add audio source to ARA document. Only WAV files are supported."); + + return juce::var(obj.release()); +} + +juce::var AudioEngine::removeARAClip(const juce::String& trackId, const juce::String& clipId) +{ + auto obj = std::make_unique(); + + auto it = trackMap.find(trackId); + if (it == trackMap.end() || !it->second->hasActiveARA()) { - if (backup.processor) - { - backup.processor->prepareToPlay(currentSampleRate, juce::jmax(currentBlockSize, 512)); - backup.processor->setStateInformation(backup.savedState.getData(), - (int)backup.savedState.getSize()); - backup.processor->reset(); - } + obj->setProperty("success", false); + return juce::var(obj.release()); } - logToDisk("renderProject: Restored " + juce::String((int)pluginBackups.size()) + " FX plugins for real-time"); - // Re-enable audio callback - isRendering = false; + it->second->getARAController()->removeAudioSource(clipId); + obj->setProperty("success", true); + return juce::var(obj.release()); +} - logToDisk("renderProject: SUCCESS - " + outputFile.getFullPathName() + - " (" + juce::String(outputFile.getSize() / 1024) + " KB)"); +juce::var AudioEngine::getARAStatusForTrack(const juce::String& trackId) const +{ + auto obj = std::make_unique(); + auto it = trackMap.find(trackId); + if (it == trackMap.end() || it->second == nullptr) + { + obj->setProperty("active", false); + obj->setProperty("activeFxIndex", -1); + obj->setProperty("lastAttemptFxIndex", -1); + obj->setProperty("lastAttemptComplete", false); + obj->setProperty("lastAttemptWasARAPlugin", false); + obj->setProperty("lastAttemptSucceeded", false); + obj->setProperty("error", "Track not found."); + return juce::var(obj.release()); + } - return true; + auto* track = it->second; + obj->setProperty("active", track->hasActiveARA()); + obj->setProperty("activeFxIndex", track->getARAFXIndex()); + obj->setProperty("lastAttemptFxIndex", track->getARALastAttemptFXIndex()); + obj->setProperty("lastAttemptComplete", track->isARALastAttemptComplete()); + obj->setProperty("lastAttemptWasARAPlugin", track->wasARALastAttemptForARAPlugin()); + obj->setProperty("lastAttemptSucceeded", track->didARALastAttemptSucceed()); + const auto debugSnapshot = track->getARADebugSnapshot(); + obj->setProperty("analysisProgress", debugSnapshot.analysisProgress); + obj->setProperty("analysisComplete", debugSnapshot.analysisComplete); + obj->setProperty("analysisRequested", debugSnapshot.analysisRequested); + obj->setProperty("analysisStarted", debugSnapshot.analysisStarted); + obj->setProperty("lastAnalysisProgressValue", debugSnapshot.lastAnalysisProgressValue); + obj->setProperty("sourceCount", debugSnapshot.sourceCount); + obj->setProperty("playbackRegionCount", debugSnapshot.playbackRegionCount); + obj->setProperty("audioSourceSamplesAccessEnabled", debugSnapshot.audioSourceSamplesAccessEnabled); + obj->setProperty("editorRendererAttached", debugSnapshot.editorRendererAttached); + obj->setProperty("playbackRendererAttached", debugSnapshot.playbackRendererAttached); + obj->setProperty("error", track->getARALastAttemptError()); + return juce::var(obj.release()); +} + +juce::var AudioEngine::shutdownARAForTrack(const juce::String& trackId) +{ + auto obj = std::make_unique(); + + auto it = trackMap.find(trackId); + if (it == trackMap.end()) + { + obj->setProperty("success", false); + return juce::var(obj.release()); + } + + it->second->shutdownARA(); + obj->setProperty("success", true); + return juce::var(obj.release()); +} + +bool AudioEngine::isARAActiveForTrack(const juce::String& trackId) const +{ + auto it = trackMap.find(trackId); + if (it == trackMap.end() || !it->second) + return false; + return it->second->hasActiveARA(); } diff --git a/Source/AudioEngine.h b/Source/AudioEngine.h index 788f828..d4c059e 100644 --- a/Source/AudioEngine.h +++ b/Source/AudioEngine.h @@ -3,6 +3,7 @@ #include #include "TrackProcessor.h" #include "AudioRecorder.h" +#include "MIDIRecorder.h" #include "PlaybackEngine.h" #include "PluginManager.h" #include "PluginWindowManager.h" @@ -10,13 +11,29 @@ #include "Metronome.h" #include "PeakCache.h" #include "AudioAnalyzer.h" +#include "ScriptEngine.h" +#include "ControlSurfaceManager.h" +#include "TimecodeSync.h" +#include "VideoReader.h" +#include "DDPExporter.h" +#include "TriggerEngine.h" +#include "SessionInterchange.h" +#include +#include "PolyPitchDetector.h" +#include "PolyResynthesizer.h" +#include "StemSeparator.h" #include #include // ... (skip lines) ... class AudioEngine : public juce::AudioIODeviceCallback, - public juce::AudioPlayHead + public juce::AudioPlayHead, + public ControlSurfaceCallback, + public juce::Timer { + struct RealtimeTrackEntry; + struct ActiveFXStage; + public: AudioEngine(); ~AudioEngine() override; @@ -31,6 +48,22 @@ class AudioEngine : public juce::AudioIODeviceCallback, void audioDeviceAboutToStart (juce::AudioIODevice* device) override; void audioDeviceStopped() override; + // Audio callback helpers (extracted for readability and testability) + void updateMasterMetering (const float* const* outputChannelData, int numOutputChannels, int numSamples); + void updatePhaseCorrelation (const float* const* outputChannelData, int numOutputChannels, int numSamples); + void updateSpectrumAnalyzer (const float* const* outputChannelData, int numOutputChannels, int numSamples); + static void buildSidechainProcessingOrder (const std::vector& rtTracks, + int processedOrder[], int& orderCount, int maxTracks); + void processMasterFXChain (const std::shared_ptr& rtMasterFX, + float* const* outputChannelData, int numOutputChannels, + int numSamples, bool useHybrid64Summing); + void processMonitoringFXChain (const std::shared_ptr& rtMonitoringFX, + float* const* outputChannelData, int numOutputChannels, + int numSamples, bool hybrid64PostChainActive); + void applyMasterGainPanMono (float* const* outputChannelData, int numOutputChannels, + int numSamples, double currentSamplePosition, + bool hybrid64PostChainActive); + juce::AudioDeviceManager& getDeviceManager() { return deviceManager; } // Messaging @@ -65,10 +98,21 @@ class AudioEngine : public juce::AudioIODeviceCallback, void setLoopMode(bool loop) { isLooping = loop; } bool getLoopMode() const { return isLooping; } double getTransportPosition() const { return currentSamplePosition / currentSampleRate; } - void setTransportPosition(double seconds) { currentSamplePosition = seconds * currentSampleRate; } + void setTransportPosition(double seconds); + bool hasAnyActiveARA() const; void setTempo(double bpm); double getTempo() const { return tempo; } + // Punch In/Out (Phase 3.1) + void setPunchRange(double startTime, double endTime, bool enabled); + bool getPunchEnabled() const { return punchEnabled.load(); } + + // Loop Recording (Phase 3.2) — handled via loop wrap detection in audio callback + + // Record-Safe (Phase 3.3) + void setTrackRecordSafe(const juce::String& trackId, bool safe); + bool getTrackRecordSafe(const juce::String& trackId) const; + // Metronome (Phase 3) void setMetronomeEnabled(bool enabled); void setMetronomeVolume(float volume); @@ -88,13 +132,25 @@ class AudioEngine : public juce::AudioIODeviceCallback, // Get clips that were completed in the last recording session std::vector getLastCompletedClips(); + // Get MIDI clips that were completed in the last recording session + std::vector getLastCompletedMIDIClips(); + juce::var getActiveRecordingMIDIPreviews(const juce::var& requests); + // Called on the message thread when a peak cache file finishes generating. // Set by MainComponent to emit a JS "peaksReady" event. std::function onPeaksReady; + // Access to PlaybackEngine (for pitch preview, etc.) + PlaybackEngine& getPlaybackEngine() { return playbackEngine; } + // Playback clip management - ID-based void addPlaybackClip(const juce::String& trackId, const juce::String& filePath, double startTime, double duration, - double offset = 0.0, double volumeDB = 0.0, double fadeIn = 0.0, double fadeOut = 0.0); + double offset = 0.0, double volumeDB = 0.0, double fadeIn = 0.0, double fadeOut = 0.0, + const juce::String& clipId = juce::String(), + const juce::String& pitchCorrectionSourceFilePath = juce::String(), + double pitchCorrectionSourceOffset = -1.0); + /** Batch-add multiple clips from a JSON array. Each element: {trackId, filePath, startTime, duration, offset, volumeDB, fadeIn, fadeOut, clipId, pitchCorrectionSourceFilePath?, pitchCorrectionSourceOffset?}. */ + void addPlaybackClipsBatch(const juce::String& clipsJSON); void removePlaybackClip(const juce::String& trackId, const juce::String& filePath); void clearPlaybackClips(); void clearTrackPlaybackClips(const juce::String& trackId); @@ -104,14 +160,39 @@ class AudioEngine : public juce::AudioIODeviceCallback, juce::var getAvailablePlugins(); bool addTrackInputFX(const juce::String& trackId, const juce::String& pluginPath, bool openEditor = true); bool addTrackFX(const juce::String& trackId, const juce::String& pluginPath, bool openEditor = true); + + // Built-in Effects (Phase 4.3) + bool addTrackBuiltInFX(const juce::String& trackId, const juce::String& effectName, bool isInputFX = false); + bool addMasterBuiltInFX(const juce::String& effectName); + juce::var getAvailableBuiltInFX(); + + // S13FX (JSFX) Management + bool addTrackS13FX(const juce::String& trackId, const juce::String& scriptPath, bool isInputFX = false); + bool addMasterS13FX(const juce::String& scriptPath); + juce::var getS13FXSliders(const juce::String& trackId, int fxIndex, bool isInputFX); + bool setS13FXSlider(const juce::String& trackId, int fxIndex, bool isInputFX, int sliderIndex, double value); + bool reloadS13FX(const juce::String& trackId, int fxIndex, bool isInputFX); + juce::var getAvailableS13FX(); + // Built-in FX Preset System + juce::var getBuiltInFXPresets(const juce::String& pluginName); + bool saveBuiltInFXPreset(const juce::String& trackId, int fxIndex, bool isInputFX, + const juce::String& presetName, bool isFactory = false); + bool loadBuiltInFXPreset(const juce::String& trackId, int fxIndex, bool isInputFX, + const juce::String& presetName); + bool deleteBuiltInFXPreset(const juce::String& pluginName, const juce::String& presetName); + // Plugin Editor Windows (Phase 3) - ID-based void openPluginEditor(const juce::String& trackId, int fxIndex, bool isInputFX); void openInstrumentEditor(const juce::String& trackId); void closePluginEditor(const juce::String& trackId, int fxIndex, bool isInputFX); + void closeAllPluginWindows(); + void setPluginWindowOwnerComponent(juce::Component* component); + void setPluginWindowShortcutForwardCallback(PluginWindowManager::ShortcutForwardCallback callback); // MIDI Device Management (Phase 2) juce::var getMIDIInputDevices(); + juce::var getMIDIOutputDevices(); bool openMIDIDevice(const juce::String& deviceName); void closeMIDIDevice(const juce::String& deviceName); juce::var getOpenMIDIDevices(); @@ -119,11 +200,22 @@ class AudioEngine : public juce::AudioIODeviceCallback, // Track Type Management (Phase 2) - ID-based void setTrackType(const juce::String& trackId, const juce::String& type); // 'audio', 'midi', 'instrument' void setTrackMIDIInput(const juce::String& trackId, const juce::String& deviceName, int channel); + void setTrackMIDIClips(const juce::String& trackId, const juce::String& clipsJSON); + bool sendMidiNote(const juce::String& trackId, int note, int velocity, bool isNoteOn); bool loadInstrument(const juce::String& trackId, const juce::String& vstPath); + void setProcessingPrecision(const juce::String& precisionMode); + juce::String getProcessingPrecision() const; + bool setTrackPluginPrecisionOverride(const juce::String& trackId, int fxIndex, bool isInputFX, const juce::String& mode); + bool setInstrumentPrecisionOverride(const juce::String& trackId, const juce::String& mode); + bool setMasterFXPrecisionOverride(int fxIndex, const juce::String& mode); + bool setMonitoringFXPrecisionOverride(int fxIndex, const juce::String& mode); + juce::var runReleaseGuardrails(); + juce::var runAutomatedRegressionSuite(); // Get loaded plugins info - ID-based juce::var getTrackInputFX(const juce::String& trackId); juce::var getTrackFX(const juce::String& trackId); + juce::var getPluginParameters(const juce::String& trackId, int fxIndex, bool isInputFX); void removeTrackInputFX(const juce::String& trackId, int fxIndex); void removeTrackFX(const juce::String& trackId, int fxIndex); void bypassTrackInputFX(const juce::String& trackId, int fxIndex, bool bypassed); @@ -136,15 +228,26 @@ class AudioEngine : public juce::AudioIODeviceCallback, juce::var getMasterFX(); void removeMasterFX(int fxIndex); void openMasterFXEditor(int fxIndex); + void bypassMasterFX(int fxIndex, bool bypassed); bool addMonitoringFX(const juce::String& pluginPath); + juce::var getMonitoringFX(); + void removeMonitoringFX(int fxIndex); + void openMonitoringFXEditor(int fxIndex); + void bypassMonitoringFX(int fxIndex, bool bypassed); void setMasterVolume(float volume); float getMasterVolume() const { return masterVolume; } void setMasterPan(float pan); float getMasterPan() const { return masterPan; } - + void setMasterMono(bool mono) { masterMono.store(mono); } + bool getMasterMono() const { return masterMono.load(); } + // Metering (Phase 4) juce::var getMeterLevels(); // Returns array of track RMS levels + juce::var getMeterClipStates(); float getMasterLevel() const; // Returns master output level + bool getMasterClipLatched() const; + void resetMeterClip(const juce::String& trackId); + juce::var getAudioDebugSnapshot() const; // Plugin State Serialization (F2 - Project Save/Load) juce::String getPluginState(const juce::String& trackId, int fxIndex, bool isInputFX); @@ -153,7 +256,7 @@ class AudioEngine : public juce::AudioIODeviceCallback, bool setMasterPluginState(int fxIndex, const juce::String& base64State); // Waveform Visualization - juce::var getWaveformPeaks(const juce::String& filePath, int samplesPerPixel, int numPixels); + juce::var getWaveformPeaks(const juce::String& filePath, int samplesPerPixel, int startSample, int numPixels); juce::var getRecordingPeaks(const juce::String& trackId, int samplesPerPixel, int numPixels); // Offline Render/Export @@ -169,6 +272,21 @@ class AudioEngine : public juce::AudioIODeviceCallback, bool normalize, bool addTail, double tailLengthMs, const juce::String& ditherType); + // Plugin Delay Compensation (PDC) + void recalculatePDC(); + + // Pan Law + void setPanLaw(const juce::String& law); + juce::String getPanLaw() const; + + // DC Offset per track + void setTrackDCOffset(const juce::String& trackId, bool enabled); + + // Sidechain Routing (Phase 4.4) + void setSidechainSource(const juce::String& destTrackId, int pluginIndex, const juce::String& sourceTrackId); + void clearSidechainSource(const juce::String& destTrackId, int pluginIndex); + juce::String getSidechainSource(const juce::String& destTrackId, int pluginIndex); + // Send/Bus Routing (Phase 11) int addTrackSend(const juce::String& sourceTrackId, const juce::String& destTrackId); void removeTrackSend(const juce::String& sourceTrackId, int sendIndex); @@ -176,15 +294,291 @@ class AudioEngine : public juce::AudioIODeviceCallback, void setTrackSendPan(const juce::String& sourceTrackId, int sendIndex, float pan); void setTrackSendEnabled(const juce::String& sourceTrackId, int sendIndex, bool enabled); void setTrackSendPreFader(const juce::String& sourceTrackId, int sendIndex, bool preFader); + void setTrackSendPhaseInvert(const juce::String& sourceTrackId, int sendIndex, bool invert); juce::var getTrackSends(const juce::String& trackId); + // Track Routing Features + void setTrackPhaseInvert(const juce::String& trackId, bool invert); + bool getTrackPhaseInvert(const juce::String& trackId) const; + void setTrackStereoWidth(const juce::String& trackId, float widthPercent); + float getTrackStereoWidth(const juce::String& trackId) const; + void setTrackMasterSendEnabled(const juce::String& trackId, bool enabled); + bool getTrackMasterSendEnabled(const juce::String& trackId) const; + void setTrackOutputChannels(const juce::String& trackId, int startChannel, int numChannels); + void setTrackPlaybackOffset(const juce::String& trackId, double offsetMs); + double getTrackPlaybackOffset(const juce::String& trackId) const; + void setTrackChannelCount(const juce::String& trackId, int numChannels); + int getTrackChannelCount(const juce::String& trackId) const; + void setTrackMIDIOutput(const juce::String& trackId, const juce::String& deviceName); + juce::String getTrackMIDIOutput(const juce::String& trackId) const; + juce::var getTrackRoutingInfo(const juce::String& trackId); + + // Lua Scripting (S13Script) + juce::var runScript(const juce::String& scriptPath); + juce::var runScriptCode(const juce::String& luaCode); + juce::String getScriptDirectory(); + juce::var listScripts(); + + // Timer callback for deferred Lua script execution + void timerCallback() override; + + // Automation (Phase 1.1) + // Set all automation points for a track parameter (bulk sync from frontend) + void setAutomationPoints(const juce::String& trackId, const juce::String& parameterId, + const juce::String& pointsJSON); + // Set automation mode for a track parameter + void setAutomationMode(const juce::String& trackId, const juce::String& parameterId, + const juce::String& modeStr); + // Get automation mode + juce::String getAutomationMode(const juce::String& trackId, const juce::String& parameterId); + // Clear automation for a track parameter + void clearAutomation(const juce::String& trackId, const juce::String& parameterId); + // Touch begin/end (for touch/latch recording modes) + void beginTouchAutomation(const juce::String& trackId, const juce::String& parameterId); + void endTouchAutomation(const juce::String& trackId, const juce::String& parameterId); + + // Tempo Map (Phase 1.2) + // Set all tempo markers from frontend (JSON array of {time, tempo}) + void setTempoMarkers(const juce::String& markersJSON); + // Get the effective BPM at a given time in seconds (step-wise lookup) + double getTempoAtTime(double timeSeconds) const; + // Clear all tempo markers (revert to single global tempo) + void clearTempoMarkers(); + + // Strip Silence (Phase 3.12) + juce::var detectSilentRegions(const juce::String& filePath, double thresholdDb, + double minSilenceMs, double minSoundMs, + double preAttackMs, double postReleaseMs); + + // Freeze Track (Phase 3.13) + juce::var freezeTrack(const juce::String& trackId); + bool unfreezeTrack(const juce::String& trackId); + // Audio Analysis (Phase 9) AudioAnalyzer& getAudioAnalyzer() { return audioAnalyzer; } + // Control Surface Support (Phase 3.10) + ControlSurfaceManager& getControlSurfaceManager() { return controlSurfaceManager; } + + // Timecode/Sync (Phase 3.9) + TimecodeSyncManager& getTimecodeSyncManager() { return timecodeSyncManager; } + + // Video Integration (Phase 3.8) + VideoReader& getVideoReader() { return videoReader; } + + // DDP Export (Phase 3.15) + DDPExporter& getDDPExporter() { return ddpExporter; } + + // Clip Launch / Trigger (Phase 4.1) + TriggerEngine& getTriggerEngine() { return triggerEngine; } + + // Session Interchange (Phase 3.14) + SessionInterchange& getSessionInterchange() { return sessionInterchange; } + + // Clip Gain Envelope (Phase 18.10) + void setClipGainEnvelope(const juce::String& trackId, const juce::String& clipId, + const juce::String& pointsJSON); + + // MIDI Learn (Phase 19.7) + struct MIDILearnMapping + { + int ccNumber; + juce::String trackId; + int pluginIndex; + int paramIndex; + }; + void startMIDILearnForPlugin(const juce::String& trackId, int pluginIndex, int paramIndex); + void stopMIDILearnMode(); + void clearMIDILearnMapping(int ccNumber); + juce::var getMIDILearnMappings(); + + // MIDI Import/Export (Phase 19.9) + juce::var importMIDIFile(const juce::String& filePath); + bool exportMIDIFile(const juce::String& trackId, const juce::String& clipId, + const juce::String& eventsJSON, const juce::String& outputPath, double clipTempo); + + // Plugin Presets (Phase 19.14) + juce::var getPluginPresets(const juce::String& trackId, int fxIndex, bool isInputFX); + bool loadPluginPreset(const juce::String& trackId, int fxIndex, bool isInputFX, + const juce::String& presetPath); + bool savePluginPreset(const juce::String& trackId, int fxIndex, bool isInputFX, + const juce::String& presetPath, const juce::String& presetName); + + // A/B Comparison (Phase 19.16) + bool storePluginABState(const juce::String& trackId, int fxIndex, bool isInputFX, + const juce::String& slot); + bool loadPluginABState(const juce::String& trackId, int fxIndex, bool isInputFX, + const juce::String& slot); + juce::String getPluginActiveSlot(const juce::String& trackId, int fxIndex, bool isInputFX); + + // Session Archive (Phase 20.5) + bool archiveSession(const juce::String& projectJsonPath, const juce::String& outputZipPath); + bool unarchiveSession(const juce::String& zipPath, const juce::String& outputDir); + + // Phase Correlation Meter (Phase 20.10) + float getPhaseCorrelation() const { return phaseCorrelationValue.load(std::memory_order_relaxed); } + + // Spectrum Analyzer (Phase 20.11) + juce::var getSpectrumData(); + + // MIDI diagnostics / plugin capabilities + juce::var getMidiDiagnostics() const; + juce::var getPluginCapabilities(const juce::String& pluginPath); + juce::var getPluginCompatibilityMatrix(); + juce::var runEngineBenchmarks(); + + // Built-in FX Oversampling (Phase 20.12) + bool setBuiltInFXOversampling(const juce::String& trackId, int fxIndex, bool isInputFX, bool enabled); + + // Channel Strip EQ (Phase 19.18) + void setChannelStripEQEnabled(const juce::String& trackId, bool enabled); + void setChannelStripEQParam(const juce::String& trackId, int paramIndex, float value); + float getChannelStripEQParam(const juce::String& trackId, int paramIndex); + + // Pitch Corrector bridge methods (auto mode) + juce::var getPitchCorrectorData(const juce::String& trackId, int fxIndex); + void setPitchCorrectorParam(const juce::String& trackId, int fxIndex, const juce::String& param, float value); + juce::var getPitchHistory(const juce::String& trackId, int fxIndex, int numFrames); + + // Pitch Corrector bridge methods (graphical mode) + juce::var analyzePitchContour(const juce::String& trackId, const juce::String& clipId); + juce::var analyzePitchContourDirect(const juce::String& filePath, double offset, double duration, const juce::String& clipId); + juce::var applyPitchCorrection(const juce::String& trackId, const juce::String& clipId, + const juce::var& notesJson, const juce::var& framesJson = juce::var(), + float globalFormantSemitones = 0.0f, + std::optional windowStartSec = std::nullopt, + std::optional windowEndSec = std::nullopt, + const juce::String& renderMode = "single", + std::function shouldCancel = {}); + juce::var previewPitchCorrection(const juce::String& trackId, const juce::String& clipId, const juce::var& notesJson); + + // Polyphonic pitch detection (Phase 6) + juce::var analyzePolyphonic(const juce::String& trackId, const juce::String& clipId); + juce::var extractMidiFromAudio(const juce::String& trackId, const juce::String& clipId); + bool isPolyphonicDetectionAvailable() const; + + // Polyphonic pitch editing (Phase 7) + juce::var applyPolyPitchCorrection(const juce::String& trackId, const juce::String& clipId, const juce::var& editedNotesJson); + juce::var soloPolyNote(const juce::String& trackId, const juce::String& clipId, const juce::String& noteId); + + // Source separation (Phase 8 + Phase 10) + juce::var separateStems(const juce::String& trackId, const juce::String& clipId); + bool isStemSeparationAvailable() const; + juce::var getAiToolsStatus(); + juce::var installAiTools(); + juce::var separateStemsAsync(const juce::String& trackId, const juce::String& clipId, const juce::String& optionsJSON); + juce::var getStemSeparationProgress(); + void cancelStemSeparation(); + void cancelAiToolsInstall(); + + // ARA Plugin Hosting (Phase 9) + juce::var initializeARAForTrack(const juce::String& trackId, int fxIndex); + juce::var addARAClip(const juce::String& trackId, const juce::String& clipId); + juce::var removeARAClip(const juce::String& trackId, const juce::String& clipId); + juce::var getARAStatusForTrack(const juce::String& trackId) const; + juce::var shutdownARAForTrack(const juce::String& trackId); + bool isARAActiveForTrack(const juce::String& trackId) const; + + // ControlSurfaceCallback overrides + void onControlSurfaceTrackVolume(const juce::String& trackId, float value01) override; + void onControlSurfaceTrackPan(const juce::String& trackId, float valueMinus1To1) override; + void onControlSurfaceTrackMute(const juce::String& trackId, bool muted) override; + void onControlSurfaceTrackSolo(const juce::String& trackId, bool soloed) override; + void onControlSurfaceTrackRecordArm(const juce::String& trackId, bool armed) override; + void onControlSurfaceTransportPlay() override; + void onControlSurfaceTransportStop() override; + void onControlSurfaceTransportRecord() override; + void onControlSurfaceMasterVolume(float value01) override; + float getTrackVolume01(const juce::String& trackId) const override; + float getTrackPan(const juce::String& trackId) const override; + bool getTrackMuted(const juce::String& trackId) const override; + bool getTrackSoloed(const juce::String& trackId) const override; + std::vector getTrackIds() const override; + // AudioPlayHead — provides tempo/position to hosted VST3 plugins juce::Optional getPosition() const override; private: + struct RealtimeTrackEntry + { + juce::String id; + juce::AudioProcessorGraph::Node::Ptr node; + std::shared_ptr> sidechainOutputBuffer; + std::shared_ptr> sendAccumBuffer; + std::vector sidechainSourceIds; + std::vector sends; + }; + + struct DesiredFXStageSlot + { + int slotId = 0; + juce::String name; + juce::String type; + juce::String pluginPath; + juce::String pluginFormat; + juce::String serializedState; + bool bypassed = false; + bool forceFloat = false; + }; + + struct DesiredFXStageSpec + { + std::vector slots; + }; + + struct ActiveFXStageSlot + { + int slotId = 0; + juce::String name; + juce::String type; + juce::String pluginPath; + juce::String pluginFormat; + bool bypassed = false; + bool forceFloat = false; + bool supportsDouble = false; + std::shared_ptr processor; + }; + + struct ActiveFXStage + { + uint64 generation = 0; + double sampleRate = 0.0; + int preparedBlockSize = 0; + ProcessingPrecisionMode precisionMode = ProcessingPrecisionMode::Float32; + std::vector slots; + }; + + using RealtimeTrackSnapshot = std::vector; + + juce::MidiBuffer buildTrackMidiBlock(const juce::String& trackId, double blockStartTimeSeconds, + int numSamples, double sampleRate, bool playing); + void queueAllNotesOffForTrack(TrackProcessor& track); + void queueAllNotesOffForAllTracks(); + void applyProcessingPrecisionToTrack(TrackProcessor& track); + void rebuildRealtimeProcessingSnapshots(); + std::unique_ptr createProcessorForStageSlot(const DesiredFXStageSlot& slot, + double sampleRate, + int preparedBlockSize, + ProcessingPrecisionMode precisionMode, + juce::String& errorMessage); + std::shared_ptr buildActiveFXStage(const DesiredFXStageSpec& spec, + double sampleRate, + int preparedBlockSize, + ProcessingPrecisionMode precisionMode, + bool monitoringStage, + juce::String& errorMessage); + bool publishMasterStageSpec(const DesiredFXStageSpec& spec); + bool publishMonitoringStageSpec(const DesiredFXStageSpec& spec); + void syncStageSpecStateFromActive(DesiredFXStageSpec& spec, const std::shared_ptr& activeStage); + void rebindStageEditors(const std::shared_ptr& oldStage, + const std::shared_ptr& newStage, + bool monitoringStage); + juce::String serialiseProcessorStateToBase64(juce::AudioProcessor* processor) const; + bool applyBase64StateToProcessor(juce::AudioProcessor* processor, const juce::String& base64State) const; + ActiveFXStageSlot* findActiveStageSlot(std::shared_ptr& stage, int slotId); + const ActiveFXStageSlot* findActiveStageSlot(const std::shared_ptr& stage, int slotId) const; + const DesiredFXStageSlot* findDesiredStageSlot(const DesiredFXStageSpec& spec, int index) const; + DesiredFXStageSlot* findDesiredStageSlot(DesiredFXStageSpec& spec, int index); // Set this AudioEngine as the AudioPlayHead on all plugins in a track void propagatePlayHead(TrackProcessor* track); // FFmpeg helpers for lossy encoding and sample rate conversion @@ -207,10 +601,14 @@ class AudioEngine : public juce::AudioIODeviceCallback, // Track storage - ID-based system std::map trackMap; // ID -> Track + std::map trackNodeMap; // ID -> graph node std::vector trackOrder; // Ordered list of track IDs for display/processing + std::shared_ptr realtimeTrackSnapshot; // Audio Recorder (Phase 2) AudioRecorder audioRecorder; + MIDIRecorder midiRecorder; + std::vector lastCompletedMIDIClips; PlaybackEngine playbackEngine; PeakCache peakCache; std::atomic isPlaying { false }; @@ -221,12 +619,34 @@ class AudioEngine : public juce::AudioIODeviceCallback, double currentSampleRate = 44100.0; int currentBlockSize = 512; // Device buffer size for re-preparing plugins after render int inputLatencySamples = 0; // Device input latency for recording compensation + std::atomic lastAudioBlockWallTimeMs { 0.0 }; + std::atomic lastAudioBlockDurationMs { 0.0 }; + std::atomic audioCallbackCounter { 0 }; + std::atomic firstCallbackAfterTransportStartPending { false }; std::atomic pendingRecordStartCapture { false }; // Audio thread captures start time - double tempo = 120.0; // BPM + double tempo = 120.0; // BPM (global default / fallback) int timeSigNumerator = 4; int timeSigDenominator = 4; Metronome metronome; + // Punch In/Out (Phase 3.1) + std::atomic punchEnabled { false }; + std::atomic punchStartTime { 0.0 }; // seconds + std::atomic punchEndTime { 0.0 }; // seconds + + // Loop Recording (Phase 3.2) + double prevSamplePosition = 0.0; // For detecting loop wraps (position jumps backward) + int loopTakeCounter = 0; + + // Tempo map — sorted list of {timeSeconds, bpm} markers. + // Accessed on the audio thread via getTempoAtTime(), guarded by ScopedTryLock. + struct TempoMarker { double timeSeconds; double bpm; }; + std::vector tempoMarkers; + mutable juce::CriticalSection tempoMapLock; + + // Dither mode for render (0=off, 1=TPDF, 2=noise-shaped). Set by renderProjectWithDither. + std::atomic pendingDitherMode_ { 0 }; + juce::File projectAudioFolder; std::vector lastCompletedClips; // Clips from last recording session @@ -236,15 +656,37 @@ class AudioEngine : public juce::AudioIODeviceCallback, // MIDI Management (Phase 2) std::unique_ptr midiManager; + std::atomic midiLateEventCount { 0 }; + std::atomic midiMaxEventsPerBlock { 0 }; + std::atomic midiLastComputedSampleOffset { 0 }; + ProcessingPrecisionMode processingPrecisionMode { ProcessingPrecisionMode::Float32 }; // Master FX (Phase 5) - std::unique_ptr masterFXChain; - std::unique_ptr monitoringFXChain; // Output-only, not in bounce - std::vector masterFXNodes; - std::vector monitoringFXNodes; + DesiredFXStageSpec desiredMasterStageSpec; + DesiredFXStageSpec desiredMonitoringStageSpec; + std::shared_ptr realtimeMasterFXSnapshot; + std::shared_ptr realtimeMonitoringFXSnapshot; + int nextMasterStageSlotId = 1; + int nextMonitoringStageSlotId = 1; + std::atomic masterStageGeneration { 0 }; + std::atomic monitoringStageGeneration { 0 }; + std::atomic masterStageBuildFailureCount { 0 }; + std::atomic monitoringStageBuildFailureCount { 0 }; + std::atomic masterStageLastBuildMs { 0.0 }; + std::atomic monitoringStageLastBuildMs { 0.0 }; float masterVolume = 1.0f; float masterPan = 0.0f; std::atomic masterOutputLevel { 0.0f }; // Peak level of master output + std::atomic lastPostTrackPlaybackPeak { 0.0f }; + std::atomic lastPostMonitoringInputPeak { 0.0f }; + std::atomic lastPostMasterFXPeak { 0.0f }; + std::atomic lastPostMonitoringFXPeak { 0.0f }; + std::atomic lastFinalOutputPeak { 0.0f }; + std::atomic lastActiveOutputChannels { 0 }; + std::atomic lastCallbackInputChannels { 0 }; + std::atomic lastCallbackOutputChannels { 0 }; + std::atomic lastReturnedRecordingClipCount { 0 }; + std::atomic lastAudioCallbackCounter { 0 }; // REAPER-style master peak meter decimation — matches the 10Hz metering timer. // At 32-sample ASIO blocks (1378 callbacks/sec), updating every 4096 samples @@ -252,21 +694,119 @@ class AudioEngine : public juce::AudioIODeviceCallback, static constexpr int MASTER_METER_UPDATE_SAMPLES = 4096; int masterMeterSampleCount { 0 }; float masterMeterPeakAccum { 0.0f }; + std::atomic masterClipLatched { false }; // Cached master pan gains — recalculated only when pan changes (avoids // cos/sin every audio callback, ~94 trig calls/sec at 48kHz/512) - std::atomic cachedMasterPanL { 0.707107f }; // cos(pi/4) - std::atomic cachedMasterPanR { 0.707107f }; // sin(pi/4) + std::atomic cachedMasterPanL { 1.0f }; + std::atomic cachedMasterPanR { 1.0f }; + + // Master automation (volume/pan curves) + AutomationList masterVolumeAutomation; + AutomationList masterPanAutomation; + + // Master mono downmix + std::atomic masterMono { false }; + + // Lua Scripting + ScriptEngine scriptEngine; // Audio Analysis (Phase 9) AudioAnalyzer audioAnalyzer; + // Control Surface Support (Phase 3.10) + ControlSurfaceManager controlSurfaceManager; + + // Timecode/Sync (Phase 3.9) + TimecodeSyncManager timecodeSyncManager; + + // Video Integration (Phase 3.8) + VideoReader videoReader; + + // DDP Export (Phase 3.15) + DDPExporter ddpExporter; + + // Clip Launch / Trigger (Phase 4.1) + TriggerEngine triggerEngine; + + // Session Interchange (Phase 3.14) + SessionInterchange sessionInterchange; + // Pre-allocated buffers — avoids heap allocs on the audio thread juce::AudioBuffer reusableTrackBuffer; juce::AudioBuffer reusableMasterBuffer; + juce::AudioBuffer reusableMasterBufferDouble; + juce::AudioBuffer masterFXFallbackBuffer; + juce::AudioBuffer monitoringFXFallbackBuffer; + std::atomic masterFXFallbackReuseCount { 0 }; + std::atomic monitoringFXFallbackReuseCount { 0 }; + std::atomic masterFXBusySkipCount { 0 }; + std::atomic monitoringFXBusySkipCount { 0 }; + + // Sidechain routing (Phase 4.4) + // Stores per-track output buffers after processing, so downstream tracks + // can use them as sidechain input. Key = trackId. Buffers are pre-allocated + // in audioDeviceAboutToStart and reused every callback. + std::map>> sidechainOutputBuffers; + + // Send accumulation buffers — each track has a buffer where incoming sends are mixed. + // Pre-allocated in audioDeviceAboutToStart and reused every callback. + std::map>> sendAccumBuffers; + + // Current pan law (applied to all tracks) + PanLaw currentPanLaw { PanLaw::Linear }; // Cached solo state — avoids scanning all tracks every callback std::atomic cachedAnySoloed { false }; + // MIDI Learn (Phase 19.7) + std::atomic midiLearnActive { false }; + juce::String midiLearnTrackId; + int midiLearnPluginIndex = -1; + int midiLearnParamIndex = -1; + std::vector midiLearnMappings; + juce::CriticalSection midiLearnLock; + + // A/B Comparison (Phase 19.16) + // Key: "trackId:fxIndex:isInputFX:slot" -> base64 plugin state + std::map pluginABStates; + // Key: "trackId:fxIndex:isInputFX" -> "A" or "B" + std::map pluginActiveSlots; + + // Phase Correlation Meter (Phase 20.10) + std::atomic phaseCorrelationValue { 1.0f }; // -1 to +1 + // Running accumulators for phase correlation (updated on audio thread) + double phaseCorr_sumLR { 0.0 }; + double phaseCorr_sumLL { 0.0 }; + double phaseCorr_sumRR { 0.0 }; + int phaseCorrSampleCount { 0 }; + static constexpr int PHASE_CORR_UPDATE_SAMPLES = 4096; + + // Spectrum Analyzer (Phase 20.11) + static constexpr int FFT_ORDER = 11; // 2^11 = 2048 + static constexpr int FFT_SIZE = 1 << FFT_ORDER; + juce::dsp::FFT spectrumFFT { FFT_ORDER }; + juce::dsp::WindowingFunction spectrumWindow { static_cast(FFT_SIZE), juce::dsp::WindowingFunction::hann }; + float spectrumInputBuffer[FFT_SIZE * 2] = {}; // ring buffer for FFT input + float spectrumOutputBuffer[FFT_SIZE] = {}; // magnitude spectrum + int spectrumWritePos { 0 }; + bool spectrumReady { false }; + juce::CriticalSection spectrumLock; + + // Polyphonic Pitch Detection (Phase 6) — lazy-loaded + PolyPitchDetector polyPitchDetector; + bool polyModelLoadAttempted = false; + + // Polyphonic Pitch Editing (Phase 7) + PolyResynthesizer polyResynthesizer; + // Cache last analysis result per clip for reuse in editing + std::map polyAnalysisCache; + + // Source Separation (Phase 8 + Phase 10) — Python subprocess + StemSeparator stemSeparator; + + // Stem file cache: hash(filePath+offset+duration) -> stem files (name -> path) + std::map stemFileCache; + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (AudioEngine) }; diff --git a/Source/AudioRecorder.cpp b/Source/AudioRecorder.cpp index 2ed8853..4ff32fa 100644 --- a/Source/AudioRecorder.cpp +++ b/Source/AudioRecorder.cpp @@ -1,5 +1,32 @@ #include "AudioRecorder.h" +namespace +{ +#if JUCE_DEBUG +constexpr bool kAudioRecordDebugLogs = true; +#else +constexpr bool kAudioRecordDebugLogs = false; +#endif + +static void logAudioRecord(const juce::String& message) +{ + if (kAudioRecordDebugLogs) + juce::Logger::writeToLog("[audio.record] " + message); +} + +static float peakFromBuffer(const juce::AudioBuffer& buffer, int numSamples) +{ + float peak = 0.0f; + const int channels = buffer.getNumChannels(); + for (int ch = 0; ch < channels; ++ch) + { + auto range = juce::FloatVectorOperations::findMinAndMax(buffer.getReadPointer(ch), numSamples); + peak = juce::jmax(peak, juce::jmax(std::abs(range.getStart()), std::abs(range.getEnd()))); + } + return peak; +} +} + AudioRecorder::AudioRecorder() { // Start the background I/O thread for ThreadedWriter @@ -14,6 +41,10 @@ AudioRecorder::~AudioRecorder() bool AudioRecorder::startRecording(const juce::String& trackId, const juce::File& file, double sampleRate, int numChannels) { + logAudioRecord("startRecording track=" + trackId + + " file=" + file.getFullPathName() + + " sampleRate=" + juce::String(sampleRate, 2) + + " channels=" + juce::String(numChannels)); // Stop any existing recording for this track (brief lock) { const juce::ScopedLock sl(writerLock); @@ -89,6 +120,8 @@ bool AudioRecorder::startRecording(const juce::String& trackId, const juce::File juce::Logger::writeToLog("AudioRecorder: Started recording track " + trackId + " to " + file.getFullPathName()); + logAudioRecord("startRecording success track=" + trackId + + " file=" + file.getFullPathName()); return true; } @@ -99,18 +132,33 @@ void AudioRecorder::writeBlock(const juce::String& trackId, const juce::AudioBuf // This is extremely rare and inaudible const juce::ScopedTryLock sl(writerLock); if (!sl.isLocked()) + { + static std::atomic globalLockMissLogCounter { 0 }; + const int lockMiss = ++globalLockMissLogCounter; + if ((lockMiss % 20) == 1) + logAudioRecord("writeBlock lock miss track=" + trackId + " count=" + juce::String(lockMiss)); return; + } auto it = activeRecordings.find(trackId); if (it == activeRecordings.end() || !it->second.isActive.load() || !it->second.threadedWriter) return; auto& state = it->second; + const float inputPeak = peakFromBuffer(buffer, numSamples); // ThreadedWriter::write() is audio-thread safe (lock-free ring buffer internally) // It copies data immediately, so buffer pointers don't need to remain valid state.threadedWriter->write(buffer.getArrayOfReadPointers(), numSamples); state.samplesWritten.fetch_add(numSamples); + if (state.debugLoggedBlocks < 5) + { + ++state.debugLoggedBlocks; + logAudioRecord("writeBlock track=" + trackId + + " numSamples=" + juce::String(numSamples) + + " inputPeak=" + juce::String(inputPeak, 4) + + " cumulativeSamples=" + juce::String(static_cast(state.samplesWritten.load()))); + } // Incremental peak accumulation for live waveform display. // Cheaper than the old interleaved-copy path: only min/max comparisons, @@ -218,6 +266,12 @@ std::vector AudioRecorder::stopAllRecordings( clip.startTime = state.startTime; clip.duration = state.samplesWritten.load() / currentSampleRate; completedClips.push_back(clip); + logAudioRecord("stopAllRecordings pending track=" + trackId + + " file=" + state.outputFile.getFullPathName() + + " startTime=" + juce::String(state.startTime, 3) + + " samplesWritten=" + juce::String(static_cast(state.samplesWritten.load())) + + " duration=" + juce::String(clip.duration, 3) + + (clip.duration <= 0.0 ? " WARNING_zero_duration" : "")); writersToFlush.push_back(std::move(state.threadedWriter)); } @@ -234,6 +288,7 @@ std::vector AudioRecorder::stopAllRecordings( juce::Logger::writeToLog("AudioRecorder: Stopped all recordings. Completed " + juce::String(completedClips.size()) + " clips."); + logAudioRecord("stopAllRecordings completed clipCount=" + juce::String(static_cast(completedClips.size()))); return completedClips; } @@ -266,11 +321,17 @@ juce::var AudioRecorder::getRecordingPeaks(const juce::String& trackId, int samp return peakData; // Each table entry covers PEAK_STRIDE samples. - // Map the requested samplesPerPixel to a number of entries per pixel. + // Use floating-point sample positions to correctly map pixels to peak entries, + // avoiding integer division truncation that caused waveform stretching. + // Previously, entriesPerPixel = samplesPerPixel / stride (integer division) + // would truncate e.g. 441/256 = 1 instead of 1.72, causing each pixel to read + // only one entry and covering just 58% of the actual recording at 100 pps. const int stride = ActiveRecording::PEAK_STRIDE; - const int entriesPerPixel = std::max(1, samplesPerPixel / stride); - const int actualPeaks = std::min(numPixels, - static_cast(tableSize / static_cast(entriesPerPixel))); + + // Calculate how many complete pixels the peak table data can cover + const double totalSamplesCovered = static_cast(tableSize) * stride; + const int maxPixelsFromData = static_cast(totalSamplesCovered / samplesPerPixel); + const int actualPeaks = std::min(numPixels, maxPixelsFromData); if (actualPeaks <= 0) return peakData; @@ -280,17 +341,19 @@ juce::var AudioRecorder::getRecordingPeaks(const juce::String& trackId, int samp for (int pixel = 0; pixel < actualPeaks; ++pixel) { - const size_t startEntry = static_cast(pixel * entriesPerPixel); - const int entriesToRead = std::min(entriesPerPixel, - static_cast(tableSize - startEntry)); + // Map pixel range to sample range, then to peak table entry range + const double pixelStartSample = pixel * static_cast(samplesPerPixel); + const double pixelEndSample = pixelStartSample + samplesPerPixel; + const size_t firstEntry = static_cast(pixelStartSample / stride); + const size_t lastEntry = std::min(static_cast(pixelEndSample / stride) + 1, + tableSize); for (int ch = 0; ch < numChannels; ++ch) { float minVal = 0.0f, maxVal = 0.0f; - for (int e = 0; e < entriesToRead; ++e) + for (size_t e = firstEntry; e < lastEntry; ++e) { - const float* entry = tablePtr + (startEntry + static_cast(e)) - * static_cast(numChannels * 2); + const float* entry = tablePtr + e * static_cast(numChannels * 2); const float eMin = entry[ch * 2]; const float eMax = entry[ch * 2 + 1]; if (eMin < minVal) minVal = eMin; diff --git a/Source/AudioRecorder.h b/Source/AudioRecorder.h index bf3dd28..e8e3c54 100644 --- a/Source/AudioRecorder.h +++ b/Source/AudioRecorder.h @@ -52,6 +52,8 @@ class AudioRecorder std::atomic samplesWritten { 0 }; // Total samples written int numChannels = 2; // Number of channels being recorded double sampleRate = 44100.0; + int debugLoggedBlocks = 0; + int debugWriteLockMissCount = 0; // Incremental peak table for live waveform display. // diff --git a/Source/AutomationList.cpp b/Source/AutomationList.cpp new file mode 100644 index 0000000..0876c4e --- /dev/null +++ b/Source/AutomationList.cpp @@ -0,0 +1,231 @@ +#include "AutomationList.h" +#include + +AutomationList::AutomationList() = default; + +// ============================================================================ +// Message-thread write API +// ============================================================================ + +void AutomationList::setPoints(std::vector newPoints) +{ + // Sort by time before storing + std::sort(newPoints.begin(), newPoints.end(), + [](const AutomationPoint& a, const AutomationPoint& b) { + return a.timeSamples < b.timeSamples; + }); + + const juce::ScopedLock sl(lock); + points = std::move(newPoints); +} + +void AutomationList::addPoint(double timeSamples, float value) +{ + const juce::ScopedLock sl(lock); + + AutomationPoint pt { timeSamples, value }; + + // Insert in sorted order + auto it = std::lower_bound(points.begin(), points.end(), pt, + [](const AutomationPoint& a, const AutomationPoint& b) { + return a.timeSamples < b.timeSamples; + }); + points.insert(it, pt); +} + +void AutomationList::removePointsInRange(double startSample, double endSample) +{ + const juce::ScopedLock sl(lock); + points.erase( + std::remove_if(points.begin(), points.end(), + [startSample, endSample](const AutomationPoint& p) { + return p.timeSamples >= startSample && p.timeSamples <= endSample; + }), + points.end()); +} + +void AutomationList::clear() +{ + const juce::ScopedLock sl(lock); + points.clear(); +} + +int AutomationList::getNumPoints() const +{ + const juce::ScopedLock sl(lock); + return static_cast(points.size()); +} + +// ============================================================================ +// Audio-thread read API +// ============================================================================ + +bool AutomationList::shouldPlayback() const +{ + auto m = mode.load(std::memory_order_relaxed); + switch (m) + { + case AutomationMode::Read: + return true; + case AutomationMode::Touch: + case AutomationMode::Latch: + return !isTouching.load(std::memory_order_relaxed); + case AutomationMode::Write: + case AutomationMode::Off: + default: + return false; + } +} + +bool AutomationList::shouldRecord() const +{ + auto m = mode.load(std::memory_order_relaxed); + switch (m) + { + case AutomationMode::Write: + return true; + case AutomationMode::Touch: + return isTouching.load(std::memory_order_relaxed); + case AutomationMode::Latch: + return true; // Latch always records once activated + case AutomationMode::Read: + case AutomationMode::Off: + default: + return false; + } +} + +int AutomationList::findPointBefore(double timeSamples) const +{ + // Binary search: find last point at or before timeSamples + // points must be sorted by timeSamples (guaranteed by setPoints/addPoint) + if (points.empty()) + return -1; + + int lo = 0; + int hi = static_cast(points.size()) - 1; + int result = -1; + + while (lo <= hi) + { + int mid = lo + (hi - lo) / 2; + if (points[static_cast(mid)].timeSamples <= timeSamples) + { + result = mid; + lo = mid + 1; + } + else + { + hi = mid - 1; + } + } + + return result; +} + +float AutomationList::eval(double timeSamples) const +{ + const juce::ScopedTryLock sl(lock); + if (!sl.isLocked() || points.empty()) + return defaultValue.load(std::memory_order_relaxed); + + int idx = findPointBefore(timeSamples); + + // Before first point — hold first point's value + if (idx < 0) + return points.front().value; + + auto sz = static_cast(points.size()); + + // At or after last point — hold last point's value + if (idx >= sz - 1) + return points.back().value; + + // Between two points — interpolate + const auto& p0 = points[static_cast(idx)]; + const auto& p1 = points[static_cast(idx + 1)]; + + if (interpolation == AutomationInterpolation::Discrete) + return p0.value; + + double dt = p1.timeSamples - p0.timeSamples; + if (dt <= 0.0) + return p0.value; + + double t = (timeSamples - p0.timeSamples) / dt; // 0.0 to 1.0 + + if (interpolation == AutomationInterpolation::Exponential) + { + // Quadratic ease for smoother volume curves + t = t * t; + } + + return static_cast(p0.value + (p1.value - p0.value) * t); +} + +void AutomationList::evalBlock(double startSample, double sampleRate, int numSamples, float* outputBuffer) const +{ + juce::ignoreUnused(sampleRate); + + const juce::ScopedTryLock sl(lock); + if (!sl.isLocked() || points.empty()) + { + float def = defaultValue.load(std::memory_order_relaxed); + for (int i = 0; i < numSamples; ++i) + outputBuffer[i] = def; + return; + } + + auto sz = static_cast(points.size()); + + // Start search from the point before the block start + int idx = findPointBefore(startSample); + + for (int i = 0; i < numSamples; ++i) + { + double t = startSample + static_cast(i); + + // Advance idx to the correct segment for this sample + while (idx < sz - 1 && points[static_cast(idx + 1)].timeSamples <= t) + ++idx; + + if (idx < 0) + { + // Before first point + outputBuffer[i] = points.front().value; + } + else if (idx >= sz - 1) + { + // At/after last point — fill rest of buffer and break + float val = points.back().value; + for (int j = i; j < numSamples; ++j) + outputBuffer[j] = val; + break; + } + else + { + const auto& p0 = points[static_cast(idx)]; + const auto& p1 = points[static_cast(idx + 1)]; + + if (interpolation == AutomationInterpolation::Discrete) + { + outputBuffer[i] = p0.value; + } + else + { + double dt = p1.timeSamples - p0.timeSamples; + if (dt <= 0.0) + { + outputBuffer[i] = p0.value; + } + else + { + double frac = (t - p0.timeSamples) / dt; + if (interpolation == AutomationInterpolation::Exponential) + frac = frac * frac; + outputBuffer[i] = static_cast(p0.value + (p1.value - p0.value) * frac); + } + } + } + } +} diff --git a/Source/AutomationList.h b/Source/AutomationList.h new file mode 100644 index 0000000..cff5877 --- /dev/null +++ b/Source/AutomationList.h @@ -0,0 +1,113 @@ +#pragma once + +#include +#include +#include + +// Automation interpolation mode +enum class AutomationInterpolation +{ + Discrete, // Step — hold value until next point + Linear, // Linear interpolation between points + Exponential // Exponential curve (useful for volume) +}; + +// Automation playback/record mode +enum class AutomationMode +{ + Off, // No automation (use manual fader value) + Read, // Play back recorded automation + Write, // Overwrite all automation while transport rolls + Touch, // Record only while touching a control + Latch // Record on touch, continue writing last value after release +}; + +// A single automation point (time + value) +struct AutomationPoint +{ + double timeSamples; // Position in samples (absolute timeline position) + float value; // Normalised 0.0–1.0 for most params, or raw dB for volume +}; + +// Thread-safe automation data for a single parameter. +// Message thread writes the point list; audio thread evaluates via eval(). +// Uses ScopedTryLock pattern matching the rest of the codebase (REAPER-style). +class AutomationList +{ +public: + AutomationList(); + ~AutomationList() = default; + + // --- Message-thread API (write side) --- + + // Replace all points (bulk set from frontend JSON). + // Acquires lock — audio thread will output silence during very brief window. + void setPoints(std::vector newPoints); + + // Add a single point (for recording). Keeps list sorted. + void addPoint(double timeSamples, float value); + + // Remove points in a time range (for automation trim / delete) + void removePointsInRange(double startSample, double endSample); + + // Clear all points + void clear(); + + // Get current point count + int getNumPoints() const; + + // Set the default value (used when no points exist or mode is Off) + void setDefaultValue(float val) { defaultValue.store(val, std::memory_order_relaxed); } + float getDefaultValue() const { return defaultValue.load(std::memory_order_relaxed); } + + // Mode + void setMode(AutomationMode newMode) { mode.store(newMode, std::memory_order_relaxed); } + AutomationMode getMode() const { return mode.load(std::memory_order_relaxed); } + + // Interpolation style + void setInterpolation(AutomationInterpolation interp) { interpolation = interp; } + AutomationInterpolation getInterpolation() const { return interpolation; } + + // Touch state (set from message thread when user grabs/releases a fader) + void beginTouch() { isTouching.store(true, std::memory_order_relaxed); } + void endTouch() { isTouching.store(false, std::memory_order_relaxed); } + bool touching() const { return isTouching.load(std::memory_order_relaxed); } + + // --- Audio-thread API (read side) --- + + // Evaluate automation value at a single sample position. + // Returns the interpolated value, or defaultValue if no points / mode is Off. + // Uses ScopedTryLock — returns defaultValue if lock is held (message thread writing). + float eval(double timeSamples) const; + + // Batch evaluate: fill outputBuffer with per-sample values for a block. + // startSample = timeline position of first sample in block. + // Much more efficient than calling eval() per sample — uses cached search position. + void evalBlock(double startSample, double sampleRate, int numSamples, float* outputBuffer) const; + + // Should the audio thread apply automation right now? + // Read mode: always. Touch: only when NOT touching. Latch: when NOT touching. + // Write mode: never (audio thread doesn't apply — it's being overwritten). + bool shouldPlayback() const; + + // Should the message thread record automation right now? + // Write: always during playback. Touch: only while touching. Latch: while touching + after. + bool shouldRecord() const; + +private: + // Points — sorted by timeSamples, protected by lock + std::vector points; + mutable juce::CriticalSection lock; + + // Atomic state (read from audio thread without lock) + std::atomic mode { AutomationMode::Off }; + std::atomic defaultValue { 0.0f }; + std::atomic isTouching { false }; + + AutomationInterpolation interpolation { AutomationInterpolation::Linear }; + + // Binary search helper — find index of last point at or before timeSamples + int findPointBefore(double timeSamples) const; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(AutomationList) +}; diff --git a/Source/BuiltInEffects.cpp b/Source/BuiltInEffects.cpp new file mode 100644 index 0000000..6551968 --- /dev/null +++ b/Source/BuiltInEffects.cpp @@ -0,0 +1,762 @@ +#include "BuiltInEffects.h" +#include "S13PluginEditors.h" + +//============================================================================== +// S13BuiltInEffect -- shared base class +//============================================================================== + +S13BuiltInEffect::S13BuiltInEffect() + : AudioProcessor(BusesProperties() + .withInput("Input", juce::AudioChannelSet::stereo(), true) + .withOutput("Output", juce::AudioChannelSet::stereo(), true)) +{ +} + +bool S13BuiltInEffect::isBusesLayoutSupported(const BusesLayout& layouts) const +{ + const auto& mainIn = layouts.getMainInputChannelSet(); + const auto& mainOut = layouts.getMainOutputChannelSet(); + if (mainOut != mainIn) return false; + return mainOut == juce::AudioChannelSet::mono() + || mainOut == juce::AudioChannelSet::stereo(); +} + +void S13BuiltInEffect::setOversamplingEnabled(bool enabled) +{ + oversamplingEnabled = enabled; +} + +juce::AudioProcessorEditor* S13BuiltInEffect::createEditor() +{ + return nullptr; // Derived classes override this +} + +juce::AudioProcessorEditor* S13EQ::createEditor() { return new S13EQEditor(*this); } +juce::AudioProcessorEditor* S13Compressor::createEditor() { return new S13CompressorEditor(*this); } +juce::AudioProcessorEditor* S13Gate::createEditor() { return new S13GateEditor(*this); } +juce::AudioProcessorEditor* S13Limiter::createEditor() { return new S13LimiterEditor(*this); } + +//============================================================================== +// S13EQ -- 8-band parametric EQ +//============================================================================== + +S13EQ::S13EQ() +{ + const float defaultFreqs[numBands] = { 30.0f, 100.0f, 250.0f, 500.0f, 1000.0f, 2500.0f, 6000.0f, 12000.0f }; + for (int i = 0; i < numBands; ++i) + { + bands[i].freq.store(defaultFreqs[i]); + bands[i].enabled.store(1.0f); + bands[i].type.store(static_cast(FilterType::Bell)); + bands[i].gain.store(0.0f); + bands[i].q.store(1.0f); + bands[i].slope.store(static_cast(FilterSlope::dB12)); + } + // First band defaults to low cut (off), last to high cut (off) + bands[0].type.store(static_cast(FilterType::LowCut)); + bands[0].freq.store(20.0f); + bands[0].enabled.store(0.0f); + bands[numBands - 1].type.store(static_cast(FilterType::HighCut)); + bands[numBands - 1].freq.store(20000.0f); + bands[numBands - 1].enabled.store(0.0f); +} + +int S13EQ::getNumStagesForSlope(FilterSlope slope) const +{ + switch (slope) + { + case FilterSlope::dB6: return 1; + case FilterSlope::dB12: return 1; + case FilterSlope::dB24: return 2; + case FilterSlope::dB48: return 4; + default: return 1; + } +} + +void S13EQ::prepareToPlay(double sampleRate, int samplesPerBlock) +{ + cachedSampleRate = sampleRate; + juce::dsp::ProcessSpec spec { sampleRate, static_cast(samplesPerBlock), 2u }; + + for (int b = 0; b < numBands; ++b) + for (int s = 0; s < maxStagesPerBand; ++s) + bandFilters[b][s].prepare(spec); + + oversampler = std::make_unique>( + 2, 1, juce::dsp::Oversampling::filterHalfBandFIREquiripple, false); + oversampler->initProcessing(static_cast(samplesPerBlock)); + + fftWritePos = 0; + fftBlockCounter = 0; + updateFilters(); +} + +void S13EQ::releaseResources() +{ + for (int b = 0; b < numBands; ++b) + for (int s = 0; s < maxStagesPerBand; ++s) + bandFilters[b][s].reset(); +} + +void S13EQ::updateBand(int b) +{ + const double sr = cachedSampleRate; + if (sr <= 0.0) return; + + const float nyquist = static_cast(sr * 0.5) - 1.0f; + const auto type = static_cast(static_cast(bands[b].type.load())); + const auto slope = static_cast(static_cast(bands[b].slope.load())); + const float freq = juce::jlimit(20.0f, nyquist, bands[b].freq.load()); + const float gainDB = juce::jlimit(-30.0f, 30.0f, bands[b].gain.load()); + const float q = juce::jlimit(0.1f, 30.0f, bands[b].q.load()); + const float gainFactor = juce::Decibels::decibelsToGain(gainDB); + + int numStages = 1; + + switch (type) + { + case FilterType::Bell: + numStages = 1; + *bandFilters[b][0].state = *juce::dsp::IIR::Coefficients::makePeakFilter(sr, freq, q, gainFactor); + break; + + case FilterType::LowShelf: + numStages = 1; + *bandFilters[b][0].state = *juce::dsp::IIR::Coefficients::makeLowShelf(sr, freq, q, gainFactor); + break; + + case FilterType::HighShelf: + numStages = 1; + *bandFilters[b][0].state = *juce::dsp::IIR::Coefficients::makeHighShelf(sr, freq, q, gainFactor); + break; + + case FilterType::LowCut: + numStages = getNumStagesForSlope(slope); + if (slope == FilterSlope::dB6) + *bandFilters[b][0].state = *juce::dsp::IIR::Coefficients::makeFirstOrderHighPass(sr, freq); + else + for (int s = 0; s < numStages; ++s) + *bandFilters[b][s].state = *juce::dsp::IIR::Coefficients::makeHighPass(sr, freq, 0.707f); + break; + + case FilterType::HighCut: + numStages = getNumStagesForSlope(slope); + if (slope == FilterSlope::dB6) + *bandFilters[b][0].state = *juce::dsp::IIR::Coefficients::makeFirstOrderLowPass(sr, freq); + else + for (int s = 0; s < numStages; ++s) + *bandFilters[b][s].state = *juce::dsp::IIR::Coefficients::makeLowPass(sr, freq, 0.707f); + break; + + case FilterType::Notch: + numStages = 1; + *bandFilters[b][0].state = *juce::dsp::IIR::Coefficients::makeNotch(sr, freq, q); + break; + + case FilterType::BandPass: + numStages = 1; + *bandFilters[b][0].state = *juce::dsp::IIR::Coefficients::makeBandPass(sr, freq, q); + break; + } + activeStages[b] = numStages; +} + +void S13EQ::updateFilters() +{ + for (int b = 0; b < numBands; ++b) + updateBand(b); +} + +void S13EQ::processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer& midi) +{ + juce::ignoreUnused(midi); + juce::ScopedNoDenormals noDenormals; + updateFilters(); + + const int numSamples = buffer.getNumSamples(); + + // Capture pre-EQ samples for spectrum + { + const float* readPtr = buffer.getReadPointer(0); + for (int i = 0; i < numSamples; ++i) + { + preEQBuffer[static_cast(fftWritePos)] = readPtr[i]; + fftWritePos = (fftWritePos + 1) % fftSize; + } + } + + // Process each enabled band + juce::dsp::AudioBlock block(buffer); + juce::dsp::ProcessContextReplacing context(block); + + for (int b = 0; b < numBands; ++b) + { + if (bands[b].enabled.load() < 0.5f) + continue; + for (int s = 0; s < activeStages[b]; ++s) + bandFilters[b][s].process(context); + } + + // Output gain + float outGainDB = juce::jlimit(-12.0f, 12.0f, outputGain.load()); + if (std::abs(outGainDB) > 0.01f) + buffer.applyGain(juce::Decibels::decibelsToGain(outGainDB)); + + // Capture post-EQ samples + { + const float* readPtr = buffer.getReadPointer(0); + int writePos = (fftWritePos - numSamples + fftSize) % fftSize; + for (int i = 0; i < numSamples; ++i) + { + postEQBuffer[static_cast(writePos)] = readPtr[i]; + writePos = (writePos + 1) % fftSize; + } + } + + // Periodic spectrum update + if (++fftBlockCounter >= fftUpdateInterval) + { + fftBlockCounter = 0; + SpectrumData newData; + computeSpectrum(preEQBuffer, newData.preEQ); + computeSpectrum(postEQBuffer, newData.postEQ); + newData.ready = true; + const std::lock_guard lock(spectrumMutex); + spectrumOutput = newData; + } +} + +void S13EQ::computeSpectrum(const std::array& input, std::array& output) +{ + std::array fftData {}; + for (int i = 0; i < fftSize; ++i) + fftData[static_cast(i)] = input[static_cast(i)]; + window.multiplyWithWindowingTable(fftData.data(), fftSize); + fft.performFrequencyOnlyForwardTransform(fftData.data()); + for (int i = 0; i < fftSize / 2; ++i) + { + float mag = fftData[static_cast(i)] / static_cast(fftSize); + output[static_cast(i)] = juce::Decibels::gainToDecibels(mag, -100.0f); + } +} + +S13EQ::SpectrumData S13EQ::getSpectrumData() const +{ + const std::lock_guard lock(spectrumMutex); + return spectrumOutput; +} + +std::vector S13EQ::getMagnitudeResponse(const std::vector& frequencies) const +{ + std::vector response(frequencies.size(), 0.0f); + for (int b = 0; b < numBands; ++b) + { + if (bands[b].enabled.load() < 0.5f) continue; + for (int s = 0; s < activeStages[b]; ++s) + { + auto coeffs = bandFilters[b][s].state; + if (coeffs == nullptr) continue; + std::vector mags(frequencies.size()); + std::vector freqsDouble(frequencies.begin(), frequencies.end()); + coeffs->getMagnitudeForFrequencyArray(freqsDouble.data(), mags.data(), + freqsDouble.size(), cachedSampleRate); + for (size_t i = 0; i < frequencies.size(); ++i) + response[i] += juce::Decibels::gainToDecibels(static_cast(mags[i])); + } + } + float outGainDB = juce::jlimit(-12.0f, 12.0f, outputGain.load()); + for (auto& r : response) r += outGainDB; + return response; +} + +void S13EQ::getStateInformation(juce::MemoryBlock& destData) +{ + juce::ValueTree state("S13EQ"); + state.setProperty("outputGain", outputGain.load(), nullptr); + state.setProperty("autoGain", autoGain.load(), nullptr); + for (int i = 0; i < numBands; ++i) + { + juce::String p = "band" + juce::String(i) + "_"; + state.setProperty(p + "enabled", bands[i].enabled.load(), nullptr); + state.setProperty(p + "type", bands[i].type.load(), nullptr); + state.setProperty(p + "freq", bands[i].freq.load(), nullptr); + state.setProperty(p + "gain", bands[i].gain.load(), nullptr); + state.setProperty(p + "q", bands[i].q.load(), nullptr); + state.setProperty(p + "slope", bands[i].slope.load(), nullptr); + } + juce::MemoryOutputStream stream(destData, false); + state.writeToStream(stream); +} + +void S13EQ::setStateInformation(const void* data, int sizeInBytes) +{ + auto state = juce::ValueTree::readFromData(data, static_cast(sizeInBytes)); + if (!state.isValid() || state.getType().toString() != "S13EQ") return; + + outputGain.store(static_cast(state.getProperty("outputGain", 0.0f))); + autoGain.store(static_cast(state.getProperty("autoGain", 0.0f))); + for (int i = 0; i < numBands; ++i) + { + juce::String p = "band" + juce::String(i) + "_"; + bands[i].enabled.store(static_cast(state.getProperty(p + "enabled", 1.0f))); + bands[i].type.store(static_cast(state.getProperty(p + "type", 0.0f))); + bands[i].freq.store(static_cast(state.getProperty(p + "freq", 1000.0f))); + bands[i].gain.store(static_cast(state.getProperty(p + "gain", 0.0f))); + bands[i].q.store(static_cast(state.getProperty(p + "q", 1.0f))); + bands[i].slope.store(static_cast(state.getProperty(p + "slope", 1.0f))); + } + updateFilters(); +} + +//============================================================================== +// S13Compressor -- Multi-style compressor +//============================================================================== + +S13Compressor::S13Compressor() {} + +float S13Compressor::computeGain(float inputDB) const +{ + const float threshDB = juce::jlimit(-60.0f, 0.0f, threshold.load()); + const float ratioVal = juce::jmax(1.0f, juce::jlimit(1.0f, 20.0f, ratio.load())); + const float kneeDB = juce::jlimit(0.0f, 24.0f, knee.load()); + + if (kneeDB > 0.0f) + { + const float halfKnee = kneeDB * 0.5f; + if (inputDB < threshDB - halfKnee) + return inputDB; + else if (inputDB > threshDB + halfKnee) + return threshDB + (inputDB - threshDB) / ratioVal; + else + { + float x = inputDB - threshDB + halfKnee; + return inputDB + ((1.0f / ratioVal) - 1.0f) * x * x / (2.0f * kneeDB); + } + } + if (inputDB <= threshDB) return inputDB; + return threshDB + (inputDB - threshDB) / ratioVal; +} + +void S13Compressor::getStyleBallistics(float& atkMs, float& relMs) const +{ + const auto styleVal = static_cast + + +

OpenStudio Keyboard Shortcuts

+

Generated on ${new Date().toLocaleDateString()}

+
+ +
+
+ ${categoriesHtml} +
+ +`; + + const printWindow = window.open("", "_blank"); + if (printWindow) { + printWindow.document.write(html); + printWindow.document.close(); + } + }, [getEffectiveShortcut]); + if (!isOpen) return null; return ( - +
{/* Search */}
- + setSearch(e.target.value)} placeholder="Search shortcuts..." className="pl-7" - autoFocus + autoFocus={!listeningActionId} />
+ {/* Listening overlay */} + {listeningActionId && ( +
+ + Press a key combination... + + {capturedShortcut && ( + + {capturedShortcut} + + )} +
+ Press Esc to cancel +
+
+ )} + +
+ Rebinding currently applies to global shortcuts. Timeline- and editor-scoped shortcuts are shown here for reference but cannot be rebound yet. +
+ {/* Shortcuts List */}
{Object.entries(grouped).map(([category, categoryActions]) => (
-

+

{category} ({categoryActions.length})

- {categoryActions.map((action) => ( -
{ - action.execute(); - onClose(); - }} - > - {action.name} - {action.shortcut ? ( - - {action.shortcut} + {categoryActions.map((action) => { + const isCustom = + customShortcuts[action.id] !== undefined; + const effectiveShortcut = getEffectiveShortcut( + action.id, + action.shortcut + ); + const shortcutScope = action.shortcutScope ?? "global"; + const shortcutScopeLabel = getActionShortcutScopeLabel(shortcutScope); + const canRebind = shortcutScope === "global"; + const isListening = listeningActionId === action.id; + + return ( +
+ { + if (!listeningActionId) { + action.execute(); + onClose(); + } + }} + title={action.name} + > + {action.name} - ) : ( - — - )} -
- ))} + +
+ {/* Shortcut badge */} + {effectiveShortcut ? ( + <> + + {effectiveShortcut} + + + {shortcutScopeLabel} + + + ) : ( + + — + + )} + + {/* Rebind button */} + + + {/* Reset button (only visible for custom shortcuts) */} + {isCustom && ( + + )} +
+
+ ); + })}
))} {Object.keys(grouped).length === 0 && (
- No shortcuts found for "{search}" + No shortcuts found for "{search}"
)}
{/* Footer */}
- - {filtered.length} action{filtered.length !== 1 ? "s" : ""} - - +
+ + {filtered.length} action{filtered.length !== 1 ? "s" : ""} + + {hasAnyCustomShortcuts && ( + + )} +
+
+ + +
diff --git a/frontend/src/components/MainToolbar.tsx b/frontend/src/components/MainToolbar.tsx index 9c036e6..d4763ce 100644 --- a/frontend/src/components/MainToolbar.tsx +++ b/frontend/src/components/MainToolbar.tsx @@ -1,6 +1,26 @@ -import { Repeat, Circle, Play, Square, Undo2, Redo2, Grid3x3, SlidersHorizontal, SlidersVertical, Settings, Blend, MousePointer, Scissors, VolumeX } from "lucide-react"; +import { + Repeat, + Circle, + Play, + Square, + Undo2, + Redo2, + Grid3x3, + SlidersHorizontal, + Settings, + Blend, + MousePointer, + Scissors, + VolumeX, + Wand2, + Cpu, +} from "lucide-react"; +import { usePitchEditorStore } from "../store/pitchEditorStore"; +import { getActionShortcut, getActionShortcutScopeLabel } from "../store/actionRegistry"; import { useDAWStore } from "../store/useDAWStore"; +import { useShallow } from "zustand/shallow"; import { Button } from "./ui"; +import { useAiToolsStatus } from "../hooks/useAiToolsStatus"; interface MainToolbarProps { onOpenSettings: () => void; @@ -13,21 +33,117 @@ export function MainToolbar({ onToggleMixer, showMixer, }: MainToolbarProps) { - const { transport, play, record, stop, toggleLoop, tracks, snapEnabled, toggleSnap, undo, redo, canUndo, canRedo, autoCrossfade, toggleAutoCrossfade, toolMode, setToolMode, toggleSplitTool, toggleMuteTool } = - useDAWStore(); - const { isPlaying, isPaused, loopEnabled } = transport; + const { status: aiToolsStatus, install, openHelp } = useAiToolsStatus(); + const mixerShortcut = getActionShortcut("view.toggleMixer") ?? "Ctrl+M"; + const loopShortcut = getActionShortcut("transport.loop") ?? "L"; + const recordShortcut = getActionShortcut("transport.record") ?? "Ctrl+R"; + const undoShortcut = getActionShortcut("edit.undo") ?? "Ctrl+Z"; + const redoShortcut = getActionShortcut("edit.redo") ?? "Ctrl+Shift+Z"; + const selectToolShortcut = getActionShortcut("tools.selectTool") ?? "V"; + const splitToolShortcut = getActionShortcut("tools.splitTool") ?? "B"; + const muteToolShortcut = getActionShortcut("tools.muteTool") ?? "X"; + const smartToolShortcut = getActionShortcut("tools.smartTool") ?? "Y"; + const timelineScopeLabel = getActionShortcutScopeLabel("timeline"); + const { + isPlaying, + isPaused, + loopEnabled, + play, + record, + stop, + toggleLoop, + tracks, + snapEnabled, + toggleSnap, + undo, + redo, + canUndo, + canRedo, + autoCrossfade, + toggleAutoCrossfade, + toolMode, + setToolMode, + toggleSplitTool, + toggleMuteTool, + showPitchEditor, + } = useDAWStore( + useShallow((s) => ({ + isPlaying: s.transport.isPlaying, + isPaused: s.transport.isPaused, + loopEnabled: s.transport.loopEnabled, + play: s.play, + record: s.record, + stop: s.stop, + toggleLoop: s.toggleLoop, + tracks: s.tracks, + snapEnabled: s.snapEnabled, + toggleSnap: s.toggleSnap, + undo: s.undo, + redo: s.redo, + canUndo: s.canUndo, + canRedo: s.canRedo, + autoCrossfade: s.autoCrossfade, + toggleAutoCrossfade: s.toggleAutoCrossfade, + toolMode: s.toolMode, + setToolMode: s.setToolMode, + toggleSplitTool: s.toggleSplitTool, + toggleMuteTool: s.toggleMuteTool, + showPitchEditor: s.showPitchEditor, + })), + ); + + // When pitch editor is open, delegate undo/redo to its own stack + const peUndo = usePitchEditorStore((s) => s.undo); + const peRedo = usePitchEditorStore((s) => s.redo); + const peUndoStack = usePitchEditorStore((s) => s.undoStack); + const peRedoStack = usePitchEditorStore((s) => s.redoStack); + + const effectiveUndo = showPitchEditor ? peUndo : undo; + const effectiveRedo = showPitchEditor ? peRedo : redo; + const effectiveCanUndo = showPitchEditor ? peUndoStack.length > 0 : canUndo; + const effectiveCanRedo = showPitchEditor ? peRedoStack.length > 0 : canRedo; const hasArmedTracks = tracks.some((t) => t.armed); + const handleAiToolsClick = async () => { + if (aiToolsStatus.installInProgress || aiToolsStatus.available) { + return; + } + + if (aiToolsStatus.state === "pythonMissing") { + await openHelp(); + return; + } + + await install(); + }; + + const aiToolsTitle = aiToolsStatus.available + ? "AI Tools Ready" + : aiToolsStatus.installInProgress + ? aiToolsStatus.message || "Installing AI Tools..." + : aiToolsStatus.state === "pythonMissing" + ? "Install Python, then retry AI Tools" + : "Install AI Tools"; + return ( -
+
{/* Transport Section */} -
+
@@ -37,7 +153,8 @@ export function MainToolbar({ active={hasArmedTracks && isPlaying} disabled={!hasArmedTracks} onClick={() => record()} - title={hasArmedTracks ? "Record" : "Arm a track to record"} + title={hasArmedTracks ? `Record (${recordShortcut})` : "Arm a track to record"} + aria-label={hasArmedTracks ? "Record" : "Arm a track to record"} > @@ -46,7 +163,8 @@ export function MainToolbar({ size="icon-lg" active={isPlaying} onClick={() => play()} - title="Play" + title="Play (Space)" + aria-label="Play" > @@ -55,7 +173,8 @@ export function MainToolbar({ size="icon-lg" disabled={!isPlaying && !isPaused} onClick={() => stop()} - title="Stop" + title="Stop (Space)" + aria-label="Stop" > @@ -64,22 +183,28 @@ export function MainToolbar({
{/* Edit Tools */} -
+
@@ -93,6 +218,9 @@ export function MainToolbar({ ? "Snap Enabled (Click to disable)" : "Snap Disabled (Click to enable)" } + aria-label={ + snapEnabled ? "Disable Snap to Grid" : "Enable Snap to Grid" + } > @@ -106,6 +234,9 @@ export function MainToolbar({ ? "Auto-Crossfade On (Click to disable)" : "Auto-Crossfade Off (Click to enable)" } + aria-label={ + autoCrossfade ? "Disable Auto-Crossfade" : "Enable Auto-Crossfade" + } > @@ -114,13 +245,18 @@ export function MainToolbar({
{/* Tool Mode */} -
+
@@ -129,7 +265,8 @@ export function MainToolbar({ size="icon-lg" active={toolMode === "split"} onClick={toggleSplitTool} - title="Split Tool (B)" + title={`Split Tool (${timelineScopeLabel}: ${splitToolShortcut})`} + aria-label="Split Tool" > @@ -138,44 +275,63 @@ export function MainToolbar({ size="icon-lg" active={toolMode === "mute"} onClick={toggleMuteTool} - title="Mute Tool (X)" + title={`Mute Tool (${timelineScopeLabel}: ${muteToolShortcut})`} + aria-label="Mute Tool" > +
{/* View Toggles - Mixer Now Works! */} -
+
-
{/* Settings */}
+ diff --git a/frontend/src/components/MasterPeakMeterCluster.tsx b/frontend/src/components/MasterPeakMeterCluster.tsx new file mode 100644 index 0000000..5e5ee47 --- /dev/null +++ b/frontend/src/components/MasterPeakMeterCluster.tsx @@ -0,0 +1,89 @@ +import { useCallback, useState } from "react"; +import { PeakMeter } from "./PeakMeter"; +import { + MASTER_DBFS_RULING_MARKS, + MASTER_DBFS_OVERLAY_WIDTH_PX, + MASTER_EXTENDED_METER_WIDTH_PX, + MASTER_METER_CLUSTER_WIDTH_PX, + normalizeDbToMeter, +} from "./meterConfig"; + +interface MasterPeakMeterClusterProps { + level: number; + clipping: boolean; + onReset?: () => void; +} + +export function MasterPeakMeterCluster({ + level, + clipping, + onReset, +}: MasterPeakMeterClusterProps) { + const [resetSignal, setResetSignal] = useState(0); + + const handleReset = useCallback(() => { + setResetSignal((value) => value + 1); + onReset?.(); + }, [onReset]); + + const getLabelPosition = useCallback((markDb: number) => { + const normalized = normalizeDbToMeter(markDb, "dbfs"); + const percent = (1 - normalized) * 100; + + if (markDb === 0) { + return { top: "6px", transform: "translateX(-50%)" }; + } + + if (markDb === -60) { + return { top: "calc(100% - 7px)", transform: "translate(-50%, -100%)" }; + } + + return { top: `${percent}%`, transform: "translate(-50%, -50%)" }; + }, []); + + return ( +
+ +
+ +
+ {MASTER_DBFS_RULING_MARKS.filter((markDb) => markDb !== 0).map((markDb) => ( + + {markDb > 0 ? `+${markDb}` : `${markDb}`} + + ))} +
+
+
+ ); +} diff --git a/frontend/src/components/MasterTrackHeader.tsx b/frontend/src/components/MasterTrackHeader.tsx index d711344..79f4ded 100644 --- a/frontend/src/components/MasterTrackHeader.tsx +++ b/frontend/src/components/MasterTrackHeader.tsx @@ -1,24 +1,84 @@ -import { useState } from "react"; -import { useDAWStore } from "../store/useDAWStore"; -import { Button, Slider } from "./ui"; +import { useCallback, useState } from "react"; +import { useDAWStore, AUTOMATION_LANE_HEIGHT, AutomationModeType } from "../store/useDAWStore"; +import { useShallow } from "zustand/shallow"; +import { Button, Knob } from "./ui"; import { Volume2, VolumeX, Power } from "lucide-react"; import { FXChainPanel } from "./FXChainPanel"; +import { getAutomationColor, getAutomationShortLabel, getAutomationParamDef } from "../store/automationParams"; +import { + TCP_HEADER_BUTTON_PAIR_CLASS, + TCP_HEADER_PRIMARY_BUTTON_CLASS, + TCP_HEADER_TOGGLE_BUTTON_CLASS, +} from "./tcpHeaderButtonStyles"; /** * MasterTrackHeader - Compact master channel displayed at the bottom of the TCP sidebar - * Shows master volume fader, mute, FX button with bypass toggle + * Shows master volume fader, mute, FX, mono, automation button + automation lane sub-headers */ export function MasterTrackHeader() { - const masterVolume = useDAWStore((s) => s.masterVolume); - const isMasterMuted = useDAWStore((s) => s.isMasterMuted); - const masterFxCount = useDAWStore((s) => s.masterFxCount); - const setMasterVolume = useDAWStore((s) => s.setMasterVolume); - const toggleMasterMute = useDAWStore((s) => s.toggleMasterMute); + const { + masterVolume, + isMasterMuted, + masterFxCount, + masterMono, + masterAutomationLanes, + showMasterAutomation, + masterAutomationEnabled, + setMasterVolume, + toggleMasterMute, + toggleMasterMono, + toggleMasterAutomation, + toggleMasterAutomationEnabled, + openEnvelopeManager, + toggleMasterAutomationLaneVisibility, + setMasterAutomationLaneMode, + armMasterAutomationLane, + } = useDAWStore(useShallow((s) => ({ + masterVolume: s.masterVolume, + isMasterMuted: s.isMasterMuted, + masterFxCount: s.masterFxCount, + masterMono: s.masterMono, + masterAutomationLanes: s.masterAutomationLanes, + showMasterAutomation: s.showMasterAutomation, + masterAutomationEnabled: s.masterAutomationEnabled, + setMasterVolume: s.setMasterVolume, + toggleMasterMute: s.toggleMasterMute, + toggleMasterMono: s.toggleMasterMono, + toggleMasterAutomation: s.toggleMasterAutomation, + toggleMasterAutomationEnabled: s.toggleMasterAutomationEnabled, + openEnvelopeManager: s.openEnvelopeManager, + toggleMasterAutomationLaneVisibility: s.toggleMasterAutomationLaneVisibility, + setMasterAutomationLaneMode: s.setMasterAutomationLaneMode, + armMasterAutomationLane: s.armMasterAutomationLane, + }))); const [showFXChain, setShowFXChain] = useState(false); const [fxBypassed, setFxBypassed] = useState(false); const hasFx = masterFxCount > 0; + const hasAutomation = + (showMasterAutomation && + masterAutomationLanes.some((lane) => lane.visible)) || + masterAutomationLanes.some((lane) => lane.armed); + + let autoButtonClass = "hover:text-green-500 hover:border-green-500"; + if (hasAutomation && !masterAutomationEnabled) + autoButtonClass = + "text-red-400! border-red-500! shadow-[0_0_6px_rgba(239,68,68,0.4)]"; + else if (hasAutomation) + autoButtonClass = + "text-green-400! border-green-500! shadow-[0_0_6px_rgba(34,197,94,0.4)]"; + + let autoPowerClass = "hover:text-green-500 hover:border-green-500"; + if (hasAutomation && !masterAutomationEnabled) + autoPowerClass = "text-red-400! border-red-500!"; + else if (hasAutomation) autoPowerClass = "text-green-400! border-green-500!"; + + const autoPowerTitle = hasAutomation + ? masterAutomationEnabled + ? "Suspend master automation" + : "Enable master automation" + : "No automation lanes"; const masterVolumeDB = masterVolume > 0 ? 20 * Math.log10(masterVolume) : -60; @@ -27,28 +87,59 @@ export function MasterTrackHeader() { const linear = db <= -60 ? 0 : Math.pow(10, db / 20); void setMasterVolume(linear); }; + const formatVolume = (db: number) => + db <= -60 ? "-inf dB" : `${db.toFixed(1)} dB`; + + const handleAutomationClick = useCallback(() => { + openEnvelopeManager("master"); + }, [openEnvelopeManager]); + + const handleAutomationContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + // Toggle showMasterAutomation on right-click + toggleMasterAutomation(); + }, [toggleMasterAutomation]); return ( <>
-
+
{/* Label */} - + Master {/* Mute */} + {/* Mono */} + + {/* FX + Bypass */} -
+
- {/* Volume Fader */} -
- + + +
+ + {/* Master Volume Knob */} +
+
{/* dB Display */} - + {masterVolumeDB <= -60 ? "-inf" : `${masterVolumeDB.toFixed(1)}`} dB
+ + {/* Automation Lane Sub-Headers */} + {showMasterAutomation && masterAutomationLanes.filter((l) => l.visible).map((lane) => { + const laneColor = getAutomationColor(lane.param); + const laneLabel = getAutomationShortLabel(lane.param); + const paramDef = getAutomationParamDef(lane.param); + return ( +
+ {/* Color indicator */} +
+ {/* Param name */} + + {laneLabel} + + {/* Value display */} + + {paramDef.formatNormalized(paramDef.defaultNormalized)} + + {/* Mode selector */} + + {/* Arm toggle */} + + {/* Hide lane button */} + +
+ ); + })}
{showFXChain && ( diff --git a/frontend/src/components/MediaExplorer.tsx b/frontend/src/components/MediaExplorer.tsx index a159890..02c4891 100644 --- a/frontend/src/components/MediaExplorer.tsx +++ b/frontend/src/components/MediaExplorer.tsx @@ -13,6 +13,7 @@ import { } from "lucide-react"; import { nativeBridge } from "../services/NativeBridge"; import { useDAWStore } from "../store/useDAWStore"; +import { useShallow } from "zustand/shallow"; import { Button, Input } from "./ui"; interface FileEntry { @@ -54,7 +55,12 @@ export function MediaExplorer({ isVisible, onClose }: MediaExplorerProps) { setMediaExplorerPath, mediaExplorerRecentPaths, addMediaExplorerRecentPath, - } = useDAWStore(); + } = useDAWStore(useShallow((s) => ({ + mediaExplorerPath: s.mediaExplorerPath, + setMediaExplorerPath: s.setMediaExplorerPath, + mediaExplorerRecentPaths: s.mediaExplorerRecentPaths, + addMediaExplorerRecentPath: s.addMediaExplorerRecentPath, + }))); const [files, setFiles] = useState([]); const [loading, setLoading] = useState(false); diff --git a/frontend/src/components/MenuBar.tsx b/frontend/src/components/MenuBar.tsx index 057ecbb..279ff86 100644 --- a/frontend/src/components/MenuBar.tsx +++ b/frontend/src/components/MenuBar.tsx @@ -2,7 +2,9 @@ import { useState, useEffect, useCallback } from "react"; import { Minus, Square, X, Copy } from "lucide-react"; import { EditMenu } from "./menus/EditMenu"; import { MenuDropdown, MenuItemProps } from "./menus/MenuDropdown"; +import { getEffectiveActionShortcut } from "../store/actionRegistry"; import { useDAWStore, THEME_PRESETS } from "../store/useDAWStore"; +import { useShallow } from "zustand/shallow"; import { nativeBridge } from "../services/NativeBridge"; /** @@ -10,6 +12,7 @@ import { nativeBridge } from "../services/NativeBridge"; * Contains File, Edit, View, Insert, Track, Options, Actions, Help menus */ export function MenuBar() { + const shortcut = (actionId: string, fallback: string) => getEffectiveActionShortcut(actionId) ?? fallback; const { toggleMixer, showMixer, @@ -31,7 +34,28 @@ export function MenuBar() { toggleVirtualKeyboard, showUndoHistory, toggleUndoHistory, - } = useDAWStore(); + } = useDAWStore(useShallow((s) => ({ + toggleMixer: s.toggleMixer, + showMixer: s.showMixer, + showMasterTrackInTCP: s.showMasterTrackInTCP, + toggleMasterTrackInTCP: s.toggleMasterTrackInTCP, + openSettings: s.openSettings, + openProjectSettings: s.openProjectSettings, + openRenderModal: s.openRenderModal, + newProject: s.newProject, + saveProject: s.saveProject, + loadProject: s.loadProject, + snapEnabled: s.snapEnabled, + toggleSnap: s.toggleSnap, + gridSize: s.gridSize, + setGridSize: s.setGridSize, + recentProjects: s.recentProjects, + clearRecentProjects: s.clearRecentProjects, + showVirtualKeyboard: s.showVirtualKeyboard, + toggleVirtualKeyboard: s.toggleVirtualKeyboard, + showUndoHistory: s.showUndoHistory, + toggleUndoHistory: s.toggleUndoHistory, + }))); const [isMaximized, setIsMaximized] = useState(false); @@ -66,6 +90,77 @@ export function MenuBar() { setIsMaximized(newState); }, []); + const handleCheckForUpdates = useCallback(async () => { + const result = await nativeBridge.checkForUpdates(true); + + if (result?.status === "up-to-date") { + useDAWStore.getState().showToast("OpenStudio is already up to date.", "success"); + return; + } + + if (result?.status === "update-available") { + const version = result.version || "the latest version"; + const mandatoryLabel = result.mandatory ? "A required " : ""; + const isMacUpdate = result.platform === "macos"; + const notes = typeof result.notes === "string" && result.notes.trim().length > 0 + ? `\n\nRelease notes:\n${result.notes}` + : ""; + const actionPrompt = isMacUpdate + ? "Download and open the DMG now? You may still need to drag the app into Applications and use right-click > Open." + : "Download and install it now?"; + + const shouldInstall = confirm( + `${mandatoryLabel}OpenStudio ${version} update is available.${notes}\n\n${actionPrompt}`, + ); + + if (!shouldInstall) return; + + const installResult = await nativeBridge.downloadAndInstallUpdate( + result.downloadUrl || "", + result.version, + result.sha256, + result.releasePageUrl, + result.installerArguments, + result.size, + ); + + if (installResult?.status === "install-started") { + useDAWStore.getState().showToast( + installResult?.message || "Update downloaded. The installer has been opened.", + "success", + ); + } else if (installResult?.status === "release-page-opened") { + useDAWStore.getState().showToast( + "Opened the release page for the latest update.", + "info", + ); + } else { + useDAWStore.getState().showToast( + installResult?.message || "Update download or installation failed.", + "error", + ); + } + + return; + } + + useDAWStore.getState().showToast( + result?.message || "Could not check for updates.", + "error", + ); + }, []); + + const handleAbout = useCallback(async () => { + const version = await nativeBridge.getAppVersion(); + alert( + `OpenStudio ${version}\n\n` + + "A hybrid DAW with a JUCE C++ backend and React/TypeScript frontend.\n\n" + + "Built with:\n JUCE 8.0 - Audio engine, VST3 hosting\n" + + " React - User interface\n Konva - Timeline canvas\n Zustand - State management\n\n" + + "github.com/openstudio", + ); + }, []); + // Helper to get just the filename from a full path const getFileName = (path: string) => { return path.split("\\").pop() || path.split("/").pop() || path; @@ -75,7 +170,7 @@ export function MenuBar() { const fileMenuItems: MenuItemProps[] = [ { label: "New Project", - shortcut: "Ctrl+N", + shortcut: shortcut("file.new", "Ctrl+N"), onClick: () => { if ( confirm( @@ -88,7 +183,7 @@ export function MenuBar() { }, { label: "Open Project...", - shortcut: "Ctrl+O", + shortcut: shortcut("file.open", "Ctrl+O"), onClick: () => { loadProject().then((success) => { if (!success) console.error("Failed to load project"); @@ -121,7 +216,7 @@ export function MenuBar() { }, { label: "Save Project", - shortcut: "Ctrl+S", + shortcut: shortcut("file.save", "Ctrl+S"), onClick: () => { saveProject(false).then((success) => { if (success) console.log("Project saved successfully"); @@ -130,7 +225,7 @@ export function MenuBar() { }, { label: "Save Project As...", - shortcut: "Ctrl+Shift+S", + shortcut: shortcut("file.saveAs", "Ctrl+Shift+S"), onClick: () => { saveProject(true).then((success) => { if (success) console.log("Project saved as new file"); @@ -144,11 +239,55 @@ export function MenuBar() { if (success) console.log("New version saved"); }); }, + }, + { + label: "Save as Template...", + onClick: () => { + const name = prompt("Template name:"); + if (name) useDAWStore.getState().saveAsTemplate(name); + }, + }, + { + label: "New from Template...", + onClick: () => { + const state = useDAWStore.getState(); + const templates = state.projectTemplates; + if (templates.length === 0) { + alert("No templates saved yet. Use 'Save as Template' first."); + return; + } + state.toggleProjectTemplates(); + }, + submenu: (() => { + const templates = useDAWStore.getState().projectTemplates; + if (templates.length === 0) return [{ label: "(no templates)", disabled: true }]; + return [ + ...templates.map((t, i) => ({ + label: t.name, + onClick: () => { + if (confirm(`Load template "${t.name}"? This will replace the current project.`)) { + useDAWStore.getState().loadTemplate(i); + } + }, + })), + { + label: "Delete Template...", + submenu: templates.map((t, i) => ({ + label: t.name, + onClick: () => { + if (confirm(`Delete template "${t.name}"?`)) { + useDAWStore.getState().deleteTemplate(i); + } + }, + })), + }, + ]; + })(), dividerAfter: true, }, { label: "Close Project", - shortcut: "Ctrl+F4", + shortcut: shortcut("file.closeProject", "Ctrl+F4"), onClick: () => { const state = useDAWStore.getState(); if (state.isModified) { @@ -165,12 +304,12 @@ export function MenuBar() { }, { label: "Project Settings...", - shortcut: "Alt+Enter", + shortcut: shortcut("file.projectSettings", "Alt+Enter"), onClick: openProjectSettings, }, { label: "Render...", - shortcut: "Ctrl+Alt+R", + shortcut: shortcut("file.render", "Ctrl+Alt+R"), onClick: openRenderModal, }, { @@ -208,17 +347,25 @@ export function MenuBar() { }, { label: "Open Project (Safe Mode)...", - shortcut: "Ctrl+Shift+O", + shortcut: shortcut("file.openSafeMode", "Ctrl+Shift+O"), onClick: () => { useDAWStore.getState().loadProject(undefined, { bypassFX: true }).then((success) => { if (success) console.log("Project loaded in Safe Mode (FX bypassed)"); }); }, + }, + { + label: "Archive Session...", + onClick: () => { useDAWStore.getState().archiveSession(); }, + }, + { + label: "Media Pool", + onClick: () => useDAWStore.getState().toggleMediaPool(), dividerAfter: true, }, { label: "Quit", - shortcut: "Ctrl+Q", + shortcut: shortcut("file.quit", "Ctrl+Q"), onClick: () => { nativeBridge.closeWindow(); }, @@ -229,7 +376,7 @@ export function MenuBar() { const viewMenuItems: MenuItemProps[] = [ { label: "Show Mixer", - shortcut: "Ctrl+M", + shortcut: shortcut("view.toggleMixer", "Ctrl+M"), onClick: toggleMixer, checked: showMixer, }, @@ -240,13 +387,13 @@ export function MenuBar() { }, { label: "Show Virtual MIDI Keyboard", - shortcut: "Alt+B", + shortcut: shortcut("view.toggleVirtualKeyboard", "Alt+B"), onClick: toggleVirtualKeyboard, checked: showVirtualKeyboard, }, { label: "Undo History", - shortcut: "Ctrl+Alt+Z", + shortcut: shortcut("view.toggleUndoHistory", "Ctrl+Alt+Z"), onClick: toggleUndoHistory, checked: showUndoHistory, }, @@ -257,7 +404,7 @@ export function MenuBar() { }, { label: "Clip Properties", - shortcut: "F2", + shortcut: shortcut("view.clipProperties", "F2"), onClick: () => useDAWStore.getState().toggleClipProperties(), checked: useDAWStore.getState().showClipProperties, }, @@ -281,6 +428,16 @@ export function MenuBar() { onClick: () => useDAWStore.getState().toggleMediaExplorer(), checked: useDAWStore.getState().showMediaExplorer, }, + { + label: "Crosshair Cursor", + onClick: () => useDAWStore.getState().toggleCrosshair(), + checked: useDAWStore.getState().showCrosshair, + }, + { + label: "Clip Launcher (Session View)", + onClick: () => useDAWStore.getState().toggleClipLauncher(), + checked: useDAWStore.getState().showClipLauncher, + }, { label: "Free Item Positioning", onClick: () => useDAWStore.getState().toggleFreePositioning(), @@ -300,6 +457,19 @@ export function MenuBar() { label: "Toolbar Editor...", onClick: () => useDAWStore.getState().toggleToolbarEditor(), }, + { + label: "Metering", + submenu: [ + { + label: "Loudness Meter (LUFS)", + onClick: () => useDAWStore.getState().toggleLoudnessMeter(), + }, + { + label: "Phase Correlation", + onClick: () => useDAWStore.getState().togglePhaseCorrelation(), + }, + ], + }, { label: "Toolbars", submenu: (() => { @@ -319,7 +489,7 @@ export function MenuBar() { }, { label: "Render...", - shortcut: "Ctrl+Alt+R", + shortcut: shortcut("file.render", "Ctrl+Alt+R"), onClick: openRenderModal, }, { @@ -329,7 +499,7 @@ export function MenuBar() { }, { label: "Zoom In", - shortcut: "Ctrl++", + shortcut: shortcut("view.zoomIn", "Ctrl++"), onClick: () => { const { pixelsPerSecond, setZoom } = useDAWStore.getState(); setZoom(Math.min(pixelsPerSecond * 1.5, 500)); @@ -337,7 +507,7 @@ export function MenuBar() { }, { label: "Zoom Out", - shortcut: "Ctrl+-", + shortcut: shortcut("view.zoomOut", "Ctrl+-"), onClick: () => { const { pixelsPerSecond, setZoom } = useDAWStore.getState(); setZoom(Math.max(pixelsPerSecond / 1.5, 10)); @@ -345,7 +515,7 @@ export function MenuBar() { }, { label: "Zoom to Fit", - shortcut: "Ctrl+0", + shortcut: shortcut("view.zoomToFit", "Ctrl+0"), onClick: () => { useDAWStore.getState().setZoom(50); }, @@ -353,13 +523,13 @@ export function MenuBar() { }, { label: "Loop Enabled", - shortcut: "L", + shortcut: shortcut("transport.loop", "L"), onClick: () => useDAWStore.getState().toggleLoop(), checked: useDAWStore.getState().transport.loopEnabled, }, { label: "Set Loop to Selection", - shortcut: "Ctrl+L", + shortcut: shortcut("view.setLoopToSelection", "Ctrl+L"), onClick: () => { const { setLoopToSelection, timeSelection } = useDAWStore.getState(); if (timeSelection) { @@ -386,22 +556,22 @@ export function MenuBar() { submenu: [ { label: "Save Screenset 1", - shortcut: "Ctrl+Shift+1", + shortcut: shortcut("view.saveScreenset1", "Ctrl+Shift+1"), onClick: () => useDAWStore.getState().saveScreenset(0), }, { label: "Save Screenset 2", - shortcut: "Ctrl+Shift+2", + shortcut: shortcut("view.saveScreenset2", "Ctrl+Shift+2"), onClick: () => useDAWStore.getState().saveScreenset(1), }, { label: "Save Screenset 3", - shortcut: "Ctrl+Shift+3", + shortcut: shortcut("view.saveScreenset3", "Ctrl+Shift+3"), onClick: () => useDAWStore.getState().saveScreenset(2), }, { label: "Load Screenset 1", - shortcut: "Ctrl+1", + shortcut: shortcut("view.loadScreenset1", "Ctrl+1"), onClick: () => useDAWStore.getState().loadScreenset(0), }, ], @@ -462,7 +632,7 @@ export function MenuBar() { const insertMenuItems: MenuItemProps[] = [ { label: "Media file...", - shortcut: "Insert", + shortcut: shortcut("insert.mediaFile", "Insert"), onClick: async () => { const { selectedTrackIds, tracks, transport, importMedia } = useDAWStore.getState(); @@ -498,7 +668,7 @@ export function MenuBar() { }, { label: "New Audio Track", - shortcut: "Ctrl+T", + shortcut: shortcut("insert.audioTrack", "Ctrl+T"), onClick: () => { const { addTrack, tracks } = useDAWStore.getState(); addTrack({ @@ -510,7 +680,7 @@ export function MenuBar() { }, { label: "New MIDI Track", - shortcut: "Ctrl+Shift+T", + shortcut: shortcut("insert.midiTrack", "Ctrl+Shift+T"), onClick: () => { const { addTrack, tracks } = useDAWStore.getState(); addTrack({ @@ -537,6 +707,21 @@ export function MenuBar() { }, dividerAfter: true, }, + { + label: "New Bus/Group Track", + onClick: () => { + const { addTrack, tracks } = useDAWStore.getState(); + addTrack({ + id: crypto.randomUUID(), + name: `Bus ${tracks.filter((t: any) => t.type === "bus").length + 1}`, + type: "bus", + }); + }, + }, + { + label: "Create Bus from Selected Tracks", + onClick: () => useDAWStore.getState().createBusFromSelectedTracks(), + }, { label: "Insert Multiple Tracks...", onClick: () => { @@ -592,7 +777,7 @@ export function MenuBar() { }, { label: "Marker at Playhead", - shortcut: "M", + shortcut: shortcut("insert.marker", "M"), onClick: () => { const { addMarker, transport } = useDAWStore.getState(); addMarker(transport.currentTime); @@ -600,7 +785,7 @@ export function MenuBar() { }, { label: "Marker with name...", - shortcut: "Shift+M", + shortcut: shortcut("insert.markerNamed", "Shift+M"), onClick: () => { const { addMarker, transport } = useDAWStore.getState(); const name = prompt("Enter marker name:"); @@ -611,7 +796,7 @@ export function MenuBar() { }, { label: "Region from selection", - shortcut: "Shift+R", + shortcut: shortcut("insert.regionFromSelection", "Shift+R"), onClick: () => { const { addRegion, timeSelection } = useDAWStore.getState(); if (timeSelection) { @@ -728,37 +913,57 @@ export function MenuBar() { })(), dividerAfter: true, }, + { + label: "Timecode / Sync Settings...", + onClick: () => useDAWStore.getState().toggleTimecodeSettings(), + }, { label: "Preferences...", - shortcut: "Ctrl+,", + shortcut: shortcut("options.preferences", "Ctrl+,"), onClick: () => useDAWStore.getState().togglePreferences(), }, ]; // Help menu const helpMenuItems: MenuItemProps[] = [ + { + label: "Getting Started Guide", + onClick: () => useDAWStore.getState().toggleGettingStarted(), + }, + { + label: "Help Reference", + shortcut: shortcut("help.contextualHelp", "F1"), + onClick: () => useDAWStore.getState().toggleContextualHelp(), + dividerAfter: true, + }, { label: "Keyboard Shortcuts", - shortcut: "F1", onClick: () => useDAWStore.getState().toggleKeyboardShortcuts(), }, { - label: "About Studio13", + label: "Check for Updates...", onClick: () => { - alert( - "Studio13 v3\n\n" + - "A hybrid DAW with JUCE C++ backend and React/TypeScript frontend.\n\n" + - "Built with:\n JUCE 8.0 — Audio engine, VST3 hosting\n" + - " React — User interface\n Konva — Timeline canvas\n Zustand — State management\n\n" + - "github.com/studio13" - ); + void handleCheckForUpdates(); }, - dividerAfter: true, }, { label: "Command Palette", - shortcut: "Ctrl+Shift+P", + shortcut: shortcut("view.commandPalette", "Ctrl+Shift+P"), onClick: () => useDAWStore.getState().toggleCommandPalette(), + dividerAfter: true, + }, + { + label: "About OpenStudio", + onClick: () => { + void handleAbout(); + /* + "OpenStudio\n\n" + + "A hybrid DAW with JUCE C++ backend and React/TypeScript frontend.\n\n" + + "Built with:\n JUCE 8.0 — Audio engine, VST3 hosting\n" + + " React — User interface\n Konva — Timeline canvas\n Zustand — State management\n\n" + + "github.com/studio13" + */ + }, }, ]; @@ -769,8 +974,8 @@ export function MenuBar() { onDoubleClick={handleDoubleClick} > {/* App icon + Menus (no-drag so clicks work normally) */} -
- Studio13 +
+ OpenStudio @@ -783,11 +988,12 @@ export function MenuBar() {
{/* Window controls */} -
+
@@ -795,6 +1001,7 @@ export function MenuBar() { onClick={handleMaximize} className="h-full px-3.5 flex items-center justify-center text-neutral-400 hover:bg-neutral-700/60 hover:text-white transition-colors" title={isMaximized ? "Restore" : "Maximize"} + aria-label={isMaximized ? "Restore window" : "Maximize window"} > {isMaximized ? : } @@ -802,6 +1009,7 @@ export function MenuBar() { onClick={handleClose} className="h-full px-3.5 flex items-center justify-center text-neutral-400 hover:bg-red-600 hover:text-white transition-colors" title="Close" + aria-label="Close window" > diff --git a/frontend/src/components/MetronomeSettings.tsx b/frontend/src/components/MetronomeSettings.tsx index 7ac34a1..08a3f15 100644 --- a/frontend/src/components/MetronomeSettings.tsx +++ b/frontend/src/components/MetronomeSettings.tsx @@ -1,6 +1,7 @@ import { createPortal } from "react-dom"; import { X } from "lucide-react"; import { useDAWStore } from "../store/useDAWStore"; +import { useShallow } from "zustand/shallow"; import classNames from "classnames"; import { Button, TimeSignatureInput, Slider } from "./ui"; @@ -24,7 +25,21 @@ export function MetronomeSettings({ isOpen, onClose }: MetronomeSettingsProps) { removeMetronomeTrack, metronomeClickPath, metronomeAccentPath, - } = useDAWStore(); + } = useDAWStore(useShallow((s) => ({ + timeSignature: s.timeSignature, + setTimeSignature: s.setTimeSignature, + metronomeAccentBeats: s.metronomeAccentBeats, + setMetronomeAccentBeats: s.setMetronomeAccentBeats, + metronomeEnabled: s.metronomeEnabled, + toggleMetronome: s.toggleMetronome, + metronomeVolume: s.metronomeVolume, + setMetronomeVolume: s.setMetronomeVolume, + metronomeTrackId: s.metronomeTrackId, + generateMetronomeTrack: s.generateMetronomeTrack, + removeMetronomeTrack: s.removeMetronomeTrack, + metronomeClickPath: s.metronomeClickPath, + metronomeAccentPath: s.metronomeAccentPath, + }))); if (!isOpen) return null; diff --git a/frontend/src/components/MissingMediaResolver.tsx b/frontend/src/components/MissingMediaResolver.tsx new file mode 100644 index 0000000..7dce27f --- /dev/null +++ b/frontend/src/components/MissingMediaResolver.tsx @@ -0,0 +1,200 @@ +import { useState } from "react"; +import { AlertTriangle, Search, FolderOpen, X, Check } from "lucide-react"; +import { nativeBridge } from "../services/NativeBridge"; +import { + Button, + Modal, + ModalHeader, + ModalContent, + ModalFooter, +} from "./ui"; + +interface MissingFile { + originalPath: string; + clipIds: string[]; + resolved: boolean; + newPath?: string; +} + +interface MissingMediaResolverProps { + isOpen: boolean; + onClose: () => void; + missingFiles: Array<{ path: string; clipIds: string[] }>; + onResolve: (originalPath: string, newPath: string) => void; + onResolveAll: () => void; +} + +/** + * MissingMediaResolver (Sprint 20.6) + * Detects missing audio files on project load and provides + * a dialog to locate or skip them. + */ +export function MissingMediaResolver({ + isOpen, + onClose, + missingFiles, + onResolve, + onResolveAll, +}: MissingMediaResolverProps) { + const [files, setFiles] = useState( + missingFiles.map((f) => ({ + originalPath: f.path, + clipIds: f.clipIds, + resolved: false, + })), + ); + const [searchDir, setSearchDir] = useState(""); + + const resolvedCount = files.filter((f) => f.resolved).length; + + const handleLocateFile = async (index: number) => { + try { + const result = await nativeBridge.browseForFile( + "Locate: " + getFileName(files[index].originalPath), + "Audio Files|*.wav;*.mp3;*.flac;*.aif;*.aiff;*.ogg", + ); + if (result) { + const updated = [...files]; + updated[index] = { ...updated[index], resolved: true, newPath: result }; + setFiles(updated); + onResolve(files[index].originalPath, result); + } + } catch { + // User cancelled + } + }; + + const handleSearchDirectory = async () => { + try { + const dir = await nativeBridge.browseForFolder("Search in folder"); + if (!dir) return; + setSearchDir(dir); + + // Try to find each missing file by name in the selected directory + const updated = [...files]; + for (let i = 0; i < updated.length; i++) { + if (updated[i].resolved) continue; + const fileName = getFileName(updated[i].originalPath); + // Check if file exists in the selected directory + try { + const found = await nativeBridge.fileExists(dir + "/" + fileName); + if (found) { + const newPath = dir + "/" + fileName; + updated[i] = { ...updated[i], resolved: true, newPath }; + onResolve(updated[i].originalPath, newPath); + } + } catch { + // File not found in dir + } + } + setFiles(updated); + } catch { + // User cancelled + } + }; + + const handleSkipAll = () => { + onClose(); + }; + + const handleDone = () => { + onResolveAll(); + onClose(); + }; + + return ( + + + +
+ {/* Warning banner */} +
+ + + {files.length} media file{files.length !== 1 ? "s" : ""} could + not be found. Locate them to restore playback. + +
+ + {/* Search in directory */} +
+ + {searchDir && ( + + Searched: {searchDir} + + )} +
+ + {/* File list */} +
+ {files.map((file, index) => ( +
+ {file.resolved ? ( + + ) : ( + + )} +
+

+ {getFileName(file.originalPath)} +

+

+ {file.originalPath} +

+ {file.newPath && ( +

+ → {file.newPath} +

+ )} +
+ + {file.clipIds.length} clip{file.clipIds.length !== 1 ? "s" : ""} + + {!file.resolved && ( + + )} +
+ ))} +
+ + {/* Status */} +

+ {resolvedCount} of {files.length} resolved +

+
+
+ + + + +
+ ); +} + +function getFileName(path: string): string { + return path.replace(/\\/g, "/").split("/").pop() || path; +} diff --git a/frontend/src/components/MixerPanel.tsx b/frontend/src/components/MixerPanel.tsx index 52eda79..30daa72 100644 --- a/frontend/src/components/MixerPanel.tsx +++ b/frontend/src/components/MixerPanel.tsx @@ -13,19 +13,33 @@ import { horizontalListSortingStrategy, } from "@dnd-kit/sortable"; -import { X } from "lucide-react"; +import { X, Save, Trash2, ExternalLink, ArrowDownToLine, Plus, Power } from "lucide-react"; import { ChannelStrip } from "./ChannelStrip"; import { SortableTrack } from "./SortableTrack"; -import { useDAWStore, Track } from "../store/useDAWStore"; +import { DetachablePanel } from "./DetachablePanel"; +import { useDAWStore, Track, MixerSnapshot } from "../store/useDAWStore"; import { useShallow } from "zustand/react/shallow"; +import { nativeBridge } from "../services/NativeBridge"; import { Button } from "./ui"; +import { useCallback, useState, useEffect } from "react"; interface MixerPanelProps { isVisible: boolean; + isDetached?: boolean; + renderInOwnWindow?: boolean; + onDetach?: () => void; + onAttach?: () => void; onClose?: () => void; } -export function MixerPanel({ isVisible, onClose }: MixerPanelProps) { +export function MixerPanel({ + isVisible, + isDetached = false, + renderInOwnWindow = false, + onDetach, + onAttach, + onClose, +}: MixerPanelProps) { const { tracks, masterVolume, @@ -37,6 +51,10 @@ export function MixerPanel({ isVisible, onClose }: MixerPanelProps) { selectedTrackIds, selectTrack, deselectAllTracks, + mixerSnapshots, + saveMixerSnapshot, + recallMixerSnapshot, + deleteMixerSnapshot, } = useDAWStore(useShallow((s) => ({ tracks: s.tracks, masterVolume: s.masterVolume, @@ -48,8 +66,15 @@ export function MixerPanel({ isVisible, onClose }: MixerPanelProps) { selectedTrackIds: s.selectedTrackIds, selectTrack: s.selectTrack, deselectAllTracks: s.deselectAllTracks, + mixerSnapshots: s.mixerSnapshots, + saveMixerSnapshot: s.saveMixerSnapshot, + recallMixerSnapshot: s.recallMixerSnapshot, + deleteMixerSnapshot: s.deleteMixerSnapshot, }))); + const handleDetach = useCallback(() => onDetach?.(), [onDetach]); + const handleAttach = useCallback(() => onAttach?.(), [onAttach]); + const sensors = useSensors( useSensor(PointerSensor), useSensor(KeyboardSensor, { @@ -65,7 +90,111 @@ export function MixerPanel({ isVisible, onClose }: MixerPanelProps) { } }; - if (!isVisible) return null; + // --- Monitoring FX state --- + interface MonitorFXSlot { + index: number; + name: string; + pluginPath?: string; + bypassed?: boolean; + precisionOverride?: "auto" | "float32"; + } + const [monitorFXList, setMonitorFXList] = useState([]); + const [showMonitorPluginPicker, setShowMonitorPluginPicker] = useState(false); + const [availablePlugins, setAvailablePlugins] = useState<{ name: string; fileOrIdentifier: string }[]>([]); + const [monitorPluginSearch, setMonitorPluginSearch] = useState(""); + const [addingMonitorPlugin, setAddingMonitorPlugin] = useState(false); + const [updatingMonitorPrecision, setUpdatingMonitorPrecision] = useState(null); + + const refreshMonitorFX = useCallback(async () => { + try { + const list = await nativeBridge.getMonitoringFX(); + setMonitorFXList(list); + } catch (e) { + console.error("[MixerPanel] Failed to get monitoring FX:", e); + } + }, []); + + useEffect(() => { + if (isVisible) { + refreshMonitorFX(); + } + }, [isVisible, refreshMonitorFX]); + + const handleAddMonitorFX = useCallback(async (pluginPath: string) => { + setAddingMonitorPlugin(true); + try { + const success = await nativeBridge.addMonitoringFX(pluginPath); + if (success) { + await refreshMonitorFX(); + setShowMonitorPluginPicker(false); + setMonitorPluginSearch(""); + } + } catch (e) { + console.error("[MixerPanel] Failed to add monitoring FX:", e); + } finally { + setAddingMonitorPlugin(false); + } + }, [refreshMonitorFX]); + + const handleRemoveMonitorFX = useCallback(async (fxIndex: number) => { + try { + await nativeBridge.removeMonitoringFX(fxIndex); + await refreshMonitorFX(); + } catch (e) { + console.error("[MixerPanel] Failed to remove monitoring FX:", e); + } + }, [refreshMonitorFX]); + + const handleBypassMonitorFX = useCallback(async (fxIndex: number, bypassed: boolean) => { + try { + await nativeBridge.bypassMonitoringFX(fxIndex, bypassed); + await refreshMonitorFX(); + } catch (e) { + console.error("[MixerPanel] Failed to bypass monitoring FX:", e); + } + }, [refreshMonitorFX]); + + const handleOpenMonitorFXEditor = useCallback(async (fxIndex: number) => { + try { + await nativeBridge.openMonitoringFXEditor(fxIndex); + } catch (e) { + console.error("[MixerPanel] Failed to open monitoring FX editor:", e); + } + }, []); + + const handleSetMonitorPrecisionOverride = useCallback(async (fxIndex: number, mode: "auto" | "float32") => { + setUpdatingMonitorPrecision(fxIndex); + try { + await nativeBridge.setMonitoringFXPrecisionOverride(fxIndex, mode); + setMonitorFXList((prev) => + prev.map((fx) => + fx.index === fxIndex ? { ...fx, precisionOverride: mode } : fx, + ), + ); + } catch (e) { + console.error("[MixerPanel] Failed to set monitor precision override:", e); + } finally { + setUpdatingMonitorPrecision(null); + } + }, []); + + const handleOpenMonitorPluginPicker = useCallback(async () => { + try { + const plugins = await nativeBridge.getAvailablePlugins(); + setAvailablePlugins( + plugins + .filter((p: any) => !p.isInstrument) + .map((p: any) => ({ name: p.name, fileOrIdentifier: p.fileOrIdentifier })) + ); + } catch (e) { + console.error("[MixerPanel] Failed to load plugins for monitor FX:", e); + } + setShowMonitorPluginPicker(true); + }, []); + + const filteredMonitorPlugins = availablePlugins.filter((p) => + p.name.toLowerCase().includes(monitorPluginSearch.toLowerCase()) + ); const masterVolumeDB = masterVolume > 0 ? 20 * Math.log10(masterVolume) : -60; @@ -82,6 +211,7 @@ export function MixerPanel({ isVisible, onClose }: MixerPanelProps) { muted: false, soloed: false, armed: false, + recordSafe: false, monitorEnabled: false, inputChannel: null, inputStartChannel: 0, @@ -96,40 +226,124 @@ export function MixerPanel({ isVisible, onClose }: MixerPanelProps) { clipping: false, automationLanes: [], showAutomation: false, + automationEnabled: true, + suspendedAutomationState: null, frozen: false, takes: [], activeTakeIndex: 0, sends: [], + phaseInverted: false, + stereoWidth: 100, + masterSendEnabled: true, + outputStartChannel: 0, + outputChannelCount: 2, + playbackOffsetMs: 0, + trackChannelCount: 2, + midiOutputDevice: "", }; - return ( -
+ const mixerContent = ( +
{/* Header */} -
+
Mixer - + ) : ( + + )} + {!isDetached && ( + + )} +
+
+ + {/* Mixer Snapshots Toolbar */} +
+ + Snapshots + + {mixerSnapshots.map((snap: MixerSnapshot, idx: number) => ( +
+ + +
+ ))} + + + Save +
- {/* Channel Strips Container — click empty space to deselect */} + {/* Channel Strips Container */}
{ if (e.target === e.currentTarget) deselectAllTracks(); }} > - {/* Master Channel - Fixed position */} - - {/* Track Channels - Sortable */} +
+ + {/* Monitor FX Section */} +
+
+ {/* Header */} +
+ + Monitor FX + + +
+ + {/* "Monitor Only" label */} +
+ + Monitor Only — not in renders + +
+ + {/* FX Slots */} +
+ {monitorFXList.length === 0 ? ( +
+ No monitoring plugins. Click + to add. +
+ ) : ( + monitorFXList.map((fx) => ( +
+ + + + +
+ )) + )} +
+ + {/* Inline Plugin Picker */} + {showMonitorPluginPicker && ( +
+
+ setMonitorPluginSearch(e.target.value)} + autoFocus + aria-label="Search monitor effect plugins" + /> + +
+
+ {filteredMonitorPlugins.length === 0 ? ( +
+ No plugins found +
+ ) : ( + filteredMonitorPlugins.slice(0, 50).map((plugin) => ( + + )) + )} +
+
+ )} +
-
+
+ ); + + if (renderInOwnWindow) { + return mixerContent; + } + + return ( + + {mixerContent} + ); } diff --git a/frontend/src/components/NoteInspector.tsx b/frontend/src/components/NoteInspector.tsx new file mode 100644 index 0000000..99aeb42 --- /dev/null +++ b/frontend/src/components/NoteInspector.tsx @@ -0,0 +1,192 @@ +import React, { useCallback } from "react"; +import { useShallow } from "zustand/shallow"; +import { usePitchEditorStore } from "../store/pitchEditorStore"; +import type { PitchNoteData } from "../services/NativeBridge"; + +const NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; + +function formatNoteName(midi: number): string { + const noteClass = ((Math.round(midi) % 12) + 12) % 12; + const octave = Math.floor(Math.round(midi) / 12) - 1; + const cents = Math.round((midi - Math.round(midi)) * 100); + return `${NOTE_NAMES[noteClass]}${octave}${cents !== 0 ? ` ${cents > 0 ? "+" : ""}${cents}c` : ""}`; +} + +interface InspectorRowProps { + label: string; + value: string; + suffix?: string; + onChange: (val: number) => void; + min?: number; + max?: number; + step?: number; + disabled?: boolean; +} + +function InspectorRow({ label, value, suffix = "", onChange, min = -999, max = 999, step = 0.1, disabled }: InspectorRowProps) { + const handleInput = useCallback((e: React.ChangeEvent) => { + const v = parseFloat(e.target.value); + if (!isNaN(v)) onChange(Math.max(min, Math.min(max, v))); + }, [onChange, min, max]); + + return ( +
+ {label} +
+ + {suffix && {suffix}} +
+
+ ); +} + +export function NoteInspector() { + const { + selectedNoteIds, notes, inspectorExpanded, toggleInspector, + setNoteFormant, setNoteGain, setNoteModulation, setNoteDrift, setNoteTransition, + updateNote, commitNoteEdit, pushUndo, + } = usePitchEditorStore( + useShallow((s) => ({ + selectedNoteIds: s.selectedNoteIds, + notes: s.notes, + inspectorExpanded: s.inspectorExpanded, + toggleInspector: s.toggleInspector, + setNoteFormant: s.setNoteFormant, + setNoteGain: s.setNoteGain, + setNoteModulation: s.setNoteModulation, + setNoteDrift: s.setNoteDrift, + setNoteTransition: s.setNoteTransition, + updateNote: s.updateNote, + commitNoteEdit: s.commitNoteEdit, + pushUndo: s.pushUndo, + })) + ); + + const selectedNotes = notes.filter(n => selectedNoteIds.includes(n.id)); + const note: PitchNoteData | null = selectedNotes.length === 1 ? selectedNotes[0] : null; + const multi = selectedNotes.length > 1; + const hasSelection = selectedNotes.length > 0; + + if (!hasSelection) return null; + + const pitchDisplay = note ? formatNoteName(note.correctedPitch) : "\u2014"; + const centsDisplay = note ? `${Math.round((note.correctedPitch - Math.round(note.correctedPitch)) * 100)}` : "\u2014"; + const formantDisplay = note ? Math.round(note.formantShift * 100).toString() : "\u2014"; + const gainDisplay = note ? note.gain.toFixed(1) : "\u2014"; + const modulationDisplay = note ? Math.round(note.vibratoDepth * 100).toString() : "\u2014"; + const driftDisplay = note ? Math.round(note.driftCorrectionAmount * 100).toString() : "\u2014"; + const transInDisplay = note ? Math.round(note.transitionIn).toString() : "\u2014"; + const transOutDisplay = note ? Math.round(note.transitionOut).toString() : "\u2014"; + + return ( +
+ + + {inspectorExpanded && ( +
+ {/* Pitch */} +
+ Pitch +
+ {pitchDisplay} + {note && Math.abs(note.correctedPitch - note.detectedPitch) > 0.05 && ( + + {(note.correctedPitch - note.detectedPitch) > 0 ? "+" : ""} + {(note.correctedPitch - note.detectedPitch).toFixed(1)}st + + )} +
+
+ + {/* Cents offset */} + { + if (!note) return; + pushUndo("Change pitch cents"); + updateNote(note.id, { correctedPitch: Math.round(note.correctedPitch) + v / 100 }); + commitNoteEdit(); + }} + /> + + {/* Formant (cents) */} + { if (note) setNoteFormant(note.id, v / 100); }} + /> + + {/* Volume */} + { if (note) setNoteGain(note.id, v); }} + /> + + {/* Modulation (vibrato) */} + { if (note) setNoteModulation(note.id, v); }} + /> + + {/* Drift */} + { if (note) setNoteDrift(note.id, v); }} + /> + + {/* Transitions */} + { if (note) setNoteTransition(note.id, v, note.transitionOut); }} + /> + { if (note) setNoteTransition(note.id, note.transitionIn, v); }} + /> +
+ )} +
+ ); +} diff --git a/frontend/src/components/ParametricGraph/ChorusGraph.tsx b/frontend/src/components/ParametricGraph/ChorusGraph.tsx new file mode 100644 index 0000000..254e74d --- /dev/null +++ b/frontend/src/components/ParametricGraph/ChorusGraph.tsx @@ -0,0 +1,184 @@ +import { useMemo, useCallback } from "react"; +import { ParametricGraph } from "./ParametricGraph"; +import type { GraphNode, GraphAxis, GraphNodeConfig } from "./ParametricGraph.types"; + +interface S13FXSlider { + index: number; + name: string; + min: number; + max: number; + def: number; + inc: number; + value: number; + isEnum: boolean; + enumNames?: string[]; +} + +interface ChorusGraphProps { + sliders: S13FXSlider[]; + onSliderChange: (sliderIndex: number, value: number) => void; + width?: number; + height?: number; +} + +const NUM_VOICES = 4; +const SLIDERS_PER_VOICE = 4; // enabled, delay, depth, rate + +export function ChorusGraph({ + sliders, + onSliderChange, + width = 340, + height = 180, +}: ChorusGraphProps) { + const sliderMap = useMemo(() => { + const map = new Map(); + for (const s of sliders) map.set(s.index, s); + return map; + }, [sliders]); + + // Extract voices + const voices = useMemo(() => { + const result: { enabled: boolean; delay: number; depth: number; rate: number }[] = []; + for (let i = 0; i < NUM_VOICES; i++) { + const base = i * SLIDERS_PER_VOICE; + result.push({ + enabled: (sliderMap.get(base)?.value ?? 0) === 1, + delay: sliderMap.get(base + 1)?.value ?? 5, + depth: sliderMap.get(base + 2)?.value ?? 2, + rate: sliderMap.get(base + 3)?.value ?? 0.5, + }); + } + return result; + }, [sliderMap]); + + // Nodes: x=delay (ms), y=depth (ms), z=rate (Hz) + const nodes: GraphNode[] = useMemo( + () => + voices.map((v, i) => ({ + id: `voice-${i}`, + x: v.delay, + y: v.depth, + z: v.rate, + enabled: v.enabled, + label: `Voice ${i + 1}`, + })), + [voices], + ); + + // Generate per-voice LFO waveforms for visualization + const perNodeCurves = useMemo(() => { + return voices + .map((voice, i) => { + if (!voice.enabled) return null; + const points: { x: number; y: number }[] = []; + const period = 1 / Math.max(voice.rate, 0.01); + const numSamples = 100; + const duration = Math.min(period * 2, 30); // Show up to 2 cycles + + for (let j = 0; j <= numSamples; j++) { + const t = (j / numSamples) * duration; + // LFO modulation: delay ± depth + const modulation = voice.depth * Math.sin(2 * Math.PI * voice.rate * t); + const delayAtTime = voice.delay + modulation; + // Map to graph: x=time-as-delay position, y=modulated delay depth + points.push({ x: delayAtTime, y: voice.depth + modulation }); + } + return { nodeId: `voice-${i}`, points }; + }) + .filter((c): c is NonNullable => c !== null); + }, [voices]); + + const xAxis: GraphAxis = useMemo( + () => ({ + label: "Delay", + min: 0, + max: 30, + scale: "linear" as const, + unit: "ms", + gridLines: [0, 5, 10, 15, 20, 25, 30], + }), + [], + ); + + const yAxis: GraphAxis = useMemo( + () => ({ + label: "Depth", + min: 0, + max: 10, + scale: "linear" as const, + unit: "ms", + gridLines: [0, 2, 4, 6, 8, 10], + }), + [], + ); + + const nodeConfig: GraphNodeConfig = useMemo( + () => ({ + maxNodes: NUM_VOICES, + zAxis: { + label: "Rate", + min: 0.05, + max: 5, + default: 0.5, + sensitivity: 0.005, + }, + }), + [], + ); + + const handleNodeAdd = useCallback( + (x: number, y: number) => { + const disabledIdx = voices.findIndex((v) => !v.enabled); + if (disabledIdx === -1) return; + const base = disabledIdx * SLIDERS_PER_VOICE; + onSliderChange(base, 1); // Enable + onSliderChange(base + 1, Math.round(Math.max(0.1, Math.min(30, x)) * 10) / 10); // Delay + onSliderChange(base + 2, Math.round(Math.max(0.1, Math.min(10, y)) * 10) / 10); // Depth + onSliderChange(base + 3, 0.5); // Default rate + }, + [voices, onSliderChange], + ); + + const handleNodeRemove = useCallback( + (id: string) => { + const voiceIdx = parseInt(id.replace("voice-", ""), 10); + if (isNaN(voiceIdx)) return; + const base = voiceIdx * SLIDERS_PER_VOICE; + onSliderChange(base, 0); // Disable + }, + [onSliderChange], + ); + + const handleNodeChange = useCallback( + (id: string, changes: Partial) => { + const voiceIdx = parseInt(id.replace("voice-", ""), 10); + if (isNaN(voiceIdx)) return; + const base = voiceIdx * SLIDERS_PER_VOICE; + if (changes.x !== undefined) { + onSliderChange(base + 1, Math.round(Math.max(0.1, Math.min(30, changes.x)) * 10) / 10); + } + if (changes.y !== undefined) { + onSliderChange(base + 2, Math.round(Math.max(0.1, Math.min(10, changes.y)) * 10) / 10); + } + if (changes.z !== undefined) { + onSliderChange(base + 3, Math.round(Math.max(0.05, Math.min(5, changes.z)) * 100) / 100); + } + }, + [onSliderChange], + ); + + return ( + + ); +} diff --git a/frontend/src/components/ParametricGraph/CompressorGraph.tsx b/frontend/src/components/ParametricGraph/CompressorGraph.tsx new file mode 100644 index 0000000..0bfc4f3 --- /dev/null +++ b/frontend/src/components/ParametricGraph/CompressorGraph.tsx @@ -0,0 +1,182 @@ +import { useMemo, useCallback } from "react"; +import { ParametricGraph } from "./ParametricGraph"; +import type { GraphNode, GraphAxis, GraphNodeConfig } from "./ParametricGraph.types"; + +interface S13FXSlider { + index: number; + name: string; + min: number; + max: number; + def: number; + inc: number; + value: number; + isEnum: boolean; + enumNames?: string[]; +} + +interface CompressorGraphProps { + sliders: S13FXSlider[]; + onSliderChange: (sliderIndex: number, value: number) => void; + width?: number; + height?: number; +} + +// Slider indices (0-based): threshold=0, ratio=1, knee=2, attack=3, release=4, +// hold=5, makeup=6, mix=7, detection=8, sc_hpf=9, sc_lpf=10, auto_gain=11, +// stereo_link=12, lookahead=13 + +function computeTransferCurve( + threshold: number, + ratio: number, + knee: number, + makeup: number, +): { x: number; y: number }[] { + const points: { x: number; y: number }[] = []; + const makeupLin = makeup; // dB + const kneeLo = threshold - knee / 2; + const kneeHi = threshold + knee / 2; + + for (let inputDB = -60; inputDB <= 0; inputDB += 0.5) { + let outputDB: number; + if (inputDB < kneeLo) { + outputDB = inputDB; + } else if (inputDB > kneeHi) { + outputDB = threshold + (inputDB - threshold) / ratio; + } else { + // Soft knee quadratic interpolation + const x = inputDB - kneeLo; + const gr = (1 - 1 / ratio) * x * x / (2 * knee); + outputDB = inputDB - gr; + } + outputDB += makeupLin; + points.push({ x: inputDB, y: Math.max(-60, Math.min(0, outputDB)) }); + } + return points; +} + +export function CompressorGraph({ + sliders, + onSliderChange, + width = 340, + height = 180, +}: CompressorGraphProps) { + const sliderMap = useMemo(() => { + const map = new Map(); + for (const s of sliders) map.set(s.index, s); + return map; + }, [sliders]); + + const threshold = sliderMap.get(0)?.value ?? -20; + const ratio = sliderMap.get(1)?.value ?? 4; + const knee = sliderMap.get(2)?.value ?? 6; + const makeup = sliderMap.get(6)?.value ?? 0; + + // Single node representing the threshold/ratio point + const nodes: GraphNode[] = useMemo( + () => [ + { + id: "comp-point", + x: threshold, + y: threshold, // At threshold, output = input (before makeup) + z: knee, + enabled: true, + label: "Threshold", + }, + ], + [threshold, knee], + ); + + const responseCurve = useMemo( + () => computeTransferCurve(threshold, ratio, knee, makeup), + [threshold, ratio, knee, makeup], + ); + + // Unity line for reference (rendered as a per-node curve) + const perNodeCurves = useMemo(() => { + const unityPts: { x: number; y: number }[] = []; + for (let db = -60; db <= 0; db += 1) unityPts.push({ x: db, y: db }); + return [{ nodeId: "unity", points: unityPts }]; + }, []); + + const xAxis: GraphAxis = useMemo( + () => ({ + label: "Input", + min: -60, + max: 0, + scale: "linear" as const, + unit: "dB", + gridLines: [-60, -48, -36, -24, -12, 0], + }), + [], + ); + + const yAxis: GraphAxis = useMemo( + () => ({ + label: "Output", + min: -60, + max: 0, + scale: "linear" as const, + unit: "dB", + gridLines: [-60, -48, -36, -24, -12, 0], + }), + [], + ); + + const nodeConfig: GraphNodeConfig = useMemo( + () => ({ + maxNodes: 1, + zAxis: { + label: "Knee", + min: 0, + max: 24, + default: 6, + sensitivity: 0.05, + }, + }), + [], + ); + + const handleNodeChange = useCallback( + (_id: string, changes: Partial) => { + if (changes.x !== undefined) { + // X = threshold + onSliderChange(0, Math.round(Math.max(-60, Math.min(0, changes.x)) * 10) / 10); + } + if (changes.y !== undefined) { + // Y movement changes ratio: at threshold, output should be threshold + // so we derive ratio from how far y deviates (not directly useful for a single-point) + // Instead, treat Y drag above the unity line as ratio adjustment + const inputAtThreshold = threshold; + const outputAtPoint = changes.y; + if (outputAtPoint < inputAtThreshold && inputAtThreshold < 0) { + // Approximate ratio from threshold and desired output at some point above threshold + const testInput = 0; // 0 dB + const desiredOutput = outputAtPoint + (testInput - inputAtThreshold); // rough + const diff = testInput - threshold; + if (diff > 0) { + const newRatio = diff / Math.max(0.1, desiredOutput - threshold + diff); + onSliderChange(1, Math.round(Math.max(1, Math.min(20, newRatio)) * 10) / 10); + } + } + } + if (changes.z !== undefined) { + onSliderChange(2, Math.round(Math.max(0, Math.min(24, changes.z)) * 10) / 10); + } + }, + [onSliderChange, threshold], + ); + + return ( + + ); +} diff --git a/frontend/src/components/ParametricGraph/DelayGraph.tsx b/frontend/src/components/ParametricGraph/DelayGraph.tsx new file mode 100644 index 0000000..722b6c5 --- /dev/null +++ b/frontend/src/components/ParametricGraph/DelayGraph.tsx @@ -0,0 +1,162 @@ +import { useMemo, useCallback } from "react"; +import { ParametricGraph } from "./ParametricGraph"; +import type { GraphNode, GraphAxis, GraphNodeConfig } from "./ParametricGraph.types"; + +interface S13FXSlider { + index: number; + name: string; + min: number; + max: number; + def: number; + inc: number; + value: number; + isEnum: boolean; + enumNames?: string[]; +} + +interface DelayGraphProps { + sliders: S13FXSlider[]; + onSliderChange: (sliderIndex: number, value: number) => void; + width?: number; + height?: number; +} + +const NUM_TAPS = 8; +const SLIDERS_PER_TAP = 5; + +export function DelayGraph({ + sliders, + onSliderChange, + width = 340, + height = 180, +}: DelayGraphProps) { + const sliderMap = useMemo(() => { + const map = new Map(); + for (const s of sliders) map.set(s.index, s); + return map; + }, [sliders]); + + // Extract taps from sliders + const taps = useMemo(() => { + const result: { enabled: boolean; time: number; level: number; pan: number; feedback: number }[] = []; + for (let i = 0; i < NUM_TAPS; i++) { + const base = i * SLIDERS_PER_TAP; + result.push({ + enabled: (sliderMap.get(base)?.value ?? 0) === 1, + time: sliderMap.get(base + 1)?.value ?? 250, + level: sliderMap.get(base + 2)?.value ?? 100, + pan: sliderMap.get(base + 3)?.value ?? 0, + feedback: sliderMap.get(base + 4)?.value ?? 0, + }); + } + return result; + }, [sliderMap]); + + // Create nodes: x=time, y=level, z=pan + const nodes: GraphNode[] = useMemo( + () => + taps.map((tap, i) => ({ + id: `tap-${i}`, + x: tap.time, + y: tap.level, + z: tap.pan, + enabled: tap.enabled, + label: `Tap ${i + 1}`, + })), + [taps], + ); + + const xAxis: GraphAxis = useMemo( + () => ({ + label: "Time", + min: 0, + max: 2000, + scale: "linear" as const, + unit: "ms", + gridLines: [0, 250, 500, 750, 1000, 1250, 1500, 1750, 2000], + }), + [], + ); + + const yAxis: GraphAxis = useMemo( + () => ({ + label: "Level", + min: 0, + max: 100, + scale: "linear" as const, + unit: "%", + gridLines: [0, 25, 50, 75, 100], + }), + [], + ); + + const nodeConfig: GraphNodeConfig = useMemo( + () => ({ + maxNodes: NUM_TAPS, + zAxis: { + label: "Pan", + min: -100, + max: 100, + default: 0, + sensitivity: 0.5, + }, + }), + [], + ); + + const handleNodeAdd = useCallback( + (x: number, y: number) => { + const disabledIdx = taps.findIndex((t) => !t.enabled); + if (disabledIdx === -1) return; + const base = disabledIdx * SLIDERS_PER_TAP; + onSliderChange(base, 1); // Enable + onSliderChange(base + 1, Math.round(Math.max(1, Math.min(2000, x)))); // Time + onSliderChange(base + 2, Math.round(Math.max(0, Math.min(100, y)))); // Level + onSliderChange(base + 3, 0); // Pan center + onSliderChange(base + 4, 0); // No feedback + }, + [taps, onSliderChange], + ); + + const handleNodeRemove = useCallback( + (id: string) => { + const tapIdx = parseInt(id.replace("tap-", ""), 10); + if (isNaN(tapIdx)) return; + const base = tapIdx * SLIDERS_PER_TAP; + onSliderChange(base, 0); // Disable + }, + [onSliderChange], + ); + + const handleNodeChange = useCallback( + (id: string, changes: Partial) => { + const tapIdx = parseInt(id.replace("tap-", ""), 10); + if (isNaN(tapIdx)) return; + const base = tapIdx * SLIDERS_PER_TAP; + if (changes.x !== undefined) { + onSliderChange(base + 1, Math.round(Math.max(1, Math.min(2000, changes.x)))); + } + if (changes.y !== undefined) { + onSliderChange(base + 2, Math.round(Math.max(0, Math.min(100, changes.y)))); + } + if (changes.z !== undefined) { + onSliderChange(base + 3, Math.round(Math.max(-100, Math.min(100, changes.z)))); + } + }, + [onSliderChange], + ); + + return ( + + ); +} diff --git a/frontend/src/components/ParametricGraph/EQGraph.tsx b/frontend/src/components/ParametricGraph/EQGraph.tsx new file mode 100644 index 0000000..f0d4566 --- /dev/null +++ b/frontend/src/components/ParametricGraph/EQGraph.tsx @@ -0,0 +1,216 @@ +import { useMemo, useCallback } from "react"; +import { ParametricGraph } from "./ParametricGraph"; +import type { GraphNode, GraphAxis, GraphNodeConfig } from "./ParametricGraph.types"; +import { + FilterType, + computeEQResponse, + computeSingleBandResponse, + type EQBand, +} from "./eqResponseCurve"; + +const SAMPLE_RATE = 48000; +const NUM_BANDS = 8; +const SLIDERS_PER_BAND = 5; + +const FILTER_TYPES = [ + { value: 0, label: "Low Shelf" }, + { value: 1, label: "Peak" }, + { value: 2, label: "High Shelf" }, + { value: 3, label: "Low Pass" }, + { value: 4, label: "High Pass" }, + { value: 5, label: "Notch" }, +]; + +interface S13FXSlider { + index: number; + name: string; + min: number; + max: number; + def: number; + inc: number; + value: number; + isEnum: boolean; + enumNames?: string[]; +} + +interface EQGraphProps { + sliders: S13FXSlider[]; + onSliderChange: (sliderIndex: number, value: number) => void; + width?: number; + height?: number; +} + +function getBandFromSliders( + sliderMap: Map, + bandIndex: number, +): EQBand { + const base = bandIndex * SLIDERS_PER_BAND; + return { + enabled: (sliderMap.get(base)?.value ?? 0) === 1, + type: sliderMap.get(base + 1)?.value ?? FilterType.Peak, + freq: sliderMap.get(base + 2)?.value ?? [80, 250, 800, 2000, 4000, 8000, 12000, 16000][bandIndex], + gainDB: sliderMap.get(base + 3)?.value ?? 0, + q: sliderMap.get(base + 4)?.value ?? 1.0, + }; +} + +export function EQGraph({ + sliders, + onSliderChange, + width = 500, + height = 200, +}: EQGraphProps) { + // Build index-based lookup map for robust slider access + const sliderMap = useMemo(() => { + const map = new Map(); + for (const s of sliders) map.set(s.index, s); + return map; + }, [sliders]); + + // Extract bands from slider data + const bands = useMemo(() => { + const result: EQBand[] = []; + for (let i = 0; i < NUM_BANDS; i++) { + result.push(getBandFromSliders(sliderMap, i)); + } + return result; + }, [sliderMap]); + + // Create graph nodes from bands + const nodes: GraphNode[] = useMemo( + () => + bands.map((band, i) => ({ + id: `band-${i}`, + x: band.freq, + y: band.gainDB, + z: band.q, + enabled: band.enabled, + nodeType: band.type, + label: `Band ${i + 1}`, + })), + [bands], + ); + + // Compute combined frequency response + const responseCurve = useMemo( + () => computeEQResponse(bands, SAMPLE_RATE), + [bands], + ); + + // Compute per-node curves + const perNodeCurves = useMemo( + () => + bands + .map((band, i) => ({ + nodeId: `band-${i}`, + points: computeSingleBandResponse(band, SAMPLE_RATE), + })) + .filter((c) => c.points.length > 0), + [bands], + ); + + // Axes configuration + const xAxis: GraphAxis = useMemo( + () => ({ + label: "Frequency", + min: 20, + max: 20000, + scale: "log" as const, + unit: "Hz", + gridLines: [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000], + }), + [], + ); + + const yAxis: GraphAxis = useMemo( + () => ({ + label: "Gain", + min: -24, + max: 24, + scale: "linear" as const, + unit: "dB", + gridLines: [-24, -18, -12, -6, 0, 6, 12, 18, 24], + }), + [], + ); + + const nodeConfig: GraphNodeConfig = useMemo( + () => ({ + maxNodes: NUM_BANDS, + zAxis: { + label: "Q", + min: 0.1, + max: 10, + default: 1.0, + sensitivity: 0.005, + }, + nodeTypes: FILTER_TYPES, + }), + [], + ); + + // Enable first disabled band at the clicked position + const handleNodeAdd = useCallback( + (x: number, y: number) => { + const disabledIdx = bands.findIndex((b) => !b.enabled); + if (disabledIdx === -1) return; // All 8 bands in use + const base = disabledIdx * SLIDERS_PER_BAND; + onSliderChange(base, 1); // Enable + onSliderChange(base + 1, FilterType.Peak); // Default type + onSliderChange(base + 2, Math.round(Math.max(20, Math.min(20000, x)))); // Freq + onSliderChange(base + 3, Math.round(Math.max(-24, Math.min(24, y)) * 10) / 10); // Gain + onSliderChange(base + 4, 1.0); // Q default + }, + [bands, onSliderChange], + ); + + // Disable the band + const handleNodeRemove = useCallback( + (id: string) => { + const bandIdx = parseInt(id.replace("band-", ""), 10); + if (isNaN(bandIdx)) return; + const base = bandIdx * SLIDERS_PER_BAND; + onSliderChange(base, 0); // Disable + onSliderChange(base + 3, 0); // Reset gain + }, + [onSliderChange], + ); + + // Update freq (x), gain (y), Q (z), or type when node changes + const handleNodeChange = useCallback( + (id: string, changes: Partial) => { + const bandIdx = parseInt(id.replace("band-", ""), 10); + if (isNaN(bandIdx)) return; + const base = bandIdx * SLIDERS_PER_BAND; + if (changes.x !== undefined) { + onSliderChange(base + 2, Math.round(Math.max(20, Math.min(20000, changes.x)))); + } + if (changes.y !== undefined) { + onSliderChange(base + 3, Math.round(Math.max(-24, Math.min(24, changes.y)) * 10) / 10); + } + if (changes.z !== undefined) { + onSliderChange(base + 4, Math.round(Math.max(0.1, Math.min(10, changes.z)) * 100) / 100); + } + if (changes.nodeType !== undefined) { + onSliderChange(base + 1, changes.nodeType); + } + }, + [onSliderChange], + ); + + return ( + + ); +} diff --git a/frontend/src/components/ParametricGraph/GateGraph.tsx b/frontend/src/components/ParametricGraph/GateGraph.tsx new file mode 100644 index 0000000..d39b70b --- /dev/null +++ b/frontend/src/components/ParametricGraph/GateGraph.tsx @@ -0,0 +1,166 @@ +import { useMemo, useCallback } from "react"; +import { ParametricGraph } from "./ParametricGraph"; +import type { GraphNode, GraphAxis, GraphNodeConfig } from "./ParametricGraph.types"; + +interface S13FXSlider { + index: number; + name: string; + min: number; + max: number; + def: number; + inc: number; + value: number; + isEnum: boolean; + enumNames?: string[]; +} + +interface GateGraphProps { + sliders: S13FXSlider[]; + onSliderChange: (sliderIndex: number, value: number) => void; + width?: number; + height?: number; +} + +// Slider indices: threshold=0, range=1, hysteresis=2, attack=3, hold=4, +// release=5, lookahead=6, detection=7, sc_hpf=8, sc_lpf=9, mode=10, mix=11 + +function computeGateTransferCurve( + threshold: number, + range: number, + hysteresis: number, +): { x: number; y: number }[] { + const points: { x: number; y: number }[] = []; + const closeThreshold = threshold - hysteresis; + + for (let inputDB = -80; inputDB <= 0; inputDB += 0.5) { + let outputDB: number; + if (inputDB >= threshold) { + outputDB = inputDB; // Gate open — pass through + } else if (inputDB <= closeThreshold) { + outputDB = inputDB + range; // Gate closed — attenuated by range + outputDB = Math.max(outputDB, -80); + } else { + // Transition zone (hysteresis region) — smooth interpolation + const t = (inputDB - closeThreshold) / Math.max(hysteresis, 0.1); + const smoothT = t * t * (3 - 2 * t); // smoothstep + outputDB = inputDB + range * (1 - smoothT); + outputDB = Math.max(outputDB, -80); + } + points.push({ x: inputDB, y: outputDB }); + } + return points; +} + +export function GateGraph({ + sliders, + onSliderChange, + width = 340, + height = 180, +}: GateGraphProps) { + const sliderMap = useMemo(() => { + const map = new Map(); + for (const s of sliders) map.set(s.index, s); + return map; + }, [sliders]); + + const threshold = sliderMap.get(0)?.value ?? -40; + const range = sliderMap.get(1)?.value ?? -80; + const hysteresis = sliderMap.get(2)?.value ?? 6; + + const nodes: GraphNode[] = useMemo( + () => [ + { + id: "gate-threshold", + x: threshold, + y: threshold, // At threshold, output = input (gate open) + z: hysteresis, + enabled: true, + label: "Threshold", + }, + ], + [threshold, hysteresis], + ); + + const responseCurve = useMemo( + () => computeGateTransferCurve(threshold, range, hysteresis), + [threshold, range, hysteresis], + ); + + // Unity line + const perNodeCurves = useMemo(() => { + const pts: { x: number; y: number }[] = []; + for (let db = -80; db <= 0; db += 1) pts.push({ x: db, y: db }); + return [{ nodeId: "unity", points: pts }]; + }, []); + + const xAxis: GraphAxis = useMemo( + () => ({ + label: "Input", + min: -80, + max: 0, + scale: "linear" as const, + unit: "dB", + gridLines: [-80, -60, -40, -20, 0], + }), + [], + ); + + const yAxis: GraphAxis = useMemo( + () => ({ + label: "Output", + min: -80, + max: 0, + scale: "linear" as const, + unit: "dB", + gridLines: [-80, -60, -40, -20, 0], + }), + [], + ); + + const nodeConfig: GraphNodeConfig = useMemo( + () => ({ + maxNodes: 1, + zAxis: { + label: "Hyst", + min: 0, + max: 12, + default: 6, + sensitivity: 0.05, + }, + }), + [], + ); + + const handleNodeChange = useCallback( + (_id: string, changes: Partial) => { + if (changes.x !== undefined) { + onSliderChange(0, Math.round(Math.max(-80, Math.min(0, changes.x)) * 10) / 10); + } + if (changes.y !== undefined) { + // Y drag below unity adjusts range + const diff = changes.y - (changes.x ?? threshold); + if (diff < 0) { + onSliderChange(1, Math.round(Math.max(-80, Math.min(0, diff)) * 10) / 10); + } + } + if (changes.z !== undefined) { + onSliderChange(2, Math.round(Math.max(0, Math.min(12, changes.z)) * 10) / 10); + } + }, + [onSliderChange, threshold], + ); + + return ( + + ); +} diff --git a/frontend/src/components/ParametricGraph/ParametricGraph.tsx b/frontend/src/components/ParametricGraph/ParametricGraph.tsx new file mode 100644 index 0000000..a55e1ba --- /dev/null +++ b/frontend/src/components/ParametricGraph/ParametricGraph.tsx @@ -0,0 +1,453 @@ +import { useCallback, useRef, useState } from "react"; +import type { ParametricGraphProps, GraphNode } from "./ParametricGraph.types"; + +// --- Coordinate helpers --- + +const MARGIN = { top: 10, right: 14, bottom: 24, left: 38 }; + +// Band colors — 8 distinct hues +const NODE_COLORS = [ + "#ef4444", // red + "#f97316", // orange + "#eab308", // yellow + "#22c55e", // green + "#06b6d4", // cyan + "#3b82f6", // blue + "#8b5cf6", // violet + "#ec4899", // pink +]; + +function valueToPixelX( + value: number, + min: number, + max: number, + scale: "linear" | "log", + plotWidth: number, +): number { + if (scale === "log") { + if (value <= 0) return 0; + const logMin = Math.log10(Math.max(min, 1)); + const logMax = Math.log10(max); + return ((Math.log10(value) - logMin) / (logMax - logMin)) * plotWidth; + } + return ((value - min) / (max - min)) * plotWidth; +} + +function pixelToValueX( + px: number, + min: number, + max: number, + scale: "linear" | "log", + plotWidth: number, +): number { + const ratio = Math.max(0, Math.min(1, px / plotWidth)); + if (scale === "log") { + const logMin = Math.log10(Math.max(min, 1)); + const logMax = Math.log10(max); + return Math.pow(10, logMin + ratio * (logMax - logMin)); + } + return min + ratio * (max - min); +} + +function valueToPixelY( + value: number, + min: number, + max: number, + plotHeight: number, +): number { + return (1 - (value - min) / (max - min)) * plotHeight; +} + +function pixelToValueY( + py: number, + min: number, + max: number, + plotHeight: number, +): number { + const ratio = Math.max(0, Math.min(1, py / plotHeight)); + return max - ratio * (max - min); +} + +// --- Format helpers --- + +function formatAxisValue(value: number, unit?: string, scale?: string): string { + if (unit === "Hz") { + if (value >= 1000) return `${(value / 1000).toFixed(value >= 10000 ? 0 : 1)}k`; + return `${Math.round(value)}`; + } + if (unit === "dB") return `${value > 0 ? "+" : ""}${value.toFixed(0)}`; + if (unit === "ms") return `${Math.round(value)}`; + if (unit === "%") return `${Math.round(value)}`; + if (unit === "s") return `${value.toFixed(1)}`; + if (scale === "log") return value >= 1000 ? `${(value / 1000).toFixed(1)}k` : `${Math.round(value)}`; + return Number.isInteger(value) ? `${value}` : value.toFixed(1); +} + +export function ParametricGraph({ + width, + height, + xAxis, + yAxis, + nodes, + nodeConfig, + responseCurve, + perNodeCurves, + onNodeAdd, + onNodeChange, + onNodeRemove, + onNodeDragStart, + onNodeDragEnd, + className, +}: ParametricGraphProps) { + const svgRef = useRef(null); + const [dragNodeId, setDragNodeId] = useState(null); + const [hoveredNodeId, setHoveredNodeId] = useState(null); + + const plotWidth = width - MARGIN.left - MARGIN.right; + const plotHeight = height - MARGIN.top - MARGIN.bottom; + + // --- Coordinate converters bound to current axes --- + const toPixelX = useCallback( + (v: number) => valueToPixelX(v, xAxis.min, xAxis.max, xAxis.scale, plotWidth), + [xAxis.min, xAxis.max, xAxis.scale, plotWidth], + ); + const toPixelY = useCallback( + (v: number) => valueToPixelY(v, yAxis.min, yAxis.max, plotHeight), + [yAxis.min, yAxis.max, plotHeight], + ); + const fromPixelX = useCallback( + (px: number) => pixelToValueX(px, xAxis.min, xAxis.max, xAxis.scale, plotWidth), + [xAxis.min, xAxis.max, xAxis.scale, plotWidth], + ); + const fromPixelY = useCallback( + (py: number) => pixelToValueY(py, yAxis.min, yAxis.max, plotHeight), + [yAxis.min, yAxis.max, plotHeight], + ); + + // --- Event handlers --- + + const getSVGPoint = useCallback( + (e: React.PointerEvent | React.MouseEvent) => { + const svg = svgRef.current; + if (!svg) return { x: 0, y: 0 }; + const rect = svg.getBoundingClientRect(); + return { + x: e.clientX - rect.left - MARGIN.left, + y: e.clientY - rect.top - MARGIN.top, + }; + }, + [], + ); + + const handleBackgroundClick = useCallback( + (e: React.MouseEvent) => { + if (!onNodeAdd) return; + const pt = getSVGPoint(e); + const xVal = fromPixelX(pt.x); + const yVal = fromPixelY(pt.y); + onNodeAdd(xVal, yVal); + }, + [onNodeAdd, getSVGPoint, fromPixelX, fromPixelY], + ); + + const handleNodePointerDown = useCallback( + (e: React.PointerEvent, nodeId: string) => { + e.stopPropagation(); + e.preventDefault(); + (e.target as SVGCircleElement).setPointerCapture(e.pointerId); + setDragNodeId(nodeId); + onNodeDragStart?.(nodeId); + }, + [onNodeDragStart], + ); + + const handleNodePointerMove = useCallback( + (e: React.PointerEvent) => { + if (!dragNodeId || !onNodeChange) return; + const pt = getSVGPoint(e); + const xVal = Math.max(xAxis.min, Math.min(xAxis.max, fromPixelX(pt.x))); + const yVal = Math.max(yAxis.min, Math.min(yAxis.max, fromPixelY(pt.y))); + onNodeChange(dragNodeId, { x: xVal, y: yVal }); + }, + [dragNodeId, onNodeChange, getSVGPoint, fromPixelX, fromPixelY, xAxis, yAxis], + ); + + const handleNodePointerUp = useCallback( + (e: React.PointerEvent) => { + if (dragNodeId) { + (e.target as SVGCircleElement).releasePointerCapture(e.pointerId); + onNodeDragEnd?.(dragNodeId); + setDragNodeId(null); + } + }, + [dragNodeId, onNodeDragEnd], + ); + + const handleNodeContextMenu = useCallback( + (e: React.MouseEvent, nodeId: string) => { + e.preventDefault(); + e.stopPropagation(); + onNodeRemove?.(nodeId); + }, + [onNodeRemove], + ); + + const handleNodeWheel = useCallback( + (e: React.WheelEvent, node: GraphNode) => { + e.stopPropagation(); + if (!onNodeChange || !nodeConfig.zAxis || node.z === undefined) return; + const { min, max, sensitivity } = nodeConfig.zAxis; + const delta = -e.deltaY * sensitivity; + const newZ = Math.max(min, Math.min(max, node.z + delta)); + onNodeChange(node.id, { z: newZ }); + }, + [onNodeChange, nodeConfig.zAxis], + ); + + // --- Rendering --- + + const xGridLines = xAxis.gridLines ?? []; + const yGridLines = yAxis.gridLines ?? []; + + // Build the combined response curve path + let responsePath = ""; + let responseAreaPath = ""; + if (responseCurve && responseCurve.length > 0) { + const zeroY = toPixelY(0); + const pts = responseCurve.map((p) => ({ + px: toPixelX(p.x), + py: toPixelY(p.y), + })); + responsePath = pts.map((p, i) => `${i === 0 ? "M" : "L"} ${p.px} ${p.py}`).join(" "); + responseAreaPath = + `M ${pts[0].px} ${zeroY} ` + + pts.map((p) => `L ${p.px} ${p.py}`).join(" ") + + ` L ${pts[pts.length - 1].px} ${zeroY} Z`; + } + + // Build per-node curve paths + const nodeCurvePaths: { nodeId: string; path: string }[] = []; + if (perNodeCurves) { + for (const nc of perNodeCurves) { + const pts = nc.points.map((p) => ({ + px: toPixelX(p.x), + py: toPixelY(p.y), + })); + const path = pts.map((p, i) => `${i === 0 ? "M" : "L"} ${p.px} ${p.py}`).join(" "); + nodeCurvePaths.push({ nodeId: nc.nodeId, path }); + } + } + + // Find the enabled nodes for rendering + const enabledNodes = nodes.filter((n) => n.enabled); + + return ( + + + {/* Plot background */} + + + {/* X grid lines */} + {xGridLines.map((v) => { + const px = toPixelX(v); + if (px < 0 || px > plotWidth) return null; + return ( + + ); + })} + + {/* Y grid lines */} + {yGridLines.map((v) => { + const py = toPixelY(v); + if (py < 0 || py > plotHeight) return null; + return ( + + ); + })} + + {/* X axis labels */} + {xGridLines.map((v) => { + const px = toPixelX(v); + if (px < 0 || px > plotWidth) return null; + return ( + + {formatAxisValue(v, xAxis.unit, xAxis.scale)} + + ); + })} + + {/* Y axis labels */} + {yGridLines.map((v) => { + const py = toPixelY(v); + if (py < 0 || py > plotHeight) return null; + return ( + + {formatAxisValue(v, yAxis.unit)} + + ); + })} + + {/* Combined response area fill */} + {responseAreaPath && ( + + )} + + {/* Per-node individual curves */} + {nodeCurvePaths.map((nc) => { + const node = enabledNodes.find((n) => n.id === nc.nodeId); + const nodeIdx = nodes.findIndex((n) => n.id === nc.nodeId); + const color = node?.color ?? NODE_COLORS[nodeIdx % NODE_COLORS.length] ?? "#666"; + return ( + + ); + })} + + {/* Combined response curve line */} + {responsePath && ( + + )} + + {/* Clip path for curves */} + + + + + + + {/* Draggable nodes */} + {enabledNodes.map((node) => { + const nodeIdx = nodes.findIndex((n) => n.id === node.id); + const color = node.color ?? NODE_COLORS[nodeIdx % NODE_COLORS.length] ?? "#3b82f6"; + const cx = toPixelX(node.x); + const cy = toPixelY(node.y); + const isHovered = hoveredNodeId === node.id; + const isDragging = dragNodeId === node.id; + + return ( + + {/* Larger hit area */} + handleNodePointerDown(e, node.id)} + onPointerMove={handleNodePointerMove} + onPointerUp={handleNodePointerUp} + onContextMenu={(e) => handleNodeContextMenu(e, node.id)} + onWheel={(e) => handleNodeWheel(e, node)} + onMouseEnter={() => setHoveredNodeId(node.id)} + onMouseLeave={() => setHoveredNodeId(null)} + /> + {/* Visible node */} + + {/* Q ring (z parameter visualization) */} + {node.z !== undefined && nodeConfig.zAxis && (isHovered || isDragging) && ( + + )} + {/* Tooltip */} + {(isHovered || isDragging) && ( + + {formatAxisValue(node.x, xAxis.unit, xAxis.scale)}{xAxis.unit ? xAxis.unit : ""}{" "} + {formatAxisValue(node.y, yAxis.unit)} + {node.z !== undefined && nodeConfig.zAxis + ? ` ${nodeConfig.zAxis.label}:${node.z.toFixed(1)}` + : ""} + + )} + + ); + })} + + + ); +} diff --git a/frontend/src/components/ParametricGraph/ParametricGraph.types.ts b/frontend/src/components/ParametricGraph/ParametricGraph.types.ts new file mode 100644 index 0000000..d2e53a7 --- /dev/null +++ b/frontend/src/components/ParametricGraph/ParametricGraph.types.ts @@ -0,0 +1,48 @@ +export interface GraphAxis { + label: string; + min: number; + max: number; + scale: "linear" | "log"; + unit?: string; + gridLines?: number[]; +} + +export interface GraphNode { + id: string; + x: number; // value in axis units (e.g., Hz for freq) + y: number; // value in axis units (e.g., dB for gain) + z?: number; // third parameter (e.g., Q), scroll-wheel controlled + enabled: boolean; + color?: string; + label?: string; + nodeType?: number; // enum value for per-node type (e.g., filter type) +} + +export interface GraphNodeConfig { + maxNodes: number; + zAxis?: { + label: string; + min: number; + max: number; + default: number; + sensitivity: number; // scroll delta per value unit + }; + nodeTypes?: { value: number; label: string }[]; +} + +export interface ParametricGraphProps { + width: number; + height: number; + xAxis: GraphAxis; + yAxis: GraphAxis; + nodes: GraphNode[]; + nodeConfig: GraphNodeConfig; + responseCurve?: { x: number; y: number }[]; + perNodeCurves?: { nodeId: string; points: { x: number; y: number }[] }[]; + onNodeAdd?: (x: number, y: number) => void; + onNodeChange?: (id: string, changes: Partial) => void; + onNodeRemove?: (id: string) => void; + onNodeDragStart?: (id: string) => void; + onNodeDragEnd?: (id: string) => void; + className?: string; +} diff --git a/frontend/src/components/ParametricGraph/ReverbGraph.tsx b/frontend/src/components/ParametricGraph/ReverbGraph.tsx new file mode 100644 index 0000000..46d1ba3 --- /dev/null +++ b/frontend/src/components/ParametricGraph/ReverbGraph.tsx @@ -0,0 +1,148 @@ +import { useMemo, useCallback } from "react"; +import { ParametricGraph } from "./ParametricGraph"; +import type { GraphNode, GraphAxis, GraphNodeConfig } from "./ParametricGraph.types"; + +interface S13FXSlider { + index: number; + name: string; + min: number; + max: number; + def: number; + inc: number; + value: number; + isEnum: boolean; + enumNames?: string[]; +} + +interface ReverbGraphProps { + sliders: S13FXSlider[]; + onSliderChange: (sliderIndex: number, value: number) => void; + width?: number; + height?: number; +} + +const NUM_POINTS = 6; +const SLIDERS_PER_POINT = 2; // time, level + +export function ReverbGraph({ + sliders, + onSliderChange, + width = 340, + height = 180, +}: ReverbGraphProps) { + const sliderMap = useMemo(() => { + const map = new Map(); + for (const s of sliders) map.set(s.index, s); + return map; + }, [sliders]); + + // Extract envelope points from sliders + // Slider layout: point N uses sliders N*2 and N*2+1 (0-based) + const envPoints = useMemo(() => { + const result: { time: number; level: number }[] = []; + for (let i = 0; i < NUM_POINTS; i++) { + const base = i * SLIDERS_PER_POINT; + result.push({ + time: sliderMap.get(base)?.value ?? i, + level: sliderMap.get(base + 1)?.value ?? Math.max(0, 100 - i * 20), + }); + } + return result; + }, [sliderMap]); + + // Nodes: x=time, y=level — all always enabled for reverb envelope + const nodes: GraphNode[] = useMemo( + () => + envPoints.map((pt, i) => ({ + id: `env-${i}`, + x: pt.time, + y: pt.level, + enabled: true, + label: `Point ${i + 1}`, + })), + [envPoints], + ); + + // Build envelope response curve (connect points in order sorted by time) + const responseCurve = useMemo(() => { + const sorted = [...envPoints] + .map((p, i) => ({ ...p, idx: i })) + .sort((a, b) => a.time - b.time); + + const points: { x: number; y: number }[] = []; + // Start at 0,0 if first point isn't at time 0 + if (sorted.length > 0 && sorted[0].time > 0) { + points.push({ x: 0, y: sorted[0].level }); + } + for (const p of sorted) { + points.push({ x: p.time, y: p.level }); + } + // Extend to max time at 0 level if last point isn't 0 + const last = sorted[sorted.length - 1]; + if (last && last.level > 0) { + points.push({ x: last.time + 0.5, y: 0 }); + } + return points; + }, [envPoints]); + + const xAxis: GraphAxis = useMemo( + () => ({ + label: "Time", + min: 0, + max: 10, + scale: "linear" as const, + unit: "s", + gridLines: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + }), + [], + ); + + const yAxis: GraphAxis = useMemo( + () => ({ + label: "Level", + min: 0, + max: 100, + scale: "linear" as const, + unit: "%", + gridLines: [0, 25, 50, 75, 100], + }), + [], + ); + + const nodeConfig: GraphNodeConfig = useMemo( + () => ({ + maxNodes: NUM_POINTS, + }), + [], + ); + + const handleNodeChange = useCallback( + (id: string, changes: Partial) => { + const ptIdx = parseInt(id.replace("env-", ""), 10); + if (isNaN(ptIdx)) return; + const base = ptIdx * SLIDERS_PER_POINT; + if (changes.x !== undefined) { + const timeSlider = sliderMap.get(base); + const maxTime = timeSlider?.max ?? 10; + onSliderChange(base, Math.round(Math.max(0, Math.min(maxTime, changes.x)) * 100) / 100); + } + if (changes.y !== undefined) { + onSliderChange(base + 1, Math.round(Math.max(0, Math.min(100, changes.y)))); + } + }, + [onSliderChange, sliderMap], + ); + + return ( + + ); +} diff --git a/frontend/src/components/ParametricGraph/SaturationGraph.tsx b/frontend/src/components/ParametricGraph/SaturationGraph.tsx new file mode 100644 index 0000000..80ed855 --- /dev/null +++ b/frontend/src/components/ParametricGraph/SaturationGraph.tsx @@ -0,0 +1,199 @@ +import { useMemo, useCallback } from "react"; +import { ParametricGraph } from "./ParametricGraph"; +import type { GraphNode, GraphAxis, GraphNodeConfig } from "./ParametricGraph.types"; + +interface S13FXSlider { + index: number; + name: string; + min: number; + max: number; + def: number; + inc: number; + value: number; + isEnum: boolean; + enumNames?: string[]; +} + +interface SaturationGraphProps { + sliders: S13FXSlider[]; + onSliderChange: (sliderIndex: number, value: number) => void; + width?: number; + height?: number; +} + +const NUM_POINTS = 8; +const SLIDERS_PER_POINT = 3; // enabled, input, output + +function computeWaveshaperCurve( + points: { enabled: boolean; input: number; output: number }[], +): { x: number; y: number }[] { + // Collect enabled points, sort by input + const sorted = points + .filter((p) => p.enabled) + .sort((a, b) => a.input - b.input); + + if (sorted.length < 2) { + // Unity curve + const curve: { x: number; y: number }[] = []; + for (let x = -1; x <= 1; x += 0.02) curve.push({ x, y: x }); + return curve; + } + + const curve: { x: number; y: number }[] = []; + for (let x = -1; x <= 1; x += 0.01) { + let y: number; + if (x <= sorted[0].input) { + y = sorted[0].output; + } else if (x >= sorted[sorted.length - 1].input) { + y = sorted[sorted.length - 1].output; + } else { + // Find segment + let i = 0; + while (i < sorted.length - 1 && sorted[i + 1].input < x) i++; + const x0 = sorted[i].input; + const y0 = sorted[i].output; + const x1 = sorted[i + 1].input; + const y1 = sorted[i + 1].output; + let t = (x - x0) / Math.max(x1 - x0, 0.0001); + // Smoothstep interpolation + t = t * t * (3 - 2 * t); + y = y0 + t * (y1 - y0); + } + curve.push({ x, y }); + } + return curve; +} + +export function SaturationGraph({ + sliders, + onSliderChange, + width = 340, + height = 180, +}: SaturationGraphProps) { + const sliderMap = useMemo(() => { + const map = new Map(); + for (const s of sliders) map.set(s.index, s); + return map; + }, [sliders]); + + // Extract control points + const controlPoints = useMemo(() => { + const result: { enabled: boolean; input: number; output: number }[] = []; + for (let i = 0; i < NUM_POINTS; i++) { + const base = i * SLIDERS_PER_POINT; + result.push({ + enabled: (sliderMap.get(base)?.value ?? 0) === 1, + input: sliderMap.get(base + 1)?.value ?? 0, + output: sliderMap.get(base + 2)?.value ?? 0, + }); + } + return result; + }, [sliderMap]); + + // Create nodes: x=input, y=output + const nodes: GraphNode[] = useMemo( + () => + controlPoints.map((pt, i) => ({ + id: `pt-${i}`, + x: pt.input, + y: pt.output, + enabled: pt.enabled, + label: `Point ${i + 1}`, + })), + [controlPoints], + ); + + const responseCurve = useMemo( + () => computeWaveshaperCurve(controlPoints), + [controlPoints], + ); + + // Unity line as reference + const perNodeCurves = useMemo(() => { + const pts: { x: number; y: number }[] = []; + for (let x = -1; x <= 1; x += 0.05) pts.push({ x, y: x }); + return [{ nodeId: "unity", points: pts }]; + }, []); + + const xAxis: GraphAxis = useMemo( + () => ({ + label: "Input", + min: -1, + max: 1, + scale: "linear" as const, + gridLines: [-1, -0.5, 0, 0.5, 1], + }), + [], + ); + + const yAxis: GraphAxis = useMemo( + () => ({ + label: "Output", + min: -1, + max: 1, + scale: "linear" as const, + gridLines: [-1, -0.5, 0, 0.5, 1], + }), + [], + ); + + const nodeConfig: GraphNodeConfig = useMemo( + () => ({ + maxNodes: NUM_POINTS, + }), + [], + ); + + const handleNodeAdd = useCallback( + (x: number, y: number) => { + const disabledIdx = controlPoints.findIndex((p) => !p.enabled); + if (disabledIdx === -1) return; + const base = disabledIdx * SLIDERS_PER_POINT; + onSliderChange(base, 1); // Enable + onSliderChange(base + 1, Math.round(Math.max(-1, Math.min(1, x)) * 100) / 100); // Input + onSliderChange(base + 2, Math.round(Math.max(-1, Math.min(1, y)) * 100) / 100); // Output + }, + [controlPoints, onSliderChange], + ); + + const handleNodeRemove = useCallback( + (id: string) => { + const ptIdx = parseInt(id.replace("pt-", ""), 10); + if (isNaN(ptIdx)) return; + const base = ptIdx * SLIDERS_PER_POINT; + onSliderChange(base, 0); // Disable + }, + [onSliderChange], + ); + + const handleNodeChange = useCallback( + (id: string, changes: Partial) => { + const ptIdx = parseInt(id.replace("pt-", ""), 10); + if (isNaN(ptIdx)) return; + const base = ptIdx * SLIDERS_PER_POINT; + if (changes.x !== undefined) { + onSliderChange(base + 1, Math.round(Math.max(-1, Math.min(1, changes.x)) * 100) / 100); + } + if (changes.y !== undefined) { + onSliderChange(base + 2, Math.round(Math.max(-1, Math.min(1, changes.y)) * 100) / 100); + } + }, + [onSliderChange], + ); + + return ( + + ); +} diff --git a/frontend/src/components/ParametricGraph/eqResponseCurve.ts b/frontend/src/components/ParametricGraph/eqResponseCurve.ts new file mode 100644 index 0000000..d066ec8 --- /dev/null +++ b/frontend/src/components/ParametricGraph/eqResponseCurve.ts @@ -0,0 +1,201 @@ +/** + * Biquad magnitude response calculation — exact TypeScript port of + * the biquad_coeff function from effects/eq_advanced.jsfx. + * + * Computes |H(e^jw)| in dB for a single biquad filter at a given frequency. + */ + +// Filter type enum (matches JSFX slider enum order) +export const FilterType = { + LowShelf: 0, + Peak: 1, + HighShelf: 2, + LowPass: 3, + HighPass: 4, + Notch: 5, +} as const; + +export interface BiquadCoeffs { + b0: number; + b1: number; + b2: number; + a1: number; + a2: number; +} + +/** + * Compute normalized biquad coefficients for a given filter type. + * Mirrors the JSFX biquad_coeff function exactly. + */ +export function computeBiquadCoeffs( + type: number, + fc: number, + gainDB: number, + q: number, + sampleRate: number, +): BiquadCoeffs { + const w0 = (2 * Math.PI * fc) / sampleRate; + const cosw = Math.cos(w0); + const sinw = Math.sin(w0); + const A = Math.pow(10, gainDB / 40); + + let b0: number, b1: number, b2: number; + let a0: number, a1: number, a2: number; + + if (type === FilterType.LowShelf) { + const alpha = (sinw / 2) * Math.sqrt((A + 1 / A) * (1 / 0.707 - 1) + 2); + const sqrtA2alpha = 2 * Math.sqrt(A) * alpha; + b0 = A * (A + 1 - (A - 1) * cosw + sqrtA2alpha); + b1 = 2 * A * (A - 1 - (A + 1) * cosw); + b2 = A * (A + 1 - (A - 1) * cosw - sqrtA2alpha); + a0 = A + 1 + (A - 1) * cosw + sqrtA2alpha; + a1 = -2 * (A - 1 + (A + 1) * cosw); + a2 = A + 1 + (A - 1) * cosw - sqrtA2alpha; + } else if (type === FilterType.Peak) { + const alpha = sinw / (2 * q); + b0 = 1 + alpha * A; + b1 = -2 * cosw; + b2 = 1 - alpha * A; + a0 = 1 + alpha / A; + a1 = -2 * cosw; + a2 = 1 - alpha / A; + } else if (type === FilterType.HighShelf) { + const alpha = (sinw / 2) * Math.sqrt((A + 1 / A) * (1 / 0.707 - 1) + 2); + const sqrtA2alpha = 2 * Math.sqrt(A) * alpha; + b0 = A * (A + 1 + (A - 1) * cosw + sqrtA2alpha); + b1 = -2 * A * (A - 1 + (A + 1) * cosw); + b2 = A * (A + 1 + (A - 1) * cosw - sqrtA2alpha); + a0 = A + 1 - (A - 1) * cosw + sqrtA2alpha; + a1 = 2 * (A - 1 - (A + 1) * cosw); + a2 = A + 1 - (A - 1) * cosw - sqrtA2alpha; + } else if (type === FilterType.LowPass) { + const alpha = sinw / (2 * q); + b0 = (1 - cosw) / 2; + b1 = 1 - cosw; + b2 = (1 - cosw) / 2; + a0 = 1 + alpha; + a1 = -2 * cosw; + a2 = 1 - alpha; + } else if (type === FilterType.HighPass) { + const alpha = sinw / (2 * q); + b0 = (1 + cosw) / 2; + b1 = -(1 + cosw); + b2 = (1 + cosw) / 2; + a0 = 1 + alpha; + a1 = -2 * cosw; + a2 = 1 - alpha; + } else { + // Notch + const alpha = sinw / (2 * q); + b0 = 1; + b1 = -2 * cosw; + b2 = 1; + a0 = 1 + alpha; + a1 = -2 * cosw; + a2 = 1 - alpha; + } + + return { + b0: b0 / a0, + b1: b1 / a0, + b2: b2 / a0, + a1: a1 / a0, + a2: a2 / a0, + }; +} + +/** + * Evaluate the magnitude response |H(e^jw)| of a biquad filter at a given frequency. + * Returns the result in dB. + */ +export function biquadMagnitudeDB(coeffs: BiquadCoeffs, freq: number, sampleRate: number): number { + const w = (2 * Math.PI * freq) / sampleRate; + const cosw = Math.cos(w); + const cos2w = Math.cos(2 * w); + const sinw = Math.sin(w); + const sin2w = Math.sin(2 * w); + + // H(e^jw) = (b0 + b1*e^-jw + b2*e^-j2w) / (1 + a1*e^-jw + a2*e^-j2w) + const numReal = coeffs.b0 + coeffs.b1 * cosw + coeffs.b2 * cos2w; + const numImag = -(coeffs.b1 * sinw + coeffs.b2 * sin2w); + const denReal = 1 + coeffs.a1 * cosw + coeffs.a2 * cos2w; + const denImag = -(coeffs.a1 * sinw + coeffs.a2 * sin2w); + + const numMagSq = numReal * numReal + numImag * numImag; + const denMagSq = denReal * denReal + denImag * denImag; + + if (denMagSq === 0) return 0; + return 10 * Math.log10(numMagSq / denMagSq); +} + +export interface EQBand { + enabled: boolean; + type: number; + freq: number; + gainDB: number; + q: number; +} + +/** + * Generate ~numPoints log-spaced frequency points from minFreq to maxFreq, + * computing the combined magnitude response of all enabled bands. + */ +export function computeEQResponse( + bands: EQBand[], + sampleRate: number, + minFreq: number = 20, + maxFreq: number = 20000, + numPoints: number = 200, +): { x: number; y: number }[] { + const points: { x: number; y: number }[] = []; + const logMin = Math.log10(minFreq); + const logMax = Math.log10(maxFreq); + + // Pre-compute coefficients for all enabled bands + const enabledCoeffs: BiquadCoeffs[] = []; + for (const band of bands) { + if (band.enabled) { + enabledCoeffs.push(computeBiquadCoeffs(band.type, band.freq, band.gainDB, band.q, sampleRate)); + } + } + + for (let i = 0; i <= numPoints; i++) { + const logFreq = logMin + (i / numPoints) * (logMax - logMin); + const freq = Math.pow(10, logFreq); + let totalDB = 0; + + for (const coeffs of enabledCoeffs) { + totalDB += biquadMagnitudeDB(coeffs, freq, sampleRate); + } + + points.push({ x: freq, y: totalDB }); + } + + return points; +} + +/** + * Compute the magnitude response of a single band (for per-node curve display). + */ +export function computeSingleBandResponse( + band: EQBand, + sampleRate: number, + minFreq: number = 20, + maxFreq: number = 20000, + numPoints: number = 200, +): { x: number; y: number }[] { + if (!band.enabled) return []; + + const coeffs = computeBiquadCoeffs(band.type, band.freq, band.gainDB, band.q, sampleRate); + const points: { x: number; y: number }[] = []; + const logMin = Math.log10(minFreq); + const logMax = Math.log10(maxFreq); + + for (let i = 0; i <= numPoints; i++) { + const logFreq = logMin + (i / numPoints) * (logMax - logMin); + const freq = Math.pow(10, logFreq); + points.push({ x: freq, y: biquadMagnitudeDB(coeffs, freq, sampleRate) }); + } + + return points; +} diff --git a/frontend/src/components/ParametricGraph/index.ts b/frontend/src/components/ParametricGraph/index.ts new file mode 100644 index 0000000..b41eaf0 --- /dev/null +++ b/frontend/src/components/ParametricGraph/index.ts @@ -0,0 +1,9 @@ +export { ParametricGraph } from "./ParametricGraph"; +export { EQGraph } from "./EQGraph"; +export { CompressorGraph } from "./CompressorGraph"; +export { GateGraph } from "./GateGraph"; +export { DelayGraph } from "./DelayGraph"; +export { ReverbGraph } from "./ReverbGraph"; +export { SaturationGraph } from "./SaturationGraph"; +export { ChorusGraph } from "./ChorusGraph"; +export type * from "./ParametricGraph.types"; diff --git a/frontend/src/components/PeakMeter.tsx b/frontend/src/components/PeakMeter.tsx index ba578a5..8b43834 100644 --- a/frontend/src/components/PeakMeter.tsx +++ b/frontend/src/components/PeakMeter.tsx @@ -1,4 +1,20 @@ -import { useEffect, useRef, useState, useCallback } from "react"; +import { useEffect, useRef, useCallback } from "react"; +import { + getMeterSegmentColor, + getPeakIndicatorColor, + getThresholdNormalized, + CENTER_DBFS_LABEL_FONT_PX, + MeterColorScheme, + METER_COLORS, + METER_RULER_FONT_PX, + METER_SEGMENT_GAP, + METER_SEGMENT_HEIGHT, + MeterScaleMode, + normalizeDbToMeter, + normalizeLevelToMeter, + normalizedMeterToDb, + MASTER_DBFS_RULING_MARKS, +} from "./meterConfig"; interface PeakMeterProps { level: number; @@ -6,6 +22,19 @@ interface PeakMeterProps { height?: number; stereo?: boolean; clipping?: boolean; // Show red clip indicator + onReset?: () => void; + scaleMode?: MeterScaleMode; + showThresholdLine?: boolean; + showRulings?: boolean; + rulingMarksDb?: readonly number[]; + width?: number; + resetSignal?: number; + showBorder?: boolean; + renderMode?: "segmented" | "continuous"; + showCenterDivider?: boolean; + showRulingLabels?: boolean; + showRulingLines?: boolean; + colorScheme?: MeterColorScheme; } export function PeakMeter({ @@ -14,122 +43,324 @@ export function PeakMeter({ height, stereo = true, clipping = false, + onReset, + scaleMode = "extended", + showThresholdLine = false, + showRulings = false, + rulingMarksDb = MASTER_DBFS_RULING_MARKS, + width, + resetSignal = 0, + showBorder = true, + renderMode = "segmented", + showCenterDivider = true, + showRulingLabels = false, + showRulingLines = true, + colorScheme = "default", }: PeakMeterProps) { const canvasRef = useRef(null); - const [containerHeight, setContainerHeight] = useState(height || 140); + const containerRef = useRef(null); + const containerHeightRef = useRef(height || 140); const resizeObserverRef = useRef(null); - // Observe container size changes + // Peak hold with 3 dB/sec decay + const peakHoldLevelRef = useRef(0); + const peakHoldTimerRef = useRef(0); // timestamp when peak was captured + const animFrameRef = useRef(null); + const lastDrawTimeRef = useRef(0); + + // RMS simulation via exponential smoothing of squared peak values + const rmsSmoothedRef = useRef(0); + + // Observe container size changes — store in ref to avoid re-render loop const containerCallbackRef = useCallback((node: HTMLDivElement | null) => { + containerRef.current = node; resizeObserverRef.current?.disconnect(); if (node) { const observer = new ResizeObserver((entries) => { for (const entry of entries) { const h = Math.floor(entry.contentRect.height); - if (h > 0) setContainerHeight(h); + if (h > 0) containerHeightRef.current = h; } }); observer.observe(node); resizeObserverRef.current = observer; // Initial measurement const h = Math.floor(node.getBoundingClientRect().height); - if (h > 0) setContainerHeight(h); + if (h > 0) containerHeightRef.current = h; } }, []); - const canvasHeight = height || containerHeight; + // Refs for latest values (avoid stale closures in animation loop) + const levelRef = useRef(level); + levelRef.current = level; + const peakHoldPropRef = useRef(peakHold); + peakHoldPropRef.current = peakHold; + const stereoRef = useRef(stereo); + stereoRef.current = stereo; + const clippingRef = useRef(clipping); + clippingRef.current = clipping; + const onResetRef = useRef(onReset); + onResetRef.current = onReset; + const scaleModeRef = useRef(scaleMode); + scaleModeRef.current = scaleMode; + const showThresholdLineRef = useRef(showThresholdLine); + showThresholdLineRef.current = showThresholdLine; + const showRulingsRef = useRef(showRulings); + showRulingsRef.current = showRulings; + const rulingMarksDbRef = useRef(rulingMarksDb); + rulingMarksDbRef.current = rulingMarksDb; + const renderModeRef = useRef(renderMode); + renderModeRef.current = renderMode; + const showCenterDividerRef = useRef(showCenterDivider); + showCenterDividerRef.current = showCenterDivider; + const showRulingLabelsRef = useRef(showRulingLabels); + showRulingLabelsRef.current = showRulingLabels; + const showRulingLinesRef = useRef(showRulingLines); + showRulingLinesRef.current = showRulingLines; + const colorSchemeRef = useRef(colorScheme); + colorSchemeRef.current = colorScheme; + + useEffect(() => { + peakHoldLevelRef.current = 0; + peakHoldTimerRef.current = 0; + rmsSmoothedRef.current = 0; + }, [resetSignal]); + // Animation loop for smooth peak hold decay useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - const width = canvas.width; - const channelWidth = stereo ? (width - 1) / 2 : width; - - // Clear canvas - ctx.fillStyle = "#0a0a0a"; - ctx.fillRect(0, 0, width, canvasHeight); - - // Apply noise floor - levels below this are treated as silence - const NOISE_FLOOR = 0.001; // ~-60dB - const clampedLevel = level < NOISE_FLOOR ? 0 : level; - - // Convert RMS to dB with proper zero handling - const dbLevel = - clampedLevel > 0 ? 20 * Math.log10(clampedLevel) : -Infinity; - const normalizedLevel = - dbLevel === -Infinity ? 0 : Math.max(0, Math.min(1, (dbLevel + 60) / 72)); // -60dB to +12dB - - // Draw meter for left channel - const drawChannel = (x: number, w: number, lv: number) => { - const h = canvasHeight * lv; - - // Background segments (LED style) - const segHeight = 2; - const gap = 1; - for (let y = canvasHeight; y > 0; y -= segHeight + gap) { - const segY = y - segHeight; - if (segY >= canvasHeight - h) { - // Lit segment - const ratio = 1 - segY / canvasHeight; - if (ratio < 0.7) { - ctx.fillStyle = "#16a34a"; // Green 600 - } else if (ratio < 0.85) { - ctx.fillStyle = "#4ade80"; // Green 400 - } else if (ratio < 0.92) { - ctx.fillStyle = "#facc15"; // Yellow 400 + const draw = (timestamp: number) => { + const canvas = canvasRef.current; + if (!canvas) { animFrameRef.current = requestAnimationFrame(draw); return; } + const ctx = canvas.getContext("2d"); + if (!ctx) { animFrameRef.current = requestAnimationFrame(draw); return; } + + // Throttle to ~30fps to reduce CPU + if (timestamp - lastDrawTimeRef.current < 33) { + animFrameRef.current = requestAnimationFrame(draw); + return; + } + lastDrawTimeRef.current = timestamp; + + const currentLevel = levelRef.current; + const currentPeakHoldProp = peakHoldPropRef.current; + const isStereo = stereoRef.current; + const isClipping = clippingRef.current; + const currentScaleMode = scaleModeRef.current; + const shouldShowThresholdLine = showThresholdLineRef.current; + const shouldShowRulings = showRulingsRef.current; + const currentRulingMarks = rulingMarksDbRef.current; + const currentRenderMode = renderModeRef.current; + const shouldShowCenterDivider = showCenterDividerRef.current; + const shouldShowRulingLabels = showRulingLabelsRef.current; + const shouldShowRulingLines = showRulingLinesRef.current; + const currentColorScheme = colorSchemeRef.current; + const ch = height || containerHeightRef.current; + if (ch <= 0) { animFrameRef.current = requestAnimationFrame(draw); return; } + + // Sync canvas pixel resolution only when it actually changed + if (canvas.height !== ch) { + canvas.height = ch; + } + const width = canvas.width; + const dividerWidth = isStereo && shouldShowCenterDivider ? 1 : 0; + const leftWidth = isStereo ? Math.floor((width - dividerWidth) / 2) : width; + const rightX = leftWidth + dividerWidth; + const rightWidth = isStereo ? width - rightX : width; + + // Update peak hold with decay + const normalizedLevel = normalizeLevelToMeter(currentLevel, currentScaleMode); + + // RMS simulation: exponential smoothing of squared normalized level + const smoothFactor = 0.3; + const squared = normalizedLevel * normalizedLevel; + rmsSmoothedRef.current = rmsSmoothedRef.current * (1 - smoothFactor) + squared * smoothFactor; + const rmsLevel = Math.sqrt(rmsSmoothedRef.current); + + // Use prop peakHold if provided, otherwise compute our own + let peakDisplay: number; + if (currentPeakHoldProp > 0) { + peakDisplay = normalizeLevelToMeter(currentPeakHoldProp, currentScaleMode); + } else { + peakDisplay = 0; + } + + // Internal peak hold with 3 dB/sec decay + if (normalizedLevel > peakHoldLevelRef.current) { + peakHoldLevelRef.current = normalizedLevel; + peakHoldTimerRef.current = timestamp; + } else { + // Decay: 3dB/sec in normalized space ~ 3/72 per second + const elapsed = (timestamp - peakHoldTimerRef.current) / 1000; + if (elapsed > 1.5) { // Hold for 1.5 seconds before decay + const decay = (elapsed - 1.5) * (3 / 72); + peakHoldLevelRef.current = Math.max(0, peakHoldLevelRef.current - decay); + } + } + + // Use whichever peak is higher + const effectivePeak = Math.max(peakDisplay, peakHoldLevelRef.current); + + // Clear canvas + ctx.fillStyle = METER_COLORS.background; + ctx.fillRect(0, 0, width, ch); + + // Draw meter channel with gradient fill (RMS = dimmer bar, peak = bright bar on top) + const drawChannel = (cx: number, w: number, peakLv: number, rmsLv: number) => { + const peakH = ch * peakLv; + const rmsH = ch * rmsLv; + + if (currentRenderMode === "continuous") { + for (let y = 0; y < ch; ++y) { + const pixelNorm = 1 - ((y + 0.5) / ch); + const pixelDb = normalizedMeterToDb(pixelNorm, currentScaleMode); + const pixelColor = getMeterSegmentColor(pixelDb, currentScaleMode, currentColorScheme); + const litFromPeak = y >= ch - peakH; + const litFromRms = y >= ch - rmsH; + + if (litFromPeak) { + ctx.fillStyle = pixelColor; + ctx.globalAlpha = 1; + } else if (litFromRms) { + ctx.fillStyle = pixelColor; + ctx.globalAlpha = 0.35; + } else { + ctx.fillStyle = METER_COLORS.unlit; + ctx.globalAlpha = 1; + } + ctx.fillRect(cx, y, w, 1); + } + ctx.globalAlpha = 1; + return; + } + + for (let y = ch; y > 0; y -= METER_SEGMENT_HEIGHT + METER_SEGMENT_GAP) { + const segY = y - METER_SEGMENT_HEIGHT; + const segmentCenterNorm = 1 - ((segY + METER_SEGMENT_HEIGHT * 0.5) / ch); + const segmentDb = normalizedMeterToDb(segmentCenterNorm, currentScaleMode); + const segmentColor = getMeterSegmentColor(segmentDb, currentScaleMode, currentColorScheme); + if (segY >= ch - peakH) { + ctx.fillStyle = segmentColor; + ctx.globalAlpha = 1; + } else if (segY >= ch - rmsH) { + ctx.fillStyle = segmentColor; + ctx.globalAlpha = 0.35; } else { - ctx.fillStyle = "#ef4444"; // Red 500 + ctx.fillStyle = METER_COLORS.unlit; + ctx.globalAlpha = 1; } + ctx.fillRect(cx, segY, w, METER_SEGMENT_HEIGHT); + } + ctx.globalAlpha = 1; + }; + + if (isStereo) { + const leftLevel = normalizedLevel * 0.97; + const rightLevel = normalizedLevel * 1.03; + const leftRms = rmsLevel * 0.97; + const rightRms = rmsLevel * 1.03; + drawChannel(0, leftWidth, leftLevel, leftRms); + drawChannel(rightX, rightWidth, rightLevel, rightRms); + + if (shouldShowCenterDivider) { + ctx.fillStyle = METER_COLORS.background; + ctx.fillRect(leftWidth, 0, dividerWidth, ch); + } + } else { + drawChannel(0, width, normalizedLevel, rmsLevel); + } + + if (shouldShowRulings) { + ctx.strokeStyle = METER_COLORS.rulerOverlay; + ctx.fillStyle = METER_COLORS.rulerOverlay; + ctx.lineWidth = 1; + const rulingFontPx = + currentColorScheme === "centerContrast" ? CENTER_DBFS_LABEL_FONT_PX : METER_RULER_FONT_PX; + ctx.font = + currentColorScheme === "centerContrast" + ? `900 ${rulingFontPx}px Arial, Helvetica, sans-serif` + : `${rulingFontPx}px Consolas, 'SFMono-Regular', monospace`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + for (const markDb of currentRulingMarks) { + const markNorm = Math.max(0, Math.min(1, 1 - normalizeDbToMeter(markDb, currentScaleMode))); + const y = Math.round(markNorm * ch); + if (shouldShowRulingLines) { + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(width, y); + ctx.stroke(); + } + + if (shouldShowRulingLabels) { + const label = markDb > 0 ? `+${markDb}` : `${markDb}`; + const labelY = Math.max( + rulingFontPx * 0.6, + Math.min(ch - rulingFontPx * 0.4, y), + ); + if (currentColorScheme === "centerContrast") { + ctx.lineWidth = 2; + ctx.strokeStyle = "rgba(0, 0, 0, 0.95)"; + ctx.strokeText(label, width * 0.5, labelY); + ctx.fillStyle = "rgba(18, 18, 18, 0.98)"; + } + ctx.fillText(label, width * 0.5, labelY); + } + } + } + + if (shouldShowThresholdLine) { + const thresholdY = ch - ch * getThresholdNormalized(currentScaleMode); + ctx.fillStyle = METER_COLORS.thresholdLine; + ctx.fillRect(0, Math.round(thresholdY), width, 1); + } + + // Peak hold indicator line (white/bright line) + if (effectivePeak > 0.01) { + const peakY = ch - ch * effectivePeak; + const peakColor = getPeakIndicatorColor( + normalizedMeterToDb(effectivePeak, currentScaleMode), + currentScaleMode, + currentColorScheme, + ); + + ctx.fillStyle = peakColor; + if (isStereo) { + ctx.fillRect(0, peakY, leftWidth, 2); + ctx.fillRect(rightX, peakY, rightWidth, 2); } else { - // Unlit segment - ctx.fillStyle = "#171717"; // Neutral 900 + ctx.fillRect(0, peakY, width, 2); } - ctx.fillRect(x, segY, w, segHeight); } - }; - if (stereo) { - // Slight deterministic L/R offset for visual interest (no Math.random() — - // random values differ every render, forcing unnecessary canvas redraws) - const leftLevel = normalizedLevel * 0.97; - const rightLevel = normalizedLevel * 1.03; - drawChannel(0, channelWidth, leftLevel); - drawChannel(channelWidth + 1, channelWidth, rightLevel); - - // Center divider - ctx.fillStyle = "#0a0a0a"; - ctx.fillRect(channelWidth, 0, 1, canvasHeight); - } else { - drawChannel(0, width, normalizedLevel); - } + // Clip indicator - red block at top if clipping + if (isClipping) { + ctx.fillStyle = METER_COLORS.clip; + ctx.fillRect(0, 0, width, 3); + } - // Peak hold indicator - if (peakHold > 0) { - const peakDb = 20 * Math.log10(peakHold); - const normalizedPeak = Math.max(0, Math.min(1, (peakDb + 60) / 72)); - const peakY = canvasHeight - canvasHeight * normalizedPeak; - ctx.fillStyle = "#f97316"; // Orange 500 - ctx.fillRect(0, peakY, width, 2); - } + animFrameRef.current = requestAnimationFrame(draw); + }; - // Clip indicator - red line at top if clipping - if (clipping) { - ctx.fillStyle = "#dc2626"; // Red 600 - ctx.fillRect(0, 0, width, 3); - } - }, [level, peakHold, canvasHeight, stereo, clipping]); + animFrameRef.current = requestAnimationFrame(draw); + return () => { + if (animFrameRef.current !== null) { + cancelAnimationFrame(animFrameRef.current); + } + }; + }, [height]); return (
{ + peakHoldLevelRef.current = 0; + peakHoldTimerRef.current = 0; + onResetRef.current?.(); + }} />
); diff --git a/frontend/src/components/PianoRoll.css b/frontend/src/components/PianoRoll.css index f362ad1..30f6952 100644 --- a/frontend/src/components/PianoRoll.css +++ b/frontend/src/components/PianoRoll.css @@ -87,3 +87,49 @@ color: #666; font-size: 14px; } + +/* Dropdown selects in the toolbar (Scale, Root, CC) */ +.piano-roll-select { + padding: 2px 6px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 4px; + color: #ccc; + font-size: 11px; + cursor: pointer; + outline: none; + min-width: 60px; +} + +.piano-roll-select:hover { + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.25); +} + +.piano-roll-select:focus { + border-color: #4cc9f0; +} + +.piano-roll-select option { + background: #222; + color: #ccc; +} + +/* Transform dropdown menu items */ +.piano-roll-transform-item { + display: block; + width: 100%; + padding: 6px 12px; + background: none; + border: none; + color: #ccc; + font-size: 12px; + text-align: left; + cursor: pointer; + white-space: nowrap; +} + +.piano-roll-transform-item:hover { + background: rgba(76, 201, 240, 0.15); + color: #fff; +} diff --git a/frontend/src/components/PianoRoll.tsx b/frontend/src/components/PianoRoll.tsx index da3b655..f8070c1 100644 --- a/frontend/src/components/PianoRoll.tsx +++ b/frontend/src/components/PianoRoll.tsx @@ -1,20 +1,56 @@ -import { useState, useRef, useEffect } from "react"; +import { useState, useRef, useEffect, useCallback, useMemo } from "react"; import { Stage, Layer, Rect, Line, Text, Group } from "react-konva"; -import { useDAWStore, MIDIEvent } from "../store/useDAWStore"; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type KonvaEvent = any; // Konva event handlers — full typing deferred to avoid 30+ null guards +import { useDAWStore, MIDIEvent, MIDICCEvent } from "../store/useDAWStore"; +import { useShallow } from "zustand/react/shallow"; import { Button } from "./ui"; import "./PianoRoll.css"; interface PianoRollProps { - clipId: string; - trackId: string; + readonly clipId: string; + readonly trackId: string; + /** Additional MIDI clip IDs to show simultaneously (multi-clip editing) */ + readonly additionalClipIds?: string[]; } +// Per-clip color tints for multi-clip editing (hue shifts) +const MULTI_CLIP_TINTS = [ + null, // Primary clip: use default velocity colors + "#ff6b9d", // Pink + "#51cf66", // Green + "#ffd43b", // Yellow + "#748ffc", // Indigo + "#f06595", // Hot pink + "#20c997", // Teal + "#ff922b", // Orange +]; + +// Step size options for step input mode +const STEP_SIZE_OPTIONS = [ + { label: "1/4", beats: 1 }, + { label: "1/8", beats: 0.5 }, + { label: "1/16", beats: 0.25 }, + { label: "1/32", beats: 0.125 }, +]; + +// Key-to-note mapping for step input (C major by default, octave set separately) +const KEY_TO_NOTE: Record = { + c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11, +}; + const NOTES_PER_OCTAVE = 12; const TOTAL_NOTES = 128; // MIDI range 0-127 const NOTE_HEIGHT = 12; const PIANO_WIDTH = 60; const GRID_SNAP = 0.25; // 1/16 note +// Lane heights +const VELOCITY_LANE_HEIGHT = 60; +const CC_LANE_HEIGHT = 80; +const LANE_DIVIDER_HEIGHT = 1; +const TOOLBAR_HEIGHT = 40; + const NOTE_NAMES = [ "C", "C#", @@ -30,19 +66,217 @@ const NOTE_NAMES = [ "B", ]; -export function PianoRoll({ clipId, trackId }: PianoRollProps) { +// ============================================ +// Scale Definitions +// ============================================ +const SCALE_DEFINITIONS: Record = { + chromatic: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], + major: [0, 2, 4, 5, 7, 9, 11], + minor: [0, 2, 3, 5, 7, 8, 10], + dorian: [0, 2, 3, 5, 7, 9, 10], + mixolydian: [0, 2, 4, 5, 7, 9, 10], + pentatonic_major: [0, 2, 4, 7, 9], + pentatonic_minor: [0, 3, 5, 7, 10], + blues: [0, 3, 5, 6, 7, 10], +}; + +const SCALE_DISPLAY_NAMES: Record = { + chromatic: "Chromatic", + major: "Major", + minor: "Minor", + dorian: "Dorian", + mixolydian: "Mixolydian", + pentatonic_major: "Pentatonic Major", + pentatonic_minor: "Pentatonic Minor", + blues: "Blues", +}; + +// CC lane presets +const CC_PRESETS = [ + { cc: 1, name: "CC#1 Modulation" }, + { cc: 7, name: "CC#7 Volume" }, + { cc: 10, name: "CC#10 Pan" }, + { cc: 11, name: "CC#11 Expression" }, + { cc: 64, name: "CC#64 Sustain" }, +]; + +/** Convert MIDI pitch number to note name string, e.g. 60 -> "C4" */ +function getNoteNameFromPitch(pitch: number): string { + const noteName = NOTE_NAMES[pitch % NOTES_PER_OCTAVE]; + const octave = Math.floor(pitch / 12) - 2; + return `${noteName}${octave}`; +} + +/** Velocity-based color: blue (quiet) -> cyan -> yellow -> red (loud) */ +function velocityColor(velocity: number): string { + const v = Math.max(0, Math.min(127, velocity)); + const t = v / 127; // 0..1 + let r: number, g: number, b: number; + if (t < 0.25) { + const s = t / 0.25; + r = 60; + g = Math.round(100 + 155 * s); + b = 240; + } else if (t < 0.5) { + const s = (t - 0.25) / 0.25; + r = Math.round(60 + 40 * s); + g = 255; + b = Math.round(240 - 140 * s); + } else if (t < 0.75) { + const s = (t - 0.5) / 0.25; + r = Math.round(100 + 155 * s); + g = 255; + b = Math.round(100 - 100 * s); + } else { + const s = (t - 0.75) / 0.25; + r = 255; + g = Math.round(255 - 200 * s); + b = 0; + } + return `rgb(${r}, ${g}, ${b})`; +} + +/** Velocity-based stroke color (slightly darker version) */ +function velocityStrokeColor(velocity: number): string { + const v = Math.max(0, Math.min(127, velocity)); + const t = v / 127; + if (t < 0.5) return "#3b82f6"; + if (t < 0.75) return "#22c55e"; + return "#ef4444"; +} + +/** Check if a MIDI note number is in the given scale */ +function isNoteInScale(noteNumber: number, scaleRoot: number, scaleType: string): boolean { + if (scaleType === "chromatic") return true; + const intervals = SCALE_DEFINITIONS[scaleType]; + if (!intervals) return true; + const degree = ((noteNumber % 12) - scaleRoot + 12) % 12; + return intervals.includes(degree); +} + +// ============================================ +// Parsed note pair: noteOn + noteOff matched +// ============================================ +interface NotePair { + noteOn: MIDIEvent; + noteOff: MIDIEvent; + noteNumber: number; + velocity: number; + startTime: number; + duration: number; + pitchBend?: number; + pressure?: number; + slide?: number; +} + +/** Extended note pair with clip ownership info for multi-clip editing */ +interface MultiClipNotePair extends NotePair { + clipId: string; + clipIndex: number; // 1-based index into additionalClips (0 = primary) + timeOffset: number; // time offset relative to primary clip +} + +export function PianoRoll({ clipId, trackId, additionalClipIds = [] }: PianoRollProps) { const containerRef = useRef(null); const [dimensions, setDimensions] = useState({ width: 800, height: 600 }); const [tool, setTool] = useState<"draw" | "select" | "erase">("draw"); const [zoom, setZoom] = useState(100); // pixels per beat const [scrollX, setScrollX] = useState(0); - const [scrollY, setScrollY] = useState(TOTAL_NOTES * NOTE_HEIGHT / 2 - 300); // Center around middle C + const [scrollY, setScrollY] = useState(TOTAL_NOTES * NOTE_HEIGHT / 2 - 300); + + // Step input: octave for keyboard input + const [stepInputOctave, setStepInputOctave] = useState(4); // C4 = middle C + + // Velocity editing state + const [velocityEditingNote, setVelocityEditingNote] = useState<{ timestamp: number; note: number } | null>(null); - const track = useDAWStore((state) => - state.tracks.find((t) => t.id === trackId), + // CC lane state + const [selectedCC, setSelectedCC] = useState(1); // CC#1 Modulation by default + const [ccDrawing, setCCDrawing] = useState(false); + + // Store selectors — consolidated into useShallow to prevent unnecessary re-renders + const { + track, tempo, scaleRoot, scaleType, + stepInputEnabled, stepInputSize, stepInputPosition, + } = useDAWStore( + useShallow((state) => ({ + track: state.tracks.find((t) => t.id === trackId), + tempo: state.transport.tempo, + scaleRoot: state.pianoRollScaleRoot, + scaleType: state.pianoRollScaleType, + stepInputEnabled: state.stepInputEnabled, + stepInputSize: state.stepInputSize, + stepInputPosition: state.stepInputPosition, + })), ); + + // Step input actions + const toggleStepInput = useDAWStore((s) => s.toggleStepInput); + const setStepInputSize = useDAWStore((s) => s.setStepInputSize); + const advanceStepInput = useDAWStore((s) => s.advanceStepInput); + const setStepInputPosition = useDAWStore((s) => s.setStepInputPosition); + const clip = track?.midiClips.find((c) => c.id === clipId); - const tempo = useDAWStore((state) => state.transport.tempo); + const clipEvents = clip?.events; + const clipCCEvents = clip?.ccEvents; + const clipDuration = clip?.duration ?? 0; + const clipStartTime = clip?.startTime ?? 0; + + // Multi-clip editing: gather additional clips from the same track + const additionalClips = useMemo(() => { + if (!track || additionalClipIds.length === 0) return []; + return additionalClipIds + .map((id) => track.midiClips.find((c) => c.id === id)) + .filter((c): c is NonNullable => c != null); + }, [track, additionalClipIds]); + + // Transform dropdown state + const [showTransformMenu, setShowTransformMenu] = useState(false); + const transformMenuRef = useRef(null); + + // Close transform menu on outside click + useEffect(() => { + if (!showTransformMenu) return; + const handleClickOutside = (e: MouseEvent) => { + if (transformMenuRef.current && !transformMenuRef.current.contains(e.target as Node)) { + setShowTransformMenu(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [showTransformMenu]); + + // Store actions — consolidated into useShallow to reduce subscription overhead + const { + updateMIDINoteVelocity, + updateMIDICCEvents, + setPianoRollScaleRoot, + setPianoRollScaleType, + transposeMIDINotes, + scaleMIDINoteVelocity, + reverseMIDINotes, + invertMIDINotes, + } = useDAWStore( + useShallow((s) => ({ + updateMIDINoteVelocity: s.updateMIDINoteVelocity, + updateMIDICCEvents: s.updateMIDICCEvents, + setPianoRollScaleRoot: s.setPianoRollScaleRoot, + setPianoRollScaleType: s.setPianoRollScaleType, + transposeMIDINotes: s.transposeMIDINotes, + scaleMIDINoteVelocity: s.scaleMIDINoteVelocity, + reverseMIDINotes: s.reverseMIDINotes, + invertMIDINotes: s.invertMIDINotes, + })), + ); + + // Derived values (computed before hooks that depend on them) + const beatsPerSecond = tempo / 60; + const pixelsPerSecond = zoom * beatsPerSecond; + const bottomLanesHeight = VELOCITY_LANE_HEIGHT + LANE_DIVIDER_HEIGHT + CC_LANE_HEIGHT + LANE_DIVIDER_HEIGHT; + const stageHeight = dimensions.height - TOOLBAR_HEIGHT; + const noteGridHeight = stageHeight - bottomLanesHeight; + const velocityLaneY = noteGridHeight; + const ccLaneY = velocityLaneY + VELOCITY_LANE_HEIGHT + LANE_DIVIDER_HEIGHT; useEffect(() => { const updateDimensions = () => { @@ -67,11 +301,9 @@ export function PianoRoll({ clipId, trackId }: PianoRollProps) { const handleWheel = (e: WheelEvent) => { e.preventDefault(); if (e.shiftKey) { - // Horizontal scroll setScrollX((prev) => Math.max(0, prev + e.deltaY)); } else { - // Vertical scroll - const maxScrollY = TOTAL_NOTES * NOTE_HEIGHT - (dimensions.height - 40); + const maxScrollY = TOTAL_NOTES * NOTE_HEIGHT - (dimensions.height - TOOLBAR_HEIGHT); setScrollY((prev) => Math.max(0, Math.min(maxScrollY, prev + e.deltaY))); } }; @@ -80,13 +312,290 @@ export function PianoRoll({ clipId, trackId }: PianoRollProps) { return () => container.removeEventListener("wheel", handleWheel); }, [dimensions.height]); + // Step input keyboard handler + useEffect(() => { + if (!stepInputEnabled) return; + + const handleKeyDown = (e: KeyboardEvent) => { + // Ignore when typing in inputs/selects + const tag = (e.target as HTMLElement).tagName; + if (tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA") return; + + const key = e.key.toLowerCase(); + + // Octave shift with up/down arrows + if (key === "arrowup") { + e.preventDefault(); + setStepInputOctave((prev) => Math.min(8, prev + 1)); + return; + } + if (key === "arrowdown") { + e.preventDefault(); + setStepInputOctave((prev) => Math.max(-2, prev - 1)); + return; + } + + // Left/Right arrows to manually move step cursor + if (key === "arrowleft") { + e.preventDefault(); + const bps = tempo / 60; + const stepSeconds = stepInputSize / bps; + setStepInputPosition(Math.max(0, stepInputPosition - stepSeconds)); + return; + } + if (key === "arrowright") { + e.preventDefault(); + advanceStepInput(); + return; + } + + // Note input: C, D, E, F, G, A, B keys + const semitone = KEY_TO_NOTE[key]; + if (semitone !== undefined) { + e.preventDefault(); + // Shift key adds sharp (+1 semitone) + const sharpOffset = e.shiftKey ? 1 : 0; + const noteNumber = (stepInputOctave + 2) * 12 + semitone + sharpOffset; + if (noteNumber < 0 || noteNumber > 127) return; + + const bps = tempo / 60; + const durationSeconds = stepInputSize / bps; + + // Add the note at stepInputPosition (direct setState to avoid closure issues) + const noteOnEvent: MIDIEvent = { timestamp: stepInputPosition, type: "noteOn", note: noteNumber, velocity: 80 }; + const noteOffEvent: MIDIEvent = { timestamp: stepInputPosition + durationSeconds, type: "noteOff", note: noteNumber, velocity: 0 }; + useDAWStore.setState((state) => ({ + tracks: state.tracks.map((t) => + t.id === trackId + ? { + ...t, + midiClips: t.midiClips.map((c) => + c.id === clipId + ? { ...c, events: [...c.events, noteOnEvent, noteOffEvent].sort((a, b) => a.timestamp - b.timestamp) } + : c, + ), + } + : t, + ), + })); + + // Advance cursor + advanceStepInput(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [stepInputEnabled, stepInputOctave, stepInputSize, stepInputPosition, tempo, clipId, trackId]); + + // Parse noteOn/noteOff pairs (must be before early return) + const notePairs: NotePair[] = useMemo(() => { + if (!clipEvents) return []; + const pairs: NotePair[] = []; + const noteOns = clipEvents.filter((e) => e.type === "noteOn"); + for (const noteOn of noteOns) { + const noteOff = clipEvents.find( + (e) => + e.type === "noteOff" && + e.note === noteOn.note && + e.timestamp > noteOn.timestamp, + ); + if (!noteOff || noteOn.note === undefined) continue; + pairs.push({ + noteOn, + noteOff, + noteNumber: noteOn.note, + velocity: noteOn.velocity || 80, + startTime: noteOn.timestamp, + duration: noteOff.timestamp - noteOn.timestamp, + pitchBend: noteOn.pitchBend, + pressure: noteOn.pressure, + slide: noteOn.slide, + }); + } + return pairs; + }, [clipEvents]); + + // Parse note pairs for additional clips (multi-clip editing) + const additionalClipNotePairs: MultiClipNotePair[] = useMemo(() => { + if (additionalClips.length === 0) return []; + const allPairs: MultiClipNotePair[] = []; + for (let ci = 0; ci < additionalClips.length; ci++) { + const ac = additionalClips[ci]; + const timeOffset = ac.startTime - clipStartTime; + const noteOns = ac.events.filter((e) => e.type === "noteOn"); + for (const noteOn of noteOns) { + const noteOff = ac.events.find( + (e) => e.type === "noteOff" && e.note === noteOn.note && e.timestamp > noteOn.timestamp, + ); + if (!noteOff || noteOn.note === undefined) continue; + allPairs.push({ + noteOn, + noteOff, + noteNumber: noteOn.note, + velocity: noteOn.velocity || 80, + startTime: noteOn.timestamp + timeOffset, + duration: noteOff.timestamp - noteOn.timestamp, + clipId: ac.id, + clipIndex: ci + 1, + timeOffset, + }); + } + } + return allPairs; + }, [additionalClips, clipStartTime]); + + // Get CC events for the selected CC number (must be before early return) + const ccEventsForLane: MIDICCEvent[] = useMemo(() => { + if (!clipCCEvents) return []; + return clipCCEvents.filter((e) => e.cc === selectedCC); + }, [clipCCEvents, selectedCC]); + + // ============================================ + // Velocity Lane Mouse Handlers (must be before early return) + // ============================================ + const handleVelocityMouseDown = useCallback((e: KonvaEvent) => { + const stage = e.target.getStage(); + const pos = stage.getPointerPosition(); + if (!pos) return; + const y = pos.y; + + if (y < velocityLaneY || y >= velocityLaneY + VELOCITY_LANE_HEIGHT) return; + + const x = pos.x; + for (const pair of notePairs) { + const barX = PIANO_WIDTH + pair.startTime * pixelsPerSecond - scrollX; + const barWidth = Math.max(4, pair.duration * pixelsPerSecond); + if (x >= barX && x <= barX + barWidth) { + const relY = y - velocityLaneY; + const newVelocity = Math.round(127 * (1 - relY / VELOCITY_LANE_HEIGHT)); + const clamped = Math.max(1, Math.min(127, newVelocity)); + setVelocityEditingNote({ timestamp: pair.startTime, note: pair.noteNumber }); + updateMIDINoteVelocity(trackId, clipId, pair.startTime, pair.noteNumber, clamped); + return; + } + } + }, [notePairs, velocityLaneY, pixelsPerSecond, scrollX, trackId, clipId, updateMIDINoteVelocity]); + + const handleVelocityMouseMove = useCallback((e: KonvaEvent) => { + if (!velocityEditingNote) return; + const stage = e.target.getStage(); + const pos = stage.getPointerPosition(); + if (!pos) return; + const relY = pos.y - velocityLaneY; + const newVelocity = Math.round(127 * (1 - Math.max(0, Math.min(VELOCITY_LANE_HEIGHT, relY)) / VELOCITY_LANE_HEIGHT)); + const clamped = Math.max(1, Math.min(127, newVelocity)); + updateMIDINoteVelocity(trackId, clipId, velocityEditingNote.timestamp, velocityEditingNote.note, clamped); + }, [velocityEditingNote, velocityLaneY, trackId, clipId, updateMIDINoteVelocity]); + + const handleVelocityMouseUp = useCallback(() => { + setVelocityEditingNote(null); + }, []); + + // ============================================ + // CC Lane Mouse Handlers (must be before early return) + // ============================================ + const handleCCMouseDown = useCallback((e: KonvaEvent) => { + const stage = e.target.getStage(); + const pos = stage.getPointerPosition(); + if (!pos) return; + const y = pos.y; + + if (y < ccLaneY || y >= ccLaneY + CC_LANE_HEIGHT) return; + + setCCDrawing(true); + + const x = pos.x - PIANO_WIDTH + scrollX; + const time = Math.max(0, x / pixelsPerSecond); + const relY = y - ccLaneY; + const value = Math.round(127 * (1 - relY / CC_LANE_HEIGHT)); + const clamped = Math.max(0, Math.min(127, value)); + + const existingEvents = clipCCEvents || []; + const filtered = existingEvents.filter( + (ev) => !(ev.cc === selectedCC && Math.abs(ev.time - time) < 0.01), + ); + const newEvents = [...filtered, { cc: selectedCC, time, value: clamped }] + .sort((a, b) => a.time - b.time); + updateMIDICCEvents(trackId, clipId, newEvents); + }, [ccLaneY, scrollX, pixelsPerSecond, selectedCC, clipCCEvents, trackId, clipId, updateMIDICCEvents]); + + const handleCCMouseMove = useCallback((e: KonvaEvent) => { + if (!ccDrawing) return; + const stage = e.target.getStage(); + const pos = stage.getPointerPosition(); + if (!pos) return; + + const x = pos.x - PIANO_WIDTH + scrollX; + const time = Math.max(0, x / pixelsPerSecond); + const relY = pos.y - ccLaneY; + const value = Math.round(127 * (1 - Math.max(0, Math.min(CC_LANE_HEIGHT, relY)) / CC_LANE_HEIGHT)); + const clamped = Math.max(0, Math.min(127, value)); + + // Read current state directly for continuous drawing + const currentTrack = useDAWStore.getState().tracks.find((t) => t.id === trackId); + const currentClip = currentTrack?.midiClips.find((c) => c.id === clipId); + const existingEvents = currentClip?.ccEvents || []; + const filtered = existingEvents.filter( + (ev) => !(ev.cc === selectedCC && Math.abs(ev.time - time) < 0.02), + ); + const newEvents = [...filtered, { cc: selectedCC, time, value: clamped }] + .sort((a, b) => a.time - b.time); + // Direct setState to avoid pushing undo for every mousemove + useDAWStore.setState((s) => ({ + tracks: s.tracks.map((t) => + t.id === trackId + ? { ...t, midiClips: t.midiClips.map((c) => c.id === clipId ? { ...c, ccEvents: newEvents } : c) } + : t, + ), + })); + }, [ccDrawing, ccLaneY, scrollX, pixelsPerSecond, selectedCC, trackId, clipId]); + + const handleCCMouseUp = useCallback(() => { + if (ccDrawing) { + setCCDrawing(false); + // Push final undo on mouse up + const currentClip = useDAWStore.getState().tracks + .find((t) => t.id === trackId)?.midiClips.find((c) => c.id === clipId); + if (currentClip?.ccEvents) { + updateMIDICCEvents(trackId, clipId, currentClip.ccEvents); + } + } + }, [ccDrawing, trackId, clipId, updateMIDICCEvents]); + + // Combined stage mouse handlers (must be before early return) + const handleStageMouseDown = useCallback((e: KonvaEvent) => { + const stage = e.target.getStage(); + const pos = stage.getPointerPosition(); + if (!pos) return; + + if (pos.y >= ccLaneY && pos.y < ccLaneY + CC_LANE_HEIGHT) { + handleCCMouseDown(e); + } else if (pos.y >= velocityLaneY && pos.y < velocityLaneY + VELOCITY_LANE_HEIGHT) { + handleVelocityMouseDown(e); + } + }, [ccLaneY, velocityLaneY, handleCCMouseDown, handleVelocityMouseDown]); + + const handleStageMouseMove = useCallback((e: KonvaEvent) => { + if (velocityEditingNote) { + handleVelocityMouseMove(e); + } else if (ccDrawing) { + handleCCMouseMove(e); + } + }, [velocityEditingNote, ccDrawing, handleVelocityMouseMove, handleCCMouseMove]); + + const handleStageMouseUp = useCallback(() => { + handleVelocityMouseUp(); + handleCCMouseUp(); + }, [handleVelocityMouseUp, handleCCMouseUp]); + + // ============================================ + // Early return AFTER all hooks + // ============================================ if (!clip || !track) { return
No MIDI clip selected
; } - const beatsPerSecond = tempo / 60; - const pixelsPerSecond = zoom * beatsPerSecond; - const getNoteY = (noteNumber: number) => { return (TOTAL_NOTES - 1 - noteNumber) * NOTE_HEIGHT - scrollY; }; @@ -100,27 +609,10 @@ export function PianoRoll({ clipId, trackId }: PianoRollProps) { return Math.round(time / beatTime) * beatTime; }; - const handleStageClick = (e: any) => { - const stage = e.target.getStage(); - const pos = stage.getPointerPosition(); - const x = pos.x - PIANO_WIDTH + scrollX; - const y = pos.y; - - if (pos.x < PIANO_WIDTH) return; // Clicked on piano keyboard - - const time = snapTime(x / pixelsPerSecond); - const note = getNoteFromY(y); - - if (note < 0 || note >= TOTAL_NOTES) return; - - if (tool === "draw") { - addNote(time, note, 0.25, 80); // Default: 1/4 note, velocity 80 - } else if (tool === "erase") { - removeNoteAtPosition(time, note); - } - }; - - const addNote = ( + /** Add a note to a specific clip (used by both draw tool and step input) */ + const addNoteToClip = ( + targetClipId: string, + targetTrackId: string, time: number, note: number, duration: number, @@ -132,21 +624,19 @@ export function PianoRoll({ clipId, trackId }: PianoRollProps) { note, velocity, }; - const noteOffEvent: MIDIEvent = { timestamp: time + duration, type: "noteOff", note, velocity: 0, }; - useDAWStore.setState((state) => ({ tracks: state.tracks.map((t) => - t.id === trackId + t.id === targetTrackId ? { ...t, midiClips: t.midiClips.map((c) => - c.id === clipId + c.id === targetClipId ? { ...c, events: [...c.events, noteOnEvent, noteOffEvent].sort( @@ -161,14 +651,20 @@ export function PianoRoll({ clipId, trackId }: PianoRollProps) { })); }; - const removeNoteAtPosition = (time: number, note: number) => { + /** Remove a note from a specific clip */ + const removeNoteFromClip = ( + targetClipId: string, + targetTrackId: string, + time: number, + note: number, + ) => { useDAWStore.setState((state) => ({ tracks: state.tracks.map((t) => - t.id === trackId + t.id === targetTrackId ? { ...t, midiClips: t.midiClips.map((c) => - c.id === clipId + c.id === targetClipId ? { ...c, events: c.events.filter( @@ -187,6 +683,37 @@ export function PianoRoll({ clipId, trackId }: PianoRollProps) { })); }; + const handleStageClick = (e: KonvaEvent) => { + const stage = e.target.getStage(); + const pos = stage.getPointerPosition(); + const x = pos.x - PIANO_WIDTH + scrollX; + const y = pos.y; + + // Ignore clicks in velocity lane and CC lane (handled separately) + if (y >= velocityLaneY) return; + + if (pos.x < PIANO_WIDTH) return; + + const time = snapTime(x / pixelsPerSecond); + const note = getNoteFromY(y); + + if (note < 0 || note >= TOTAL_NOTES) return; + + if (tool === "draw") { + addNoteToClip(clipId, trackId, time, note, 0.25, 80); + } else if (tool === "erase") { + // Try to remove from primary clip first, then additional clips + removeNoteFromClip(clipId, trackId, time, note); + for (const ac of additionalClips) { + removeNoteFromClip(ac.id, trackId, time, note); + } + } + }; + + // ============================================ + // Render Functions + // ============================================ + const renderPianoKeyboard = () => { const keys = []; for (let i = 0; i < TOTAL_NOTES; i++) { @@ -196,7 +723,7 @@ export function PianoRoll({ clipId, trackId }: PianoRollProps) { const isC = noteName === "C"; keys.push( - + { - const lines = []; - const gridWidth = clip.duration * pixelsPerSecond; + const elements: React.ReactNode[] = []; + const gridWidth = clipDuration * pixelsPerSecond; + const beatInterval = 1 / beatsPerSecond; + + // Beat grid shading: alternate beats + let beatIndex = 0; + for (let t = 0; t < clipDuration; t += beatInterval) { + const bx = PIANO_WIDTH + t * pixelsPerSecond - scrollX; + const bw = beatInterval * pixelsPerSecond; + + if (bx + bw < PIANO_WIDTH || bx > dimensions.width) { + beatIndex++; + continue; + } + + if (beatIndex % 2 === 1) { + elements.push( + , + ); + } + beatIndex++; + } - // Horizontal lines (note rows) + // Horizontal lines (note rows) with scale highlighting for (let i = 0; i <= TOTAL_NOTES; i++) { const y = i * NOTE_HEIGHT - scrollY; - const noteName = NOTE_NAMES[(TOTAL_NOTES - 1 - i) % NOTES_PER_OCTAVE]; + const noteNumber = TOTAL_NOTES - 1 - i; + const noteName = NOTE_NAMES[noteNumber >= 0 ? noteNumber % NOTES_PER_OCTAVE : 0]; const isC = noteName === "C"; + const isBlackKey = noteName.includes("#"); + const inScale = noteNumber >= 0 && isNoteInScale(noteNumber, scaleRoot, scaleType); - lines.push( + // Scale highlighting: in-scale rows get a lighter tint + if (i < TOTAL_NOTES && scaleType !== "chromatic" && inScale) { + elements.push( + , + ); + } + + // Black key row background tint + if (isBlackKey && i < TOTAL_NOTES) { + elements.push( + , + ); + } + + elements.push( dimensions.width) continue; - lines.push( + elements.push( { + const ghostElements: React.ReactNode[] = []; + const editingClipIds = new Set([clipId, ...additionalClipIds]); + const otherClips = track.midiClips.filter((c) => !editingClipIds.has(c.id)); + + for (const otherClip of otherClips) { + const timeOffset = otherClip.startTime - clipStartTime; + const noteOns = otherClip.events.filter((e) => e.type === "noteOn"); + + for (const noteOn of noteOns) { + const noteOff = otherClip.events.find( + (e) => + e.type === "noteOff" && + e.note === noteOn.note && + e.timestamp > noteOn.timestamp, + ); + + if (!noteOff || noteOn.note === undefined) continue; + + const adjustedTime = noteOn.timestamp + timeOffset; + const x = PIANO_WIDTH + adjustedTime * pixelsPerSecond - scrollX; + const y = getNoteY(noteOn.note); + const width = (noteOff.timestamp - noteOn.timestamp) * pixelsPerSecond; + + if (x + width < PIANO_WIDTH || x > dimensions.width) continue; + + ghostElements.push( + , + ); + } + } + + return ghostElements; }; const renderNotes = () => { - const noteRects = []; - const noteOns = clip.events.filter((e) => e.type === "noteOn"); + const noteElements: React.ReactNode[] = []; - for (const noteOn of noteOns) { - const noteOff = clip.events.find( - (e) => - e.type === "noteOff" && - e.note === noteOn.note && - e.timestamp > noteOn.timestamp, + // Render primary clip notes + for (const pair of notePairs) { + const x = PIANO_WIDTH + pair.startTime * pixelsPerSecond - scrollX; + const y = getNoteY(pair.noteNumber); + const width = pair.duration * pixelsPerSecond; + + if (x + width < PIANO_WIDTH || x > dimensions.width) continue; + + const fillColor = velocityColor(pair.velocity); + const strokeColor = velocityStrokeColor(pair.velocity); + const hasPressure = pair.pressure !== undefined && pair.pressure > 0; + const hasPitchBend = pair.pitchBend !== undefined && pair.pitchBend !== 0; + const hasSlide = pair.slide !== undefined && pair.slide > 0; + + const noteLabel = getNoteNameFromPitch(pair.noteNumber); + const showName = width > 40; + + noteElements.push( + + + {hasPressure && ( + + )} + {hasPitchBend && width > 8 && ( + 0 + ? [x + width - 6, y + NOTE_HEIGHT - 4, x + width - 3, y + 2, x + width, y + NOTE_HEIGHT - 4] + : [x + width - 6, y + 2, x + width - 3, y + NOTE_HEIGHT - 4, x + width, y + 2] + } + fill="#ffffff" + closed + opacity={0.7} + listening={false} + /> + )} + {hasSlide && width > 8 && ( + + )} + {showName && ( + + )} + , ); + } - if (!noteOff || noteOn.note === undefined) continue; + // Render additional clip notes (multi-clip editing) with tinted colors + for (const pair of additionalClipNotePairs) { + const x = PIANO_WIDTH + pair.startTime * pixelsPerSecond - scrollX; + const y = getNoteY(pair.noteNumber); + const width = pair.duration * pixelsPerSecond; + + if (x + width < PIANO_WIDTH || x > dimensions.width) continue; + + const tintColor = MULTI_CLIP_TINTS[pair.clipIndex % MULTI_CLIP_TINTS.length] || "#ff6b9d"; + const noteLabel = getNoteNameFromPitch(pair.noteNumber); + const showName = width > 40; + + noteElements.push( + + + {showName && ( + + )} + , + ); + } - const x = PIANO_WIDTH + noteOn.timestamp * pixelsPerSecond - scrollX; - const y = getNoteY(noteOn.note); + return noteElements; + }; - // Skip if outside visible area - const noteWidth = (noteOff.timestamp - noteOn.timestamp) * pixelsPerSecond; - if (x + noteWidth < PIANO_WIDTH || x > dimensions.width) continue; - const width = (noteOff.timestamp - noteOn.timestamp) * pixelsPerSecond; - const velocity = noteOn.velocity || 80; - const opacity = velocity / 127; + // Step input cursor line + const renderStepInputCursor = () => { + if (!stepInputEnabled) return null; + const cursorX = PIANO_WIDTH + stepInputPosition * pixelsPerSecond - scrollX; + if (cursorX < PIANO_WIDTH || cursorX > dimensions.width) return null; - noteRects.push( + return ( + + {/* Vertical cursor line */} + + {/* Small triangle indicator at top */} + + + ); + }; + + // ============================================ + // Velocity Lane Rendering + // ============================================ + const renderVelocityLane = () => { + const elements: React.ReactNode[] = []; + + // Background + divider + label + elements.push( + , + , + , + ); + + // Horizontal guide lines at 25%, 50%, 75% + for (const frac of [0.25, 0.5, 0.75]) { + const gy = velocityLaneY + VELOCITY_LANE_HEIGHT * (1 - frac); + elements.push( + , + ); + } + + // Draw velocity bars for each note + for (const pair of notePairs) { + const barX = PIANO_WIDTH + pair.startTime * pixelsPerSecond - scrollX; + const barWidth = Math.max(4, pair.duration * pixelsPerSecond - 1); + + if (barX + barWidth < PIANO_WIDTH || barX > dimensions.width) continue; + + const velFrac = pair.velocity / 127; + const barHeight = velFrac * (VELOCITY_LANE_HEIGHT - 4); + const barY = velocityLaneY + VELOCITY_LANE_HEIGHT - barHeight - 2; + const color = velocityColor(pair.velocity); + + elements.push( , ); } - return noteRects; + // Clickable overlay for the whole velocity lane + elements.push( + , + ); + + return elements; + }; + + // ============================================ + // CC Lane Rendering + // ============================================ + const renderCCLane = () => { + const elements: React.ReactNode[] = []; + const ccPreset = CC_PRESETS.find((p) => p.cc === selectedCC); + + // Background + divider + label + elements.push( + , + , + , + ); + + // Horizontal guide lines + for (const frac of [0.25, 0.5, 0.75]) { + const gy = ccLaneY + CC_LANE_HEIGHT * (1 - frac); + elements.push( + , + ); + } + + // Draw CC events as vertical bars and connecting line + if (ccEventsForLane.length > 0) { + // Draw connecting line + const linePoints: number[] = []; + for (const ev of ccEventsForLane) { + const x = PIANO_WIDTH + ev.time * pixelsPerSecond - scrollX; + const y = ccLaneY + CC_LANE_HEIGHT * (1 - ev.value / 127); + linePoints.push(x, y); + } + if (linePoints.length >= 4) { + elements.push( + , + ); + } + + // Draw individual CC points + for (let i = 0; i < ccEventsForLane.length; i++) { + const ev = ccEventsForLane[i]; + const x = PIANO_WIDTH + ev.time * pixelsPerSecond - scrollX; + if (x < PIANO_WIDTH || x > dimensions.width) continue; + + const valFrac = ev.value / 127; + const barHeight = valFrac * (CC_LANE_HEIGHT - 4); + const barY = ccLaneY + CC_LANE_HEIGHT - barHeight - 2; + + // Vertical stem + dot + elements.push( + , + , + ); + } + } + + // Clickable overlay + elements.push( + , + ); + + return elements; }; return ( @@ -315,7 +1183,7 @@ export function PianoRoll({ clipId, trackId }: PianoRollProps) { onClick={() => setTool("draw")} title="Draw Tool (D)" > - ✏️ Draw + Draw
- + setZoom(parseInt(e.target.value))} + onChange={(e) => setZoom(Number.parseInt(e.target.value))} className="zoom-slider" /> +
+ + {/* Scale Selector */} + + + + +
+ + {/* CC Lane Selector */} + + +
+ + {/* Transform Dropdown */} +
+ + {showTransformMenu && ( +
+ + + + +
+ + +
+ + +
+ )} +
+
+ + {/* Step Input Mode */} + + {stepInputEnabled && ( + <> + + + + Oct: {stepInputOctave} + + + )} + + {/* Multi-clip indicator */} + {additionalClips.length > 0 && ( + <> +
+ + Editing {additionalClips.length + 1} clips + + + )}
{/* Background */} @@ -358,18 +1374,30 @@ export function PianoRoll({ clipId, trackId }: PianoRollProps) { x={0} y={0} width={dimensions.width} - height={dimensions.height} + height={stageHeight} fill="#1a1a1a" /> - {/* Grid */} + {/* Grid with beat shading and scale highlighting */} {renderGrid()} {/* Piano keyboard */} {renderPianoKeyboard()} - {/* MIDI notes */} + {/* Ghost notes from other MIDI clips on this track */} + {renderGhostNotes()} + + {/* MIDI notes with velocity coloring (includes multi-clip notes) */} {renderNotes()} + + {/* Step input cursor line */} + {renderStepInputCursor()} + + {/* Velocity lane */} + {renderVelocityLane()} + + {/* CC lane */} + {renderCCLane()}
diff --git a/frontend/src/components/PitchCorrectorPanel.tsx b/frontend/src/components/PitchCorrectorPanel.tsx new file mode 100644 index 0000000..aa1ff71 --- /dev/null +++ b/frontend/src/components/PitchCorrectorPanel.tsx @@ -0,0 +1,680 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { nativeBridge, PitchCorrectorData, PitchHistoryFrame } from "../services/NativeBridge"; +import { FACTORY_PRESETS, PRESET_CATEGORIES, PitchCorrectorPreset } from "./pitchCorrectorPresets"; + +// Scale names matching C++ PitchMapper::Scale enum +const SCALE_NAMES = [ + "Chromatic", "Major", "Natural Minor", "Harmonic Minor", "Melodic Minor", + "Pentatonic Major", "Pentatonic Minor", "Blues", "Dorian", "Mixolydian", + "Lydian", "Phrygian", "Locrian", "Whole Tone", "Diminished", "Custom", +]; + +const NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; + +// Scale intervals for each scale type (matches C++ exactly) +const SCALE_INTERVALS: boolean[][] = [ + [true, true, true, true, true, true, true, true, true, true, true, true], // Chromatic + [true, false, true, false, true, true, false, true, false, true, false, true], // Major + [true, false, true, true, false, true, false, true, true, false, true, false], // Natural Minor + [true, false, true, true, false, true, false, true, true, false, false, true], // Harmonic Minor + [true, false, true, true, false, true, false, true, false, true, false, true], // Melodic Minor + [true, false, true, false, true, false, false, true, false, true, false, false], // Pentatonic Major + [true, false, false, true, false, true, false, true, false, false, true, false], // Pentatonic Minor + [true, false, false, true, false, true, true, true, false, false, true, false], // Blues + [true, false, true, true, false, true, false, true, false, true, true, false], // Dorian + [true, false, true, false, true, true, false, true, false, true, true, false], // Mixolydian + [true, false, true, false, true, false, true, true, false, true, false, true], // Lydian + [true, true, false, true, false, true, false, true, true, false, true, false], // Phrygian + [true, true, false, true, false, true, true, false, true, false, true, false], // Locrian + [true, false, true, false, true, false, true, false, true, false, true, false], // Whole Tone + [true, false, true, true, false, true, true, false, true, true, false, true], // Diminished + [true, true, true, true, true, true, true, true, true, true, true, true], // Custom +]; + +// Smooth lerp for animated range transitions +const RANGE_SMOOTH = 0.08; + +interface PitchCorrectorPanelProps { + trackId: string; + fxIndex: number; + onClose?: () => void; + onOpenGraphicalEditor?: () => void; +} + +function Knob({ label, value, min, max, step, unit, onChange, formatValue, tooltip }: { + label: string; + value: number; + min: number; + max: number; + step?: number; + unit?: string; + onChange: (v: number) => void; + formatValue?: (v: number) => string; + tooltip?: string; +}) { + const display = formatValue ? formatValue(value) : `${Math.round(value)}${unit || ""}`; + return ( +
+ + onChange(parseFloat(e.target.value))} + className="w-16 h-1 accent-blue-500" + style={{ writingMode: "horizontal-tb" }} + /> + {display} +
+ ); +} + +export function PitchCorrectorPanel({ trackId, fxIndex, onClose, onOpenGraphicalEditor }: PitchCorrectorPanelProps) { + const [data, setData] = useState(null); + const [history, setHistory] = useState([]); + const canvasRef = useRef(null); + const containerRef = useRef(null); + const animFrameRef = useRef(0); + const pollRef = useRef | null>(null); + + // Smooth animated pitch range (lerped each frame for fluid display) + const smoothRangeRef = useRef({ min: 48, max: 72 }); + + // Preset & A/B state + const [showPresetMenu, setShowPresetMenu] = useState(false); + const [activePreset, setActivePreset] = useState(null); + const [abSlot, setAbSlot] = useState<"A" | "B">("A"); + const [slotA, setSlotA] = useState(null); + const [slotB, setSlotB] = useState(null); + + // Responsive canvas size + const [canvasWidth, setCanvasWidth] = useState(500); + + // Observe container width for responsive layout + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const ro = new ResizeObserver((entries) => { + const w = entries[0].contentRect.width; + setCanvasWidth(Math.max(200, Math.floor(w - 16))); // 16px for px-2 padding + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + // Poll pitch data at ~30fps + useEffect(() => { + const poll = async () => { + const d = await nativeBridge.getPitchCorrectorData(trackId, fxIndex); + if (d) setData(d); + const h = await nativeBridge.getPitchHistory(trackId, fxIndex, 256); + if (h) setHistory(h); + }; + + pollRef.current = setInterval(poll, 33); + poll(); + return () => { if (pollRef.current) clearInterval(pollRef.current); }; + }, [trackId, fxIndex]); + + // Draw pitch display with high-DPI support and smooth range animation + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const draw = () => { + const dpr = window.devicePixelRatio || 1; + const cssW = canvasWidth; + const cssH = 120; + + // High-DPI: set canvas buffer size to match device pixels + if (canvas.width !== cssW * dpr || canvas.height !== cssH * dpr) { + canvas.width = cssW * dpr; + canvas.height = cssH * dpr; + canvas.style.width = `${cssW}px`; + canvas.style.height = `${cssH}px`; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + } + + const w = cssW; + const h = cssH; + ctx.clearRect(0, 0, w, h); + + // Background + ctx.fillStyle = "#0a0a0a"; + ctx.fillRect(0, 0, w, h); + + if (history.length === 0) { + animFrameRef.current = requestAnimationFrame(draw); + return; + } + + // Find target pitch range + let targetMin = 127, targetMax = 0; + for (const f of history) { + if (f.detected > 0) { + targetMin = Math.min(targetMin, f.detected); + targetMax = Math.max(targetMax, f.detected); + } + if (f.corrected > 0) { + targetMin = Math.min(targetMin, f.corrected); + targetMax = Math.max(targetMax, f.corrected); + } + } + + if (targetMin > targetMax) { + targetMin = 48; + targetMax = 72; + } + + targetMin = Math.floor(targetMin) - 2; + targetMax = Math.ceil(targetMax) + 2; + + // Smooth lerp the display range for fluid transitions + const sr = smoothRangeRef.current; + sr.min += (targetMin - sr.min) * RANGE_SMOOTH; + sr.max += (targetMax - sr.max) * RANGE_SMOOTH; + + const minMidi = sr.min; + const maxMidi = sr.max; + const midiRange = maxMidi - minMidi; + + if (midiRange < 1) { + animFrameRef.current = requestAnimationFrame(draw); + return; + } + + // Draw horizontal grid lines (semitones) + for (let m = Math.ceil(minMidi); m <= Math.floor(maxMidi); m++) { + const y = h - ((m - minMidi) / midiRange) * h; + + // Alternating semitone background bands + const noteIdx = ((m % 12) + 12) % 12; + const isWhiteKey = [0, 2, 4, 5, 7, 9, 11].includes(noteIdx); + if (!isWhiteKey) { + ctx.fillStyle = "rgba(255,255,255,0.015)"; + ctx.fillRect(0, y - (h / midiRange) * 0.5, w, h / midiRange); + } + + ctx.strokeStyle = "#1a1a1a"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(w, y); + ctx.stroke(); + + // Note name labels + ctx.fillStyle = isWhiteKey ? "#444" : "#333"; + ctx.font = "9px monospace"; + ctx.fillText(NOTE_NAMES[noteIdx] + (Math.floor(m / 12) - 1), 2, y - 2); + } + + const step = w / history.length; + + // Draw detected pitch (orange) with line glow + ctx.save(); + ctx.shadowColor = "#f59e0b"; + ctx.shadowBlur = 3; + ctx.strokeStyle = "#f59e0b"; + ctx.lineWidth = 1.5; + ctx.beginPath(); + let started = false; + for (let i = 0; i < history.length; i++) { + const f = history[i]; + if (f.detected <= 0 || f.confidence < 0.3) { + started = false; + continue; + } + const x = i * step; + const y = h - ((f.detected - minMidi) / midiRange) * h; + if (!started) { ctx.moveTo(x, y); started = true; } + else ctx.lineTo(x, y); + } + ctx.stroke(); + ctx.restore(); + + // Draw corrected pitch (green) with line glow + ctx.save(); + ctx.shadowColor = "#22c55e"; + ctx.shadowBlur = 4; + ctx.strokeStyle = "#22c55e"; + ctx.lineWidth = 2; + ctx.beginPath(); + started = false; + for (let i = 0; i < history.length; i++) { + const f = history[i]; + if (f.corrected <= 0 || f.confidence < 0.3) { + started = false; + continue; + } + const x = i * step; + const y = h - ((f.corrected - minMidi) / midiRange) * h; + if (!started) { ctx.moveTo(x, y); started = true; } + else ctx.lineTo(x, y); + } + ctx.stroke(); + ctx.restore(); + + // Current pitch indicator (right edge dot) + const lastFrame = history[history.length - 1]; + if (lastFrame && lastFrame.corrected > 0 && lastFrame.confidence > 0.3) { + const cy = h - ((lastFrame.corrected - minMidi) / midiRange) * h; + ctx.beginPath(); + ctx.arc(w - 4, cy, 3, 0, Math.PI * 2); + ctx.fillStyle = "#22c55e"; + ctx.fill(); + } + + animFrameRef.current = requestAnimationFrame(draw); + }; + + animFrameRef.current = requestAnimationFrame(draw); + return () => cancelAnimationFrame(animFrameRef.current); + }, [history, canvasWidth]); + + const setParam = useCallback((param: string, value: number) => { + nativeBridge.setPitchCorrectorParam(trackId, fxIndex, param, value); + }, [trackId, fxIndex]); + + // Capture current params as a snapshot + const captureCurrentParams = useCallback((): PitchCorrectorPreset["params"] | null => { + if (!data) return null; + return { + key: data.key, scale: data.scale, retuneSpeed: data.retuneSpeed, + correctionStrength: data.correctionStrength, humanize: data.humanize, + formantCorrection: data.formantCorrection, formantShift: data.formantShift, + mix: data.mix, transpose: data.transpose, noteEnables: [...data.noteEnables], + }; + }, [data]); + + // Apply a preset's params to the backend + const applyPresetParams = useCallback((params: PitchCorrectorPreset["params"]) => { + if (params.key !== undefined) setParam("key", params.key); + if (params.scale !== undefined) setParam("scale", params.scale); + setParam("retuneSpeed", params.retuneSpeed); + setParam("correctionStrength", params.correctionStrength); + setParam("humanize", params.humanize); + setParam("formantCorrection", params.formantCorrection ? 1 : 0); + setParam("formantShift", params.formantShift); + setParam("mix", params.mix); + if (params.transpose !== undefined) setParam("transpose", params.transpose); + if (params.noteEnables) { + for (let i = 0; i < 12; i++) { + setParam(`noteEnable_${i}`, params.noteEnables[i] ? 1 : 0); + } + } + }, [setParam]); + + const loadPreset = useCallback((preset: PitchCorrectorPreset) => { + const current = captureCurrentParams(); + if (abSlot === "A") setSlotA(current); + else setSlotB(current); + + applyPresetParams(preset.params); + setActivePreset(preset.name); + setShowPresetMenu(false); + }, [captureCurrentParams, abSlot, applyPresetParams]); + + // A/B toggle + const toggleAB = useCallback(() => { + const current = captureCurrentParams(); + if (abSlot === "A") { + setSlotA(current); + if (slotB) applyPresetParams(slotB); + setAbSlot("B"); + } else { + setSlotB(current); + if (slotA) applyPresetParams(slotA); + setAbSlot("A"); + } + }, [captureCurrentParams, abSlot, slotA, slotB, applyPresetParams]); + + // Save user preset + const saveUserPreset = useCallback(async () => { + const params = captureCurrentParams(); + if (!params) return; + const path = await nativeBridge.showSaveDialog("preset.ospreset", "Save Preset", "*.ospreset"); + if (!path) return; + const preset: PitchCorrectorPreset = { + name: path.split(/[/\\]/).pop()?.replace(/\.ospreset$/i, "") || "User Preset", + category: "User", + params, + }; + await nativeBridge.saveProjectToFile(path, JSON.stringify(preset, null, 2)); + }, [captureCurrentParams]); + + // Load user preset + const loadUserPreset = useCallback(async () => { + const path = await nativeBridge.showOpenDialog("Load Preset", "*.ospreset;*.s13preset"); + if (!path) return; + const json = await nativeBridge.loadProjectFromFile(path); + if (!json) return; + try { + const preset = JSON.parse(json) as PitchCorrectorPreset; + if (preset.params) { + applyPresetParams(preset.params); + setActivePreset(preset.name); + } + } catch { /* invalid preset file */ } + setShowPresetMenu(false); + }, [applyPresetParams]); + + const key = data?.key ?? 0; + const scale = data?.scale ?? 0; + const retuneSpeed = data?.retuneSpeed ?? 50; + const humanize = data?.humanize ?? 0; + const transpose = data?.transpose ?? 0; + const correctionStrength = data?.correctionStrength ?? 1; + const formantCorrection = data?.formantCorrection ?? false; + const formantShift = data?.formantShift ?? 0; + const mixVal = data?.mix ?? 1; + const midiOutput = data?.midiOutput ?? false; + const midiChannel = data?.midiChannel ?? 1; + const noteEnables = data?.noteEnables ?? Array(12).fill(true); + + // Auto-populate note enables when scale changes + const handleScaleChange = (newScale: number) => { + setParam("scale", newScale); + if (newScale < SCALE_INTERVALS.length && newScale !== 15) { // 15 = Custom + const intervals = SCALE_INTERVALS[newScale]; + for (let i = 0; i < 12; i++) { + const noteIdx = (i + key) % 12; + setParam(`noteEnable_${noteIdx}`, intervals[i] ? 1 : 0); + } + } + }; + + return ( +
+ {/* Header */} +
+
+
+ OpenStudio Pitch Correct +
+
+ {/* Preset selector */} +
+ + {showPresetMenu && ( +
+ {/* User preset save/load */} +
+ + +
+ {/* Factory presets by category */} + {PRESET_CATEGORIES.map(cat => ( +
+
{cat}
+ {FACTORY_PRESETS.filter(p => p.category === cat).map(p => ( + + ))} +
+ ))} +
+ )} +
+ + {/* A/B comparison */} + + + {onOpenGraphicalEditor && ( + + )} + {/* Current note display */} +
+ + {data?.noteName || "--"} + + + {data && data.centsDeviation !== 0 + ? `${data.centsDeviation > 0 ? "+" : ""}${Math.round(data.centsDeviation)}c` + : ""} + +
+ {/* Confidence */} +
+
+
0.7 ? "#22c55e" : (data?.confidence ?? 0) > 0.4 ? "#f59e0b" : "#ef4444", + }} + /> +
+ {Math.round((data?.confidence ?? 0) * 100)}% +
+ {onClose && ( + + )} +
+
+ + {/* Pitch Display Canvas */} +
+ +
+ ● Detected + ━ Detected + ━ Corrected +
+
+ + {/* Key & Scale */} +
+
+ + +
+
+ + +
+
+ + +
+
+ + {/* Note Enable Buttons */} +
+ +
+ {NOTE_NAMES.map((name, i) => { + const noteIdx = (i + key) % 12; + const isEnabled = noteEnables[noteIdx]; + const isBlack = name.includes("#"); + return ( + + ); + })} +
+
+ + {/* Knobs Row */} +
+ setParam("retuneSpeed", v)} + tooltip="How fast pitch snaps to the target note. 0ms = instant (hard tune), higher = more natural" + /> + setParam("correctionStrength", v / 100)} + tooltip="How much correction is applied. 100% = full correction, 0% = no correction" + /> + setParam("humanize", v)} + tooltip="Adds natural pitch variation to avoid robotic sound" + /> + setParam("mix", v / 100)} + tooltip="Dry/wet mix. 100% = fully corrected, 0% = original signal" + /> +
+ + + {formantCorrection && ( + setParam("formantShift", parseFloat(e.target.value))} + className="w-14 h-1 accent-green-500" + title={`Formant shift: ${formantShift.toFixed(1)} semitones`} + /> + )} +
+
+ + {/* MIDI Output */} +
+ MIDI Out + + {midiOutput && ( +
+ Ch + +
+ )} +
+ + {/* Footer info */} +
+ Detected: {data?.detectedPitch ? `${data.detectedPitch.toFixed(1)} Hz` : "--"} + Corrected: {data?.correctedPitch ? `${data.correctedPitch.toFixed(1)} Hz` : "--"} + Latency: ~46ms +
+
+ ); +} diff --git a/frontend/src/components/PitchEditorCanvas.ts b/frontend/src/components/PitchEditorCanvas.ts new file mode 100644 index 0000000..f726fae --- /dev/null +++ b/frontend/src/components/PitchEditorCanvas.ts @@ -0,0 +1,675 @@ +/** + * PitchEditorCanvas — Imperative Canvas 2D rendering for the pitch editor lower zone. + * + * Handles drawing the grid, notes, contour, playhead, selection, and hover effects. + * Called from PitchEditorLowerZone via requestAnimationFrame. + */ + +import type { PitchNoteData, PitchContourData, PolyNoteData, UnifiedNoteData } from "../services/NativeBridge"; +import type { PitchRenderCoverageRange } from "../store/pitchEditorStore"; +import { polyToUnified, monoToUnified } from "../services/NativeBridge"; + +const NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; +const BLACK_KEYS = new Set([1, 3, 6, 8, 10]); +export const PIANO_WIDTH = 30; // small piano reference strip on left edge of canvas + +export interface PitchEditorViewport { + scrollX: number; // seconds (horizontal offset, same as dawScrollX / pps) + scrollY: number; // MIDI note (bottom of viewport) + pixelsPerSecond: number; // from DAW store (synced with timeline) + pixelsPerSemitone: number; // vertical zoom + clipStartTime: number; // clip offset in project timeline + clipDuration: number; +} + +export interface PitchEditorRenderState { + notes: PitchNoteData[]; + contour: PitchContourData | null; + selectedNoteIds: string[]; + hoveredNoteId: string | null; + currentTime: number; // transport position (seconds) + isPlaying: boolean; + bpm: number; + timeSignature: [number, number]; + + // Scale overlay + scaleNotes: boolean[]; // 12 booleans + scaleKey: number; // 0-11 + + // Polyphonic mode + polyMode: boolean; + polyNotes: PolyNoteData[]; + showPitchSalience: boolean; + pitchSalience: number[][] | null; // downsampled [T][264] + salienceDownsampleFactor: number; + salienceHopSize: number; + salienceSampleRate: number; + renderCoverage: PitchRenderCoverageRange[]; +} + +// Convert a MIDI note to Y position on canvas +function midiToY(midi: number, viewport: PitchEditorViewport, canvasHeight: number): number { + const visibleSemitones = canvasHeight / viewport.pixelsPerSemitone; + const topMidi = viewport.scrollY + visibleSemitones; + return (topMidi - midi) * viewport.pixelsPerSemitone; +} + +// Convert clip-relative time (seconds) to X position on canvas. +// Note/contour times are relative to clip start (0 = start of clip). +// viewport.scrollX is in project-seconds, so we add clipStartTime. +function timeToX(clipTime: number, viewport: PitchEditorViewport): number { + const projectTime = viewport.clipStartTime + clipTime; + return PIANO_WIDTH + (projectTime - viewport.scrollX) * viewport.pixelsPerSecond; +} + +// Convert project-absolute time to X (for playhead, beat grid — already in project time) +function projectTimeToX(projectTime: number, viewport: PitchEditorViewport): number { + return PIANO_WIDTH + (projectTime - viewport.scrollX) * viewport.pixelsPerSecond; +} + +// Convert X position to clip-relative time +export function xToTime(x: number, viewport: PitchEditorViewport): number { + const projectTime = (x - PIANO_WIDTH) / viewport.pixelsPerSecond + viewport.scrollX; + return projectTime - viewport.clipStartTime; +} + +// Convert Y position to MIDI note +export function yToMidi(y: number, viewport: PitchEditorViewport, canvasHeight: number): number { + const visibleSemitones = canvasHeight / viewport.pixelsPerSemitone; + const topMidi = viewport.scrollY + visibleSemitones; + return topMidi - y / viewport.pixelsPerSemitone; +} + +export type NoteHitZone = "body" | "left" | "right" | "top-center" | "bottom-left" | "bottom-right"; + +/** Hit-test a point against note rectangles. Returns note ID and zone. */ +export function hitTestNote( + x: number, y: number, + notes: PitchNoteData[], + viewport: PitchEditorViewport, + canvasHeight: number +): { noteId: string; edge: NoteHitZone } | null { + const EDGE_PX = 6; + const HANDLE_SIZE = 8; + for (let i = notes.length - 1; i >= 0; i--) { + const n = notes[i]; + const nx = timeToX(n.startTime, viewport); + const nx2 = timeToX(n.endTime, viewport); + const ny = midiToY(n.correctedPitch + 0.5, viewport, canvasHeight); + const nh = viewport.pixelsPerSemitone; + if (x >= nx && x <= nx2 && y >= ny && y <= ny + nh) { + const w = nx2 - nx; + // Smart control handles (only if note is wide enough) + if (w > 30 && nh > 8) { + const midX = nx + w / 2; + // Top-center handle: straighten/vibrato + if (Math.abs(x - midX) < HANDLE_SIZE && y - ny < HANDLE_SIZE) { + return { noteId: n.id, edge: "top-center" }; + } + // Bottom-left handle: formant + if (x - nx < HANDLE_SIZE * 1.5 && ny + nh - y < HANDLE_SIZE) { + return { noteId: n.id, edge: "bottom-left" }; + } + // Bottom-right handle: gain + if (nx2 - x < HANDLE_SIZE * 1.5 && ny + nh - y < HANDLE_SIZE) { + return { noteId: n.id, edge: "bottom-right" }; + } + } + // Edge resize + if (x - nx < EDGE_PX) return { noteId: n.id, edge: "left" }; + if (nx2 - x < EDGE_PX) return { noteId: n.id, edge: "right" }; + return { noteId: n.id, edge: "body" }; + } + } + return null; +} + +/** Main render function — draws everything on the canvas. */ +export function renderPitchEditor( + ctx: CanvasRenderingContext2D, + width: number, + height: number, + viewport: PitchEditorViewport, + state: PitchEditorRenderState +) { + const dpr = window.devicePixelRatio || 1; + ctx.save(); + ctx.scale(dpr, dpr); + + // Clear + ctx.fillStyle = "#1a1a1a"; + ctx.fillRect(0, 0, width, height); + + const visibleSemitones = height / viewport.pixelsPerSemitone; + const bottomMidi = viewport.scrollY; + const topMidi = bottomMidi + visibleSemitones; + const minMidi = Math.floor(bottomMidi); + const maxMidi = Math.ceil(topMidi); + + // Check if a non-chromatic scale is active + const hasScale = state.scaleNotes.some((v) => !v) || false; + + // --- 1. Piano keys + semitone bands --- + for (let midi = minMidi; midi <= maxMidi; midi++) { + const y = midiToY(midi + 0.5, viewport, height); + const h = viewport.pixelsPerSemitone; + const noteClass = ((midi % 12) + 12) % 12; + const isBlack = BLACK_KEYS.has(noteClass); + const inScale = state.scaleNotes[noteClass]; + const isTonic = noteClass === state.scaleKey; + + // Semitone band — dim out-of-scale notes + if (hasScale && !inScale) { + ctx.fillStyle = "#111111"; + } else if (hasScale && isTonic) { + ctx.fillStyle = "#252525"; + } else { + ctx.fillStyle = isBlack ? "#161616" : "#1e1e1e"; + } + ctx.fillRect(PIANO_WIDTH, y, width - PIANO_WIDTH, h); + + // Grid line + ctx.strokeStyle = "#2a2a2a"; + ctx.lineWidth = noteClass === 0 ? 1 : 0.5; // C notes get thicker line + ctx.beginPath(); + ctx.moveTo(PIANO_WIDTH, y + h); + ctx.lineTo(width, y + h); + ctx.stroke(); + + // Piano key label + if (y >= -h && y <= height + h) { + ctx.fillStyle = "#1a1a1a"; + ctx.fillRect(0, y, PIANO_WIDTH, h); + ctx.strokeStyle = "#333"; + ctx.lineWidth = 0.5; + ctx.strokeRect(0, y, PIANO_WIDTH, h); + + if (isBlack) { + ctx.fillStyle = hasScale && !inScale ? "#222" : "#333"; + ctx.fillRect(0, y, PIANO_WIDTH * 0.65, h); + } + + // Label for C notes or every few semitones + if (noteClass === 0 || viewport.pixelsPerSemitone > 14) { + const octave = Math.floor(midi / 12) - 1; + const name = NOTE_NAMES[noteClass] + octave; + const keyBrightness = hasScale ? (inScale ? (isTonic ? "#bbb" : "#999") : "#555") : (isBlack ? "#888" : "#999"); + ctx.fillStyle = keyBrightness; + ctx.font = "9px Inter, system-ui, sans-serif"; + ctx.textAlign = "right"; + ctx.textBaseline = "middle"; + ctx.fillText(name, PIANO_WIDTH - 3, y + h / 2); + } + } + } + + // Piano key border + ctx.strokeStyle = "#333"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(PIANO_WIDTH, 0); + ctx.lineTo(PIANO_WIDTH, height); + ctx.stroke(); + + // --- 2. Vertical grid lines (beats) --- + if (state.bpm > 0) { + const beatDuration = 60 / state.bpm; + const beatsPerBar = state.timeSignature[0]; + const startTime = viewport.scrollX; + const endTime = startTime + (width - PIANO_WIDTH) / viewport.pixelsPerSecond; + const firstBeat = Math.floor(startTime / beatDuration); + const lastBeat = Math.ceil(endTime / beatDuration); + + for (let b = firstBeat; b <= lastBeat; b++) { + const t = b * beatDuration; + const x = projectTimeToX(t, viewport); + if (x < PIANO_WIDTH || x > width) continue; + const isBar = b % beatsPerBar === 0; + ctx.strokeStyle = isBar ? "#3a3a3a" : "#262626"; + ctx.lineWidth = isBar ? 1 : 0.5; + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, height); + ctx.stroke(); + } + } + + // --- 2.5. Waveform RMS overlay (amplitude envelope behind notes) --- + if (state.renderCoverage.length > 0) { + for (const range of state.renderCoverage) { + if (range.state === "hq_ready") continue; + const x1 = timeToX(range.startTime, viewport); + const x2 = timeToX(range.endTime, viewport); + if (x2 < PIANO_WIDTH || x1 > width) continue; + ctx.fillStyle = range.state === "preview_ready" + ? "rgba(56, 189, 248, 0.12)" + : "rgba(245, 158, 11, 0.10)"; + ctx.fillRect(Math.max(PIANO_WIDTH, x1), 0, Math.max(0, Math.min(width, x2) - Math.max(PIANO_WIDTH, x1)), height); + + ctx.strokeStyle = range.state === "preview_ready" + ? "rgba(56, 189, 248, 0.22)" + : "rgba(245, 158, 11, 0.18)"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x1, 0); + ctx.lineTo(x1, height); + ctx.moveTo(x2, 0); + ctx.lineTo(x2, height); + ctx.stroke(); + } + } + + if (state.contour?.frames.rms && state.contour.frames.rms.length > 0) { + const { times, rms, midi: rmsMidi, confidence: rmsConf } = state.contour.frames; + const startTime = viewport.scrollX - viewport.clipStartTime; + const endTime = startTime + (width - PIANO_WIDTH) / viewport.pixelsPerSecond; + // Find the max RMS for normalization + let maxRms = 0; + for (const val of rms) { + if (val > maxRms) maxRms = val; + } + if (maxRms > 0) { + ctx.save(); + ctx.globalAlpha = 0.12; + ctx.fillStyle = "#60a5fa"; // blue tint + // Draw RMS bars aligned to pitch position + for (let i = 0; i < times.length; i++) { + const t = times[i]; + if (t < startTime - 0.1 || t > endTime + 0.1) continue; + if (rmsConf[i] < 0.1 || rmsMidi[i] <= 0) continue; // skip unvoiced/silent + const x = timeToX(t, viewport); + if (x < PIANO_WIDTH || x > width) continue; + const normalizedRms = rms[i] / maxRms; + const barHeight = normalizedRms * viewport.pixelsPerSemitone * 3; // scale to ~3 semitones max height + const y = midiToY(rmsMidi[i], viewport, height); + const nextT = i + 1 < times.length ? times[i + 1] : t + 0.01; + const barWidth = Math.max(1, (nextT - t) * viewport.pixelsPerSecond); + ctx.fillRect(x, y - barHeight / 2, barWidth, barHeight); + } + ctx.restore(); + } + } + + // --- 3. Pitch contour (raw detected pitch) --- + if (state.contour && state.contour.frames.times.length > 0) { + const { times, midi: midiArr, confidence: confArr } = state.contour.frames; + ctx.beginPath(); + ctx.strokeStyle = "rgba(245, 158, 11, 0.35)"; // amber, semi-transparent + ctx.lineWidth = 1.5; + let started = false; + for (let i = 0; i < times.length; i++) { + const midiVal = midiArr[i]; + const conf = confArr[i]; + if (midiVal <= 0 || conf < 0.3) { + started = false; + continue; + } + const x = timeToX(times[i], viewport); + const y = midiToY(midiVal, viewport, height); + if (x < PIANO_WIDTH - 10 || x > width + 10) { started = false; continue; } + if (!started) { + ctx.moveTo(x, y); + started = true; + } else { + ctx.lineTo(x, y); + } + } + ctx.stroke(); + } + + // --- 3.5. Corrected pitch overlay (white line — shows where notes will actually play) --- + if (!state.polyMode && state.notes.length > 0 && state.contour && state.contour.frames.times.length > 0) { + const { times, midi: midiArr, confidence: confArr } = state.contour.frames; + ctx.beginPath(); + ctx.strokeStyle = "rgba(255, 255, 255, 0.65)"; + ctx.lineWidth = 1.5; + let corrStarted = false; + for (let i = 0; i < times.length; i++) { + const rawMidi = midiArr[i]; + const conf = confArr[i]; + if (rawMidi <= 0 || conf < 0.3) { corrStarted = false; continue; } + const frameTime = times[i]; + // Find which note this frame falls within + const note = state.notes.find(n => frameTime >= n.startTime && frameTime <= n.endTime); + if (note === undefined) { corrStarted = false; continue; } + // Shift the raw pitch by the correction applied to this note + const correctedMidi = rawMidi + (note.correctedPitch - note.detectedPitch); + const x = timeToX(frameTime, viewport); + const y = midiToY(correctedMidi, viewport, height); + if (x < PIANO_WIDTH - 10 || x > width + 10) { corrStarted = false; continue; } + if (corrStarted) { ctx.lineTo(x, y); } else { ctx.moveTo(x, y); corrStarted = true; } + } + ctx.stroke(); + } + + // --- 3.6. Pitch salience heatmap (polyphonic mode) --- + if (state.polyMode && state.showPitchSalience && state.pitchSalience && state.pitchSalience.length > 0) { + const salience = state.pitchSalience; + const dsFactor = state.salienceDownsampleFactor || 1; + const hopSec = (state.salienceHopSize || 256) / (state.salienceSampleRate || 22050); + const frameTimeSec = hopSec * dsFactor; + const SALIENCE_BINS = salience[0]?.length || 264; + // 264 bins = 88 notes * 3 (1/3 semitone), MIDI 21-108 + const MIDI_LOW = 21; + const binsPerSemitone = 3; + + ctx.globalAlpha = 0.3; + for (let f = 0; f < salience.length; f++) { + const t = f * frameTimeSec; + const x = timeToX(t, viewport); + const xNext = timeToX(t + frameTimeSec, viewport); + if (xNext < PIANO_WIDTH || x > width) continue; + const fw = Math.max(1, xNext - x); + + for (let b = 0; b < SALIENCE_BINS; b++) { + const val = salience[f][b]; + if (val < 20) continue; // skip low energy + const midi = MIDI_LOW + b / binsPerSemitone; + const y = midiToY(midi + 0.5 / binsPerSemitone, viewport, height); + const bh = viewport.pixelsPerSemitone / binsPerSemitone; + if (y + bh < 0 || y > height) continue; + // Color: yellow-orange gradient based on intensity + const intensity = val / 255; + const r2 = Math.round(255 * intensity); + const g = Math.round(180 * intensity); + ctx.fillStyle = `rgb(${r2},${g},0)`; + ctx.fillRect(x, y, fw, Math.max(1, bh)); + } + } + ctx.globalAlpha = 1; + } + + // --- 4. Note blocks (monophonic or polyphonic) --- + // 12 hue-based colors for pitch classes in poly mode (rainbow mapping) + const POLY_NOTE_COLORS = [ + [220, 50, 50], // C — red + [220, 100, 50], // C# — red-orange + [220, 160, 30], // D — orange + [180, 180, 30], // D# — yellow + [80, 180, 50], // E — yellow-green + [40, 180, 80], // F — green + [30, 180, 160], // F# — teal + [40, 140, 200], // G — blue + [60, 100, 220], // G# — indigo + [120, 70, 220], // A — purple + [180, 50, 200], // A# — magenta + [220, 50, 150], // B — pink + ]; + + const notesToRender: UnifiedNoteData[] = + state.polyMode + ? state.polyNotes.map(polyToUnified) + : state.notes.map(n => monoToUnified(n)); + + for (const note of notesToRender) { + const x1 = timeToX(note.startTime, viewport); + const x2 = timeToX(note.endTime, viewport); + if (x2 < PIANO_WIDTH || x1 > width) continue; // off-screen + + const y = midiToY(note.correctedPitch + 0.5, viewport, height); + const h = viewport.pixelsPerSemitone; + const w = x2 - x1; + const isSelected = state.selectedNoteIds.includes(note.id); + const isHovered = state.hoveredNoteId === note.id; + const isShifted = Math.abs(note.correctedPitch - note.detectedPitch) > 0.1; + const isUnvoiced = note.voiced === false; + + // Note fill — poly mode uses pitch-class rainbow colors with confidence-based opacity + if (note.isPoly) { + const pitchClass = ((Math.round(note.detectedPitch) % 12) + 12) % 12; + const [cr, cg, cb] = POLY_NOTE_COLORS[pitchClass]; + const confAlpha = 0.3 + (note.confidence ?? 0.5) * 0.55; // 0.3–0.85 based on confidence + if (isSelected) { + ctx.fillStyle = `rgba(${cr}, ${cg}, ${cb}, ${Math.min(1, confAlpha + 0.2)})`; + } else if (isShifted) { + // Slightly brighter when shifted + ctx.fillStyle = `rgba(${Math.min(255, cr + 30)}, ${Math.min(255, cg + 30)}, ${Math.min(255, cb + 30)}, ${confAlpha})`; + } else { + ctx.fillStyle = `rgba(${cr}, ${cg}, ${cb}, ${confAlpha})`; + } + } else { + const alpha = 0.75; + if (isUnvoiced) { + ctx.fillStyle = `rgba(100, 100, 100, 0.4)`; // gray for unvoiced + } else if (isSelected) { + ctx.fillStyle = `rgba(59, 130, 246, ${alpha})`; // blue + } else if (isShifted) { + ctx.fillStyle = `rgba(245, 158, 11, ${alpha})`; // amber + } else { + ctx.fillStyle = `rgba(34, 197, 94, ${alpha})`; // green + } + } + + // Rounded rect + const r = Math.min(3, h / 4, w / 4); + ctx.beginPath(); + ctx.moveTo(x1 + r, y); + ctx.lineTo(x2 - r, y); + ctx.quadraticCurveTo(x2, y, x2, y + r); + ctx.lineTo(x2, y + h - r); + ctx.quadraticCurveTo(x2, y + h, x2 - r, y + h); + ctx.lineTo(x1 + r, y + h); + ctx.quadraticCurveTo(x1, y + h, x1, y + h - r); + ctx.lineTo(x1, y + r); + ctx.quadraticCurveTo(x1, y, x1 + r, y); + ctx.closePath(); + ctx.fill(); + + // Diagonal hatching for unvoiced notes (Melodyne-style) + if (isUnvoiced && w > 4) { + ctx.save(); + ctx.clip(); // clip to the rounded rect path + ctx.strokeStyle = "rgba(150, 150, 150, 0.3)"; + ctx.lineWidth = 0.5; + const spacing = 6; + for (let hx = -h; hx < w + h; hx += spacing) { + ctx.beginPath(); + ctx.moveTo(x1 + hx, y); + ctx.lineTo(x1 + hx + h, y + h); + ctx.stroke(); + } + ctx.restore(); + } + + // Border — poly notes always get a thin border for overlap visibility + if (isSelected || isHovered) { + ctx.strokeStyle = isSelected ? "#60a5fa" : "#888"; + ctx.lineWidth = isSelected ? 1.5 : 1; + ctx.stroke(); + } else if (note.isPoly) { + const pitchClass = ((Math.round(note.detectedPitch) % 12) + 12) % 12; + const [br, bg, bb] = POLY_NOTE_COLORS[pitchClass]; + ctx.strokeStyle = `rgba(${br}, ${bg}, ${bb}, 0.6)`; + ctx.lineWidth = 0.75; + ctx.stroke(); + } + + // Pitch curves inside note blob (clipped to blob bounds) + if (w > 10 && !isUnvoiced) { + ctx.save(); + ctx.beginPath(); + ctx.rect(x1, y, w, h); + ctx.clip(); + + // Draw original pitch contour from frame data (faint) + if (state.contour && state.contour.frames.times.length > 0 && !state.polyMode) { + const { times, midi: midiArr, confidence: confArr } = state.contour.frames; + ctx.beginPath(); + ctx.strokeStyle = "rgba(255,255,255,0.15)"; + ctx.lineWidth = 1; + let started = false; + for (let fi = 0; fi < times.length; fi++) { + const ft = times[fi]; + if (ft < note.startTime - 0.01 || ft > note.endTime + 0.01) continue; + const rawMidi = midiArr[fi]; + if (rawMidi <= 0 || confArr[fi] < 0.3) { started = false; continue; } + const px = timeToX(ft, viewport); + const py = midiToY(rawMidi, viewport, height); + if (started) ctx.lineTo(px, py); else { ctx.moveTo(px, py); started = true; } + } + ctx.stroke(); + } + + // Draw corrected pitch contour (bold white — shows where audio will play) + if (state.contour && state.contour.frames.times.length > 0 && !state.polyMode) { + const { times, midi: midiArr, confidence: confArr } = state.contour.frames; + ctx.beginPath(); + ctx.strokeStyle = isSelected ? "rgba(255,255,255,0.6)" : "rgba(255,255,255,0.35)"; + ctx.lineWidth = 1.5; + let started = false; + const pitchShift = note.correctedPitch - note.detectedPitch; + for (let fi = 0; fi < times.length; fi++) { + const ft = times[fi]; + if (ft < note.startTime - 0.01 || ft > note.endTime + 0.01) continue; + const rawMidi = midiArr[fi]; + if (rawMidi <= 0 || confArr[fi] < 0.3) { started = false; continue; } + const corrMidi = rawMidi + pitchShift; + const px = timeToX(ft, viewport); + const py = midiToY(corrMidi, viewport, height); + if (started) ctx.lineTo(px, py); else { ctx.moveTo(px, py); started = true; } + } + ctx.stroke(); + } + + // Fallback: draw drift curve if no contour data available + if ((state.contour === null || state.polyMode) && note.pitchDrift && note.pitchDrift.length > 1) { + ctx.beginPath(); + ctx.strokeStyle = isSelected ? "rgba(255,255,255,0.4)" : "rgba(255,255,255,0.2)"; + ctx.lineWidth = 1; + for (let i = 0; i < note.pitchDrift.length; i++) { + const t = note.startTime + (i / (note.pitchDrift.length - 1)) * (note.endTime - note.startTime); + const px = timeToX(t, viewport); + const drift = note.pitchDrift[i]; + const py = midiToY(note.correctedPitch + drift, viewport, height); + if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py); + } + ctx.stroke(); + } + + ctx.restore(); + } + + // Note name label inside block (if wide enough) + if (w > 30 && h > 10) { + const noteClass = Math.round(note.correctedPitch) % 12; + const octave = Math.floor(Math.round(note.correctedPitch) / 12) - 1; + const label = NOTE_NAMES[noteClass < 0 ? noteClass + 12 : noteClass] + octave; + ctx.fillStyle = "rgba(255,255,255,0.7)"; + ctx.font = `${Math.min(10, h - 2)}px Inter, system-ui, sans-serif`; + ctx.textAlign = "left"; + ctx.textBaseline = "middle"; + ctx.fillText(label, x1 + 4, y + h / 2); + } + + // Smart control handles on hover (VariAudio-style) + if (isHovered && w > 30 && h > 8 && !isUnvoiced) { + const hs = 5; // handle size + const handleColor = "rgba(255,255,255,0.6)"; + const midX = x1 + w / 2; + + // Top-center: straighten/vibrato (triangle up) + ctx.fillStyle = handleColor; + ctx.beginPath(); + ctx.moveTo(midX - hs, y + hs + 1); + ctx.lineTo(midX, y + 1); + ctx.lineTo(midX + hs, y + hs + 1); + ctx.closePath(); + ctx.fill(); + + // Bottom-left: formant (small diamond) + ctx.beginPath(); + const blx = x1 + hs + 2; + const bly = y + h - hs - 1; + ctx.moveTo(blx, bly - hs / 2); + ctx.lineTo(blx + hs / 2, bly); + ctx.lineTo(blx, bly + hs / 2); + ctx.lineTo(blx - hs / 2, bly); + ctx.closePath(); + ctx.fill(); + + // Bottom-right: gain (small square) + const brx = x2 - hs - 4; + const bry = y + h - hs - 2; + ctx.fillRect(brx, bry, hs, hs); + } + } + + // --- 4.5. Transition curves between adjacent notes --- + if (!state.polyMode) { + const monoNotes = state.notes; + for (let i = 0; i < monoNotes.length - 1; i++) { + const n1 = monoNotes[i]; + const n2 = monoNotes[i + 1]; + const gap = n2.startTime - n1.endTime; + if (gap > 0.1) continue; // too far apart + const hasTransition = n1.transitionOut > 0 || n2.transitionIn > 0; + if (!hasTransition) continue; + + const transMs = Math.max(n1.transitionOut, n2.transitionIn); + const transSec = transMs / 1000; + const x1 = timeToX(n1.endTime - transSec / 2, viewport); + const x2 = timeToX(n2.startTime + transSec / 2, viewport); + const y1 = midiToY(n1.correctedPitch, viewport, height); + const y2 = midiToY(n2.correctedPitch, viewport, height); + + if (x2 < PIANO_WIDTH || x1 > width) continue; + + ctx.beginPath(); + ctx.strokeStyle = "rgba(245, 158, 11, 0.6)"; + ctx.lineWidth = 2; + const midX = (x1 + x2) / 2; + ctx.moveTo(x1, y1); + ctx.bezierCurveTo(midX, y1, midX, y2, x2, y2); + ctx.stroke(); + } + } + + // --- 5. Playhead (currentTime is in project time, not clip-relative) --- + if (state.currentTime >= 0) { + const px = projectTimeToX(state.currentTime, viewport); + if (px >= PIANO_WIDTH && px <= width) { + ctx.strokeStyle = "#fff"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(px, 0); + ctx.lineTo(px, height); + ctx.stroke(); + } + } + + ctx.restore(); +} + +/** + * Lightweight playhead-only render for the RAF loop during playback. + * Blits a pre-rendered static canvas (grid, notes, contour) and draws + * only the playhead line on top — avoids redrawing the entire canvas at 60fps. + */ +export function renderPlayheadOverlay( + ctx: CanvasRenderingContext2D, + width: number, + height: number, + staticCanvas: HTMLCanvasElement | OffscreenCanvas, + currentTime: number, + viewport: PitchEditorViewport, +) { + const dpr = globalThis.window?.devicePixelRatio || 1; + ctx.clearRect(0, 0, width * dpr, height * dpr); + ctx.drawImage(staticCanvas as any, 0, 0); + + // Draw playhead + if (currentTime >= 0) { + ctx.save(); + ctx.scale(dpr, dpr); + const px = projectTimeToX(currentTime, viewport); + if (px >= PIANO_WIDTH && px <= width) { + ctx.strokeStyle = "#fff"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(px, 0); + ctx.lineTo(px, height); + ctx.stroke(); + } + ctx.restore(); + } +} diff --git a/frontend/src/components/PitchEditorLowerZone.tsx b/frontend/src/components/PitchEditorLowerZone.tsx new file mode 100644 index 0000000..6a70241 --- /dev/null +++ b/frontend/src/components/PitchEditorLowerZone.tsx @@ -0,0 +1,1167 @@ +import React, { useRef, useEffect, useCallback, useState, useMemo } from "react"; +import { useShallow } from "zustand/shallow"; +import { useDAWStore } from "../store/useDAWStore"; +import { usePitchEditorStore, PitchEditorTool, PitchSnapMode } from "../store/pitchEditorStore"; +import { GripHorizontal, X, Scissors, MousePointer, Activity, Waves, ChevronRight, Pencil } from "lucide-react"; +import { NoteInspector } from "./NoteInspector"; +import { CorrectPitchModal } from "./CorrectPitchModal"; +import { + renderPitchEditor, + renderPlayheadOverlay, + hitTestNote, + xToTime, + yToMidi, + PIANO_WIDTH, + type PitchEditorViewport, + type PitchEditorRenderState, +} from "./PitchEditorCanvas"; + +const MIN_PPS = 1; +const MAX_PPS = 1000; +const ZOOM_SENSITIVITY = 0.0015; + +const TOOL_DEFS: { id: PitchEditorTool; label: string; key: string; icon: React.ReactNode; title: string }[] = [ + { id: "select", label: "Select", key: "1", icon: , title: "Select & move notes (1)" }, + { id: "drift", label: "Drift", key: "2", icon: , title: "Edit pitch drift (2)" }, + { id: "vibrato", label: "Vibrato", key: "3", icon: , title: "Edit vibrato (3)" }, + { id: "transition", label: "Trans", key: "4", icon: , title: "Edit transitions (4)" }, + { id: "draw", label: "Draw", key: "5", icon: , title: "Draw pitch curve on note (5)" }, + { id: "split", label: "Split", key: "6", icon: , title: "Split note at click (6)" }, +]; + +const SNAP_MODES: { id: PitchSnapMode; label: string }[] = [ + { id: "off", label: "Off" }, + { id: "chromatic", label: "Chromatic" }, + { id: "scale", label: "Scale" }, +]; + +const NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; + +function formatNoteName(midi: number): string { + const noteClass = Math.round(midi) % 12; + const octave = Math.floor(Math.round(midi) / 12) - 1; + const cents = Math.round((midi - Math.round(midi)) * 100); + const name = NOTE_NAMES[noteClass < 0 ? noteClass + 12 : noteClass]; + return `${name}${octave}${cents !== 0 ? ` ${cents > 0 ? "+" : ""}${cents}¢` : ""}`; +} + +// Click-to-audition: play a short sine tone at a MIDI pitch via Web Audio API +let _auditionCtx: AudioContext | null = null; +function auditionNote(midiPitch: number, durationMs = 200) { + _auditionCtx ??= new AudioContext(); + const ctx = _auditionCtx; + const freq = 440 * Math.pow(2, (midiPitch - 69) / 12); + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = "sine"; + osc.frequency.value = freq; + gain.gain.setValueAtTime(0.15, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + durationMs / 1000); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(); + osc.stop(ctx.currentTime + durationMs / 1000 + 0.05); +} + +export function PitchEditorLowerZone() { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const rafRef = useRef(0); + // Offscreen canvas caches the static content (grid, notes, contour) so the + // playback RAF loop only needs to blit + draw playhead, not redraw everything. + const staticCanvasRef = useRef(null); + // Track the scrollX that the static cache was rendered at, so we can invalidate + // when auto-scroll during playback changes the viewport. + const staticCacheScrollXRef = useRef(-1); + const [canvasSize, setCanvasSize] = useState({ width: 800, height: 300 }); + + // DAW store + const { + pixelsPerSecond, scrollX: dawScrollX, lowerZoneHeight, tcpWidth, + pitchEditorTrackId, pitchEditorClipId, + setLowerZoneHeight, closePitchEditor, setScroll, setZoom, + } = useDAWStore( + useShallow((s) => ({ + pixelsPerSecond: s.pixelsPerSecond, + scrollX: s.scrollX, + lowerZoneHeight: s.lowerZoneHeight, + tcpWidth: s.tcpWidth, + pitchEditorTrackId: s.pitchEditorTrackId, + pitchEditorClipId: s.pitchEditorClipId, + setLowerZoneHeight: s.setLowerZoneHeight, + closePitchEditor: s.closePitchEditor, + setScroll: s.setScroll, + setZoom: s.setZoom, + })) + ); + + const clipInfo = useDAWStore( + useShallow((s) => { + if (!s.pitchEditorTrackId || !s.pitchEditorClipId) return null; + const track = s.tracks.find((t) => t.id === s.pitchEditorTrackId); + if (!track) return null; + const clip = track.clips.find((c) => c.id === s.pitchEditorClipId); + if (!clip) return null; + return { + startTime: clip.startTime, + duration: clip.duration, + trackName: track.name, + clipName: clip.name || clip.filePath?.split(/[\\/]/).pop() || "Clip", + }; + }) + ); + + // Keep clipInfo in a ref so event handlers always read the latest value without stale closures. + const clipInfoRef = useRef(clipInfo); + useEffect(() => { clipInfoRef.current = clipInfo; }, [clipInfo]); + + const currentTime = useDAWStore((s) => s.transport.currentTime); + const isPlaying = useDAWStore((s) => s.transport.isPlaying); + const bpm = useDAWStore((s) => s.transport.tempo); + const timeSignature = useDAWStore((s) => s.timeSignature); + + const { + contour, notes, isAnalyzing, selectedNoteIds, tool, snapMode, progressPercent, progressLabel, + applyState, applyMessage, + scrollY, zoomY, + clipStartTime: pitchClipStartTime, clipDuration: pitchClipDuration, + analyze, setTool, setSnapMode, + selectNote, selectAll, deselectAll, + updateNote, commitNoteEdit, moveSelectedPitch, splitNote, + correctSelectedToScale, correctAllToScale, + undo, redo, pushUndo, + setScrollY, + undoStack, redoStack, + polyMode, polyNotes, polyAnalysisResult, showPitchSalience, + togglePolyMode, analyzePolyphonic, moveSelectedPolyPitch, + applyPolyCorrection, togglePitchSalience, + polyNoteThreshold, polyOnsetThreshold, polyMinDuration, + setPolyNoteThreshold, setPolyOnsetThreshold, setPolyMinDuration, + scaleKey, scaleType, scaleNotes, setScale, autoDetectScale, + mergeNotes, toggleCorrectPitchModal, + abCompareMode, toggleABCompare, + drawPitchOnNote, beginDrawPitch, commitDrawPitch, + globalFormantCents, setGlobalFormantCents, + renderCoverage, + } = usePitchEditorStore( + useShallow((s) => ({ + contour: s.contour, notes: s.notes, isAnalyzing: s.isAnalyzing, progressPercent: s.progressPercent, progressLabel: s.progressLabel, + applyState: s.applyState, applyMessage: s.applyMessage, + selectedNoteIds: s.selectedNoteIds, tool: s.tool, snapMode: s.snapMode, + scrollY: s.scrollY, zoomY: s.zoomY, + clipStartTime: s.clipStartTime, clipDuration: s.clipDuration, + analyze: s.analyze, setTool: s.setTool, setSnapMode: s.setSnapMode, + selectNote: s.selectNote, selectAll: s.selectAll, deselectAll: s.deselectAll, + updateNote: s.updateNote, commitNoteEdit: s.commitNoteEdit, moveSelectedPitch: s.moveSelectedPitch, + splitNote: s.splitNote, + correctSelectedToScale: s.correctSelectedToScale, correctAllToScale: s.correctAllToScale, + undo: s.undo, redo: s.redo, pushUndo: s.pushUndo, + setScrollY: s.setScrollY, + undoStack: s.undoStack, redoStack: s.redoStack, + polyMode: s.polyMode, polyNotes: s.polyNotes, + polyAnalysisResult: s.polyAnalysisResult, showPitchSalience: s.showPitchSalience, + togglePolyMode: s.togglePolyMode, analyzePolyphonic: s.analyzePolyphonic, + moveSelectedPolyPitch: s.moveSelectedPolyPitch, + applyPolyCorrection: s.applyPolyCorrection, togglePitchSalience: s.togglePitchSalience, + polyNoteThreshold: s.polyNoteThreshold, polyOnsetThreshold: s.polyOnsetThreshold, + polyMinDuration: s.polyMinDuration, + setPolyNoteThreshold: s.setPolyNoteThreshold, setPolyOnsetThreshold: s.setPolyOnsetThreshold, + setPolyMinDuration: s.setPolyMinDuration, + scaleKey: s.scaleKey, scaleType: s.scaleType, scaleNotes: s.scaleNotes, + setScale: s.setScale, autoDetectScale: s.autoDetectScale, + mergeNotes: s.mergeNotes, toggleCorrectPitchModal: s.toggleCorrectPitchModal, + abCompareMode: s.abCompareMode, toggleABCompare: s.toggleABCompare, + drawPitchOnNote: s.drawPitchOnNote, beginDrawPitch: s.beginDrawPitch, commitDrawPitch: s.commitDrawPitch, + globalFormantCents: s.globalFormantCents, setGlobalFormantCents: s.setGlobalFormantCents, + renderCoverage: s.renderCoverage, + })) + ); + + const applyStateClassName = + applyState === "error" ? "text-red-400 bg-red-500/10 border-red-500/30" + : applyState === "done" ? "text-emerald-300 bg-emerald-500/10 border-emerald-500/30" + : applyState === "preview_ready" ? "text-sky-300 bg-sky-500/10 border-sky-500/30" + : applyState === "queued" || applyState === "processing" || applyState === "preview_processing" || applyState === "final_processing" + ? "text-daw-accent bg-daw-accent/10 border-daw-accent/30" + : "text-neutral-500 bg-neutral-800/80 border-neutral-700"; + const formantStatusHint = globalFormantCents !== 0 + ? ( + applyState === "preview_processing" + ? "Rendering preview near playhead..." + : applyState === "preview_ready" || applyState === "final_processing" || applyState === "done" + ? "Previewing rendered formant" + : "Rendered formant preview" + ) + : "Clip-wide timbre shift"; + + const [hoveredNoteId, setHoveredNoteId] = useState(null); + const [dragState, setDragState] = useState<{ + type: "move" | "pitch" | "resize-left" | "resize-right" | "straighten" | "formant" | "gain" | "drift" | "transition-in" | "transition-out" | "transition-both" | "draw"; + noteId: string; + startMouseY: number; + origPitch: number; + origStart: number; + origEnd: number; + origValue: number; // original vibratoDepth/formantShift/gain for smart controls + } | null>(null); + // Clear analyzed range tracking when clip changes + const analyzedRangesRef = useRef>([]); + useEffect(() => { + analyzedRangesRef.current = []; + }, [pitchEditorClipId]); + + // Auto-analyze on mount + useEffect(() => { + if (pitchEditorTrackId && pitchEditorClipId && !contour && !isAnalyzing) { + // Record initial range so scroll-based re-analyze won't duplicate it + analyzedRangesRef.current = [[0, 30]]; + analyze(); + } + }, [pitchEditorTrackId, pitchEditorClipId, contour, isAnalyzing, analyze]); + + // Re-analyze when scrolling into unanalyzed territory (debounced) + const analyzeTimeoutRef = useRef | null>(null); + useEffect(() => { + if (!pitchEditorTrackId || !pitchEditorClipId || isAnalyzing || !contour || !clipInfo) return; + + // Never re-analyze if the user has made manual edits — it would wipe splits/moves. + if (undoStack.length > 0) return; + + const visibleStartProject = dawScrollX / pixelsPerSecond; + const visibleStart = visibleStartProject - clipInfo.startTime; + const visibleEnd = visibleStart + canvasSize.width / pixelsPerSecond; + const clipStart = Math.max(0, visibleStart); + const clipEnd = Math.min(clipInfo.duration, visibleEnd); + if (clipEnd <= clipStart) return; + + // Only re-analyze if this range hasn't been analyzed (2s tolerance) + const alreadyAnalyzed = analyzedRangesRef.current.some( + ([s, e]) => clipStart >= s - 2 && clipEnd <= e + 2 + ); + if (alreadyAnalyzed) return; + + if (analyzeTimeoutRef.current) clearTimeout(analyzeTimeoutRef.current); + analyzeTimeoutRef.current = setTimeout(() => { + analyzedRangesRef.current.push([clipStart, clipEnd]); + analyze(clipStart, clipEnd); + }, 500); + + return () => { + if (analyzeTimeoutRef.current) clearTimeout(analyzeTimeoutRef.current); + }; + }, [dawScrollX, pixelsPerSecond, canvasSize.width, pitchEditorTrackId, pitchEditorClipId, isAnalyzing, contour, clipInfo, analyze, undoStack]); + + // Resize observer + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const ro = new ResizeObserver((entries) => { + const { width, height } = entries[0].contentRect; + setCanvasSize({ width: Math.max(100, width), height: Math.max(50, height) }); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + // Viewport — scrollX in seconds for canvas coordinate math + const scrollXSeconds = dawScrollX / pixelsPerSecond; + const viewport: PitchEditorViewport = useMemo(() => ({ + scrollX: scrollXSeconds, + scrollY, + pixelsPerSecond, + pixelsPerSemitone: zoomY, + clipStartTime: pitchClipStartTime, + clipDuration: pitchClipDuration, + }), [scrollXSeconds, scrollY, pixelsPerSecond, zoomY, pitchClipStartTime, pitchClipDuration]); + + // Build render state (avoids object recreation in RAF) + const buildRenderState = useCallback(( + st: ReturnType, + daw: ReturnType + ): PitchEditorRenderState => ({ + notes: st.notes, + contour: st.contour, + selectedNoteIds: st.selectedNoteIds, + hoveredNoteId: null, + currentTime: daw.transport.currentTime, + isPlaying: daw.transport.isPlaying, + bpm: daw.transport.tempo, + timeSignature: [daw.timeSignature.numerator, daw.timeSignature.denominator], + scaleNotes: st.scaleNotes, + scaleKey: st.scaleKey, + polyMode: st.polyMode, + polyNotes: st.polyNotes, + showPitchSalience: st.showPitchSalience, + pitchSalience: st.polyAnalysisResult?.pitchSalience ?? null, + salienceDownsampleFactor: st.polyAnalysisResult?.salienceDownsampleFactor ?? 1, + salienceHopSize: st.polyAnalysisResult?.hopSize ?? 256, + salienceSampleRate: st.polyAnalysisResult?.sampleRate ?? 22050, + renderCoverage: st.renderCoverage, + }), []); + + // Static render — draws grid, notes, contour to both the visible canvas AND an + // offscreen cache. During playback the RAF loop blits the cache + draws playhead + // only, avoiding a full redraw at 60fps. + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const dpr = window.devicePixelRatio || 1; + canvas.width = canvasSize.width * dpr; + canvas.height = canvasSize.height * dpr; + canvas.style.width = `${canvasSize.width}px`; + canvas.style.height = `${canvasSize.height}px`; + + const renderState: PitchEditorRenderState = { + notes, contour, selectedNoteIds, + hoveredNoteId, + currentTime, isPlaying, bpm, + timeSignature: [timeSignature.numerator, timeSignature.denominator], + scaleNotes, scaleKey, + polyMode, polyNotes, showPitchSalience, + pitchSalience: polyAnalysisResult?.pitchSalience ?? null, + salienceDownsampleFactor: polyAnalysisResult?.salienceDownsampleFactor ?? 1, + salienceHopSize: polyAnalysisResult?.hopSize ?? 256, + salienceSampleRate: polyAnalysisResult?.sampleRate ?? 22050, + renderCoverage, + }; + renderPitchEditor(ctx, canvasSize.width, canvasSize.height, viewport, renderState); + + // Cache to offscreen canvas for playback RAF overlay + const offscreen = new OffscreenCanvas(canvas.width, canvas.height); + const offCtx = offscreen.getContext("2d"); + if (offCtx) { + offCtx.drawImage(canvas, 0, 0); + } + staticCanvasRef.current = offscreen; + }, [canvasSize, viewport, notes, contour, selectedNoteIds, hoveredNoteId, currentTime, isPlaying, bpm, timeSignature, scaleNotes, scaleKey, polyMode, polyNotes, showPitchSalience, polyAnalysisResult, renderCoverage]); + + // Playhead RAF — during playback, blit cached static canvas + draw playhead only. + // This avoids a full renderPitchEditor() (grid, notes, contour) at 60fps. + // The static cache is re-rendered by the effect above when state changes (scroll, zoom, edits). + useEffect(() => { + if (!isPlaying) return; + const clipStart = clipInfo?.startTime ?? 0; + const clipDur = clipInfo?.duration ?? 0; + + // Render static content once at playback start so the cache is fresh + const canvas = canvasRef.current; + if (canvas) { + const ctx = canvas.getContext("2d"); + if (ctx) { + const st = usePitchEditorStore.getState(); + const daw = useDAWStore.getState(); + const vp: PitchEditorViewport = { + scrollX: daw.scrollX / daw.pixelsPerSecond, + scrollY: st.scrollY, + pixelsPerSecond: daw.pixelsPerSecond, + pixelsPerSemitone: st.zoomY, + clipStartTime: clipStart, + clipDuration: clipDur, + }; + renderPitchEditor(ctx, canvasSize.width, canvasSize.height, vp, buildRenderState(st, daw)); + // Update cache + const offscreen = new OffscreenCanvas(canvas.width, canvas.height); + const offCtx = offscreen.getContext("2d"); + if (offCtx) offCtx.drawImage(canvas, 0, 0); + staticCanvasRef.current = offscreen; + staticCacheScrollXRef.current = daw.scrollX; + } + } + + const animate = () => { + const cvs = canvasRef.current; + if (!cvs) return; + const ctx = cvs.getContext("2d"); + if (!ctx) return; + + const daw = useDAWStore.getState(); + const st = usePitchEditorStore.getState(); + const currentScrollX = daw.scrollX; + const vp: PitchEditorViewport = { + scrollX: currentScrollX / daw.pixelsPerSecond, + scrollY: st.scrollY, + pixelsPerSecond: daw.pixelsPerSecond, + pixelsPerSemitone: st.zoomY, + clipStartTime: clipStart, + clipDuration: clipDur, + }; + + // If viewport scrolled (auto-scroll during playback), invalidate static cache + if (Math.abs(currentScrollX - staticCacheScrollXRef.current) > 0.5) { + renderPitchEditor(ctx, canvasSize.width, canvasSize.height, vp, buildRenderState(st, daw)); + const offscreen = new OffscreenCanvas(cvs.width, cvs.height); + const offCtx = offscreen.getContext("2d"); + if (offCtx) offCtx.drawImage(cvs, 0, 0); + staticCanvasRef.current = offscreen; + staticCacheScrollXRef.current = currentScrollX; + } else if (staticCanvasRef.current) { + // Fast path: blit cached content + playhead only + renderPlayheadOverlay(ctx, canvasSize.width, canvasSize.height, + staticCanvasRef.current, daw.transport.currentTime, vp); + } else { + // Fallback: full render + renderPitchEditor(ctx, canvasSize.width, canvasSize.height, vp, buildRenderState(st, daw)); + } + + rafRef.current = requestAnimationFrame(animate); + }; + + rafRef.current = requestAnimationFrame(animate); + return () => cancelAnimationFrame(rafRef.current); + }, [isPlaying, canvasSize, clipInfo, buildRenderState]); + + // Wheel handler — attached imperatively with passive:false so preventDefault works + const pixelsPerSecondRef = useRef(pixelsPerSecond); + const dawScrollXRef = useRef(dawScrollX); + const scrollYRef = useRef(scrollY); + const zoomYRef = useRef(zoomY); + pixelsPerSecondRef.current = pixelsPerSecond; + dawScrollXRef.current = dawScrollX; + scrollYRef.current = scrollY; + zoomYRef.current = zoomY; + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const handler = (e: WheelEvent) => { + e.preventDefault(); + const curPps = pixelsPerSecondRef.current; + const curScrollX = dawScrollXRef.current; + const curScrollY = scrollYRef.current; + const curZoomY = zoomYRef.current; + const daw = useDAWStore.getState(); + + if (e.ctrlKey || e.metaKey) { + // Horizontal zoom — mirrors Timeline.tsx exactly + const rect = canvas.getBoundingClientRect(); + const cursorX = e.clientX - rect.left; + const factor = Math.exp(-e.deltaY * ZOOM_SENSITIVITY); + const newPps = Math.max(MIN_PPS, Math.min(MAX_PPS, curPps * factor)); + const timeAtCursor = (curScrollX + cursorX) / curPps; + const newScrollX = Math.max(0, timeAtCursor * newPps - cursorX); + setZoom(newPps); + setScroll(newScrollX, daw.scrollY); + } else if (e.shiftKey) { + // Horizontal scroll — same scrollSpeed as Timeline + const newScrollX = Math.max(0, curScrollX + e.deltaY * 2); + setScroll(newScrollX, daw.scrollY); + } else { + // Vertical pitch scroll + const delta = e.deltaY / curZoomY; + setScrollY(curScrollY - delta); + } + }; + + canvas.addEventListener("wheel", handler, { passive: false }); + return () => canvas.removeEventListener("wheel", handler); + }, [setZoom, setScroll, setScrollY]); + + // Mouse handlers + const handleMouseDown = useCallback((e: React.MouseEvent) => { + const rect = canvasRef.current?.getBoundingClientRect(); + if (!rect) return; + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + // Build fresh viewport from store state — no React closure issues. + const dawState = useDAWStore.getState(); + const pitchState = usePitchEditorStore.getState(); + const freshViewport: PitchEditorViewport = { + scrollX: dawState.scrollX / dawState.pixelsPerSecond, + scrollY: pitchState.scrollY, + pixelsPerSecond: dawState.pixelsPerSecond, + pixelsPerSemitone: pitchState.zoomY, + clipStartTime: pitchState.clipStartTime, + clipDuration: pitchState.clipDuration, + }; + const currentNotes = pitchState.notes; + + const hit = hitTestNote(x, y, currentNotes, freshViewport, canvasSize.height); + + // Alt+click: audition note (play sine tone at note pitch) + if (e.altKey && hit) { + const note = currentNotes.find(n => n.id === hit.noteId); + if (note) auditionNote(note.correctedPitch, 300); + selectNote(hit.noteId, e.ctrlKey || e.metaKey); + return; + } + + // Double-click splits a note regardless of active tool + if (e.detail === 2 && hit) { + const t = xToTime(x, freshViewport); + splitNote(hit.noteId, t); + return; + } + + if (hit) { + selectNote(hit.noteId, e.ctrlKey || e.metaKey); + const note = currentNotes.find((n) => n.id === hit.noteId); + if (!note) return; + + if (hit.edge === "top-center") { + pushUndo("Straighten vibrato"); + setDragState({ + type: "straighten", noteId: hit.noteId, startMouseY: y, + origPitch: note.correctedPitch, origStart: note.startTime, origEnd: note.endTime, + origValue: note.vibratoDepth, + }); + } else if (hit.edge === "bottom-left") { + pushUndo("Change formant"); + setDragState({ + type: "formant", noteId: hit.noteId, startMouseY: y, + origPitch: note.correctedPitch, origStart: note.startTime, origEnd: note.endTime, + origValue: note.formantShift, + }); + } else if (hit.edge === "bottom-right") { + pushUndo("Change gain"); + setDragState({ + type: "gain", noteId: hit.noteId, startMouseY: y, + origPitch: note.correctedPitch, origStart: note.startTime, origEnd: note.endTime, + origValue: note.gain, + }); + } else if (hit.edge === "body") { + // Tool-specific drag behavior + const currentTool = usePitchEditorStore.getState().tool; + if (currentTool === "vibrato") { + pushUndo("Adjust vibrato"); + setDragState({ + type: "straighten", noteId: hit.noteId, startMouseY: y, + origPitch: note.correctedPitch, origStart: note.startTime, origEnd: note.endTime, + origValue: note.vibratoDepth, + }); + } else if (currentTool === "drift") { + pushUndo("Adjust drift"); + setDragState({ + type: "drift", noteId: hit.noteId, startMouseY: y, + origPitch: note.correctedPitch, origStart: note.startTime, origEnd: note.endTime, + origValue: note.driftCorrectionAmount, + }); + } else if (currentTool === "pitch") { + // Pitch tool: same as move but with finer control (no snap) and visual feedback + pushUndo("Adjust pitch"); + setDragState({ + type: "pitch", noteId: hit.noteId, startMouseY: y, + origPitch: note.correctedPitch, origStart: note.startTime, origEnd: note.endTime, + origValue: note.correctedPitch, + }); + } else if (currentTool === "transition") { + // Transition tool on body: adjust both transitionIn and transitionOut simultaneously + pushUndo("Adjust transitions"); + setDragState({ + type: "transition-both", noteId: hit.noteId, startMouseY: y, + origPitch: note.correctedPitch, origStart: note.startTime, origEnd: note.endTime, + origValue: (note.transitionIn + note.transitionOut) / 2, + }); + } else if (currentTool === "split") { + const t = xToTime(x, freshViewport); + splitNote(hit.noteId, t); + return; + } else if (currentTool === "draw") { + beginDrawPitch(); + const clipTime = xToTime(x, freshViewport); + const midi = yToMidi(y, freshViewport, canvasSize.height); + drawPitchOnNote(hit.noteId, clipTime, midi); + setDragState({ + type: "draw", noteId: hit.noteId, startMouseY: y, + origPitch: note.correctedPitch, origStart: note.startTime, origEnd: note.endTime, + origValue: 0, + }); + } else { + pushUndo("Move note"); + setDragState({ + type: "move", noteId: hit.noteId, startMouseY: y, + origPitch: note.correctedPitch, origStart: note.startTime, origEnd: note.endTime, + origValue: 0, + }); + } + } else if (hit.edge === "left" || hit.edge === "right") { + const currentTool = usePitchEditorStore.getState().tool; + if (currentTool === "transition") { + pushUndo("Adjust transition"); + setDragState({ + type: hit.edge === "left" ? "transition-in" : "transition-out", + noteId: hit.noteId, startMouseY: y, + origPitch: note.correctedPitch, origStart: note.startTime, origEnd: note.endTime, + origValue: hit.edge === "left" ? note.transitionIn : note.transitionOut, + }); + } else { + pushUndo("Resize note"); + setDragState({ + type: hit.edge === "left" ? "resize-left" : "resize-right", + noteId: hit.noteId, startMouseY: y, + origPitch: note.correctedPitch, origStart: note.startTime, origEnd: note.endTime, + origValue: 0, + }); + } + } + } else { + deselectAll(); + } + }, [canvasSize.height, tool, selectNote, deselectAll, splitNote, pushUndo, beginDrawPitch, drawPitchOnNote]); + + const handleMouseMove = useCallback((e: React.MouseEvent) => { + const rect = canvasRef.current?.getBoundingClientRect(); + if (!rect) return; + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + // Build fresh viewport from store state — no React closure issues. + const dawState = useDAWStore.getState(); + const pitchState = usePitchEditorStore.getState(); + const freshViewport: PitchEditorViewport = { + scrollX: dawState.scrollX / dawState.pixelsPerSecond, + scrollY: pitchState.scrollY, + pixelsPerSecond: dawState.pixelsPerSecond, + pixelsPerSemitone: pitchState.zoomY, + clipStartTime: pitchState.clipStartTime, + clipDuration: pitchState.clipDuration, + }; + + if (dragState) { + const deltaY = -(y - dragState.startMouseY); + if (dragState.type === "move") { + const deltaSemitones = deltaY / freshViewport.pixelsPerSemitone; + let newPitch = dragState.origPitch + deltaSemitones; + if (snapMode === "chromatic") newPitch = Math.round(newPitch); + updateNote(dragState.noteId, { correctedPitch: newPitch }); + } else if (dragState.type === "resize-left") { + const t = xToTime(x, freshViewport); + if (t < dragState.origEnd - 0.02) updateNote(dragState.noteId, { startTime: t }); + } else if (dragState.type === "resize-right") { + const t = xToTime(x, freshViewport); + if (t > dragState.origStart + 0.02) updateNote(dragState.noteId, { endTime: t }); + } else if (dragState.type === "straighten") { + // Drag up = reduce vibrato (straighten), drag down = increase + const newDepth = Math.max(0, Math.min(2, dragState.origValue - deltaY / 100)); + updateNote(dragState.noteId, { vibratoDepth: newDepth }); + } else if (dragState.type === "formant") { + // Drag up = shift formants higher (cents). 1px ≈ 2 cents. + const deltaCents = -deltaY * 2; + const newCents = Math.max(-386, Math.min(386, Math.round(dragState.origValue * 100 + deltaCents))); + updateNote(dragState.noteId, { formantShift: newCents / 100 }); + } else if (dragState.type === "gain") { + // Drag up = louder, drag down = softer + const newGain = Math.max(-24, Math.min(24, dragState.origValue + deltaY / 5)); + updateNote(dragState.noteId, { gain: Math.round(newGain * 2) / 2 }); + } else if (dragState.type === "drift") { + // Drag up = less drift correction (preserve), drag down = more correction (straighten) + const newDrift = Math.max(0, Math.min(1, dragState.origValue - deltaY / 100)); + updateNote(dragState.noteId, { driftCorrectionAmount: newDrift }); + } else if (dragState.type === "pitch") { + // Pitch tool: finer control (0.1 semitone per 10px), no chromatic snap + const deltaSemitones = deltaY / (freshViewport.pixelsPerSemitone * 1.5); + const newPitch = dragState.origPitch + deltaSemitones; + updateNote(dragState.noteId, { correctedPitch: Math.round(newPitch * 10) / 10 }); + } else if (dragState.type === "transition-in") { + const newMs = Math.max(0, Math.min(200, dragState.origValue + deltaY / 2)); + updateNote(dragState.noteId, { transitionIn: Math.round(newMs) }); + } else if (dragState.type === "transition-out") { + const newMs = Math.max(0, Math.min(200, dragState.origValue + deltaY / 2)); + updateNote(dragState.noteId, { transitionOut: Math.round(newMs) }); + } else if (dragState.type === "transition-both") { + // Transition tool on body: adjust both in and out together + const newMs = Math.max(0, Math.min(200, dragState.origValue + deltaY / 2)); + updateNote(dragState.noteId, { transitionIn: Math.round(newMs), transitionOut: Math.round(newMs) }); + } else if (dragState.type === "draw") { + const clipTime = xToTime(x, freshViewport); + const midi = yToMidi(y, freshViewport, canvasSize.height); + drawPitchOnNote(dragState.noteId, clipTime, midi); + } + return; + } + + const currentNotes = pitchState.notes; + const hit = hitTestNote(x, y, currentNotes, freshViewport, canvasSize.height); + setHoveredNoteId(hit?.noteId ?? null); + + const canvas = canvasRef.current; + if (canvas) { + if (hit?.edge === "left" || hit?.edge === "right") { + canvas.style.cursor = "ew-resize"; + } else if (hit?.edge === "top-center" || hit?.edge === "bottom-left" || hit?.edge === "bottom-right") { + canvas.style.cursor = "ns-resize"; + } else if (hit) { + const currentTool = usePitchEditorStore.getState().tool; + canvas.style.cursor = currentTool === "draw" ? "crosshair" + : currentTool === "split" ? "crosshair" + : currentTool === "pitch" ? "ns-resize" + : currentTool === "transition" ? "col-resize" + : "grab"; + } else { + const currentTool = usePitchEditorStore.getState().tool; + canvas.style.cursor = currentTool === "draw" ? "not-allowed" : "default"; + } + } + }, [dragState, canvasSize.height, snapMode, updateNote, drawPitchOnNote]); + + const handleMouseUp = useCallback(() => { + if (dragState) { + if (dragState.type === "draw") { + commitDrawPitch(); + } else { + commitNoteEdit(); + } + } + setDragState(null); + }, [dragState, commitNoteEdit, commitDrawPitch]); + + // Keyboard shortcuts + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (!pitchEditorTrackId) return; + if (e.ctrlKey && e.key === "z" && !e.shiftKey) { e.preventDefault(); undo(); return; } + if (e.ctrlKey && (e.key === "y" || (e.key === "z" && e.shiftKey))) { e.preventDefault(); redo(); return; } + if (e.ctrlKey && e.key === "a") { e.preventDefault(); selectAll(); return; } + if (e.key === "Escape") { closePitchEditor(); return; } + if (e.key === "ArrowUp" && !e.ctrlKey) { + e.preventDefault(); + if (polyMode) moveSelectedPolyPitch(e.shiftKey ? 0.1 : 1); + else moveSelectedPitch(e.shiftKey ? 0.1 : 1); + return; + } + if (e.key === "ArrowDown" && !e.ctrlKey) { + e.preventDefault(); + if (polyMode) moveSelectedPolyPitch(e.shiftKey ? -0.1 : -1); + else moveSelectedPitch(e.shiftKey ? -0.1 : -1); + return; + } + if (e.key === "q" || e.key === "Q") { correctSelectedToScale(); return; } + if (e.ctrlKey && e.key === "j") { + e.preventDefault(); + const sIds = usePitchEditorStore.getState().selectedNoteIds; + if (sIds.length >= 2) mergeNotes(sIds); + return; + } + const toolKey = Number.parseInt(e.key); + if (toolKey >= 1 && toolKey <= TOOL_DEFS.length) setTool(TOOL_DEFS[toolKey - 1].id); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [pitchEditorTrackId, undo, redo, selectAll, closePitchEditor, moveSelectedPitch, moveSelectedPolyPitch, polyMode, correctSelectedToScale, setTool, mergeNotes]); + + // Panel resize + const isDragging = useRef(false); + const handleResizeStart = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + isDragging.current = true; + const startY = e.clientY; + const startH = lowerZoneHeight; + document.body.style.cursor = "row-resize"; + document.body.style.userSelect = "none"; + const onMove = (me: MouseEvent) => setLowerZoneHeight(startH + (startY - me.clientY)); + const onUp = () => { + isDragging.current = false; + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + }; + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + }, [lowerZoneHeight, setLowerZoneHeight]); + + const hoveredNote = hoveredNoteId ? notes.find((n) => n.id === hoveredNoteId) : null; + const selectedNote = selectedNoteIds.length === 1 ? notes.find((n) => n.id === selectedNoteIds[0]) : null; + const displayNote = hoveredNote || selectedNote; + + // Left panel width = tcpWidth + resize_handle(6) - PIANO_WIDTH + // so that PIANO_WIDTH in canvas compensates and content aligns with timeline + const leftPanelWidth = tcpWidth + 6 - PIANO_WIDTH; + + return ( +
+ {/* Resize grip */} +
+ +
+ + {/* Two-column layout */} +
+ + {/* ── Left panel (controls) ── */} +
+ {/* Header */} +
+ Pitch Editor + +
+ + {/* Clip info */} +
+
{clipInfo?.trackName ?? "—"}
+
{clipInfo?.clipName ?? "—"}
+
+ + {/* Scrollable controls area */} +
+ + {/* Tools */} +
+
Tools
+
+ {TOOL_DEFS.map((t) => ( + + ))} +
+

+ Double-click or use Split tool to split notes. Draw modifies pitch on existing notes. +

+
+ + {/* Snap */} +
+
Snap
+
+ {SNAP_MODES.map((m) => ( + + ))} +
+
+ + {/* Scale/Key */} +
+
Scale
+
+ + +
+ +
+ + {/* Correction */} +
+
Correct
+
+ + + + {selectedNoteIds.length >= 2 && ( + + )} +
+
+ + {/* Global Formant */} +
+
Formant
+
+ Global + setGlobalFormantCents(Number(e.target.value))} + className="flex-1 h-1 accent-daw-accent cursor-pointer" + /> + + {globalFormantCents > 0 ? "+" : ""} + {globalFormantCents}c + +
+
+ {formantStatusHint} + {applyState !== "idle" && applyMessage && ( + + {applyMessage} + + )} +
+
+ + {/* Note Inspector */} + + + {/* Undo / Redo */} +
+
+ + +
+
+ + {/* Poly mode */} +
+
Mode
+ + {polyMode && ( +
+ + + + {/* Poly detection tuning */} +
+
Detection Tuning
+
+
+
+ Note thresh + {polyNoteThreshold.toFixed(2)} +
+ setPolyNoteThreshold(Number(e.target.value))} + className="w-full h-0.5 bg-neutral-700 rounded-full appearance-none cursor-pointer accent-purple-500" + /> +
+
+
+ Onset thresh + {polyOnsetThreshold.toFixed(2)} +
+ setPolyOnsetThreshold(Number(e.target.value))} + className="w-full h-0.5 bg-neutral-700 rounded-full appearance-none cursor-pointer accent-purple-500" + /> +
+
+
+ Min duration + {polyMinDuration}ms +
+ setPolyMinDuration(Number(e.target.value))} + className="w-full h-0.5 bg-neutral-700 rounded-full appearance-none cursor-pointer accent-purple-500" + /> +
+
+
+
+ )} +
+ + {/* A/B Compare */} +
+ +
+ + {polyMode && ( +
+ +
+ )} +
+ + {/* Status footer */} +
+ {displayNote ? ( + <> +
+ Pitch + {formatNoteName(displayNote.correctedPitch)} + {Math.abs(displayNote.correctedPitch - displayNote.detectedPitch) > 0.05 && ( + + ({(displayNote.correctedPitch - displayNote.detectedPitch) > 0 ? "+" : ""}{(displayNote.correctedPitch - displayNote.detectedPitch).toFixed(1)}st) + + )} +
+
+ Detected: {formatNoteName(displayNote.detectedPitch)} +
+ + ) : selectedNoteIds.length > 1 ? ( +
{selectedNoteIds.length} notes selected
+ ) : ( +
Hover or click a note
+ )} + {(isAnalyzing || progressLabel) && ( +
{progressLabel || "Analyzing…"}
+ )} + {!isAnalyzing && applyMessage && ( +
+ {applyMessage} +
+ )} +
+
+ + {/* ── Canvas (aligned with timeline) ── */} +
+ {isAnalyzing && !contour && ( +
+
+
+
{progressLabel || "Analyzing pitch…"}
+ {progressPercent > 0 && progressPercent < 100 && ( +
+
+
+ )} +
+
+ )} + e.preventDefault()} + /> +
+
+ + {/* Correct Pitch macro modal */} + +
+ ); +} diff --git a/frontend/src/components/Playhead.tsx b/frontend/src/components/Playhead.tsx index 1aa104c..867d271 100644 --- a/frontend/src/components/Playhead.tsx +++ b/frontend/src/components/Playhead.tsx @@ -19,7 +19,7 @@ interface PlayheadProps { */ export function Playhead({ pixelsPerSecond, - scrollX, + scrollX: _scrollX, stageHeight, viewportWidth, type, diff --git a/frontend/src/components/PluginBrowser.tsx b/frontend/src/components/PluginBrowser.tsx index f5eef65..b880d1e 100644 --- a/frontend/src/components/PluginBrowser.tsx +++ b/frontend/src/components/PluginBrowser.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { createPortal } from "react-dom"; import { X, @@ -11,11 +11,33 @@ import { AudioWaveform, Music, Box, + Code, + Star, + FolderOpen, } from "lucide-react"; import { nativeBridge } from "../services/NativeBridge"; import { useDAWStore } from "../store/useDAWStore"; +import { + getFXChainSlots, + notifyFXChainChanged, + notifyInstrumentChanged, + waitForFXChainLength, + waitForInstrumentPlugin, +} from "../utils/fxChain"; import { Button, Input, Select } from "./ui"; +// Persist favorites in localStorage +const FAVORITES_KEY = "studio13_plugin_favorites"; +function loadFavorites(): Set { + try { + const raw = localStorage.getItem(FAVORITES_KEY); + return raw ? new Set(JSON.parse(raw)) : new Set(); + } catch { return new Set(); } +} +function saveFavorites(favs: Set) { + localStorage.setItem(FAVORITES_KEY, JSON.stringify([...favs])); +} + interface Plugin { name: string; manufacturer: string; @@ -23,6 +45,170 @@ interface Plugin { fileOrIdentifier: string; isInstrument: boolean; snapshot?: string; // base64 data URL from C++ snapshot lookup + pluginFormat?: string; + pluginFormatName?: string; + pluginType?: "vst3" | "lv2" | "clap" | "s13fx" | "builtin"; // Plugin format type + producesMidi?: boolean; + isMidiEffect?: boolean; + supportsDoublePrecision?: boolean; +} + +type PluginCapabilityPatch = Pick< + Plugin, + | "pluginType" + | "pluginFormat" + | "isInstrument" + | "producesMidi" + | "isMidiEffect" + | "supportsDoublePrecision" +>; + +const pluginCatalogCache: { + plugins: Plugin[] | null; + loadPromise: Promise | null; + capabilityPromise: Promise | null; + capabilityPatches: Map; +} = { + plugins: null, + loadPromise: null, + capabilityPromise: null, + capabilityPatches: new Map(), +}; + +function applyCachedCapabilities(plugin: Plugin): Plugin { + const cached = pluginCatalogCache.capabilityPatches.get(plugin.fileOrIdentifier); + return cached ? { ...plugin, ...cached } : plugin; +} + +async function fetchPluginCatalog(): Promise { + if (pluginCatalogCache.plugins) { + return pluginCatalogCache.plugins; + } + + if (pluginCatalogCache.loadPromise) { + return pluginCatalogCache.loadPromise; + } + + pluginCatalogCache.loadPromise = (async () => { + const pluginList = await nativeBridge.getAvailablePlugins(); + const hostPlugins: Plugin[] = pluginList.map((p: any) => { + const fmt = (p.pluginFormat || p.pluginFormatName || "").toLowerCase(); + let pluginType: Plugin["pluginType"] = "vst3"; + if (fmt.includes("lv2")) pluginType = "lv2"; + else if (fmt.includes("clap")) pluginType = "clap"; + return applyCachedCapabilities({ ...p, pluginType }); + }); + + let s13fxPlugins: Plugin[] = []; + try { + const scripts = await nativeBridge.getAvailableS13FX(); + s13fxPlugins = scripts.map((s: any) => ({ + name: s.name, + manufacturer: s.author || "S13FX", + category: s.tags?.[0] || "Script", + fileOrIdentifier: s.filePath, + isInstrument: false, + pluginType: "s13fx" as const, + })); + } catch { + // S13FX not available, that's OK + } + + const combined = [...hostPlugins, ...s13fxPlugins]; + pluginCatalogCache.plugins = combined; + return combined; + })(); + + try { + return await pluginCatalogCache.loadPromise; + } finally { + pluginCatalogCache.loadPromise = null; + } +} + +async function hydratePluginCapabilities(catalog: Plugin[]): Promise { + const hostPluginsNeedingCapabilities = catalog.filter( + (plugin) => + plugin.pluginType !== "s13fx" && + !pluginCatalogCache.capabilityPatches.has(plugin.fileOrIdentifier), + ); + + if (hostPluginsNeedingCapabilities.length === 0) { + return pluginCatalogCache.plugins ?? catalog; + } + + if (pluginCatalogCache.capabilityPromise) { + return pluginCatalogCache.capabilityPromise; + } + + pluginCatalogCache.capabilityPromise = (async () => { + await Promise.all( + hostPluginsNeedingCapabilities.map(async (plugin) => { + try { + const capabilities = await nativeBridge.getPluginCapabilities( + plugin.fileOrIdentifier, + ); + + if (!capabilities?.success) { + return; + } + + const fmt = ( + capabilities.pluginFormat || + plugin.pluginFormat || + plugin.pluginFormatName || + "" + ).toLowerCase(); + + let pluginType: Plugin["pluginType"] = plugin.pluginType ?? "vst3"; + if (fmt.includes("lv2")) pluginType = "lv2"; + else if (fmt.includes("clap")) pluginType = "clap"; + else if (fmt.includes("builtin")) pluginType = "builtin"; + else pluginType = "vst3"; + + pluginCatalogCache.capabilityPatches.set(plugin.fileOrIdentifier, { + pluginType, + pluginFormat: capabilities.pluginFormat || plugin.pluginFormat, + isInstrument: + typeof capabilities.isInstrument === "boolean" + ? capabilities.isInstrument + : plugin.isInstrument, + producesMidi: + typeof capabilities.producesMidi === "boolean" + ? capabilities.producesMidi + : plugin.producesMidi, + isMidiEffect: + typeof capabilities.isMidiEffect === "boolean" + ? capabilities.isMidiEffect + : plugin.isMidiEffect, + supportsDoublePrecision: + typeof capabilities.supportsDoublePrecision === "boolean" + ? capabilities.supportsDoublePrecision + : plugin.supportsDoublePrecision, + }); + } catch { + // Ignore capability failures and keep cached scan metadata. + } + }), + ); + + const mergedCatalog = (pluginCatalogCache.plugins ?? catalog).map(applyCachedCapabilities); + pluginCatalogCache.plugins = mergedCatalog; + return mergedCatalog; + })(); + + try { + return await pluginCatalogCache.capabilityPromise; + } finally { + pluginCatalogCache.capabilityPromise = null; + } +} + +function invalidatePluginCatalogCache() { + pluginCatalogCache.plugins = null; + pluginCatalogCache.loadPromise = null; + pluginCatalogCache.capabilityPromise = null; + pluginCatalogCache.capabilityPatches.clear(); } // Map VST3 category substrings to Lucide icons and colors @@ -56,9 +242,40 @@ function getCategoryIcon(category: string) { return { match: "Other", Icon: Box, color: "#6b7280" }; } +// ---- Plugin Category Groups ---- +// Maps VST3 category strings (e.g. "Fx|Dynamics", "Instrument|Synth") to +// predefined groups for easy filtering. +const CATEGORY_GROUPS = [ + { id: "all", label: "All", keywords: [] }, + { id: "instruments", label: "Instruments", keywords: ["instrument", "synth", "sampler", "piano", "organ", "drum"] }, + { id: "effects", label: "Effects", keywords: ["fx", "effect"] }, + { id: "dynamics", label: "Dynamics", keywords: ["dynamics", "compressor", "limiter", "gate", "expander"] }, + { id: "eq", label: "EQ", keywords: ["eq", "equalizer", "filter"] }, + { id: "reverb", label: "Reverb", keywords: ["reverb", "room", "hall", "plate"] }, + { id: "delay", label: "Delay", keywords: ["delay", "echo"] }, + { id: "modulation", label: "Modulation", keywords: ["modulation", "chorus", "flanger", "phaser", "tremolo", "vibrato"] }, + { id: "distortion", label: "Distortion", keywords: ["distortion", "saturation", "overdrive", "bitcrusher", "waveshaper"] }, + { id: "other", label: "Other", keywords: [] }, +] as const; + +type CategoryGroupId = typeof CATEGORY_GROUPS[number]["id"]; + +function getPluginGroupId(plugin: { category: string; isInstrument: boolean }): CategoryGroupId { + if (plugin.isInstrument) return "instruments"; + const lowerCat = plugin.category.toLowerCase(); + for (const group of CATEGORY_GROUPS) { + if (group.id === "all" || group.id === "other") continue; + if (group.keywords.some((kw) => lowerCat.includes(kw))) return group.id; + } + // If it has "Fx" in category but no specific match, classify as "effects" + if (lowerCat.includes("fx") || lowerCat.includes("effect")) return "effects"; + return "other"; +} + interface PluginBrowserProps { trackId: string; targetChain: "input" | "track" | "master" | "instrument"; + trackType?: "audio" | "midi" | "instrument" | "bus"; onClose: () => void; embedded?: boolean; } @@ -66,35 +283,72 @@ interface PluginBrowserProps { export function PluginBrowser({ trackId, targetChain, + trackType, onClose, embedded = false, }: PluginBrowserProps) { - const [plugins, setPlugins] = useState([]); - const [loading, setLoading] = useState(false); + const [plugins, setPlugins] = useState(() => pluginCatalogCache.plugins ?? []); + const [loading, setLoading] = useState(() => pluginCatalogCache.plugins == null); const [searchTerm, setSearchTerm] = useState(""); const [categoryFilter, setCategoryFilter] = useState("All"); + const [categoryGroupFilter, setCategoryGroupFilter] = useState("all"); const [addingPlugin, setAddingPlugin] = useState(null); + const [favorites, setFavorites] = useState>(loadFavorites); + const [showFavoritesOnly, setShowFavoritesOnly] = useState(false); - useEffect(() => { - loadPlugins(); - }, []); + const toggleFavorite = (pluginId: string) => { + setFavorites((prev) => { + const next = new Set(prev); + if (next.has(pluginId)) next.delete(pluginId); + else next.add(pluginId); + saveFavorites(next); + return next; + }); + }; const loadPlugins = async () => { - setLoading(true); try { - const pluginList = await nativeBridge.getAvailablePlugins(); - setPlugins(pluginList); + if (pluginCatalogCache.plugins) { + setPlugins(pluginCatalogCache.plugins); + setLoading(false); + + void hydratePluginCapabilities(pluginCatalogCache.plugins) + .then((hydratedCatalog) => { + setPlugins(hydratedCatalog); + }) + .catch((e) => { + console.error("[PluginBrowser] Failed to hydrate cached capabilities:", e); + }); + return; + } + + setLoading(true); + const catalog = await fetchPluginCatalog(); + setPlugins(catalog); + setLoading(false); + + void hydratePluginCapabilities(catalog) + .then((hydratedCatalog) => { + setPlugins(hydratedCatalog); + }) + .catch((e) => { + console.error("[PluginBrowser] Failed to hydrate plugin capabilities:", e); + }); } catch (e) { console.error("[PluginBrowser] Failed to load plugins:", e); - } finally { setLoading(false); } }; + useEffect(() => { + void loadPlugins(); + }, []); + const handleScan = async () => { setLoading(true); try { await nativeBridge.scanForPlugins(); + invalidatePluginCatalogCache(); await loadPlugins(); } catch (e) { console.error("[PluginBrowser] Failed to scan:", e); @@ -107,33 +361,81 @@ export function PluginBrowser({ setAddingPlugin(plugin.fileOrIdentifier); try { let success = false; - if (targetChain === "instrument") { + let shouldNotifyChain = false; + const store = useDAWStore.getState(); + + if (plugin.pluginType === "s13fx") { + // S13FX script — use dedicated bridge + if (targetChain === "master") { + success = await nativeBridge.addMasterS13FX(plugin.fileOrIdentifier); + shouldNotifyChain = success; + } else { + const fxTargetChain = + targetChain === "input" || targetChain === "track" + ? targetChain + : null; + const isInputFX = fxTargetChain === "input"; + const expectedLength = + fxTargetChain !== null + ? (await getFXChainSlots(trackId, fxTargetChain)).length + 1 + : null; + success = await nativeBridge.addTrackS13FX( + trackId, + plugin.fileOrIdentifier, + isInputFX, + ); + if (success && expectedLength !== null && fxTargetChain !== null) { + await waitForFXChainLength(trackId, fxTargetChain, expectedLength); + shouldNotifyChain = true; + } + } + } else if (targetChain === "instrument") { success = await nativeBridge.loadInstrument( trackId, plugin.fileOrIdentifier, ); if (success) { - useDAWStore.getState().updateTrack(trackId, { + store.updateTrack(trackId, { + instrumentPlugin: plugin.fileOrIdentifier, + }); + await waitForInstrumentPlugin( + trackId, + plugin.fileOrIdentifier, + (candidateTrackId) => + useDAWStore + .getState() + .tracks.find((track) => track.id === candidateTrackId)?.instrumentPlugin, + ); + notifyInstrumentChanged({ + trackId, instrumentPlugin: plugin.fileOrIdentifier, }); - // Open the instrument plugin editor in a separate window await nativeBridge.openInstrumentEditor(trackId); } } else if (targetChain === "input") { - success = await nativeBridge.addTrackInputFX( + success = await store.addTrackFXWithUndo( trackId, plugin.fileOrIdentifier, + "input", ); } else if (targetChain === "track") { - success = await nativeBridge.addTrackFX( + success = await store.addTrackFXWithUndo( trackId, plugin.fileOrIdentifier, + "track", ); } else if (targetChain === "master") { success = await nativeBridge.addMasterFX(plugin.fileOrIdentifier); + shouldNotifyChain = success; } if (success) { + if (shouldNotifyChain && targetChain !== "instrument") { + notifyFXChainChanged({ + trackId, + chainType: targetChain, + }); + } console.log( `[PluginBrowser] Added ${plugin.name} to ${targetChain}`, ); @@ -146,16 +448,44 @@ export function PluginBrowser({ } }; - // Filter plugins based on targetChain - const basePlugins = targetChain === "instrument" - ? plugins.filter((p) => p.isInstrument) - : plugins.filter((p) => !p.isInstrument); + // Filter plugins based on targetChain and track type + const basePlugins = useMemo(() => { + if (targetChain === "instrument") { + // Only show instrument plugins + return plugins.filter((p) => p.isInstrument); + } + // For FX chains (input/track/master), exclude instrument plugins + let filtered = plugins.filter((p) => !p.isInstrument); + // For pure MIDI tracks (no instrument loaded), audio FX won't work + // — only show S13FX and plugins that explicitly support MIDI + if (trackType === "midi") { + filtered = filtered.filter( + (p) => p.pluginType === "s13fx" || p.producesMidi || p.isMidiEffect, + ); + } + return filtered; + }, [plugins, targetChain, trackType]); const categories = [ "All", ...Array.from(new Set(basePlugins.map((p) => p.category))), ]; + // Compute available category groups (only show tabs that have plugins) + const availableGroups = useMemo(() => { + const groupCounts = new Map(); + for (const p of basePlugins) { + const gid = getPluginGroupId(p); + groupCounts.set(gid, (groupCounts.get(gid) || 0) + 1); + } + return CATEGORY_GROUPS.filter( + (g) => g.id === "all" || (groupCounts.get(g.id) || 0) > 0 + ).map((g) => ({ + ...g, + count: g.id === "all" ? basePlugins.length : groupCounts.get(g.id) || 0, + })); + }, [basePlugins]); + // Enhanced search: match against name, manufacturer, AND category const filteredPlugins = basePlugins.filter((p) => { const term = searchTerm.toLowerCase(); @@ -165,9 +495,22 @@ export function PluginBrowser({ p.category.toLowerCase().includes(term); const matchesCategory = categoryFilter === "All" || p.category === categoryFilter; - return matchesSearch && matchesCategory; + const matchesGroup = + categoryGroupFilter === "all" || getPluginGroupId(p) === categoryGroupFilter; + const matchesFavorite = !showFavoritesOnly || favorites.has(p.fileOrIdentifier); + return matchesSearch && matchesCategory && matchesGroup && matchesFavorite; }); + // Sort: favorites first, then alphabetical + const sortedPlugins = useMemo(() => { + return [...filteredPlugins].sort((a, b) => { + const aFav = favorites.has(a.fileOrIdentifier) ? 0 : 1; + const bFav = favorites.has(b.fileOrIdentifier) ? 0 : 1; + if (aFav !== bFav) return aFav - bFav; + return a.name.localeCompare(b.name); + }); + }, [filteredPlugins, favorites]); + const content = ( <>
({ value: cat, label: cat }))} className="min-w-[150px]" /> + +
+ {/* Category Group Filter Tabs */} +
+ {availableGroups.map((group) => ( + + ))} +
+
{loading ? ( -
- Loading plugins... +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ {/* Icon skeleton */} +
+ {/* Text skeleton */} +
+
+
+
+
+ {/* Button skeleton */} +
+
+ ))} +
+ Loading available plugins... +
- ) : filteredPlugins.length === 0 ? ( + ) : sortedPlugins.length === 0 ? (
- No plugins found. Click "Scan" to search your system. + {showFavoritesOnly ? "No favorite plugins. Click the star on a plugin to favorite it." : "No plugins found. Click \"Scan\" to search your system."}
) : ( - filteredPlugins.map((plugin, idx) => { - const { Icon, color } = getCategoryIcon(plugin.category); + sortedPlugins.map((plugin, idx) => { + const isScript = plugin.pluginType === "s13fx"; + const isFav = favorites.has(plugin.fileOrIdentifier); + const { Icon, color } = isScript + ? { Icon: Code, color: "#84cc16" } + : getCategoryIcon(plugin.category); return (
+ {/* Favorite star */} + {/* Snapshot or category icon */} {plugin.snapshot ? (
{plugin.name} + {isScript && ( + + S13FX + + )}
{plugin.manufacturer}
- {plugin.category} + {isScript ? "JSFX Script" : plugin.category}
+ + + ); +} diff --git a/frontend/src/components/ProjectSettingsModal.tsx b/frontend/src/components/ProjectSettingsModal.tsx index 591be2c..544b7f5 100644 --- a/frontend/src/components/ProjectSettingsModal.tsx +++ b/frontend/src/components/ProjectSettingsModal.tsx @@ -1,5 +1,7 @@ import { useState, useEffect } from "react"; import { useDAWStore } from "../store/useDAWStore"; +import { useShallow } from "zustand/shallow"; +import { nativeBridge } from "../services/NativeBridge"; import { Button, Input, @@ -31,13 +33,36 @@ export function ProjectSettingsModal({ projectBitDepth, transport, timeSignature, + projectAuthor, + projectRevisionNotes, setProjectName, setProjectNotes, setProjectSampleRate, setProjectBitDepth, setTempo, setTimeSignature: setTimeSignatureAction, - } = useDAWStore(); + setProjectAuthor, + addRevisionNote, + deleteRevisionNote, + } = useDAWStore(useShallow((s) => ({ + projectName: s.projectName, + projectNotes: s.projectNotes, + projectSampleRate: s.projectSampleRate, + projectBitDepth: s.projectBitDepth, + transport: s.transport, + timeSignature: s.timeSignature, + projectAuthor: s.projectAuthor, + projectRevisionNotes: s.projectRevisionNotes, + setProjectName: s.setProjectName, + setProjectNotes: s.setProjectNotes, + setProjectSampleRate: s.setProjectSampleRate, + setProjectBitDepth: s.setProjectBitDepth, + setTempo: s.setTempo, + setTimeSignature: s.setTimeSignature, + setProjectAuthor: s.setProjectAuthor, + addRevisionNote: s.addRevisionNote, + deleteRevisionNote: s.deleteRevisionNote, + }))); // Local state for form const [localName, setLocalName] = useState(projectName); @@ -51,6 +76,9 @@ export function ProjectSettingsModal({ const [localTimeSignatureDenom, setLocalTimeSignatureDenom] = useState( timeSignature.denominator ); + const [localAuthor, setLocalAuthor] = useState(projectAuthor); + const [newNote, setNewNote] = useState(""); + const [localPanLaw, setLocalPanLaw] = useState("constant_power"); // Sync local state when modal opens or store changes useEffect(() => { @@ -62,6 +90,11 @@ export function ProjectSettingsModal({ setLocalTempo(transport.tempo); setLocalTimeSignatureNum(timeSignature.numerator); setLocalTimeSignatureDenom(timeSignature.denominator); + setLocalAuthor(projectAuthor); + setNewNote(""); + + // Fetch current pan law from backend + nativeBridge.getPanLaw().then((law) => setLocalPanLaw(law)); } }, [ isOpen, @@ -71,6 +104,7 @@ export function ProjectSettingsModal({ projectBitDepth, transport.tempo, timeSignature, + projectAuthor, ]); const handleApply = () => { @@ -80,9 +114,18 @@ export function ProjectSettingsModal({ setProjectBitDepth(localBitDepth); setTempo(localTempo); setTimeSignatureAction(localTimeSignatureNum, localTimeSignatureDenom); + if (localAuthor !== projectAuthor) setProjectAuthor(localAuthor); + nativeBridge.setPanLaw(localPanLaw); onClose(); }; + const handleAddNote = () => { + const trimmed = newNote.trim(); + if (!trimmed) return; + addRevisionNote(trimmed); + setNewNote(""); + }; + const handleCancel = () => { // Reset to original values setLocalName(projectName); @@ -92,6 +135,9 @@ export function ProjectSettingsModal({ setLocalTempo(transport.tempo); setLocalTimeSignatureNum(timeSignature.numerator); setLocalTimeSignatureDenom(timeSignature.denominator); + setLocalAuthor(projectAuthor); + setNewNote(""); + // Pan law resets on next open via getPanLaw() fetch onClose(); }; @@ -115,7 +161,6 @@ export function ProjectSettingsModal({ {/* Project Notes */}