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.
+
-
+
+
+
+
+
+
+
-## 🚀 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