From e0d9d41e7a2bfac7795a36f7f43c35e93d2561d5 Mon Sep 17 00:00:00 2001 From: Sourav Das Date: Sat, 24 Oct 2026 11:15:11 +0530 Subject: [PATCH 01/33] Finalize 0.1.06 release notes and Store readiness (#32) --- docs/releases/0.1.06.md | 54 +++++++++++++++++++++++--------- docs/store-release-activation.md | 30 +++++++++++++++++- 2 files changed, 69 insertions(+), 15 deletions(-) diff --git a/docs/releases/0.1.06.md b/docs/releases/0.1.06.md index a64d5386..8233bc17 100644 --- a/docs/releases/0.1.06.md +++ b/docs/releases/0.1.06.md @@ -1,12 +1,11 @@ # OpenStudio 0.1.06 -This is an unpublished release candidate proposed on `fix-reverbs`. The -[candidate comparison since v0.1.05](https://github.com/sdevil7th/OpenStudio/compare/v0.1.05...fix-reverbs) -covers the application changes below and Store submission safeguards. The -[review baseline](https://github.com/sdevil7th/OpenStudio/compare/v0.1.05...e1cf8024bcc293f6b3ac2ad49caadf84c20193f8) -contains only the preceding Store work. Candidate features are not included in -the downloadable 0.1.05 installer. The final tag comparison and qualification -of its exact artifacts remain release gates. +The [release comparison since v0.1.05](https://github.com/sdevil7th/OpenStudio/compare/v0.1.05...v0.1.06) +covers the application changes and Store submission safeguards in this release. +The application source was reviewed in [PR #31](https://github.com/sdevil7th/OpenStudio/pull/31) +at [commit 6b48f530](https://github.com/sdevil7th/OpenStudio/commit/6b48f530d995d45a1aab0365b4f9d32cb9684a17). +The testing and listening limits below remain explicit; publication does not +establish the unmet low-buffer target or unverified hardware acceptance. ## Highlights @@ -15,14 +14,25 @@ of its exact artifacts remain release gates. analysis, and Find locates controls and workflows without silently changing an effect's machine or mode. Exact entry, keyboard adjustment, Compare and Undo retain the native parameter identities. -- Add independent Octave and Fuzz switching in NAM Rack, Guitar/Bass processing - profiles and three original fuzz voices. Clean blend now lives on the pedal - with a knob and recessed percentage display. It blends the input to Fuzz; +- Add independently switchable Fuzz alongside Octave in NAM Rack, with three + original voices adapted to the existing Guitar/Bass profiles. Clean blend now + lives on the pedal with a knob and recessed percentage display. It blends the input to Fuzz; subsequent Drive, Distortion and amp processing still apply. - Process eligible independent native-NAM audio tracks across persistent workers, scaling with available physical cores. Unsupported hosted-plugin, MIDI and routed paths retain serial processing. This introduces no extra device buffering, but does not yet meet the four-track, 16-sample target. +- Add selectable Legacy/Corrected processing to selected free effects and + instruments. Corrected paths address Pitch Correct's Custom note masks and + rate-dependent analysis, Preamp bypass alignment, Saturator Foldback symmetry, + and oscillator/modulation timing. New instances use Corrected; older saved + states missing the setting retain Legacy. +- Add 41 authored Reverb voice presets and whole-collection recall while + retaining the eight historical factory voices. Optional return-level, + processing and Shimmer scheduling choices preserve older saved behavior. +- Add NAM Preset Library Profile review for mismatched Guitar/Bass labels and + an explicit separate-profile copy action that preserves the original preset + and the current rack. ## Fixes @@ -37,6 +47,10 @@ of its exact artifacts remain release gates. horizontal dragging sets the project/loop range; explicit Select time mappings retain editing time selection. Stop and cancelled gestures discard stale preview and viewport work. +- Copy selected audio and MIDI clips together with matching drag previews and + one Undo step, preserving relative time and track placement. Primary-drag + copies selected whole tracks. Piano Roll Primary-drag keeps pitch fixed; + Shift-drag keeps note timing fixed. - Reduce excessive gain at moderate Vintage/Sustain fuzz settings and stabilize downstream Distortion calculations and the shared high-pass filter at high oversampling. Maximum fuzz gain remains available. Stacked gain can still @@ -45,6 +59,10 @@ of its exact artifacts remain release gates. work scheduling. Response measurement waits for pending control writes, so its isolated wet-only measurement uses the displayed settings. Preserve compatibility paths and stored parameters across the free suite. +- Preserve finite headroom in the standalone Gate and Clean compressor, and + include filter ringing, feedback, manual delays and instrument decay in + export-tail bounds. Embedded NAM and coloured compatibility paths retain + their prior bounds. - Save NAM metadata, portable presets and Pitch Correct presets through a bounded atomic JSON writer instead of the project-only writer. Failed saves are reported without replacing the existing file or relaxing project checks. @@ -94,21 +112,29 @@ of its exact artifacts remain release gates. - Previously recorded third-party plugin export/state and automation issues remain open in the roadmap, including AmpliTube cold-export parity, Nekobi restoration and the native played-guitar Punch/AutoJoin timeout. This - candidate does not claim to resolve them. + release does not claim to resolve them. ## Upgrade Notes -- Back up projects and presets before trying this candidate. Use normal +- Back up projects and presets before upgrading. Use normal in-place installation when a qualified update is published. On Windows, uninstalling first removes user-scoped settings, optional runtimes and caches. - Existing presets retain their compatibility mode. Newly saved Pitch presets include that mode explicitly. Fuzz gain changes at moderate settings may change sustain and the saturation of later pedals; audition saved rigs and adjust Fuzz Level and the rack Gate intentionally. +- OpenStudio's default shortcuts change: R records, 1/Home seeks to absolute + project start, and +/- zoom the timeline. Track Arm and Piano Roll Select + Range use Primary+Shift+R; Pitch Editor Select uses V. Primary means Ctrl on + Windows/Linux and Command on macOS. Other profiles and custom overrides + retain priority. Use Keyboard, Mouse & Trackpad to inspect effective bindings. +- Changing a plugin's Signal path resets its processing history and requires + stopped configuration. Complete-state Undo, Compare and portable recall + retain the selected mode. - Select the distribution-native Linux package matching the host. Published download and update endpoints continue to describe the actual released - artifacts; preparing this candidate does not update those feeds. + artifacts; the release workflow updates those feeds after publication. - The [testing guide](../testing.md), [NAM Rack guide](../nam-rack.md) and [free-plugin guide](../free-plugins.md) hold the workflows and qualification - limits. Candidate build/test results must be tied to the tested source and + limits. Build/test results must be tied to the tested source and binary before release approval. diff --git a/docs/store-release-activation.md b/docs/store-release-activation.md index 5cb81a22..e83a42df 100644 --- a/docs/store-release-activation.md +++ b/docs/store-release-activation.md @@ -1,6 +1,34 @@ # Microsoft Store activation plan -Status recorded: October 8, 2026. +Status reviewed: October 24, 2026. Historical checkpoints below retain their +original scope. + +## October 24 release readiness and recovered 0.1.05 submission + +The [October 8 recovery run](https://github.com/sdevil7th/OpenStudio/actions/runs/37806805920) +successfully uploaded and committed the existing submission +`1152921505702075647` for package `0.1.5.0`. Its retained report reached +`PreProcessing` with `targetPublishMode=Immediate`. The exact MSIX SHA-256 was +`e816fb874fa50168042cfb064d1364570e0d07f3a3812cce8508f77efcc60eb1`. +This supersedes the uncommitted-draft status recorded in the earlier attempt +below; it does not establish current certification, publication or an installed +Store upgrade. + +The October 24 repository check confirms `OPENSTUDIO_STORE_ENABLED=true`, +the three required `MS_STORE_*` environment secret names, and the `v*`-only +Store environment without required reviewers. A normal `v0.1.06` tag therefore +builds and validates MSIX `0.1.6.0`, runs live read-only Store preflight before +GitHub publication, then submits the matching package after publication. The +new tag's preflight must establish the current published baseline and reject +an unrelated pending submission. + +The release-specific publishing policy remains scoped to `v0.1.05`. +`v0.1.06` inherits the last published submission's saved publishing mode. +`Immediate` publishes after successful certification; `Manual` retains a +publication hold. Do not rerun the old recovery or extend its policy without +checking the current baseline. Retain the new release's sanitized readiness +and submission reports, then verify certification, delivery and a Store-installed +upgrade separately. ## October 8 unpublished 0.1.04 attempt and 0.1.05 follow-up From 9e3c4c7c4bc8516935acfa0ed41a23d48354d629 Mon Sep 17 00:00:00 2001 From: Sourav Das Date: Sat, 24 Oct 2026 12:22:53 +0530 Subject: [PATCH 02/33] Fix mounted macOS release qualification identity (#33) --- .github/workflows/release.yml | 9 ++++ .github/workflows/verify.yml | 8 +-- docs/releases/0.1.06.md | 7 +++ docs/store-release-activation.md | 10 ++++ docs/testing.md | 46 ++++++++++++++++ tests/test_desktop_qualification.py | 81 +++++++++++++++++++++++++--- tools/run-window-lifecycle-smoke.ps1 | 26 +++++++-- 7 files changed, 174 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8aa56083..5d0381d8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -708,6 +708,15 @@ jobs: -ReportPath "$REPORT_PATH" \ -TimeoutSeconds 180 + - name: Retain packaged macOS qualification evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: macos-packaged-qualification + path: ${{ runner.temp }}/OpenStudio_PackagedWindowLifecycleHarness* + if-no-files-found: ignore + retention-days: 30 + - name: Verify macOS release outputs shell: bash run: | diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 31fb09c2..b4d1425b 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -570,6 +570,10 @@ jobs: steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - name: Test POSIX desktop ownership and lifecycle identity + shell: bash + run: python3 -B -m unittest tests.test_desktop_qualification -v + - name: Prepare system FFmpeg for audio conversion qualification shell: bash run: | @@ -677,10 +681,6 @@ jobs: output/review/runtime-Release-*/qualification.json if-no-files-found: warn - - name: Test POSIX desktop process ownership - shell: bash - run: python3 -B -m unittest tests.test_desktop_qualification -v - - name: Retain native FX order regression evidence (macOS) if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 diff --git a/docs/releases/0.1.06.md b/docs/releases/0.1.06.md index 8233bc17..1a1fdffc 100644 --- a/docs/releases/0.1.06.md +++ b/docs/releases/0.1.06.md @@ -1,5 +1,12 @@ # OpenStudio 0.1.06 +Publication status: [the release attempt](https://github.com/sdevil7th/OpenStudio/actions/runs/40067513185) +failed at the mounted macOS DMG's qualification wrapper. GitHub publication, +Store submission and website release publication were skipped. The tag is +retained as the failed attempt; the changes below were not distributed as +0.1.06. See the [qualification record](../testing.md#october-24-0106-release-attempt) +for the path-identity failure and the narrower checks that passed. + The [release comparison since v0.1.05](https://github.com/sdevil7th/OpenStudio/compare/v0.1.05...v0.1.06) covers the application changes and Store submission safeguards in this release. The application source was reviewed in [PR #31](https://github.com/sdevil7th/OpenStudio/pull/31) diff --git a/docs/store-release-activation.md b/docs/store-release-activation.md index e83a42df..8fc69ab0 100644 --- a/docs/store-release-activation.md +++ b/docs/store-release-activation.md @@ -5,6 +5,16 @@ original scope. ## October 24 release readiness and recovered 0.1.05 submission +The [0.1.06 release attempt](https://github.com/sdevil7th/OpenStudio/actions/runs/40067513185) +passed live Store preflight. Its report confirms the prior submission +`1152921505702075647` is **Published**, no pending update blocks submission, and +the effective mode is **Immediate**. The checked `0.1.6.0` MSIX SHA-256 was +`4d0ac49968561b21b27a08b60b660d4421fde12b7be253c496b4fb51aa972c3f`. +The macOS qualification wrapper then blocked publication at its lexical +executable-path comparison, which does not account for POSIX aliases. GitHub publication and Store +submission were skipped; preflight performed no Store mutation. A corrected +release must run its own preflight and submit its exact new package. + The [October 8 recovery run](https://github.com/sdevil7th/OpenStudio/actions/runs/37806805920) successfully uploaded and committed the existing submission `1152921505702075647` for package `0.1.5.0`. Its retained report reached diff --git a/docs/testing.md b/docs/testing.md index 41b2f553..63bffad2 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -2209,6 +2209,52 @@ The opt-in native `-CaseName ten-hour-listening` retains its historical command Keep generated JSON, WAV, screenshots, hashes and failed/intermediate runs under ignored `output/`. Store lasting contracts in the maintenance guide and executable tests. The 3 October development baseline passed 2,480 frontend tests, 168 native groups and 233 host checks; current source still requires appropriately scoped checks after changes. +## October 24 0.1.06 release attempt + +[Release run 40067513185](https://github.com/sdevil7th/OpenStudio/actions/runs/40067513185) +tested tag `v0.1.06` at `e0d9d41e7a2bfac7795a36f7f43c35e93d2561d5`. +This was a failed publication attempt, not a distributed application release. + +- Windows: the actual installer passed installation, 99 normal-mode installed + lifecycle checks and uninstall. The tested executable SHA-256 was + `490ae61640acf6b3dc8586e1c1158110d00f2149228e3fca876facf0f2b4d08d`. + The host was build 26100 with an elevated account and explicit offline + operation. This does not qualify Windows 10 Repair, safe mode, a standard-user + installation, previous-version upgrade or physical audio. The direct-download + installer was unsigned; no code-signing certificate was configured. +- Linux: the installed Ubuntu 22.04 package passed 85 normal and 76 safe lifecycle + checks, 264 render/export checks, 361 runtime-safety checks and 40 engine + groups. The glibc 2.35 ABI audit passed. The finished AppImage passed launcher, + 85 lifecycle and 264 render checks under extract-and-run/Xvfb. These results + do not establish physical audio, native Wayland or the original Arch/fish + reporter's installation behavior. +- macOS: universal compilation, runtime validation and unsigned DMG construction + passed. The mounted application exited successfully and its required native + lifecycle report assertions passed, but the wrapper then rejected executable + identity. Its lexical `GetFullPath` comparison did not account for parent + aliases such as `/var` and `/private/var`. The complete packaged qualification + therefore failed; do not count it as a macOS release pass. +- Store: read-only preflight confirmed the previous 0.1.05 submission is + Published and the new package's effective publishing mode is Immediate. + Failed macOS qualification skipped GitHub publication, Store submission and + website dispatch. No 0.1.06 Store mutation occurred. + +The wrapper correction compares POSIX filesystem identity with +`os.path.samefile`, using the Python 3 dependency already required by its owned +process supervisor. Different existing files, missing paths and case-distinct +files on a case-sensitive volume still fail. The report destination cannot +alias and replace the executable. Windows retains its existing normalization +and 8.3-path coverage without a new Python dependency. Parent-symlink and actual +macOS `/var` fixtures run before native compilation; packaged failure JSON and +adjacent logs are retained even when the qualification step fails. + +Local Windows qualification of that correction ran 26 tests: 16 passed and 10 +POSIX-only cases were skipped. An explicit Windows PowerShell 5.1 wrapper run +passed 13 cases with four POSIX-only skips. Native POSIX/macOS fixture execution +and a new successful packaged release run remain required. Evidence is retained +under ignored `output/review/release-0106/`; it does not change the separate +audio, installation and listening limits above. + ## Desktop and recording acceptance Build packaged assets before native UI qualification. `tools/run-window-lifecycle-smoke.ps1` forces the packaged frontend; a running Vite must not influence the result. Use `-AppPath` for the exact Debug, Release or installed executable being tested. On an idle interactive Windows desktop, explicitly opt into physical mouse input and repeated pitch windows: diff --git a/tests/test_desktop_qualification.py b/tests/test_desktop_qualification.py index 06c6b2fc..ce1ec320 100644 --- a/tests/test_desktop_qualification.py +++ b/tests/test_desktop_qualification.py @@ -238,8 +238,8 @@ def test_powershell_wrapper_waits_for_app_but_disposes_retained_session(self): self.assert_child_stopped(self.child_record()["pid"]) -@unittest.skipUnless(POWERSHELL and os.name == "nt", "Windows PowerShell is required for lifecycle wrapper fixtures") -class WindowsLifecycleQualification(unittest.TestCase): +@unittest.skipUnless(POWERSHELL, "PowerShell is required for lifecycle wrapper fixtures") +class LifecycleQualification(unittest.TestCase): def setUp(self): temporary = tempfile.TemporaryDirectory() self.addCleanup(temporary.cleanup) @@ -330,6 +330,7 @@ def test_missing_packaged_shell_is_rejected(self): report["startupSelfTest"]["packagedFrontendPresent"] = False self.assertNotEqual(self.validate(report).returncode, 0) + @unittest.skipUnless(os.name == "nt", "WebView2 user data is Windows-specific") def test_user_data_paths_and_override_evidence_cannot_hide_install_directory_writes(self): for key in ("webView2UserDataPath", "userDataFolderOverride"): for user_data in (self.bundle, self.bundle / "browser-data"): @@ -400,13 +401,16 @@ def run_mock_process(self, *, mode="normal", stale=False, behavior="success", re return json.loads(record.read_text(encoding="utf-8-sig")) def test_wrapper_passes_safe_offline_flags_restores_environment_and_records_binary(self): - record = self.run_mock_process(mode="safe", stale=True) + record = self.run_mock_process(mode="safe", stale=os.name == "nt") self.assertTrue(record["success"], record.get("error")) self.assertIn("--ui-safe-mode", record["arguments"]) self.assertIn("--window-lifecycle-offline", record["arguments"]) self.assertEqual(record["waitMilliseconds"], 30000) self.assertEqual(record["childUI"], "1") - self.assertIn("missing-webview-runtime-", record["childRuntime"]) + if os.name == "nt": + self.assertIn("missing-webview-runtime-", record["childRuntime"]) + else: + self.assertEqual(record["childRuntime"], "parent-runtime") self.assertEqual((record["parentUI"], record["parentRuntime"]), ("parent-ui", "parent-runtime")) self.assertEqual(record["stopped"], []) self.assertTrue(record["disposed"]) @@ -429,7 +433,7 @@ def test_old_binary_is_refused_before_any_process_launch(self): def test_timeout_launch_failure_and_invalid_artifacts_restore_parent_state(self): for behavior in ("timeout", "start-failure", "invalid-json", "missing-report", "nonzero-exit", "cleanup-failure"): with self.subTest(behavior=behavior): - record = self.run_mock_process(stale=True, behavior=behavior) + record = self.run_mock_process(stale=os.name == "nt", behavior=behavior) self.assertFalse(record["success"]) self.assertEqual((record["parentUI"], record["parentRuntime"]), ("parent-ui", "parent-runtime")) self.assertEqual(record["stopped"], []) @@ -437,6 +441,7 @@ def test_timeout_launch_failure_and_invalid_artifacts_restore_parent_state(self) evidence = json.loads(self.report.with_suffix(".qualification.json").read_text(encoding="utf-8-sig")) self.assertEqual(evidence["status"], "fail") + @unittest.skipUnless(os.name == "nt", "WebView2 runtime overrides are Windows-specific") def test_stale_override_must_be_cleared_by_application(self): report = self.report_fixture() report["browserExecutableFolderOverride"] = "still-invalid" @@ -445,12 +450,74 @@ def test_stale_override_must_be_cleared_by_application(self): self.assertIn("not removed", record["error"]) def test_report_from_another_executable_cannot_qualify_this_binary(self): + unrelated_directory = self.root / "unrelated bundle" + unrelated_directory.mkdir() + unrelated = unrelated_directory / self.app.name + shutil.copyfile(self.app, unrelated) + for reported in (unrelated, unrelated_directory / "missing executable"): + with self.subTest(reported=reported): + report = self.report_fixture() + report["executablePath"] = str(reported) + record = self.run_mock_process(report=report) + self.assertFalse(record["success"]) + self.assertIn("different executable", record["error"]) + evidence = json.loads(self.report.with_suffix(".qualification.json").read_text(encoding="utf-8-sig")) + self.assertEqual(evidence["status"], "fail") + self.assertTrue(record["disposed"]) + + @unittest.skipUnless(os.name == "posix", "POSIX filesystem identity is required") + def test_parent_directory_symlink_identifies_the_same_executable(self): + alias = self.root / "bundle alias" + alias.symlink_to(self.bundle, target_is_directory=True) report = self.report_fixture() - report["executablePath"] = str(self.root / "unrelated" / "OpenStudio.exe") + report["executablePath"] = str(alias / self.app.name) + record = self.run_mock_process(report=report) + self.assertTrue(record["success"], record.get("error")) + # Exercise the opposite spelling too: launch through the alias while + # the native report resolves the parent directory to its real path. + real_app = self.app + self.app = alias / self.app.name + report["executablePath"] = str(real_app) + record = self.run_mock_process(report=report) + self.assertTrue(record["success"], record.get("error")) + + @unittest.skipUnless(sys.platform == "darwin", "macOS /var alias is required") + def test_macos_var_mount_alias_identifies_the_same_executable(self): + with tempfile.TemporaryDirectory(dir="/var/tmp", prefix="openstudio identity ") as temporary: + self.app = Path(temporary) / "OpenStudio" + self.app.write_bytes(b"fixture only --window-lifecycle-offline; never executed") + report = self.report_fixture() + report["executablePath"] = str(self.app.resolve(strict=True)) + self.assertNotEqual(report["executablePath"], str(self.app), "Fixture must exercise /var -> /private/var") + record = self.run_mock_process(report=report) + self.assertTrue(record["success"], record.get("error")) + + @unittest.skipUnless(os.name == "posix", "POSIX filesystem identity is required") + def test_case_distinct_executable_cannot_qualify_on_case_sensitive_volume(self): + distinct = self.app.with_name(self.app.name.swapcase()) + if distinct.exists(): + self.skipTest("The fixture volume is not case-sensitive") + shutil.copyfile(self.app, distinct) + self.assertFalse(os.path.samefile(self.app, distinct)) + report = self.report_fixture() + report["executablePath"] = str(distinct) record = self.run_mock_process(report=report) self.assertFalse(record["success"]) self.assertIn("different executable", record["error"]) + @unittest.skipUnless(os.name == "posix", "POSIX filesystem identity is required") + def test_report_path_alias_cannot_replace_the_executable(self): + alias = self.root / "bundle alias" + alias.symlink_to(self.bundle, target_is_directory=True) + self.report = alias / self.app.name + original = self.app.read_bytes() + record = self.run_mock_process() + self.assertFalse(record["success"]) + self.assertFalse(record["started"]) + self.assertIn("ReportPath must not replace", record["error"]) + self.assertEqual(self.app.read_bytes(), original) + + @unittest.skipUnless(os.name == "nt", "Windows 8.3 aliases are required") def test_short_executable_path_uses_the_same_identity_normalization(self): import ctypes original_app = self.app @@ -474,6 +541,7 @@ def test_short_executable_path_uses_the_same_identity_normalization(self): self.assertFalse(record["success"]) self.assertIn("different executable", record["error"]) + @unittest.skipUnless(os.name == "nt", "Windows token elevation is required") def test_standard_user_requirement_cannot_report_elevated_success(self): record = self.run_mock_process(require_standard=True) if record["success"]: @@ -483,6 +551,7 @@ def test_standard_user_requirement_cannot_report_elevated_success(self): self.assertFalse(record["started"]) self.assertIn("non-elevated Windows", record["error"]) + @unittest.skipUnless(os.name == "nt", "Windows Job ownership is required") def test_owned_job_removes_descendants_after_parent_exit_and_preserves_unrelated_process(self): module = LIFECYCLE.with_name("OwnedDesktopProcess.psm1") fixture_dir = self.root / "fixture scripts" diff --git a/tools/run-window-lifecycle-smoke.ps1 b/tools/run-window-lifecycle-smoke.ps1 index 63421d60..ca43a45b 100644 --- a/tools/run-window-lifecycle-smoke.ps1 +++ b/tools/run-window-lifecycle-smoke.ps1 @@ -112,6 +112,26 @@ function Read-LifecycleReport([string]$Path) { catch { throw "Invalid window lifecycle report JSON: $($_.Exception.Message)" } } +function Test-LifecycleExecutableIdentity([string]$ExpectedPath, [string]$ReportedPath) { + if ([string]::IsNullOrWhiteSpace($ReportedPath) -or + !(Test-Path -LiteralPath $ExpectedPath -PathType Leaf) -or + !(Test-Path -LiteralPath $ReportedPath -PathType Leaf)) { return $false } + if ($onWindows) { + return [IO.Path]::GetFullPath($ReportedPath).Equals( + [IO.Path]::GetFullPath($ExpectedPath), [StringComparison]::OrdinalIgnoreCase) + } + # GetFullPath is lexical: macOS /var and /private/var can name the same + # mounted executable. Compare filesystem identity, never file contents or + # case-folded POSIX paths. Python 3 is also required by the process owner. + $python = Get-Command python3 -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($null -eq $python) { throw 'Python 3 is required to verify POSIX executable identity.' } + $identity = & $python.Source -c 'import os, sys; print("same" if os.path.samefile(sys.argv[1], sys.argv[2]) else "different")' $ExpectedPath $ReportedPath + if ($LASTEXITCODE -ne 0 -or $identity -notin @('same', 'different')) { + throw 'Unable to verify POSIX executable identity.' + } + return $identity -eq 'same' +} + if ($ValidateOnly) { Assert-LifecycleReport (Read-LifecycleReport $ReportPath) $StartupMode Write-Output "Window lifecycle report validated ($StartupMode): $ReportPath" @@ -140,7 +160,7 @@ if ([string]::IsNullOrWhiteSpace($ReportPath)) { $ReportPath = Join-Path ([IO.Path]::GetTempPath()) ('OpenStudio_WindowLifecycle_' + [guid]::NewGuid().ToString('N') + '.json') } $ReportPath = [IO.Path]::GetFullPath($ReportPath) -if ($ReportPath -eq $resolvedAppPath) { throw 'ReportPath must not replace the executable.' } +if (Test-LifecycleExecutableIdentity $resolvedAppPath $ReportPath) { throw 'ReportPath must not replace the executable.' } $reportDirectory = Split-Path -Parent $ReportPath New-Item -ItemType Directory -Path $reportDirectory -Force | Out-Null if (Test-Path -LiteralPath $ReportPath) { Remove-Item -LiteralPath $ReportPath -Force } @@ -173,8 +193,8 @@ try { if ($process.ExitCode -ne 0) { throw "Window lifecycle process exited with code $($process.ExitCode). Report: $ReportPath" } $report = Read-LifecycleReport $ReportPath Assert-LifecycleReport $report $StartupMode - if (![IO.Path]::GetFullPath($report.executablePath).Equals($resolvedAppPath, [StringComparison]::OrdinalIgnoreCase)) { - throw 'Lifecycle report belongs to a different executable.' + if (!(Test-LifecycleExecutableIdentity $resolvedAppPath $report.executablePath)) { + throw "Lifecycle report belongs to a different executable. Expected '$resolvedAppPath'; reported '$($report.executablePath)'." } $frontendPath = $report.startupSelfTest.packagedFrontendPath if ([string]::IsNullOrWhiteSpace($frontendPath) -or !(Test-Path -LiteralPath $frontendPath -PathType Leaf)) { From aa27a4913316f43b4d090b7cbffe6b377f2679c3 Mon Sep 17 00:00:00 2001 From: Sourav Das Date: Sat, 24 Oct 2026 12:32:14 +0530 Subject: [PATCH 03/33] Prepare reviewed 0.1.07 release notes (#34) --- docs/releases/0.1.07.md | 148 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 docs/releases/0.1.07.md diff --git a/docs/releases/0.1.07.md b/docs/releases/0.1.07.md new file mode 100644 index 00000000..8291d91f --- /dev/null +++ b/docs/releases/0.1.07.md @@ -0,0 +1,148 @@ +# OpenStudio 0.1.07 + +The [full release comparison since v0.1.05](https://github.com/sdevil7th/OpenStudio/compare/v0.1.05...v0.1.07) +includes the free-plugin, NAM Rack and recording improvements reviewed in +[PR #31](https://github.com/sdevil7th/OpenStudio/pull/31), plus the release-wrapper +correction in [PR #33](https://github.com/sdevil7th/OpenStudio/pull/33). +The 0.1.06 tag did not produce a GitHub release or Store submission: its mounted +macOS qualification stopped at an executable-path comparison. The +[hotfix-only comparison](https://github.com/sdevil7th/OpenStudio/compare/v0.1.06...v0.1.07) +covers that correction and release records; the full feature overview below is +relative to the last published application version, 0.1.05. Testing and listening +limits remain explicit. + +## Highlights + +- Give the free effects and instruments more useful compact and large editors: + primary controls stay accessible, wider views show related controls and + analysis, and Find locates controls and workflows without silently changing + an effect's machine or mode. Exact entry, keyboard adjustment, Compare and + Undo retain the native parameter identities. +- Add independently switchable Fuzz alongside Octave in NAM Rack, with three + original voices adapted to the existing Guitar/Bass profiles. Clean blend now + lives on the pedal with a knob and recessed percentage display. It blends the input to Fuzz; + subsequent Drive, Distortion and amp processing still apply. +- Process eligible independent native-NAM audio tracks across persistent + workers, scaling with available physical cores. Unsupported hosted-plugin, + MIDI and routed paths retain serial processing. This introduces no extra + device buffering, but does not yet meet the four-track, 16-sample target. +- Add selectable Legacy/Corrected processing to selected free effects and + instruments. Corrected paths address Pitch Correct's Custom note masks and + rate-dependent analysis, Preamp bypass alignment, Saturator Foldback symmetry, + and oscillator/modulation timing. New instances use Corrected; older saved + states missing the setting retain Legacy. +- Add 41 authored Reverb voice presets and whole-collection recall while + retaining the eight historical factory voices. Optional return-level, + processing and Shimmer scheduling choices preserve older saved behavior. +- Add NAM Preset Library Profile review for mismatched Guitar/Bass labels and + an explicit separate-profile copy action that preserves the original preset + and the current rack. + +## Fixes + +- Compare POSIX executable identity rather than path spelling during packaged + macOS qualification. Parent aliases such as /var and /private/var no longer + reject the same file; different or missing executables still fail. Retain + packaged lifecycle reports and logs when a release check fails. +- Keep pending Audio Settings selections until Apply or OK instead of allowing + periodic device readback to replace a newly chosen buffer size. Report the + size actually accepted by the driver and distinguish recent callback and + track-processing failures from driver interruptions. +- Bound recording-waveform previews and serialize outstanding preview reads. + Dragging the ruler vertically zooms around its anchor without the recording + follow-scroll fighting the drag, including the interval between pressing + the ruler and crossing the drag threshold. With the default ruler mapping, + horizontal dragging sets the project/loop range; explicit Select time mappings retain + editing time selection. + Stop and cancelled gestures discard stale preview and viewport work. +- Copy selected audio and MIDI clips together with matching drag previews and + one Undo step, preserving relative time and track placement. Primary-drag + copies selected whole tracks. Piano Roll Primary-drag keeps pitch fixed; + Shift-drag keeps note timing fixed. +- Reduce excessive gain at moderate Vintage/Sustain fuzz settings and stabilize + downstream Distortion calculations and the shared high-pass filter at high + oversampling. Maximum fuzz gain remains available. Stacked gain can still + amplify input hiss; these changes do not claim a commercial pedal emulation. +- Improve Reverb damping, Hold headroom, inactive-engine retirement and Shimmer + work scheduling. Response measurement waits for pending control writes, so + its isolated wet-only measurement uses the displayed settings. Preserve + compatibility paths and stored parameters across the free suite. +- Preserve finite headroom in the standalone Gate and Clean compressor, and + include filter ringing, feedback, manual delays and instrument decay in + export-tail bounds. Embedded NAM and coloured compatibility paths retain + their prior bounds. +- Save NAM metadata, portable presets and Pitch Correct presets through a + bounded atomic JSON writer instead of the project-only writer. Failed saves + are reported without replacing the existing file or relaxing project checks. + Pitch presets and A/B retain the processing mode that gives their note mask + meaning; old presets without a mode recall Legacy behavior. +- Explain when NAM Rack reduces loud output peaks in plain language, separate + recent activity from earlier events, and retain distinct emergency-fault + messages. Numeric instrument controls do not change or add Undo history when + focused and left without an edit. +- Require the free-suite browser qualification to export schemas from the + current native build in Windows CI. An explicitly requested missing schema + now fails qualification rather than silently skipping those editor checks. +- Keep TONE3000 capture-loading errors visible in compact editors when a + request completes immediately after the details panel is collapsed. +- Preserve existing destination files when audio conversion finishes after + its deadline, including when the child process exits between polling checks. +- Correct ARA tempo and bar reader handles on 64-bit systems, and reject invalid + content types or event indices before reading event data. +- Make Linux desktop-entry packaging independent of the source checkout's line + endings. Create new automatic-update staging directories with private + permissions instead of inheriting group-write access and then rejecting + those directories. Existing unsafe paths remain rejected. +- Preserve Store listing settings when Microsoft changes read-only metadata, + and support validating and resuming the exact existing submission. Recovery + tags no longer trigger ordinary application release publication. + +## Known Issues + +- Four monitored, recording NAM tracks at 16 samples and 4x oversampling have + not passed the required sustained test. Retained short Debug and Release + probes contain missed deadlines or skipped work; 8 samples is also + unqualified. Average CPU readings and passing recording-file integrity checks + do not establish crackle-free monitoring. Oversampling is not lowered + automatically. +- Exact-artifact listening acceptance, physical audio/MIDI devices, long + recording sessions and mixed-DPI usability remain separate gates. Automated + audio checks establish specific signal, state and safety contracts, not + subjective fuzz, pitch or reverb quality. Demanding Shimmer and Pitch Correct + configurations retain measured deadline limits. +- Release-specific installed Windows upgrade/Repair, the affected Windows 10 + installation and current macOS package acceptance remain unverified. Linux + automated qualification is recorded in the testing guide; physical desktop + and audio-device acceptance of the published package remain separate gates. + Moving Linux downloads to native packages does not prove the original + AppImage reporter's failure resolved. Signing, reputation, Microsoft + certification and website/update-feed publication require the final artifact. +- Previously recorded third-party plugin export/state and automation issues + remain open in the roadmap, including AmpliTube cold-export parity, Nekobi + restoration and the native played-guitar Punch/AutoJoin timeout. This + release does not claim to resolve them. + +## Upgrade Notes + +- Back up projects and presets before upgrading. Use normal + in-place installation when a qualified update is published. On Windows, + uninstalling first removes user-scoped settings, optional runtimes and caches. +- Existing presets retain their compatibility mode. Newly saved Pitch presets + include that mode explicitly. Fuzz gain changes at moderate settings may + change sustain and the saturation of later pedals; audition saved rigs and + adjust Fuzz Level and the rack Gate intentionally. +- OpenStudio's default shortcuts change: R records, 1/Home seeks to absolute + project start, and +/- zoom the timeline. Track Arm and Piano Roll Select + Range use Primary+Shift+R; Pitch Editor Select uses V. Primary means Ctrl on + Windows/Linux and Command on macOS. Other profiles and custom overrides + retain priority. Use Keyboard, Mouse & Trackpad to inspect effective bindings. +- Changing a plugin's Signal path resets its processing history and requires + stopped configuration. Complete-state Undo, Compare and portable recall + retain the selected mode. +- Select the distribution-native Linux package matching the host. Published + download and update endpoints continue to describe the actual released + artifacts; the release workflow updates those feeds after publication. +- The [testing guide](../testing.md), [NAM Rack guide](../nam-rack.md) and + [free-plugin guide](../free-plugins.md) hold the workflows and qualification + limits. Build/test results must be tied to the tested source and + binary before release approval. From 2db99ef18f82915da61ffb97c7404f6904b860a6 Mon Sep 17 00:00:00 2001 From: Sourav Das Date: Sat, 24 Oct 2026 14:28:04 +0530 Subject: [PATCH 04/33] Prepare 0.1.08 recovery fixes and release notes (#35) * Fix plugin parameter history across consecutive gestures * Close native Touch on keyboard Undo and qualify WebKit history * Retain and summarize AppImage lifecycle failure evidence * Give the ruler browser fixture document-owned module lifetime * Prepare reviewed cumulative 0.1.08 release notes * Isolate AppImage qualification outputs without discarding evidence --- .github/workflows/release.yml | 3 +- .github/workflows/verify.yml | 3 +- docs/releases/0.1.08.md | 161 ++++++++++++++++++ docs/store-release-activation.md | 19 ++- docs/testing.md | 59 ++++++- frontend/e2e/nam-fuzz-workflow.spec.ts | 32 +++- frontend/e2e/support/rulerChecks.ts | 24 ++- frontend/playwright.config.ts | 2 +- .../src/__tests__/builtInPluginPanel.test.tsx | 89 ++++++++++ .../src/components/BuiltInPluginPanel.tsx | 53 ++++-- frontend/src/components/NAMRackDesignPort.tsx | 1 + tests/test_desktop_qualification.py | 92 ++++++++++ tests/test_linux_qualification.py | 105 ++++++++++++ tools/smoke-linux-appimage.py | 51 +++++- 14 files changed, 665 insertions(+), 29 deletions(-) create mode 100644 docs/releases/0.1.08.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5d0381d8..3fc14fa5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -886,9 +886,10 @@ jobs: "dist/linux/OpenStudio-${VERSION}-linux-x86_64.AppImage" - name: Exercise the finished AppImage launcher + # The preceding Openbox report stays in the parent; this suite requires fresh output. env: APPIMAGE_EXTRACT_AND_RUN: '1' - run: xvfb-run -a python3 tools/run-linux-qualification.py --app "dist/linux/OpenStudio-${VERSION}-linux-x86_64.AppImage" --output output/linux-appimage --desktop --render + run: xvfb-run -a python3 tools/run-linux-qualification.py --app "dist/linux/OpenStudio-${VERSION}-linux-x86_64.AppImage" --output output/linux-appimage/launcher --desktop --render - name: Attest Linux installer build provenance uses: actions/attest-build-provenance@43d14bc2b83dec42d39ecae14e916627a18bb661 # v3 diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index b4d1425b..51449f0c 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -869,11 +869,12 @@ jobs: run: python3 tools/smoke-linux-appimage.py "dist/linux/OpenStudio-${VERSION}-linux-x86_64.AppImage" - name: Exercise final AppImage launcher and native windows + # The preceding Openbox report stays in the parent; this suite requires fresh output. shell: bash env: APPIMAGE_EXTRACT_AND_RUN: '1' run: | - xvfb-run -a python3 tools/run-linux-qualification.py --app "dist/linux/OpenStudio-${VERSION}-linux-x86_64.AppImage" --output output/linux-appimage --desktop + xvfb-run -a python3 tools/run-linux-qualification.py --app "dist/linux/OpenStudio-${VERSION}-linux-x86_64.AppImage" --output output/linux-appimage/launcher --desktop - name: Retain Linux qualification evidence if: always() diff --git a/docs/releases/0.1.08.md b/docs/releases/0.1.08.md new file mode 100644 index 00000000..e1d72e52 --- /dev/null +++ b/docs/releases/0.1.08.md @@ -0,0 +1,161 @@ +# OpenStudio 0.1.08 + +The [full release comparison since v0.1.05](https://github.com/sdevil7th/OpenStudio/compare/v0.1.05...v0.1.08) +includes the free-plugin, NAM Rack and recording improvements reviewed in +[PR #31](https://github.com/sdevil7th/OpenStudio/pull/31), plus the release-wrapper +correction in [PR #33](https://github.com/sdevil7th/OpenStudio/pull/33) and +plugin Undo corrections in [PR #35](https://github.com/sdevil7th/OpenStudio/pull/35). +The 0.1.06 and 0.1.07 tag attempts did not publish a GitHub release or submit a +Store update. Their tags remain unchanged. The +[hotfix-only comparison since v0.1.07](https://github.com/sdevil7th/OpenStudio/compare/v0.1.07...v0.1.08) +covers the Undo corrections, qualification diagnostics and release records; +the full feature overview below is relative to the last published application +version, 0.1.05. Testing and listening limits remain explicit. + +## Highlights + +- Give the free effects and instruments more useful compact and large editors: + primary controls stay accessible, wider views show related controls and + analysis, and Find locates controls and workflows without silently changing + an effect's machine or mode. Exact entry, keyboard adjustment, Compare and + Undo retain the native parameter identities. +- Add independently switchable Fuzz alongside Octave in NAM Rack, with three + original voices adapted to the existing Guitar/Bass profiles. Clean blend now + lives on the pedal with a knob and recessed percentage display. It blends the input to Fuzz; + subsequent Drive, Distortion and amp processing still apply. +- Process eligible independent native-NAM audio tracks across persistent + workers, scaling with available physical cores. Unsupported hosted-plugin, + MIDI and routed paths retain serial processing. This introduces no extra + device buffering, but does not yet meet the four-track, 16-sample target. +- Add selectable Legacy/Corrected processing to selected free effects and + instruments. Corrected paths address Pitch Correct's Custom note masks and + rate-dependent analysis, Preamp bypass alignment, Saturator Foldback symmetry, + and oscillator/modulation timing. New instances use Corrected; older saved + states missing the setting retain Legacy. +- Add 41 authored Reverb voice presets and whole-collection recall while + retaining the eight historical factory voices. Optional return-level, + processing and Shimmer scheduling choices preserve older saved behavior. +- Add NAM Preset Library Profile review for mismatched Guitar/Bass labels and + an explicit separate-profile copy action that preserves the original preset + and the current rack. + +## Fixes + +- Keep consecutive edits of the same plugin parameter as separate Undo steps. + Fuzz Clean exact entry, a keyboard adjustment and double-click reset now + undo and redo individually even when you make the edits quickly. + Cancelled numeric entry adds no history step; + held NAM keyboard adjustments and wheel bursts retain their gesture grouping. + Keyboard Undo and Redo also end the active automation touch, matching the + toolbar, so automation does not remain touched after the edit is undone. +- Compare POSIX executable identity rather than path spelling during packaged + macOS qualification. Parent aliases such as /var and /private/var no longer + reject the same file; different or missing executables still fail. Retain + packaged lifecycle reports and logs when a release check fails, including + AppImage's separate window-manager check. Print bounded failure diagnostics + without weakening the native checks. +- Keep pending Audio Settings selections until Apply or OK instead of allowing + periodic device readback to replace a newly chosen buffer size. Report the + size actually accepted by the driver and distinguish recent callback and + track-processing failures from driver interruptions. +- Bound recording-waveform previews and serialize outstanding preview reads. + Dragging the ruler vertically zooms around its anchor without the recording + follow-scroll fighting the drag, including the interval between pressing + the ruler and crossing the drag threshold. With the default ruler mapping, + horizontal dragging sets the project/loop range; explicit Select time mappings retain + editing time selection. + Stop and cancelled gestures discard stale preview and viewport work. +- Copy selected audio and MIDI clips together with matching drag previews and + one Undo step, preserving relative time and track placement. Primary-drag + copies selected whole tracks. Piano Roll Primary-drag keeps pitch fixed; + Shift-drag keeps note timing fixed. +- Reduce excessive gain at moderate Vintage/Sustain fuzz settings and stabilize + downstream Distortion calculations and the shared high-pass filter at high + oversampling. Maximum fuzz gain remains available. Stacked gain can still + amplify input hiss; these changes do not claim a commercial pedal emulation. +- Improve Reverb damping, Hold headroom, inactive-engine retirement and Shimmer + work scheduling. Response measurement waits for pending control writes, so + its isolated wet-only measurement uses the displayed settings. Preserve + compatibility paths and stored parameters across the free suite. +- Preserve finite headroom in the standalone Gate and Clean compressor, and + include filter ringing, feedback, manual delays and instrument decay in + export-tail bounds. Embedded NAM and coloured compatibility paths retain + their prior bounds. +- Save NAM metadata, portable presets and Pitch Correct presets through a + bounded atomic JSON writer instead of the project-only writer. Failed saves + are reported without replacing the existing file or relaxing project checks. + Pitch presets and A/B retain the processing mode that gives their note mask + meaning; old presets without a mode recall Legacy behavior. +- Explain when NAM Rack reduces loud output peaks in plain language, separate + recent activity from earlier events, and retain distinct emergency-fault + messages. Numeric instrument controls do not change or add Undo history when + focused and left without an edit. +- Require the free-suite browser qualification to export schemas from the + current native build in Windows CI. An explicitly requested missing schema + now fails qualification rather than silently skipping those editor checks. +- Keep TONE3000 capture-loading errors visible in compact editors when a + request completes immediately after the details panel is collapsed. +- Preserve existing destination files when audio conversion finishes after + its deadline, including when the child process exits between polling checks. +- Correct ARA tempo and bar reader handles on 64-bit systems, and reject invalid + content types or event indices before reading event data. +- Make Linux desktop-entry packaging independent of the source checkout's line + endings. Create new automatic-update staging directories with private + permissions instead of inheriting group-write access and then rejecting + those directories. Existing unsafe paths remain rejected. +- Preserve Store listing settings when Microsoft changes read-only metadata, + and support validating and resuming the exact existing submission. Recovery + tags no longer trigger ordinary application release publication. + +## Known Issues + +- Four monitored, recording NAM tracks at 16 samples and 4x oversampling have + not passed the required sustained test. Retained short Debug and Release + probes contain missed deadlines or skipped work; 8 samples is also + unqualified. Average CPU readings and passing recording-file integrity checks + do not establish crackle-free monitoring. Oversampling is not lowered + automatically. +- Exact-artifact listening acceptance, physical audio/MIDI devices, long + recording sessions and mixed-DPI usability remain separate gates. Automated + audio checks establish specific signal, state and safety contracts, not + subjective fuzz, pitch or reverb quality. Demanding Shimmer and Pitch Correct + configurations retain measured deadline limits. +- Release-specific installed Windows upgrade/Repair, the affected Windows 10 + installation and current macOS package acceptance remain unverified. Linux + automated qualification is recorded in the testing guide; physical desktop + and audio-device acceptance of the published package remain separate gates. + An earlier recovery-branch AppImage lifecycle failure lost its per-check + report, so that failure remains unclassified. Qualification now retains that + evidence; this does not establish the cause of the earlier failure. + Moving Linux downloads to native packages does not prove the original + AppImage reporter's failure resolved. Signing, reputation, Microsoft + certification and website/update-feed publication require the final artifact. +- Previously recorded third-party plugin export/state and automation issues + remain open in the roadmap, including AmpliTube cold-export parity, Nekobi + restoration and the native played-guitar Punch/AutoJoin timeout. This + release does not claim to resolve them. + +## Upgrade Notes + +- Back up projects and presets before upgrading. Use normal + in-place installation when a qualified update is published. On Windows, + uninstalling first removes user-scoped settings, optional runtimes and caches. +- Existing presets retain their compatibility mode. Newly saved Pitch presets + include that mode explicitly. Fuzz gain changes at moderate settings may + change sustain and the saturation of later pedals; audition saved rigs and + adjust Fuzz Level and the rack Gate intentionally. +- OpenStudio's default shortcuts change: R records, 1/Home seeks to absolute + project start, and +/- zoom the timeline. Track Arm and Piano Roll Select + Range use Primary+Shift+R; Pitch Editor Select uses V. Primary means Ctrl on + Windows/Linux and Command on macOS. Other profiles and custom overrides + retain priority. Use Keyboard, Mouse & Trackpad to inspect effective bindings. +- Changing a plugin's Signal path resets its processing history and requires + stopped configuration. Complete-state Undo, Compare and portable recall + retain the selected mode. +- Select the distribution-native Linux package matching the host. Published + download and update endpoints continue to describe the actual released + artifacts; the release workflow updates those feeds after publication. +- The [testing guide](../testing.md), [NAM Rack guide](../nam-rack.md) and + [free-plugin guide](../free-plugins.md) hold the workflows and qualification + limits. Build/test results must be tied to the tested source and + binary before release approval. diff --git a/docs/store-release-activation.md b/docs/store-release-activation.md index 8fc69ab0..a7761b49 100644 --- a/docs/store-release-activation.md +++ b/docs/store-release-activation.md @@ -15,6 +15,19 @@ executable-path comparison, which does not account for POSIX aliases. GitHub pub submission were skipped; preflight performed no Store mutation. A corrected release must run its own preflight and submit its exact new package. +[PR #33](https://github.com/sdevil7th/OpenStudio/pull/33) corrects the POSIX +file-identity check and retains packaged lifecycle evidence on failure. Its +alias fixtures passed on both native macOS architectures. The subsequent +[0.1.07 run](https://github.com/sdevil7th/OpenStudio/actions/runs/40072590094) +was deliberately cancelled before publication after macOS browser CI exposed +a separate parameter-history race: consecutive edits of Fuzz Clean could merge, +making Undo skip an intermediate value. [PR #35](https://github.com/sdevil7th/OpenStudio/pull/35) +captures those history boundaries without duplicating native automation begins, +and closes Touch gestures when keyboard Undo/Redo cancels their deferred commit. +Neither attempt published a GitHub application release or submitted a Store +update; 0.1.07 did not reach live Store preflight. Release notes for the follow-up +must retain the full feature range since the last published version, 0.1.05. + The [October 8 recovery run](https://github.com/sdevil7th/OpenStudio/actions/runs/37806805920) successfully uploaded and committed the existing submission `1152921505702075647` for package `0.1.5.0`. Its retained report reached @@ -26,14 +39,14 @@ Store upgrade. The October 24 repository check confirms `OPENSTUDIO_STORE_ENABLED=true`, the three required `MS_STORE_*` environment secret names, and the `v*`-only -Store environment without required reviewers. A normal `v0.1.06` tag therefore -builds and validates MSIX `0.1.6.0`, runs live read-only Store preflight before +Store environment without required reviewers. A normal numeric application tag +builds and validates its matching MSIX, runs live read-only Store preflight before GitHub publication, then submits the matching package after publication. The new tag's preflight must establish the current published baseline and reject an unrelated pending submission. The release-specific publishing policy remains scoped to `v0.1.05`. -`v0.1.06` inherits the last published submission's saved publishing mode. +Later application tags inherit the last published submission's saved publishing mode. `Immediate` publishes after successful certification; `Manual` retains a publication hold. Do not rerun the old recovery or extend its policy without checking the current baseline. Retain the new release's sanitized readiness diff --git a/docs/testing.md b/docs/testing.md index 63bffad2..a0e34e34 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -465,6 +465,29 @@ final run; the baseline's 43/62 amp/cab misses at 44.1 kHz / 8 and the earlier after-run's 11/35 demonstrate why noisy single runs cannot establish an overall performance improvement. +### Plugin parameter gesture history + +Run `npm run test -- src/__tests__/builtInPluginPanel.test.tsx +src/__tests__/builtInPluginParamHistory.test.ts` from `frontend`, then run +`npx playwright test e2e/nam-fuzz-workflow.spec.ts --project=chromium --project=webkit` on +Windows and macOS. The browser fixture starts its own Vite server and uses +the platform's primary Undo modifier. + +For Fuzz Clean, enter 38.4%, cancel a 75% draft, adjust finely to 38.6%, +then reset to 0%. Three Undo operations must restore 38.6%, 38.4%, and 0%; +Redo must replay the three committed edits. Cancel must add no history step. +Exercise transitions before deferred blur/key-up commits run, with native +writes still pending; a delay added between actions can hide the race. +Also verify pointer-to-focus handoff, held-key and wheel continuation, +separate gestures of the same parameter, dependent parameter changes and +failed writes. History boundaries must not duplicate native automation +begins or allow an older gesture end to close a newer gesture. Keyboard Undo/Redo +must finish native Touch even when it cancels the pending edit-commit timer; +the browser regression checks balanced native begin/end calls as well as values. +The core workflow retains its 30-second case budget. WebKit's image-heavy +geometry and preset-copy cases allow 90 seconds, with assertion deadlines +unchanged; this separates screenshot cost from a missing functional transition. + ### Swollen Pickle reference fuzz review, October 23, 2026 This is working-tree evidence against base app commit @@ -1246,6 +1269,15 @@ use typed helpers in `frontend/e2e/support/`; no standalone browser script or pre-running server is needed. Public behavior and profile-dependent ruler actions are described in [input profiles](input-profiles.md#mouse-and-wheel-safety). +The PR #35 Ubuntu trace at `be48dfc1` failed in the ruler fixture's asynchronous +module bootstrap with `Resulting promise was garbage collected`, before any +ruler interaction. It showed one successful document load, no Vite reconnect or +reload, and the fixture's seeded track already rendered. The fixture now uses +App's exact store URL in a document-owned module script, followed by synchronous +setup, as the recording fixture already does. This corrects test lifetime +ownership; all ruler assertions and timeouts remain unchanged. It does not +establish the cause of the separate packaged AppImage lifecycle exit 2. + Recording zoom coverage is in `recordingWaveformPreview.test.ts` and `e2e/recording-zoom.spec.ts`. The browser fixture seeds two takes at 556 seconds, holds the serial preview response, and exercises the visible ruler in both @@ -2250,11 +2282,34 @@ adjacent logs are retained even when the qualification step fails. Local Windows qualification of that correction ran 26 tests: 16 passed and 10 POSIX-only cases were skipped. An explicit Windows PowerShell 5.1 wrapper run -passed 13 cases with four POSIX-only skips. Native POSIX/macOS fixture execution -and a new successful packaged release run remain required. Evidence is retained +passed 13 cases with four POSIX-only skips. Native POSIX/macOS fixtures +subsequently passed in PR #33 on both macOS architectures; a new successful +packaged release run remains required. Evidence is retained under ignored `output/review/release-0106/`; it does not change the separate audio, installation and listening limits above. +The recovery branch's initial [PR #35 run](https://github.com/sdevil7th/OpenStudio/actions/runs/40074265576) +passed the installed Linux package's normal/safe, render, runtime and engine +checks, plus AppImage contents and ABI checks. Its separate Openbox AppImage +lifecycle process exited with code 2. The report was outside the uploaded +artifact directories, so that run cannot identify the failed native check; +do not classify it as a missing-assets failure or a passing lifecycle test. +`smoke-linux-appimage.py` now defaults to +`output/linux-appimage/window-manager-lifecycle.json`, inside the retained +artifact tree, and prints bounded, allowlisted failed-check/startup diagnostics. +Its adjacent application and startup logs are retained too. Fixtures cover the +actual default invocation, malformed reports, failure details and owned-process +cleanup. The [diagnostic run](https://github.com/sdevil7th/OpenStudio/actions/runs/40075858881) +then passed all 85 Openbox lifecycle checks and all ten frontend roles reached +ready, using the extracted AppImage executable. This does not identify the +earlier exit-2 cause. Its subsequent launcher suite stopped before launching +because the retained report made that suite's output directory nonempty. +Keep the window-manager report in the parent and run the launcher suite in +fresh `output/linux-appimage/launcher`; both remain inside the uploaded artifact +tree. The freshness guard must continue rejecting existing evidence, and +neither a passing Openbox check nor a composition fixture qualifies the final +launcher suite on its own. + ## Desktop and recording acceptance Build packaged assets before native UI qualification. `tools/run-window-lifecycle-smoke.ps1` forces the packaged frontend; a running Vite must not influence the result. Use `-AppPath` for the exact Debug, Release or installed executable being tested. On an idle interactive Windows desktop, explicitly opt into physical mouse input and repeated pitch windows: diff --git a/frontend/e2e/nam-fuzz-workflow.spec.ts b/frontend/e2e/nam-fuzz-workflow.spec.ts index 61c8386f..5403aaae 100644 --- a/frontend/e2e/nam-fuzz-workflow.spec.ts +++ b/frontend/e2e/nam-fuzz-workflow.spec.ts @@ -6,7 +6,7 @@ const address = { trackId: "nam-fuzz-workflow-qa", chain: "track", fxIndex: 0 } const url = `/?${new URLSearchParams({ window: "pluginEditor", platform: "windows", windowChrome: "native", mockPlugin: "nam", sessionId: JSON.stringify({ address, title: "OpenStudio NAM Rack", fallbackName: "OpenStudio NAM Rack" }), namView: "rack", namFocus: "gate", namSection: "pre" })}`; type PresetEntries = Awaited>; interface ProfileCopyQa { entries: PresetEntries; calls: [string, string, 0 | 1][]; fail: boolean } -declare global { interface Window { namProfileCopyQa: ProfileCopyQa } } +declare global { interface Window { namProfileCopyQa: ProfileCopyQa; namFuzzGestureQa: boolean[] } } async function openRack(page: Page) { await page.goto(url); @@ -23,6 +23,16 @@ async function values(page: Page) { test("Fuzz Clean pedal knob supports exact entry, cancel, keyboard, reset and Undo", async ({ page }) => { await openRack(page); + await page.evaluate(async () => { + const path = "/src/services/NativeBridge.ts"; + const { nativeBridge } = await import(/* @vite-ignore */ path) as typeof import("../src/services/NativeBridge"); + window.namFuzzGestureQa = []; + const original = nativeBridge.builtInPluginGesture.bind(nativeBridge); + nativeBridge.builtInPluginGesture = async (address, id, starting) => { + if (id === "fuzzCleanMix") window.namFuzzGestureQa.push(starting); + return original(address, id, starting); + }; + }); const undoShortcut = `${await primaryModifier(page)}+z`; const knob = page.getByRole("slider", { name: "Fuzz Clean blend", exact: true }); await expect(knob).toHaveAccessibleDescription(/Fuzz is bypassed/); @@ -51,10 +61,25 @@ test("Fuzz Clean pedal knob supports exact entry, cancel, keyboard, reset and Un await expect(knob).toHaveAttribute("aria-valuenow", "0.384"); await page.keyboard.press(undoShortcut); await expect(knob).toHaveAttribute("aria-valuenow", "0"); + const redoShortcut = `${await primaryModifier(page)}+Shift+z`; + for (const expected of ["0.384", "0.386", "0"]) { + await page.keyboard.press(redoShortcut); + await expect(knob).toHaveAttribute("aria-valuenow", expected); + await expect.poll(async () => (await values(page))?.fuzzCleanMix).toBeCloseTo(Number(expected)); + } + await expect.poll(() => page.evaluate(() => { + const gestures = window.namFuzzGestureQa; + return gestures[gestures.length - 1]; + })).toBe(false); + const gestures = await page.evaluate(() => window.namFuzzGestureQa); + expect(gestures.length).toBeGreaterThan(0); + expect(gestures).toEqual(Array.from({ length: gestures.length }, (_, index) => index % 2 === 0)); }); for (const viewport of [{ width: 640, height: 480 }, { width: 900, height: 600 }, { width: 1280, height: 900 }, { width: 1920, height: 1080 }, { width: 3840, height: 2160 }]) { - test(`Fuzz Clean is integrated without overlapping controls at ${viewport.width}x${viewport.height}`, async ({ page }, testInfo) => { + test(`Fuzz Clean is integrated without overlapping controls at ${viewport.width}x${viewport.height}`, async ({ page, browserName }, testInfo) => { + // WebKit's image-heavy rack workflows need more case time; assertion deadlines stay unchanged. + if (browserName === "webkit") test.setTimeout(90_000); await page.setViewportSize(viewport); await openRack(page); const knob = page.getByRole("slider", { name: "Fuzz Clean blend", exact: true }); @@ -108,7 +133,8 @@ for (const viewport of [{ width: 640, height: 480 }, { width: 900, height: 600 } } for (const width of [1280, 900, 640]) { -test(`profile review at ${width}px creates only an explicitly confirmed library copy and reports duplicate or failed copies`, async ({ page }, testInfo) => { +test(`profile review at ${width}px creates only an explicitly confirmed library copy and reports duplicate or failed copies`, async ({ page, browserName }, testInfo) => { + if (browserName === "webkit") test.setTimeout(90_000); await page.setViewportSize({ width, height: width === 640 ? 480 : 900 }); await openRack(page); if (width <= 900) { diff --git a/frontend/e2e/support/rulerChecks.ts b/frontend/e2e/support/rulerChecks.ts index ff281ced..33a02ad9 100644 --- a/frontend/e2e/support/rulerChecks.ts +++ b/frontend/e2e/support/rulerChecks.ts @@ -1,5 +1,5 @@ import type { Page } from "@playwright/test"; -import type { DAWState, useDAWStore } from "../../src/store/useDAWStore"; +import type { DAWState, createDefaultTrack, useDAWStore } from "../../src/store/useDAWStore"; import type { BrowserRegressionReport } from "./interactionFixtureTypes"; type RulerSeedOverrides = Partial>; @@ -16,6 +16,7 @@ interface RulerView { declare global { interface Window { __rulerGestureQA: { store: typeof useDAWStore }; + __rulerGestureModules: { store: typeof useDAWStore; createDefaultTrack: typeof createDefaultTrack }; } } @@ -46,12 +47,21 @@ export async function runRulerChecks(page: Page): Promise { - // Follow App's transformed Vite import so HMR cannot create a second store. - const source = await (await fetch('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/src/App.tsx')).text(); - const match = source.match(/from "([^"]*useDAWStore\.ts[^"]*)"/); - if (!match) throw new Error('Cannot locate the real App store import'); - const { useDAWStore: store, createDefaultTrack } = await import(/* @vite-ignore */ match[1]) as typeof import("../../src/store/useDAWStore"); + await page.getByRole('contentinfo', { name: 'Transport controls' }).waitFor({ state: 'visible' }); + // Follow App's exact Vite URL, but let the document own module loading: + // Chromium can collect a CDP evaluation's pending dynamic-import Promise. + const appResponse = await page.request.get('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/src/App.tsx'); + assert(appResponse.ok(), 'App source must be available for the fixture'); + const source = await appResponse.text(); + const storeUrl = source.match(/from "([^"]*useDAWStore\.ts[^"]*)"/)?.[1]; + assert(storeUrl, 'Cannot locate the real App store import'); + await page.addScriptTag({ type: 'module', content: ` + import { useDAWStore as store, createDefaultTrack } from ${JSON.stringify(storeUrl)}; + window.__rulerGestureModules = { store, createDefaultTrack }; + ` }); + await page.waitForFunction(() => Boolean(window.__rulerGestureModules)); + await page.evaluate(() => { + const { store, createDefaultTrack } = window.__rulerGestureModules; const track = createDefaultTrack('ruler-browser-qa', 'Ruler browser QA', '#4379ee', 'audio', []); window.__rulerGestureQA = { store }; store.setState({ diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index e6a82681..dd255480 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -34,7 +34,7 @@ export default defineConfig({ userAgent: process.platform === 'darwin' ? devices['Desktop Safari'].userAgent : `Mozilla/5.0 (${desktopIdentity}) AppleWebKit/605.1.15 (KHTML, like Gecko) Safari/605.1.15`, }, - testMatch: /(?:media-file-dialogs|mixer-controls|input-profile-wheel|automation-input-runtime|audio-settings-switching|ui-audit-regressions|render-export-flow|profile-onboarding|nam-rack-approved-surfaces)\.spec\.ts/, + testMatch: /(?:media-file-dialogs|mixer-controls|input-profile-wheel|automation-input-runtime|audio-settings-switching|ui-audit-regressions|render-export-flow|profile-onboarding|nam-rack-approved-surfaces|nam-fuzz-workflow)\.spec\.ts/, }, ], webServer: { diff --git a/frontend/src/__tests__/builtInPluginPanel.test.tsx b/frontend/src/__tests__/builtInPluginPanel.test.tsx index 96aa594b..6fe87d90 100644 --- a/frontend/src/__tests__/builtInPluginPanel.test.tsx +++ b/frontend/src/__tests__/builtInPluginPanel.test.tsx @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import { BuiltInPluginPanel, BuiltInParamControl, + beginBuiltInPluginParamGesture, createFrameCoalescedParamWriter, createParamWriteReconciler, createSchemaRequestGate, @@ -19,6 +20,7 @@ import { SuiteParameter } from "../components/builtin/SuiteParameter"; import { reverbControlLayout } from "../utils/reverbControlLayout"; import { ReverbEditor } from "../components/builtin/ReverbEditor"; import { capturePluginState, eqValues } from "../components/builtin/EQToolbar"; +import { BuiltInPluginParamHistory, type BuiltInPluginHistoryReplay } from "../utils/builtInPluginParamHistory"; function param(overrides: Partial): BuiltInParamDescriptor { return { @@ -200,6 +202,93 @@ const panelSchemas = [ ]), ]; +describe("built-in parameter gesture boundaries", () => { + function harness(initial: Record, flush = async () => true) { + const history = new BuiltInPluginParamHistory("gesture-window"); + const values = { ...initial }; + const commits: Promise[] = []; + const begin = (id: string, continuing = false) => beginBuiltInPluginParamGesture( + history, id, key => history.begin(key, key, values[key]), + () => { commits.push(history.commit(flush)); }, continuing, + ); + const change = (id: string, value: number) => { + history.update(id, value); + values[id] = value; + }; + const commit = () => { commits.push(history.commit(flush)); }; + const replay: BuiltInPluginHistoryReplay = async (entry, direction) => { + for (const change of entry.changes) values[change.paramId] = change[direction]; + return true; + }; + return { history, values, commits, begin, change, commit, replay }; + } + + it("captures exact entry, keyboard adjustment and reset separately before pending native writes finish", async () => { + let release!: (value: boolean) => void; + const pendingWrite = new Promise(resolve => { release = resolve; }); + const edit = harness({ fuzzCleanMix: 0 }, () => pendingWrite); + edit.begin("fuzzCleanMix"); edit.change("fuzzCleanMix", .384); + // Focus restoration and another edit can occur in the same browser task, + // before either the blur/keyup timer or the native write has completed. + edit.begin("fuzzCleanMix"); edit.change("fuzzCleanMix", .386); + edit.begin("fuzzCleanMix"); edit.change("fuzzCleanMix", 0); + edit.commit(); + expect(edit.history.getUndoEntries()).toHaveLength(0); + release(true); await Promise.all(edit.commits); + expect(edit.history.getUndoEntries().map(entry => entry.changes[0])).toEqual([ + { paramId: "fuzzCleanMix", label: "fuzzCleanMix", before: 0, after: .384 }, + { paramId: "fuzzCleanMix", label: "fuzzCleanMix", before: .384, after: .386 }, + { paramId: "fuzzCleanMix", label: "fuzzCleanMix", before: .386, after: 0 }, + ]); + for (const expected of [.386, .384, 0]) { + expect(await edit.history.undo(async () => true, edit.replay)).toBe("applied"); + expect(edit.values.fuzzCleanMix).toBe(expected); + } + for (const expected of [.384, .386, 0]) { + expect(await edit.history.redo(async () => true, edit.replay)).toBe("applied"); + expect(edit.values.fuzzCleanMix).toBe(expected); + } + }); + + it("retains held-key and wheel continuations as one edit, while a new gesture of that parameter is separate", async () => { + const edit = harness({ mix: .5 }); + edit.begin("mix"); edit.change("mix", .51); + edit.begin("mix", true); edit.change("mix", .52); + edit.begin("mix", true); edit.change("mix", .53); + expect(edit.commits).toHaveLength(0); + edit.begin("mix"); edit.change("mix", .6); edit.commit(); + await Promise.all(edit.commits); + expect(edit.history.getUndoEntries().map(entry => entry.changes[0])).toEqual([ + { paramId: "mix", label: "mix", before: .5, after: .53 }, + { paramId: "mix", label: "mix", before: .53, after: .6 }, + ]); + }); + + it("keeps dependent parameter changes together and closes them before another parameter's gesture", async () => { + const edit = harness({ profile: 0, gate: -60, mix: .5 }); + edit.begin("profile"); edit.change("profile", 1); + edit.history.begin("gate", "gate", -60); edit.change("gate", -48); + edit.begin("mix", true); edit.change("mix", .6); edit.commit(); + await Promise.all(edit.commits); + expect(edit.history.getUndoEntries().map(entry => entry.changes.map(change => change.paramId))) + .toEqual([["profile", "gate"], ["mix"]]); + await edit.history.undo(async () => true, edit.replay); + await edit.history.undo(async () => true, edit.replay); + expect(edit.values).toEqual({ profile: 0, gate: -60, mix: .5 }); + }); + + it("keeps a rejected flush from merging its captured edit into the next gesture", async () => { + const flush = vi.fn().mockResolvedValueOnce(false).mockResolvedValue(true); + const edit = harness({ mix: 0 }, flush); + edit.begin("mix"); edit.change("mix", .384); + edit.begin("mix"); edit.change("mix", .386); edit.commit(); + expect(await Promise.all(edit.commits)).toEqual([false, true]); + expect(edit.history.getUndoEntries()).toHaveLength(1); + expect(await edit.history.undo(async () => true, edit.replay)).toBe("applied"); + expect(edit.values.mix).toBe(.384); + }); +}); + describe("BuiltInPluginPanel schema model", () => { it("coalesces NAM parameter writes only within an animation frame", async () => { const frames = createFrameHarness(); diff --git a/frontend/src/components/BuiltInPluginPanel.tsx b/frontend/src/components/BuiltInPluginPanel.tsx index d1ea15f5..a748097f 100644 --- a/frontend/src/components/BuiltInPluginPanel.tsx +++ b/frontend/src/components/BuiltInPluginPanel.tsx @@ -151,6 +151,19 @@ const PARAM_ADJUSTMENT_KEYS = new Set([ "End", ]); +/** Capture a completed edit before a new gesture can reuse the same parameter. */ +export function beginBuiltInPluginParamGesture( + history: BuiltInPluginParamHistory, + paramId: string, + begin: (paramId: string) => boolean, + commit: () => void, + continueSameParam = false, +): boolean { + if (history.getActiveParamId() + && (!continueSameParam || !history.hasActiveParam(paramId))) commit(); + return begin(paramId); +} + export function createNAMBootSchema(address: BuiltInPluginAddress, fallbackName: string): BuiltInPluginSchema { return { schemaVersion: 1, @@ -993,6 +1006,7 @@ function BuiltInPluginPanelContent({ const paramCommitTimerRef = useRef(null); const pointerParamRef = useRef(null); const keyboardParamRef = useRef(null); + const wheelParamRef = useRef(null); const clearScheduledParamCommit = useCallback(() => { if (paramCommitTimerRef.current === null) return; @@ -1032,6 +1046,7 @@ function BuiltInPluginPanelContent({ clearScheduledParamCommit(); paramCommitTimerRef.current = window.setTimeout(() => { paramCommitTimerRef.current = null; + wheelParamRef.current = null; void paramHistory.commit(flushParamWrites).finally(historyChanged); void finishAutomationGestures(); }, delayMs); @@ -1047,13 +1062,19 @@ function BuiltInPluginPanelContent({ return paramHistory.begin(paramId, currentParam.label, currentParam.value); }, [paramHistory, writeAddress]); - const beginExclusiveParamGesture = useCallback((paramId: string) => { + const beginExclusiveParamGesture = useCallback((paramId: string, continueSameParam = false) => { clearScheduledParamCommit(); - if (paramHistory.getActiveParamId() && !paramHistory.hasActiveParam(paramId)) { + const preservesAutomationGesture = paramHistory.hasActiveParam(paramId); + return beginBuiltInPluginParamGesture(paramHistory, paramId, beginParamEdit, () => { + // commit() takes the edit synchronously, before the async native flush. + // A new interaction must not cancel a pending key-up/blur commit and + // silently merge its history into another edit of the same parameter. void paramHistory.commit(flushParamWrites).finally(historyChanged); - void finishAutomationGestures(); - } - return beginParamEdit(paramId); + // Preserve an already-open native touch gesture for this parameter. + // Ending it asynchronously and immediately reopening it can send two + // begins before the old end, or let the old end close the new gesture. + if (!preservesAutomationGesture) void finishAutomationGestures(); + }, continueSameParam); }, [beginParamEdit, clearScheduledParamCommit, flushParamWrites, paramHistory, finishAutomationGestures, historyChanged]); const replayParamHistory = useCallback(async ( @@ -1113,10 +1134,12 @@ function BuiltInPluginPanelContent({ canRedo: paramHistory.canRedo(), undo: () => { clearScheduledParamCommit(); + void finishAutomationGestures(); void paramHistory.undo(flushParamWrites, replayParamHistory).finally(historyChanged); }, redo: () => { clearScheduledParamCommit(); + void finishAutomationGestures(); void paramHistory.redo(flushParamWrites, replayParamHistory).finally(historyChanged); }, }); @@ -1339,6 +1362,7 @@ function BuiltInPluginPanelContent({ const finishPointerParamGesture = () => { const paramId = pointerParamRef.current; pointerParamRef.current = null; + if (wheelParamRef.current === paramId) wheelParamRef.current = null; if (paramId && paramHistory.hasActiveParam(paramId)) { // Capture runs before the control's own pointer-up handler. Deferring the // commit keeps that handler's final value in the same history gesture. @@ -1361,6 +1385,8 @@ function BuiltInPluginPanelContent({ activateShortcutContext({ kind: "plugin", sessionId: pluginShortcutSessionId }); const paramId = paramIdFromEventTarget(event.target); pointerParamRef.current = paramId; + keyboardParamRef.current = null; + wheelParamRef.current = null; if (paramId) beginExclusiveParamGesture(paramId); else if (paramHistory.getActiveParamId()) { clearScheduledParamCommit(); @@ -1374,15 +1400,20 @@ function BuiltInPluginPanelContent({ onWheelCapture={(event) => { const paramId = paramIdFromEventTarget(event.target); if (!paramId || resolveProfiledParameterWheel(event.nativeEvent).operation !== "adjust") return; - beginExclusiveParamGesture(paramId); + const continuing = wheelParamRef.current === paramId; + keyboardParamRef.current = null; + wheelParamRef.current = paramId; + beginExclusiveParamGesture(paramId, continuing); scheduleParamCommit(); }} onKeyDownCapture={(event) => { if (!PARAM_ADJUSTMENT_KEYS.has(event.key)) return; const paramId = paramIdFromEventTarget(event.target); if (!paramId) return; + const continuing = keyboardParamRef.current === paramId; keyboardParamRef.current = paramId; - beginExclusiveParamGesture(paramId); + wheelParamRef.current = null; + beginExclusiveParamGesture(paramId, continuing); }} onKeyUpCapture={(event) => { if (!PARAM_ADJUSTMENT_KEYS.has(event.key)) return; @@ -1395,7 +1426,8 @@ function BuiltInPluginPanelContent({ onFocusCapture={(event) => { activateShortcutContext({ kind: "plugin", sessionId: pluginShortcutSessionId }); const paramId = paramIdFromEventTarget(event.target); - if (paramId) beginExclusiveParamGesture(paramId); + if (paramId) beginExclusiveParamGesture(paramId, + pointerParamRef.current === paramId || keyboardParamRef.current === paramId); }} onBlur={(event) => { // Numeric fields commit in their own blur handler. Close the gesture @@ -1404,6 +1436,7 @@ function BuiltInPluginPanelContent({ if (!paramId || !paramHistory.hasActiveParam(paramId)) return; pointerParamRef.current = null; keyboardParamRef.current = null; + wheelParamRef.current = null; scheduleParamCommit(0); }} > @@ -1454,7 +1487,7 @@ function BuiltInPluginPanelContent({ return flushParamWrites(); })} onHostBypass={value => applySuiteState(JSON.stringify({ hostBypassed: value }))} onApplyState={applySuiteState} onApplyValues={applySuiteValues} onRecallPreset={recallSuitePreset} onFlush={flushParamWrites} - onGestureStart={(id) => { pointerParamRef.current = id; beginExclusiveParamGesture(id); }} + onGestureStart={(id) => { pointerParamRef.current = id; beginExclusiveParamGesture(id, true); }} onGestureEnd={finishPointerParamGesture} canUndo={paramHistory.canUndo()} canRedo={paramHistory.canRedo()} onUndo={() => { clearScheduledParamCommit(); void finishAutomationGestures(); void paramHistory.undo(flushParamWrites, replayParamHistory).finally(historyChanged); }} @@ -1462,7 +1495,7 @@ function BuiltInPluginPanelContent({ ) : ( applySuiteState(JSON.stringify({ hostBypassed: value }))} onApplyState={applySuiteState} onApplyValues={applySuiteValues} onRecallPreset={recallSuitePreset} onFlush={flushParamWrites} - onGestureStart={(id) => { pointerParamRef.current = id; beginExclusiveParamGesture(id); }} + onGestureStart={(id) => { pointerParamRef.current = id; beginExclusiveParamGesture(id, true); }} onGestureEnd={finishPointerParamGesture} canUndo={paramHistory.canUndo()} canRedo={paramHistory.canRedo()} onUndo={() => { clearScheduledParamCommit(); void finishAutomationGestures(); void paramHistory.undo(flushParamWrites, replayParamHistory).finally(historyChanged); }} diff --git a/frontend/src/components/NAMRackDesignPort.tsx b/frontend/src/components/NAMRackDesignPort.tsx index a63f89c4..50199b11 100644 --- a/frontend/src/components/NAMRackDesignPort.tsx +++ b/frontend/src/components/NAMRackDesignPort.tsx @@ -4644,6 +4644,7 @@ function FuzzCleanKnob() { max={100} step={stepForParam(param) * 100} value={draft} + data-param-id={param.id} aria-label="Fuzz Clean blend percentage" aria-describedby={descriptionId} className="nam-fuzz-clean-entry rounded border border-amber-300 bg-neutral-950 px-1 text-center text-xs tabular-nums text-amber-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-300" diff --git a/tests/test_desktop_qualification.py b/tests/test_desktop_qualification.py index ce1ec320..41cb688d 100644 --- a/tests/test_desktop_qualification.py +++ b/tests/test_desktop_qualification.py @@ -1,5 +1,7 @@ import importlib.util +from contextlib import redirect_stderr import copy +import io import json import os from pathlib import Path @@ -11,6 +13,7 @@ import tempfile import time import unittest +from unittest import mock import wave @@ -30,6 +33,95 @@ def load_tool(name): class DesktopQualification(unittest.TestCase): + def test_appimage_default_report_is_inside_retained_artifact_directory(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + appimage = root / "OpenStudio.AppImage" + appimage.touch() + previous = Path.cwd() + try: + os.chdir(root) + with mock.patch.object(sys, "argv", ["smoke-linux-appimage.py", str(appimage)]), \ + mock.patch.object(desktop.shutil, "which", return_value="fixture-tool"), \ + mock.patch.object(desktop.subprocess, "call", return_value=7) as launch: + self.assertEqual(desktop.main(), 7) + command = launch.call_args.args[0] + report = Path(command[command.index("--report") + 1]) + self.assertEqual(report, root.resolve() / "output/linux-appimage/window-manager-lifecycle.json") + self.assertTrue(report.parent.is_dir()) + finally: + os.chdir(previous) + + def test_appimage_failure_summary_is_bounded_and_only_includes_lifecycle_fields(self): + with tempfile.TemporaryDirectory() as temporary: + report = Path(temporary) / "lifecycle.json" + report.write_text(json.dumps({ + "harnessMode": "window_lifecycle", "success": False, "environment": {"secret": "do-not-print"}, + "checks": [{"id": "pitch_interactive", "status": "fail", "detail": "x" * 2000, + "unrelated": "do-not-print"}] * 20, + "frontendDocuments": [{"role": "pitch", "startupState": "timedOut", + "url": "do-not-print"}] * 20, + "startupSelfTest": {"shellReady": True, "browserBackendSupported": False, + "packagedFrontendPresent": True, "runtimePath": "do-not-print"}, + }), encoding="utf-8") + summary = desktop.failure_diagnostics(report) + self.assertIs(summary["success"], False) + self.assertEqual(summary["harnessMode"], "window_lifecycle") + self.assertEqual(summary["failedCheckCount"], 20) + self.assertEqual(len(summary["failedChecks"]), 12) + self.assertEqual(summary["failedChecks"][0], + {"id": "pitch_interactive", "status": "fail", "detail": "x" * 512}) + self.assertEqual(len(summary["frontendDocuments"]), 12) + self.assertEqual(summary["frontendDocuments"][0], {"role": "pitch", "startupState": "timedOut"}) + self.assertEqual(summary["startupSelfTest"], {"shellReady": True, "browserBackendSupported": False, + "packagedFrontendPresent": True}) + self.assertEqual(summary["logPath"], str(report.with_suffix(".log"))) + self.assertNotIn("do-not-print", json.dumps(summary)) + + def test_appimage_missing_or_invalid_failure_report_still_provides_log_locations(self): + cases = [(None, "report was not written"), (b"{bad-json", "report is unreadable or malformed"), + (b"[]", "report is unreadable or malformed"), (b"\xff", "report is unreadable or malformed"), + (b" " * (desktop.MAX_DIAGNOSTIC_REPORT_BYTES + 1), "report exceeds diagnostic size limit")] + with tempfile.TemporaryDirectory() as temporary: + report = Path(temporary) / "lifecycle.json" + for content, expected in cases: + with self.subTest(expected=expected, bytes=None if content is None else len(content)): + if content is not None: + report.write_bytes(content) + summary = desktop.failure_diagnostics(report) + self.assertEqual(summary["reportError"], expected) + self.assertEqual(summary["reportPath"], str(report)) + self.assertEqual(summary["startupLogPath"], str(report.with_suffix(".startup.log"))) + + def test_appimage_native_failure_prints_failed_check_and_preserves_cleanup(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + appimage, report = root / "OpenStudio.AppImage", root / "lifecycle.json" + appimage.touch() + manager, application = mock.Mock(), mock.Mock() + manager.poll.return_value = None + application.wait.return_value = 2 + + def launch(command, **_): + if command[0] == "openbox": + return manager + report.write_text(json.dumps({"harnessMode": "window_lifecycle", "success": False, + "checks": [{"id": "plugin_bounds_stable", "status": "fail", + "detail": "window bounds changed"}]}), encoding="utf-8") + return application + + error_output = io.StringIO() + with mock.patch.object(sys, "argv", ["smoke-linux-appimage.py", str(appimage), + "--inside-display", "--report", str(report)]), \ + mock.patch.object(desktop.subprocess, "Popen", side_effect=launch), \ + mock.patch.object(desktop, "stop_process_group") as cleanup, redirect_stderr(error_output): + self.assertEqual(desktop.main(), 1) + self.assertIn("Packaged application exited with 2", error_output.getvalue()) + self.assertIn('"id": "plugin_bounds_stable"', error_output.getvalue()) + self.assertIn('"detail": "window bounds changed"', error_output.getvalue()) + self.assertEqual(cleanup.call_args_list, [mock.call(application), mock.call(manager)]) + self.assertTrue(report.with_suffix(".log").is_file()) + def test_boot_alone_does_not_qualify_detached_windows(self): with self.assertRaises(ValueError): desktop.validate_report({"harnessMode": "window_lifecycle", "success": True, diff --git a/tests/test_linux_qualification.py b/tests/test_linux_qualification.py index 87c3f4d5..0ddcca4c 100644 --- a/tests/test_linux_qualification.py +++ b/tests/test_linux_qualification.py @@ -1,10 +1,15 @@ import importlib.util +from contextlib import redirect_stderr, redirect_stdout +import io +import json import os from pathlib import Path +import shlex import sys import tempfile import time import unittest +from unittest import mock spec = importlib.util.spec_from_file_location('qualification', Path(__file__).resolve().parents[1] / 'tools/run-linux-qualification.py') qualification = importlib.util.module_from_spec(spec) @@ -12,6 +17,106 @@ class NativeDiagnosticsTests(unittest.TestCase): + def test_launcher_uses_fresh_child_without_replacing_window_manager_evidence(self): + workflows = Path(__file__).resolve().parents[1] / '.github/workflows' + for name in ('verify.yml', 'release.yml'): + with self.subTest(workflow=name): + commands = [] + for line in (workflows / name).read_text(encoding='utf-8').splitlines(): + if 'tools/run-linux-qualification.py' not in line: + continue + # Both folded `run:` values and shell block commands carry + # the CLI on one line; parse its arguments, not exact text. + command = shlex.split(line.strip().removeprefix('run: ')) + if '--app' in command and command[command.index('--app') + 1].endswith('.AppImage'): + commands.append(command) + self.assertEqual(len(commands), 1, 'Each workflow must qualify its finished AppImage') + command = commands[0] + self.assertIn('--output', command) + configured_output = Path(command[command.index('--output') + 1]) + self.assertFalse(configured_output.is_absolute(), 'Evidence must stay in the checkout artifacts') + self.assert_launcher_evidence_isolation(configured_output) + + def assert_launcher_evidence_isolation(self, configured_output): + # This composes the real CLI freshness guard with mocked native results; + # it does not launch or qualify an actual Linux application. + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + app = root / 'OpenStudio.AppImage' + app.write_bytes(b'fixture only; never executed') + parent = root / 'output/linux-appimage' + parent.mkdir(parents=True) + retained = parent / 'window-manager-lifecycle.json' + original = b'{"fixture":"retained window-manager evidence"}\n' + retained.write_bytes(original) + launcher = (root / configured_output).resolve() + self.assertTrue(launcher.is_relative_to(parent.resolve()), + 'Both phases must remain inside the retained AppImage artifact directory') + argv = ['run-linux-qualification.py', '--app', str(app), '--desktop', '--output'] + error_output = io.StringIO() + with mock.patch.object(sys, 'argv', [*argv, str(parent)]), \ + mock.patch.object(qualification, 'execute') as execute, redirect_stderr(error_output): + with self.assertRaises(SystemExit) as rejected: + qualification.main() + self.assertEqual(rejected.exception.code, 2) + self.assertIn('Use a fresh output directory', error_output.getvalue()) + execute.assert_not_called() + self.assertEqual(retained.read_bytes(), original) + + def native_fixture(command, log, _env, _timeout, **_): + report = Path(command[command.index('--report') + 1]) + self.assertEqual(report.parent, launcher.resolve()) + log.write_text('mock native fixture\n') + if '--startup-self-test' in command: + report.write_text('shellReady=true\n') + else: + self.assertIn('--window-lifecycle-harness', command) + report.write_text(json.dumps({ + 'success': True, 'startupMode': 'normal', + 'checks': [{'id': name, 'status': 'pass'} for name in ( + 'main_frontend_ready', 'mixer_frontend_ready', 'mixer_reopened_frontend_ready', + 'midi_frontend_ready', 'midi_reopened_frontend_ready', + 'plugin_frontend_ready', 'plugin_reopened_frontend_ready', + 'plugin_removed_editor_stays_closed', 'pitch_cycle_1_interactive_ready', + 'pitch_cycle_2_interactive_ready', 'pitch_native_relative_shift_committed', + 'pitch_native_undo_preserved')], + })) + return 0 + + read_text = Path.read_text + + def read_fixture_os(path, *args, **kwargs): + if path == Path('/etc/os-release'): + return 'NAME="Mock Linux fixture"\n' + return read_text(path, *args, **kwargs) + + with mock.patch.object(sys, 'argv', [*argv, str(launcher)]), \ + mock.patch.dict(os.environ, {'DISPLAY': ':fixture'}), \ + mock.patch.object(qualification.Path, 'read_text', read_fixture_os), \ + mock.patch.object(qualification, 'execute', side_effect=native_fixture) as execute, \ + redirect_stdout(io.StringIO()): + self.assertEqual(qualification.main(), 0) + self.assertEqual(execute.call_count, 4) + self.assertEqual(retained.read_bytes(), original) + result_path = launcher / 'qualification.json' + result = json.loads(result_path.read_text()) + self.assertTrue(result['success']) + self.assertEqual(result['audioQuality'], 'not_asserted') + self.assertEqual({check['id'] for check in result['checks']}, { + 'startup', 'runtime-path-argv0', 'runtime-path-inherited-appdir', + 'native-window-lifecycle', 'native-runtime-diagnostics'}) + + # Reusing even the child remains forbidden and must preserve both reports. + launcher_evidence = result_path.read_bytes() + with mock.patch.object(sys, 'argv', [*argv, str(launcher)]), \ + mock.patch.object(qualification, 'execute') as execute, redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit) as rejected: + qualification.main() + self.assertEqual(rejected.exception.code, 2) + execute.assert_not_called() + self.assertEqual(result_path.read_bytes(), launcher_evidence) + self.assertEqual(retained.read_bytes(), original) + @staticmethod def runtime_report(): return { diff --git a/tools/smoke-linux-appimage.py b/tools/smoke-linux-appimage.py index 7e0c60f6..dbbdeea1 100644 --- a/tools/smoke-linux-appimage.py +++ b/tools/smoke-linux-appimage.py @@ -24,6 +24,53 @@ "pitch_native_relative_shift_committed", "pitch_native_correction_file_published", "pitch_native_undo_preserved", "pitch_owner_loss_retains_checkpoint", } +MAX_DIAGNOSTIC_REPORT_BYTES = 256 * 1024 +MAX_DIAGNOSTIC_ROWS = 12 + + +def failure_diagnostics(report: Path) -> dict: + """Summarize known lifecycle fields without dumping the report or environment.""" + summary = {"reportPath": str(report), "logPath": str(report.with_suffix(".log")), + "startupLogPath": str(report.with_suffix(".startup.log"))} + try: + with report.open("rb") as source: + content = source.read(MAX_DIAGNOSTIC_REPORT_BYTES + 1) + if len(content) > MAX_DIAGNOSTIC_REPORT_BYTES: + summary["reportError"] = "report exceeds diagnostic size limit" + return summary + document = json.loads(content) + if not isinstance(document, dict): + raise ValueError("Expected an object") + except FileNotFoundError: + summary["reportError"] = "report was not written" + return summary + except (OSError, ValueError, UnicodeError, RecursionError): + summary["reportError"] = "report is unreadable or malformed" + return summary + + def fields(row: dict, keys: tuple) -> dict: + return {key: row[key][:512 if key == "detail" else 96] + for key in keys if isinstance(row.get(key), str)} + + summary.update(fields(document, ("harnessMode",))) + if isinstance(document.get("success"), bool): + summary["success"] = document["success"] + checks = document.get("checks", []) + failed = [row for row in checks if isinstance(row, dict) and row.get("status") == "fail"] \ + if isinstance(checks, list) else [] + summary["failedCheckCount"] = len(failed) + summary["failedChecks"] = [fields(row, ("id", "status", "detail")) + for row in failed[:MAX_DIAGNOSTIC_ROWS]] + documents = document.get("frontendDocuments", []) + if isinstance(documents, list): + summary["frontendDocuments"] = [fields(row, ("role", "startupState")) + for row in documents[:MAX_DIAGNOSTIC_ROWS] if isinstance(row, dict)] + startup = document.get("startupSelfTest", {}) + if isinstance(startup, dict): + summary["startupSelfTest"] = { + key: startup[key] for key in ("shellReady", "browserBackendSupported", "packagedFrontendPresent") + if isinstance(startup.get(key), bool)} + return summary def validate_report(report: dict) -> None: @@ -53,7 +100,8 @@ def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("appimage", type=Path) parser.add_argument("--timeout", type=int, default=240) - parser.add_argument("--report", type=Path, default=Path("output/linux-appimage-lifecycle.json")) + parser.add_argument("--report", type=Path, + default=Path("output/linux-appimage/window-manager-lifecycle.json")) parser.add_argument("--inside-display", action="store_true", help=argparse.SUPPRESS) args = parser.parse_args() appimage = args.appimage.resolve() @@ -92,6 +140,7 @@ def main() -> int: validate_report(json.loads(report.read_text(encoding="utf-8"))) except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired) as error: print(str(error), file=sys.stderr) + print("AppImage lifecycle diagnostics: " + json.dumps(failure_diagnostics(report)), file=sys.stderr) return 1 finally: if process is not None: From d64ea6ae80273133bd17b7e9a30af9a464c714e5 Mon Sep 17 00:00:00 2001 From: Sourav Das Date: Wed, 4 Nov 2026 11:40:16 +0530 Subject: [PATCH 05/33] Improve low-buffer recording and MIDI editing workflows --- CMakeLists.txt | 76 +- Source/AudioEngine.cpp | 1114 +++++++++++-- Source/AudioEngine.h | 35 +- Source/AudioEngineParallelRegression.inc | 12 +- Source/AudioFileConversion.cpp | 6 +- Source/AudioFileConversionRegression.cpp | 120 ++ Source/AudioRecorder.cpp | 16 +- Source/AudioRecorder.h | 7 +- Source/AudioXRunTelemetry.h | 81 + Source/BuiltInEffects.cpp | 31 + Source/BuiltInEffects.h | 5 + Source/ClipPreviewData.cpp | 52 + Source/ClipSourceDescriptor.h | 48 + Source/ClipTempoAnalyzer.h | 294 ++++ Source/EditingWorkflowRegression.cpp | 65 + Source/EditingWorkflowRegression.h | 4 + Source/InlineEQDryRegression.cpp | 303 ++++ Source/InlineEQDryRegression.h | 3 + Source/IntegerPDCRegression.cpp | 266 +++ Source/IntegerPDCRegression.h | 5 + Source/LatestAnalysisQueue.h | 103 ++ Source/Main.cpp | 230 ++- Source/MainComponent.cpp | 188 ++- Source/MainComponent.h | 5 + Source/MixerGain.h | 13 + Source/PackedWaveformPeaks.h | 75 + Source/PackedWaveformPeaksRegression.cpp | 130 ++ Source/PackedWaveformPeaksRegression.h | 4 + Source/PeakCache.cpp | 320 ++-- Source/PeakCache.h | 35 +- Source/PeakCacheGenerationRegression.cpp | 152 ++ Source/PeakCacheGenerationRegression.h | 4 + Source/PlaybackEngine.cpp | 465 ++++-- Source/PlaybackEngine.h | 83 +- Source/PlaybackTinyBlockRegression.cpp | 225 +++ Source/PlaybackTinyBlockRegression.h | 5 + Source/PreparedIntegerDelayLine.h | 91 ++ Source/ProjectQualification.cpp | 515 ++++++ Source/ProjectRateCopy.cpp | 102 ++ Source/ProjectRateCopy.h | 10 + Source/RealtimeCallbackTrace.h | 124 ++ Source/RealtimeCallbackTraceRegression.cpp | 126 ++ Source/RealtimeCallbackTraceRegression.h | 3 + Source/RealtimeSourceReadWindow.h | 66 + Source/RealtimeSourceReadWindowRegression.cpp | 67 + Source/RealtimeSourceReadWindowRegression.h | 3 + Source/RecordingDiskWriter.cpp | 68 + Source/RecordingDiskWriter.h | 34 + Source/RecordingDiskWriterRegression.cpp | 211 +++ Source/RecordingDiskWriterRegression.h | 3 + Source/RecordingQualification.cpp | 147 ++ Source/RecordingQualification.h | 7 + Source/RecordingStartRegression.cpp | 186 +++ Source/TempoDetectionRegression.cpp | 74 + Source/TempoDetectionRegression.h | 6 + Source/TrackProcessor.cpp | 1439 +++++++++++------ Source/TrackProcessor.h | 20 +- WORKFLOWS.md | 7 + cmake/ApplyJUCEHeapBlockPatch.cmake | 45 + cmake/ApplyJUCERealtimePatches.cmake | 2 + docs/USER_MANUAL.md | 182 ++- docs/implemented_features.md | 10 + docs/input-profiles.md | 29 + docs/issue-coverage-matrix.md | 249 +++ docs/issue-remediation-plan.md | 450 ++++++ docs/workflow-review-qualification.md | 105 ++ frontend/e2e/crossfade-editor.spec.ts | 79 + frontend/e2e/midi-device-availability.spec.ts | 88 + frontend/e2e/midi-join-export.spec.ts | 123 ++ frontend/e2e/mixer-entry-resize.spec.ts | 233 +++ frontend/e2e/native-tempo-preview.spec.ts | 92 ++ frontend/e2e/packed-waveform-tiles.spec.ts | 152 ++ frontend/e2e/prepared-recording-start.spec.ts | 53 + frontend/e2e/project-rate-copy.spec.ts | 66 + frontend/e2e/recording-zoom.spec.ts | 168 +- .../e2e/shared-project-qualification.spec.ts | 874 ++++++++++ frontend/e2e/tempo-detection.spec.ts | 214 +++ frontend/e2e/timeline-source-geometry.spec.ts | 18 + frontend/e2e/tsconfig.json | 7 + frontend/src/App.tsx | 24 +- .../__tests__/actionCatalogIntegrity.test.ts | 22 + .../src/__tests__/audioConversion.test.ts | 105 ++ frontend/src/__tests__/clipCrossfades.test.ts | 228 +++ .../__tests__/interactionSafetyGuards.test.ts | 2 +- .../src/__tests__/joinedMIDIIdentity.test.ts | 79 + .../src/__tests__/midiNotePairIndex.test.ts | 69 + .../src/__tests__/midiSelectionExport.test.ts | 68 + .../src/__tests__/midiTempoAnalysis.test.ts | 60 + .../src/__tests__/midiThumbnailIndex.test.ts | 91 ++ frontend/src/__tests__/mixerLevel.test.ts | 18 + .../src/__tests__/packedWaveformPeaks.test.ts | 70 + .../src/__tests__/projectSaveSafety.test.ts | 31 + .../recordingWaveformPreview.test.ts | 63 +- .../__tests__/remediationIntegration.test.ts | 149 ++ .../__tests__/staffReviewRegressions.test.ts | 48 + frontend/src/__tests__/tempoAudition.test.ts | 100 ++ .../__tests__/timelineEnhancements.test.ts | 307 ++++ .../__tests__/timelineInsertionTarget.test.ts | 18 + .../__tests__/timelineRulerDensity.test.ts | 36 + .../timelineRulerVisibility.test.tsx | 7 +- .../src/__tests__/transportRecording.test.ts | 72 +- .../__tests__/transportUpdateOrdering.test.ts | 13 + .../__tests__/waveformRequestQueue.test.ts | 29 + frontend/src/__tests__/waveformTiles.test.ts | 57 + .../src/components/AudioConversionStatus.tsx | 24 + frontend/src/components/ChannelStrip.tsx | 9 +- frontend/src/components/ContextMenu.tsx | 9 +- frontend/src/components/CrossfadeEditor.tsx | 248 ++- .../src/components/MIDIDeviceSelector.tsx | 25 +- frontend/src/components/MainToolbar.tsx | 11 +- frontend/src/components/MixerLevelInput.tsx | 82 + frontend/src/components/MixerPanel.tsx | 37 +- frontend/src/components/PanelResizeHandle.tsx | 123 ++ frontend/src/components/PianoRoll.css | 6 + frontend/src/components/PianoRoll.tsx | 80 +- .../src/components/ProjectSettingsModal.tsx | 35 +- frontend/src/components/RenderModal.tsx | 16 - .../src/components/TempoDetectionModal.tsx | 226 +++ frontend/src/components/Timeline.css | 8 + frontend/src/components/Timeline.tsx | 1389 +++++++++------- frontend/src/components/TimelineRuler.tsx | 44 +- frontend/src/components/TransportBar.tsx | 16 +- frontend/src/services/NativeBridge.ts | 154 +- frontend/src/store/actionRegistry.ts | 76 +- frontend/src/store/actions/audioConversion.ts | 163 ++ frontend/src/store/actions/clipBackendSync.ts | 159 ++ frontend/src/store/actions/clipEditing.ts | 26 +- frontend/src/store/actions/clips.ts | 253 ++- frontend/src/store/actions/midi.ts | 117 +- frontend/src/store/actions/project.ts | 22 +- frontend/src/store/actions/rendering.ts | 11 +- .../src/store/actions/timelineClipCopy.ts | 4 + .../src/store/actions/timelineEnhancements.ts | 291 ++++ frontend/src/store/actions/tracks.ts | 21 +- frontend/src/store/actions/transport.ts | 82 +- frontend/src/store/actions/uiState.ts | 9 + frontend/src/store/useDAWStore.ts | 53 +- frontend/src/utils/clipCrossfades.ts | 262 +++ frontend/src/utils/clipSourceDescriptor.ts | 16 + frontend/src/utils/joinMIDIClips.ts | 46 + frontend/src/utils/midiClipSerialization.ts | 14 +- frontend/src/utils/midiExportOptions.ts | 24 + frontend/src/utils/midiNotes.ts | 93 +- frontend/src/utils/midiPreview.ts | 121 ++ frontend/src/utils/midiSelectionExport.ts | 41 + frontend/src/utils/midiTempoAnalysis.ts | 133 ++ frontend/src/utils/mixerLevel.ts | 15 + frontend/src/utils/packedWaveformPeaks.ts | 64 + frontend/src/utils/projectValidation.ts | 24 + .../src/utils/recordingWaveformPreview.ts | 44 +- frontend/src/utils/shortcutContext.ts | 1 + frontend/src/utils/shortcutProfiles.ts | 3 + frontend/src/utils/snapToGrid.ts | 8 + frontend/src/utils/tempoAudition.ts | 135 ++ frontend/src/utils/timelineClipStretch.ts | 5 + frontend/src/utils/timelineDragAxisLock.ts | 17 + frontend/src/utils/timelineRulerDensity.ts | 28 + frontend/src/utils/waveformRequestQueue.ts | 41 + frontend/src/utils/waveformTiles.ts | 62 + tools/analyze-callback-trace.py | 183 +++ tools/check-project-render-reference.py | 203 +++ tools/qualify-midi-tempo.mjs | 35 + tools/run-project-qualification-headless.ps1 | 65 + tools/test_analyze_callback_trace.py | 80 + tools/test_juce_heap_guard_patch.py | 58 + ...line-source-geometry-browser-regression.js | 229 +++ tools/validate-input-docs.mjs | 137 ++ tools/validate-input-docs.test.mjs | 62 + 168 files changed, 16840 insertions(+), 2132 deletions(-) create mode 100644 Source/AudioXRunTelemetry.h create mode 100644 Source/ClipPreviewData.cpp create mode 100644 Source/ClipSourceDescriptor.h create mode 100644 Source/ClipTempoAnalyzer.h create mode 100644 Source/EditingWorkflowRegression.cpp create mode 100644 Source/EditingWorkflowRegression.h create mode 100644 Source/InlineEQDryRegression.cpp create mode 100644 Source/InlineEQDryRegression.h create mode 100644 Source/IntegerPDCRegression.cpp create mode 100644 Source/IntegerPDCRegression.h create mode 100644 Source/LatestAnalysisQueue.h create mode 100644 Source/MixerGain.h create mode 100644 Source/PackedWaveformPeaks.h create mode 100644 Source/PackedWaveformPeaksRegression.cpp create mode 100644 Source/PackedWaveformPeaksRegression.h create mode 100644 Source/PeakCacheGenerationRegression.cpp create mode 100644 Source/PeakCacheGenerationRegression.h create mode 100644 Source/PlaybackTinyBlockRegression.cpp create mode 100644 Source/PlaybackTinyBlockRegression.h create mode 100644 Source/PreparedIntegerDelayLine.h create mode 100644 Source/ProjectQualification.cpp create mode 100644 Source/ProjectRateCopy.cpp create mode 100644 Source/ProjectRateCopy.h create mode 100644 Source/RealtimeCallbackTrace.h create mode 100644 Source/RealtimeCallbackTraceRegression.cpp create mode 100644 Source/RealtimeCallbackTraceRegression.h create mode 100644 Source/RealtimeSourceReadWindow.h create mode 100644 Source/RealtimeSourceReadWindowRegression.cpp create mode 100644 Source/RealtimeSourceReadWindowRegression.h create mode 100644 Source/RecordingDiskWriter.cpp create mode 100644 Source/RecordingDiskWriter.h create mode 100644 Source/RecordingDiskWriterRegression.cpp create mode 100644 Source/RecordingDiskWriterRegression.h create mode 100644 Source/RecordingQualification.cpp create mode 100644 Source/RecordingQualification.h create mode 100644 Source/RecordingStartRegression.cpp create mode 100644 Source/TempoDetectionRegression.cpp create mode 100644 Source/TempoDetectionRegression.h create mode 100644 cmake/ApplyJUCEHeapBlockPatch.cmake create mode 100644 docs/issue-coverage-matrix.md create mode 100644 docs/issue-remediation-plan.md create mode 100644 docs/workflow-review-qualification.md create mode 100644 frontend/e2e/crossfade-editor.spec.ts create mode 100644 frontend/e2e/midi-device-availability.spec.ts create mode 100644 frontend/e2e/midi-join-export.spec.ts create mode 100644 frontend/e2e/mixer-entry-resize.spec.ts create mode 100644 frontend/e2e/native-tempo-preview.spec.ts create mode 100644 frontend/e2e/packed-waveform-tiles.spec.ts create mode 100644 frontend/e2e/prepared-recording-start.spec.ts create mode 100644 frontend/e2e/project-rate-copy.spec.ts create mode 100644 frontend/e2e/shared-project-qualification.spec.ts create mode 100644 frontend/e2e/tempo-detection.spec.ts create mode 100644 frontend/e2e/timeline-source-geometry.spec.ts create mode 100644 frontend/src/__tests__/audioConversion.test.ts create mode 100644 frontend/src/__tests__/clipCrossfades.test.ts create mode 100644 frontend/src/__tests__/joinedMIDIIdentity.test.ts create mode 100644 frontend/src/__tests__/midiNotePairIndex.test.ts create mode 100644 frontend/src/__tests__/midiSelectionExport.test.ts create mode 100644 frontend/src/__tests__/midiTempoAnalysis.test.ts create mode 100644 frontend/src/__tests__/midiThumbnailIndex.test.ts create mode 100644 frontend/src/__tests__/mixerLevel.test.ts create mode 100644 frontend/src/__tests__/packedWaveformPeaks.test.ts create mode 100644 frontend/src/__tests__/remediationIntegration.test.ts create mode 100644 frontend/src/__tests__/staffReviewRegressions.test.ts create mode 100644 frontend/src/__tests__/tempoAudition.test.ts create mode 100644 frontend/src/__tests__/timelineEnhancements.test.ts create mode 100644 frontend/src/__tests__/timelineInsertionTarget.test.ts create mode 100644 frontend/src/__tests__/timelineRulerDensity.test.ts create mode 100644 frontend/src/__tests__/waveformRequestQueue.test.ts create mode 100644 frontend/src/__tests__/waveformTiles.test.ts create mode 100644 frontend/src/components/AudioConversionStatus.tsx create mode 100644 frontend/src/components/MixerLevelInput.tsx create mode 100644 frontend/src/components/PanelResizeHandle.tsx create mode 100644 frontend/src/components/TempoDetectionModal.tsx create mode 100644 frontend/src/components/Timeline.css create mode 100644 frontend/src/store/actions/audioConversion.ts create mode 100644 frontend/src/store/actions/clipBackendSync.ts create mode 100644 frontend/src/store/actions/timelineEnhancements.ts create mode 100644 frontend/src/utils/clipCrossfades.ts create mode 100644 frontend/src/utils/clipSourceDescriptor.ts create mode 100644 frontend/src/utils/joinMIDIClips.ts create mode 100644 frontend/src/utils/midiExportOptions.ts create mode 100644 frontend/src/utils/midiSelectionExport.ts create mode 100644 frontend/src/utils/midiTempoAnalysis.ts create mode 100644 frontend/src/utils/mixerLevel.ts create mode 100644 frontend/src/utils/packedWaveformPeaks.ts create mode 100644 frontend/src/utils/tempoAudition.ts create mode 100644 frontend/src/utils/timelineRulerDensity.ts create mode 100644 frontend/src/utils/waveformRequestQueue.ts create mode 100644 frontend/src/utils/waveformTiles.ts create mode 100644 tools/analyze-callback-trace.py create mode 100644 tools/check-project-render-reference.py create mode 100644 tools/qualify-midi-tempo.mjs create mode 100644 tools/run-project-qualification-headless.ps1 create mode 100644 tools/test_analyze_callback_trace.py create mode 100644 tools/test_juce_heap_guard_patch.py create mode 100644 tools/timeline-source-geometry-browser-regression.js create mode 100644 tools/validate-input-docs.mjs create mode 100644 tools/validate-input-docs.test.mjs diff --git a/CMakeLists.txt b/CMakeLists.txt index 32a6b1a6..36302339 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -89,6 +89,14 @@ if(WIN32) endif() endif() + # A regeneration can run from the build directory. Resolve user-supplied + # relative SDK paths against the source tree once, not the caller's cwd. + if(NOT IS_ABSOLUTE "${JUCE_ASIOSDK_PATH}") + get_filename_component(_OPENSTUDIO_ABSOLUTE_ASIO_SDK "${JUCE_ASIOSDK_PATH}" + ABSOLUTE BASE_DIR "${CMAKE_SOURCE_DIR}") + set(JUCE_ASIOSDK_PATH "${_OPENSTUDIO_ABSOLUTE_ASIO_SDK}" + CACHE PATH "Path to ASIO SDK" FORCE) + endif() set(OPENSTUDIO_ASIO_HEADER "${JUCE_ASIOSDK_PATH}/common/iasiodrv.h") if(EXISTS "${OPENSTUDIO_ASIO_HEADER}") @@ -127,6 +135,7 @@ FetchContent_MakeAvailable(juce) # checkouts also need updated recipes applied before compiling host code. set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/cmake/ApplyJUCERealtimePatches.cmake" + "${CMAKE_CURRENT_SOURCE_DIR}/cmake/ApplyJUCEHeapBlockPatch.cmake" "${CMAKE_CURRENT_SOURCE_DIR}/cmake/ApplyJUCEVST3StatePatch.cmake" "${CMAKE_CURRENT_SOURCE_DIR}/cmake/ApplyJUCEAutomationPatch.cmake" "${CMAKE_CURRENT_SOURCE_DIR}/cmake/ApplyJUCELV2IdentifierPatch.cmake" @@ -192,11 +201,12 @@ if(MSVC) # MSVC /O2 and /Ox can break WRL callback dispatch in JUCE's WebView2 # resource-provider path, leaving Release builds on about:blank even though # the controller was created successfully. Restrict /O1 to JUCE's GUI-extra - # translation unit so audio/DSP code keeps the normal Release optimisation. + # translation unit so audio/DSP code keeps its normal optimisation. Debug + # uses an optimized host too; retain the same WebView-specific exception. set_property(SOURCE "${juce_SOURCE_DIR}/modules/juce_gui_extra/juce_gui_extra.cpp" APPEND PROPERTY COMPILE_OPTIONS - $<$,$>:/O1> + $<$,$,$>:/O1> ) endif() @@ -484,10 +494,23 @@ if(MSVC) # supported ABI instead of the removed experimental coroutine headers. set_property(SOURCE Source/StoreUpdater.cpp APPEND PROPERTY COMPILE_OPTIONS /await:strict) - # Realtime audio translation units below are intentionally optimized - # in Debug. CMP0184 controls runtime checks at target scope, so the target - # must opt out to avoid MSVC's incompatible /O2 + /RTC1 combination. + # Optimize the entire Debug host, including JUCE core and UI/regression + # translation units. Header-defined COMDAT helpers are shared at link time: + # a /Od definition emitted by a non-audio file can otherwise replace an + # optimized definition called by /O2 realtime code. Keep Debug symbols, + # _DEBUG, the Debug CRT and assertions; only code generation changes. + target_compile_options(OpenStudio PRIVATE + $<$:/O2> + $<$:/Ob2> + $<$:/Oi> + $<$:/Ot> + ) + # CMP0184 controls runtime checks at target scope. /RTC1 is incompatible + # with optimized code; leave those checks disabled as before. set_property(TARGET OpenStudio PROPERTY MSVC_RUNTIME_CHECKS "") + # Re-select COMDATs from the current objects rather than retaining an + # earlier unoptimized selection in an incremental-link database. + target_link_options(OpenStudio PRIVATE $<$:/INCREMENTAL:NO>) # The headless aggregate regression nests the complete NAM DSP suite # inside the host suite. Both deliberately keep sizeable fixed DSP state # on the calling thread, which exceeds Windows' 1 MiB executable default @@ -514,14 +537,34 @@ target_sources(OpenStudio PRIVATE Source/RecordingRecovery.cpp Source/RecordingRecoveryRegression.cpp Source/AudioEngine.cpp + Source/ProjectQualification.cpp + Source/ProjectRateCopy.cpp + Source/ClipPreviewData.cpp + Source/RecordingQualification.cpp + Source/RecordingStartRegression.cpp + Source/RealtimeSourceReadWindowRegression.cpp + Source/PlaybackTinyBlockRegression.cpp + Source/RealtimeCallbackTraceRegression.cpp + Source/RecordingDiskWriterRegression.cpp + Source/InlineEQDryRegression.cpp + Source/IntegerPDCRegression.cpp + Source/PackedWaveformPeaksRegression.cpp + Source/PeakCacheGenerationRegression.cpp Source/RealtimeTrackExecutor.cpp Source/MetronomeRegression.cpp + Source/TempoDetectionRegression.cpp + Source/EditingWorkflowRegression.cpp + Source/EditingWorkflowRegression.h + Source/LatestAnalysisQueue.h + Source/MixerGain.h + Source/TempoDetectionRegression.h Source/FreePluginRegression.cpp Source/NAMDelayRegression.h Source/NAMDelayRegression.cpp Source/NAMDelayRegressionLifecycle.cpp Source/NAMDelayRegressionRackTail.cpp Source/AudioRecorder.cpp + Source/RecordingDiskWriter.cpp Source/PlaybackEngine.cpp Source/PeakCache.cpp Source/AudioConverter.cpp @@ -604,14 +647,14 @@ if(WIN32) COMMENT "Copying out-of-process crash reporter") endif() -# Keep the complete production audio callback path optimized even in the app's -# developer build. `build.py dev --run` uses Debug; leaving JUCE's ASIO -# conversion/convolution modules or the surrounding host/track helpers at /Od -# creates periodic work spikes that do not represent the shipped DSP. +# Non-MSVC developer builds keep the production audio callback path optimized +# with source-local options. MSVC optimizes the full Debug host above so that +# shared header COMDAT helpers cannot come from an unoptimized translation unit. set(OPENSTUDIO_REALTIME_AUDIO_SOURCES Source/AudioEngine.cpp Source/RealtimeTrackExecutor.cpp Source/AudioRecorder.cpp + Source/RecordingDiskWriter.cpp Source/PlaybackEngine.cpp Source/TrackProcessor.cpp Source/Metronome.cpp @@ -620,6 +663,10 @@ set(OPENSTUDIO_REALTIME_AUDIO_SOURCES Source/TimecodeSync.cpp Source/TriggerEngine.cpp Source/BuiltInEffects.cpp + Source/InlineEQDryRegression.cpp + Source/IntegerPDCRegression.cpp + Source/PackedWaveformPeaksRegression.cpp + Source/PeakCacheGenerationRegression.cpp Source/BuiltInEffects2.cpp Source/NAMCabPresentation.cpp Source/NAMPolyOctaver.cpp @@ -634,16 +681,7 @@ set(OPENSTUDIO_REALTIME_AUDIO_SOURCES "${juce_SOURCE_DIR}/modules/juce_audio_utils/juce_audio_utils.cpp" "${juce_SOURCE_DIR}/modules/juce_dsp/juce_dsp.cpp" ) -if(MSVC) - set_property(SOURCE - ${OPENSTUDIO_REALTIME_AUDIO_SOURCES} - APPEND PROPERTY COMPILE_OPTIONS - $<$:/O2> - $<$:/Ob2> - $<$:/Oi> - $<$:/Ot> - ) -else() +if(NOT MSVC) set_property(SOURCE ${OPENSTUDIO_REALTIME_AUDIO_SOURCES} APPEND PROPERTY COMPILE_OPTIONS diff --git a/Source/AudioEngine.cpp b/Source/AudioEngine.cpp index 3a4004e0..fd724321 100644 --- a/Source/AudioEngine.cpp +++ b/Source/AudioEngine.cpp @@ -1,3 +1,5 @@ +#include "MixerGain.h" +#include "EditingWorkflowRegression.h" #include "PluginAutomationDelivery.h" #include "OfflinePluginTransport.h" #include "OfflineHostedControl.h" @@ -6,6 +8,19 @@ #include "BuiltInInstrumentPreviewBindings.h" #include "RuntimeLocation.h" #include "AudioEngine.h" +#include "ClipSourceDescriptor.h" +#include "AudioXRunTelemetry.h" +#include "ClipTempoAnalyzer.h" +#include "AudioFileConversionRegression.h" +#include "TempoDetectionRegression.h" +#include "RealtimeSourceReadWindowRegression.h" +#include "PlaybackTinyBlockRegression.h" +#include "RealtimeCallbackTraceRegression.h" +#include "RecordingDiskWriterRegression.h" +#include "InlineEQDryRegression.h" +#include "IntegerPDCRegression.h" +#include "PackedWaveformPeaksRegression.h" +#include "PeakCacheGenerationRegression.h" #include "RegressionMessagePump.h" #include "PluginParameterIdentity.h" #include "MicrophoneAccess.h" @@ -59,6 +74,36 @@ namespace { +struct CallbackTraceThreadSample +{ + std::uint64_t threadId = 0, cycles = 0; + std::uint32_t processorGroup = 0, processorNumber = 0; + bool processorAvailable = false, cyclesValid = false; +}; + +CallbackTraceThreadSample captureCallbackTraceThread(bool includeCycles) noexcept +{ + CallbackTraceThreadSample sample; +#if JUCE_WINDOWS + sample.threadId = static_cast(::GetCurrentThreadId()); + PROCESSOR_NUMBER processor {}; + ::GetCurrentProcessorNumberEx(&processor); + sample.processorGroup = static_cast(processor.Group); + sample.processorNumber = static_cast(processor.Number); + sample.processorAvailable = true; + if (includeCycles) + { + ULONG64 cycles = 0; + sample.cyclesValid = ::QueryThreadCycleTime(::GetCurrentThread(), &cycles) != FALSE; + sample.cycles = static_cast(cycles); + } +#else + juce::ignoreUnused(includeCycles); + sample.threadId = static_cast(reinterpret_cast(juce::Thread::getCurrentThreadId())); +#endif + return sample; +} + juce::String builtInInstanceIdentity(const std::shared_ptr& processor); void registerBuiltInInstanceIdentity(const std::shared_ptr&, const juce::String&); // Called by control/state workers with the route publication lease held. @@ -3897,6 +3942,7 @@ void AudioEngine::rebuildRealtimeProcessingSnapshots() RealtimeTrackEntry entry; entry.id = trackId; + entry.playbackContinuityKey = PlaybackEngine::prepareTrackContinuityKey(trackId); entry.node = nodeIt->second; entry.processor = dynamic_cast(entry.node->getProcessor()); @@ -4856,37 +4902,48 @@ bool AudioEngine::setProbeRecordingDirectory(const juce::File& directory) return true; } -void AudioEngine::recordQuarantinedTrackInput(const RealtimeTrackEntry& entry, +void AudioEngine::recordTrackHardwareInput(const RealtimeTrackEntry& entry, const float* const* inputs, int inputCount, int samples, juce::int64 blockPosition) { - // This is a recorder-only lane. It never accesses DSP buffers, MIDI queues, - // position setters, meters or any other worker-owned TrackProcessor state. - if (!isPlaying.load() || !isRecordMode.load() || recordingDeviceInterrupted.load()) return; - const double position = static_cast(blockPosition) / juce::jmax(1.0, currentSampleRate); - if (punchEnabled.load() && (position < punchStartTime.load() || position >= punchEndTime.load())) return; - const double compensated = juce::jmax(0.0, static_cast(blockPosition - inputLatencySamples) - / juce::jmax(1.0, currentSampleRate)); + // Called only for a playing/recording callback snapshot. This scratch belongs + // to the callback, never to a worker: capture selected hardware before any + // clip, send, track gain or FX contribution is mixed into processing buffers. auto* track = entry.processor; - if (track == nullptr || !track->getRecordArmed() || track->getTrackType() != TrackType::Audio) return; + if (!isPlaying.load(std::memory_order_acquire) || !isRecordMode.load(std::memory_order_acquire) + || recordingDeviceInterrupted.load(std::memory_order_acquire) || track == nullptr + || !track->getRecordArmed() || track->getTrackType() != TrackType::Audio) return; const int start = track->getInputStartChannel(); - const int channels = juce::jmin(track->getInputChannelCount(), reusableTrackBuffer.getNumChannels()); - if (start < 0 || start >= inputCount || channels <= 0) return; - reusableTrackBuffer.clear(0, samples); - // A newer routing snapshot may add an incoming send after this processor - // entered the old, isolated batch. Only the callback writes this current - // accumulator; preserve its contribution without touching owned DSP data. - if (entry.sendAccumBuffer != nullptr && entry.sendAccumBuffer->getNumSamples() >= samples) - for (int channel = 0; channel < juce::jmin(2, reusableTrackBuffer.getNumChannels(), - entry.sendAccumBuffer->getNumChannels()); ++channel) - reusableTrackBuffer.addFrom(channel, 0, *entry.sendAccumBuffer, channel, 0, samples); - playbackEngine.fillTrackBuffer(entry.id, reusableTrackBuffer, - position - track->getPlaybackOffset() / 1000.0, samples, currentSampleRate); - const bool automationCanUnmute = track->getMuteAutomation().shouldPlayback() - && track->getMuteAutomation().hasPlaybackData(); - if (!track->getMute() || automationCanUnmute) - for (int channel = 0; channel < channels && start + channel < inputCount; ++channel) - reusableTrackBuffer.addFrom(channel, 0, inputs[start + channel], samples); - audioRecorder.writeBlock(entry.id, reusableTrackBuffer, samples, compensated); + const int channels = track->getInputChannelCount(); + if (start < 0 || start >= inputCount || channels <= 0 || samples <= 0) return; + if (samples > reusableTrackBuffer.getNumSamples() || channels > reusableTrackBuffer.getNumChannels()) + { + OpenStudioCrashDiagnostics::recordRealtimeFault(OpenStudioCrashDiagnostics::RealtimeFault::recordingBufferContract); + return; + } + int first = 0, last = samples; + if (punchEnabled.load(std::memory_order_acquire)) + { + const double startInBlock = punchStartTime.load(std::memory_order_relaxed) * currentSampleRate - static_cast(blockPosition); + const double endInBlock = punchEndTime.load(std::memory_order_relaxed) * currentSampleRate - static_cast(blockPosition); + if (!std::isfinite(startInBlock) || !std::isfinite(endInBlock)) return; + const auto boundary = [samples](double position) { + const double nearest = std::round(position); + if (std::abs(position - nearest) <= 1.0e-6) position = nearest; + return static_cast(juce::jlimit(0.0, static_cast(samples), std::ceil(position))); + }; + first = boundary(startInBlock); last = boundary(endInBlock); + } + const int count = last - first; + if (count <= 0) return; + for (int channel = 0; channel < channels; ++channel) + { + if (start + channel < inputCount && inputs[start + channel] != nullptr) + reusableTrackBuffer.copyFrom(channel, 0, inputs[start + channel] + first, count); + else reusableTrackBuffer.clear(channel, 0, count); + } + const double compensated = juce::jmax(0.0, + static_cast(blockPosition + first - inputLatencySamples) / juce::jmax(1.0, currentSampleRate)); + audioRecorder.writeBlock(entry.id, reusableTrackBuffer, count, compensated); } void AudioEngine::audioDeviceError( @@ -6238,7 +6295,7 @@ void AudioEngine::applyMasterGainPanMono (float* const* outputChannelData, for (int sample = 0; sample < numSamples; ++sample) { const float db = volumeRead ? reusableMasterAutomationBuffer.getSample(0, sample) : 0.0f; - const float volume = volumeRead ? (db <= -60.0f ? 0.0f : juce::Decibels::decibelsToGain(db)) : manualVolume; + const float volume = volumeRead ? MixerGain::fromDecibels(db) : manualVolume; const float trim = trimRead ? juce::Decibels::decibelsToGain(juce::jlimit(-60.0f, 12.0f, reusableMasterAutomationBuffer.getSample(2, sample))) : manualTrim; if (panRead) computePanLawGains(currentPanLaw.load(std::memory_order_acquire), reusableMasterAutomationBuffer.getSample(1, sample), 1.0f, left, right); for (int channel = 0; channel < numOutputChannels; ++channel) @@ -6389,6 +6446,10 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha int numSamples, const juce::AudioIODeviceCallbackContext& context) { + const auto traceOptions = realtimeCallbackTrace.getOptions(); + const bool tracingCallback = (traceOptions & RealtimeCallbackTrace::enabledFlag) != 0; + const auto callbackEntryTicks = tracingCallback ? juce::Time::getHighResolutionTicks() : 0; + const double callbackStartWallTimeMs = juce::Time::getMillisecondCounterHiRes(); #if JUCE_WINDOWS registerWindowsAudioCallbackMMCSS(); #endif @@ -6396,7 +6457,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha juce::ignoreUnused(context); audioCallbackScopedNoDenormalsCount.fetch_add (1, std::memory_order_relaxed); const uint64 callbackCounter = audioCallbackCounter.fetch_add(1, std::memory_order_acq_rel) + 1; - const bool shouldSampleAudioCallbackStages = + const bool shouldSampleAudioCallbackStages = tracingCallback || (callbackCounter % audioCallbackStageSampleInterval) == 0; std::int64_t sampledPlaybackTicks = 0; @@ -6405,11 +6466,15 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha std::int64_t sampledMasterMonitoringTicks = 0; std::int64_t sampledMeteringSyncTicks = 0; lastAudioCallbackCounter.store(callbackCounter, std::memory_order_relaxed); - const bool firstCallbackAfterTransportStart = firstCallbackAfterTransportStartPending.exchange(false, std::memory_order_acq_rel); + // One transport snapshot owns the entire block. A prepared start published + // midway through a stopped callback must not advance that unrecorded block. + const bool callbackPlaying = isPlaying.load(std::memory_order_acquire); + const bool callbackRecording = isRecordMode.load(std::memory_order_acquire); + const bool firstCallbackAfterTransportStart = callbackPlaying + && firstCallbackAfterTransportStartPending.exchange(false, std::memory_order_acq_rel); const bool callbackStartedWhileRecording = - isPlaying.load(std::memory_order_relaxed) - && isRecordMode.load(std::memory_order_relaxed); - const double callbackStartWallTimeMs = juce::Time::getMillisecondCounterHiRes(); + callbackPlaying + && callbackRecording; lastAudioBlockWallTimeMs.store(callbackStartWallTimeMs, std::memory_order_release); lastCallbackInputChannels.store(numInputChannels, std::memory_order_relaxed); lastCallbackOutputChannels.store(numOutputChannels, std::memory_order_relaxed); @@ -6421,6 +6486,56 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha / currentSampleRate) * 1000.0 : 0.0; + std::optional pairedTrace; + if (tracingCallback) + { + auto& event = pairedTrace.emplace(); + event.callbackCounter = callbackCounter; + event.startQpcTicks = callbackEntryTicks; + event.startSamplePosition = currentSamplePosition.load(std::memory_order_acquire); + event.startWallTimeMs = callbackStartWallTimeMs; + event.budgetMs = callbackBlockDurationMs; + event.sampleRate = currentSampleRate; + event.blockSize = numSamples; + event.playing = callbackPlaying; + event.recording = callbackRecording; + event.threadCyclesRequested = (traceOptions & RealtimeCallbackTrace::threadCyclesFlag) != 0; + const auto thread = captureCallbackTraceThread(event.threadCyclesRequested); + event.threadId = thread.threadId; + event.threadCycleDelta = thread.cycles; // Start value until the end sample. + event.threadCyclesValid = thread.cyclesValid; + event.processorIdsAvailable = thread.processorAvailable; + event.processorGroupStart = thread.processorGroup; + event.processorNumberStart = thread.processorNumber; + } + const auto finishCallbackTraceClock = [&]() noexcept + { + if (pairedTrace) + { + auto& event = *pairedTrace; + const auto thread = captureCallbackTraceThread(event.threadCyclesRequested); + event.processorIdsAvailable = event.processorIdsAvailable && thread.processorAvailable; + event.processorGroupEnd = thread.processorGroup; + event.processorNumberEnd = thread.processorNumber; + event.threadCyclesValid = event.threadCyclesValid && thread.cyclesValid + && thread.cycles >= event.threadCycleDelta; + event.threadCycleDelta = event.threadCyclesValid ? thread.cycles - event.threadCycleDelta : 0; + event.endQpcTicks = juce::Time::getHighResolutionTicks(); + } + // Optional CPU queries are included in the measured wall interval. + // Telemetry publication below remains outside it, as before tracing. + return juce::Time::getMillisecondCounterHiRes(); + }; + const auto publishCallbackTrace = [&](double endWallTimeMs, const std::array* stages) noexcept + { + if (!pairedTrace) return; + auto& event = *pairedTrace; + event.measuredWallMs = endWallTimeMs - callbackStartWallTimeMs; + if (!RealtimeCallbackTrace::shouldPublish(traceOptions, callbackCounter, event.measuredWallMs, event.budgetMs)) return; + event.stageTimingsAvailable = stages != nullptr; + if (stages != nullptr) event.stageMs = *stages; + realtimeCallbackTrace.tryPush(event); + }; if (callbackBlockDurationMs > 0.0) lastAudioBlockDurationMs.store( callbackBlockDurationMs, @@ -6497,13 +6612,14 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha 1, std::memory_order_relaxed); } const double callbackEndWallTimeMs = - juce::Time::getMillisecondCounterHiRes(); + finishCallbackTraceClock(); recordAudioCallbackTiming( callbackEndWallTimeMs - callbackStartWallTimeMs, callbackEndWallTimeMs, callbackBlockDurationMs, callbackCounter, callbackStartedWhileRecording); + publishCallbackTrace(callbackEndWallTimeMs, nullptr); return; } @@ -6521,7 +6637,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha tunerRouteGeneration(route)); } const double callbackEndWallTimeMs = - juce::Time::getMillisecondCounterHiRes(); + finishCallbackTraceClock(); const double callbackDurationMs = callbackEndWallTimeMs - callbackStartWallTimeMs; recordAudioCallbackTiming( @@ -6530,6 +6646,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha lastAudioBlockDurationMs.load(std::memory_order_relaxed), callbackCounter, callbackStartedWhileRecording); + publishCallbackTrace(callbackEndWallTimeMs, nullptr); return; } @@ -6591,13 +6708,17 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha std::memory_order_seq_cst); jassert(rtTracks != nullptr); if (rtTracks == nullptr) + { + if (pairedTrace) publishCallbackTrace(finishCallbackTraceClock(), nullptr); return; + } const auto blockCaptureEpoch = pluginAutomationClock->epoch.load(std::memory_order_acquire); const auto blockStartSamplePosition = currentSamplePosition.load( std::memory_order_acquire); - pluginAutomationClock->update(static_cast(blockStartSamplePosition) / juce::jmax(1.0, currentSampleRate), currentSampleRate, isPlaying.load(std::memory_order_acquire)); - const ScopedPluginAutomationProcessing blockCaptureContext(static_cast(blockStartSamplePosition) / juce::jmax(1.0, currentSampleRate), currentSampleRate, blockCaptureEpoch, isPlaying.load(std::memory_order_acquire)); + if (pairedTrace) pairedTrace->startSamplePosition = blockStartSamplePosition; + pluginAutomationClock->update(static_cast(blockStartSamplePosition) / juce::jmax(1.0, currentSampleRate), currentSampleRate, callbackPlaying); + const ScopedPluginAutomationProcessing blockCaptureContext(static_cast(blockStartSamplePosition) / juce::jmax(1.0, currentSampleRate), currentSampleRate, blockCaptureEpoch, callbackPlaying); // Every routed buffer represents exactly one callback. Clearing at the // callback boundary prevents a skipped source/destination from replaying // stale sidechain or send audio on the next block. @@ -6619,7 +6740,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha clearRealtimeBuffer(entry.sendAccumBuffer); } const bool shouldLogCallbackSummary = kAudioPathDebugLogs - && (((callbackCounter % 50) == 1) || ((isPlaying || isRecordMode) && firstCallbackAfterTransportStart)); + && (((callbackCounter % 50) == 1) || ((callbackPlaying || callbackRecording) && firstCallbackAfterTransportStart)); const bool shouldSampleSignalChainPeaks = shouldSampleAudioCallbackStages || kAudioPathDebugLogs; @@ -6632,8 +6753,8 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha { preTrackPeak = peakFromFloatChannels(outputChannelData, numOutputChannels, numSamples); OPENSTUDIO_LOG_AUDIO_ENGINE_PLAYBACK("callback start #" + juce::String(static_cast(callbackCounter)) - + " isPlaying=" + juce::String(isPlaying.load() ? "true" : "false") - + " isRecordMode=" + juce::String(isRecordMode.load() ? "true" : "false") + + " isPlaying=" + juce::String(callbackPlaying ? "true" : "false") + + " isRecordMode=" + juce::String(callbackRecording ? "true" : "false") + " inputChannels=" + juce::String(numInputChannels) + " outputChannels=" + juce::String(numOutputChannels) + " sampleRate=" + juce::String(currentSampleRate, 2) @@ -6679,7 +6800,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha blockStartSamplePosition), // Record mode can be armed before the transport advances. A parked // record latch must not repeatedly restart the click at one position. - isPlaying.load(std::memory_order_relaxed)); + callbackPlaying); } if (shouldSampleAudioCallbackStages) { @@ -6737,7 +6858,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha } const bool excludedBySolo = anySoloed && !track->getSolo() && !track->getSoloSafe(); - const bool hasActiveAudioRecording = isRecordMode.load(std::memory_order_relaxed) + const bool hasActiveAudioRecording = callbackRecording && track->getRecordArmed() && track->getTrackType() == TrackType::Audio; const bool needsRecordingPath = track->getRecordArmed() || hasActiveAudioRecording; @@ -6779,7 +6900,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha // 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 + if (!callbackPlaying && !shouldReadHardwareInput && !trackEntry.hasIncomingSends && !track->needsProcessing( @@ -6820,7 +6941,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha // 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 && !hasActiveARA) + if (callbackPlaying && !hasActiveARA) { // Apply per-track playback offset (ms → seconds) double trackOffsetSec = track->getPlaybackOffset() / 1000.0; @@ -6840,7 +6961,9 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha trackBuffer, adjustedTime, numSamples, - currentSampleRate + currentSampleRate, + track->getTrackChannelCount() == 1, + trackEntry.playbackContinuityKey ); if (shouldSampleAudioCallbackStages) { @@ -6875,7 +6998,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha < numInputChannels; ++ch) { - if (isPlaying || trackEntry.hasIncomingSends) + if (callbackPlaying || trackEntry.hasIncomingSends) { // Preserve accumulated sends while monitoring stopped, // and playback material while overdubbing. @@ -6901,40 +7024,11 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha } } - // ========== 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.load() && hasActiveAudioRecording) - { - // Punch recording: only write audio within punch range - bool shouldWrite = true; - if (punchEnabled.load(std::memory_order_acquire)) - { - double posSeconds = - static_cast( - blockStartSamplePosition) - / currentSampleRate; - double punchStart = punchStartTime.load(std::memory_order_relaxed); - double punchEnd = punchEndTime.load(std::memory_order_relaxed); - shouldWrite = (posSeconds >= punchStart && posSeconds < punchEnd); - } - - if (shouldWrite) - { - const double compensatedBlockStart = juce::jmax( - 0.0, - (static_cast( - blockStartSamplePosition) - - inputLatencySamples) - / juce::jmax(1.0, currentSampleRate)); - if (!recordingDeviceInterrupted.load(std::memory_order_acquire)) - audioRecorder.writeBlock( - trackId, - trackBuffer, - numSamples, - compensatedBlockStart); - } - } + // Record only the selected hardware input. The monitoring buffer above + // already contains playback/sends, and must never be the default take. + if (callbackPlaying && hasActiveAudioRecording) + recordTrackHardwareInput(trackEntry, inputChannelData, numInputChannels, + numSamples, blockStartSamplePosition); // Keep the recorder tap above faithful to the selected hardware route. // For monitoring and FX, centre a one-channel input by adding that same @@ -6968,7 +7062,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha blockStartTimeSeconds, numSamples, currentSampleRate, - isPlaying.load()); + callbackPlaying); auto& midiMessages = work.midi; int prevMidiMax = midiMaxEventsPerBlock.load(std::memory_order_relaxed); while (midiMessages.getNumEvents() > prevMidiMax @@ -6983,7 +7077,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha work.position = blockStartTimeSeconds; work.rate = currentSampleRate; work.automationEpoch = blockCaptureEpoch; - work.playing = isPlaying.load(std::memory_order_acquire); + work.playing = callbackPlaying; return true; }; const auto reduceTrack = [&](const RealtimeTrackEntry& trackEntry, @@ -6997,14 +7091,14 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha const bool shouldReadHardwareInput = work.shouldReadHardwareInput; const bool hasActiveARA = work.hasActiveARA; juce::ignoreUnused(trackId, shouldReadHardwareInput, hasActiveARA); - if (shouldLogCallbackSummary && isPlaying.load()) + if (shouldLogCallbackSummary && callbackPlaying) { OPENSTUDIO_LOG_AUDIO_ENGINE_PLAYBACK("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 && !hasActiveARA) ? "true" : "false") + + " clipPlaybackRequested=" + juce::String((callbackPlaying && !hasActiveARA) ? "true" : "false") + " trackBufferPeak=" + juce::String( peakFromFloatBuffer(trackBuffer, numSamples), 4)); } @@ -7122,8 +7216,9 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha { return pendingEntry.processor == entry.processor && pendingEntry.work->jobIndex >= 0; }); if (ownedByPendingBatch) { - recordQuarantinedTrackInput(entry, inputChannelData, numInputChannels, - numSamples, blockStartSamplePosition); + if (callbackStartedWhileRecording) + recordTrackHardwareInput(entry, inputChannelData, numInputChannels, + numSamples, blockStartSamplePosition); continue; } // Keep unrelated sends, sidechains and recorder taps in their @@ -7409,7 +7504,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha // Previously also fired unconditionally in release builds; removed because // it calls FileLogger (disk write + mutex) from the audio thread. if (kAudioPathDebugLogs - && (shouldLogCallbackSummary || (((isPlaying || isRecordMode) && finalOutputPeak <= 0.0001f) + && (shouldLogCallbackSummary || (((callbackPlaying || callbackRecording) && finalOutputPeak <= 0.0001f) && ((callbackCounter % 15) == 1)))) { if (preTrackPeak == 0.0f) @@ -7436,7 +7531,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha timecodeSyncManager.processBlock(numSamples, currentSampleRate, callbackTempo, static_cast(blockStartSamplePosition) / currentSampleRate, - isPlaying.load()); + callbackPlaying); // ========== Clip Launch / Trigger Engine (Phase 4.1) ========== { @@ -7460,7 +7555,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha // 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.load(std::memory_order_relaxed)) + if (callbackPlaying) { auto expectedPosition = blockStartSamplePosition; @@ -7492,7 +7587,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha + juce::String( rtTracks->processingOrderCount) + " numSamples=" + juce::String(numSamples) - + " transportPlaying=" + juce::String(isPlaying.load() ? "true" : "false") + + " transportPlaying=" + juce::String(callbackPlaying ? "true" : "false") + " currentPositionSeconds=" + juce::String( currentSampleRate > 0.0 @@ -7506,7 +7601,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha } const double callbackEndWallTimeMs = - juce::Time::getMillisecondCounterHiRes(); + finishCallbackTraceClock(); const double callbackProcessMs = callbackEndWallTimeMs - callbackStartWallTimeMs; const double expectedBlockMs = lastAudioBlockDurationMs.load (std::memory_order_relaxed); @@ -7543,6 +7638,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha recordAudioCallbackStageTiming( stageProcessMs, callbackEndWallTimeMs); + publishCallbackTrace(callbackEndWallTimeMs, &stageProcessMs); } recordAudioCallbackTiming( callbackProcessMs, @@ -9434,14 +9530,15 @@ void AudioEngine::setTransportPlaying(bool playing) if (isPlaying == playing) return; // No change - isPlaying = playing; + // Stop is immediate. Start is published only after deferred sources and + // control-side ARA notifications are ready, so callbacks cannot outrun setup. + if (!playing) isPlaying.store(false, std::memory_order_release); if (playing) pluginAutomationClock->epoch.fetch_add(1, std::memory_order_acq_rel); pluginAutomationClock->update(getTransportPosition(), currentSampleRate, playing); if (playing) { playbackEngine.commitAllDeferredClipAudioFiles(); - firstCallbackAfterTransportStartPending.store(true, std::memory_order_release); logToDisk("Transport PLAY. SampleRate: " + juce::String(currentSampleRate) + " Position: " + juce::String(getTransportPosition()) + "s"); @@ -9491,6 +9588,11 @@ void AudioEngine::setTransportPlaying(bool playing) editorFocusedAtPlayStart); } } + if (playing) + { + firstCallbackAfterTransportStartPending.store(true, std::memory_order_release); + isPlaying.store(true, std::memory_order_release); + } } void AudioEngine::rolloverLoopRecordings(double newTakeStartSeconds) @@ -9537,7 +9639,9 @@ void AudioEngine::rolloverLoopRecordings(double newTakeStartSeconds) outputFile, currentSampleRate, track->getInputChannelCount(), - newTakeStartSeconds, + punchEnabled.load(std::memory_order_acquire) + ? juce::jmax(newTakeStartSeconds, punchStartTime.load(std::memory_order_relaxed)) + : newTakeStartSeconds, previousTake)) { if (previousTake.file.existsAsFile()) @@ -9628,6 +9732,48 @@ void AudioEngine::setTransportRecordingAsync(bool recording, std::function completion, + std::function isCurrentRequest) +{ + jassert(juce::MessageManager::getInstance()->isThisTheMessageThread()); + const auto generation = ++recordingRequestGeneration; + const auto requestedPosition = currentSamplePosition.load(std::memory_order_acquire); + auto apply = [this, generation, requestedPosition, completion = std::move(completion), + isCurrentRequest = std::move(isCurrentRequest)](bool allowed) mutable + { + const auto current = [&] { + return generation == recordingRequestGeneration + && (!isCurrentRequest || isCurrentRequest()); + }; + if (!allowed || !current() || isPlaying.load(std::memory_order_acquire) + || isRecordMode.load(std::memory_order_acquire) + || currentSamplePosition.load(std::memory_order_acquire) != requestedPosition) + { if (completion) completion(false); return; } + + // This creates/flushes writers and recovery journals on the control + // thread. Stopped callbacks keep monitoring but never capture or move. + setTransportRecording(true); + const auto preparedGeneration = recordingRequestGeneration; + const bool accepted = isRecordMode.load(std::memory_order_acquire) + && (!isCurrentRequest || isCurrentRequest()) + && currentSamplePosition.load(std::memory_order_acquire) == requestedPosition; + if (!accepted) + { + if (recordingRequestGeneration == preparedGeneration + && isRecordMode.load(std::memory_order_acquire)) setTransportRecording(false); + if (completion) completion(false); + return; + } + setTransportPlaying(true); + if (completion) completion(isPlaying.load(std::memory_order_acquire) + && isRecordMode.load(std::memory_order_acquire)); + }; + // Offline fixtures supply explicit PCM and must never request/open hardware. + if (deviceStartupMode != DeviceStartup::offline && recordingRequiresAudioInput()) + requestAudioInputAccess(std::move(apply)); + else apply(true); +} + bool AudioEngine::finalizeInterruptedRecording() { if (!recordingDeviceInterrupted.load(std::memory_order_acquire)) return false; @@ -9648,7 +9794,8 @@ void AudioEngine::setTransportRecording(bool recording) return; // No change if (recording && recordingDeviceInterrupted.load(std::memory_order_acquire)) return; - if (recording && recordingRequiresAudioInput() && !isMicrophonePermissionGrantedForInput()) + if (recording && deviceStartupMode != DeviceStartup::offline + && recordingRequiresAudioInput() && !isMicrophonePermissionGrantedForInput()) { juce::Logger::writeToLog("Recording requires microphone authorization; no recording state was changed."); return; @@ -9730,7 +9877,9 @@ void AudioEngine::setTransportRecording(bool recording) { audioRecorder.setRecordingStartTime( trackId, - getTransportPosition()); + punchEnabled.load(std::memory_order_acquire) + ? juce::jmax(getTransportPosition(), punchStartTime.load(std::memory_order_relaxed)) + : getTransportPosition()); anyAudioStarted = true; } @@ -71324,12 +71473,13 @@ juce::var AudioEngine::getActiveRecordingMIDIPreviews(const juce::var& requests) void AudioEngine::addPlaybackClip(const juce::String& trackId, const juce::String& filePath, double startTime, double duration, double offset, double volumeDB, double fadeIn, double fadeOut, const juce::String& clipId, - const juce::String& pitchCorrectionSourceFilePath, double pitchCorrectionSourceOffset) + const juce::String& pitchCorrectionSourceFilePath, double pitchCorrectionSourceOffset, + int fadeInCurve, int fadeOutCurve) { juce::File audioFile(filePath); juce::File sourceAudioFile(pitchCorrectionSourceFilePath); playbackEngine.addClip(audioFile, startTime, duration, trackId, offset, volumeDB, fadeIn, fadeOut, clipId, - sourceAudioFile, pitchCorrectionSourceOffset); + sourceAudioFile, pitchCorrectionSourceOffset, fadeInCurve, fadeOutCurve); juce::Logger::writeToLog("AudioEngine: Added playback clip to track " + trackId + " (offset=" + juce::String(offset) + "s, vol=" + juce::String(volumeDB) + "dB)"); OPENSTUDIO_LOG_AUDIO_ENGINE_PLAYBACK("addPlaybackClip track=" + trackId @@ -71368,7 +71518,8 @@ void AudioEngine::addPlaybackClipsBatch(const juce::String& clipsJSON) juce::File audioFile (fp); juce::File sourceAudioFile (sourcePath); playbackEngine.addClip (audioFile, startTime, dur, trackId, off, vol, fi, fo, cId, - sourceAudioFile, sourceOffset); + sourceAudioFile, sourceOffset, + static_cast(item["fadeInCurve"]), static_cast(item["fadeOutCurve"])); OPENSTUDIO_LOG_AUDIO_ENGINE_PLAYBACK("addPlaybackClipsBatch clip track=" + trackId + " clipId=" + cId + " file=" + fp @@ -71408,7 +71559,38 @@ void AudioEngine::clearTrackPlaybackClips(const juce::String& trackId) -juce::var AudioEngine::getWaveformPeaks(const juce::String& filePath, int samplesPerPixel, int startSample, int numPixels) +juce::var AudioEngine::analyzeClipTempo(const juce::String& trackId, const juce::String& clipId, + double rangeStartSeconds, double rangeDurationSeconds, const juce::var& source, const std::atomic* cancelled) const +{ + const auto snapshot = resolveReadOnlyClipSource(playbackEngine, trackId, clipId, source); + if (snapshot) + { + const auto& clip = *snapshot; + const double start = std::isfinite(rangeStartSeconds) ? juce::jlimit(0.0, clip.duration, rangeStartSeconds) : 0.0; + const double duration = rangeDurationSeconds > 0.0 && std::isfinite(rangeDurationSeconds) + ? juce::jmin(rangeDurationSeconds, clip.duration - start) : clip.duration - start; + auto result = ClipTempoAnalyzer::analyze(clip.audioFile, clip.offset + start, duration, cancelled); + if (auto* object = result.getDynamicObject()) + { + if (result.hasProperty("analysisStartSeconds")) object->setProperty("analysisStartSeconds", start + static_cast(result["analysisStartSeconds"])); + if (auto* segments = result["segments"].getArray()) + for (auto& segment : *segments) + if (auto* values = segment.getDynamicObject()) values->setProperty("startSeconds", start + static_cast(segment["startSeconds"])); + if (result.hasProperty("beatOffsetSeconds") && static_cast(result["bpm"]) > 0.0) + object->setProperty("beatOffsetSeconds", std::fmod(start + static_cast(result["beatOffsetSeconds"]), 60.0 / static_cast(result["bpm"]))); + } + return result; + } + auto* result = new juce::DynamicObject(); + result->setProperty("error", "The audio clip is no longer available."); + result->setProperty("confidence", 0.0); + result->setProperty("source", "audio"); + result->setProperty("candidates", juce::Array()); + return juce::var(result); +} + +juce::var AudioEngine::getWaveformPeaks(const juce::String& filePath, int samplesPerPixel, int startSample, int numPixels, + juce::int64 sourceRangeStartSample, juce::int64 sourceRangeEndSample) { // REAPER-inspired: read from pre-computed peak cache (.ospeaks) instead of audio file. // If cache doesn't exist, kick off async generation and return empty. @@ -71435,7 +71617,8 @@ juce::var AudioEngine::getWaveformPeaks(const juce::String& filePath, int sample } // Read from peak cache (instant — memory-cached mipmap lookup) - return peakCache.getPeaks(audioFile, samplesPerPixel, startSample, numPixels); + return peakCache.getPeaks(audioFile, samplesPerPixel, startSample, numPixels, + sourceRangeStartSample, sourceRangeEndSample); } double AudioEngine::getAudioPeakAmplitude(const juce::String& filePath, @@ -72270,9 +72453,7 @@ juce::var AudioEngine::getRealtimeAudioTelemetry() const root->setProperty( "namRackOversamplingFactor", getNAMRackOversamplingFactor()); - root->setProperty( - "audioDeviceXRunCount", - deviceManager.getXRunCount()); + AudioXRunTelemetry::publish(*root, AudioXRunTelemetry::capture(deviceManager)); root->setProperty( "activeOutputChannels", lastActiveOutputChannels.load( @@ -72663,9 +72844,7 @@ juce::var AudioEngine::getAudioDebugSnapshot() const root->setProperty( "namRackOversamplingFactor", getNAMRackOversamplingFactor()); - const auto xruns = deviceManager.getXRunCount(); - root->setProperty("audioDeviceXRunCount", xruns); - root->setProperty("audioDeviceXRunTelemetrySupported", xruns >= 0); + AudioXRunTelemetry::publish(*root, AudioXRunTelemetry::capture(deviceManager)); root->setProperty("recordingDeviceInterrupted", recordingDeviceInterrupted.load(std::memory_order_acquire)); #if JUCE_WINDOWS const auto cpuUsagePercent = getProcessCpuUsagePercent(); @@ -73351,6 +73530,9 @@ juce::var AudioEngine::getAudioDebugSnapshot() const root->setProperty("playbackAudioDataCacheMissCount", playbackEngine.getAudioDataCacheMissCount()); root->setProperty("playbackAudioDataCachedFileCount", playbackEngine.getAudioDataCachedFileCount()); root->setProperty("playbackReadAheadUnderrunCount", playbackEngine.getAudioDataCacheMissCount()); + root->setProperty("playbackStreamingWindowHits", static_cast(playbackEngine.getStreamingWindowHits())); + root->setProperty("playbackStreamingWindowReads", static_cast(playbackEngine.getStreamingWindowReads())); + root->setProperty("playbackStreamingWindowFallbacks", static_cast(playbackEngine.getStreamingWindowFallbacks())); root->setProperty("playbackStreamingReaderCount", playbackEngine.getAudioDataCachedFileCount()); root->setProperty("playbackStreamingReadAheadCapacityBytes", playbackEngine.getStreamingReadAheadCapacityBytes()); @@ -73840,6 +74022,8 @@ bool AudioEngine::renderProject(const juce::String& source, double startTime, do if (!clearAutomationPreviews()) return false; if (keepRunning && !keepRunning()) return false; const juce::ScopedLock offlineRenderTransaction(offlineRenderTransactionLock); + lastOfflineRenderSourceReadMisses.store(0); + lastOfflineRenderSourceReadFailed.store(false); if (keepRunning && !keepRunning()) return false; // Consume the request at transaction entry so invalid ranges, unsupported // formats, and unwritable paths cannot leak dither into the next render. @@ -74043,6 +74227,8 @@ bool AudioEngine::renderProject(const juce::String& source, double startTime, do juce::String id; float volumeDB; float pan; + double playbackOffsetSeconds = 0.0; + bool monoSource = false; bool muted; bool soloed; bool soloSafe; @@ -74076,6 +74262,8 @@ bool AudioEngine::renderProject(const juce::String& source, double startTime, do snap.id = trackId; snap.volumeDB = track->getVolume(); snap.pan = track->getPan(); + snap.playbackOffsetSeconds = track->getPlaybackOffset() / 1000.0; + snap.monoSource = track->getTrackChannelCount() == 1; snap.muted = track->getMute(); snap.soloed = track->getSolo(); snap.soloSafe = track->getSoloSafe(); @@ -74109,6 +74297,10 @@ bool AudioEngine::renderProject(const juce::String& source, double startTime, do || snap.sendCount > 0 || snap.sidechainSourceCount > 0 || snap.hasTrackAutomation + || track->getTrimVolume() != 0.0f + || track->getPhaseInvert() + || std::abs(track->getStereoWidth() - 100.0f) > 0.01f + || track->getChannelStripEQEnabled() || (!isStemRender && !snap.masterSendEnabled); if (snap.soloed) anySoloed = true; trackSnapshots.push_back(snap); @@ -74950,12 +75142,18 @@ bool AudioEngine::renderProject(const juce::String& source, double startTime, do // Create fresh offline playback engine for each pass (deterministic reads) PlaybackEngine passPlayback; + passPlayback.setOfflineSourceReads(true); // The Lagrange render SRC path is stateful across blocks and currently // unsafe for arbitrary render offsets/ranges. Keep exports on the // exact-position stateless interpolation path unless explicitly opted in. passPlayback.setRenderMode(useRenderLagrangeSrc); + std::set offlineClipIds; + for (const auto& clip : clipSnapshot) + if (clip.isActive && renderProcessingTrackIds.count(clip.trackId) != 0) + offlineClipIds.insert(clip.clipId); for (const auto& entry : renderedPreviewSegmentSnapshot) { + if (offlineClipIds.count(entry.first) == 0) continue; for (const auto& segment : entry.second) { passPlayback.setClipRenderedPreviewSegment(entry.first, @@ -74967,10 +75165,17 @@ bool AudioEngine::renderProject(const juce::String& source, double startTime, do } for (const auto& clip : clipSnapshot) { - passPlayback.addClip(clip.audioFile, clip.startTime, clip.duration, - clip.trackId, clip.offset, clip.volumeDB, - clip.fadeIn, clip.fadeOut, clip.clipId, - clip.originalAudioFile, clip.originalOffset); + if (clip.isActive && renderProcessingTrackIds.count(clip.trackId) != 0) + passPlayback.addClipFromSnapshot(clip); + } + if (passPlayback.hasOfflineSourceReadFailure()) + { + lastOfflineRenderSourceReadFailed.store(true); + lastOfflineRenderSourceReadMisses.fetch_add(passPlayback.getAudioDataCacheMissCount()); + logToDisk("renderProject: FAIL - could not open an offline source"); + writer.reset(); + restorePreparedTracksForRealtime(); + return false; } // Create fresh metronome for each pass @@ -75032,6 +75237,10 @@ bool AudioEngine::renderProject(const juce::String& source, double startTime, do } int samplesThisBlock = (int)std::min((juce::int64)blockSize, samplesRemaining); const juce::int64 renderBlockStartSample = processingTotalSamples - samplesRemaining; + // Derive the clock from integer frames. Repeatedly adding block + // durations drifts across long renders and can admit a frame at + // an otherwise exact clip/source end boundary. + currentTimeSeconds = startTime + static_cast(renderBlockStartSample) / actualSampleRate; const auto outputWindow = getOfflineRenderBlockWindow(offlineRenderPlan, renderBlockStartSample, samplesThisBlock); @@ -75110,8 +75319,8 @@ bool AudioEngine::renderProject(const juce::String& source, double startTime, do juce::AudioBuffer trackBuffer(2, samplesThisBlock); trackBuffer.clear(); - passPlayback.fillTrackBuffer(snap.id, trackBuffer, currentTimeSeconds, - samplesThisBlock, actualSampleRate); + passPlayback.fillTrackBuffer(snap.id, trackBuffer, currentTimeSeconds - snap.playbackOffsetSeconds, + samplesThisBlock, actualSampleRate, snap.monoSource); if (admittedSourceSamples < samplesThisBlock) trackBuffer.clear(admittedSourceSamples, samplesThisBlock - admittedSourceSamples); @@ -75129,7 +75338,7 @@ bool AudioEngine::renderProject(const juce::String& source, double startTime, do } } - const float volumeGain = juce::Decibels::decibelsToGain(snap.volumeDB); + const float volumeGain = MixerGain::fromDecibels(snap.volumeDB); float leftGain = 1.0f; float rightGain = 1.0f; computePanLawGains( @@ -75204,8 +75413,9 @@ bool AudioEngine::renderProject(const juce::String& source, double startTime, do { juce::AudioBuffer playbackBuffer(2, samplesThisBlock); playbackBuffer.clear(); - passPlayback.fillTrackBuffer(trackEntry.id, playbackBuffer, currentTimeSeconds, - samplesThisBlock, actualSampleRate); + passPlayback.fillTrackBuffer(trackEntry.id, playbackBuffer, + currentTimeSeconds - track->getPlaybackOffset() / 1000.0, + samplesThisBlock, actualSampleRate, snapshotIt->second.monoSource); if (admittedSourceSamples < samplesThisBlock) playbackBuffer.clear(admittedSourceSamples, samplesThisBlock - admittedSourceSamples); @@ -75371,6 +75581,16 @@ bool AudioEngine::renderProject(const juce::String& source, double startTime, do } } + if (passPlayback.hasOfflineSourceReadFailure()) + { + lastOfflineRenderSourceReadFailed.store(true); + lastOfflineRenderSourceReadMisses.fetch_add(passPlayback.getAudioDataCacheMissCount()); + logToDisk("renderProject: FAIL - offline source read failed; no concealed audio accepted"); + writer.reset(); + restorePreparedTracksForRealtime(); + return false; + } + // Measure only the aligned output window. Startup latency is rendered // to warm/flush the chain, but must not influence normalization. if (outputWindow.numSamples > 0) @@ -75481,7 +75701,6 @@ bool AudioEngine::renderProject(const juce::String& source, double startTime, do alignedSamplesVisited += outputWindow.numSamples; - currentTimeSeconds += (double)samplesThisBlock / actualSampleRate; samplePositionForMetronome += samplesThisBlock; samplesRemaining -= samplesThisBlock; } @@ -75833,22 +76052,26 @@ juce::var AudioEngine::runRenderExportRegression(const juce::File& outputDirecto { juce::Array checks; juce::Array artifacts; + juce::StringArray checkIdentifiers; bool overallPass = true; const auto addCheck = [&] (const juce::String& id, bool pass, const juce::String& detail, const juce::var& diagnostic = {}) { + const bool validIdentifier = id.trim().isNotEmpty() && !checkIdentifiers.contains(id); + const bool checkPassed = pass && validIdentifier; + if (validIdentifier) checkIdentifiers.add(id); auto* check = new juce::DynamicObject(); - check->setProperty("id", id); - check->setProperty("status", pass ? "pass" : "fail"); - check->setProperty("detail", detail); + check->setProperty("id", validIdentifier ? id : "invalid_check_identifier_" + juce::String(checks.size())); + check->setProperty("status", checkPassed ? "pass" : "fail"); + check->setProperty("detail", validIdentifier ? detail : "Missing or duplicate regression identifier: " + id + ". " + detail); if (! diagnostic.isVoid()) check->setProperty("diagnostic", diagnostic); checks.add(juce::var(check)); if (! isPlaying.load()) pumpRegressionMessages(); - overallPass = overallPass && pass; + overallPass = overallPass && checkPassed; }; const auto hardwareEvidence = [this]() @@ -75885,6 +76108,62 @@ juce::var AudioEngine::runRenderExportRegression(const juce::File& outputDirecto addCheck("output_directory_ready", outputDirectoryReady, outputDirectory.getFullPathName()); + if (outputDirectoryReady) + runAudioFileConversionRegression(outputDirectory, [&](const char* id, bool pass) { + addCheck(id, pass, "Bounded band-limited file conversion, content cache and atomic source route invariants."); + }); + if (outputDirectoryReady) + runTempoDetectionRegression(outputDirectory, [&](const char* id, bool pass) { + addCheck(id, pass, "Audio-derived tempo evidence, ambiguity and bounded-work controls."); + }); + runRealtimeSourceReadWindowRegression([&](const char* id, bool pass) { + addCheck(id, pass, "Bounded prepared streaming-source window preserves exact frames, readiness and source identity."); + }); + runRealtimeCallbackTraceRegression([&](const char* id, bool pass) { + addCheck(id, pass, "Opt-in paired callback tracing preserves bounded SPSC ownership, sample identity and drop accounting."); + }); + runEditingWorkflowRegression([&](const char* id, bool pass) { + addCheck(id, pass, "Fader silence and bounded, cancellable tempo work; no hardware timing or listening claim."); + }); + AudioXRunTelemetry::runRegression([&](const char* id, bool pass) { + addCheck(id, pass, "Driver and host overload telemetry remain distinct, preserve the legacy combined field and withhold inconsistent splits."); + }); + runRecordingDiskWriterRegression([&](const char* id, bool pass) { + addCheck(id, pass, "Prepared recording FIFO preserves exact eight-frame PCM, bounded overflow, background failure status and stop-tail draining without producer wake notifications."); + }); + const auto inlineEQDryRegression = runInlineEQDryRegression(); + if (const auto* items = inlineEQDryRegression["checks"].getArray()) + for (const auto& item : *items) + addCheck(item["id"].toString(), static_cast(item["pass"]), item["detail"].toString()); + const auto packedWaveformRegression = runPackedWaveformPeaksRegression(); + if (const auto* items = packedWaveformRegression["checks"].getArray()) + for (const auto& item : *items) + addCheck(item["id"].toString(), static_cast(item["pass"]), item["detail"].toString()); + if (outputDirectoryReady) + outputDirectory.getChildFile("packed-waveform-benchmark.json").replaceWithText( + juce::JSON::toString(packedWaveformRegression["benchmark"])); + if (outputDirectoryReady) + { + const auto peakCacheGenerationRegression = runPeakCacheGenerationRegression(outputDirectory); + if (const auto* items = peakCacheGenerationRegression["checks"].getArray()) + for (const auto& item : *items) + addCheck(item["id"].toString(), static_cast(item["pass"]), item["detail"].toString()); + } + if (outputDirectoryReady) + runPlaybackTinyBlockRegression(outputDirectory, [&](const char* id, bool pass) { + addCheck(id, pass, "Tiny-block playback preserves integer/fractional SRC, fades, channels, EOF and clip-index mutations."); + }); + if (outputDirectoryReady) + runIntegerPDCRegression(outputDirectory, [&](const char* id, bool pass) { + addCheck(id, pass, "Prepared integer PDC preserves JUCE delay history, tap progression and production-track transitions; timings are diagnostic."); + }); + if (outputDirectoryReady) + { + const auto recordingStart = runPreparedRecordingStartRegression(outputDirectory); + if (const auto* items = recordingStart["checks"].getArray()) + for (const auto& item : *items) + addCheck(item["id"].toString(), static_cast(item["pass"]), item["detail"].toString()); + } // Distribution/feedback invariants, independent of the audio render chain. for (const int bits : { 16, 18, 20, 22, 24 }) @@ -75990,10 +76269,14 @@ juce::var AudioEngine::runRenderExportRegression(const juce::File& outputDirecto const auto feedAlignmentCapture=[&]{ if(left->alignmentCapture->state.load()==1&&right->alignmentCapture->state.load()==1) // Continuous analysis calls the cancellation probe inside its - // FFT loops too. Advance one block per probe so the synthetic - // producer cannot outrun the bounded stream consumer. - for(int block=0;block<(static_cast(captureRequestValue["projectSpan"])?1:64)&&left->alignmentCapture->state.load()==1;++block) + // FFT loops too. Bound each synthetic burst and stop when a + // complete chunk is queued. One block per 5ms polling loop + // ties virtual audio progress to Windows sleep granularity and + // can time out even though no real input or DSP path stalled. + for(int block=0;block<(static_cast(captureRequestValue["projectSpan"])?4:64)&&left->alignmentCapture->state.load()==1;++block) { + if(static_cast(captureRequestValue["projectSpan"]) + &&(!left->alignmentCapture->continuousDrained()||!right->alignmentCapture->continuousDrained()))break; for(int i=0;i<512;++i)for(int ch=0;ch<2;++ch){a.setSample(ch,i,signal(position+i-trackMap[alignTrackA]->getPDCDelay()));b.setSample(ch,i,-signal(position+i-37-trackMap[alignTrackB]->getPDCDelay()));} left->alignmentCapture->process(a,position,true);right->alignmentCapture->process(b,position,true);position+=512; } @@ -76098,6 +76381,363 @@ juce::var AudioEngine::runRenderExportRegression(const juce::File& outputDirecto fixtureWritten, fixtureFile.getFullPathName()); + // Long sources must use complete worker reads, regardless of the realtime + // full-decode budget, render speed, seek position or block partitioning. + { + const auto longFile = outputDirectory.getChildFile("offline-long-source.wav"); + juce::WavAudioFormat wav; + std::unique_ptr stream(longFile.createOutputStream()); + auto writer = wav.createWriterFor(stream, juce::AudioFormatWriterOptions() + .withSampleRate(fixtureSampleRate).withNumChannels(1).withBitsPerSample(16)); + juce::AudioBuffer pattern(1, 65536); + for (int sample = 0; sample < pattern.getNumSamples(); ++sample) + pattern.setSample(0, sample, static_cast((sample % 256) - 128) / 512.0f); + bool written = writer != nullptr; + for (int chunk = 0; written && chunk < 257; ++chunk) + written = writer->writeFromAudioSampleBuffer(pattern, 0, pattern.getNumSamples()); + writer.reset(); + PlaybackEngine offline; + offline.setOfflineSourceReads(true); + constexpr int start = 246000; + offline.addClip(longFile, 0.0, 4.0, "long-source", start / fixtureSampleRate, + 0.0, 0.0, 0.0, "long-source-clip"); + juce::AudioBuffer output(2, 2048); + int visited = 0; + double maxError = 0.0; + constexpr int partitions[] { 7, 2048, 333, 512 }; + int partition = 0; + while (written && visited < 192000) + { + const int count = juce::jmin(partitions[partition++ % 4], 192000 - visited); + output.clear(); + offline.fillTrackBuffer("long-source", output, visited / fixtureSampleRate, count, fixtureSampleRate); + for (int sample = 0; sample < count; ++sample) + { + const float expected = static_cast(((start + visited + sample) % 256) - 128) / 512.0f; + maxError = juce::jmax(maxError, std::abs(static_cast(output.getSample(0, sample) - expected)), + std::abs(static_cast(output.getSample(1, sample) - expected))); + } + visited += count; + } + addCheck("offline_long_source_complete_bounded_reads", written && visited == 192000 + && !offline.hasOfflineSourceReadFailure() && offline.getFullyDecodedSourceCount() == 0 + && offline.getAudioDataCachedFileCount() == 0 && maxError < 1.0e-6, + "A source above 64 MiB decoded is read exactly after a nonzero seek with uneven blocks, without full decoding or read-ahead concealment.", maxError); + PlaybackEngine missing; + missing.setOfflineSourceReads(true); + missing.addClip(outputDirectory.getChildFile("missing-offline-source.wav"), 0, 1, "missing"); + addCheck("offline_missing_source_fails_closed", missing.hasOfflineSourceReadFailure(), + "An unavailable offline source latches a render failure rather than valid silence."); + + const auto sourceClips = offline.getClipSnapshot(); + auto snapshot = sourceClips.empty() + ? PlaybackEngine::ClipInfo(longFile, 0, 4, "long-source") : sourceClips.front(); + snapshot.fadeIn = 0.5; snapshot.fadeOut = 0.25; + snapshot.fadeInCurve = 1; snapshot.fadeOutCurve = 2; + snapshot.gainEnvelope = { { 0.0, 0.5f }, { 4.0, 0.5f } }; + snapshot.isActive = false; + PlaybackEngine restored; + restored.setOfflineSourceReads(true); + restored.addClipFromSnapshot(snapshot); + const auto restoredClips = restored.getClipSnapshot(); + output.clear(); restored.fillTrackBuffer("long-source", output, 0, 512, fixtureSampleRate); + addCheck("offline_clip_snapshot_complete", restoredClips.size() == 1 + && restoredClips.front().fadeInCurve == 1 && restoredClips.front().fadeOutCurve == 2 + && restoredClips.front().gainEnvelope.size() == 2 && !restoredClips.front().isActive + && output.getMagnitude(0, 512) == 0.0f, + "Offline snapshots retain fade shapes, gain envelopes and inactive state."); + snapshot.isActive = true; + PlaybackEngine shaped; + shaped.setOfflineSourceReads(true); + shaped.addClipFromSnapshot(snapshot); + output.clear(); shaped.fillTrackBuffer("long-source", output, 0.25, 512, fixtureSampleRate); + double shapedError = 0.0; + for (int sample = 0; sample < 512; ++sample) + { + const float source = static_cast(((start + 12000 + sample) % 256) - 128) / 512.0f; + const double expected = source * 0.5 * std::sqrt((0.25 + sample / fixtureSampleRate) / 0.5); + shapedError = juce::jmax(shapedError, std::abs(output.getSample(0, sample) - expected)); + } + addCheck("offline_snapshot_shapes_and_envelope_audio", written && shapedError < 1.0e-6, + "A restored equal-power fade and gain envelope produce the independently calculated sample gains.", shapedError); + } + { + const auto edgeFile = outputDirectory.getChildFile("waveform-trim-boundaries.wav"); + juce::AudioBuffer edgeAudio(1, 1537); edgeAudio.clear(); + edgeAudio.setSample(0, 0, 0.9f); edgeAudio.setSample(0, 63, 0.8f); + edgeAudio.setSample(0, 1499, 0.5f); edgeAudio.setSample(0, 1500, 1.0f); + PeakCache cache; + const bool ready = writeBufferToWavFile(edgeAudio, 1537, fixtureSampleRate, edgeFile) && cache.generateSync(edgeFile); + const auto tail = cache.getPeaks(edgeFile, 4096, 0, 1); + const auto clipped = cache.getPeaks(edgeFile, 1024, 0, 2, 64, 1500); + const auto* tailData = tail.getArray(); const auto* clippedData = clipped.getArray(); + addCheck("waveform_partial_final_aggregate", ready && tailData != nullptr && tailData->size() == 3 + && std::abs(static_cast((*tailData)[2]) - 1.0) < 1.0e-5, + "The final partial waveform pixel remains visible even when shorter than one aggregate."); + addCheck("waveform_exact_trim_boundaries", ready && clippedData != nullptr && clippedData->size() == 5 + && std::abs(static_cast((*clippedData)[2])) < 1.0e-5 + && std::abs(static_cast((*clippedData)[4]) - 0.5) < 1.0e-5, + "Removed edge transients are excluded while a retained transient one sample before the trim remains visible."); + } + { + const auto timing = juce::JSON::parse(R"json({"tempo":120,"tempoMarkers":[{"time":1,"bpm":60}],"timeSignature":{"numerator":7,"denominator":8}})json"); + const auto tracks = juce::JSON::parse(R"json([{"name":"tempo-map","clips":[{"startTime":0,"events":[{"type":"noteOn","timestamp":2,"note":60,"velocity":100},{"type":"noteOff","timestamp":2.5,"note":60}]}]}])json"); + const auto midiPath = outputDirectory.getChildFile("tempo-map-export.mid"); + bool valid = exportProjectMIDI(midiPath.getFullPathName(), tracks, 120, timing); + juce::MidiFile file; juce::FileInputStream input(midiPath); + valid = valid && file.readFrom(input); + bool note = false, noteOff = false, signature = false; int tempoEvents = 0; + for (int track = 0; valid && track < file.getNumTracks(); ++track) + for (int event = 0; event < file.getTrack(track)->getNumEvents(); ++event) + { + const auto& message = file.getTrack(track)->getEventPointer(event)->message; + // 120 BPM for one second is two quarter notes; 60 BPM for + // the following second adds one, independently of PPQ. + if (message.isNoteOn()) note = std::abs(message.getTimeStamp() + - 3.0 * file.getTimeFormat()) < 1.0e-6; + if (message.isNoteOff()) noteOff = std::abs(message.getTimeStamp() + - 3.5 * file.getTimeFormat()) < 1.0e-6; + if (message.isTempoMetaEvent()) ++tempoEvents; + if (message.isTimeSignatureMetaEvent()) + { + int numerator = 0, denominator = 0; message.getTimeSignatureInfo(numerator, denominator); + const auto* metadata = message.getMetaEventData(); + signature = numerator == 7 && denominator == 8 + && message.getMetaEventLength() == 4 && metadata[2] == 24 && metadata[3] == 8; + } + } + file.convertTimestampTicksToSeconds(); + bool noteSeconds = false, noteOffSeconds = false; + for (int track = 0; valid && track < file.getNumTracks(); ++track) + for (int event = 0; event < file.getTrack(track)->getNumEvents(); ++event) + { + const auto& message = file.getTrack(track)->getEventPointer(event)->message; + if (message.isNoteOn()) noteSeconds = std::abs(message.getTimeStamp() - 2.0) < 1.0e-6; + if (message.isNoteOff()) noteOffSeconds = std::abs(message.getTimeStamp() - 2.5) < 1.0e-6; + } + addCheck("midi_export_project_tempo_map_and_meter", valid && note && noteOff && noteSeconds + && noteOffSeconds && signature && tempoEvents == 2, + "MIDI event ticks integrate the tempo map and preserve the project's 7/8 meter metadata."); + const auto denseTracks = juce::JSON::parse(R"json([{"name":"dense-controller","clips":[{"startTime":0,"events":[{"type":"cc","timestamp":0.1,"controller":4,"value":77,"channel":10},{"type":"cc","timestamp":0.1002,"controller":4,"value":78,"channel":10}]}]}])json"); + const auto densePath = outputDirectory.getChildFile("dense-controller-export.mid"); + bool denseValid = exportProjectMIDI(densePath.getFullPathName(), denseTracks, 120, + juce::JSON::parse(R"json({"tempo":120,"timeSignature":{"numerator":4,"denominator":4}})json")); + juce::MidiFile denseFile; juce::FileInputStream denseInput(densePath); + denseValid = denseValid && denseFile.readFrom(denseInput); + std::vector controllerTicks; + for (int track = 0; denseValid && track < denseFile.getNumTracks(); ++track) + for (int event = 0; event < denseFile.getTrack(track)->getNumEvents(); ++event) + { + const auto& message = denseFile.getTrack(track)->getEventPointer(event)->message; + if (message.isController() && message.getControllerNumber() == 4) + controllerTicks.push_back(message.getTimeStamp()); + } + addCheck("midi_export_preserves_submillisecond_controller_tick_separation", + denseValid && denseFile.getTimeFormat() == 9600 && controllerTicks.size() == 2 + && controllerTicks[1] > controllerTicks[0], + "Distinct controller values 0.2 ms apart remain distinct SMF ticks rather than collapsing in an external DAW."); + const auto latePath = outputDirectory.getChildFile("rounded-tempo-long-export.mid"); + const auto lateTracks = juce::JSON::parse(R"json([{"name":"late-note","clips":[{"startTime":400,"events":[{"type":"noteOn","timestamp":0,"note":60,"velocity":100},{"type":"noteOff","timestamp":0.5,"note":60}]}]}])json"); + bool lateValid = exportProjectMIDI(latePath.getFullPathName(), lateTracks, 154, + juce::JSON::parse(R"json({"tempo":154})json")); + juce::MidiFile lateFile; juce::FileInputStream lateInput(latePath); + lateValid = lateValid && lateFile.readFrom(lateInput); + lateFile.convertTimestampTicksToSeconds(); + bool lateOn = false, lateOff = false; + for (int track = 0; lateValid && track < lateFile.getNumTracks(); ++track) + for (int event = 0; event < lateFile.getTrack(track)->getNumEvents(); ++event) + { + const auto& message = lateFile.getTrack(track)->getEventPointer(event)->message; + if (message.isNoteOn()) lateOn = std::abs(message.getTimeStamp() - 400.0) < 0.000041; + if (message.isNoteOff()) lateOff = std::abs(message.getTimeStamp() - 400.5) < 0.000041; + } + addCheck("midi_export_integer_tempo_does_not_accumulate_long_song_drift", + lateValid && lateOn && lateOff, + "At 154 BPM a note after 400 seconds round-trips within one 9600-PPQ tick using the encoded integer tempo."); + } + + { + for (const double expectedBpm : { 90.0, 120.0, 154.0 }) + { + const int samples = 12 * static_cast(fixtureSampleRate); + juce::AudioBuffer clicks(1, samples); clicks.clear(); + const double beat = fixtureSampleRate * 60.0 / expectedBpm; + for (double position = fixtureSampleRate * 0.2; position + 128 < samples; position += beat) + for (int sample = 0; sample < 128; ++sample) + clicks.setSample(0, static_cast(position) + sample, + 0.8f * std::exp(-static_cast(sample) / 18.0f) + * (sample % 2 == 0 ? 1.0f : -1.0f)); + const auto path = outputDirectory.getChildFile("tempo-clicks-" + juce::String(expectedBpm) + ".wav"); + const bool written = writeBufferToWavFile(clicks, samples, fixtureSampleRate, path); + const auto analysis = ClipTempoAnalyzer::analyze(path, 0.0, 12.0); + bool candidateFound = false; + if (const auto* choices = analysis["candidates"].getArray()) + for (const auto& choice : *choices) + candidateFound = candidateFound || std::abs(static_cast(choice["bpm"]) - expectedBpm) < 2.5; + addCheck("tempo_detector_clicks_" + juce::String(expectedBpm), written && analysis["error"].isVoid() + && candidateFound && std::abs(static_cast(analysis["bpm"]) - expectedBpm) < 2.5, + "Known periodic clicks retain their tempo candidate; phase/downbeat and real-music confidence remain diagnostic only.", analysis); + } + const auto silence = outputDirectory.getChildFile("tempo-silence.wav"); + juce::AudioBuffer silent(1, 4 * static_cast(fixtureSampleRate)); silent.clear(); + const bool written = writeBufferToWavFile(silent, silent.getNumSamples(), fixtureSampleRate, silence); + const auto analysis = ClipTempoAnalyzer::analyze(silence, 0.0, 4.0); + addCheck("tempo_detector_silence_rejected", written && !analysis["error"].isVoid() + && static_cast(analysis["confidence"]) == 0.0, + "Silence cannot produce an applicable tempo estimate."); + const auto noisePath = outputDirectory.getChildFile("tempo-unmetered-noise.wav"); + juce::AudioBuffer noise(1, 8 * static_cast(fixtureSampleRate)); + juce::uint32 noiseSeed = 123456789u; + for (int sample = 0; sample < noise.getNumSamples(); ++sample) + { + noiseSeed = noiseSeed * 1664525u + 1013904223u; + noise.setSample(0, sample, static_cast(static_cast(noiseSeed >> 8) - 8388608) / 33554432.0f); + } + const bool noiseWritten = writeBufferToWavFile(noise, noise.getNumSamples(), fixtureSampleRate, noisePath); + const auto noiseAnalysis = ClipTempoAnalyzer::analyze(noisePath, 0.0, 8.0); + addCheck("tempo_detector_unmetered_noise_rejected", noiseWritten && !noiseAnalysis["error"].isVoid() + && static_cast(noiseAnalysis["confidence"]) == 0.0, + "A deterministic unmetered noise source cannot gain false confidence from the onset envelope's DC baseline.", noiseAnalysis); + } + + { + const auto stereoSourceFile = outputDirectory.getChildFile("channel-fold-stereo.wav"); + const auto monoSourceFile = outputDirectory.getChildFile("channel-expand-mono.wav"); + juce::AudioBuffer stereoSource(2, 1024), monoSource(1, 1024); + for (int sample = 0; sample < 1024; ++sample) + { + stereoSource.setSample(0, sample, 0.5f); stereoSource.setSample(1, sample, -0.25f); + monoSource.setSample(0, sample, 0.375f); + } + const bool channelSourcesWritten = writeBufferToWavFile(stereoSource, 1024, fixtureSampleRate, stereoSourceFile) + && writeBufferToWavFile(monoSource, 1024, fixtureSampleRate, monoSourceFile); + PlaybackEngine stereoSourcePlayback, monoSourcePlayback; + stereoSourcePlayback.setOfflineSourceReads(true); monoSourcePlayback.setOfflineSourceReads(true); + stereoSourcePlayback.addClip(stereoSourceFile, 0, 1024 / fixtureSampleRate, "channel-fold"); + monoSourcePlayback.addClip(monoSourceFile, 0, 1024 / fixtureSampleRate, "channel-expand"); + juce::AudioBuffer folded(2, 512), expanded(2, 512), singleChannel(1, 512), unchangedStereo(2, 512); + folded.clear(); expanded.clear(); singleChannel.clear(); unchangedStereo.clear(); + stereoSourcePlayback.fillTrackBuffer("channel-fold", folded, 0, 512, fixtureSampleRate, true); + monoSourcePlayback.fillTrackBuffer("channel-expand", expanded, 0, 512, fixtureSampleRate); + stereoSourcePlayback.fillTrackBuffer("channel-fold", singleChannel, 0, 512, fixtureSampleRate); + stereoSourcePlayback.fillTrackBuffer("channel-fold", unchangedStereo, 0, 512, fixtureSampleRate); + double channelError = 0.0; + for (int sample = 0; sample < 512; ++sample) + { + for (int channel = 0; channel < 2; ++channel) + { + channelError = juce::jmax(channelError, std::abs(static_cast(folded.getSample(channel, sample)) - 0.125)); + channelError = juce::jmax(channelError, std::abs(static_cast(expanded.getSample(channel, sample)) - 0.375)); + } + channelError = juce::jmax(channelError, std::abs(static_cast(singleChannel.getSample(0, sample)) - 0.125)); + channelError = juce::jmax(channelError, std::abs(static_cast(unchangedStereo.getSample(0, sample)) - 0.5)); + channelError = juce::jmax(channelError, std::abs(static_cast(unchangedStereo.getSample(1, sample)) + 0.25)); + } + addCheck("clip_channel_adaptation", channelSourcesWritten && channelError < 1.0e-6, + "Stereo sources average L+R on mono tracks, mono sources expand to stereo, and stereo tracks retain channel identity.", channelError); + TrackProcessor monoFXTrack; + monoFXTrack.setTrackChannelCount(1); monoFXTrack.setPanLaw(PanLaw::Linear); + monoFXTrack.prepareToPlay(fixtureSampleRate, 512); + auto stereoFX = std::make_unique(OpenStudioUtilityEffect::Kind::GainPhase); + const bool rightPolaritySet = stereoFX->setControl("polarityR", 1.0f); + const bool stereoFXAdded = monoFXTrack.addTrackFX(std::move(stereoFX), fixtureSampleRate, 512); + juce::MidiBuffer noMidi; monoFXTrack.processBlock(folded, noMidi); + double postFXMonoError = 0.0; + for (int sample = 128; sample < 512; ++sample) + postFXMonoError = juce::jmax(postFXMonoError, + std::abs(static_cast(folded.getSample(0, sample) + folded.getSample(1, sample)))); + addCheck("mono_source_stereo_fx_output_preserved", channelSourcesWritten && rightPolaritySet && stereoFXAdded + && monoFXTrack.getProcessingChannelCount() >= 2 && postFXMonoError < 1.0e-5 + && folded.getMagnitude(0, 128, 384) > 0.05f && folded.getMagnitude(1, 128, 384) > 0.05f, + "Mono source adaptation occurs before FX; a stereo processor's opposite-polarity outputs remain independently audible."); + } + + { + const auto eofSourceFile = outputDirectory.getChildFile("source-eof-retained-frame.wav"); + juce::AudioBuffer eofSource(1, 1024); + for (int sample = 0; sample < 1024; ++sample) eofSource.setSample(0, sample, 0.125f); + eofSource.setSample(0, 1023, 0.75f); + const bool eofSourceWritten = writeBufferToWavFile(eofSource, 1024, fixtureSampleRate, eofSourceFile); + for (const bool offlineReads : { false, true }) + for (const double destinationRate : { 48000.0, 96000.0, 44100.0 }) + { + PlaybackEngine eofPlayback; + eofPlayback.setOfflineSourceReads(offlineReads); + eofPlayback.addClip(eofSourceFile, 0, 1024 / fixtureSampleRate, "eof-test"); + juce::AudioBuffer eofOutput(2, 4096); eofOutput.clear(); + eofPlayback.fillTrackBuffer("eof-test", eofOutput, 0, 4096, destinationRate); + const int validFrames = static_cast(std::ceil(1024 * destinationRate / fixtureSampleRate - 1.0e-9)); + const bool lastRetained = std::abs(eofOutput.getSample(0, validFrames - 1) - 0.75f) < 1.0e-6f + && std::abs(eofOutput.getSample(1, validFrames - 1) - 0.75f) < 1.0e-6f; + const bool noPastEOF = eofOutput.getMagnitude(validFrames, 4096 - validFrames) == 0.0f; + addCheck("playback_eof_frame_" + juce::String(offlineReads ? "offline_" : "cached_") + juce::String(destinationRate), + eofSourceWritten && lastRetained && noPastEOF && !eofPlayback.hasOfflineSourceReadFailure(), + "The final valid source frame survives equal-rate and resampled output; samples after source EOF remain silent."); + } + double partitionBoundaryError = 0.0; + constexpr juce::int64 boundaryEndFrame = 19451719; + constexpr juce::int64 boundaryCaptureStart = boundaryEndFrame - 1200; + const double boundaryEndTime = static_cast(boundaryEndFrame) / fixtureSampleRate; + constexpr double fractionalOffsetSamples = 3.0 / 7.0; + const double boundaryClipStart = boundaryEndTime - (1024.0 - fractionalOffsetSamples) / fixtureSampleRate; + for (const bool offlineReads : { false, true }) + for (const int partition : { 512, 333 }) + { + PlaybackEngine boundaryPlayback; boundaryPlayback.setOfflineSourceReads(offlineReads); + boundaryPlayback.addClip(eofSourceFile, boundaryClipStart, boundaryEndTime - boundaryClipStart, + "fractional-boundary", fractionalOffsetSamples / fixtureSampleRate); + juce::AudioBuffer boundaryBlock(2, 512); + int captured = 0; + while (captured < 1600) + { + const int count = juce::jmin(partition, 1600 - captured); boundaryBlock.clear(); + boundaryPlayback.fillTrackBuffer("fractional-boundary", boundaryBlock, + static_cast(boundaryCaptureStart + captured) / fixtureSampleRate, count, fixtureSampleRate); + for (int sample = 0; sample < count; ++sample) + { + const int timelineFrame = captured + sample; + const double expected = timelineFrame < 177 || timelineFrame >= 1200 ? 0.0 + : timelineFrame == 1199 ? 0.75 : 0.125; + for (int channel = 0; channel < 2; ++channel) + partitionBoundaryError = juce::jmax(partitionBoundaryError, + std::abs(boundaryBlock.getSample(channel, sample) - expected)); + } + captured += count; + } + } + addCheck("playback_long_timeline_fractional_boundary_partition", eofSourceWritten && partitionBoundaryError < 1.0e-6, + "A rational clip start near405seconds retains its last frame and excludes the exact end frame across cached/offline readers and uneven block partitions.", partitionBoundaryError); + PlaybackEngine finalFrame; + finalFrame.setOfflineSourceReads(true); + finalFrame.addClip(eofSourceFile, 0, 1.0 / fixtureSampleRate, "final-frame", 1023 / fixtureSampleRate); + juce::AudioBuffer oneFrame(2, 4); oneFrame.clear(); + finalFrame.fillTrackBuffer("final-frame", oneFrame, 0, 4, fixtureSampleRate); + addCheck("playback_single_retained_eof_frame", eofSourceWritten + && std::abs(oneFrame.getSample(0, 0) - 0.75f) < 1.0e-6f && oneFrame.getMagnitude(1, 3) == 0.0f, + "A trim retaining exactly the source's last frame is audible rather than discarded."); + } + + { + constexpr double introFixtureRate = 8000.0; + constexpr int introFixtureSamples = 150 * 8000; + juce::AudioBuffer delayedPulse(1, introFixtureSamples); delayedPulse.clear(); + for (double position = 90.2 * introFixtureRate; position + 32 < introFixtureSamples; position += 0.5 * introFixtureRate) + for (int sample = 0; sample < 32; ++sample) + delayedPulse.setSample(0, static_cast(position) + sample, + 0.8f * std::exp(-static_cast(sample) / 5.0f) * (sample % 2 == 0 ? 1.0f : -1.0f)); + const auto introFixtureFile = outputDirectory.getChildFile("tempo-long-silent-intro.wav"); + const bool introWritten = writeBufferToWavFile(delayedPulse, introFixtureSamples, introFixtureRate, introFixtureFile); + const auto introAnalysis = ClipTempoAnalyzer::analyze(introFixtureFile, 0.0, 150.0); + addCheck("tempo_detector_bounded_audible_window", introWritten && introAnalysis["error"].isVoid() + && std::abs(static_cast(introAnalysis["bpm"]) - 120.0) < 2.5 + && static_cast(introAnalysis["analysisStartSeconds"]) > 0.0 + && static_cast(introAnalysis["analysisDurationSeconds"]) <= 60.0 + && static_cast(introAnalysis["analysisStartSeconds"]) + static_cast(introAnalysis["analysisDurationSeconds"]) <= 150.0 + && static_cast(introAnalysis["beatOffsetSeconds"]) >= 0.0 + && static_cast(introAnalysis["beatOffsetSeconds"]) < 60.0 / static_cast(introAnalysis["bpm"]), + "Nine bounded energy probes select actual later audio after a 90-second silent lead-in; tempo DSP remains limited to 60 seconds and phase is clip-relative.", introAnalysis); + } + // File-reference integration: the same worker learner handles mono/stereo, // bounded offsets, continuous FFT hops and invalid/cancelled requests. const auto learnFile=[&](const juce::File& file,double start=0.0,bool running=true) @@ -76151,6 +76791,105 @@ juce::var AudioEngine::runRenderExportRegression(const juce::File& outputDirecto trackAdded, trackAdded ? trackId : "Failed to create the fixture track."); + { + auto* track = trackMap.count(trackId) != 0 ? trackMap[trackId] : nullptr; + const auto renderFixture = [&](const juce::String& label, juce::AudioBuffer& audio) + { + const auto path = outputDirectory.getChildFile("static-track-" + label + ".wav"); + double rate = 0.0; + return track != nullptr && renderProject("master", 0, 1, path.getFullPathName(), + "wav", fixtureSampleRate, 32, 2, false, false, 0, false) + && readAudioFileForParity(path, audio, rate) && audio.getNumSamples() == fixtureSamples; + }; + juce::AudioBuffer baseline, changed; + const bool ready = renderFixture("baseline", baseline); + if (track != nullptr) track->setTrimVolume(-6.0f); + bool trim = ready && renderFixture("trim", changed); + double trimError = 0.0; + if (trim) for (int channel = 0; channel < 2; ++channel) + for (int sample = 0; sample < fixtureSamples; ++sample) + trimError = juce::jmax(trimError, std::abs(static_cast(changed.getSample(channel, sample) + - baseline.getSample(channel, sample) * juce::Decibels::decibelsToGain(-6.0f)))); + if (track != nullptr) { track->setTrimVolume(0.0f); track->setPhaseInvert(true); } + bool phase = ready && renderFixture("phase", changed); + double phaseError = 0.0; + if (phase) for (int channel = 0; channel < 2; ++channel) + for (int sample = 0; sample < fixtureSamples; ++sample) + phaseError = juce::jmax(phaseError, std::abs(static_cast(changed.getSample(channel, sample) + + baseline.getSample(channel, sample)))); + if (track != nullptr) track->setPhaseInvert(false); + addCheck("render_static_track_trim_and_polarity", trim && phase && trimError < 1.0e-5 && phaseError < 1.0e-5, + "Static track trim and polarity match the independent gain and sign transformations in the exported samples."); + } + + { + const auto missingPath = outputDirectory.getChildFile("removed-render-source.wav"); + const bool copied = fixtureFile.copyFileTo(missingPath); + const auto missingTrack = addTrack("excluded-missing-source", "audio"); + addPlaybackClip(missingTrack, missingPath.getFullPathName(), 0, 1, 0, 0, 0, 0, "removed-source-clip"); + const bool sourceFileRemoved = missingPath.deleteFile(); + setTrackMute(missingTrack, true); + const bool mutedOk = renderProject("master", 0, 1, + outputDirectory.getChildFile("muted-missing-source.wav").getFullPathName(), + "wav", fixtureSampleRate, 32, 2, false, false, 0, false); + setTrackMute(missingTrack, false); + const bool excludedOk = renderProject("stem:" + trackId, 0, 1, + outputDirectory.getChildFile("excluded-missing-source.wav").getFullPathName(), + "wav", fixtureSampleRate, 32, 2, false, false, 0, false); + const bool includedRejected = !renderProject("master", 0, 1, + outputDirectory.getChildFile("included-missing-source.wav").getFullPathName(), + "wav", fixtureSampleRate, 32, 2, false, false, 0, false); + addCheck("render_missing_source_only_when_required", copied && sourceFileRemoved && mutedOk && excludedOk && includedRejected, + "Unreadable muted/excluded sources do not block export; an included unavailable source fails closed."); + removeTrack(missingTrack); + } + + { + const double transportBeforePreview = getTransportPosition(); + const bool playingBeforePreview = isTransportPlaying(); + const auto previewData = getClipTempoPreviewData(trackId, "render-export-regression-clip", 0.25, 0.2); + juce::MemoryOutputStream previewDecodedStream; + const bool previewDecoded = juce::Base64::convertFromBase64(previewDecodedStream, previewData["wavBase64"].toString()); + const auto previewBytes = previewDecodedStream.getMemoryBlock(); + juce::AudioFormatManager previewFormats; previewFormats.registerBasicFormats(); + std::unique_ptr previewReader(previewFormats.createReaderFor( + std::make_unique(previewBytes, false))); + bool previewParity = previewDecoded && previewReader && previewReader->sampleRate == fixtureSampleRate + && previewReader->lengthInSamples == 9600 && previewReader->numChannels == 1; + if (previewReader) + { + juce::AudioBuffer previewAudio(1, 9600); + previewParity = previewReader->read(&previewAudio, 0, 9600, 0, true, false) && previewParity; + for (int sample = 0; sample < 9600; ++sample) + previewParity = std::abs(previewAudio.getSample(0, sample) - fixture.getSample(0, 12000 + sample)) < 1.0e-6f && previewParity; + } + addCheck("tempo_preview_pcm_range_preserves_transport", previewParity && getTransportPosition() == transportBeforePreview + && isTransportPlaying() == playingBeforePreview && deviceManager.getCurrentAudioDevice() == nullptr, + "Bounded dry-source preview PCM matches its retained audio range without changing transport or opening hardware."); + auto* descriptor = new juce::DynamicObject(); + descriptor->setProperty("filePath", fixtureFile.getFullPathName()); + descriptor->setProperty("startTime", 0.0); descriptor->setProperty("duration", 1.0); + descriptor->setProperty("offset", 0.0); descriptor->setProperty("volumeDB", 0.0); + descriptor->setProperty("fadeIn", 0.0); descriptor->setProperty("fadeOut", 0.0); + descriptor->setProperty("fadeInCurve", 0); descriptor->setProperty("fadeOutCurve", 0); + descriptor->setProperty("gainEnvelope", juce::Array()); + const juce::var source(descriptor); + const auto countBefore = playbackEngine.getClipSnapshot().size(); + const auto mutedPreview = getClipTempoPreviewData(trackId, "absent-muted-clip", 0.25, 0.2, source); + addCheck("muted_source_preview_without_playback_publication", + mutedPreview["wavBase64"] == previewData["wavBase64"] && playbackEngine.getClipSnapshot().size() == countBefore + && getTransportPosition() == transportBeforePreview, + "An absent muted clip's document source yields identical preview PCM without adding a playback clip."); + const auto sourceTempo = analyzeClipTempo(trackId, "absent-muted-clip", 0.0, -1.0, source); + const auto activeTempo = analyzeClipTempo(trackId, "render-export-regression-clip", 0.0, 1.0); + addCheck("muted_source_analysis_matches_active_source", juce::JSON::toString(sourceTempo) == juce::JSON::toString(activeTempo), + "Read-only analysis does not depend on an active playback publication."); + auto invalidSource = source.clone(); invalidSource.getDynamicObject()->setProperty("offset", -1.0); + addCheck("clip_source_descriptor_rejects_invalid_geometry", + getClipTempoPreviewData(trackId, "render-export-regression-clip", 0.0, 0.2, invalidSource).hasProperty("error"), + "An invalid supplied descriptor fails closed instead of silently previewing a different active source."); + } + // Observe the host clock from a processor inside the actual export chain, // including a different export rate and a tempo change within the range. { @@ -77958,6 +78697,7 @@ juce::var AudioEngine::runRenderExportRegression(const juce::File& outputDirecto "Explicit offline startup retained zero device starts, hardware callbacks and callback registrations for the complete export fixture.", hardwareAfter); root->setProperty("hardwareBefore", hardwareBefore); root->setProperty("hardwareAfter", hardwareAfter); + root->setProperty("inlineEQDryRegression", inlineEQDryRegression); root->setProperty("offlineRequired", requireOffline); root->setProperty("harnessMode", "render_export_regression"); root->setProperty("claimLevel", "objective_only"); @@ -78870,7 +79610,7 @@ juce::var AudioEngine::freezeTrack(const juce::String& trackId) if (admittedSourceSamples > 0) playbackEngine.fillTrackBuffer(trackId, trackBuffer, sourceBlockTime, - admittedSourceSamples, renderRate); + admittedSourceSamples, renderRate, track->getTrackChannelCount() == 1); // Process through FX chain track->setCurrentBlockPosition(automationTime); @@ -79187,6 +79927,37 @@ juce::var AudioEngine::importMIDIFile(const juce::String& filePath) result->setProperty("numTracks", midiFile.getNumTracks()); result->setProperty("ticksPerQuarterNote", midiFile.getTimeFormat()); + juce::Array sourceTempoMarkers; + double sourceTempo = 120.0; + int sourceNumerator = 4, sourceDenominator = 4; + for (int track = 0; track < midiFile.getNumTracks(); ++track) + { + const auto* sequence = midiFile.getTrack(track); + for (int event = 0; sequence != nullptr && event < sequence->getNumEvents(); ++event) + { + const auto& message = sequence->getEventPointer(event)->message; + if (message.isTempoMetaEvent() && message.getTempoSecondsPerQuarterNote() > 0.0) + { + const double sourceBpm = 60.0 / message.getTempoSecondsPerQuarterNote(); + auto* marker = new juce::DynamicObject(); + marker->setProperty("time", message.getTimeStamp()); marker->setProperty("bpm", sourceBpm); + sourceTempoMarkers.add(marker); + if (message.getTimeStamp() <= 1.0e-9) sourceTempo = sourceBpm; + } + if (message.isTimeSignatureMetaEvent() && message.getTimeStamp() <= 1.0e-9) + message.getTimeSignatureInfo(sourceNumerator, sourceDenominator); + } + } + if (!sourceTempoMarkers.isEmpty()) + { + auto* metadata = new juce::DynamicObject(); + metadata->setProperty("tempo", sourceTempo); metadata->setProperty("tempoMarkers", sourceTempoMarkers); + auto* signature = new juce::DynamicObject(); + signature->setProperty("numerator", sourceNumerator); signature->setProperty("denominator", sourceDenominator); + metadata->setProperty("timeSignature", juce::var(signature)); + result->setProperty("sourceTempoInfo", juce::var(metadata)); + } + juce::Array tracksArray; for (int t = 0; t < midiFile.getNumTracks(); ++t) @@ -79226,7 +79997,8 @@ juce::var AudioEngine::importMIDIFile(const juce::String& filePath) ++channelCounts[msg.getChannel()]; evtObj->setProperty("type", "noteOff"); evtObj->setProperty("note", msg.getNoteNumber()); - evtObj->setProperty("velocity", 0); + evtObj->setProperty("velocity", msg.getVelocity()); + evtObj->setProperty("releaseVelocity", msg.getVelocity()); evtObj->setProperty("channel", msg.getChannel()); } else if (msg.isController()) @@ -79249,6 +80021,27 @@ juce::var AudioEngine::importMIDIFile(const juce::String& filePath) evtObj->setProperty("type", "tempo"); evtObj->setProperty("bpm", 60000000.0 / msg.getTempoSecondsPerQuarterNote() / 1000000.0); } + else if (msg.isProgramChange() || msg.isChannelPressure() || msg.isAftertouch()) + { + ++channelCounts[msg.getChannel()]; + evtObj->setProperty("channel", msg.getChannel()); + if (msg.isProgramChange()) + { + evtObj->setProperty("type", "programChange"); + evtObj->setProperty("value", msg.getProgramChangeNumber()); + } + else if (msg.isChannelPressure()) + { + evtObj->setProperty("type", "channelPressure"); + evtObj->setProperty("value", msg.getChannelPressureValue()); + } + else + { + evtObj->setProperty("type", "polyPressure"); + evtObj->setProperty("note", msg.getNoteNumber()); + evtObj->setProperty("value", msg.getAfterTouchValue()); + } + } else { continue; @@ -79272,7 +80065,8 @@ juce::var AudioEngine::importMIDIFile(const juce::String& filePath) return juce::var(result); } -bool AudioEngine::exportProjectMIDI(const juce::String& outputPath, const juce::var& midiTracks, double bpm) +bool AudioEngine::exportProjectMIDI(const juce::String& outputPath, const juce::var& midiTracks, double bpm, + const juce::var& timingOptions) { auto tracksVar = midiTracks; if (tracksVar.isString()) @@ -79282,9 +80076,56 @@ bool AudioEngine::exportProjectMIDI(const juce::String& outputPath, const juce:: if (tracksArray == nullptr) return false; - if (bpm <= 0.0) + if (timingOptions.hasProperty("tempo")) bpm = static_cast(timingOptions["tempo"]); + if (!std::isfinite(bpm) || bpm <= 0.0) bpm = 120.0; + std::vector> tempoMap { { 0.0, bpm } }; + if (const auto* markers = timingOptions["tempoMarkers"].getArray()) + { + for (const auto& marker : *markers) + { + const double time = static_cast(marker["time"]); + const double markerBpm = static_cast(marker.hasProperty("bpm") ? marker["bpm"] : marker["tempo"]); + if (std::isfinite(time) && time >= 0.0 && std::isfinite(markerBpm) + && markerBpm >= 1.0 && markerBpm <= 1000.0) + tempoMap.emplace_back(time, markerBpm); + } + } + else if (timingOptions.isVoid()) + { + const juce::ScopedLock mapLock(tempoMapLock); + for (const auto& marker : tempoMarkers) tempoMap.emplace_back(marker.timeSeconds, marker.bpm); + } + std::stable_sort(tempoMap.begin(), tempoMap.end(), [] (const auto& a, const auto& b) { return a.first < b.first; }); + // SMF tempo is integer microseconds/quarter. Use that exact encoded rate + // for event ticks as well, otherwise rounding accumulates over long songs. + for (auto& marker : tempoMap) + marker.second = 60000000.0 / juce::roundToInt(60000000.0 / marker.second); + // Preserve closely spaced controller changes when exchanging with DAWs + // that collapse multiple values for the same controller at one SMF tick. + constexpr int exportTicksPerQuarterNote = 9600; + const auto secondsToTicks = [&tempoMap, exportTicksPerQuarterNote] (double seconds) + { + const double end = juce::jmax(0.0, seconds); + double previous = 0.0, rate = tempoMap.front().second, ticks = 0.0; + for (const auto& [time, nextRate] : tempoMap) + { + if (time > end) break; + ticks += (time - previous) * rate * (exportTicksPerQuarterNote / 60.0); + previous = time; + rate = nextRate; + } + return ticks + (end - previous) * rate * (exportTicksPerQuarterNote / 60.0); + }; + const auto signature = timingOptions["timeSignature"]; + const int signatureNumerator = signature.hasProperty("numerator") + ? juce::jlimit(1, 32, static_cast(signature["numerator"])) : timeSigNumerator.load(); + int signatureDenominator = signature.hasProperty("denominator") + ? static_cast(signature["denominator"]) : timeSigDenominator.load(); + if (signatureDenominator < 1 || signatureDenominator > 64 + || (signatureDenominator & (signatureDenominator - 1)) != 0) signatureDenominator = 4; + auto getDoubleProperty = [] (juce::DynamicObject* obj, const char* name, double fallback) { if (obj == nullptr || ! obj->hasProperty(name)) @@ -79318,7 +80159,8 @@ bool AudioEngine::exportProjectMIDI(const juce::String& outputPath, const juce:: return; const juce::String eventType = eventObj->getProperty("type").toString(); - const double ticks = juce::jmax(0.0, absoluteTimeSeconds) * (bpm / 60.0) * 480.0; + if (!std::isfinite(absoluteTimeSeconds)) return; + const double ticks = secondsToTicks(absoluteTimeSeconds); const int channel = getIntProperty(eventObj, "channel", 1, 1, 16); if (eventType == "noteOn") @@ -79365,7 +80207,7 @@ bool AudioEngine::exportProjectMIDI(const juce::String& outputPath, const juce:: }; juce::MidiFile midiFile; - midiFile.setTicksPerQuarterNote(480); + midiFile.setTicksPerQuarterNote(exportTicksPerQuarterNote); int exportedTrackCount = 0; for (const auto& trackVar : *tracksArray) @@ -79375,7 +80217,17 @@ bool AudioEngine::exportProjectMIDI(const juce::String& outputPath, const juce:: continue; juce::MidiMessageSequence sequence; - sequence.addEvent(juce::MidiMessage::tempoMetaEvent(static_cast(60000000.0 / bpm)), 0.0); + for (const auto& [time, markerBpm] : tempoMap) + sequence.addEvent(juce::MidiMessage::tempoMetaEvent(juce::roundToInt(60000000.0 / markerBpm)), secondsToTicks(time)); + int denominatorPower = 0; + for (int denominator = signatureDenominator; denominator > 1; denominator >>= 1) + ++denominatorPower; + // SMF: 24 MIDI clocks per quarter-note click, eight notated 32nds + // per quarter note. JUCE's helper supplies 1 and 96 for these fields. + const juce::uint8 meterBytes[] { 0xff, 0x58, 0x04, + static_cast(signatureNumerator), + static_cast(denominatorPower), 24, 8 }; + sequence.addEvent(juce::MidiMessage(meterBytes, sizeof(meterBytes), 0.0)); const juce::String trackName = trackObj->getProperty("name").toString(); if (trackName.isNotEmpty()) sequence.addEvent(juce::MidiMessage::textMetaEvent(0x03, trackName), 0.0); diff --git a/Source/AudioEngine.h b/Source/AudioEngine.h index a90e05f5..36e204db 100644 --- a/Source/AudioEngine.h +++ b/Source/AudioEngine.h @@ -1,6 +1,7 @@ #include "BuiltInIRPreparation.h" #pragma once #include "RealtimeTrackExecutor.h" +#include "RealtimeCallbackTrace.h" #include #include "TrackProcessor.h" @@ -128,6 +129,8 @@ class AudioEngine : public juce::AudioIODeviceCallback, // Transport control (Phase 2) void setTransportPlaying(bool playing); void setTransportRecordingAsync(bool recording, std::function completion); + void startPreparedRecordingAsync(std::function completion, + std::function isCurrentRequest = {}); void setTransportRecording(bool recording); bool recordingRequiresAudioInput() const; bool trackRequiresAudioInput(const juce::String& trackId) const; @@ -214,7 +217,7 @@ class AudioEngine : public juce::AudioIODeviceCallback, double offset = 0.0, double volumeDB = 0.0, double fadeIn = 0.0, double fadeOut = 0.0, const juce::String& clipId = juce::String(), const juce::String& pitchCorrectionSourceFilePath = juce::String(), - double pitchCorrectionSourceOffset = -1.0); + double pitchCorrectionSourceOffset = -1.0, int fadeInCurve = 0, int fadeOutCurve = 0); /** Batch-add multiple clips from a JSON array. Each element: {trackId, filePath, startTime, duration, offset, volumeDB, fadeIn, fadeOut, clipId, pitchCorrectionSourceFilePath?, pitchCorrectionSourceOffset?}. */ void addPlaybackClipsBatch(const juce::String& clipsJSON); void removePlaybackClip(const juce::String& trackId, const juce::String& filePath); @@ -319,6 +322,8 @@ class AudioEngine : public juce::AudioIODeviceCallback, juce::var runReleaseGuardrails(); juce::var runAutomatedRegressionSuite(); juce::var runRenderExportRegression(const juce::File& outputDirectory, bool requireOffline = false); + juce::var runProjectQualification(const juce::File& projectFile, const juce::File& frontendPayload, + const juce::File& outputDirectory); juce::var runCleanGuitarPitchBendRegression(); juce::var runNAMRackRegression(); juce::var runNAMRackDIRegression(const juce::File& inputFile, @@ -398,6 +403,14 @@ class AudioEngine : public juce::AudioIODeviceCallback, // Compact atomics-only status for always-visible rack/tuner UI. The full // debug snapshot remains an explicit diagnostic operation. juce::var getRealtimeAudioTelemetry() const; + // Explicit CLI diagnostics only. Reporter is the sole queue consumer; + // ordinary UI/debug snapshots never drain or enable paired tracing. + void setRealtimeCallbackTraceEnabled(bool enabled, bool threadCycles = false) noexcept + { realtimeCallbackTrace.setEnabled(enabled, threadCycles); } + std::size_t drainRealtimeCallbackTrace(RealtimeCallbackTraceEvent* destination, std::size_t maximum) noexcept + { return realtimeCallbackTrace.drain(destination, maximum); } + RealtimeCallbackTraceStats getRealtimeCallbackTraceStats() const noexcept + { return realtimeCallbackTrace.getStats(); } // Plugin State Serialization (F2 - Project Save/Load) juce::String getPluginState(const juce::String& trackId, int fxIndex, bool isInputFX); @@ -415,7 +428,13 @@ class AudioEngine : public juce::AudioIODeviceCallback, const std::function()>& publicationLeaseFactory = {}); // Waveform Visualization - juce::var getWaveformPeaks(const juce::String& filePath, int samplesPerPixel, int startSample, int numPixels); + juce::var analyzeClipTempo(const juce::String& trackId, const juce::String& clipId, + double rangeStartSeconds = 0.0, double rangeDurationSeconds = -1.0, + const juce::var& source = {}, const std::atomic* cancelled = nullptr) const; + juce::var getClipTempoPreviewData(const juce::String& trackId, const juce::String& clipId, + double rangeStartSeconds, double durationSeconds, const juce::var& source = {}) const; + juce::var getWaveformPeaks(const juce::String& filePath, int samplesPerPixel, int startSample, int numPixels, + juce::int64 sourceRangeStartSample = 0, juce::int64 sourceRangeEndSample = -1); double getAudioPeakAmplitude(const juce::String& filePath, double offsetSeconds, double durationSeconds) const; @@ -605,7 +624,8 @@ class AudioEngine : public juce::AudioIODeviceCallback, // MIDI Import/Export (Phase 19.9) juce::var importMIDIFile(const juce::String& filePath); - bool exportProjectMIDI(const juce::String& outputPath, const juce::var& midiTracks, double bpm = 120.0); + bool exportProjectMIDI(const juce::String& outputPath, const juce::var& midiTracks, double bpm = 120.0, + const juce::var& timingOptions = juce::var()); bool exportMIDIFile(const juce::String& trackId, const juce::String& clipId, const juce::String& eventsJSON, const juce::String& outputPath, double clipTempo); @@ -760,6 +780,7 @@ class AudioEngine : public juce::AudioIODeviceCallback, struct RealtimeTrackEntry { juce::String id; + juce::int64 playbackContinuityKey = 0; juce::AudioProcessorGraph::Node::Ptr node; TrackProcessor* processor = nullptr; std::shared_ptr> sidechainOutputBuffer; @@ -906,7 +927,7 @@ class AudioEngine : public juce::AudioIODeviceCallback, }; static void runParallelTrackJob(void* context) noexcept; void drainRealtimeTrackExecution(); // Caller has stopped callback dispatch. - void recordQuarantinedTrackInput(const RealtimeTrackEntry& entry, + void recordTrackHardwareInput(const RealtimeTrackEntry& entry, const float* const* inputs, int inputCount, int samples, juce::int64 blockPosition); struct PublishedBuiltInProcessorOwner @@ -983,6 +1004,7 @@ class AudioEngine : public juce::AudioIODeviceCallback, const std::function& keepRunning) const; // Device settings persistence uint64 recordingRequestGeneration = 0; + static juce::var runPreparedRecordingStartRegression(const juce::File& outputDirectory); std::map monitorRequestGenerations; std::unique_ptr inputPermissionSavedSettings; void saveDeviceSettings(); @@ -1079,6 +1101,8 @@ class AudioEngine : public juce::AudioIODeviceCallback, std::atomic isRecordMode { false }; std::atomic recordingDeviceInterrupted { false }; std::atomic isRendering { false }; // Blocks audio callback during offline render + std::atomic lastOfflineRenderSourceReadMisses { 0 }; + std::atomic lastOfflineRenderSourceReadFailed { false }; juce::CriticalSection offlineRenderTransactionLock; std::atomic isLooping { false }; // The callback advances this integral sample cursor. Control-thread seeks @@ -1122,7 +1146,7 @@ class AudioEngine : public juce::AudioIODeviceCallback, static constexpr int audioCallbackHistogramCellCount = audioCallbackWindowSeconds * audioCallbackHistogramBins; static constexpr int audioCallbackStageCount = 5; - static constexpr uint64 audioCallbackStageSampleInterval = 16; + static constexpr uint64 audioCallbackStageSampleInterval = RealtimeCallbackTrace::sampleInterval; enum AudioCallbackStageIndex { audioCallbackStagePlayback = 0, @@ -1150,6 +1174,7 @@ class AudioEngine : public juce::AudioIODeviceCallback, audioCallbackWindowMaxNanoseconds {}; std::array audioCallbackStageWindows {}; + RealtimeCallbackTrace realtimeCallbackTrace; std::atomic firstAudioCallbackTelemetryWallTimeMs { 0.0 }; std::atomic windowsAudioCallbackMMCSSRequested { false }; std::atomic windowsAudioCallbackMMCSSActive { false }; diff --git a/Source/AudioEngineParallelRegression.inc b/Source/AudioEngineParallelRegression.inc index d6420734..b53348f7 100644 --- a/Source/AudioEngineParallelRegression.inc +++ b/Source/AudioEngineParallelRegression.inc @@ -852,13 +852,7 @@ juce::var AudioEngine::runParallelTrackRegression() quarantinePass = reader->read(&take, 0, frames, 0, true, false) && quarantinePass; for (int frame = 0; frame < frames; ++frame) { - float expected = fixtureSample(index, frame); - if (mixedGraph && index == 5) - expected += fixtureSample(4, frame - sourceDelay) - * (std::cos(juce::MathConstants::pi / 4.0f) * 0.5f); - if (addIncomingWhilePending && index == 1) - expected += fixtureSample(4, frame - sourceDelay) - * (std::cos(juce::MathConstants::pi / 4.0f) * 0.25f); + const float expected = fixtureSample(index, frame); const float actual = take.getSample(0, frame); const double error = std::abs(static_cast(actual) - expected); maximumRecordingError = juce::jmax(maximumRecordingError, error); @@ -876,9 +870,9 @@ juce::var AudioEngine::runParallelTrackRegression() check(addIncomingWhilePending ? "pending_worker_new_incoming_send_recording" : mixedGraph ? "pending_worker_mixed_send_recording" : "pending_worker_quarantine_preserves_raw_recording", quarantinePass, addIncomingWhilePending - ? "A new snapshot adds an unowned source send into an old queued processor. Identity quarantine retains all old owned scratch while that raw take includes the current incoming send; unrelated send monitoring/recording follows an explicit zero-padded PDC delay oracle without new dispatch." + ? "A new snapshot adds an unowned source send into an old queued processor. Identity quarantine retains all old owned scratch; monitoring follows the explicit send/PDC oracle while each take contains only its selected hardware input." : mixedGraph - ? "Four retained native owners remain untouched while an unrelated source/send/destination graph processes on callback. Three blocks match an explicit zero-padded PDC delay oracle for monitoring and raw send-destination PCM, and no new worker batch is dispatched." + ? "Four retained native owners remain untouched while an unrelated source/send/destination graph processes on callback. Monitoring matches the zero-padded send/PDC oracle; raw takes independently match selected hardware PCM, and no new worker batch is dispatched." : "A deliberately held helper retains a track lock and snapshot pin across three real host callbacks; monitoring is silent, DSP counters/scratch remain untouched and all four raw recordings preserve exact frame counts and 16-bit input parity.", quarantineDiagnosticValue); } check("device_stop_waits_for_pending_worker", controlDrainPass, diff --git a/Source/AudioFileConversion.cpp b/Source/AudioFileConversion.cpp index 1077f82c..dd959df9 100644 --- a/Source/AudioFileConversion.cpp +++ b/Source/AudioFileConversion.cpp @@ -41,7 +41,11 @@ juce::Result AudioFileConversion::convert(const juce::File& source, const juce:: if (expectedLength < 1) return juce::Result::fail("Source is shorter than one output sample"); reader.reset(); // Allow explicit in-place conversion, but only publish after success. - juce::TemporaryFile staged(destination, juce::TemporaryFile::useHiddenFile); + // Keep staging names independent of the destination basename. A hashed + // project-cache target can already be near Windows' path limit; inherited + // nested _temp suffixes could make a valid destination unreadable/leaked. + juce::TemporaryFile staged(destination, destination.getParentDirectory().getNonexistentChildFile( + ".os-convert-" + juce::Uuid().toString().removeCharacters("-"), "." + options.format)); juce::String codec; if (options.format == "flac") codec = "flac"; else if (options.format == "wav") codec = bits == 32 ? "pcm_f32le" : "pcm_s" + juce::String(bits) + "le"; diff --git a/Source/AudioFileConversionRegression.cpp b/Source/AudioFileConversionRegression.cpp index c6795aa0..5e86696c 100644 --- a/Source/AudioFileConversionRegression.cpp +++ b/Source/AudioFileConversionRegression.cpp @@ -2,6 +2,8 @@ #include "AudioFileConversion.h" #include "FFmpegLocator.h" #include "OwnedChildProcess.h" +#include "ProjectRateCopy.h" +#include "PlaybackEngine.h" #include namespace @@ -224,4 +226,122 @@ void runAudioFileConversionRegression(const juce::File& root, const std::functio check("converter_invalid_input_preserves_destination", protectedOutput.replaceWithText("existing destination") && AudioFileConversion::convert(protectedOutput, same, options, alive).failed() && openFixture(same) != nullptr); + + const auto cache = directory.getChildFile("rate-copy-cache"); + const auto sourceHash = juce::SHA256(stereo).toHexString(); + const auto rateCopy = ProjectRateCopy::create(stereo, cache, 44100, alive); + const auto cachedCopy = ProjectRateCopy::create(stereo, cache, 44100, alive); + const juce::File convertedFile(rateCopy["filePath"].toString()); + auto rateReader = openFixture(convertedFile); + check("project_rate_copy_float32_channels_duration", static_cast(rateCopy["success"]) && rateReader + && rateReader->sampleRate == 44100 && rateReader->numChannels == 2 && rateReader->lengthInSamples == 8820 + && rateReader->bitsPerSample == 32 && rateReader->usesFloatingPointData); + check("project_rate_copy_content_cache_preserves_original", static_cast(cachedCopy["success"]) + && static_cast(cachedCopy["cacheHit"]) && cachedCopy["filePath"] == rateCopy["filePath"] + && juce::SHA256(stereo).toHexString() == sourceHash); + // Final SHA256-named files fit below MAX_PATH, while the former nested + // basename-derived staging paths exceed it. Keep that boundary covered. + const auto longCache = directory.getChildFile(juce::String::repeatedString("p", + juce::jmax(1, 180 - directory.getFullPathName().length() - 1))); + const auto longPathCopy = ProjectRateCopy::create(stereo, longCache, 44100, alive); + const auto longPathHit = ProjectRateCopy::create(stereo, longCache, 44100, alive); + auto longPathReader = openFixture(juce::File(longPathCopy["filePath"].toString())); + check("project_rate_copy_long_project_path_has_short_atomic_staging", + static_cast(longPathCopy["success"]) && static_cast(longPathHit["cacheHit"]) + && longPathReader && longPathReader->lengthInSamples == 8820 + && longCache.getNumberOfChildFiles(juce::File::findFiles) == 2 + && juce::SHA256(stereo).toHexString() == sourceHash); + const auto back = ProjectRateCopy::create(convertedFile, cache, 48000, alive); + auto backReader = openFixture(juce::File(back["filePath"].toString())); + bool roundtripPitch = static_cast(back["success"]) && backReader && backReader->lengthInSamples == 9600 && backReader->numChannels == 2; + if (backReader) + { + juce::AudioBuffer audio(2, 9600); + roundtripPitch = backReader->read(&audio, 0, 9600, 0, true, true) && roundtripPitch; + for (int sample = 256; sample < 9344; ++sample) + roundtripPitch = std::abs(audio.getSample(0, sample) - 0.25 * std::sin(juce::MathConstants::twoPi * 1000 * sample / 48000)) < 0.0001 && roundtripPitch; + } + check("project_rate_copy_48000_44100_48000_pitch_duration", roundtripPitch); + const auto changedSource = directory.getChildFile("identity-source.wav"); + const bool firstIdentityWritten = writeFixture(changedSource, 48000, 1, 9600, 1000); + const auto firstIdentity = ProjectRateCopy::create(changedSource, cache, 44100, alive); + const auto unchangedTime = changedSource.getLastModificationTime(); + const bool secondIdentityWritten = writeFixture(changedSource, 48000, 1, 9600, 2000); + changedSource.setLastModificationTime(unchangedTime); + const auto secondIdentity = ProjectRateCopy::create(changedSource, cache, 44100, alive); + check("project_rate_copy_identity_includes_source_content", firstIdentityWritten && secondIdentityWritten + && static_cast(firstIdentity["success"]) && static_cast(secondIdentity["success"]) + && firstIdentity["filePath"] != secondIdentity["filePath"]); + const auto cancelledCopy = ProjectRateCopy::create(stereo, cache, 44100, [] { return false; }); + check("project_rate_copy_cancelled_job_never_publishes_clip", !static_cast(cancelledCopy["success"]) + && static_cast(cancelledCopy["cancelled"]) && juce::SHA256(stereo).toHexString() == sourceHash); + + PlaybackEngine sourceRoute; sourceRoute.setOfflineSourceReads(true); + sourceRoute.addClip(stereo, 12.3, 0.1, "conversion-track", 0.05, -3.0, 0.01, 0.02, "conversion-clip", stereo, 0.05, 1, 2); + sourceRoute.setClipGainEnvelope("conversion-track", "conversion-clip", { { 0, 0.5f }, { 0.1, 0.75f } }); + const bool routeApplied = static_cast(rateCopy["success"]) && sourceRoute.replaceClipSourcePreservingTimeline( + "conversion-track", "conversion-clip", stereo, convertedFile, convertedFile, 0.05, alive); + const auto routeAfter = sourceRoute.getClipSnapshot(); + check("project_rate_copy_atomic_route_preserves_trim_gain_fades", routeApplied && routeAfter.size() == 1 + && routeAfter[0].audioFile == convertedFile && routeAfter[0].originalAudioFile == convertedFile + && routeAfter[0].offset == 0.05 && routeAfter[0].originalOffset == 0.05 && routeAfter[0].startTime == 12.3 + && routeAfter[0].duration == 0.1 && routeAfter[0].volumeDB == -3 && routeAfter[0].fadeInCurve == 1 + && routeAfter[0].fadeOutCurve == 2 && routeAfter[0].gainEnvelope.size() == 2); + const bool staleRejected = !sourceRoute.replaceClipSourcePreservingTimeline("conversion-track", "conversion-clip", stereo, stereo, stereo, 0.05, alive); + const bool restoredRoute = sourceRoute.replaceClipSourcePreservingTimeline("conversion-track", "conversion-clip", convertedFile, stereo, stereo, 0.05, alive); + check("project_rate_copy_route_undo_and_stale_compare_swap", staleRejected && restoredRoute + && sourceRoute.getClipSnapshot()[0].audioFile == stereo && sourceRoute.getClipSnapshot()[0].offset == 0.05); + const bool projectReloadRejected = !sourceRoute.replaceClipSourcePreservingTimeline("conversion-track", "conversion-clip", stereo, convertedFile, convertedFile, 0.05, alive, [&]() { + sourceRoute.clearAllClips(); sourceRoute.addClip(stereo, 0, 0.1, "conversion-track", 0, 0, 0, 0, "conversion-clip"); + return std::static_pointer_cast(std::make_shared(1)); + }); + check("project_rate_copy_reopened_identity_cannot_receive_stale_source", projectReloadRejected && sourceRoute.getClipSnapshot()[0].audioFile == stereo); + + PlaybackEngine envelopeRoute; envelopeRoute.setOfflineSourceReads(true); + envelopeRoute.addClip(stereo, 0, 0.2, "envelope-track", 0, 0, 0, 0, "envelope-clip"); + std::vector envelope; + for (int point = 4095; point >= 0; --point) envelope.push_back({ point * 0.2 / 4095.0, 0.25f + 0.5f * static_cast(point) / 4095.0f }); + envelopeRoute.setClipGainEnvelope("envelope-track", "envelope-clip", envelope); + juce::AudioBuffer envelopeAudio(2, 4096); envelopeAudio.clear(); + envelopeRoute.fillTrackBuffer("envelope-track", envelopeAudio, 0, 4096, 48000); + double envelopeError = 0.0; + for (int sample = 0; sample < 4096; ++sample) + { + const double expected = 0.25 * std::sin(juce::MathConstants::twoPi * 1000 * sample / 48000) + * (0.25 + 0.5 * (sample / 48000.0) / 0.2); + envelopeError = juce::jmax(envelopeError, std::abs(envelopeAudio.getSample(0, sample) - expected)); + } + check("large_sorted_gain_envelope_binary_lookup_audio_parity", envelopeError < 1.0e-6); + + const auto impulseSource = directory.getChildFile("float-overunity-impulse.wav"); + juce::WavAudioFormat impulseFormat; + std::unique_ptr impulseStream = impulseSource.createOutputStream(); + auto impulseWriter = impulseFormat.createWriterFor(impulseStream, juce::AudioFormatWriterOptions() + .withSampleRate(48000).withNumChannels(2).withBitsPerSample(32)); + juce::AudioBuffer impulse(2, 1024); impulse.clear(); + impulse.setSample(0, 800, 1.5f); impulse.setSample(1, 800, -0.75f); + bool impulsePass = impulseWriter && impulseWriter->writeFromAudioSampleBuffer(impulse, 0, 1024) && impulseWriter->flush(); + impulseWriter.reset(); + const auto impulseConverted = ProjectRateCopy::create(impulseSource, cache, 44100, alive); + auto impulseReader = openFixture(juce::File(impulseConverted["filePath"].toString())); + if (impulseReader) + { + juce::AudioBuffer convertedImpulse(2, static_cast(impulseReader->lengthInSamples)); + impulsePass = impulseReader->read(&convertedImpulse, 0, convertedImpulse.getNumSamples(), 0, true, true) && impulsePass; + int peakSample = 0; float peakValue = 0; + for (int sample = 0; sample < convertedImpulse.getNumSamples(); ++sample) + { + const float value = std::abs(convertedImpulse.getSample(0, sample)); + if (value > peakValue) { peakValue = value; peakSample = sample; } + impulsePass = std::abs(convertedImpulse.getSample(1, sample) + 0.5f * convertedImpulse.getSample(0, sample)) < 1.0e-6f && impulsePass; + } + impulsePass = peakValue > 1.0f && std::abs(peakSample - 735) <= 1 && impulsePass; + } + else impulsePass = false; + check("project_rate_copy_impulse_timing_float_headroom_and_channel_phase", impulsePass && static_cast(impulseConverted["success"])); + auto* diagnostics = new juce::DynamicObject(); + diagnostics->setProperty("firstCopy", rateCopy); diagnostics->setProperty("cachedCopy", cachedCopy); + diagnostics->setProperty("roundtripCopy", back); diagnostics->setProperty("longPathCopy", longPathCopy); + diagnostics->setProperty("longPathCacheDirectoryCharacters", longCache.getFullPathName().length()); + directory.getChildFile("project-rate-copy-regression.json").replaceWithText(juce::JSON::toString(juce::var(diagnostics), true)); } diff --git a/Source/AudioRecorder.cpp b/Source/AudioRecorder.cpp index 1959e5ef..3d3a83f7 100644 --- a/Source/AudioRecorder.cpp +++ b/Source/AudioRecorder.cpp @@ -73,7 +73,7 @@ void AudioRecorder::maintainRecordingPreviews() AudioRecorder::AudioRecorder(const juce::File& recoveryRoot) : recoveryJournal(recoveryRoot) { - // Start the background I/O thread for ThreadedWriter + // Start the shared background I/O thread for prepared recording FIFOs. writerThread.startThread(juce::Thread::Priority::normal); } @@ -124,7 +124,7 @@ bool AudioRecorder::startRecordingInternal(const juce::String& trackId, + " file=" + file.getFullPathName() + " sampleRate=" + juce::String(sampleRate, 2) + " channels=" + juce::String(numChannels)); - std::unique_ptr previousWriter; + std::unique_ptr previousWriter; std::shared_ptr previousWriteStatus; double previousSampleRate = sampleRate; // Construct the replacement completely before unpublishing any current @@ -196,12 +196,11 @@ bool AudioRecorder::startRecordingInternal(const juce::String& trackId, checkedStream->flush(); if (writeStatus->fault.load() != RecordingWriteStatus::none) return false; - // Wrap in ThreadedWriter - moves disk I/O to background thread + // Prepare all FIFO storage and register the disk client before publication. auto checkedWriter = std::make_unique(std::move(writer), writeStatus, checkedStream); - auto threadedWriter = std::make_unique( - checkedWriter.release(), writerThread, 65536); // Periodic header publication and checked flush, exclusively on disk thread. - threadedWriter->setFlushInterval(static_cast(sampleRate * 5.0)); + auto threadedWriter = std::make_unique( + std::move(checkedWriter), writerThread, 65536, static_cast(sampleRate * 5.0)); auto preview = std::make_shared(sampleRate, numChannels); @@ -320,8 +319,7 @@ void AudioRecorder::writeBlock(const juce::String& trackId, const float inputPeak = peakFromBuffer(buffer, numSamples); #endif - // ThreadedWriter::write() is audio-thread safe (lock-free ring buffer internally) - // It copies data immediately, so buffer pointers don't need to remain valid + // Copy and FIFO publication only: no background-thread wake event or mutex. if (!state.threadedWriter->write(buffer.getArrayOfReadPointers(), numSamples)) { writerBufferOverflowCount.fetch_add(1, std::memory_order_relaxed); @@ -398,7 +396,7 @@ void AudioRecorder::writeBlock(const juce::String& trackId, void AudioRecorder::stopRecording(const juce::String& trackId) { const juce::ScopedLock control(controlLock); - std::unique_ptr writerToFlush; + std::unique_ptr writerToFlush; juce::File outputFile; { const juce::ScopedLock sl(writerLock); diff --git a/Source/AudioRecorder.h b/Source/AudioRecorder.h index 5342a6cb..db09ce87 100644 --- a/Source/AudioRecorder.h +++ b/Source/AudioRecorder.h @@ -6,10 +6,11 @@ #include #include #include "RecordingWriterSafety.h" +#include "RecordingDiskWriter.h" // Handles recording audio to disk for individual tracks. -// Uses JUCE's ThreadedWriter to move disk I/O off the audio thread, -// preventing crackling and buffer underruns. +// Copies PCM into a prepared FIFO; a polling disk client writes it in the +// background without audio-thread locks or wake notifications. class AudioRecorder { public: @@ -90,7 +91,7 @@ class AudioRecorder struct ActiveRecording { juce::String trackId; - std::unique_ptr threadedWriter; + std::unique_ptr threadedWriter; std::shared_ptr writeStatus; juce::File outputFile; std::atomic isActive { false }; diff --git a/Source/AudioXRunTelemetry.h b/Source/AudioXRunTelemetry.h new file mode 100644 index 00000000..a569c3ba --- /dev/null +++ b/Source/AudioXRunTelemetry.h @@ -0,0 +1,81 @@ +#pragma once +#include +#include + +namespace AudioXRunTelemetry +{ +struct Snapshot +{ + int combined = 0; + int driver = -1; + int host = -1; + bool driverSupported = false; + bool splitConsistent = false; +}; + +// AudioDeviceManager combines max(0, driver xruns) with its load-measurer +// deadline count. Derive the host part only while the driver's count/identity +// stayed stable across that combined read; never label a racing split exact. +inline Snapshot derive(int driverBefore, int combined, int driverAfter, bool sameDevice = true) noexcept +{ + Snapshot snapshot; + snapshot.combined = combined; + snapshot.driver = driverAfter; + snapshot.driverSupported = driverAfter >= 0; + snapshot.splitConsistent = sameDevice && driverBefore == driverAfter && combined >= 0 + && (!snapshot.driverSupported || combined >= driverAfter); + if (snapshot.splitConsistent) + snapshot.host = combined - (snapshot.driverSupported ? driverAfter : 0); + return snapshot; +} + +// Message/control-thread only, like the surrounding telemetry getters. Device +// lifetime is controlled there. Two bounded reads, no retry or callback lock. +inline Snapshot capture(const juce::AudioDeviceManager& manager) +{ + const auto* beforeDevice = manager.getCurrentAudioDevice(); + const int before = beforeDevice != nullptr ? beforeDevice->getXRunCount() : -1; + const int combined = manager.getXRunCount(); + const auto* afterDevice = manager.getCurrentAudioDevice(); + const int after = afterDevice != nullptr ? afterDevice->getXRunCount() : -1; + return derive(before, combined, after, beforeDevice == afterDevice); +} + +inline void publish(juce::DynamicObject& object, const Snapshot& snapshot) +{ + // Preserve the shipped combined field and its original support flag. + object.setProperty("audioDeviceXRunCount", snapshot.combined); + object.setProperty("audioDeviceXRunTelemetrySupported", snapshot.combined >= 0); + object.setProperty("audioDriverXRunCount", snapshot.driver); + object.setProperty("audioDriverXRunTelemetrySupported", snapshot.driverSupported); + object.setProperty("audioHostXRunCount", snapshot.host); + object.setProperty("audioXRunSplitSnapshotConsistent", snapshot.splitConsistent); +} + +inline void runRegression(const std::function& check) +{ + const auto unknown = derive(-1, 17, -1); + check("xrun_split_unknown_driver_retains_exact_host_count", !unknown.driverSupported + && unknown.driver == -1 && unknown.combined == 17 && unknown.host == 17 && unknown.splitConsistent); + const auto zero = derive(0, 0, 0); + check("xrun_split_zero_supported", zero.driverSupported && zero.driver == 0 && zero.host == 0 && zero.splitConsistent); + const auto stable = derive(105, 5642, 105); + check("xrun_split_stable_driver_and_host_counts", stable.driver == 105 && stable.combined == 5642 + && stable.host == 5537 && stable.driverSupported && stable.splitConsistent); + const auto changed = derive(105, 5642, 106); + const auto changedSupport = derive(-1, 17, 0); + const auto changedDevice = derive(0, 17, 0, false); + check("xrun_split_races_withhold_host_decomposition", changed.host == -1 && !changed.splitConsistent + && changed.driver == 106 && changed.driverSupported && changedSupport.host == -1 + && !changedSupport.splitConsistent && changedDevice.host == -1 && !changedDevice.splitConsistent); + const auto inconsistent = derive(105, 100, 105); + const auto invalidCombined = derive(-1, -1, -1); + check("xrun_split_inconsistent_counters_are_not_precise", inconsistent.host == -1 && !inconsistent.splitConsistent + && invalidCombined.host == -1 && !invalidCombined.splitConsistent); + juce::DynamicObject serialized; publish(serialized, stable); + check("xrun_split_preserves_legacy_combined_field", static_cast(serialized.getProperty("audioDeviceXRunCount")) == 5642 + && static_cast(serialized.getProperty("audioDriverXRunCount")) == 105 + && static_cast(serialized.getProperty("audioHostXRunCount")) == 5537 + && static_cast(serialized.getProperty("audioXRunSplitSnapshotConsistent"))); +} +} diff --git a/Source/BuiltInEffects.cpp b/Source/BuiltInEffects.cpp index d7861004..b2baaea4 100644 --- a/Source/BuiltInEffects.cpp +++ b/Source/BuiltInEffects.cpp @@ -1198,6 +1198,37 @@ void OpenStudioEQ::captureMatchOutput(const juce::AudioBuffer& buffer) no } void OpenStudioEQ::processEQAudioBlock(juce::AudioBuffer& fullBuffer) +{ + // An embedded strip with no audible/analysis transition needs only the + // existing output meter. Detector scratch is refreshed by the general + // path before it can be consumed on the next enabled/requested block. + // Standalone/key/phase EQs always retain their complete processing path. + const int numSamples = fullBuffer.getNumSamples(); + if (bandCount == numBands + && !powerEnabled.load(std::memory_order_acquire) + && !smoothedPowerMix.isSmoothing() + && smoothedPowerMix.getCurrentValue() <= 1.0e-6f + && fullBuffer.getNumChannels() == 2 && numSamples > 0 + && getMainBusNumInputChannels() == 2 && !supportsExternalKey() + && !linearPhase && !spectralEQ && !matchCapture + && !draftPreview.requested() + && spectrumDemandSamplesRemaining.load(std::memory_order_acquire) <= 0 + && spectrumCaptureSlot < 0 + && !modeTransitionPending + && pendingProcessingMode == juce::jlimit(0, 2, + static_cast(std::round(stereoMode.load(std::memory_order_relaxed)))) + && dryScratch.getNumChannels() >= 2 + && dryScratch.getNumSamples() >= numSamples + && bandScratch.getNumSamples() >= numSamples) + { + ++embeddedDryFastBlocks; + publishOutputPeaks(fullBuffer); + return; + } + processEQAudioBlockGeneral(fullBuffer); +} + +void OpenStudioEQ::processEQAudioBlockGeneral(juce::AudioBuffer& fullBuffer) { captureDetectorKey(fullBuffer); const int mainChannels = juce::jmin(getMainBusNumInputChannels(), fullBuffer.getNumChannels()); diff --git a/Source/BuiltInEffects.h b/Source/BuiltInEffects.h index 781d0dd4..96e20058 100644 --- a/Source/BuiltInEffects.h +++ b/Source/BuiltInEffects.h @@ -93,6 +93,9 @@ class OpenStudioEQ : public OpenStudioBuiltInEffect void reset() override; void processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer& midi) override; + // Device-free qualification of the embedded, fully dry channel-strip path. + static juce::var runEmbeddedDryRegression(); + void getStateInformation(juce::MemoryBlock& destData) override; void setStateInformation(const void* data, int sizeInBytes) override; @@ -224,6 +227,8 @@ class OpenStudioEQ : public OpenStudioBuiltInEffect std::uint32_t midiProgramSerial=0; int midiProgramSampleOffset=0; void processEQAudioBlock(juce::AudioBuffer& buffer); + void processEQAudioBlockGeneral(juce::AudioBuffer& buffer); + juce::uint64 embeddedDryFastBlocks = 0; // Callback-owned; device-free qualification reads after processing. void publishMIDIPrograms(std::unique_ptr next); void getEQStateInformationRaw(juce::MemoryBlock& destination, const std::array& snapshot, const std::array& configuration); void saveMIDIPrograms(juce::ValueTree& state) const; diff --git a/Source/ClipPreviewData.cpp b/Source/ClipPreviewData.cpp new file mode 100644 index 00000000..28d6dbe9 --- /dev/null +++ b/Source/ClipPreviewData.cpp @@ -0,0 +1,52 @@ +#include "AudioEngine.h" +#include "ClipSourceDescriptor.h" +#include + +juce::var AudioEngine::getClipTempoPreviewData(const juce::String& trackId, const juce::String& clipId, + double rangeStartSeconds, double durationSeconds, const juce::var& source) const +{ + auto* result = new juce::DynamicObject(); + const auto fail = [&](const juce::String& error) { result->setProperty("error", error); return juce::var(result); }; + if (!std::isfinite(rangeStartSeconds) || !std::isfinite(durationSeconds) || durationSeconds <= 0) + return fail("The preview range is invalid"); + const auto snapshot = resolveReadOnlyClipSource(playbackEngine, trackId, clipId, source); + if (snapshot) + { + const auto& clip = *snapshot; + juce::AudioFormatManager formats; formats.registerBasicFormats(); + std::unique_ptr reader(formats.createReaderFor(clip.audioFile)); + if (!reader || !std::isfinite(reader->sampleRate) || reader->sampleRate < 8000 || reader->sampleRate > 384000) + return fail("The preview source is unavailable"); + const double rate = reader->sampleRate; + const int channels = juce::jlimit(1, 2, static_cast(reader->numChannels)); + const double start = juce::jlimit(0.0, clip.duration, rangeStartSeconds); + // At most 384000 stereo frames (~4 MiB encoded), independent of rate. + const double seconds = juce::jmin(8.0, durationSeconds, clip.duration - start, 384000.0 / rate); + const int samples = static_cast(std::floor(seconds * rate)); + if (samples < 1) return fail("The selected preview range has no audio"); + reader.reset(); + PlaybackEngine preview; preview.setOfflineSourceReads(true); preview.addClipFromSnapshot(clip); + auto memory = std::make_unique(); auto* bytes = memory.get(); + std::unique_ptr stream = std::move(memory); + juce::WavAudioFormat format; + auto writer = format.createWriterFor(stream, juce::AudioFormatWriterOptions().withSampleRate(rate) + .withNumChannels(channels).withBitsPerSample(32).withSampleFormat(juce::AudioFormatWriterOptions::SampleFormat::floatingPoint)); + if (!writer) return fail("The preview buffer could not be prepared"); + juce::AudioBuffer block(channels, 1024); + for (int rendered = 0; rendered < samples; rendered += 1024) + { + const int count = juce::jmin(1024, samples - rendered); block.clear(); + preview.fillTrackBuffer(trackId, block, clip.startTime + start + rendered / rate, count, rate); + if (preview.hasOfflineSourceReadFailure() || !writer->writeFromAudioSampleBuffer(block, 0, count)) + return fail("The source preview could not be read"); + } + if (!writer->flush()) return fail("The preview buffer could not be finalized"); + result->setProperty("wavBase64", juce::Base64::toBase64(bytes->getData(), bytes->getDataSize())); + result->setProperty("sampleRate", rate); result->setProperty("numChannels", channels); + result->setProperty("durationSeconds", samples / rate); result->setProperty("rangeStartSeconds", start); + result->setProperty("filePath", clip.audioFile.getFullPathName()); + result->setProperty("sourceOffsetSeconds", clip.offset); result->setProperty("clipDurationSeconds", clip.duration); + return juce::var(result); + } + return fail("The selected clip is no longer available"); +} diff --git a/Source/ClipSourceDescriptor.h b/Source/ClipSourceDescriptor.h new file mode 100644 index 00000000..ffbfcc0e --- /dev/null +++ b/Source/ClipSourceDescriptor.h @@ -0,0 +1,48 @@ +#pragma once +#include "PlaybackEngine.h" +#include +#include + +// Worker-thread source access never publishes the descriptor to realtime playback. +inline std::optional resolveReadOnlyClipSource( + const PlaybackEngine& playback, const juce::String& trackId, const juce::String& clipId, + const juce::var& source) +{ + if (source.isVoid()) + { + for (const auto& clip : playback.getClipSnapshot()) + if (clip.trackId == trackId && clip.clipId == clipId) return clip; + return std::nullopt; + } + if (!source.isObject() || !source["filePath"].isString() + || !juce::File::isAbsolutePath(source["filePath"].toString())) return std::nullopt; + const auto number = [&](const char* name, double minimum, double maximum) { + const auto value = source[name]; + return (value.isDouble() || value.isInt() || value.isInt64()) + && std::isfinite(static_cast(value)) + && static_cast(value) >= minimum && static_cast(value) <= maximum; + }; + if (!number("startTime", 0, 1e9) || !number("duration", 1e-9, 1e9) || !number("offset", 0, 1e9) + || !number("volumeDB", -144, 24) || !number("fadeIn", 0, 1e9) || !number("fadeOut", 0, 1e9) + || !number("fadeInCurve", 0, 4) || !number("fadeOutCurve", 0, 4)) return std::nullopt; + PlaybackEngine::ClipInfo clip(juce::File(source["filePath"].toString()), source["startTime"], source["duration"], + trackId, source["offset"], source["volumeDB"], source["fadeIn"], source["fadeOut"], source["fadeInCurve"], source["fadeOutCurve"]); + clip.clipId = clipId; + clip.originalAudioFile = clip.audioFile; + clip.originalOffset = clip.offset; + const auto* points = source["gainEnvelope"].getArray(); + if (points == nullptr || points->size() > 16384) return std::nullopt; + double previousTime = -1.0; + for (const auto& point : *points) + { + const auto time = point["time"], gain = point["gain"]; + if (!(time.isDouble() || time.isInt() || time.isInt64()) || !(gain.isDouble() || gain.isInt() || gain.isInt64())) + return std::nullopt; + const double t = time, g = gain; + if (!std::isfinite(t) || !std::isfinite(g) || t < 0 || t <= previousTime || t > 1e9 || g < 0 || g > 2) + return std::nullopt; + clip.gainEnvelope.push_back({ t, static_cast(g) }); + previousTime = t; + } + return clip; +} diff --git a/Source/ClipTempoAnalyzer.h b/Source/ClipTempoAnalyzer.h new file mode 100644 index 00000000..7d934f11 --- /dev/null +++ b/Source/ClipTempoAnalyzer.h @@ -0,0 +1,294 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +// Bounded worker-only analysis. Project BPM and QA samples are never inputs. +// Ellis 2007: log-spectral novelty, autocorrelation and log-tempo listener prior. +// Adaptive spectral whitening follows the approach documented by aubio. +namespace ClipTempoAnalyzer +{ +namespace detail +{ +constexpr double rate = 11025.0; +constexpr int fftSize = 1024, hop = 128; +constexpr double frameRate = rate / hop; +inline double preference(double bpm) { return std::exp(-0.5 * std::pow(std::log2(bpm / 120.0) / 0.9, 2.0)); } +inline bool sameFamily(double a, double b) +{ + for (const double ratio : { 0.5, 2.0 / 3.0, 1.0, 1.5, 2.0, 3.0 }) + if (std::abs(a / b / ratio - 1.0) < 0.035) return true; + return false; +} +struct Segment +{ + double start = 0.0, duration = 0.0, energy = 0.0, threshold = 0.0; + int channel = 0; + std::vector onset, correlation; + double at(double lag) const + { + if (lag < 1.0 || lag + 1.0 >= static_cast(correlation.size())) return 0.0; + const auto index = static_cast(lag); + return correlation[index] + (lag - static_cast(index)) * (correlation[index + 1] - correlation[index]); + } + double score(double bpm) const + { + const double lag = frameRate * 60.0 / bpm; + return juce::jmax(0.0, (at(lag) + 0.35 * at(lag * 2.0) + 0.15 * at(lag * 3.0)) / 1.5); + } +}; +inline bool analyzeSegment(juce::AudioFormatReader& reader, double offset, Segment& segment, const std::atomic* cancelled) +{ + const int count = static_cast(segment.duration * rate); + std::vector audio(static_cast(count), 0.0f); + constexpr int blockSize = 65536; + juce::AudioBuffer input(juce::jlimit(1, 2, static_cast(reader.numChannels)), blockSize + 1); + const auto sourceStart = static_cast((offset + segment.start) * reader.sampleRate); + juce::int64 loadedStart = -1; + int loadedCount = 0; + double energy = 0.0; + for (int sample = 0; sample < count; ++sample) + { + if ((sample & 4095) == 0 && cancelled && cancelled->load(std::memory_order_relaxed)) return false; + const double source = sample * reader.sampleRate / rate; + const auto sourceIndex = static_cast(source); + if (loadedStart < 0 || sourceIndex + 1 >= loadedStart + loadedCount) + { + loadedStart = sourceIndex; + loadedCount = static_cast(juce::jmin(blockSize + 1, reader.lengthInSamples - sourceStart - loadedStart)); + if (loadedCount <= 0 || !reader.read(&input, 0, loadedCount, sourceStart + loadedStart, true, true)) return false; + } + const int position = static_cast(sourceIndex - loadedStart); + const float fraction = static_cast(source - static_cast(sourceIndex)); + const float a = input.getSample(segment.channel, position); + const float b = input.getSample(segment.channel, juce::jmin(position + 1, loadedCount - 1)); + const float value = a + (b - a) * fraction; + if (!std::isfinite(value)) return false; + audio[static_cast(sample)] = value; + energy += value * value; + } + if (energy / juce::jmax(1, count) < 1.0e-10) return false; + juce::dsp::FFT fft(10); + std::array transform {}; + std::array previous {}, average {}; + std::array hann {}; + for (int sample = 0; sample < fftSize; ++sample) + hann[static_cast(sample)] = 0.5f - 0.5f * std::cos(juce::MathConstants::twoPi * static_cast(sample) / static_cast(fftSize - 1)); + std::vector> bands; + std::vector frameEnergies; + bands.reserve(static_cast(count / hop)); + for (int start = 0; start + fftSize <= count; start += hop) + { + if (cancelled && cancelled->load(std::memory_order_relaxed)) return false; + transform.fill(0.0f); + double frameEnergy = 0.0; + for (int sample = 0; sample < fftSize; ++sample) + { + const float value = audio[static_cast(start + sample)] * hann[static_cast(sample)]; + transform[static_cast(sample)] = value; + frameEnergy += value * value; + } + frameEnergies.push_back(frameEnergy); + fft.performFrequencyOnlyForwardTransform(transform.data()); + std::array flux {}; + for (int bin = 4; bin < fftSize / 2; ++bin) + { + const auto index = static_cast(bin); + const float magnitude = transform[index]; + average[index] = bands.empty() ? magnitude : 0.97f * average[index] + 0.03f * magnitude; + const float whitened = std::log1p(4.0f * magnitude / juce::jmax(1.0e-4f, average[index])); + const double frequency = bin * rate / fftSize; + const int band = frequency < 180.0 ? 0 : frequency < 800.0 ? 1 : frequency < 2500.0 ? 2 : 3; + if (frequency < 5000.0) flux[static_cast(band)] += juce::jmax(0.0f, whitened - previous[index]); + previous[index] = whitened; + } + bands.push_back(flux); + } + if (bands.size() < 128) return false; + // Whitening must not amplify tiny, periodic FFT leakage from a sustained + // tone into fictitious note attacks. Require actual envelope modulation. + std::sort(frameEnergies.begin(), frameEnergies.end()); + const auto lowEnergy = frameEnergies[frameEnergies.size() / 20]; + const auto highEnergy = frameEnergies[frameEnergies.size() * 19 / 20]; + if (highEnergy < 1.0e-12 || (highEnergy - lowEnergy) / highEnergy < 0.01) return false; + bands.front().fill(0.0); + segment.onset.assign(bands.size(), 0.0); + constexpr std::array weights { 0.35, 0.15, 0.35, 0.15 }; + const int radius = juce::roundToInt(frameRate * 0.5); + for (size_t band = 0; band < weights.size(); ++band) + { + std::vector prefix(bands.size() + 1, 0.0), novelty(bands.size(), 0.0); + for (size_t i = 0; i < bands.size(); ++i) prefix[i + 1] = prefix[i] + bands[i][band]; + for (size_t i = 0; i < bands.size(); ++i) + { + const auto first = static_cast(juce::jmax(0, static_cast(i) - radius)); + const auto last = juce::jmin(bands.size(), i + static_cast(radius) + 1); + novelty[i] = juce::jmax(0.0, bands[i][band] - (prefix[last] - prefix[first]) / static_cast(last - first)); + } + auto sorted = novelty; + std::sort(sorted.begin(), sorted.end()); + const double scale = juce::jmax(1.0e-8, sorted[sorted.size() * 9 / 10]); + for (size_t i = 0; i < bands.size(); ++i) + segment.onset[i] += weights[band] * juce::jmin(3.0, novelty[i] / scale); + } + const double maximum = *std::max_element(segment.onset.begin(), segment.onset.end()); + int attacks = 0; + for (size_t i = 1; i + 1 < segment.onset.size(); ++i) + if (segment.onset[i] > maximum * 0.15 && segment.onset[i] > segment.onset[i - 1] && segment.onset[i] >= segment.onset[i + 1]) ++attacks; + if (maximum < 1.0e-5 || attacks < 4) return false; + double mean = 0.0; + for (const auto value : segment.onset) mean += value; + mean /= static_cast(segment.onset.size()); + segment.threshold = juce::jmax(0.2, 3.0 / std::sqrt(static_cast(segment.onset.size()) / (fftSize / hop))); + const int end = juce::jmin(static_cast(std::ceil(frameRate * 60.0 / 45.0)) * 3 + 2, static_cast(segment.onset.size() / 2)); + segment.correlation.assign(static_cast(end + 1), 0.0); + double strongest = 0.0; + for (int lag = 1; lag <= end; ++lag) + { + if (cancelled && cancelled->load(std::memory_order_relaxed)) return false; + double product = 0.0, firstEnergy = 0.0, secondEnergy = 0.0; + for (size_t i = static_cast(lag); i < segment.onset.size(); ++i) + { + const double a = segment.onset[i] - mean, b = segment.onset[i - static_cast(lag)] - mean; + product += a * b; firstEnergy += a * a; secondEnergy += b * b; + } + const double correlation = product / std::sqrt(juce::jmax(1.0e-20, firstEnergy * secondEnergy)); + segment.correlation[static_cast(lag)] = correlation; + if (lag >= static_cast(frameRate * 60.0 / 240.0)) strongest = juce::jmax(strongest, correlation); + } + return strongest >= segment.threshold; +} +} + +inline juce::var analyze(const juce::File& file, double offset, double duration, const std::atomic* cancelled = nullptr) +{ + auto* result = new juce::DynamicObject(); result->setProperty("source", "audio"); + const auto fail = [result](const juce::String& message) { + result->setProperty("error", message); result->setProperty("confidence", 0.0); + result->setProperty("candidates", juce::Array()); return juce::var(result); + }; + const auto isCancelled = [&] { return cancelled && cancelled->load(std::memory_order_relaxed); }; + const auto cancelledResult = [&] { result->setProperty("cancelled", true); return fail("Tempo analysis cancelled."); }; + if (isCancelled()) return cancelledResult(); + juce::AudioFormatManager manager; manager.registerBasicFormats(); + std::unique_ptr reader(manager.createReaderFor(file)); + if (reader == nullptr || !std::isfinite(reader->sampleRate) || reader->sampleRate < 8000.0 || reader->sampleRate > 384000.0 + || reader->lengthInSamples <= 0 || !std::isfinite(offset) || !std::isfinite(duration) || offset < 0.0 || duration < 3.0) + return fail("Select at least three seconds of readable audio with a steady pulse."); + const double available = juce::jmin(duration, static_cast(reader->lengthInSamples) / reader->sampleRate - offset); + if (available < 3.0) return fail("The clip has too little readable audio for tempo analysis."); + const double window = juce::jmin(available, 20.0, juce::jmax(6.0, available / 3.0)); + std::vector probes; + const double probeDuration = juce::jmin(2.0, available); + const int probeSamples = juce::jmax(1, juce::roundToInt(reader->sampleRate * probeDuration)); + juce::AudioBuffer probe(juce::jlimit(1, 2, static_cast(reader->numChannels)), probeSamples); + for (int index = 0; index < 9; ++index) + { + if (isCancelled()) return cancelledResult(); + const double center = available * (static_cast(index) + 0.5) / 9.0; + const double probeStart = juce::jlimit(0.0, available - probeDuration, center - probeDuration * 0.5); + if (!reader->read(&probe, 0, probeSamples, static_cast((offset + probeStart) * reader->sampleRate), true, true)) return fail("An audio source read failed."); + detail::Segment segment; segment.start = juce::jlimit(0.0, available - window, center - window * 0.5); segment.duration = window; + for (int channel = 0; channel < probe.getNumChannels(); ++channel) + { + const double rms = probe.getRMSLevel(channel, 0, probeSamples); + if (rms * rms > segment.energy) { segment.energy = rms * rms; segment.channel = channel; } + } + probes.push_back(std::move(segment)); + } + std::stable_sort(probes.begin(), probes.end(), [](const auto& a, const auto& b) { return a.energy > b.energy; }); + std::vector segments; + int attempted = 0; + for (auto& segment : probes) + { + if (segment.energy < 1.0e-10 || std::any_of(segments.begin(), segments.end(), [&](const auto& other) { + return std::abs(segment.start - other.start) <= window * 0.4; })) continue; + ++attempted; + if (isCancelled()) return cancelledResult(); + if (detail::analyzeSegment(*reader, offset, segment, cancelled)) segments.push_back(std::move(segment)); + if (attempted == 3) break; // Includes rejected/noisy windows: at most 60 seconds of FFT/onset work. + } + if (isCancelled()) return cancelledResult(); + if (segments.empty()) return fail("No repeated pulse stands out. Try a steadier range, tap the pulse, or count beats manually."); + struct Candidate { double bpm = 0.0, score = 0.0, evidence = 0.0; }; + std::vector grid; + for (int index = 0; index <= 1950; ++index) + { + if (isCancelled()) return cancelledResult(); + const double bpm = 45.0 + index * 0.1; + double evidence = 0.0; + for (const auto& segment : segments) evidence += segment.score(bpm); + evidence /= static_cast(segments.size()); + grid.push_back({ bpm, evidence * detail::preference(bpm), evidence }); + } + std::stable_sort(grid.begin(), grid.end(), [](const auto& a, const auto& b) { return a.score > b.score; }); + const auto best = grid.front(); + if (best.evidence < 0.07) return fail("No stable beat grid was found across the sections. Select a clearer rhythm or count beats manually."); + std::vector ranked; + for (const auto& candidate : grid) + { + if (std::all_of(ranked.begin(), ranked.end(), [&](const auto& other) { return std::abs(candidate.bpm / other.bpm - 1.0) > 0.025; })) ranked.push_back(candidate); + if (ranked.size() == 6) break; + } + double minimumBpm = 1.0e9, maximumBpm = 0.0, totalDuration = 0.0; + size_t phaseSegment = 0; double phaseScore = -1.0; + juce::Array segmentResults; + for (size_t index = 0; index < segments.size(); ++index) + { + if (isCancelled()) return cancelledResult(); + const auto& segment = segments[index]; + auto segmentBest = best; + for (const auto& candidate : grid) + if (segment.score(candidate.bpm) * detail::preference(candidate.bpm) > segment.score(segmentBest.bpm) * detail::preference(segmentBest.bpm)) segmentBest = candidate; + double folded = segmentBest.bpm; + for (const double ratio : { 0.5, 2.0 / 3.0, 1.0, 1.5, 2.0, 3.0 }) + if (std::abs(segmentBest.bpm * ratio - best.bpm) < std::abs(folded - best.bpm)) folded = segmentBest.bpm * ratio; + minimumBpm = juce::jmin(minimumBpm, folded); maximumBpm = juce::jmax(maximumBpm, folded); totalDuration += segment.duration; + const double score = segment.score(best.bpm); + if (score > phaseScore) { phaseScore = score; phaseSegment = index; } + auto* item = new juce::DynamicObject(); item->setProperty("startSeconds", segment.start); item->setProperty("durationSeconds", segment.duration); + item->setProperty("bpm", folded); item->setProperty("confidence", juce::jlimit(0.0, 0.85, score)); segmentResults.add(item); + } + const double spread = maximumBpm - minimumBpm; + const bool stable = spread / best.bpm < 0.04; + double contrast = 1.0; + for (const auto& candidate : ranked) if (!detail::sameFamily(candidate.bpm, best.bpm)) { + contrast = juce::jlimit(0.0, 1.0, 1.0 - candidate.score / juce::jmax(1.0e-8, best.score)); break; + } + const double confidence = juce::jmin(stable ? 0.88 : 0.35, 0.88 * juce::jlimit(0.0, 1.0, best.evidence / 0.45) * (0.45 + 0.55 * contrast)); + const auto& primary = segments[phaseSegment]; + const double period = 60.0 / best.bpm; + std::array phases {}; + for (size_t i = 0; i < primary.onset.size(); ++i) + { + const double time = primary.start + (static_cast(i) * detail::hop + detail::fftSize * 0.5) / detail::rate; + const int phase = static_cast(std::floor(std::fmod(time, period) / period * static_cast(phases.size()))) % static_cast(phases.size()); + phases[static_cast(phase)] += primary.onset[i]; + } + const double phase = static_cast(std::distance(phases.begin(), std::max_element(phases.begin(), phases.end()))); + result->setProperty("bpm", best.bpm); result->setProperty("confidence", confidence); + result->setProperty("beatOffsetSeconds", (phase + 0.5) / static_cast(phases.size()) * period); + result->setProperty("analysisStartSeconds", primary.start); result->setProperty("analysisDurationSeconds", primary.duration); + result->setProperty("analyzedDurationSeconds", totalDuration); result->setProperty("tempoSpreadBpm", spread); result->setProperty("segments", segmentResults); + juce::Array alternatives; + for (const auto& candidate : ranked) { + auto* item = new juce::DynamicObject(); item->setProperty("bpm", candidate.bpm); + item->setProperty("confidence", confidence * candidate.score / juce::jmax(1.0e-8, best.score)); alternatives.add(item); + } + for (const double ratio : { 0.5, 2.0 / 3.0, 1.5, 2.0 }) { + const double bpm = best.bpm * ratio; + if (bpm < 20.0 || bpm > 400.0) continue; + bool present = false; + for (const auto& alternative : alternatives) if (std::abs(static_cast(alternative["bpm"]) / bpm - 1.0) < 0.02) present = true; + if (!present) { auto* item = new juce::DynamicObject(); item->setProperty("bpm", bpm); item->setProperty("confidence", confidence * 0.7); alternatives.add(item); } + } + result->setProperty("candidates", alternatives); + result->setProperty("warning", juce::String(stable ? "" : "Sections suggest different tempos; one BPM may not fit the range. ") + + "Compared " + juce::String(static_cast(segments.size())) + " audible section(s), " + juce::String(totalDuration, 1) + + " s in total. Check half/double or triplet pulse levels and adjust beat phase while auditioning. Confidence is evidence strength; downbeat is not detected."); + return juce::var(result); +} +} diff --git a/Source/EditingWorkflowRegression.cpp b/Source/EditingWorkflowRegression.cpp new file mode 100644 index 00000000..a2748303 --- /dev/null +++ b/Source/EditingWorkflowRegression.cpp @@ -0,0 +1,65 @@ +#include "EditingWorkflowRegression.h" +#include "TrackProcessor.h" +#include "MixerGain.h" +#include "LatestAnalysisQueue.h" +#include "ClipTempoAnalyzer.h" + +void runEditingWorkflowRegression(const std::function& check) +{ + bool silent = true, adjacentAudible = true; + for (const int blockSize : { 8, 512 }) + for (const bool automated : { false, true }) + for (const float db : { -60.0f, -59.9f, -6.0f }) + { + TrackProcessor processor; + processor.setVolume(automated ? 0.0f : db); + if (automated) + { + processor.getVolumeAutomation().setMode(AutomationMode::Read); + processor.getVolumeAutomation().setPoints({ { 0.0, db }, { 1.0, db } }); + } + processor.prepareToPlay(48000.0, blockSize); + juce::AudioBuffer input(2, blockSize); + juce::MidiBuffer midi; + for (int iteration = 0; iteration < 4; ++iteration) + { + for (int channel = 0; channel < 2; ++channel) + for (int sample = 0; sample < blockSize; ++sample) input.setSample(channel, sample, 0.25f); + processor.setCurrentBlockPosition(iteration * blockSize / 48000.0); + processor.processBlock(input, midi); + } + const float magnitude = input.getMagnitude(0, input.getNumSamples()); + if (db == -60.0f) silent = silent && magnitude == 0.0f; + else adjacentAudible = adjacentAudible && magnitude > 0.0f; + processor.releaseResources(); + } + check("mixer_floor_static_automated_8_and_512_exact_silence", silent); + check("mixer_above_floor_static_automated_remains_audible", adjacentAudible); + check("mixer_shared_gain_floor_and_adjacent_contract", MixerGain::fromDecibels(-60.0f) == 0.0f + && MixerGain::fromDecibels(-59.9f) > 0.0f + && std::abs(MixerGain::fromDecibels(-6.0f) - std::pow(10.0f, -6.0f / 20.0f)) < 1.0e-7f); + + // A blocked first task makes replacing pending work deterministic. No GUI, + // device or subjective musical estimate is involved in cancellation testing. + LatestAnalysisQueue queue; + juce::WaitableEvent entered, release; + std::atomic completions { 0 }, workCount { 0 }, cancellations { 0 }; + const auto complete = [&](juce::var result) { + if (static_cast(result["cancelled"])) ++cancellations; + ++completions; + }; + queue.submit("first", [&](const std::atomic&) { + ++workCount; entered.signal(); release.wait(3000); return juce::var(); + }, complete); + const bool started = entered.wait(3000); + for (int request = 0; request < 20; ++request) + queue.submit(juce::String(request), [&](const std::atomic&) { ++workCount; return juce::var(); }, complete); + queue.cancel("19"); + release.signal(); + queue.shutdown(); + check("tempo_queue_bounds_obsolete_work_and_completes_every_request", started && workCount.load() == 1 + && completions.load() == 21 && cancellations.load() == 21); + std::atomic cancelled { true }; + const auto result = ClipTempoAnalyzer::analyze(juce::File(), 0.0, 60.0, &cancelled); + check("tempo_cancel_before_source_open", static_cast(result["cancelled"])); +} diff --git a/Source/EditingWorkflowRegression.h b/Source/EditingWorkflowRegression.h new file mode 100644 index 00000000..81a27bd9 --- /dev/null +++ b/Source/EditingWorkflowRegression.h @@ -0,0 +1,4 @@ +#pragma once +#include +#include +void runEditingWorkflowRegression(const std::function& check); diff --git a/Source/InlineEQDryRegression.cpp b/Source/InlineEQDryRegression.cpp new file mode 100644 index 00000000..b7816d7b --- /dev/null +++ b/Source/InlineEQDryRegression.cpp @@ -0,0 +1,303 @@ +#include "InlineEQDryRegression.h" +#include "BuiltInEffects.h" +#include +#include +#include +#include +#include +#include + +namespace +{ +juce::var timingSummary(std::vector samples) +{ + std::sort(samples.begin(), samples.end()); + const auto percentile = [&](double fraction) + { + return samples[static_cast(fraction * static_cast(samples.size() - 1))]; + }; + auto* result = new juce::DynamicObject(); + result->setProperty("p50Microseconds", percentile(0.50)); + result->setProperty("p95Microseconds", percentile(0.95)); + result->setProperty("p99Microseconds", percentile(0.99)); + return juce::var(result); +} +} + +juce::var OpenStudioEQ::runEmbeddedDryRegression() +{ + juce::Array checks; + bool passed = true; + const auto check = [&](const char* id, bool pass, const char* detail) + { + auto* item = new juce::DynamicObject(); + item->setProperty("id", id); item->setProperty("pass", pass); item->setProperty("detail", detail); + checks.add(juce::var(item)); passed = passed && pass; + }; + constexpr int tracks = 31, blockSize = 8, batches = 6000; + constexpr double rate = 48000.0; + std::array, tracks> full, fast, peakOnly; + juce::AudioBuffer input(2, blockSize); + for (int sample = 0; sample < blockSize; ++sample) + { + input.setSample(0, sample, sample % 2 == 0 ? 0.17f : -0.13f); + input.setSample(1, sample, sample % 3 == 0 ? -0.07f : 0.03f); + } + for (size_t track = 0; track < full.size(); ++track) + { + full[track] = std::make_unique(); + fast[track] = std::make_unique(); + peakOnly[track] = std::make_unique(); + for (auto* processor : { full[track].get(), fast[track].get(), peakOnly[track].get() }) + { + processor->setPowerEnabled(false); + processor->setRateAndBufferSizeDetails(rate, blockSize); + processor->prepareToPlay(rate, blockSize); + } + } + std::vector fullTimes, fastTimes, peakTimes; + fullTimes.reserve(batches); fastTimes.reserve(batches); peakTimes.reserve(batches); + const double toMicroseconds = 1000000.0 / static_cast(juce::Time::getHighResolutionTicksPerSecond()); + bool exactAudio = true; + juce::MidiBuffer midi; + for (int batch = 0; batch < batches; ++batch) + { + const auto measureFull = [&] + { + const auto start = juce::Time::getHighResolutionTicks(); + for (const auto& processor : full) processor->processEQAudioBlockGeneral(input); + fullTimes.push_back(static_cast(juce::Time::getHighResolutionTicks() - start) * toMicroseconds); + }; + const auto measureFast = [&] + { + const auto start = juce::Time::getHighResolutionTicks(); + for (const auto& processor : fast) processor->processBlock(input, midi); + fastTimes.push_back(static_cast(juce::Time::getHighResolutionTicks() - start) * toMicroseconds); + }; + const auto measurePeak = [&] + { + const auto start = juce::Time::getHighResolutionTicks(); + for (const auto& processor : peakOnly) processor->publishOutputPeaks(input); + peakTimes.push_back(static_cast(juce::Time::getHighResolutionTicks() - start) * toMicroseconds); + }; + if (batch % 3 == 0) { measureFull(); measureFast(); measurePeak(); } + else if (batch % 3 == 1) { measureFast(); measurePeak(); measureFull(); } + else { measurePeak(); measureFull(); measureFast(); } + for (int sample = 0; sample < blockSize; ++sample) + exactAudio = exactAudio && input.getSample(0, sample) == (sample % 2 == 0 ? 0.17f : -0.13f) + && input.getSample(1, sample) == (sample % 3 == 0 ? -0.07f : 0.03f); + } + bool exactMeters = true; + juce::uint64 benchmarkFastBlocks = 0; + for (size_t track = 0; track < full.size(); ++track) + { + for (size_t channel = 0; channel < 2; ++channel) + exactMeters = exactMeters && full[track]->outputPeaks[channel].load() == peakOnly[track]->outputPeaks[channel].load() + && full[track]->outputPeaks[channel].load() == fast[track]->outputPeaks[channel].load(); + benchmarkFastBlocks += fast[track]->embeddedDryFastBlocks; + full[track]->releaseResources(); fast[track]->releaseResources(); peakOnly[track]->releaseResources(); + } + check("inline_eq_disabled_benchmark_preserves_audio", exactAudio, + "Prepared embedded EQ, power fully dry, nonzero stereo eight-frame blocks: exact unchanged sample values."); + check("inline_eq_disabled_benchmark_peak_lower_bound_exact", exactMeters, + "Peak-only lower-bound loop preserves both output dB meters; this is a cost diagnostic, not an enabled processing substitute."); + check("inline_eq_disabled_benchmark_uses_actual_fast_entry", + benchmarkFastBlocks == static_cast(tracks) * batches, + "Every timed candidate block uses the guarded production entry; the reference calls the unchanged general DSP helper."); + + const auto sameAudio = [](const juce::AudioBuffer& a, const juce::AudioBuffer& b) + { + if (a.getNumChannels() != b.getNumChannels() || a.getNumSamples() != b.getNumSamples()) return false; + for (int channel = 0; channel < a.getNumChannels(); ++channel) + if (std::memcmp(a.getReadPointer(channel), b.getReadPointer(channel), + static_cast(a.getNumSamples()) * sizeof(float)) != 0) return false; + return true; + }; + const auto sameState = [](const OpenStudioEQ& a, const OpenStudioEQ& b) + { + bool exact = a.activeProcessingMode == b.activeProcessingMode + && a.pendingProcessingMode == b.pendingProcessingMode && a.modeTransitionPending == b.modeTransitionPending + && a.wetPathHasState == b.wetPathHasState + && a.smoothedPowerMix.getCurrentValue() == b.smoothedPowerMix.getCurrentValue() + && a.smoothedPowerMix.getTargetValue() == b.smoothedPowerMix.getTargetValue() + && a.smoothedPowerMix.isSmoothing() == b.smoothedPowerMix.isSmoothing() + && a.smoothedModeMix.getCurrentValue() == b.smoothedModeMix.getCurrentValue() + && a.smoothedModeMix.getTargetValue() == b.smoothedModeMix.getTargetValue() + && a.spectrumDemandSamplesRemaining.load() == b.spectrumDemandSamplesRemaining.load() + && a.spectrumCaptureSlot == b.spectrumCaptureSlot && a.spectrumCaptureWritePos == b.spectrumCaptureWritePos + && a.spectrumCaptureGeneration == b.spectrumCaptureGeneration + && a.draftPreview.requested() == b.draftPreview.requested(); + for (size_t channel = 0; channel < 2; ++channel) + exact = exact && a.outputPeaks[channel].load() == b.outputPeaks[channel].load(); + for (int band = 0; band < a.bandCount; ++band) + exact = exact && a.dynamicGainDB[static_cast(band)].load() == b.dynamicGainDB[static_cast(band)].load(); + return exact; + }; + const auto fill = [](juce::AudioBuffer& block, juce::int64 frame) + { + for (int channel = 0; channel < block.getNumChannels(); ++channel) + for (int sample = 0; sample < block.getNumSamples(); ++sample) + { + const auto phase = (frame + sample + channel * 37) % 127; + const float value = static_cast(phase - 63) / 256.0f; + block.setSample(channel, sample, channel % 2 == 0 ? value : value * -0.37f); + } + }; + bool dryParity = true, rampParity = true, modeParity = true, dispatchParity = true, resetStateParity = true; + juce::int64 parityBlocks = 0; + for (const double testRate : { 44100.0, 48000.0, 96000.0 }) + for (const int length : { 1, 8, 16, 512 }) + for (const int channels : { 1, 2 }) + { + auto reference = std::make_unique(); + auto candidate = std::make_unique(); + for (auto* processor : { reference.get(), candidate.get() }) + { + processor->setPowerEnabled(false); + processor->setRateAndBufferSizeDetails(testRate, 512); + processor->prepareToPlay(testRate, 512); + auto& band = processor->bands[3]; + band.gain.store(6.0f); band.freq.store(1200.0f); band.dynamicEnabled.store(1.0f); + band.dynamicRange.store(-9.0f); band.dynamicThreshold.store(-24.0f); + band.dynamicAttack.store(3.0f); band.dynamicRelease.store(45.0f); + } + juce::AudioBuffer a(channels, length), b(channels, length); + juce::int64 cursor = 0; + const auto step = [&] + { + fill(a, cursor); b.makeCopyOf(a, true); cursor += length; + reference->processEQAudioBlockGeneral(a); candidate->processBlock(b, midi); ++parityBlocks; + return sameAudio(a, b) && sameState(*reference, *candidate); + }; + for (int block = 0; block < 128; ++block) dryParity = step() && dryParity; + dispatchParity = dispatchParity && (candidate->embeddedDryFastBlocks > 0) == (channels == 2); + const auto beforeMode = candidate->embeddedDryFastBlocks; + reference->stereoMode.store(1.0f); candidate->stereoMode.store(1.0f); + modeParity = step() && candidate->embeddedDryFastBlocks == beforeMode && modeParity; + modeParity = step() && modeParity; + reference->stereoMode.store(0.0f); candidate->stereoMode.store(0.0f); + modeParity = step() && modeParity; + const int transitionBlocks = static_cast(std::ceil(testRate * 0.025 / length)) + 2; + for (int phase = 0; phase < 3; ++phase) + { + const bool enabled = phase != 1; + reference->setPowerEnabled(enabled); candidate->setPowerEnabled(enabled); + for (int block = 0; block < transitionBlocks; ++block) rampParity = step() && rampParity; + } + reference->setPowerEnabled(false); candidate->setPowerEnabled(false); + reference->reset(); candidate->reset(); + resetStateParity = step() && resetStateParity; + for (auto* processor : { reference.get(), candidate.get() }) processor->prepareToPlay(testRate, 512); + resetStateParity = step() && resetStateParity; + juce::MemoryBlock referenceState, candidateState; + reference->getStateInformation(referenceState); candidate->getStateInformation(candidateState); + resetStateParity = juce::ValueTree::readFromData(referenceState.getData(), referenceState.getSize()).isEquivalentTo( + juce::ValueTree::readFromData(candidateState.getData(), candidateState.getSize())) && resetStateParity; + reference->releaseResources(); candidate->releaseResources(); + } + check("inline_eq_dry_shortcut_exact_rate_shape_matrix", dryParity && dispatchParity, + "Exact asymmetric mono/stereo PCM, both dB meters and runtime state at 44.1/48/96kHz with 1/8/16/512-frame blocks; mono retains the general path."); + check("inline_eq_dry_shortcut_enable_ramp_and_reenable_exact", rampParity, + "After long dry input, active dynamic EQ enables, ramps off and re-enables with exact general-path audio, meters, power/mode state and dynamic gain."); + check("inline_eq_dry_shortcut_mode_change_falls_back", modeParity, + "A dry mode request uses the general path before subsequent steady blocks can qualify."); + check("inline_eq_dry_shortcut_reset_reprepare_state_exact", resetStateParity, + "Reset, re-preparation, first subsequent input and serialized parameter state preserve general-path parity."); + + auto spectrumReference = std::make_unique(); + auto spectrumCandidate = std::make_unique(); + for (auto* processor : { spectrumReference.get(), spectrumCandidate.get() }) + { + processor->setPowerEnabled(false); processor->setRateAndBufferSizeDetails(rate, blockSize); + processor->prepareToPlay(rate, blockSize); processor->getSpectrumData(); + } + juce::AudioBuffer spectrumA(2, blockSize), spectrumB(2, blockSize); + bool spectrumParity = true, spectrumReady = false; + for (int block = 0; block < 7600; ++block) + { + fill(spectrumA, block * blockSize); spectrumB.makeCopyOf(spectrumA, true); + spectrumReference->processEQAudioBlockGeneral(spectrumA); spectrumCandidate->processBlock(spectrumB, midi); + spectrumParity = sameAudio(spectrumA, spectrumB) && sameState(*spectrumReference, *spectrumCandidate) && spectrumParity; + if (block == 300) + { + const auto a = spectrumReference->getSpectrumData(), b = spectrumCandidate->getSpectrumData(); + spectrumReady = a.ready && b.ready && a.fftLength == b.fftLength && a.source == b.source + && a.preEQ == b.preEQ && a.postEQ == b.postEQ && a.externalKey == b.externalKey; + } + } + check("inline_eq_dry_shortcut_spectrum_demand_and_expiry_exact", + spectrumParity && spectrumReady && spectrumCandidate->embeddedDryFastBlocks > 0, + "Dry spectrum polling captures identical FFT data, expires/cleans the same capture state, then resumes the shortcut."); + bool draftParity = true; + for (auto* processor : { spectrumReference.get(), spectrumCandidate.get() }) + { + BuiltInEQDraftPreview::Plan plan; plan.rate = rate; plan.stages = 1; + plan.coefficients[0] = { 1.0f, 0.0f, 0.0f, 0.0f, 0.0f }; plan.fingerprint = processor->draftFingerprint(); + draftParity = static_cast(processor->draftPreview.start("inline-dry-qa", plan)["success"]) && draftParity; + } + const auto beforeDraft = spectrumCandidate->embeddedDryFastBlocks; + spectrumA.clear(); spectrumB.clear(); + spectrumReference->processEQAudioBlockGeneral(spectrumA); spectrumCandidate->processBlock(spectrumB, midi); + draftParity = sameAudio(spectrumA, spectrumB) && sameState(*spectrumReference, *spectrumCandidate) + && spectrumCandidate->embeddedDryFastBlocks == beforeDraft + && !spectrumReference->draftPreview.requested() && !spectrumCandidate->draftPreview.requested() && draftParity; + spectrumReference->processEQAudioBlockGeneral(spectrumA); spectrumCandidate->processBlock(spectrumB, midi); + draftParity = sameAudio(spectrumA, spectrumB) && sameState(*spectrumReference, *spectrumCandidate) + && spectrumCandidate->embeddedDryFastBlocks == beforeDraft + 1 && draftParity; + check("inline_eq_dry_shortcut_pending_draft_uses_general_path", draftParity, + "A pending draft request is handled by the existing general path and rejects/resets the ineligible embedded audition identically."); + spectrumReference->releaseResources(); spectrumCandidate->releaseResources(); + + auto wideReference = std::make_unique(); + auto wideCandidate = std::make_unique(); + for (auto* processor : { wideReference.get(), wideCandidate.get() }) + { + processor->setPowerEnabled(false); processor->setRateAndBufferSizeDetails(rate, blockSize); + processor->prepareToPlay(rate, blockSize); + } + juce::AudioBuffer wideA(4, blockSize), wideB(4, blockSize); + fill(wideA, 17); wideB.makeCopyOf(wideA); + wideReference->processEQAudioBlockGeneral(wideA); wideCandidate->processBlock(wideB, midi); + check("inline_eq_dry_shortcut_wide_buffer_keeps_general_path", + sameAudio(wideA, wideB) && sameState(*wideReference, *wideCandidate) && wideCandidate->embeddedDryFastBlocks == 0, + "An embedded wider buffer retains the general main-pair view and untouched extra channels."); + wideReference->releaseResources(); wideCandidate->releaseResources(); + + auto standaloneReference = std::make_unique(true); + auto standaloneCandidate = std::make_unique(true); + for (auto* processor : { standaloneReference.get(), standaloneCandidate.get() }) + { + processor->setPowerEnabled(false); processor->setRateAndBufferSizeDetails(rate, blockSize); + processor->prepareToPlay(rate, blockSize); processor->externalDetector.store(1.0f); + processor->bands[3].gain.store(6.0f); processor->bands[3].dynamicEnabled.store(1.0f); + processor->bands[3].dynamicRange.store(-6.0f); processor->bands[3].detectorSource.store(2.0f); + } + juce::AudioBuffer standaloneA(4, blockSize), standaloneB(4, blockSize); + bool standaloneParity = true; + for (int block = 0; block < 600; ++block) + { + if (block == 100) { standaloneReference->setPowerEnabled(true); standaloneCandidate->setPowerEnabled(true); } + fill(standaloneA, block * blockSize); standaloneB.makeCopyOf(standaloneA, true); + standaloneReference->processEQAudioBlockGeneral(standaloneA); standaloneCandidate->processBlock(standaloneB, midi); + standaloneParity = sameAudio(standaloneA, standaloneB) && sameState(*standaloneReference, *standaloneCandidate) && standaloneParity; + } + check("inline_eq_dry_shortcut_standalone_key_keeps_general_path", + standaloneParity && standaloneCandidate->embeddedDryFastBlocks == 0, + "Standalone EQ with external-key input and dynamic processing never takes the embedded shortcut and remains exact."); + standaloneReference->releaseResources(); standaloneCandidate->releaseResources(); + auto* benchmark = new juce::DynamicObject(); + benchmark->setProperty("classification", "diagnostic_only"); + benchmark->setProperty("tracks", tracks); benchmark->setProperty("blockSize", blockSize); + benchmark->setProperty("sampleRate", rate); benchmark->setProperty("batches", batches); + benchmark->setProperty("fullDisabledBatch", timingSummary(std::move(fullTimes))); + benchmark->setProperty("guardedFastBatch", timingSummary(std::move(fastTimes))); + benchmark->setProperty("peakOnlyLowerBoundBatch", timingSummary(std::move(peakTimes))); + benchmark->setProperty("actualFastBlocks", static_cast(benchmarkFastBlocks)); + auto* result = new juce::DynamicObject(); result->setProperty("pass", passed); + result->setProperty("checks", checks); result->setProperty("benchmark", juce::var(benchmark)); + result->setProperty("parityBlocks", parityBlocks); + result->setProperty("scope", "Device-free exact dry/transition/analysis/state parity and embedded EQ cost. Timing is diagnostic_only; no hardware or subjective sound claim."); + return juce::var(result); +} + +juce::var runInlineEQDryRegression() { return OpenStudioEQ::runEmbeddedDryRegression(); } diff --git a/Source/InlineEQDryRegression.h b/Source/InlineEQDryRegression.h new file mode 100644 index 00000000..b4e85151 --- /dev/null +++ b/Source/InlineEQDryRegression.h @@ -0,0 +1,3 @@ +#pragma once +#include +juce::var runInlineEQDryRegression(); diff --git a/Source/IntegerPDCRegression.cpp b/Source/IntegerPDCRegression.cpp new file mode 100644 index 00000000..d1a74662 --- /dev/null +++ b/Source/IntegerPDCRegression.cpp @@ -0,0 +1,266 @@ +#include "IntegerPDCRegression.h" +#include "PreparedIntegerDelayLine.h" +#include "TrackProcessor.h" +#include +#include +#include + +namespace { +using ReferenceLine = juce::dsp::DelayLine; +constexpr int maximumDelay = 96000, ringSize = maximumDelay + 2; +juce::dsp::ProcessSpec spec(int channels, int blockSize) +{ + return { 48000.0, static_cast(blockSize), static_cast(channels) }; +} +float source(int frame, int channel, int track = 0) +{ + return static_cast(((frame + track * 7 + channel * 3) % 127) - 63) / 256.0f + 0.03125f; +} +bool identical(float left, float right) { return std::memcmp(&left, &right, sizeof(float)) == 0; } +juce::uint64 hashSample(juce::uint64 hash, float sample) +{ + juce::uint32 bits = 0; std::memcpy(&bits, &sample, sizeof(bits)); + return (hash ^ bits) * 1099511628211ULL; +} +bool stableTapParity() +{ + ReferenceLine reference(maximumDelay); PreparedIntegerDelayLine candidate(maximumDelay); + bool exact = true; + constexpr int blocks[] { 1, 8, 16, 512 }; + for (const int tap : { 0, 1, 6, 128, 96000 }) + { + reference.prepare(spec(2, 512)); candidate.prepare(spec(2, 512)); + reference.setDelay(static_cast(tap)); candidate.setDelay(static_cast(tap)); + int frame = 0, block = 0; + while (frame < ringSize * 2 + 19) + { + const int count = juce::jmin(blocks[block++ % 4], ringSize * 2 + 19 - frame); + for (int sample = 0; sample < count; ++sample, ++frame) + for (int channel = 0; channel < 2; ++channel) + { + const float input = source(frame, channel); + reference.pushSample(channel, input); candidate.pushSample(channel, input); + exact = identical(reference.popSample(channel), candidate.popSample(channel)) && exact; + } + } + } + return exact; +} +bool multiTapTransitionParity() +{ + ReferenceLine reference(maximumDelay); PreparedIntegerDelayLine candidate(maximumDelay); + reference.prepare(spec(2, 512)); candidate.prepare(spec(2, 512)); + int current = 0, target = 0, pending = 0, remaining = 0; + constexpr int ramp = 960; + bool exact = true; + const auto begin = [&] { if (remaining == 0 && pending != current) { target = pending; remaining = ramp; } }; + for (int frame = 0; frame < ringSize * 2 + 31; ++frame) + { + if (frame % 337 == 0) pending = std::array{ 0, 1, 6, 128, 96000 }[static_cast((frame / 337) % 5)]; + begin(); + const float progress = remaining > 0 ? 1.0f - static_cast(remaining - 1) / ramp : 0.0f; + const float mix = progress * progress * (3.0f - 2.0f * progress); + for (int channel = 0; channel < 2; ++channel) + { + reference.pushSample(channel, source(frame, channel)); candidate.pushSample(channel, source(frame, channel)); + if (remaining > 0) + { + const float oldReference = reference.popSample(channel, static_cast(current), false); + const float oldCandidate = candidate.popSample(channel, static_cast(current), false); + // Repeated peeks must not advance either cursor. + exact = identical(oldReference, reference.popSample(channel, static_cast(current), false)) + && identical(oldCandidate, candidate.popSample(channel, static_cast(current), false)) && exact; + const float nextReference = reference.popSample(channel, static_cast(target), true); + const float nextCandidate = candidate.popSample(channel, static_cast(target), true); + exact = identical(oldReference + (nextReference - oldReference) * mix, + oldCandidate + (nextCandidate - oldCandidate) * mix) && exact; + } + else exact = identical(reference.popSample(channel), candidate.popSample(channel)) && exact; + } + if (remaining > 0 && --remaining == 0) + { + current = target; + reference.setDelay(static_cast(current)); candidate.setDelay(static_cast(current)); begin(); + } + } + return exact; +} +bool stableBlockParity() +{ + ReferenceLine reference(maximumDelay); + PreparedIntegerDelayLine candidate(maximumDelay); + std::array audio {}; + constexpr int lengths[] { 0, 1, 8, 16, 512 }; + constexpr float taps[] { 0.0f, 1.0f, 6.0f, 128.0f, 96000.0f, 0.5f, 127.75f }; + const std::array edges { -0.0f, 0.0f, std::numeric_limits::max(), + -std::numeric_limits::max(), std::numeric_limits::denorm_min(), -0.25f }; + bool exact = true; + for (const int channels : { 2, 1, 2 }) + { + reference.prepare(spec(channels, 512)); candidate.prepare(spec(channels, 512)); + int frame = 0, block = 0; + while (frame < ringSize * 2 + 19) + { + const int count = juce::jmin(lengths[block % 5], ringSize * 2 + 19 - frame); + const float tap = taps[(block / 13) % 7]; + reference.setDelay(tap); candidate.setDelay(tap); + for (int channel = 0; channel < channels; ++channel) + { + for (int sample = 0; sample < count; ++sample) + audio[static_cast(sample)] = (block % 11) == 0 + ? edges[static_cast(sample % 6)] : source(frame + sample, channel); + const auto input = audio; + candidate.processChannel(channel, audio.data(), count); + for (int sample = 0; sample < count; ++sample) + { + reference.pushSample(channel, input[static_cast(sample)]); + exact = identical(reference.popSample(channel), audio[static_cast(sample)]) && exact; + } + // Mix both APIs to expose cursor/history errors across block boundaries. + reference.pushSample(channel, 0.125f); candidate.pushSample(channel, 0.125f); + exact = identical(reference.popSample(channel, tap, false), candidate.popSample(channel, tap, false)) && exact; + exact = identical(reference.popSample(channel), candidate.popSample(channel)) && exact; + } + frame += count; ++block; + } + } + return exact; +} +bool lifecycleAndFloatParity() +{ + ReferenceLine reference(maximumDelay); PreparedIntegerDelayLine candidate(maximumDelay); + bool exact = candidate.getMaximumDelayInSamples() == reference.getMaximumDelayInSamples(); + const std::array values { 0.0f, -0.0f, 1.0f, -1.0f, std::numeric_limits::max(), + -std::numeric_limits::max(), std::numeric_limits::denorm_min(), + -std::numeric_limits::denorm_min(), 0.25f, -0.5f }; + for (const int channels : { 2, 1, 2 }) + { + reference.prepare(spec(channels, 8)); candidate.prepare(spec(channels, 8)); + for (const float tap : { 0.0f, 1.0f, 6.0f, 128.0f, 96000.0f, 0.5f, 127.75f }) + { + reference.setDelay(tap); candidate.setDelay(tap); + for (int frame = 0; frame < 1024; ++frame) + for (int channel = 0; channel < channels; ++channel) + { + const float input = values[static_cast((frame + channel) % static_cast(values.size()))]; + reference.pushSample(channel, input); candidate.pushSample(channel, input); + exact = identical(reference.popSample(channel), candidate.popSample(channel)) && exact; + } + reference.reset(); candidate.reset(); + exact = reference.getDelay() == candidate.getDelay() && exact; + for (int frame = 0; frame < 16; ++frame) + for (int channel = 0; channel < channels; ++channel) + { + reference.pushSample(channel, source(frame, channel)); candidate.pushSample(channel, source(frame, channel)); + exact = identical(reference.popSample(channel), candidate.popSample(channel)) && exact; + } + } + } + return exact; +} +juce::var statistics(std::vector values) +{ + std::sort(values.begin(), values.end()); + auto* result = new juce::DynamicObject(); + result->setProperty("medianMicrosecondsPer31TrackBatch", values[values.size() / 2]); + result->setProperty("p95MicrosecondsPer31TrackBatch", values[static_cast(0.95 * static_cast(values.size() - 1))]); + result->setProperty("p99MicrosecondsPer31TrackBatch", values[static_cast(0.99 * static_cast(values.size() - 1))]); + return juce::var(result); +} +juce::var benchmark(bool mixedTaps) +{ + constexpr int tracks = 31, batches = 6000, blockSize = 8; + std::vector> references; + std::vector> candidates; + for (int track = 0; track < tracks; ++track) + { + auto reference = std::make_unique(maximumDelay); + auto candidate = std::make_unique(maximumDelay); + reference->prepare(spec(2, blockSize)); candidate->prepare(spec(2, blockSize)); + const float tap = static_cast(mixedTaps ? std::array{ 0, 1, 6, 128, 96000 }[static_cast(track % 5)] : 0); + reference->setDelay(tap); candidate->setDelay(tap); + references.push_back(std::move(reference)); candidates.push_back(std::move(candidate)); + } + std::array input {}, referenceOutput {}, candidateOutput {}; + std::vector referenceTimes, candidateTimes; + referenceTimes.reserve(batches); candidateTimes.reserve(batches); + juce::uint64 referenceChecksum = 1469598103934665603ULL, candidateChecksum = referenceChecksum; + bool exact = true; + const double toMicroseconds = 1.0e6 / static_cast(juce::Time::getHighResolutionTicksPerSecond()); + for (int batch = 0; batch < batches; ++batch) + { + for (int track = 0; track < tracks; ++track) + for (int sample = 0; sample < blockSize; ++sample) + for (int channel = 0; channel < 2; ++channel) + input[static_cast((track * blockSize + sample) * 2 + channel)] = source(batch * blockSize + sample, channel, track); + const auto measureReference = [&] + { + const auto start = juce::Time::getHighResolutionTicks(); + for (int track = 0; track < tracks; ++track) + for (int sample = 0; sample < blockSize; ++sample) + for (int channel = 0; channel < 2; ++channel) + { + const auto index = static_cast((track * blockSize + sample) * 2 + channel); + references[static_cast(track)]->pushSample(channel, input[index]); + referenceOutput[index] = references[static_cast(track)]->popSample(channel); + } + referenceTimes.push_back(static_cast(juce::Time::getHighResolutionTicks() - start) * toMicroseconds); + }; + const auto measureCandidate = [&] + { + const auto start = juce::Time::getHighResolutionTicks(); + for (int track = 0; track < tracks; ++track) + for (int sample = 0; sample < blockSize; ++sample) + for (int channel = 0; channel < 2; ++channel) + { + const auto index = static_cast((track * blockSize + sample) * 2 + channel); + candidates[static_cast(track)]->pushSample(channel, input[index]); + candidateOutput[index] = candidates[static_cast(track)]->popSample(channel); + } + candidateTimes.push_back(static_cast(juce::Time::getHighResolutionTicks() - start) * toMicroseconds); + }; + if ((batch & 1) == 0) { measureReference(); measureCandidate(); } + else { measureCandidate(); measureReference(); } + // Output reads/hash occur outside the timers and keep both computations + // observable without charging checksum overhead to either implementation. + for (size_t index = 0; index < referenceOutput.size(); ++index) + { + exact = identical(referenceOutput[index], candidateOutput[index]) && exact; + referenceChecksum = hashSample(referenceChecksum, referenceOutput[index]); + candidateChecksum = hashSample(candidateChecksum, candidateOutput[index]); + } + } + auto* result = new juce::DynamicObject(); + result->setProperty("trackCount", tracks); result->setProperty("callbackBatches", batches); + result->setProperty("blockSize", blockSize); result->setProperty("channelsPerTrack", 2); + result->setProperty("reference", statistics(std::move(referenceTimes))); + result->setProperty("candidate", statistics(std::move(candidateTimes))); + result->setProperty("exactPCMParity", exact); + result->setProperty("referenceChecksumHex", juce::String::toHexString(referenceChecksum)); + result->setProperty("candidateChecksumHex", juce::String::toHexString(candidateChecksum)); + result->setProperty("matchingChecksums", referenceChecksum == candidateChecksum); + return juce::var(result); +} +} + +void runIntegerPDCRegression(const juce::File& directory, + const std::function& check) +{ + const bool stable = stableTapParity(), transitions = multiTapTransitionParity(), lifecycle = lifecycleAndFloatParity(); + check("integer_pdc_candidate_stable_integral_taps_and_wrap_exact", stable); + check("integer_pdc_candidate_dual_peek_advance_and_transition_exact", transitions); + check("integer_pdc_block_channel_warm_history_wrap_and_float_parity", stableBlockParity()); + check("integer_pdc_candidate_reset_reprepare_signed_zero_and_overflow_exact", lifecycle); + const auto production = TrackProcessor::runPreparedPDCRegression(); + check("integer_pdc_production_track_helper_exact_juce_reference", static_cast(production["pass"])); + const auto zero = benchmark(false), mixed = benchmark(true); + check("integer_pdc_candidate_zero_benchmark_exact_pcm_and_checksum", static_cast(zero["exactPCMParity"]) && static_cast(zero["matchingChecksums"])); + check("integer_pdc_candidate_mixed_benchmark_exact_pcm_and_checksum", static_cast(mixed["exactPCMParity"]) && static_cast(mixed["matchingChecksums"])); + auto* report = new juce::DynamicObject(); + report->setProperty("classification", "diagnostic_only: device-free PDC primitive cost; no hardware or audible-quality acceptance"); + report->setProperty("productionIntegration", true); + report->setProperty("productionTrackReference", production); + report->setProperty("stableTapParity", stable); report->setProperty("transitionParity", transitions); report->setProperty("lifecycleFloatParity", lifecycle); + report->setProperty("zeroDelay", zero); report->setProperty("mixedSteadyTaps", mixed); + directory.getChildFile("integer-pdc-benchmark.json").replaceWithText(juce::JSON::toString(juce::var(report), true)); +} diff --git a/Source/IntegerPDCRegression.h b/Source/IntegerPDCRegression.h new file mode 100644 index 00000000..ebcbda6d --- /dev/null +++ b/Source/IntegerPDCRegression.h @@ -0,0 +1,5 @@ +#pragma once +#include +#include +void runIntegerPDCRegression(const juce::File& directory, + const std::function& check); diff --git a/Source/LatestAnalysisQueue.h b/Source/LatestAnalysisQueue.h new file mode 100644 index 00000000..aa824e46 --- /dev/null +++ b/Source/LatestAnalysisQueue.h @@ -0,0 +1,103 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +// Message/worker threads only. One running request and one replaceable pending +// request bound both CPU work and retained source data during rapid Analyze/Cancel. +class LatestAnalysisQueue final : private juce::Thread +{ +public: + using Work = std::function&)>; + using Completion = std::function; + LatestAnalysisQueue() : juce::Thread("Clip tempo analysis") { startThread(juce::Thread::Priority::low); } + ~LatestAnalysisQueue() override { shutdown(); } + + static juce::var cancelledResult() + { + auto* result = new juce::DynamicObject(); + result->setProperty("cancelled", true); + result->setProperty("error", "Tempo analysis cancelled."); + result->setProperty("source", "audio"); + result->setProperty("confidence", 0.0); + result->setProperty("candidates", juce::Array()); + return juce::var(result); + } + + void submit(juce::String id, Work work, Completion completion) + { + auto request = std::make_shared(std::move(id), std::move(work), std::move(completion)); + std::shared_ptr retired; + { + const std::lock_guard lock(mutex); + if (stopping) retired = request; + else + { + if (active) active->cancelled.store(true); + retired = std::exchange(pending, request); + } + } + if (retired) retired->completion(cancelledResult()); + notify(); + } + + void cancel(const juce::String& id) + { + const std::lock_guard lock(mutex); + for (const auto& request : { active, pending }) + if (request && request->id == id) request->cancelled.store(true); + notify(); + } + + void shutdown() + { + { + const std::lock_guard lock(mutex); + stopping = true; + if (active) active->cancelled.store(true); + if (pending) pending->cancelled.store(true); + } + notify(); + // Never force-terminate inside a decoder or allocator. Work cooperates. + stopThread(-1); + } + +private: + struct Request + { + Request(juce::String key, Work task, Completion done) + : id(std::move(key)), work(std::move(task)), completion(std::move(done)) {} + juce::String id; + Work work; + Completion completion; + std::atomic cancelled { false }; + }; + std::mutex mutex; + std::shared_ptr active, pending; + bool stopping = false; + + void run() override + { + for (;;) + { + std::shared_ptr request; + { + const std::lock_guard lock(mutex); + request = std::exchange(pending, {}); + active = request; + if (!request && stopping) return; + } + if (!request) { wait(-1); continue; } + auto result = request->cancelled.load() ? cancelledResult() : request->work(request->cancelled); + if (request->cancelled.load()) result = cancelledResult(); + { + const std::lock_guard lock(mutex); + active.reset(); + } + request->completion(std::move(result)); + } + } +}; diff --git a/Source/Main.cpp b/Source/Main.cpp index 6b71ad3c..9bce0e85 100644 --- a/Source/Main.cpp +++ b/Source/Main.cpp @@ -6,6 +6,7 @@ #include #include "ApplicationLaunchState.h" #include "AudioEngine.h" +#include "RecordingQualification.h" #include "CrashDiagnostics.h" #include "RuntimeSafetyRegression.h" #include "RecordingRecoveryRegression.h" @@ -84,6 +85,194 @@ juce::String getCommandLineOptionValue(const juce::String& commandLine, const ju return {}; } +// Opt-in qualification only. No timer or file work in ordinary application runs. +class RealtimeQualificationReporter final : private juce::Timer +{ +public: + RealtimeQualificationReporter(AudioEngine& engineToRead, juce::File reportToWrite, + juce::File callbackTraceToWrite = {}, bool threadCycles = false) + : engine(engineToRead), report(std::move(reportToWrite)) + { + report.getParentDirectory().createDirectory(); + if (callbackTraceToWrite != juce::File()) + { + callbackTraceFile = std::move(callbackTraceToWrite); + if (callbackTraceFile == report + || (!callbackTraceFile.hasFileExtension("ndjson") && !callbackTraceFile.hasFileExtension("jsonl"))) + juce::Logger::writeToLog("Paired callback trace ignored: use a separate .ndjson or .jsonl sidecar."); + else if (callbackTraceFile.getParentDirectory().createDirectory()) + { + callbackTraceOutput = callbackTraceFile.createOutputStream(); + if (callbackTraceOutput != nullptr && callbackTraceOutput->openedOk()) + { + callbackTraceSessionId = juce::Uuid().toString(); + callbackTraceCyclesRequested = threadCycles; + callbackTraceBatch.resize(RealtimeCallbackTrace::capacity); + auto* header = new juce::DynamicObject(); + header->setProperty("kind", "callback_trace_header"); + header->setProperty("schemaVersion", 1); + header->setProperty("classification", "diagnostic_only"); + header->setProperty("traceSessionId", callbackTraceSessionId); + header->setProperty("capturedAtMilliseconds", static_cast(juce::Time::currentTimeMillis())); + header->setProperty("startWallTimeMs", juce::Time::getMillisecondCounterHiRes()); + header->setProperty("sampleIntervalCallbacks", static_cast(RealtimeCallbackTrace::sampleInterval)); + header->setProperty("capturesAllDeadlineMisses", true); + header->setProperty("qpcTicksPerSecond", juce::Time::getHighResolutionTicksPerSecond()); + header->setProperty("timingScope", "AudioEngine entry through processing; excludes caller/ASIO wrapper and telemetry publication"); + header->setProperty("queueCapacity", static_cast(RealtimeCallbackTrace::capacity)); + juce::Array names; + for (const auto* name : { "playback", "tracksFXRouting", "masterMonitoring", "meteringSync", "callbackOverhead" }) names.add(name); + header->setProperty("stageNames", names); + header->setProperty("threadCyclesRequested", threadCycles); + header->setProperty("wallTimingScope", "AudioEngine entry includes callback prelude and optional thread queries; caller/ASIO work and telemetry/queue publication after the end timestamp are excluded."); + header->setProperty("threadCycleScope", "Raw QueryThreadCycleTime delta only. Do not convert to elapsed CPU time or assert descheduling: CPU frequency and processor migration affect interpretation."); + header->setProperty("perturbation", "Opt-in diagnostic instrumentation; acceptance must be repeated with tracing disabled."); + if (writeCallbackTraceObject(juce::var(header))) + { + callbackTraceOutput->flush(); + if (callbackTraceOutput->getStatus().wasOk()) + engine.setRealtimeCallbackTraceEnabled(true, threadCycles); + } + if (!engine.getRealtimeCallbackTraceStats().enabled) + disableCallbackTraceAfterWriteFailure(); + } + else juce::Logger::writeToLog("Paired callback trace could not open: " + callbackTraceFile.getFullPathName()); + } + } + timerCallback(); + startTimer(1000); + } + ~RealtimeQualificationReporter() override + { + stopTimer(); + engine.setRealtimeCallbackTraceEnabled(false); + timerCallback(); + if (callbackTraceOutput != nullptr) callbackTraceOutput->flush(); + } +private: + bool writeCallbackTraceObject(const juce::var& object) + { + if (callbackTraceOutput == nullptr) return false; + const auto json = juce::JSON::toString(object, true); + return callbackTraceOutput->write(json.toRawUTF8(), json.getNumBytesAsUTF8()) + && callbackTraceOutput->write("\n", 1); + } + void disableCallbackTraceAfterWriteFailure() + { + engine.setRealtimeCallbackTraceEnabled(false); + ++callbackTraceWriteFailures; + juce::Logger::writeToLog("Paired callback trace disabled after output failure: " + callbackTraceFile.getFullPathName()); + callbackTraceOutput.reset(); + } + void drainCallbackTrace() + { + if (callbackTraceOutput == nullptr) return; + const auto count = engine.drainRealtimeCallbackTrace(callbackTraceBatch.data(), callbackTraceBatch.size()); + for (std::size_t index = 0; index < count; ++index) + { + const auto& event = callbackTraceBatch[index]; + auto* object = new juce::DynamicObject(); + object->setProperty("kind", "callback"); + object->setProperty("classification", "diagnostic_only"); + object->setProperty("traceSessionId", callbackTraceSessionId); + object->setProperty("callbackCounter", static_cast(event.callbackCounter)); + object->setProperty("startSamplePosition", static_cast(event.startSamplePosition)); + object->setProperty("startQpcTicks", static_cast(event.startQpcTicks)); + object->setProperty("endQpcTicks", static_cast(event.endQpcTicks)); + object->setProperty("startWallTimeMs", event.startWallTimeMs); + object->setProperty("measuredWallMs", event.measuredWallMs); + object->setProperty("budgetMs", event.budgetMs); + object->setProperty("sampleRate", event.sampleRate); + object->setProperty("blockSize", event.blockSize); + object->setProperty("playing", event.playing); + object->setProperty("recording", event.recording); + object->setProperty("deadlineMiss", event.budgetMs > 0.0 && event.measuredWallMs > event.budgetMs); + object->setProperty("stageTimingsAvailable", event.stageTimingsAvailable); + juce::Array stages; + for (const auto duration : event.stageMs) stages.add(duration); + object->setProperty("stageMs", stages); + object->setProperty("threadId", static_cast(event.threadId)); + object->setProperty("processorIdsAvailable", event.processorIdsAvailable); + object->setProperty("processorGroupStart", static_cast(event.processorGroupStart)); + object->setProperty("processorNumberStart", static_cast(event.processorNumberStart)); + object->setProperty("processorGroupEnd", static_cast(event.processorGroupEnd)); + object->setProperty("processorNumberEnd", static_cast(event.processorNumberEnd)); + object->setProperty("threadCyclesRequested", event.threadCyclesRequested); + object->setProperty("threadCyclesValid", event.threadCyclesValid); + object->setProperty("threadCycleDelta", static_cast(event.threadCycleDelta)); + if (!writeCallbackTraceObject(juce::var(object))) + { + // Earlier records in this drained batch can still be buffered + // when a later write fails; retain a conservative loss bound. + callbackTracePossiblyUnwritten += static_cast(count); + disableCallbackTraceAfterWriteFailure(); + return; + } + ++callbackTraceWrittenSamples; + } + const auto stats = engine.getRealtimeCallbackTraceStats(); + auto* batch = new juce::DynamicObject(); + batch->setProperty("kind", "callback_trace_batch"); + batch->setProperty("classification", "diagnostic_only"); + batch->setProperty("traceSessionId", callbackTraceSessionId); + batch->setProperty("capturedAtMilliseconds", static_cast(juce::Time::currentTimeMillis())); + batch->setProperty("startWallTimeMs", juce::Time::getMillisecondCounterHiRes()); + batch->setProperty("batchSamples", static_cast(count)); + batch->setProperty("published", static_cast(stats.published)); + batch->setProperty("consumed", static_cast(stats.consumed)); + batch->setProperty("pending", static_cast(stats.pending)); + batch->setProperty("droppedFull", static_cast(stats.droppedFull)); + batch->setProperty("enabled", stats.enabled); + if (!writeCallbackTraceObject(juce::var(batch))) + { + callbackTracePossiblyUnwritten += static_cast(count); + disableCallbackTraceAfterWriteFailure(); + return; + } + callbackTraceOutput->flush(); + if (callbackTraceOutput->getStatus().failed()) + { + callbackTracePossiblyUnwritten += static_cast(count); + disableCallbackTraceAfterWriteFailure(); + } + } + void timerCallback() override + { + drainCallbackTrace(); + auto* data = new juce::DynamicObject(); + data->setProperty("capturedAtMilliseconds", static_cast(juce::Time::currentTimeMillis())); + data->setProperty("telemetry", engine.getRealtimeAudioTelemetry()); + data->setProperty("audioDebugSnapshot", engine.getAudioDebugSnapshot()); + const auto trace = engine.getRealtimeCallbackTraceStats(); + auto* traceStatus = new juce::DynamicObject(); + traceStatus->setProperty("enabled", trace.enabled); + traceStatus->setProperty("classification", "diagnostic_only"); + traceStatus->setProperty("filePath", callbackTraceFile.getFullPathName()); + traceStatus->setProperty("threadCyclesRequested", callbackTraceCyclesRequested); + traceStatus->setProperty("published", static_cast(trace.published)); + traceStatus->setProperty("consumed", static_cast(trace.consumed)); + traceStatus->setProperty("pending", static_cast(trace.pending)); + traceStatus->setProperty("droppedFull", static_cast(trace.droppedFull)); + traceStatus->setProperty("writtenSamples", static_cast(callbackTraceWrittenSamples)); + traceStatus->setProperty("possiblyUnwrittenSamples", static_cast(callbackTracePossiblyUnwritten)); + traceStatus->setProperty("writeFailures", callbackTraceWriteFailures); + data->setProperty("callbackTrace", juce::var(traceStatus)); + data->setProperty("subjectiveAudioQuality", "not_asserted"); + juce::TemporaryFile temporary(report); + if (temporary.getFile().replaceWithText(juce::JSON::toString(juce::var(data)))) + temporary.overwriteTargetFileWithTemporary(); + } + AudioEngine& engine; + juce::File report; + juce::File callbackTraceFile; + juce::String callbackTraceSessionId; + std::unique_ptr callbackTraceOutput; + std::vector callbackTraceBatch; + juce::uint64 callbackTraceWrittenSamples = 0, callbackTracePossiblyUnwritten = 0; + int callbackTraceWriteFailures = 0; + bool callbackTraceCyclesRequested = false; +}; + juce::File getWritableStartupLogFile() { auto logDir = juce::File::getSpecialLocation(juce::File::userApplicationDataDirectory) @@ -1396,6 +1585,8 @@ class OpenStudioApplication : public juce::JUCEApplication const auto startupSelfTestMode = commandLineHasFlag(commandLine, "--startup-self-test"); const auto automatedRegressionHeadlessMode = commandLineHasFlag(commandLine, "--automated-regression-headless"); const auto renderExportRegressionHeadlessMode = commandLineHasFlag(commandLine, "--render-export-regression-headless"); + const auto projectQualificationPath = getCommandLineOptionValue(commandLine, "--qualify-project-headless"); + const auto recordingQualificationPath = getCommandLineOptionValue(commandLine, "--qualify-recording-headless"); const auto startupSelfTestReportPath = getCommandLineOptionValue(commandLine, "--report"); const auto pitchRegressionHeadlessJobPath = getCommandLineOptionValue(commandLine, "--pitch-regression-headless"); const auto pitchRegressionJobPath = getCommandLineOptionValue(commandLine, "--pitch-regression"); @@ -1414,6 +1605,8 @@ class OpenStudioApplication : public juce::JUCEApplication && commandLineHasFlag(commandLine, "--window-lifecycle-offline"); ScopedHeadlessRegressionMessages headlessMessages( automatedRegressionHeadlessMode || renderExportRegressionHeadlessMode + || projectQualificationPath.isNotEmpty() + || recordingQualificationPath.isNotEmpty() || pitchRegressionHeadlessJobPath.isNotEmpty() || cleanGuitarRegressionReportPath.isNotEmpty() || namRackRegressionReportPath.isNotEmpty() @@ -1598,7 +1791,7 @@ class OpenStudioApplication : public juce::JUCEApplication const bool focusedNAMOffline = namRackRegressionReportPath.isNotEmpty() && (juce::SystemStats::getEnvironmentVariable("OPENSTUDIO_NAM_FUZZ_ONLY", {}) == "1" || juce::SystemStats::getEnvironmentVariable("OPENSTUDIO_NAM_TWO_RACK_CPU_ONLY", {}) == "1"); - audioEngine = std::make_unique(focusedNAMOffline || windowLifecycleOffline || renderExportRegressionHeadlessMode + audioEngine = std::make_unique(focusedNAMOffline || windowLifecycleOffline || renderExportRegressionHeadlessMode || projectQualificationPath.isNotEmpty() || recordingQualificationPath.isNotEmpty() ? AudioEngine::DeviceStartup::offline : AudioEngine::DeviceStartup::openHardware); audioEngine->onFXSlotsRemoved = [this](const juce::String& trackId, const juce::String& chain, int firstIndex) @@ -1618,6 +1811,30 @@ class OpenStudioApplication : public juce::JUCEApplication return; } + if (recordingQualificationPath.isNotEmpty()) + { + const auto secondsOption = getCommandLineOptionValue(commandLine, "--seconds"); + const auto result = runRecordingQualification(juce::File(recordingQualificationPath), + juce::File(headlessOutputDirectoryPath), secondsOption.isEmpty() ? 130.0 : secondsOption.getDoubleValue()); + const bool written = startupSelfTestReportPath.isNotEmpty() + && writeHeadlessResult(juce::File(startupSelfTestReportPath), result); + setApplicationReturnValue(written && result["objectiveGateStatus"].toString() == "pass" ? 0 : 2); + quit(); + return; + } + if (projectQualificationPath.isNotEmpty()) + { + const auto outputDirectory = juce::File(headlessOutputDirectoryPath.trim().unquoted()); + const auto payloadFile = juce::File(getCommandLineOptionValue(commandLine, "--project-qualification-payload").trim().unquoted()); + const auto result = audioEngine->runProjectQualification(juce::File(projectQualificationPath.trim().unquoted()), + payloadFile, outputDirectory); + const bool written = startupSelfTestReportPath.isNotEmpty() + && writeHeadlessResult(juce::File(startupSelfTestReportPath.trim().unquoted()), result); + setApplicationReturnValue(written && result["objectiveGateStatus"].toString() == "pass" ? 0 : 2); + quit(); + return; + } + if (renderExportRegressionHeadlessMode) { const auto outputDirectory = headlessOutputDirectoryPath.isNotEmpty() @@ -1728,6 +1945,15 @@ class OpenStudioApplication : public juce::JUCEApplication juce::Logger::writeToLog("MainWindow Created."); + const auto telemetryReport = getCommandLineOptionValue(commandLine, "--realtime-telemetry-report"); + const auto callbackTracePath = getCommandLineOptionValue(commandLine, "--realtime-callback-trace"); + if (telemetryReport.isNotEmpty()) + qualificationReporter = std::make_unique(*audioEngine, juce::File(telemetryReport), + callbackTracePath.isEmpty() ? juce::File() : juce::File::getCurrentWorkingDirectory().getChildFile(callbackTracePath), + commandLineHasFlag(commandLine, "--realtime-thread-cycles")); + else if (callbackTracePath.isNotEmpty()) + juce::Logger::writeToLog("Paired callback trace ignored: --realtime-telemetry-report is required for its sole consumer."); + if (windowLifecycleHarnessMode) { const auto reportFile = startupSelfTestReportPath.isNotEmpty() @@ -1745,6 +1971,7 @@ class OpenStudioApplication : public juce::JUCEApplication void shutdown() override { + qualificationReporter.reset(); if (audioEngine != nullptr) OpenStudioCrashDiagnostics::beginFinalShutdown(); juce::Logger::writeToLog("Application Check-out."); @@ -3146,6 +3373,7 @@ class OpenStudioApplication : public juce::JUCEApplication std::unique_ptr startupLogger; std::unique_ptr audioEngine; + std::unique_ptr qualificationReporter; AppUpdater appUpdater; std::unique_ptr storeQueryWindow; MainComponent::StartupMode startupMode = MainComponent::StartupMode::normal; diff --git a/Source/MainComponent.cpp b/Source/MainComponent.cpp index cbe17a57..13d069f9 100644 --- a/Source/MainComponent.cpp +++ b/Source/MainComponent.cpp @@ -14,11 +14,13 @@ #include "ApplicationLaunchState.h" #include "FFmpegLocator.h" #include "AudioFileConversion.h" +#include "ProjectRateCopy.h" #include "RecordingRecovery.h" #include "JsonEnvelope.h" #include "NAMModelSafety.h" #include "OwnedChildProcess.h" #include "RecordingWriterSafety.h" +#include "PackedWaveformPeaks.h" #include #include #include @@ -9859,11 +9861,24 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, .withNativeFunction ("setTransportStateWithToken", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { if (args.size() != 3 || !args[2].isString() || args[2].toString().length() > 128) { completion(false); return; } const auto action = args[0].toString(); - if (action != "position" && action != "playing" && action != "recording") { completion(false); return; } + if (action != "position" && action != "playing" && action != "recording" && action != "preparedRecording") { completion(false); return; } if (action == "position" && !std::isfinite(static_cast(args[1]))) { completion(false); return; } const auto token = args[2].toString(); cancelAutomationEditorFlush(); const auto sequence = ++transportRequestSequence; + if (action == "preparedRecording") + { + if (!static_cast(args[1])) { completion(false); return; } + const juce::Component::SafePointer safe(this); + audioEngine.startPreparedRecordingAsync( + [safe, sequence, token, completion = std::move(completion)](bool success) mutable { + if (!safe) { completion(false); return; } + if (success && safe->transportRequestSequence == sequence) safe->transportRequestToken = token; + completion(success); + }, + [safe, sequence] { return safe && safe->transportRequestSequence == sequence; }); + return; + } if (action == "recording") { const juce::Component::SafePointer safe(this); @@ -11056,6 +11071,39 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, completion(clipArray); }) // Waveform Visualization + .withNativeFunction ("cancelClipTempoAnalysis", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (!args.isEmpty()) tempoAnalysisQueue.cancel(args[0].toString()); + completion(true); + }) + .withNativeFunction ("analyzeClipTempo", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + const auto trackId = args[0].toString(), clipId = args[1].toString(); + const double rangeStart = args.size() > 2 ? static_cast(args[2]) : 0.0; + const double rangeDuration = args.size() > 3 ? static_cast(args[3]) : -1.0; + const auto source = args.size() > 4 ? args[4].clone() : juce::var(); + const auto requestId = args.size() > 5 ? args[5].toString() : juce::Uuid().toString(); + juce::Component::SafePointer safeThis(this); + tempoAnalysisQueue.submit(requestId, + [safeThis, trackId, clipId, rangeStart, rangeDuration, source](const std::atomic& cancelled) { + if (safeThis == nullptr) return LatestAnalysisQueue::cancelledResult(); + return safeThis->audioEngine.analyzeClipTempo(trackId, clipId, rangeStart, rangeDuration, source, &cancelled); + }, + [safeThis, completion](juce::var result) { + juce::MessageManager::callAsync([safeThis, result, completion]() mutable { if (safeThis != nullptr) completion(result); }); + }); + }) + .withNativeFunction ("getClipTempoPreviewData", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() < 2) { completion(juce::var()); return; } + const auto trackId = args[0].toString(), clipId = args[1].toString(); + const double start = args.size() > 2 ? static_cast(args[2]) : 0.0; + const double duration = args.size() > 3 ? static_cast(args[3]) : 4.0; + const auto source = args.size() > 4 ? args[4].clone() : juce::var(); + juce::Component::SafePointer safeThis(this); + mediaPreviewPool.addJob([safeThis, trackId, clipId, start, duration, source, completion]() mutable { + if (safeThis == nullptr) return; + const auto result = safeThis->audioEngine.getClipTempoPreviewData(trackId, clipId, start, duration, source); + juce::MessageManager::callAsync([safeThis, result, completion]() mutable { if (safeThis != nullptr) completion(result); }); + }); + }) .withNativeFunction ("getWaveformPeaks", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { // args: [filePath, samplesPerPixel, startSample, numPixels] // Legacy 3-arg form also accepted (startSample=0) @@ -11064,11 +11112,54 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, int samplesPerPixel = args[1]; int startSample = (args.size() >= 4) ? static_cast(args[2]) : 0; int numPixels = (args.size() >= 4) ? static_cast(args[3]) : static_cast(args[2]); - completion(audioEngine.getWaveformPeaks(filePath, samplesPerPixel, startSample, numPixels)); + const auto rangeStart = args.size() > 4 ? static_cast(args[4]) : 0; + const auto rangeEnd = args.size() > 5 ? static_cast(args[5]) : -1; + if (rangeStart > 0 || rangeEnd >= 0) + { + // A clipped edge may need at most two small source + // reads. Keep that work off the WebView/message thread. + juce::Component::SafePointer safeThis(this); + mediaPreviewPool.addJob([safeThis, filePath, samplesPerPixel, startSample, numPixels, + rangeStart, rangeEnd, completion]() mutable { + if (safeThis == nullptr) return; + const auto result = safeThis->audioEngine.getWaveformPeaks(filePath, samplesPerPixel, + startSample, numPixels, rangeStart, rangeEnd); + juce::MessageManager::callAsync([safeThis, result, completion]() mutable { + if (safeThis != nullptr) completion(result); + }); + }); + } + else completion(audioEngine.getWaveformPeaks(filePath, samplesPerPixel, startSample, numPixels)); } else { completion(juce::Array()); } }) + .withNativeFunction ("getWaveformPeaksPacked", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() < 4 || ! args[0].isString()) + { + completion(PackedWaveformPeaks::encode(juce::var())); + return; + } + const auto filePath = args[0].toString(); + const int samplesPerPixel = args[1]; + const int startSample = args[2]; + const int numPixels = args[3]; + const auto rangeStart = args.size() > 4 ? static_cast(args[4]) : 0; + const auto rangeEnd = args.size() > 5 ? static_cast(args[5]) : -1; + juce::Component::SafePointer safeThis(this); + mediaPreviewPool.addJob([safeThis, filePath, samplesPerPixel, startSample, numPixels, + rangeStart, rangeEnd, completion]() mutable { + if (safeThis == nullptr) return; + // Cache lookup, clipped-edge reads, Float32 packing, and + // base64 conversion stay off the WebView message thread. + const auto packed = PackedWaveformPeaks::encode( + safeThis->audioEngine.getWaveformPeaks(filePath, samplesPerPixel, + startSample, numPixels, rangeStart, rangeEnd)); + juce::MessageManager::callAsync([safeThis, packed, completion]() mutable { + if (safeThis != nullptr) completion(packed); + }); + }); + }) .withNativeFunction ("getAudioPeakAmplitude", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { if (args.size() != 3 || ! args[0].isString() @@ -11128,7 +11219,7 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, }) // Playback clip management .withNativeFunction ("addPlaybackClip", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { - // Accepts 4-11 args: trackId, filePath, startTime, duration, [offset], [volumeDB], [fadeIn], [fadeOut], [clipId], [pitchCorrectionSourceFilePath], [pitchCorrectionSourceOffset] + // Optional final args: native fadeInCurve/fadeOutCurve (0..4). // Note: numeric args can be int or double depending on JS value serialization if (args.size() >= 4 && args[1].isString()) { juce::String trackId = args[0].toString(); @@ -11148,7 +11239,9 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, + " start=" + juce::String(startTime, 3) + " duration=" + juce::String(duration, 3)); audioEngine.addPlaybackClip(trackId, filePath, startTime, duration, offset, volumeDB, fadeIn, fadeOut, clipId, - pitchCorrectionSourceFilePath, pitchCorrectionSourceOffset); + pitchCorrectionSourceFilePath, pitchCorrectionSourceOffset, + args.size() > 11 ? static_cast(args[11]) : 0, + args.size() > 12 ? static_cast(args[12]) : 0); completion(true); } else { completion(false); @@ -13318,11 +13411,84 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, // Args: [filePath, midiTracks] // midiTracks is an array of { name, clips: [{ startTime, duration, events }] }. if (args.size() >= 2 && args[0].isString() && args[1].isArray()) { - completion(audioEngine.exportProjectMIDI(args[0].toString(), args[1], 120.0)); + completion(audioEngine.exportProjectMIDI(args[0].toString(), args[1], audioEngine.getTempo(), + args.size() > 2 ? args[2] : juce::var())); } else { completion(false); } }) + .withNativeFunction ("createProjectRateCopy", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() < 4 || !juce::File::isAbsolutePath(args[0].toString()) || args[3].toString().isEmpty()) + { completion(juce::var()); return; } + const auto source = juce::File(args[0].toString()); + const int rate = static_cast(args[1]); + const auto projectPath = args[2].toString(), jobId = args[3].toString(); + const auto originalPath = args.size() > 4 ? args[4].toString() : juce::String(); + const auto cache = projectPath.isNotEmpty() && juce::File::isAbsolutePath(projectPath) + ? juce::File(projectPath).getParentDirectory().getChildFile("Audio").getChildFile("RateCopies") + : juce::File::getSpecialLocation(juce::File::userApplicationDataDirectory).getChildFile("OpenStudio").getChildFile("AudioCache").getChildFile("RateCopies"); + auto active = std::make_shared>(true); + { + const juce::ScopedLock sl(projectRateCopyJobLock); + auto previous = projectRateCopyJobs.find(jobId); + if (previous != projectRateCopyJobs.end()) previous->second->store(false); + projectRateCopyJobs[jobId] = active; + } + juce::Component::SafePointer safeThis(this); + mediaOperations.addJob([safeThis, source, cache, rate, jobId, originalPath, active, alive = mediaOperations.token(), + completion = mediaOperations.guardCompletion(std::move(completion))]() { + const auto keepRunning = [alive, active] { return MessageThreadLifetime::accepts(alive) && active->load(); }; + auto result = ProjectRateCopy::create(source, cache, rate, keepRunning); + if (static_cast(result["success"]) && originalPath.isNotEmpty() && juce::File(originalPath) != source) + { + const auto original = ProjectRateCopy::create(juce::File(originalPath), cache, rate, keepRunning); + if (!static_cast(original["success"])) result = original; + else result.getDynamicObject()->setProperty("originalSourceFilePath", original["filePath"]); + } + if (static_cast(result["success"]) && !result.hasProperty("originalSourceFilePath")) + result.getDynamicObject()->setProperty("originalSourceFilePath", result["filePath"]); + juce::MessageManager::callAsync([safeThis, result, jobId, active, completion]() { + if (safeThis == nullptr) return; + { + const juce::ScopedLock sl(safeThis->projectRateCopyJobLock); + const auto job = safeThis->projectRateCopyJobs.find(jobId); + if (job != safeThis->projectRateCopyJobs.end() && job->second == active) safeThis->projectRateCopyJobs.erase(job); + } + (*completion)(result); + }); + }); + }) + .withNativeFunction ("cancelProjectRateCopy", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + const juce::ScopedLock sl(projectRateCopyJobLock); + const auto job = projectRateCopyJobs.find(args.isEmpty() ? juce::String() : args[0].toString()); + if (job != projectRateCopyJobs.end()) job->second->store(false); + completion(job != projectRateCopyJobs.end()); + }) + .withNativeFunction ("replacePlaybackClipSource", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() < 6) { completion(false); return; } + const auto trackId = args[0].toString(), clipId = args[1].toString(); + const auto expected = juce::File(args[2].toString()), replacement = juce::File(args[3].toString()), immutable = juce::File(args[4].toString()); + const double originalOffset = static_cast(args[5]); + juce::Component::SafePointer safeThis(this); + mediaOperations.addJob([safeThis, trackId, clipId, expected, replacement, immutable, originalOffset, alive = mediaOperations.token(), + completion = mediaOperations.guardCompletion(std::move(completion))]() { + if (safeThis == nullptr) return; + const auto publicationLease = [safeThis, alive, clipId]() -> std::shared_ptr { + if (safeThis == nullptr || !MessageThreadLifetime::accepts(alive)) return {}; + auto lease = std::make_shared(safeThis->pitchCorrectionJobLock); + if (safeThis->activePitchClipId == clipId) + { + ++safeThis->previewRenderGeneration; ++safeThis->noteRenderGeneration; ++safeThis->fullClipRenderGeneration; + safeThis->activePreviewRequestGroup = {}; safeThis->activeNoteRenderRequestGroup = {}; safeThis->activeFullClipRequestGroup = {}; + safeThis->activePitchClipId = {}; + } + return lease; + }; + const bool success = safeThis->audioEngine.getPlaybackEngine().replaceClipSourcePreservingTimeline(trackId, clipId, + expected, replacement, immutable, originalOffset, [alive] { return MessageThreadLifetime::accepts(alive); }, publicationLease); + juce::MessageManager::callAsync([completion, success] { (*completion)(success); }); + }); + }) .withNativeFunction ("convertAudioFile", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { // Args: [inputPath, outputPath, format, sampleRate, bitDepth, channels] if (args.size() >= 6 && args[0].isString() && args[1].isString() @@ -14522,13 +14688,14 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, } }) .withNativeFunction ("exportMIDIFile", [this] (const juce::Array& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { - if (args.size() == 2 && args[0].isString()) { + if (args.size() >= 2 && args.size() < 5 && args[0].isString()) { auto tracks = args[1]; if (args[1].isString()) tracks = juce::JSON::parse(args[1].toString()); completion(tracks.isArray() - && audioEngine.exportProjectMIDI(args[0].toString(), tracks, 120.0)); + && audioEngine.exportProjectMIDI(args[0].toString(), tracks, audioEngine.getTempo(), + args.size() > 2 ? args[2] : juce::var())); } else if (args.size() >= 5) { bool ok = audioEngine.exportMIDIFile( args[0].toString(), // trackId @@ -14585,7 +14752,8 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, dragDir.createDirectory(); auto outputFile = dragDir.getNonexistentChildFile(suggestedName, ".mid", false); - const bool ok = audioEngine.exportProjectMIDI(outputFile.getFullPathName(), tracks, 120.0); + const bool ok = audioEngine.exportProjectMIDI(outputFile.getFullPathName(), tracks, audioEngine.getTempo(), + args.size() > 2 ? args[2] : juce::var()); complete(ok, ok ? outputFile.getFullPathName() : juce::String(), ok ? juce::String() : "Failed to export MIDI drag file."); @@ -15059,6 +15227,7 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, juce::String trackId = args[0].toString(); juce::String clipId = args[1].toString(); juce::var notes = args[2]; + { const juce::ScopedLock jobLock(pitchCorrectionJobLock); activePitchClipId = clipId; } juce::var frames = (args.size() >= 4) ? args[3] : juce::var(); juce::String requestId = (args.size() >= 5) ? args[4].toString() : juce::String(); float globalFormantSemitones = (args.size() >= 6) @@ -16014,6 +16183,8 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, activeNoteRenderRequestGroup = {}; activeFullClipRequestGroup = {}; + activePitchClipId = {}; + auto& playbackEngine = audioEngine.getPlaybackEngine(); playbackEngine.clearAllPitchPreviewRoutes (clipId); playbackEngine.cancelDeferredClipAudioFile (clipId); @@ -16662,6 +16833,7 @@ MainComponent::~MainComponent() drain(noteRenderPool, "note rendering"); drain(fullClipHQPool, "full-clip rendering"); drain(polyAnalysisBridgePool, "polyphonic analysis"); + tempoAnalysisQueue.shutdown(); drain(mediaPreviewPool, "media preview"); OpenStudioCrashDiagnostics::recordBreadcrumb("shutdown_wait", "media render and analysis"); mediaOperations.shutdown(); diff --git a/Source/MainComponent.h b/Source/MainComponent.h index f370be6c..6ab80564 100644 --- a/Source/MainComponent.h +++ b/Source/MainComponent.h @@ -1,5 +1,6 @@ #include "ToneSearchRequest.h" #pragma once +#include "LatestAnalysisQueue.h" #include "ProjectFileStore.h" #include "RecoveryJournal.h" #include "OwnedBackgroundTasks.h" @@ -238,7 +239,10 @@ class MainComponent : public juce::Component, juce::ThreadPool noteRenderPool { 1 }; juce::ThreadPool fullClipHQPool { 1 }; juce::ThreadPool mediaPreviewPool { 2 }; + LatestAnalysisQueue tempoAnalysisQueue; OwnedBackgroundTasks mediaOperations; + juce::CriticalSection projectRateCopyJobLock; + std::map>> projectRateCopyJobs; OwnedBackgroundTasks aiPreflightOperations; // File jobs must finish before the WebView and completion owner disappear. juce::ThreadPool projectFilePool { 1 }; @@ -285,6 +289,7 @@ class MainComponent : public juce::Component, juce::Thread::Priority::low }; juce::CriticalSection pitchCorrectionJobLock; + juce::String activePitchClipId; juce::String activePreviewRequestGroup; juce::String activeNoteRenderRequestGroup; juce::String activeFullClipRequestGroup; diff --git a/Source/MixerGain.h b/Source/MixerGain.h new file mode 100644 index 00000000..db81aefe --- /dev/null +++ b/Source/MixerGain.h @@ -0,0 +1,13 @@ +#pragma once +#include + +namespace MixerGain +{ +// The UI persists a finite floor for project compatibility and displays it as +// -infinity. Apply the same silence contract to static, automated and offline gain. +constexpr float silenceDb = -60.0f; +inline float fromDecibels(float value) noexcept +{ + return juce::Decibels::decibelsToGain(value, silenceDb); +} +} diff --git a/Source/PackedWaveformPeaks.h b/Source/PackedWaveformPeaks.h new file mode 100644 index 00000000..1526c7ef --- /dev/null +++ b/Source/PackedWaveformPeaks.h @@ -0,0 +1,75 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace PackedWaveformPeaks +{ +inline constexpr int maximumChannels = 64; +inline constexpr int maximumPeaks = 32768; + +// Display data only. This preserves every stored Float32 bit without asking +// JUCE's message-thread JSON writer to decimal-format each individual peak. +// The byte order is explicit even on a big-endian native host. +inline juce::var encode(const juce::var& flat) +{ + auto* result = new juce::DynamicObject(); + juce::var payload(result); + result->setProperty("format", "openstudio.waveform.f32"); + result->setProperty("version", 1); + result->setProperty("byteOrder", "little"); + const auto fail = [&payload, result](const char* message) + { + result->setProperty("error", message); + return payload; + }; + const auto* values = flat.getArray(); + if (values == nullptr) + return fail("Waveform data is not an array."); + if (values->isEmpty()) + { + result->setProperty("channels", 0); + result->setProperty("peakCount", 0); + result->setProperty("byteLength", 0); + result->setProperty("data", ""); + return payload; + } + const auto& channelEntry = (*values)[0]; + const double channelValue = static_cast(channelEntry); + if ((! channelEntry.isInt() && ! channelEntry.isInt64() && ! channelEntry.isDouble()) + || ! std::isfinite(channelValue) || channelValue < 1.0 + || channelValue > maximumChannels || std::floor(channelValue) != channelValue) + return fail("Invalid waveform channel count."); + const auto channels = static_cast(channelValue); + const auto valueCount = values->size() - 1; + if (valueCount % (channels * 2) != 0 || valueCount / (channels * 2) > maximumPeaks) + return fail("Invalid waveform peak count."); + juce::MemoryBlock bytes(static_cast(valueCount) * sizeof(float)); + auto* destination = static_cast(bytes.getData()); + static_assert(sizeof(float) == 4 && std::numeric_limits::is_iec559, + "Packed waveform transport requires IEEE-754 Float32."); + for (int index = 0; index < valueCount; ++index) + { + const auto& value = (*values)[index + 1]; + if ((! value.isDouble() && ! value.isInt() && ! value.isInt64()) + || ! std::isfinite(static_cast(value))) + return fail("Non-finite waveform peak."); + const auto sample = static_cast(static_cast(value)); + if (! std::isfinite(sample)) + return fail("Waveform peak exceeds Float32 range."); + std::uint32_t bits = 0; + std::memcpy(&bits, &sample, sizeof(bits)); + for (int byte = 0; byte < 4; ++byte) + destination[static_cast(index) * 4 + static_cast(byte)] = + static_cast(bits >> (byte * 8)); + } + result->setProperty("channels", channels); + result->setProperty("peakCount", valueCount / (channels * 2)); + result->setProperty("byteLength", static_cast(bytes.getSize())); + result->setProperty("data", juce::Base64::toBase64(bytes.getData(), bytes.getSize())); + return payload; +} +} diff --git a/Source/PackedWaveformPeaksRegression.cpp b/Source/PackedWaveformPeaksRegression.cpp new file mode 100644 index 00000000..e21f923a --- /dev/null +++ b/Source/PackedWaveformPeaksRegression.cpp @@ -0,0 +1,130 @@ +#include "PackedWaveformPeaksRegression.h" +#include "PackedWaveformPeaks.h" +#include "PeakCache.h" +#include +#include + +namespace +{ +bool exactPackedFloatBits(const juce::var& flat, const juce::var& packed) +{ + const auto* values = flat.getArray(); + const auto* object = packed.getDynamicObject(); + if (values == nullptr || object == nullptr || object->hasProperty("error")) return false; + juce::MemoryOutputStream decoded; + if (!juce::Base64::convertFromBase64(decoded, object->getProperty("data").toString())) return false; + const auto expected = values->isEmpty() ? 0 : values->size() - 1; + if (decoded.getDataSize() != static_cast(expected) * 4 + || static_cast(object->getProperty("byteLength")) != expected * 4) return false; + const auto* bytes = static_cast(decoded.getData()); + for (int index = 0; index < expected; ++index) + { + const auto sample = static_cast(static_cast((*values)[index + 1])); + std::uint32_t expectedBits = 0; + std::memcpy(&expectedBits, &sample, sizeof(expectedBits)); + std::uint32_t actualBits = 0; + for (int byte = 0; byte < 4; ++byte) + actualBits |= static_cast(bytes[static_cast(index) * 4 + static_cast(byte)]) << (byte * 8); + if (actualBits != expectedBits) return false; + } + return true; +} +} + +juce::var runPackedWaveformPeaksRegression() +{ + auto* report = new juce::DynamicObject(); + juce::Array checks; + const auto check = [&checks](const char* name, bool pass) + { + auto* item = new juce::DynamicObject(); + item->setProperty("id", name); + item->setProperty("pass", pass); + item->setProperty("detail", "Lossless Float32 waveform transport, bounded metadata and native source-edge parity."); + checks.add(juce::var(item)); + }; + { + juce::Array values { 2 }; + for (const std::uint32_t bits : { 0x80000000u, 0u, 0x00000001u, 0x80000001u, + 0xff7fffffu, 0x7f7fffffu, 0xbe000000u, 0x3f400000u }) + { + float value = 0.0f; + std::memcpy(&value, &bits, sizeof(value)); + values.add(value); + } + const auto packed = PackedWaveformPeaks::encode(values); + check("waveform_packed_stereo_exact_float32_bits", exactPackedFloatBits(values, packed) + && static_cast(packed["channels"]) == 2 && static_cast(packed["peakCount"]) == 2 + && packed["byteOrder"].toString() == "little" && static_cast(packed["version"]) == 1); + } + { + const auto empty = PackedWaveformPeaks::encode(juce::Array()); + check("waveform_packed_empty_explicit", static_cast(empty["channels"]) == 0 + && static_cast(empty["peakCount"]) == 0 && exactPackedFloatBits(juce::Array(), empty)); + const auto badShape = PackedWaveformPeaks::encode(juce::Array { 2, 0.5f }); + const auto badChannel = PackedWaveformPeaks::encode(juce::Array { 65 }); + const auto badChannelType = PackedWaveformPeaks::encode(juce::Array { "1", 0.0f, 0.5f }); + const auto badValue = PackedWaveformPeaks::encode(juce::Array { 1, 0.0f, std::numeric_limits::infinity() }); + check("waveform_packed_invalid_input_reported", badShape.hasProperty("error") + && badChannel.hasProperty("error") && badChannelType.hasProperty("error") && badValue.hasProperty("error") + && PackedWaveformPeaks::encode(juce::var()).hasProperty("error")); + } + { + juce::TemporaryFile source(".wav"); + juce::AudioBuffer samples(2, 1537); + samples.clear(); + samples.setSample(0, 63, 1.0f); samples.setSample(0, 1499, 0.5f); samples.setSample(0, 1500, 1.0f); + samples.setSample(1, 63, -1.0f); samples.setSample(1, 1499, -0.25f); samples.setSample(1, 1500, -1.0f); + std::unique_ptr stream = source.getFile().createOutputStream(); + juce::WavAudioFormat wav; + auto writer = wav.createWriterFor(stream, juce::AudioFormatWriterOptions() + .withSampleRate(48000).withNumChannels(2).withBitsPerSample(32) + .withSampleFormat(juce::AudioFormatWriterOptions::SampleFormat::floatingPoint)); + bool ready = writer != nullptr && writer->writeFromAudioSampleBuffer(samples, 0, samples.getNumSamples()); + writer.reset(); + PeakCache cache; + ready = ready && cache.generateSync(source.getFile()); + const auto clipped = cache.getPeaks(source.getFile(), 1024, 0, 4096, 64, 1500); + const auto* values = clipped.getArray(); + check("waveform_packed_trim_edges_exact", ready && values != nullptr && values->size() == 9 + && static_cast((*values)[2]) == 0.0 && static_cast((*values)[6]) == 0.5 + && static_cast((*values)[7]) == -0.25 + && exactPackedFloatBits(clipped, PackedWaveformPeaks::encode(clipped))); + const auto tail = cache.getPeaks(source.getFile(), 4096, 0, 4096); + check("waveform_packed_partial_final_pixel_exact", ready && tail.getArray() != nullptr && tail.size() == 5 + && exactPackedFloatBits(tail, PackedWaveformPeaks::encode(tail))); + cache.invalidate(source.getFile()); + } + { + juce::Array values { 2 }; + for (int index = 0; index < 4096 * 4; ++index) + values.add(static_cast((index % 2047) - 1023) / 1024.0f); + const auto packed = PackedWaveformPeaks::encode(values); + const auto packingStart = juce::Time::getHighResolutionTicks(); + size_t encodedBytes = 0; + for (int repeat = 0; repeat < 8; ++repeat) + encodedBytes += PackedWaveformPeaks::encode(values)["data"].toString().getNumBytesAsUTF8(); + const auto oldStart = juce::Time::getHighResolutionTicks(); + size_t legacyBytes = 0; + for (int repeat = 0; repeat < 8; ++repeat) legacyBytes += juce::JSON::toString(values, true).getNumBytesAsUTF8(); + const auto oldEnd = juce::Time::getHighResolutionTicks(); + size_t packedBytes = 0; + for (int repeat = 0; repeat < 8; ++repeat) packedBytes += juce::JSON::toString(packed, true).getNumBytesAsUTF8(); + const auto packedEnd = juce::Time::getHighResolutionTicks(); + auto* benchmark = new juce::DynamicObject(); + benchmark->setProperty("classification", "diagnostic_only"); + benchmark->setProperty("tiles", 8); + benchmark->setProperty("peaksPerTile", 4096); + benchmark->setProperty("workerPackingMs", juce::Time::highResolutionTicksToSeconds(oldStart - packingStart) * 1000.0); + benchmark->setProperty("legacyJsonMs", juce::Time::highResolutionTicksToSeconds(oldEnd - oldStart) * 1000.0); + benchmark->setProperty("packedCompletionJsonMs", juce::Time::highResolutionTicksToSeconds(packedEnd - oldEnd) * 1000.0); + benchmark->setProperty("legacyJsonBytes", static_cast(legacyBytes)); + benchmark->setProperty("packedJsonBytes", static_cast(packedBytes)); + benchmark->setProperty("workerBase64Bytes", static_cast(encodedBytes)); + report->setProperty("benchmark", juce::var(benchmark)); + check("waveform_packed_full_tile_exact", exactPackedFloatBits(values, packed) + && static_cast(packed["peakCount"]) == 4096 && legacyBytes > 0 && packedBytes > 0); + } + report->setProperty("checks", checks); + return juce::var(report); +} diff --git a/Source/PackedWaveformPeaksRegression.h b/Source/PackedWaveformPeaksRegression.h new file mode 100644 index 00000000..ac2d1d8e --- /dev/null +++ b/Source/PackedWaveformPeaksRegression.h @@ -0,0 +1,4 @@ +#pragma once +#include + +juce::var runPackedWaveformPeaksRegression(); diff --git a/Source/PeakCache.cpp b/Source/PeakCache.cpp index ade2fb9d..7aeb6760 100644 --- a/Source/PeakCache.cpp +++ b/Source/PeakCache.cpp @@ -1,5 +1,6 @@ #include "PeakCache.h" #include +#include namespace { constexpr juce::int64 maxPeakDataBytes = 256 * 1024 * 1024; } @@ -73,105 +74,176 @@ bool PeakCache::hasCachedPeaks(const juce::File& audioFile) const void PeakCache::invalidate(const juce::File& audioFile) { const auto key = audioFile.getFullPathName(); - { - const juce::ScopedLock sl(cacheLock); - memoryCache.erase(key); + const juce::ScopedLock publication(publicationLock); + { + const juce::ScopedLock sl(cacheLock); + ++sourceGenerations[key]; + memoryCache.erase(key); + } + { + const juce::ScopedLock sl(pendingLock); + pendingGenerations.erase(key); + } + auto peakFile = getPeakFilePath(audioFile); + if (peakFile.existsAsFile()) peakFile.deleteFile(); + } + { + const juce::ScopedLock sl(boundaryLock); + boundaryPeaks.clear(); } +} + +PeakCache::SourceVersion PeakCache::captureSourceVersion(const juce::File& audioFile) const +{ + SourceVersion version; + version.key = audioFile.getFullPathName(); { - const juce::ScopedLock sl(pendingLock); - pendingGenerations.erase(key); + const juce::ScopedLock sl(cacheLock); + version.generation = sourceGenerations[version.key]; } + version.size = audioFile.getSize(); + version.modified = audioFile.getLastModificationTime().toMilliseconds(); + return version; +} - auto peakFile = getPeakFilePath(audioFile); - if (peakFile.existsAsFile()) - peakFile.deleteFile(); +bool PeakCache::isGenerationCurrent(const SourceVersion& version) const +{ + const juce::ScopedLock sl(cacheLock); + const auto found = sourceGenerations.find(version.key); + const auto generation = found == sourceGenerations.end() ? 0 : found->second; + return !stopping.load() && generation == version.generation; +} +bool PeakCache::sourceMatches(const juce::File& audioFile, const SourceVersion& version) +{ + return audioFile.existsAsFile() && audioFile.getSize() == version.size + && audioFile.getLastModificationTime().toMilliseconds() == version.modified; +} + +std::shared_ptr PeakCache::publishLoadedEntry( + const juce::File& audioFile, const SourceVersion& version, + std::shared_ptr loaded) const +{ + const juce::ScopedLock publication(publicationLock); + if (!isGenerationCurrent(version) || !sourceMatches(audioFile, version)) return {}; + const juce::ScopedLock sl(cacheLock); + // invalidate() uses publicationLock first, so generation cannot change + // between validation and insertion. Prefer a current entry already loaded. + auto& current = memoryCache[version.key]; + if (!current) current = std::move(loaded); + return current; } juce::var PeakCache::getPeaks(const juce::File& audioFile, - int samplesPerPixel, - int startSample, - int numPixels) const + int samplesPerPixel, int startSample, int numPixels, + juce::int64 sourceRangeStartSample, + juce::int64 sourceRangeEndSample) const { - if (samplesPerPixel < 1 || numPixels < 1 || numPixels > 32768) + if (samplesPerPixel < 1 || startSample < 0 || numPixels < 1 || numPixels > 32768) return juce::var(juce::Array()); - auto buildResult = [samplesPerPixel, startSample, numPixels](const CacheEntry& entry) - { - juce::Array peakData; - if (entry.levels.empty()) - return juce::var(peakData); - // Find the best mipmap level (closest stride <= samplesPerPixel) - const MipmapLevel* bestLevel = &entry.levels[0]; - for (const auto& level : entry.levels) + std::shared_ptr entry; + SourceVersion sourceVersion; + sourceVersion.key = audioFile.getFullPathName(); + { + const juce::ScopedLock sl(cacheLock); + sourceVersion.generation = sourceGenerations[sourceVersion.key]; + const auto found = memoryCache.find(sourceVersion.key); + if (found != memoryCache.end()) entry = found->second; + } + if (entry == nullptr) + { + auto loaded = std::make_shared(); + sourceVersion.size = audioFile.getSize(); + sourceVersion.modified = audioFile.getLastModificationTime().toMilliseconds(); + if (!loadFromFile(getPeakFilePath(audioFile), audioFile, *loaded)) + return juce::var(juce::Array()); + entry = publishLoadedEntry(audioFile, sourceVersion, std::move(loaded)); + if (!entry) return juce::var(juce::Array()); + } + juce::Array peaks; + if (entry->levels.empty()) return juce::var(peaks); + const auto rangeStart = juce::jlimit(0, entry->totalSamples, sourceRangeStartSample); + const auto rangeEnd = sourceRangeEndSample < 0 ? entry->totalSamples + : juce::jlimit(rangeStart, entry->totalSamples, sourceRangeEndSample); + if (startSample >= rangeEnd || rangeStart >= rangeEnd) return juce::var(peaks); + const MipmapLevel* level = &entry->levels.front(); + for (const auto& candidate : entry->levels) + if (candidate.stride <= samplesPerPixel) level = &candidate; + const auto pixelCount = juce::jmin(numPixels, + (rangeEnd - startSample + samplesPerPixel - 1) / samplesPerPixel); + const int channels = entry->numChannels; + peaks.ensureStorageAllocated(1 + static_cast(pixelCount) * channels * 2); + peaks.add(channels); + + // A trim can bisect a cached bucket. Decode only that bounded bucket on + // the worker and cache its retained aggregate; never include removed audio. + const auto clippedBucket = [&](juce::int64 first, juce::int64 last) + { + const auto key = audioFile.getFullPathName() + ":" + juce::String(sourceVersion.generation) + + ":" + juce::String(audioFile.getLastModificationTime().toMilliseconds()) + + ":" + juce::String(first) + ":" + juce::String(last); { - if (level.stride <= samplesPerPixel) - bestLevel = &level; + const juce::ScopedLock sl(boundaryLock); + const auto found = boundaryPeaks.find(key); + if (found != boundaryPeaks.end()) return found->second; } - - int ratio = samplesPerPixel / bestLevel->stride; - if (ratio < 1) ratio = 1; - - const int numChannels = bestLevel->numChannels; - const int startPeak = (startSample > 0) ? std::min(startSample / bestLevel->stride, bestLevel->numPeaks - 1) : 0; - const int remainingMipmapPeaks = std::max(0, bestLevel->numPeaks - startPeak); - const int actualPeaks = std::min(numPixels, remainingMipmapPeaks / std::max(1, ratio)); - - peakData.ensureStorageAllocated(1 + actualPeaks * numChannels * 2); - peakData.add(juce::var(numChannels)); - - const int stride2 = numChannels * 2; - for (int pixel = 0; pixel < actualPeaks; ++pixel) + std::vector result(static_cast(channels * 2), 0.0f); + juce::AudioFormatManager manager; + manager.registerBasicFormats(); + std::unique_ptr reader(manager.createReaderFor(audioFile)); + const int count = static_cast(last - first); // at most one mip bucket + juce::AudioBuffer samples(channels, count); + if (reader == nullptr || !reader->read(&samples, 0, count, first, true, true)) + return std::vector(); + for (int channel = 0; channel < channels; ++channel) { - const int srcStart = startPeak + pixel * ratio; - const int srcEnd = std::min(srcStart + ratio, bestLevel->numPeaks); - - for (int ch = 0; ch < numChannels; ++ch) - { - float minVal = 0.0f; - float maxVal = 0.0f; - - for (int s = srcStart; s < srcEnd; ++s) - { - const int dataIdx = s * stride2 + ch * 2; - if (dataIdx + 1 < static_cast(bestLevel->data.size())) - { - const float mn = bestLevel->data[static_cast(dataIdx)]; - const float mx = bestLevel->data[static_cast(dataIdx) + 1]; - if (s == srcStart || mn < minVal) minVal = mn; - if (s == srcStart || mx > maxVal) maxVal = mx; - } - } - - peakData.add(juce::var(minVal)); - peakData.add(juce::var(maxVal)); - } + const auto extrema = samples.findMinMax(channel, 0, count); + result[static_cast(channel * 2)] = extrema.getStart(); + result[static_cast(channel * 2 + 1)] = extrema.getEnd(); } - - return juce::var(peakData); + const juce::ScopedLock sl(boundaryLock); + if (boundaryPeaks.size() >= 256) boundaryPeaks.erase(boundaryPeaks.begin()); + boundaryPeaks[key] = result; + return result; }; - + std::map> edges; + for (juce::int64 pixel = 0; pixel < pixelCount; ++pixel) { - const juce::ScopedLock sl(cacheLock); - auto it = memoryCache.find(audioFile.getFullPathName()); - if (it != memoryCache.end()) - return buildResult(it->second); - } - - CacheEntry entry; - auto peakFile = getPeakFilePath(audioFile); - - if (!peakFile.existsAsFile() || !loadFromFile(peakFile, audioFile, entry)) - return juce::var(juce::Array()); - - auto result = buildResult(entry); - { - const juce::ScopedLock sl(cacheLock); - memoryCache[audioFile.getFullPathName()] = std::move(entry); + const auto pixelStart = static_cast(startSample) + pixel * samplesPerPixel; + const auto first = juce::jmax(pixelStart, rangeStart); + const auto last = juce::jmin(pixelStart + samplesPerPixel, rangeEnd); + const int firstBucket = static_cast(first / level->stride); + const int lastBucket = static_cast((last + level->stride - 1) / level->stride); + std::array result {}; + bool hasValues = false; + for (int bucket = firstBucket; first < last && bucket < lastBucket && bucket < level->numPeaks; ++bucket) + { + const auto bucketFirst = static_cast(bucket) * level->stride; + const auto bucketLast = juce::jmin(bucketFirst + level->stride, entry->totalSamples); + const auto retainedFirst = juce::jmax(bucketFirst, rangeStart); + const auto retainedLast = juce::jmin(bucketLast, rangeEnd); + const float* values = level->data.data() + static_cast(bucket) * channels * 2; + if (retainedFirst != bucketFirst || retainedLast != bucketLast) + { + auto found = edges.find(bucket); + if (found == edges.end()) found = edges.emplace(bucket, clippedBucket(retainedFirst, retainedLast)).first; + if (found->second.empty()) return juce::var(juce::Array()); + values = found->second.data(); + } + for (int channel = 0; channel < channels; ++channel) + { + const auto index = static_cast(channel * 2); + result[index] = hasValues ? juce::jmin(result[index], values[index]) : values[index]; + result[index + 1] = hasValues ? juce::jmax(result[index + 1], values[index + 1]) : values[index + 1]; + } + hasValues = true; + } + for (int value = 0; value < channels * 2; ++value) peaks.add(result[static_cast(value)]); } - return result; + return juce::var(peaks); } bool PeakCache::loadFromFile(const juce::File& peakFile, const juce::File& audioFile, CacheEntry& entry) const @@ -235,9 +307,14 @@ bool PeakCache::writeToFile(const juce::File& peakFile, const CacheEntry& entry, int64_t sourceFileSize, int64_t sourceModTimeMs) { juce::TemporaryFile temporary(peakFile); - bool finalized = false; - { - juce::FileOutputStream fos(temporary.getFile()); + return writeStagedFile(temporary.getFile(), entry, sourceFileSize, sourceModTimeMs) + && temporary.overwriteTargetFileWithTemporary(); +} + +bool PeakCache::writeStagedFile(const juce::File& stagingFile, const CacheEntry& entry, + int64_t sourceFileSize, int64_t sourceModTimeMs) +{ + juce::FileOutputStream fos(stagingFile); if (!fos.openedOk()) return false; @@ -266,9 +343,7 @@ bool PeakCache::writeToFile(const juce::File& peakFile, const CacheEntry& entry, } fos.flush(); - finalized = fos.getStatus().wasOk(); - } - return finalized && temporary.overwriteTargetFileWithTemporary(); + return fos.getStatus().wasOk(); } bool PeakCache::buildPeaks(const juce::File& audioFile, CacheEntry& entry) @@ -417,33 +492,66 @@ bool PeakCache::buildPeaks(const juce::File& audioFile, CacheEntry& entry) bool PeakCache::generateSync(const juce::File& audioFile) { + return generateSyncForVersion(audioFile, captureSourceVersion(audioFile)); +} + +bool PeakCache::generateSyncForVersion(const juce::File& audioFile, const SourceVersion& version) +{ + if (!isGenerationCurrent(version) || !sourceMatches(audioFile, version)) return false; CacheEntry entry; if (!buildPeaks(audioFile, entry) || stopping.load()) return false; + if (!isGenerationCurrent(version) || !sourceMatches(audioFile, version)) return false; + juce::TemporaryFile temporary(getPeakFilePath(audioFile)); + const bool staged = writeStagedFile(temporary.getFile(), entry, version.size, version.modified); + const bool written = publishGeneratedEntry(audioFile, version, + std::make_shared(std::move(entry)), temporary, staged); + if (written) + juce::Logger::writeToLog("PeakCache: Generated peaks for " + audioFile.getFileName()); - // Write to disk - auto peakFile = getPeakFilePath(audioFile); - bool written = writeToFile(peakFile, entry, - audioFile.getSize(), - audioFile.getLastModificationTime().toMilliseconds()); + return written; +} - // Store in memory cache +bool PeakCache::publishGeneratedEntry(const juce::File& audioFile, const SourceVersion& version, + std::shared_ptr entry, juce::TemporaryFile& temporary, bool stagedReady) +{ + const juce::ScopedLock publication(publicationLock); + if (!isGenerationCurrent(version) || !sourceMatches(audioFile, version)) return false; + const bool written = stagedReady && temporary.overwriteTargetFileWithTemporary(); { const juce::ScopedLock sl(cacheLock); - memoryCache[audioFile.getFullPathName()] = std::move(entry); + memoryCache[version.key] = std::move(entry); } + return written; +} - if (written) - juce::Logger::writeToLog("PeakCache: Generated peaks for " + audioFile.getFileName()); +bool PeakCache::claimPendingGeneration(const SourceVersion& version) +{ + // Validate and claim as one publication transaction. An old request that + // paused before enqueue must not replace a newer generation's ownership. + const juce::ScopedLock publication(publicationLock); + if (!isGenerationCurrent(version)) return false; + const juce::ScopedLock sl(pendingLock); + const auto found = pendingGenerations.find(version.key); + if (found != pendingGenerations.end() && found->second == version.generation) return false; + pendingGenerations[version.key] = version.generation; + return true; +} - return written; +void PeakCache::finishPendingGeneration(const SourceVersion& version) +{ + const juce::ScopedLock sl(pendingLock); + const auto found = pendingGenerations.find(version.key); + if (found != pendingGenerations.end() && found->second == version.generation) + pendingGenerations.erase(found); } void PeakCache::generateAsync(const juce::File& audioFile, std::function onComplete) { if (stopping.load()) return; - const auto complete = [alive = completions.token(), onComplete] { - if (MessageThreadLifetime::accepts(alive) && onComplete) onComplete(); + const auto version = captureSourceVersion(audioFile); + const auto complete = [this, alive = completions.token(), version, onComplete] { + if (MessageThreadLifetime::accepts(alive) && isGenerationCurrent(version) && onComplete) onComplete(); }; // If already cached, call completion immediately if (hasCachedPeaks(audioFile)) @@ -453,24 +561,14 @@ void PeakCache::generateAsync(const juce::File& audioFile, std::function return; } - // Guard: don't queue duplicate generation jobs for the same file - { - const juce::ScopedLock sl (pendingLock); - auto key = audioFile.getFullPathName(); - if (pendingGenerations.count(key) > 0) - return; // Already being generated - pendingGenerations.insert(key); - } + if (!claimPendingGeneration(version)) return; // Queue background generation - backgroundPool.addJob([this, audioFile, complete]() + backgroundPool.addJob([this, audioFile, version, complete]() { - generateSync(audioFile); + generateSyncForVersion(audioFile, version); - { - const juce::ScopedLock sl (pendingLock); - pendingGenerations.erase(audioFile.getFullPathName()); - } + finishPendingGeneration(version); juce::MessageManager::callAsync(complete); }); diff --git a/Source/PeakCache.h b/Source/PeakCache.h index ed6f6c40..2d328301 100644 --- a/Source/PeakCache.h +++ b/Source/PeakCache.h @@ -38,7 +38,9 @@ class PeakCache juce::var getPeaks(const juce::File& audioFile, int samplesPerPixel, int startSample, - int numPixels) const; + int numPixels, + juce::int64 sourceRangeStartSample = 0, + juce::int64 sourceRangeEndSample = -1) const; /** Check if a valid, up-to-date cache exists for this audio file. */ bool hasCachedPeaks(const juce::File& audioFile) const; @@ -101,6 +103,24 @@ class PeakCache std::vector levels; }; + struct SourceVersion + { + juce::String key; + juce::uint64 generation = 0; + juce::int64 size = 0, modified = 0; + }; + + SourceVersion captureSourceVersion(const juce::File&) const; + bool isGenerationCurrent(const SourceVersion&) const; + static bool sourceMatches(const juce::File&, const SourceVersion&); + std::shared_ptr publishLoadedEntry(const juce::File&, const SourceVersion&, + std::shared_ptr) const; + bool generateSyncForVersion(const juce::File&, const SourceVersion&); + bool claimPendingGeneration(const SourceVersion&); + void finishPendingGeneration(const SourceVersion&); + bool publishGeneratedEntry(const juce::File&, const SourceVersion&, + std::shared_ptr, juce::TemporaryFile&, bool stagedReady); + // Get the .ospeaks file path for an audio file static juce::File getPeakFilePath(const juce::File& audioFile); @@ -110,19 +130,28 @@ class PeakCache // Write peak data to a peak cache file static bool writeToFile(const juce::File& peakFile, const CacheEntry& entry, int64_t sourceFileSize, int64_t sourceModTimeMs); + static bool writeStagedFile(const juce::File&, const CacheEntry&, int64_t, int64_t); // Generate all mipmap levels from an audio file bool buildPeaks(const juce::File& audioFile, CacheEntry& entry); std::atomic stopping { false }; MessageThreadLifetime completions; friend class RuntimeSafetyRegression; + friend struct PeakCacheGenerationRegressionAccess; // In-memory cache: audioFilePath -> CacheEntry - mutable std::map memoryCache; + mutable std::map> memoryCache; + mutable std::map sourceGenerations; mutable juce::CriticalSection cacheLock; + // Only final sidecar rename/delete and brief publication checks share this + // lock. Full-file scanning, staging writes and reads never hold cacheLock. + mutable juce::CriticalSection publicationLock; + // Small immutable edge aggregates, built only by clipped-request workers. + mutable std::map> boundaryPeaks; + mutable juce::CriticalSection boundaryLock; // Track files currently being generated to prevent duplicate jobs - std::set pendingGenerations; + std::map pendingGenerations; juce::CriticalSection pendingLock; // Keep waveform generation serialized and low-impact; concurrent full-file diff --git a/Source/PeakCacheGenerationRegression.cpp b/Source/PeakCacheGenerationRegression.cpp new file mode 100644 index 00000000..e71e2393 --- /dev/null +++ b/Source/PeakCacheGenerationRegression.cpp @@ -0,0 +1,152 @@ +#include "PeakCacheGenerationRegression.h" +#include "PeakCache.h" + +namespace +{ +bool writeSource(const juce::File& file, float value, int frames = 2048) +{ + juce::AudioBuffer samples(1, frames); + juce::FloatVectorOperations::fill(samples.getWritePointer(0), value, frames); + auto output = file.createOutputStream(); + if (!output) return false; + output->setPosition(0); + output->truncate(); + std::unique_ptr stream(std::move(output)); + juce::WavAudioFormat format; + auto writer = format.createWriterFor(stream, juce::AudioFormatWriterOptions() + .withSampleRate(48000).withNumChannels(1).withBitsPerSample(32) + .withSampleFormat(juce::AudioFormatWriterOptions::SampleFormat::floatingPoint)); + return writer && writer->writeFromAudioSampleBuffer(samples, 0, frames); +} + +bool peakEquals(const juce::var& peaks, float value) +{ + const auto* values = peaks.getArray(); + return values && values->size() >= 3 + && static_cast((*values)[1]) == value && static_cast((*values)[2]) == value; +} +} + +struct PeakCacheGenerationRegressionAccess +{ + static juce::var run() + { + juce::Array checks; + const auto check = [&checks](const char* id, bool pass, const char* detail) + { + auto* item = new juce::DynamicObject(); + item->setProperty("id", id); + item->setProperty("pass", pass); + item->setProperty("detail", detail); + checks.add(juce::var(item)); + }; + juce::TemporaryFile source(".wav"); + const auto& audio = source.getFile(); + PeakCache cache; + const auto sidecar = PeakCache::getPeakFilePath(audio); + bool ready = writeSource(audio, 0.25f) && cache.generateSync(audio); + const auto oldVersion = cache.captureSourceVersion(audio); + auto oldEntry = std::make_shared(); + ready = ready && cache.loadFromFile(sidecar, audio, *oldEntry); + juce::TemporaryFile oldStage(sidecar); + ready = ready && PeakCache::writeStagedFile(oldStage.getFile(), *oldEntry, + oldVersion.size, oldVersion.modified); + + // Pause an actual completed load and staged write, refresh the same + // unchanged source, then resume both old publication operations. + cache.invalidate(audio); + ready = ready && cache.generateSync(audio); + const auto currentVersion = cache.captureSourceVersion(audio); + const auto freshEntry = cache.memoryCache[audio.getFullPathName()]; + const auto rejected = cache.publishLoadedEntry(audio, oldVersion, oldEntry); + check("peak_cache_stale_completed_load_cannot_replace_refresh", ready && !rejected + && currentVersion.generation != oldVersion.generation + && cache.memoryCache[audio.getFullPathName()] == freshEntry + && peakEquals(cache.getPeaks(audio, 64, 0, 1), 0.25f), + "A completed sidecar load resumed after invalidation cannot overwrite the refreshed immutable entry, even when source metadata is unchanged."); + + juce::MemoryBlock before, after; + const bool captured = sidecar.loadFileAsData(before); + const bool staleWrite = cache.publishGeneratedEntry(audio, oldVersion, oldEntry, oldStage, true); + check("peak_cache_stale_staged_write_cannot_replace_sidecar", ready && captured && !staleWrite + && sidecar.loadFileAsData(after) && before == after + && cache.memoryCache[audio.getFullPathName()] == freshEntry, + "A completed old-generation staging write cannot replace either the current sidecar bytes or its memory entry."); + + check("peak_cache_queued_old_generation_rejected", ready + && !cache.generateSyncForVersion(audio, oldVersion) + && cache.memoryCache[audio.getFullPathName()] == freshEntry, + "Generation work uses its enqueue-time version and exits before scanning when that generation has been invalidated."); + + const bool newClaimed = cache.claimPendingGeneration(currentVersion); + const bool oldEnqueueRejected = !cache.claimPendingGeneration(oldVersion); + const auto pendingOwner = cache.pendingGenerations.find(currentVersion.key); + check("peak_cache_old_enqueue_cannot_replace_new_pending_owner", newClaimed && oldEnqueueRejected + && pendingOwner != cache.pendingGenerations.end() && pendingOwner->second == currentVersion.generation, + "An old request resumed before enqueue cannot replace a newer generation's singleflight ownership."); + cache.finishPendingGeneration(oldVersion); + const bool retained = cache.pendingGenerations.count(currentVersion.key) != 0; + cache.finishPendingGeneration(currentVersion); + check("peak_cache_old_job_cannot_erase_new_pending_owner", retained + && cache.pendingGenerations.count(currentVersion.key) == 0, + "Completing an old generation retains the newer generation's singleflight ownership; its own completion releases it."); + + juce::TemporaryFile changedStage(sidecar); + const bool changedStaged = PeakCache::writeStagedFile(changedStage.getFile(), *freshEntry, + currentVersion.size, currentVersion.modified); + ready = ready && writeSource(audio, 0.75f, 4096); + const bool sourceChanged = audio.getSize() != currentVersion.size + || audio.getLastModificationTime().toMilliseconds() != currentVersion.modified; + const bool identityRejected = !cache.publishGeneratedEntry(audio, currentVersion, + freshEntry, changedStage, changedStaged); + // loadFileAsData appends; each independent sidecar snapshot must start + // with a fresh block rather than reusing the prior assertion's bytes. + juce::MemoryBlock afterChanged; + const bool sidecarRead = sidecar.loadFileAsData(afterChanged); + const bool sidecarUnchanged = before == afterChanged; + check("peak_cache_changed_source_identity_rejects_staging", ready && changedStaged + && sourceChanged && identityRejected && sidecarRead && sidecarUnchanged, + "A size/mtime change during scanning or staging prevents old peak data from being relabeled or replacing the sidecar without an explicit invalidation."); + auto* identityDiagnostic = new juce::DynamicObject(); + identityDiagnostic->setProperty("ready", ready); + identityDiagnostic->setProperty("changedStaged", changedStaged); + identityDiagnostic->setProperty("sourceChanged", sourceChanged); + identityDiagnostic->setProperty("identityRejected", identityRejected); + identityDiagnostic->setProperty("sidecarRead", sidecarRead); + identityDiagnostic->setProperty("sidecarUnchanged", sidecarUnchanged); + identityDiagnostic->setProperty("beforeBytes", static_cast(before.getSize())); + identityDiagnostic->setProperty("afterBytes", static_cast(afterChanged.getSize())); + identityDiagnostic->setProperty("sourceSizeBefore", currentVersion.size); + identityDiagnostic->setProperty("sourceSizeAfter", audio.getSize()); + checks.getLast().getDynamicObject()->setProperty("diagnostic", juce::var(identityDiagnostic)); + + cache.invalidate(audio); + ready = ready && cache.generateSync(audio); + const auto edgeVersion = cache.captureSourceVersion(audio); + ready = ready && peakEquals(cache.getPeaks(audio, 64, 0, 1, 1, 2047), 0.75f); + const auto oldEdges = cache.boundaryPeaks; + cache.invalidate(audio); + ready = ready && writeSource(audio, 0.5f, 4096) + && audio.setLastModificationTime(juce::Time(edgeVersion.modified)) && cache.generateSync(audio); + // Model an old edge worker resuming after the new generation cleared + // the edge table. Runtime generation is part of its immutable key. + cache.boundaryPeaks.insert(oldEdges.begin(), oldEdges.end()); + check("peak_cache_old_edge_publication_is_generation_isolated", ready + && peakEquals(cache.getPeaks(audio, 64, 0, 1, 1, 2047), 0.5f), + "Late clipped-edge aggregates from a prior generation cannot be reused, including same-size sources with restored modification time."); + cache.invalidate(audio); + auto* report = new juce::DynamicObject(); + report->setProperty("checks", checks); + report->setProperty("scope", "Deterministic production publication interleavings; no timed races, generation-job scheduling, hardware or audio-quality assertion."); + return juce::var(report); + } +}; + +juce::var runPeakCacheGenerationRegression(const juce::File& outputDirectory) +{ + const auto report = PeakCacheGenerationRegressionAccess::run(); + if (outputDirectory.isDirectory()) + outputDirectory.getChildFile("peak-cache-generation-regression.json") + .replaceWithText(juce::JSON::toString(report, true)); + return report; +} diff --git a/Source/PeakCacheGenerationRegression.h b/Source/PeakCacheGenerationRegression.h new file mode 100644 index 00000000..26159733 --- /dev/null +++ b/Source/PeakCacheGenerationRegression.h @@ -0,0 +1,4 @@ +#pragma once +#include + +juce::var runPeakCacheGenerationRegression(const juce::File& outputDirectory); diff --git a/Source/PlaybackEngine.cpp b/Source/PlaybackEngine.cpp index ffb1ba57..e6df3454 100644 --- a/Source/PlaybackEngine.cpp +++ b/Source/PlaybackEngine.cpp @@ -5,6 +5,43 @@ namespace { +double snapNearWholeSample(double position) noexcept +{ + const double whole = std::round(position); + return std::abs(position - whole) < 1.0e-6 ? whole : position; +} + +int boundedSampleBoundary(double position, int blockSamples) noexcept +{ + // Most long clips cover the whole eight-frame callback. Their boundaries + // need no rounding; preserve near-edge snapping for the interior cases. + if (position <= 0.0) + return 0; + if (position >= static_cast(blockSamples)) + return blockSamples; + return static_cast(juce::jlimit(0.0, static_cast(blockSamples), + std::ceil(snapNearWholeSample(position)))); +} + +#if defined(_MSC_VER) + #define OPENSTUDIO_BOUNDARY_BENCH_NOINLINE __declspec(noinline) +#elif defined(__GNUC__) || defined(__clang__) + #define OPENSTUDIO_BOUNDARY_BENCH_NOINLINE __attribute__((noinline)) +#else + #define OPENSTUDIO_BOUNDARY_BENCH_NOINLINE +#endif +OPENSTUDIO_BOUNDARY_BENCH_NOINLINE int referenceSampleBoundary(double position, int blockSamples) noexcept +{ + return static_cast(juce::jlimit(0.0, static_cast(blockSamples), + std::ceil(snapNearWholeSample(position)))); +} + +OPENSTUDIO_BOUNDARY_BENCH_NOINLINE int measuredSampleBoundary(double position, int blockSamples) noexcept +{ + return boundedSampleBoundary(position, blockSamples); +} +#undef OPENSTUDIO_BOUNDARY_BENCH_NOINLINE + constexpr unsigned int kPitchRouteScrubPreview = 1u << 0; constexpr unsigned int kPitchRouteClipLivePreview = 1u << 1; constexpr unsigned int kPitchRouteRenderedSegment = 1u << 2; @@ -173,6 +210,82 @@ static float sampleBufferCubic (const juce::AudioBuffer& buffer, } } +juce::var PlaybackEngine::runSampleBoundaryRegression() +{ + bool exact = true; + int cases = 0; + const auto compare = [&](double position, int size) + { + exact = boundedSampleBoundary(position, size) == referenceSampleBoundary(position, size) && exact; + ++cases; + }; + juce::Random random(0x5a6d8f12); + for (const int size : { 1, 8, 16, 32, 512, 65536 }) + { + for (const double position : { -1.0e300, -1.0e12, -8.0, 0.0, 1.0e12, 1.0e300 }) + compare(position, size); + for (const double edge : { -1.0, 0.0, 1.0, static_cast(size - 1), static_cast(size), static_cast(size + 1) }) + { + compare(std::nextafter(edge, -std::numeric_limits::infinity()), size); + compare(std::nextafter(edge, std::numeric_limits::infinity()), size); + for (const double delta : { -1.000001e-6, -1.0e-6, -0.999999e-6, -1.0e-12, + 0.0, 1.0e-12, 0.999999e-6, 1.0e-6, 1.000001e-6 }) + compare(edge + delta, size); + } + for (int iteration = 0; iteration < 4096; ++iteration) + compare((random.nextDouble() * 4.0 - 1.5) * size, size); + } + + // Runtime input and non-inlined wrappers keep both implementations' work + // observable. Alternate order to avoid consistently favouring a warm path. + std::array positions {}; + for (size_t index = 0; index < positions.size(); ++index) + { + const double time = random.nextDouble() * 187.0; + positions[index] = (index & 1u) == 0 ? -time * 48000.0 : (410.0 - time) * 48000.0; + } + std::vector referenceTimes, measuredTimes; + juce::uint64 referenceChecksum = 0, measuredChecksum = 0; + constexpr int batches = 32, repeats = 8; + constexpr int operationsPerBatch = 4096 * repeats; + const double toNanoseconds = 1.0e9 / static_cast(juce::Time::getHighResolutionTicksPerSecond()); + const auto measure = [&](bool reference) + { + juce::uint64 checksum = 0; + const auto start = juce::Time::getHighResolutionTicks(); + for (int repeat = 0; repeat < repeats; ++repeat) + for (const double position : positions) + checksum += static_cast(reference + ? referenceSampleBoundary(position, 8) : measuredSampleBoundary(position, 8)); + const double elapsed = static_cast(juce::Time::getHighResolutionTicks() - start) + * toNanoseconds / operationsPerBatch; + if (reference) { referenceTimes.push_back(elapsed); referenceChecksum += checksum; } + else { measuredTimes.push_back(elapsed); measuredChecksum += checksum; } + }; + for (int batch = 0; batch < batches; ++batch) + if ((batch & 1) == 0) { measure(true); measure(false); } + else { measure(false); measure(true); } + const auto statistics = [](std::vector values) + { + std::sort(values.begin(), values.end()); + auto* result = new juce::DynamicObject(); + result->setProperty("medianNanosecondsPerBoundary", values[values.size() / 2]); + result->setProperty("p95NanosecondsPerBoundary", values[static_cast(0.95 * static_cast(values.size() - 1))]); + return juce::var(result); + }; + auto* result = new juce::DynamicObject(); + result->setProperty("exactFiniteParity", exact); + result->setProperty("finiteCases", cases); + result->setProperty("benchmarkClassification", "diagnostic_only: isolated outside-block boundary cost; no hardware timing or audible-quality claim"); + result->setProperty("operationsPerVariant", batches * operationsPerBatch); + result->setProperty("matchingNonzeroChecksums", referenceChecksum != 0 && referenceChecksum == measuredChecksum); + result->setProperty("referenceChecksum", static_cast(referenceChecksum)); + result->setProperty("candidateChecksum", static_cast(measuredChecksum)); + result->setProperty("reference", statistics(std::move(referenceTimes))); + result->setProperty("earlyClamp", statistics(std::move(measuredTimes))); + return juce::var(result); +} + float PlaybackEngine::applyFadeCurve(float t, int curveType) { // t is normalized 0.0 to 1.0 @@ -309,12 +422,8 @@ void PlaybackEngine::StreamingContinuityState::advanceFrame( PlaybackEngine::StreamingContinuityState& PlaybackEngine::getTrackPlaybackContinuityState( - const juce::String& trackId) noexcept + juce::int64 trackKey) noexcept { - juce::int64 trackKey = trackId.hashCode64(); - if (trackKey == 0) - trackKey = 1; - ++trackPlaybackContinuityUseCounter; if (trackPlaybackContinuityUseCounter == 0) trackPlaybackContinuityUseCounter = 1; @@ -523,7 +632,7 @@ PlaybackEngine::runOuterLockContinuityRegression() PlaybackEngine probe; auto& continuity = probe.getTrackPlaybackContinuityState( - trackId); + prepareTrackContinuityKey(trackId)); continuity.reset(1); continuity.beginBlock(true, false, 1); for (int sample = 0; @@ -631,6 +740,14 @@ PlaybackEngine::~PlaybackEngine() fullyDecodedBytesInUse = 0; refreshFullyDecodedSourceDiagnosticsLocked(); clips.clear(); + clipIndicesByTrack.clear(); +} + +void PlaybackEngine::rebuildClipIndicesLocked() +{ + clipIndicesByTrack.clear(); + for (size_t index = 0; index < clips.size(); ++index) + clipIndicesByTrack[clips[index].trackId].push_back(index); } void PlaybackEngine::refreshStreamingReaderDiagnosticsLocked() noexcept @@ -705,6 +822,22 @@ void PlaybackEngine::preloadReader(const juce::File& file, // publication lock. The callback can keep the previously published state. const auto filePath = file.getFullPathName(); const auto cacheKey = readerKey.isNotEmpty() ? readerKey : filePath; + if (offlineSourceReads) + { + // This engine belongs exclusively to the export worker. Do not decode + // whole files or run export ahead of a realtime read-ahead thread. + if (offlineReaders.find(cacheKey) != offlineReaders.end()) return; + std::unique_ptr source(formatManager.createReaderFor(file)); + if (source == nullptr) + { + offlineSourceReadFailure.store(true); + return; + } + offlineReaders.emplace(cacheKey, + std::make_unique(std::move(source))); + invalidatePreparedSources(); + return; + } std::shared_ptr existingReader; { const juce::ScopedLock sl(lock); @@ -806,6 +939,7 @@ void PlaybackEngine::preloadReader(const juce::File& file, fullyDecodedSources.emplace( cacheKey, std::move(decodedSource)); + invalidatePreparedSources(); fullyDecodedSourceAccessTimes[cacheKey] = juce::Time::currentTimeMillis(); refreshFullyDecodedSourceDiagnosticsLocked(); @@ -850,13 +984,18 @@ void PlaybackEngine::preloadReader(const juce::File& file, bufferedReader->setReadTimeout(0); primeStreamingReader(*bufferedReader, initialOffsetSeconds, maxWaitMilliseconds); + StreamingContinuityState preparedContinuity; + preparedContinuity.readWindow = std::make_unique( + static_cast(bufferedReader->numChannels), &streamingWindowCounters); + std::shared_ptr concurrentlyPublishedReader; { const juce::ScopedLock sl(lock); auto [it, inserted] = readers.emplace(cacheKey, bufferedReader); if (!inserted && it->second != nullptr) concurrentlyPublishedReader = it->second; - streamingContinuityStates.try_emplace(cacheKey); + streamingContinuityStates.try_emplace(cacheKey, std::move(preparedContinuity)); + invalidatePreparedSources(); readerAccessTimes[cacheKey] = juce::Time::currentTimeMillis(); evictOldReaders(cacheKey); } @@ -924,8 +1063,13 @@ void PlaybackEngine::requestReadAheadAtTime(double timelineTimeSeconds) primeStreamingReader(*request.reader, request.offsetSeconds, 0); } -juce::BufferingAudioReader* PlaybackEngine::getCachedReader(const juce::String& readerKey) +juce::AudioFormatReader* PlaybackEngine::getCachedReader(const juce::String& readerKey) { + if (offlineSourceReads) + { + const auto reader = offlineReaders.find(readerKey); + return reader != offlineReaders.end() ? reader->second.get() : nullptr; + } auto it = readers.find(readerKey); if (it != readers.end() && it->second != nullptr) return it->second.get(); @@ -945,6 +1089,26 @@ PlaybackEngine::getFullyDecodedSource( return nullptr; } +const PlaybackEngine::PreparedSourceBinding& PlaybackEngine::resolvePreparedSource( + const juce::String& readerKey, PreparedSourceBinding& binding) +{ + if (binding.generation == sourceBindingGeneration) + return binding; + + sourceBindingResolutionCount.fetch_add(1, std::memory_order_relaxed); + binding.decoded = getFullyDecodedSource(readerKey); + binding.reader = binding.decoded == nullptr ? getCachedReader(readerKey) : nullptr; + binding.continuity = nullptr; + if (binding.reader != nullptr) + { + const auto state = streamingContinuityStates.find(readerKey); + if (state != streamingContinuityStates.end()) + binding.continuity = &state->second; + } + binding.generation = sourceBindingGeneration; + return binding; +} + bool PlaybackEngine::evictFullyDecodedSourcesToFitLocked( juce::int64 requiredBytes, const juce::String& protectedKey, @@ -1039,6 +1203,7 @@ bool PlaybackEngine::evictFullyDecodedSourcesToFitLocked( retiredSources.push_back( std::move(source->second)); fullyDecodedSources.erase(source); + invalidatePreparedSources(); fullyDecodedSourceAccessTimes.erase(key); ++evicted; } @@ -1103,6 +1268,7 @@ void PlaybackEngine::evictOldReaders(const juce::String& protectedKey) const auto& path = entries[i].second; readers.erase(path); streamingContinuityStates.erase(path); + invalidatePreparedSources(); readerAccessTimes.erase(path); } @@ -1127,7 +1293,7 @@ void PlaybackEngine::evictOldReaders(const juce::String& protectedKey) float PlaybackEngine::interpolateGainEnvelope(const std::vector& points, double time) { - if (points.empty()) + if (points.empty() || !std::isfinite(time)) return 1.0f; // Before first point @@ -1138,36 +1304,36 @@ float PlaybackEngine::interpolateGainEnvelope(const std::vector= points.back().time) return points.back().gain; - // Find surrounding points and interpolate linearly - for (size_t i = 0; i + 1 < points.size(); ++i) - { - if (time >= points[i].time && time < points[i + 1].time) - { - double t = (time - points[i].time) / (points[i + 1].time - points[i].time); - return points[i].gain + static_cast(t) * (points[i + 1].gain - points[i].gain); - } - } - - return 1.0f; + const auto next = std::upper_bound(points.begin(), points.end(), time, + [](double value, const GainEnvelopePoint& point) { return value < point.time; }); + const auto& before = *std::prev(next); + const double amount = (time - before.time) / (next->time - before.time); + return before.gain + static_cast(amount) * (next->gain - before.gain); } void PlaybackEngine::setClipGainEnvelope(const juce::String& trackId, const juce::String& clipId, const std::vector& points) { - juce::ScopedLock sl(lock); - juce::String key = trackId + "::" + clipId; - if (points.empty()) + auto sorted = points; + sorted.erase(std::remove_if(sorted.begin(), sorted.end(), [](const auto& point) { + return !std::isfinite(point.time) || !std::isfinite(point.gain); + }), sorted.end()); + std::stable_sort(sorted.begin(), sorted.end(), [](const auto& a, const auto& b) { return a.time < b.time; }); + const juce::ScopedLock sl(lock); + const juce::String key = trackId + "::" + clipId; + if (sorted.empty()) gainEnvelopes.erase(key); else - gainEnvelopes[key] = points; + gainEnvelopes[key] = std::move(sorted); } void PlaybackEngine::addClip(const juce::File& audioFile, double startTime, double duration, const juce::String& trackId, double offset, double volumeDB, double fadeIn, double fadeOut, const juce::String& clipId, - const juce::File& sourceAudioFile, double sourceOffset) + const juce::File& sourceAudioFile, double sourceOffset, int fadeInCurve, int fadeOutCurve) { if (!audioFile.existsAsFile()) { + if (offlineSourceReads) offlineSourceReadFailure.store(true); juce::Logger::writeToLog("PlaybackEngine: Cannot add clip - file does not exist: " + audioFile.getFullPathName()); return; } @@ -1221,7 +1387,8 @@ void PlaybackEngine::addClip(const juce::File& audioFile, double startTime, doub segment.fileOffsetSec); } - ClipInfo clip(effectiveFile, startTime, duration, trackId, effectiveOffset, volumeDB, fadeIn, fadeOut); + ClipInfo clip(effectiveFile, startTime, duration, trackId, effectiveOffset, volumeDB, fadeIn, fadeOut, + juce::jlimit(0, 4, fadeInCurve), juce::jlimit(0, 4, fadeOutCurve)); clip.clipId = clipId; clip.envelopeKey = trackId + "::" + clipId; // Pre-compute to avoid string alloc on audio thread clip.readerKey = clipReaderKey; @@ -1230,7 +1397,9 @@ void PlaybackEngine::addClip(const juce::File& audioFile, double startTime, doub int totalClipCount = 0; { const juce::ScopedLock sl(lock); + clip.publicationSerial = ++nextClipPublicationSerial; clips.push_back(clip); + clipIndicesByTrack[trackId].push_back(clips.size() - 1); totalClipCount = static_cast(clips.size()); } @@ -1250,6 +1419,51 @@ void PlaybackEngine::addClip(const juce::File& audioFile, double startTime, doub juce::ignoreUnused(totalClipCount); // The diagnostic macro is disabled in Release. } +bool PlaybackEngine::replaceClipSourcePreservingTimeline(const juce::String& trackId, const juce::String& clipId, + const juce::File& expectedFile, const juce::File& replacement, const juce::File& immutableSource, + double immutableOffset, const std::function& keepRunning, + const std::function()>& publicationLeaseFactory) +{ + const auto active = [&] { return !keepRunning || keepRunning(); }; + if (!active() || !replacement.existsAsFile() || !immutableSource.existsAsFile()) return false; + juce::uint64 serial = 0; double offset = 0.0; + { + const juce::ScopedLock sl(lock); + for (const auto& clip : clips) + if (clip.trackId == trackId && clip.clipId == clipId && clip.audioFile == expectedFile) + { serial = clip.publicationSerial; offset = clip.offset; break; } + } + if (serial == 0) return false; + const auto readerKey = "clip|" + clipId + "|" + replacement.getFullPathName(); + preloadReader(replacement, readerKey, offset); + std::shared_ptr preparedReader; + { + const juce::ScopedLock sl(lock); + const auto reader = readers.find(readerKey); + if (reader != readers.end()) preparedReader = reader->second; + if (getFullyDecodedSource(readerKey) == nullptr && getCachedReader(readerKey) == nullptr) return false; + } + if (preparedReader != nullptr && !primeStreamingReader(*preparedReader, offset, 250)) return false; + if (!active()) return false; + const auto publicationLease = publicationLeaseFactory ? publicationLeaseFactory() : std::shared_ptr(); + if (publicationLeaseFactory && publicationLease == nullptr) return false; + const juce::ScopedLock sl(lock); + for (auto& clip : clips) + { + if (clip.trackId != trackId || clip.clipId != clipId || clip.audioFile != expectedFile || clip.publicationSerial != serial) continue; + // A source representation change retains all musical geometry and gain. + clip.audioFile = replacement; clip.readerKey = readerKey; + clip.preparedSource = {}; + clip.originalAudioFile = immutableSource; clip.originalOffset = immutableOffset; + clip.publicationSerial = ++nextClipPublicationSerial; + clearAllPitchPreviewRoutes(clipId); + pitchCorrectedFiles.erase(clipId); + deferredClipSwaps.erase(clipId); + return true; + } + return false; +} + void PlaybackEngine::removeClip(const juce::String& trackId, const juce::String& filePath) { juce::ScopedLock sl(lock); @@ -1262,6 +1476,7 @@ void PlaybackEngine::removeClip(const juce::String& trackId, const juce::String& }), clips.end() ); + rebuildClipIndicesLocked(); juce::Logger::writeToLog("PlaybackEngine: Removed clip from track " + trackId); } @@ -1277,6 +1492,7 @@ void PlaybackEngine::removeClipById(const juce::String& trackId, const juce::Str }), clips.end() ); + rebuildClipIndicesLocked(); gainEnvelopes.erase(trackId + "::" + clipId); juce::Logger::writeToLog("PlaybackEngine: Removed clip " + clipId + " from track " + trackId); @@ -1304,6 +1520,8 @@ void PlaybackEngine::replaceClipAudioFile(const juce::String& clipId, const juce clip.audioFile = newFile; clip.offset = restoringOriginal ? clip.originalOffset : 0.0; clip.readerKey = newReaderKey; + clip.preparedSource = {}; + clip.publicationSerial = ++nextClipPublicationSerial; // Clear any active pitch preview — the corrected audio is now baked // into the file, so the real-time PitchShifter must not double-shift. clipPitchPreviews.erase(clipId); @@ -1434,6 +1652,9 @@ bool PlaybackEngine::setClipRenderedPreviewSegment(const juce::String& clipId, }), segments.end()); segments.push_back({ audioFile, startSec, endSec, fileOffsetSec, segmentReaderKey }); + // Publication owns the same lock as fillTrackBuffer. Prepare scratch capacity + // here, never while the audio callback traverses a large edited segment set. + reusableChunkBoundaries.reserve(juce::jmax(reusableChunkBoundaries.capacity(), segments.size() * 2 + 2)); std::sort(segments.begin(), segments.end(), [] (const RenderedPreviewSegment& a, const RenderedPreviewSegment& b) { return a.startSec < b.startSec; }); @@ -1648,7 +1869,11 @@ void PlaybackEngine::clearAllClips() const int preservedCorrectedCount = static_cast(pitchCorrectedFiles.size()); #endif clips.clear(); + clipIndicesByTrack.clear(); + invalidatePreparedSources(); readers.clear(); + offlineReaders.clear(); + offlineSourceReadFailure.store(false); streamingContinuityStates.clear(); readerAccessTimes.clear(); refreshStreamingReaderDiagnosticsLocked(); @@ -1681,6 +1906,7 @@ void PlaybackEngine::clearTrackClips(const juce::String& trackId) }), clips.end() ); + rebuildClipIndicesLocked(); lagrangeInterpolatorL.reset(); lagrangeInterpolatorR.reset(); @@ -1702,7 +1928,31 @@ int PlaybackEngine::getNumClipsForTrack(const juce::String& trackId) const std::vector PlaybackEngine::getClipSnapshot() const { juce::ScopedLock sl(lock); - return clips; + auto snapshot = clips; + for (auto& clip : snapshot) + { + const auto envelope = gainEnvelopes.find(clip.envelopeKey); + if (envelope != gainEnvelopes.end()) clip.gainEnvelope = envelope->second; + } + return snapshot; +} + +void PlaybackEngine::addClipFromSnapshot(const ClipInfo& snapshot) +{ + addClip(snapshot.audioFile, snapshot.startTime, snapshot.duration, + snapshot.trackId, snapshot.offset, snapshot.volumeDB, + snapshot.fadeIn, snapshot.fadeOut, snapshot.clipId, + snapshot.originalAudioFile, snapshot.originalOffset); + const juce::ScopedLock sl(lock); + for (auto& clip : clips) + { + if (clip.trackId != snapshot.trackId || clip.clipId != snapshot.clipId) continue; + clip.fadeInCurve = snapshot.fadeInCurve; + clip.fadeOutCurve = snapshot.fadeOutCurve; + clip.isActive = snapshot.isActive; + if (!snapshot.gainEnvelope.empty()) gainEnvelopes[clip.envelopeKey] = snapshot.gainEnvelope; + break; + } } // ---- Pitch preview methods ---- @@ -1745,6 +1995,7 @@ void PlaybackEngine::setClipPitchPreview (const juce::String& clipId, + " to original file for preview"); } clip.readerKey = desiredReaderKey; + clip.preparedSource = {}; originalFileToPreload = clip.originalAudioFile; originalReaderKey = desiredReaderKey; originalOffsetToPreload = clip.originalOffset; @@ -2124,7 +2375,9 @@ void PlaybackEngine::fillTrackBuffer(const juce::String& trackId, juce::AudioBuffer& buffer, double currentTime, int numSamples, - double sampleRate) + double sampleRate, + bool foldStereoSourceToMono, + juce::int64 preparedContinuityKey) { if (numSamples <= 0 || sampleRate <= 0.0 @@ -2139,7 +2392,8 @@ void PlaybackEngine::fillTrackBuffer(const juce::String& trackId, reusableTrackPlaybackBuffer .getNumChannels()); auto& trackContinuity = - getTrackPlaybackContinuityState(trackId); + getTrackPlaybackContinuityState(preparedContinuityKey != 0 + ? preparedContinuityKey : prepareTrackContinuityKey(trackId)); const double continuityTolerance = 2.0 / sampleRate; const bool timelineContiguous = @@ -2226,6 +2480,7 @@ void PlaybackEngine::fillTrackBuffer(const juce::String& trackId, 0, numSamples); auto& playbackMix = reusableTrackPlaybackBuffer; + auto* const* mixChannels = playbackMix.getArrayOfWritePointers(); int overlappingClipCount = 0; int mixedClipCount = 0; @@ -2235,10 +2490,14 @@ void PlaybackEngine::fillTrackBuffer(const juce::String& trackId, double windowEnd = currentTime + (numSamples / sampleRate); - // Find and mix all clips that should be playing at current time - for (const auto& clip : clips) + // Only the control thread rebuilds this index. The try-lock above protects + // both the indices and the vector throughout this callback's use. + const auto trackClips = clipIndicesByTrack.find(trackId); + if (trackClips != clipIndicesByTrack.end()) + for (const auto clipIndex : trackClips->second) { - if (clip.trackId != trackId || !clip.isActive) + const auto& clip = clips[clipIndex]; + if (!clip.isActive) continue; double clipEndTime = clip.startTime + clip.duration; @@ -2249,10 +2508,9 @@ void PlaybackEngine::fillTrackBuffer(const juce::String& trackId, ++overlappingClipCount; { - const int clipOutputStart = juce::jlimit (0, numSamples, - static_cast (std::ceil ((clip.startTime - currentTime) * sampleRate - 1.0e-9))); + const int clipOutputStart = boundedSampleBoundary((clip.startTime - currentTime) * sampleRate, numSamples); const int clipOutputEnd = juce::jlimit (clipOutputStart, numSamples, - static_cast (std::ceil ((clipEndTime - currentTime) * sampleRate - 1.0e-9))); + boundedSampleBoundary((clipEndTime - currentTime) * sampleRate, numSamples)); if (clipOutputEnd <= clipOutputStart) continue; @@ -2269,8 +2527,9 @@ void PlaybackEngine::fillTrackBuffer(const juce::String& trackId, const auto requiredBoundaryCapacity = renderedSegmentsForClip != nullptr ? renderedSegmentsForClip->size() * 2 + 2 : 2; if (chunkBoundaries.capacity() < requiredBoundaryCapacity) { - chunkBoundaries.reserve (requiredBoundaryCapacity); + jassertfalse; // Every segment publication must prepare this capacity. chunkBoundaryReserveCount.fetch_add (1, std::memory_order_relaxed); + continue; } chunkBoundaries.push_back (clipOutputStart); chunkBoundaries.push_back (clipOutputEnd); @@ -2279,9 +2538,9 @@ void PlaybackEngine::fillTrackBuffer(const juce::String& trackId, for (const auto& segment : *renderedSegmentsForClip) { const int segmentStart = juce::jlimit (clipOutputStart, clipOutputEnd, - static_cast (std::ceil ((clip.startTime + segment.startSec - currentTime) * sampleRate - 1.0e-9))); + boundedSampleBoundary((clip.startTime + segment.startSec - currentTime) * sampleRate, numSamples)); const int segmentEnd = juce::jlimit (clipOutputStart, clipOutputEnd, - static_cast (std::ceil ((clip.startTime + segment.endSec - currentTime) * sampleRate - 1.0e-9))); + boundedSampleBoundary((clip.startTime + segment.endSec - currentTime) * sampleRate, numSamples)); if (segmentStart > clipOutputStart && segmentStart < clipOutputEnd) chunkBoundaries.push_back (segmentStart); if (segmentEnd > clipOutputStart && segmentEnd < clipOutputEnd) @@ -2291,7 +2550,7 @@ void PlaybackEngine::fillTrackBuffer(const juce::String& trackId, std::sort (chunkBoundaries.begin(), chunkBoundaries.end()); chunkBoundaries.erase (std::unique (chunkBoundaries.begin(), chunkBoundaries.end()), chunkBoundaries.end()); - const float clipGain = juce::Decibels::decibelsToGain (static_cast (clip.volumeDB)); + const float clipGain = clip.linearGain; const std::vector* envPoints = nullptr; if (clip.clipId.isNotEmpty()) { @@ -2303,6 +2562,7 @@ void PlaybackEngine::fillTrackBuffer(const juce::String& trackId, auto mixChunk = [&] (int outputStart, int requestedOutputSamples, const juce::File& playbackFile, const juce::String& readerKey, + PreparedSourceBinding& preparedSource, double playbackOffset, bool usingRenderedPreviewSegment, bool usingCorrectedSource) { @@ -2310,28 +2570,14 @@ void PlaybackEngine::fillTrackBuffer(const juce::String& trackId, if (requestedOutputSamples <= 0) return false; - const auto* decodedSource = - getFullyDecodedSource(readerKey); - auto* reader = decodedSource == nullptr - ? getCachedReader(readerKey) - : nullptr; - StreamingContinuityState* - streamingContinuity = nullptr; - if (reader != nullptr) - { - auto continuity = - streamingContinuityStates.find( - readerKey); - if (continuity - != streamingContinuityStates.end()) - { - streamingContinuity = - &continuity->second; - } - } + const auto& source = resolvePreparedSource(readerKey, preparedSource); + const auto* decodedSource = source.decoded; + auto* reader = source.reader; + auto* streamingContinuity = source.continuity; if (decodedSource == nullptr && reader == nullptr) { + if (offlineSourceReads) offlineSourceReadFailure.store(true); const int missingReaders = missingReaderCount.fetch_add (1, std::memory_order_relaxed) + 1; OPENSTUDIO_LOG_AUDIO_PLAYBACK("fillTrackBuffer missingStreamingReader track=" + trackId + " clipId=" + clip.clipId @@ -2347,10 +2593,7 @@ void PlaybackEngine::fillTrackBuffer(const juce::String& trackId, ? decodedSource->sampleRate : reader->sampleRate; const double ratio = fileSampleRate / sampleRate; - double exactFileStart = juce::jmax (0.0, playbackOffset) * fileSampleRate; - const double roundedFileStart = std::round (exactFileStart); - if (std::abs (exactFileStart - roundedFileStart) < 1.0e-6) - exactFileStart = roundedFileStart; + const double exactFileStart = snapNearWholeSample(juce::jmax (0.0, playbackOffset) * fileSampleRate); const juce::int64 fileStartSample = static_cast (std::floor (exactFileStart)); const juce::int64 readStartSample = fileStartSample > 0 ? fileStartSample - 1 : fileStartSample; @@ -2369,8 +2612,13 @@ void PlaybackEngine::fillTrackBuffer(const juce::String& trackId, if (fileSamplesAvailable < fileSamplesToRead) { fileSamplesToRead = static_cast (fileSamplesAvailable); - outputSamples = static_cast (std::floor ( - (static_cast (fileSamplesToRead - 1) - bufferStartPosition) / ratio)); + // Source length is an exclusive frame boundary, not the last + // sample index. Cubic interpolation clamps its endpoint taps; + // keep every output whose source position is before EOF. + const double availableOutputSamples = std::ceil( + snapNearWholeSample((static_cast(fileSamplesToRead) - bufferStartPosition) / ratio)); + outputSamples = static_cast(juce::jlimit(0.0, + static_cast(requestedOutputSamples), availableOutputSamples)); } if (outputSamples <= 0 || fileSamplesToRead <= 0) return false; @@ -2407,13 +2655,9 @@ void PlaybackEngine::fillTrackBuffer(const juce::String& trackId, } else { - allReadAheadReady = reader->read( - &reusableFileBuffer, - 0, - fileSamplesToRead, - readStartSample, - true, - true); + allReadAheadReady = streamingContinuity != nullptr && streamingContinuity->readWindow + ? streamingContinuity->readWindow->read(*reader, reusableFileBuffer, fileSamplesToRead, readStartSample) + : reader->read(&reusableFileBuffer, 0, fileSamplesToRead, readStartSample, true, true); } const double outputTimelineStart = currentTime @@ -2446,6 +2690,7 @@ void PlaybackEngine::fillTrackBuffer(const juce::String& trackId, } if (! allReadAheadReady) { + if (offlineSourceReads) offlineSourceReadFailure.store(true); audioDataCacheMissCount.fetch_add(1, std::memory_order_relaxed); streamingContinuityConcealmentCount .fetch_add( @@ -2529,14 +2774,28 @@ void PlaybackEngine::fillTrackBuffer(const juce::String& trackId, const int outChannels = playbackOutputChannels; - const bool expandMonoSourceToStereo = - readerChannels == 1 && outChannels >= 2; - const int channelsToProcess = expandMonoSourceToStereo + const bool mixMonoSource = readerChannels == 1 + || (readerChannels > 1 && (foldStereoSourceToMono || outChannels == 1)); + const int channelsToProcess = mixMonoSource ? outChannels : std::min (outChannels, readerChannels); + // At the device rate an integer source cursor needs no cubic + // interpolation. Keep the fractional/rate-conversion path exactly + // as before; fades, envelopes, folding and continuity still run. + const bool integerSourceCursor = ratio == 1.0 && fileStartFraction == 0.0; + const int integerSourceStart = readStartOffset; + const auto* const* sourceChannels = reusableFileBuffer.getArrayOfReadPointers(); + jassert(!integerSourceCursor || integerSourceStart + outputSamples <= fileSamplesToRead); + const auto sourceSampleAt = [&] (int channel, int sample) noexcept + { + if (!allReadAheadReady) return 0.0f; + return integerSourceCursor + ? sourceChannels[channel][integerSourceStart + sample] + : sampleBufferCubic(reusableFileBuffer, channel, fileSamplesToRead, + bufferStartPosition + sample * ratio); + }; for (int i = 0; i < outputSamples; ++i) { - const double filePos = bufferStartPosition + i * ratio; const double sampleTimeInClip = chunkClipStart + (i / sampleRate); float fadeGain = 1.0f; if (clip.fadeIn > 0.0 && sampleTimeInClip < clip.fadeIn) @@ -2547,16 +2806,11 @@ void PlaybackEngine::fillTrackBuffer(const juce::String& trackId, const float envGain = envPoints ? interpolateGainEnvelope (*envPoints, sampleTimeInClip) : 1.0f; const float totalGain = clipGain * fadeGain * envGain; - if (expandMonoSourceToStereo) + if (mixMonoSource) { - const float sourceSample = - allReadAheadReady - ? sampleBufferCubic( - reusableFileBuffer, - 0, - fileSamplesToRead, - filePos) - : 0.0f; + float sourceSample = sourceSampleAt(0, i); + if (readerChannels > 1 && allReadAheadReady) + sourceSample = 0.5f * (sourceSample + sourceSampleAt(1, i)); const float continuitySample = streamingContinuity != nullptr ? streamingContinuity @@ -2569,24 +2823,14 @@ void PlaybackEngine::fillTrackBuffer(const juce::String& trackId, continuitySample * totalGain; for (int ch = 0; ch < channelsToProcess; ++ch) { - playbackMix.addSample( - ch, - outputStart + i, - mixedSample); + mixChannels[ch][outputStart + i] += mixedSample; } } else { for (int ch = 0; ch < channelsToProcess; ++ch) { - const float sourceSample = - allReadAheadReady - ? sampleBufferCubic( - reusableFileBuffer, - ch, - fileSamplesToRead, - filePos) - : 0.0f; + const float sourceSample = sourceSampleAt(ch, i); const float continuitySample = streamingContinuity != nullptr ? streamingContinuity @@ -2597,10 +2841,7 @@ void PlaybackEngine::fillTrackBuffer(const juce::String& trackId, : sourceSample; const float mixedSample = continuitySample * totalGain; - playbackMix.addSample( - ch, - outputStart + i, - mixedSample); + mixChannels[ch][outputStart + i] += mixedSample; } } if (streamingContinuity != nullptr) @@ -2658,7 +2899,7 @@ void PlaybackEngine::fillTrackBuffer(const juce::String& trackId, } const double chunkClipStart = currentTime + (static_cast (chunkStart) / sampleRate) - clip.startTime; - juce::File playbackFile = clip.audioFile; + const auto& playbackFile = activeSegment != nullptr ? activeSegment->audioFile : clip.audioFile; double playbackOffset = clip.offset + chunkClipStart; bool usingRenderedPreviewSegment = false; bool usingCorrectedSource = false; @@ -2666,7 +2907,6 @@ void PlaybackEngine::fillTrackBuffer(const juce::String& trackId, if (activeSegment != nullptr) { - playbackFile = activeSegment->audioFile; playbackOffset = activeSegment->fileOffsetSec + (chunkClipStart - activeSegment->startSec); usingRenderedPreviewSegment = true; readerKey = &activeSegment->readerKey; @@ -2674,13 +2914,16 @@ void PlaybackEngine::fillTrackBuffer(const juce::String& trackId, else if (clip.clipId.isNotEmpty()) { auto correctedIt = pitchCorrectedFiles.find (clip.clipId); + // Existence was validated before source publication. Never + // query the filesystem from a realtime callback; the prepared + // reader remains authoritative until the source is replaced. usingCorrectedSource = correctedIt != pitchCorrectedFiles.end() - && correctedIt->second.existsAsFile() && playbackFile == correctedIt->second; } mixedAnyChunk = mixChunk (chunkStart, chunkEnd - chunkStart, playbackFile, - *readerKey, playbackOffset, + *readerKey, activeSegment != nullptr ? activeSegment->preparedSource : clip.preparedSource, + playbackOffset, usingRenderedPreviewSegment, usingCorrectedSource) || mixedAnyChunk; } @@ -2699,6 +2942,7 @@ void PlaybackEngine::fillTrackBuffer(const juce::String& trackId, outerLockContinuityRecoveryCount.fetch_add( 1, std::memory_order_relaxed); } + auto* const* outputChannels = buffer.getArrayOfWritePointers(); for (int sample = 0; sample < numSamples; ++sample) @@ -2710,17 +2954,10 @@ void PlaybackEngine::fillTrackBuffer(const juce::String& trackId, const float continuousSample = trackContinuity.processSample( channel, - playbackMix.getSample( - channel, sample), + mixChannels[channel][sample], true); - playbackMix.setSample( - channel, - sample, - continuousSample); - buffer.addSample( - channel, - sample, - continuousSample); + mixChannels[channel][sample] = continuousSample; + outputChannels[channel][sample] += continuousSample; } trackContinuity.advanceFrame(true); } diff --git a/Source/PlaybackEngine.h b/Source/PlaybackEngine.h index 3c306127..e69da37f 100644 --- a/Source/PlaybackEngine.h +++ b/Source/PlaybackEngine.h @@ -1,4 +1,5 @@ #pragma once +#include "RealtimeSourceReadWindow.h" #include @@ -16,6 +17,7 @@ #include #include #include +#include /** * PlaybackEngine manages audio clip playback for the DAW. @@ -24,6 +26,16 @@ */ class PlaybackEngine { + struct FullyDecodedSource; + struct StreamingContinuityState; + struct PreparedSourceBinding + { + juce::uint64 generation = 0; + const FullyDecodedSource* decoded = nullptr; + juce::AudioFormatReader* reader = nullptr; + StreamingContinuityState* continuity = nullptr; + }; + public: PlaybackEngine(); ~PlaybackEngine(); @@ -45,6 +57,7 @@ class PlaybackEngine double duration; // Clip duration (seconds) double offset; // Offset into audio file (for trimming, seconds) double volumeDB; // Per-clip gain (-60 to +12 dB) + float linearGain; // Prepared with the clip, never recomputed per callback double fadeIn; // Fade in length (seconds) double fadeOut; // Fade out length (seconds) int fadeInCurve; // Fade in curve type: 0=linear, 1=equal_power, 2=s_curve, 3=log, 4=exp @@ -54,23 +67,38 @@ class PlaybackEngine juce::String envelopeKey; // Pre-computed "trackId::clipId" key — avoids string alloc in audio thread juce::String readerKey; // Stable read-ahead stream for this logical clip bool isActive; // Whether clip is currently loaded + juce::uint64 publicationSerial = 0; + // Populated by getClipSnapshot; the realtime envelope remains in its keyed map. + std::vector gainEnvelope; ClipInfo(const juce::File& file, double start, double dur, const juce::String& track, double off = 0.0, double volDB = 0.0, double fIn = 0.0, double fOut = 0.0, int fInCurve = 0, int fOutCurve = 0) : audioFile(file), startTime(start), duration(dur), offset(off), - volumeDB(volDB), fadeIn(fIn), fadeOut(fOut), fadeInCurve(fInCurve), fadeOutCurve(fOutCurve), + volumeDB(volDB), linearGain(juce::Decibels::decibelsToGain(static_cast(volDB))), + fadeIn(fIn), fadeOut(fOut), fadeInCurve(fInCurve), fadeOutCurve(fOutCurve), trackId(track), isActive(true) {} + + private: + friend class PlaybackEngine; + // Borrowed only while the publication lock is held. A resource change + // invalidates this binding before any cached pointer can be used. + mutable PreparedSourceBinding preparedSource; }; // Clip management void addClip(const juce::File& audioFile, double startTime, double duration, const juce::String& trackId, double offset = 0.0, double volumeDB = 0.0, double fadeIn = 0.0, double fadeOut = 0.0, const juce::String& clipId = juce::String(), const juce::File& sourceAudioFile = juce::File(), - double sourceOffset = -1.0); + double sourceOffset = -1.0, int fadeInCurve = 0, int fadeOutCurve = 0); void removeClip(const juce::String& trackId, const juce::String& filePath); void removeClipById(const juce::String& trackId, const juce::String& clipId); void clearAllClips(); void clearTrackClips(const juce::String& trackId); + void addClipFromSnapshot(const ClipInfo& clip); + bool replaceClipSourcePreservingTimeline(const juce::String& trackId, const juce::String& clipId, + const juce::File& expectedFile, const juce::File& replacement, const juce::File& immutableSource, + double immutableOffset, const std::function& keepRunning = {}, + const std::function()>& publicationLeaseFactory = {}); // Hot-swap a clip's audio file (used after pitch correction writes a new file) void replaceClipAudioFile(const juce::String& clipId, const juce::File& newFile); @@ -89,6 +117,7 @@ class PlaybackEngine double endSec = 0.0; double fileOffsetSec = 0.0; juce::String readerKey; + mutable PreparedSourceBinding preparedSource; }; struct ClipPlaybackSourceStatus @@ -121,12 +150,19 @@ class PlaybackEngine const std::vector& points); // Called from audio callback to fill track buffer with playback audio - // Called from audio callback to fill track buffer with playback audio + // Cache this key when publishing a track; zero requests the legacy lookup. + static juce::int64 prepareTrackContinuityKey(const juce::String& trackId) noexcept + { + const auto key = trackId.hashCode64(); + return key == 0 ? 1 : key; + } void fillTrackBuffer(const juce::String& trackId, juce::AudioBuffer& buffer, double currentTime, int numSamples, - double sampleRate); + double sampleRate, + bool foldStereoSourceToMono = false, + juce::int64 preparedContinuityKey = 0); // ---- Real-time pitch preview ---- @@ -260,6 +296,9 @@ class PlaybackEngine int getAudioDataCacheMissCount() const { return audioDataCacheMissCount.load(std::memory_order_relaxed); } int getAudioDataCachedFileCount() const { return cachedReaderCount.load(std::memory_order_acquire); } juce::int64 getStreamingReadAheadCapacityBytes() const; + juce::uint64 getStreamingWindowHits() const { return streamingWindowCounters.hits.load(std::memory_order_relaxed); } + juce::uint64 getStreamingWindowReads() const { return streamingWindowCounters.reads.load(std::memory_order_relaxed); } + juce::uint64 getStreamingWindowFallbacks() const { return streamingWindowCounters.fallbacks.load(std::memory_order_relaxed); } int getStreamingReaderEvictionCount() const { return streamingReaderEvictionCount.load(std::memory_order_relaxed); } int getStreamingReaderBudgetOvercommitCount() const { return streamingReaderBudgetOvercommitCount.load(std::memory_order_relaxed); } int getFullyDecodedSourceCount() const { return fullyDecodedSourceCount.load(std::memory_order_acquire); } @@ -305,6 +344,9 @@ class PlaybackEngine static OuterLockContinuityRegressionResult runOuterLockContinuityRegression(); + // Device-free boundary parity and diagnostic-only fixed-cost benchmark. + static juce::var runSampleBoundaryRegression(); + // Control-thread hint used before starts/seeks. It only requests/optionally // waits for JUCE's background buffering thread; source decoding never runs // on the realtime callback. @@ -316,6 +358,17 @@ class PlaybackEngine // Render mode: uses Lagrange interpolation for higher quality resampling void setRenderMode(bool isRendering) { renderMode = isRendering; } + // Select before adding clips on an isolated offline worker. This changes + // I/O policy only; realtime readers must remain nonblocking. + void setOfflineSourceReads(bool enabled) + { + if (offlineSourceReads != enabled) invalidatePreparedSources(); + offlineSourceReads = enabled; + } + bool hasOfflineSourceReadFailure() const noexcept { return offlineSourceReadFailure.load(); } + juce::uint64 getSourceBindingResolutionCount() const noexcept + { return sourceBindingResolutionCount.load(std::memory_order_relaxed); } + // Each streaming reader holds two 32768-frame blocks and source readers are // capped to two channels. The normal cache target is therefore 128 MiB. // Readers referenced by active clips are never evicted into permanent @@ -340,8 +393,10 @@ class PlaybackEngine juce::int64 decodedBytes = 0; }; + RealtimeSourceReadWindow::Counters streamingWindowCounters; struct StreamingContinuityState { + std::unique_ptr readWindow; std::array lastOutput {}; std::array concealedOutput {}; double expectedNextTimelineTime = 0.0; @@ -369,6 +424,10 @@ class PlaybackEngine }; std::vector clips; + // Published under lock on clip insertion/removal. Tiny callbacks must only + // visit this track's clips instead of rescanning the entire project. + std::map> clipIndicesByTrack; + void rebuildClipIndicesLocked(); // Declared before readers so it outlives them during reverse-order member // destruction. BufferingAudioReader registers itself as a time-slice client. juce::TimeSliceThread streamingReadAheadThread { "PlaybackEngine-ReadAhead" }; @@ -383,6 +442,12 @@ class PlaybackEngine // waits on a decoder/read-ahead lock. std::map> fullyDecodedSources; + // Offline renders decode one bounded block at a time, synchronously, and + // never conceal a read-ahead miss as successfully rendered audio. + std::map> offlineReaders; + bool offlineSourceReads = false; + juce::uint64 nextClipPublicationSerial = 0; + std::atomic offlineSourceReadFailure { false }; std::map fullyDecodedSourceAccessTimes; juce::int64 fullyDecodedBytesInUse = 0; @@ -410,12 +475,18 @@ class PlaybackEngine juce::uint64 trackPlaybackContinuityUseCounter = 0; StreamingContinuityState& getTrackPlaybackContinuityState( - const juce::String& trackId) noexcept; + juce::int64 trackKey) noexcept; // Get cached audio format reader (audio-thread safe — never creates readers) - juce::BufferingAudioReader* getCachedReader(const juce::String& readerKey); + juce::AudioFormatReader* getCachedReader(const juce::String& readerKey); const FullyDecodedSource* getFullyDecodedSource( const juce::String& readerKey) const noexcept; + const PreparedSourceBinding& resolvePreparedSource( + const juce::String& readerKey, PreparedSourceBinding& binding); + // Protected by lock (or exclusively owned by an offline worker). + juce::uint64 sourceBindingGeneration = 1; + std::atomic sourceBindingResolutionCount { 0 }; + void invalidatePreparedSources() noexcept { ++sourceBindingGeneration; } // Pre-load reader on message thread so it's ready for audio thread void preloadReader(const juce::File& file, diff --git a/Source/PlaybackTinyBlockRegression.cpp b/Source/PlaybackTinyBlockRegression.cpp new file mode 100644 index 00000000..aa62ff11 --- /dev/null +++ b/Source/PlaybackTinyBlockRegression.cpp @@ -0,0 +1,225 @@ +#include "PlaybackTinyBlockRegression.h" +#include "PlaybackEngine.h" +#include + +namespace { +constexpr int frames = 8192; +constexpr double rate = 48000.0; +float value(int channel, int frame) +{ + return frame >= 0 && frame < frames + ? static_cast((frame % 127) - 63) / 128.0f * (channel == 0 ? 1.0f : -0.5f) + : 0.0f; +} +float cubic(int channel, double position) +{ + position = juce::jlimit(0.0, static_cast(frames - 1), position); + const int centre = static_cast(std::floor(position)); + const auto at = [&](int offset) { return value(channel, juce::jlimit(0, frames - 1, centre + offset)); }; + const float t = static_cast(position - centre), t2 = t * t, t3 = t2 * t; + return 0.5f * ((2.0f * at(0)) + (-at(-1) + at(1)) * t + + (2.0f * at(-1) - 5.0f * at(0) + 4.0f * at(1) - at(2)) * t2 + + (-at(-1) + 3.0f * at(0) - 3.0f * at(1) + at(2)) * t3); +} +bool matches(PlaybackEngine& engine, const juce::String& track, double time, + const std::function& expected, bool mono = false, + double deviceRate = rate) +{ + juce::AudioBuffer block(2, 8); block.clear(); + engine.fillTrackBuffer(track, block, time, 8, deviceRate, mono); + for (int channel = 0; channel < 2; ++channel) + for (int sample = 0; sample < 8; ++sample) + if (std::abs(block.getSample(channel, sample) - expected(channel, sample)) > 1.0e-7f) return false; + return true; +} +} + +void runPlaybackTinyBlockRegression(const juce::File& directory, + const std::function& check) +{ + // Exercise both null and owned storage after the pinned HeapBlock guard. + juce::HeapBlock empty; + empty.free(); empty.free(); + juce::HeapBlock owned(8); + owned[0] = 42; + juce::HeapBlock moved(std::move(owned)); + bool ownership = owned.getData() == nullptr && moved[0] == 42; + owned.free(); moved.free(); moved.free(); + ownership &= empty.getData() == nullptr && moved.getData() == nullptr; + check("realtime_heap_guard_preserves_empty_owned_and_moved_lifetimes", ownership); + bool viewParity = true; + for (const int channels : { 1, 2, 64 }) + { + juce::AudioBuffer storage(channels, 16); + storage.clear(); + for (int repeat = 0; repeat < 32; ++repeat) + { + juce::AudioBuffer view(storage.getArrayOfWritePointers(), channels, 8); + view.setSample(channels - 1, 0, static_cast(repeat)); + view.setDataToReferTo(storage.getArrayOfWritePointers(), channels, 8, 8); + view.setSample(channels - 1, 0, static_cast(repeat + 1)); + } + juce::AudioBuffer copy; + copy.makeCopyOf(storage); + storage.clear(); + viewParity &= copy.getSample(channels - 1, 0) == 31.0f + && copy.getSample(channels - 1, 8) == 32.0f; + } + check("realtime_buffer_view_rebind_lifetime_and_owned_copy_parity", viewParity); + const auto boundary = PlaybackEngine::runSampleBoundaryRegression(); + check("tiny_block_sample_boundary_exact_finite_parity", static_cast(boundary["exactFiniteParity"])); + check("tiny_block_sample_boundary_benchmark_preserves_work", static_cast(boundary["matchingNonzeroChecksums"])); + directory.getChildFile("sample-boundary-benchmark.json").replaceWithText(juce::JSON::toString(boundary, true)); + const auto fixture = directory.getChildFile("tiny-block-stereo.wav"); + juce::AudioBuffer source(2, frames); + for (int channel = 0; channel < 2; ++channel) + for (int sample = 0; sample < frames; ++sample) source.setSample(channel, sample, value(channel, sample)); + bool ready = false; + { + auto fileStream = fixture.createOutputStream(); + if (fileStream != nullptr) + { + fileStream->setPosition(0); fileStream->truncate(); + std::unique_ptr stream = std::move(fileStream); + juce::WavAudioFormat format; + auto writer = format.createWriterFor(stream, + juce::AudioFormatWriterOptions{}.withSampleRate(rate).withNumChannels(2).withBitsPerSample(24)); + ready = writer != nullptr && writer->writeFromAudioSampleBuffer(source, 0, frames); + } + } + check("tiny_block_fixture_written", ready); + if (!ready) return; + PlaybackEngine engine; engine.setOfflineSourceReads(true); + const auto add = [&](const juce::String& track, const juce::String& id, double offset = 0.0, + double gain = 0.0, double fade = 0.0) + { + engine.addClip(fixture, 0.0, frames / rate - offset, track, offset, gain, fade, 0.0, id); + }; + add("a", "a1"); add("b", "b1"); + bool preparedKeyParity = true; + const auto preparedKey = PlaybackEngine::prepareTrackContinuityKey("a"); + for (const int frame : { 0, 8, 16, 2048, 2056, 31, frames - 8 }) + { + juce::AudioBuffer block(2, 8); block.clear(); + engine.fillTrackBuffer("a", block, frame / rate, 8, rate, false, preparedKey); + for (int channel = 0; channel < 2; ++channel) + for (int sample = 0; sample < 8; ++sample) + preparedKeyParity &= block.getSample(channel, sample) == value(channel, frame + sample); + } + check("prepared_track_key_preserves_tiny_block_pcm_and_seek_continuity", preparedKeyParity); + bool exact = true; + for (const int frame : { 0, 8, 31, 510, frames - 8 }) + exact &= matches(engine, "a", frame / rate, + [frame](int channel, int sample) { return value(channel, frame + sample); }); + check("tiny_block_integer_cursor_exact_pcm_seek_and_eof", exact); + check("tiny_block_integer_cursor_mono_fold", + matches(engine, "a", 31.0 / rate, [](int, int sample) { + return 0.5f * (value(0, 31 + sample) + value(1, 31 + sample)); }, true)); + add("fractional", "frac", 0.25 / rate); + const bool fractional = matches(engine, "fractional", 31.0 / rate, + [](int channel, int sample) { return cubic(channel, 31.25 + sample); }); + const bool conversion = matches(engine, "a", 31.0 / rate, + [](int channel, int sample) { return cubic(channel, 31.0 + sample * rate / 44100.0); }, false, 44100.0); + check("tiny_block_fractional_and_rate_conversion_preserve_cubic", fractional && conversion); + add("gain", "gain", 0.0, -6.0, 0.01); + check("tiny_block_fades_and_prepared_gain_preserved", matches(engine, "gain", 31.0 / rate, + [](int channel, int sample) { return value(channel, 31 + sample) + * juce::Decibels::decibelsToGain(-6.0f) * static_cast((31.0 + sample) / rate / 0.01); })); + engine.removeClipById("a", "a1"); + const auto silence = [](int, int) { return 0.0f; }; + const auto pcm = [](int channel, int sample) { return value(channel, 31 + sample); }; + bool mutations = matches(engine, "a", 31.0 / rate, silence) + && matches(engine, "b", 31.0 / rate, pcm); + add("a", "a2"); engine.removeClip("b", fixture.getFullPathName()); + mutations &= matches(engine, "b", 31.0 / rate, silence) && matches(engine, "a", 31.0 / rate, pcm); + const auto snapshots = engine.getClipSnapshot(); engine.clearTrackClips("a"); + mutations &= matches(engine, "a", 31.0 / rate, silence); + for (const auto& snapshot : snapshots) if (snapshot.trackId == "a") engine.addClipFromSnapshot(snapshot); + mutations &= matches(engine, "a", 31.0 / rate, pcm); + engine.clearAllClips(); + mutations &= matches(engine, "a", 31.0 / rate, silence); + add("after-clear", "last"); + mutations &= matches(engine, "after-clear", 31.0 / rate, pcm); + check("tiny_block_track_index_insert_erase_clear_and_snapshot_restore", mutations); + // Exceeds the old callback scratch reserve (64 boundaries). Each prepared + // segment references the same PCM at its original position. + bool segmentsReady = true; + for (int segment = 0; segment < 40; ++segment) + { + // Keep windows farther apart than preview replacement's 1 ms tolerance. + const double start = segment * 128.0 / rate, end = (segment * 128.0 + 64.0) / rate; + segmentsReady &= engine.setClipRenderedPreviewSegment("last", fixture, start, end, start); + } + const auto previewSnapshot = engine.getRenderedPreviewSegmentSnapshot(); + const auto preparedSegments = previewSnapshot.find("last"); + segmentsReady &= preparedSegments != previewSnapshot.end() && preparedSegments->second.size() == 40; + const int reservesBefore = engine.getChunkBoundaryReserveCount(); + bool segmentParity = segmentsReady; + for (int frame = 0; frame < 5120; frame += 8) + segmentParity &= matches(engine, "after-clear", frame / rate, + [frame](int channel, int sample) { return value(channel, frame + sample); }); + check("tiny_block_many_preview_segments_preserve_pcm", segmentParity); + check("tiny_block_many_preview_segments_need_no_callback_reserve", + segmentsReady && engine.getChunkBoundaryReserveCount() == reservesBefore); + + const auto replacement = directory.getChildFile("tiny-block-replacement.wav"); + source.applyGain(-0.25f); + bool replacementReady = false; + { + auto fileStream = replacement.createOutputStream(); + if (fileStream != nullptr) + { + fileStream->setPosition(0); fileStream->truncate(); + std::unique_ptr stream = std::move(fileStream); + juce::WavAudioFormat format; + auto writer = format.createWriterFor(stream, + juce::AudioFormatWriterOptions{}.withSampleRate(rate).withNumChannels(2).withBitsPerSample(24)); + replacementReady = writer != nullptr && writer->writeFromAudioSampleBuffer(source, 0, frames); + } + } + check("prepared_source_replacement_fixture_written", replacementReady); + if (!replacementReady) return; + + bool steadyBindings = true, replacementParity = true, previewParity = true, reloadParity = true; + for (const bool offline : { false, true }) + { + PlaybackEngine probe; probe.setOfflineSourceReads(offline); + probe.addClip(fixture, 0, frames / rate, "binding", 0, 0, 0, 0, "binding-clip"); + steadyBindings &= matches(probe, "binding", 31.0 / rate, pcm); + const auto warmResolutions = probe.getSourceBindingResolutionCount(); + for (int block = 0; block < 128; ++block) + { + const int frame = 64 + block * 8; + steadyBindings &= matches(probe, "binding", frame / rate, + [frame](int channel, int sample) { return value(channel, frame + sample); }); + } + steadyBindings &= warmResolutions > 0 && warmResolutions == probe.getSourceBindingResolutionCount(); + const auto replacedPCM = [](int channel, int sample) { return -0.25f * value(channel, 31 + sample); }; + probe.replaceClipAudioFile("binding-clip", replacement); + replacementParity &= matches(probe, "binding", 31.0 / rate, replacedPCM); + // Restore an already cached key: no reader publication invalidates the + // global generation here, so the clip itself must reset its binding. + probe.replaceClipAudioFile("binding-clip", fixture); + replacementParity &= matches(probe, "binding", 31.0 / rate, pcm); + replacementParity &= probe.replaceClipSourcePreservingTimeline("binding", "binding-clip", + fixture, replacement, fixture, 0.0); + replacementParity &= matches(probe, "binding", 31.0 / rate, replacedPCM); + replacementParity &= probe.replaceClipSourcePreservingTimeline("binding", "binding-clip", + replacement, fixture, fixture, 0.0); + replacementParity &= matches(probe, "binding", 31.0 / rate, pcm); + + previewParity &= probe.setClipRenderedPreviewSegment("binding-clip", replacement, 0.0, 0.1); + previewParity &= matches(probe, "binding", 31.0 / rate, replacedPCM); + // The preview survives a clear/re-add. Its old reader/decoded pointers + // have been destroyed and must never be dereferenced after the reload. + probe.clearAllClips(); + probe.addClip(fixture, 0, frames / rate, "binding", 0, 0, 0, 0, "binding-clip"); + reloadParity &= matches(probe, "binding", 31.0 / rate, replacedPCM); + probe.clearClipRenderedPreviewSegments("binding-clip"); + reloadParity &= matches(probe, "binding", 31.0 / rate, pcm); + } + check("prepared_source_steady_tiny_blocks_reuse_resolved_readers", steadyBindings); + check("prepared_source_replacement_and_cached_key_restore_exact_pcm", replacementParity); + check("prepared_source_preview_uses_its_own_binding", previewParity); + check("prepared_source_surviving_preview_rebinds_after_project_reload", reloadParity); +} diff --git a/Source/PlaybackTinyBlockRegression.h b/Source/PlaybackTinyBlockRegression.h new file mode 100644 index 00000000..e8ed9c8c --- /dev/null +++ b/Source/PlaybackTinyBlockRegression.h @@ -0,0 +1,5 @@ +#pragma once +#include +#include +void runPlaybackTinyBlockRegression(const juce::File& directory, + const std::function& check); diff --git a/Source/PreparedIntegerDelayLine.h b/Source/PreparedIntegerDelayLine.h new file mode 100644 index 00000000..8daebe89 --- /dev/null +++ b/Source/PreparedIntegerDelayLine.h @@ -0,0 +1,91 @@ +#pragma once +#include +#include +#include + +// Prepared PDC history with conditional cursor wrapping. PDC requests integral +// delays; retain JUCE Linear's arithmetic and peek/advance API so the existing +// transition law and warm history remain bit-identical to the reference. +class PreparedIntegerDelayLine final +{ +public: + explicit PreparedIntegerDelayLine(int maximumDelay = 96000) + : totalSize(juce::jmax(4, maximumDelay + 2)) {} + + void prepare(const juce::dsp::ProcessSpec& spec) + { + jassert(spec.numChannels > 0 && spec.numChannels <= 2); + samples.setSize(static_cast(spec.numChannels), totalSize, false, false, true); + reset(); + } + void reset() + { + writePosition.fill(0); readPosition.fill(0); samples.clear(); + } + int getMaximumDelayInSamples() const noexcept { return totalSize - 2; } + void setDelay(float requested) noexcept + { + delay = juce::jlimit(0.0f, static_cast(getMaximumDelayInSamples()), requested); + delayInteger = static_cast(std::floor(delay)); + delayFraction = delay - static_cast(delayInteger); + } + float getDelay() const noexcept { return delay; } + // Stable taps can process a whole channel with one buffer lookup. Keep + // writing zero-delay history and the exact Linear arithmetic: a later + // compensation change must see the same warm history as sample processing. + void processChannel(int channel, float* audio, int count) noexcept + { + jassert(channel >= 0 && channel < samples.getNumChannels() && count >= 0); + auto* const history = samples.getWritePointer(channel); + const auto index = static_cast(channel); + int write = writePosition[index], read = readPosition[index]; + for (int sample = 0; sample < count; ++sample) + { + history[write] = audio[sample]; + write = decrement(write); + int first = read + delayInteger, second = first + 1; + if (second >= totalSize) + { + if (first >= totalSize) first -= totalSize; + second -= totalSize; + } + const float firstValue = history[first], secondValue = history[second]; + audio[sample] = firstValue + delayFraction * (secondValue - firstValue); + read = decrement(read); + } + writePosition[index] = write; + readPosition[index] = read; + } + void pushSample(int channel, float sample) noexcept + { + const auto index = static_cast(channel); + samples.setSample(channel, writePosition[index], sample); + writePosition[index] = decrement(writePosition[index]); + } + float popSample(int channel, float requestedDelay = -1.0f, bool advance = true) noexcept + { + if (requestedDelay >= 0.0f) setDelay(requestedDelay); + const auto channelIndex = static_cast(channel); + int first = readPosition[channelIndex] + delayInteger; + int second = first + 1; + if (second >= totalSize) + { + if (first >= totalSize) first -= totalSize; + second -= totalSize; + } + const float firstValue = samples.getSample(channel, first); + const float secondValue = samples.getSample(channel, second); + // Preserve signed-zero and overflow behaviour of JUCE Linear even + // when delayFraction is zero; returning firstValue alone differs. + const float result = firstValue + delayFraction * (secondValue - firstValue); + if (advance) readPosition[channelIndex] = decrement(readPosition[channelIndex]); + return result; + } + +private: + int decrement(int position) const noexcept { return position == 0 ? totalSize - 1 : position - 1; } + juce::AudioBuffer samples; + std::array writePosition {}, readPosition {}; + int totalSize = 4, delayInteger = 0; + float delay = 0.0f, delayFraction = 0.0f; +}; diff --git a/Source/ProjectQualification.cpp b/Source/ProjectQualification.cpp new file mode 100644 index 00000000..77c4c45d --- /dev/null +++ b/Source/ProjectQualification.cpp @@ -0,0 +1,515 @@ +#include "AudioEngine.h" +#include "ClipTempoAnalyzer.h" +#include +#include +#include + +namespace +{ +bool qualificationHasContent(const juce::var& value) +{ + if (const auto* values = value.getArray()) return !values->isEmpty(); + if (const auto* object = value.getDynamicObject()) return object->getProperties().size() != 0; + return !value.isVoid() && !value.toString().isEmpty(); +} + +double qualificationNumber(const juce::var& object, const char* key, double fallback) +{ + return object.hasProperty(key) ? static_cast(object[key]) : fallback; +} + +juce::var qualificationAudioStats(const juce::File& file) +{ + auto* result = new juce::DynamicObject(); + result->setProperty("filePath", file.getFullPathName()); + juce::AudioFormatManager formats; formats.registerBasicFormats(); + std::unique_ptr reader(formats.createReaderFor(file)); + if (reader == nullptr) { result->setProperty("readable", false); return juce::var(result); } + const int channels = juce::jlimit(1, 2, static_cast(reader->numChannels)); + juce::AudioBuffer block(channels, 16384); + bool valid = true; double peak = 0.0, energy = 0.0; + juce::int64 visited = 0; + while (visited < reader->lengthInSamples) + { + const int count = static_cast(juce::jmin(16384, reader->lengthInSamples - visited)); + if (!reader->read(&block, 0, count, visited, true, true)) { valid = false; break; } + for (int channel = 0; channel < channels; ++channel) + for (int sample = 0; sample < count; ++sample) + { + const double value = block.getSample(channel, sample); + valid = valid && std::isfinite(value); peak = juce::jmax(peak, std::abs(value)); energy += value * value; + } + visited += count; + } + result->setProperty("readable", valid && visited == reader->lengthInSamples); + result->setProperty("sampleRate", reader->sampleRate); + result->setProperty("channels", static_cast(reader->numChannels)); + result->setProperty("bitsPerSample", static_cast(reader->bitsPerSample)); + result->setProperty("floatingPoint", reader->usesFloatingPointData); + result->setProperty("frames", reader->lengthInSamples); + result->setProperty("durationSeconds", static_cast(reader->lengthInSamples) / reader->sampleRate); + result->setProperty("peak", peak); + result->setProperty("rms", visited > 0 ? std::sqrt(energy / (static_cast(visited) * channels)) : 0.0); + result->setProperty("sha256", juce::SHA256(file).toHexString()); + return juce::var(result); +} + +juce::var qualificationComparePCM(const juce::File& firstFile, const juce::File& secondFile) +{ + auto* result = new juce::DynamicObject(); + juce::AudioFormatManager formats; formats.registerBasicFormats(); + std::unique_ptr first(formats.createReaderFor(firstFile)), second(formats.createReaderFor(secondFile)); + bool valid = first != nullptr && second != nullptr; + if (valid) valid = first->sampleRate == second->sampleRate && first->numChannels == second->numChannels + && first->lengthInSamples == second->lengthInSamples && first->numChannels == 2; + juce::AudioBuffer a(2, 16384), b(2, 16384); + double difference = 0.0; juce::int64 visited = 0; + while (valid && visited < first->lengthInSamples) + { + const int count = static_cast(juce::jmin(16384, first->lengthInSamples - visited)); + valid = first->read(&a, 0, count, visited, true, true) && second->read(&b, 0, count, visited, true, true); + for (int channel = 0; valid && channel < 2; ++channel) + for (int sample = 0; sample < count; ++sample) + difference = juce::jmax(difference, std::abs(static_cast(a.getSample(channel, sample) - b.getSample(channel, sample)))); + visited += count; + } + result->setProperty("pass", valid && difference == 0.0); + result->setProperty("maxAbsolutePCMError", difference); + result->setProperty("framesCompared", visited); + return juce::var(result); +} + +struct QualificationStemWindow +{ + double startTime = 0.0, duration = 2.0, peak = 0.0, strongestRms = 0.0; + juce::int64 scannedFrames = 0; + juce::String clipId, reason; + bool readable = true; +}; +QualificationStemWindow qualificationStemWindow(const juce::var& track) +{ + QualificationStemWindow result; + const auto* clips = track["clips"].getArray(); + if (clips == nullptr || clips->isEmpty()) + { + result.reason = "No audio source: unassigned MIDI is intentionally silent."; + return result; + } + result.startTime = static_cast((*clips)[0]["startTime"]); + juce::AudioFormatManager formats; formats.registerBasicFormats(); + double bestEnergy = -1.0; + for (const auto& clip : *clips) + { + if (static_cast(clip["muted"])) continue; + std::unique_ptr reader(formats.createReaderFor(juce::File(clip["filePath"].toString()))); + if (reader == nullptr) { result.readable = false; result.reason = "Source could not be opened during full retained-range scan."; return result; } + const double duration = static_cast(clip["duration"]); + const double offset = qualificationNumber(clip, "offset", 0.0); + const auto first = static_cast(std::floor(offset * reader->sampleRate)); + const auto last = juce::jmin(reader->lengthInSamples, + static_cast(std::ceil((offset + duration) * reader->sampleRate))); + const int channels = juce::jlimit(1, 2, static_cast(reader->numChannels)); + juce::AudioBuffer probe(channels, 65536); + for (auto position = first; position < last; position += 65536) + { + const int count = static_cast(juce::jmin(65536, last - position)); + if (!reader->read(&probe, 0, count, position, true, true)) + { result.readable = false; result.reason = "A source read failed during the full retained-range scan."; return result; } + double energy = 0.0; + for (int channel = 0; channel < channels; ++channel) + { + const double rms = probe.getRMSLevel(channel, 0, count); + energy += rms * rms; + result.peak = juce::jmax(result.peak, static_cast(probe.getMagnitude(channel, 0, count))); + } + result.scannedFrames += count; + if (energy > bestEnergy) + { + bestEnergy = energy; + result.strongestRms = std::sqrt(energy / channels); + result.duration = juce::jmin(2.0, duration); + const double center = static_cast(position - first) / reader->sampleRate + count * 0.5 / reader->sampleRate; + result.startTime = static_cast(clip["startTime"]) + + juce::jlimit(0.0, juce::jmax(0.0, duration - result.duration), center - result.duration * 0.5); + result.clipId = clip["id"].toString(); + } + } + } + result.reason = result.peak == 0.0 + ? "Full retained-source PCM scan found only zero samples; silence is the expected content." + : "Window contains the strongest retained-source RMS block from a bounded-memory full PCM scan."; + return result; +} + +} + +juce::var AudioEngine::runProjectQualification(const juce::File& projectFile, const juce::File& frontendPayload, + const juce::File& outputDirectory) +{ + auto* report = new juce::DynamicObject(); + juce::Array checks, tracksReport, renders, stems, unsupported; + bool allPassed = true; + const auto hardwareEvidence = [this] { + auto* object = new juce::DynamicObject(); + object->setProperty("startupMode", deviceStartupMode == DeviceStartup::offline ? "offline" : "openHardware"); + object->setProperty("audioDeviceOpened", deviceManager.getCurrentAudioDevice() != nullptr); + object->setProperty("audioDeviceStartCount", static_cast(audioDeviceStartCount.load())); + object->setProperty("audioCallbackCount", static_cast(audioCallbackCounter.load())); + object->setProperty("callbackRegistrationCount", static_cast(hardwareCallbackRegistrationCount.load())); + return juce::var(object); + }; + const auto zeroHardware = [](const juce::var& value) { + return value["startupMode"].toString() == "offline" && !static_cast(value["audioDeviceOpened"]) + && static_cast(value["audioDeviceStartCount"]) == 0 + && static_cast(value["audioCallbackCount"]) == 0 + && static_cast(value["callbackRegistrationCount"]) == 0; + }; + const auto check = [&](const juce::String& id, bool pass, const juce::String& detail, const juce::var& diagnostic = {}) { + auto* item = new juce::DynamicObject(); item->setProperty("id", id); item->setProperty("status", pass ? "pass" : "fail"); + item->setProperty("detail", detail); if (!diagnostic.isVoid()) item->setProperty("diagnostic", diagnostic); + checks.add(item); allPassed = allPassed && pass; + }; + const bool stemsOnly = juce::SystemStats::getEnvironmentVariable("OPENSTUDIO_PROJECT_STEMS_ONLY", {}) == "1"; + report->setProperty("harnessMode", stemsOnly ? "saved_project_stem_qualification" : "saved_project_qualification"); + report->setProperty("masterRenderScope", stemsOnly ? "not_asserted: stems-only run" : "full repeated master renders"); + report->setProperty("subjectiveAudioQuality", "not_asserted"); + report->setProperty("hardwareBefore", hardwareEvidence()); + const auto sourceHash = juce::SHA256(projectFile).toHexString(); + report->setProperty("projectPath", projectFile.getFullPathName()); report->setProperty("projectSha256", sourceHash); + const auto finish = [&] { + report->setProperty("hardwareAfter", hardwareEvidence()); + check("offline_launch_contract", zeroHardware(report->getProperty("hardwareBefore")) && zeroHardware(report->getProperty("hardwareAfter")), + "Qualification opens no audio device, registers no callback and creates no application window."); + check("project_copy_unchanged", juce::SHA256(projectFile).toHexString() == sourceHash, "Copied project bytes remain unchanged."); + report->setProperty("checks", checks); report->setProperty("tracks", tracksReport); + report->setProperty("renders", renders); report->setProperty("stems", stems); report->setProperty("unsupportedState", unsupported); + report->setProperty("objectiveGateStatus", allPassed ? "pass" : "fail"); return juce::var(report); + }; + check("offline_startup_required", zeroHardware(report->getProperty("hardwareBefore")), "Refuse qualification after hardware startup."); + const auto project = juce::JSON::parse(projectFile); + const auto payload = juce::JSON::parse(frontendPayload); + const auto* rawTracks = project["tracks"].getArray(); + const auto* normalizedTracks = payload["tracks"].getArray(); + check("frontend_payload_provenance", projectFile.existsAsFile() && frontendPayload.existsAsFile() + && payload["sourceProjectSha256"].toString() == sourceHash && rawTracks != nullptr && normalizedTracks != nullptr + && rawTracks->size() == normalizedTracks->size(), "Frontend normalization and MIDI serialization belong to this exact copied project."); + report->setProperty("frontendNormalizationEvidence", payload["qualification"]); + check("frontend_project_load_completed", payload["qualification"]["loader"].toString() == "useDAWStore.loadProject" + && !static_cast(payload["qualification"]["isProjectLoading"]) + && payload["qualification"]["projectRestoreError"].toString().isEmpty(), "The real frontend loader completed without a restore error."); + check("output_directory_ready", outputDirectory.createDirectory().wasOk(), outputDirectory.getFullPathName()); + if (!allPassed) return finish(); + + const auto rejectContent = [&](const juce::var& object, const char* property, const juce::String& context) { + if (qualificationHasContent(object[property])) unsupported.add(context + ": " + property); + }; + for (const auto* property : { "masterFXPaths", "masterFXStates", "masterFXStageState", "monitorFXStageState", "masterAutomationLanes", "unavailableFXStages" }) + rejectContent(project, property, "project"); + for (const auto& track : *rawTracks) + { + const auto context = track["name"].toString(); + for (const auto* property : { "inputFXPaths", "trackFXPaths", "inputFXStates", "trackFXStates", "instrumentPlugin", "instrumentState", + "samplerSamplePath", "automationLanes", "unavailableFX", "sends", "trackFXSidechains", "takes", "midiOutputDevice" }) + rejectContent(track, property, context); + if (track["type"].toString() != "audio" && track["type"].toString() != "midi") unsupported.add(context + ": track type"); + if (static_cast(track["channelStripEQEnabled"]) || qualificationHasContent(track["channelStripEQState"])) + unsupported.add(context + ": inline channel EQ"); + if (const auto* clips = track["clips"].getArray()) for (const auto& clip : *clips) + { + if (static_cast(clip["reversed"]) || qualificationNumber(clip, "playbackRate", 1.0) != 1.0 + || qualificationNumber(clip, "pitchShift", 0.0) != 0.0 || qualificationHasContent(clip["pitchCorrectionSourceFilePath"])) + unsupported.add(context + ": unsupported clip DSP state " + clip["id"].toString()); + } + } + check("complete_saved_dsp_state_supported", unsupported.isEmpty(), "Unsupported saved DSP state fails qualification before rendering.", unsupported); + if (!allPassed) return finish(); + + setPanLaw("linear"); setTempo(qualificationNumber(project, "tempo", 120.0)); + setTimeSignature(static_cast(project["timeSignature"]["numerator"]), static_cast(project["timeSignature"]["denominator"])); + setTempoMarkers(juce::JSON::toString(project["tempoMarkers"])); + setProcessingPrecision(project["processingPrecision"].toString()); + setMasterVolume(static_cast(qualificationNumber(project, "masterVolume", 1.0))); + setMasterPan(static_cast(qualificationNumber(project, "masterPan", 0.0))); + setMasterMute(static_cast(project["isMasterMuted"])); setMasterMono(static_cast(project["masterMono"])); + setAutomationTrimValue("master", static_cast(qualificationNumber(project, "masterTrimVolumeDB", 0.0))); + report->setProperty("panLaw", getPanLaw()); report->setProperty("processingPrecision", getProcessingPrecision()); + report->setProperty("projectSampleRate", project["projectSampleRate"]); + int audioClipCount = 0, midiClipCount = 0, midiEventCount = 0; + juce::Array midiExportTracks; + std::map> expectedMIDI; + const auto midiKey = [](const juce::String& type, int channel, int address, int value) { + return type + ":" + juce::String(channel) + ":" + juce::String(address) + ":" + juce::String(value); + }; + juce::AudioFormatManager formats; formats.registerBasicFormats(); + std::set ids, sources; + for (const auto& savedTrack : *rawTracks) + { + const auto id = savedTrack["id"].toString(); + juce::var track; + for (const auto& candidate : *normalizedTracks) if (candidate["id"].toString() == id) { track = candidate; break; } + if (track.isVoid() || id.isEmpty() || !ids.insert(id).second) { check("track_identity", false, id); return finish(); } + const auto* savedClips = savedTrack["clips"].getArray(); + const auto* normalizedClips = track["clips"].getArray(); + if (savedClips == nullptr || normalizedClips == nullptr || savedClips->size() != normalizedClips->size()) + { check("audio_clip_identity", false, id); return finish(); } + for (const auto& savedClip : *savedClips) + { + bool found = false; + for (const auto& normalizedClip : *normalizedClips) + if (savedClip["id"] == normalizedClip["id"] && savedClip["filePath"] == normalizedClip["filePath"] + && savedClip["startTime"] == normalizedClip["startTime"] && savedClip["duration"] == normalizedClip["duration"] + && qualificationNumber(savedClip, "offset", 0) == qualificationNumber(normalizedClip, "offset", 0)) found = true; + if (!found) { check("audio_clip_identity", false, savedClip["id"].toString()); return finish(); } + } + const auto type = track["type"].toString(); + if (addTrack(id, type) != id) { check("track_creation", false, id); return finish(); } + setTrackVolume(id, static_cast(qualificationNumber(track, "volumeDB", 0.0))); + setTrackPan(id, static_cast(qualificationNumber(track, "pan", 0.0))); + setTrackMute(id, static_cast(track["muted"])); setTrackSolo(id, static_cast(track["soloed"])); + setTrackSoloSafe(id, static_cast(track["soloSafe"])); + setTrackPhaseInvert(id, static_cast(track["phaseInverted"])); + setTrackStereoWidth(id, static_cast(qualificationNumber(track, "stereoWidth", 100.0))); + setTrackMasterSendEnabled(id, !track.hasProperty("masterSendEnabled") || static_cast(track["masterSendEnabled"])); + setTrackOutputChannels(id, static_cast(track["outputStartChannel"]), static_cast(qualificationNumber(track, "outputChannelCount", 2.0))); + setTrackChannelCount(id, static_cast(qualificationNumber(track, "trackChannelCount", 2.0))); + setTrackPlaybackOffset(id, qualificationNumber(track, "playbackOffsetMs", 0.0)); + setTrackInputChannels(id, static_cast(track["inputStartChannel"]), static_cast(qualificationNumber(track, "inputChannelCount", 2.0))); + setAutomationTrimValue(id, static_cast(qualificationNumber(track, "trimVolumeDB", 0.0))); + setTrackMIDIInput(id, track["midiInputDevice"].toString(), static_cast(track["midiChannel"])); + auto* item = new juce::DynamicObject(); item->setProperty("id", id); item->setProperty("name", track["name"]); + item->setProperty("type", type); item->setProperty("routing", getTrackRoutingInfo(id)); + item->setProperty("volumeDB", trackMap[id]->getVolume()); item->setProperty("pan", trackMap[id]->getPan()); + item->setProperty("muted", trackMap[id]->getMute()); item->setProperty("soloed", trackMap[id]->getSolo()); + int loadedClips = 0; + if (const auto* clips = track["clips"].getArray()) for (const auto& clip : *clips) + { + const juce::File file(clip["filePath"].toString()); + std::unique_ptr reader(formats.createReaderFor(file)); + if (reader == nullptr) { check("readable_source", false, file.getFullPathName()); return finish(); } + sources.insert(file.getFullPathName()); + if (static_cast(clip["muted"])) continue; + addPlaybackClip(id, file.getFullPathName(), static_cast(clip["startTime"]), static_cast(clip["duration"]), + qualificationNumber(clip, "offset", 0), qualificationNumber(clip, "volumeDB", 0), + qualificationNumber(clip, "fadeIn", 0), qualificationNumber(clip, "fadeOut", 0), clip["id"].toString(), {}, -1.0, + static_cast(clip["fadeInShape"]), static_cast(clip["fadeOutShape"])); + if (const auto* envelope = clip["gainEnvelope"].getArray()) + { + std::vector points; + for (const auto& point : *envelope) points.push_back({ static_cast(point["time"]), static_cast(point["gain"]) }); + playbackEngine.setClipGainEnvelope(id, clip["id"].toString(), points); + } + ++loadedClips; ++audioClipCount; + } + const auto midi = payload["backendMIDIClipsByTrackId"][juce::Identifier(id)]; + const auto* midiClips = midi.getArray(); + if (midiClips == nullptr) { check("midi_serialization_present", false, id); return finish(); } + setTrackMIDIClips(id, juce::JSON::toString(midi)); + int expectedEvents = 0; + for (const auto& clip : *midiClips) if (const auto* events = clip["events"].getArray()) + { + expectedEvents += events->size(); + for (const auto& event : *events) + { + const auto eventType = event["type"].toString(); + int address = 0, value = static_cast(event["value"]); + if (eventType == "noteOn" || eventType == "noteOff") + { + address = static_cast(event["note"]); + value = static_cast(eventType == "noteOff" && event.hasProperty("releaseVelocity") + ? event["releaseVelocity"] : event["velocity"]); + } + else if (eventType == "cc") address = static_cast(event["controller"]); + else if (eventType == "polyPressure") address = static_cast(event["note"]); + expectedMIDI[track["name"].toString() + "|" + midiKey(eventType, static_cast(event["channel"]), address, value)].push_back( + static_cast(clip["startTime"]) + static_cast(event["timestamp"])); + } + } + if (!midiClips->isEmpty()) + { + auto* exportTrack = new juce::DynamicObject(); exportTrack->setProperty("name", track["name"]); + exportTrack->setProperty("clips", midi); midiExportTracks.add(exportTrack); + } + const auto* processor = trackMap[id]; + check("midi_event_count_" + id, processor->getScheduledMIDIClipCount() == midiClips->size() + && processor->getScheduledMIDIEventCount() == expectedEvents, "Native scheduler retains all frontend-normalized MIDI clips and events."); + midiClipCount += midiClips->size(); midiEventCount += expectedEvents; + item->setProperty("audioClips", loadedClips); item->setProperty("midiClips", midiClips->size()); + item->setProperty("scheduledMIDIEvents", expectedEvents); + item->setProperty("instrumentConfigured", false); + item->setProperty("expectedSilentMIDI", type == "midi"); tracksReport.add(item); + } + report->setProperty("trackCount", static_cast(ids.size())); report->setProperty("audioClipCount", audioClipCount); + report->setProperty("midiClipCount", midiClipCount); report->setProperty("scheduledMIDIEventCount", midiEventCount); + report->setProperty("distinctAudioSources", static_cast(sources.size())); + check("native_audio_clip_count", playbackEngine.getNumClips() == audioClipCount, "Every normalized, unmuted audio clip is present in native playback."); + if (!allPassed) return finish(); + + if (!stemsOnly) + { + const auto midiFile = outputDirectory.getChildFile("project-midi.mid"); + auto* midiTiming = new juce::DynamicObject(); midiTiming->setProperty("tempo", project["tempo"]); + midiTiming->setProperty("tempoMarkers", project["tempoMarkers"]); midiTiming->setProperty("timeSignature", project["timeSignature"]); + bool midiValid = exportProjectMIDI(midiFile.getFullPathName(), midiExportTracks, getTempo(), juce::var(midiTiming)); + juce::MidiFile exportedMIDI; juce::FileInputStream midiInput(midiFile); + midiValid = midiValid && exportedMIDI.readFrom(midiInput); + const int midiPPQ = exportedMIDI.getTimeFormat(); + int tempoMetadataCount = 0, meterMetadataCount = 0; + for (int trackIndex = 0; midiValid && trackIndex < exportedMIDI.getNumTracks(); ++trackIndex) + for (int eventIndex = 0; eventIndex < exportedMIDI.getTrack(trackIndex)->getNumEvents(); ++eventIndex) + { + const auto& message = exportedMIDI.getTrack(trackIndex)->getEventPointer(eventIndex)->message; + if (message.isTempoMetaEvent() && std::abs(60.0 / message.getTempoSecondsPerQuarterNote() - getTempo()) < 0.001) ++tempoMetadataCount; + if (message.isTimeSignatureMetaEvent()) + { + int numerator = 0, denominator = 0; message.getTimeSignatureInfo(numerator, denominator); + if (numerator == static_cast(project["timeSignature"]["numerator"]) + && denominator == static_cast(project["timeSignature"]["denominator"])) ++meterMetadataCount; + } + } + exportedMIDI.convertTimestampTicksToSeconds(); + std::map> actualMIDI; + int actualMIDIEvents = 0; + for (int trackIndex = 0; midiValid && trackIndex < exportedMIDI.getNumTracks(); ++trackIndex) + { + juce::String exportedTrackName; + for (int eventIndex = 0; eventIndex < exportedMIDI.getTrack(trackIndex)->getNumEvents(); ++eventIndex) + { + const auto& message = exportedMIDI.getTrack(trackIndex)->getEventPointer(eventIndex)->message; + if (message.isTrackNameEvent()) exportedTrackName = message.getTextFromTextMetaEvent(); + } + for (int eventIndex = 0; eventIndex < exportedMIDI.getTrack(trackIndex)->getNumEvents(); ++eventIndex) + { + const auto& message = exportedMIDI.getTrack(trackIndex)->getEventPointer(eventIndex)->message; + juce::String type; int address = 0, value = 0; + if (message.isNoteOn()) { type = "noteOn"; address = message.getNoteNumber(); value = message.getVelocity(); } + else if (message.isNoteOff()) { type = "noteOff"; address = message.getNoteNumber(); value = message.getVelocity(); } + else if (message.isController()) { type = "cc"; address = message.getControllerNumber(); value = message.getControllerValue(); } + else if (message.isPitchWheel()) { type = "pitchBend"; value = message.getPitchWheelValue(); } + else if (message.isProgramChange()) { type = "programChange"; value = message.getProgramChangeNumber(); } + else if (message.isChannelPressure()) { type = "channelPressure"; value = message.getChannelPressureValue(); } + else if (message.isAftertouch()) { type = "polyPressure"; address = message.getNoteNumber(); value = message.getAfterTouchValue(); } + else continue; + actualMIDI[exportedTrackName + "|" + midiKey(type, message.getChannel(), address, value)].push_back(message.getTimeStamp()); ++actualMIDIEvents; + } + } + double midiTimeError = 0.0; + bool midiIdentities = actualMIDI.size() == expectedMIDI.size(); + for (auto& entry : expectedMIDI) + { + auto& actual = actualMIDI[entry.first]; std::sort(actual.begin(), actual.end()); std::sort(entry.second.begin(), entry.second.end()); + if (actual.size() != entry.second.size()) { midiIdentities = false; continue; } + for (size_t event = 0; event < actual.size(); ++event) midiTimeError = juce::jmax(midiTimeError, std::abs(actual[event] - entry.second[event])); + } + auto* midiReport = new juce::DynamicObject(); midiReport->setProperty("filePath", midiFile.getFullPathName()); + midiReport->setProperty("expectedMusicalEvents", midiEventCount); midiReport->setProperty("decodedMusicalEvents", actualMIDIEvents); + midiReport->setProperty("maxTimestampErrorSeconds", midiTimeError); midiReport->setProperty("ticksPerQuarterNote", midiPPQ); + midiReport->setProperty("sha256", juce::SHA256(midiFile).toHexString()); report->setProperty("midiExport", juce::var(midiReport)); + check("actual_project_midi_export_roundtrip", midiValid && midiIdentities && actualMIDIEvents == midiEventCount && midiPPQ > 0 + && tempoMetadataCount == midiExportTracks.size() && meterMetadataCount == midiExportTracks.size() + && midiTimeError <= 60.0 / getTempo() / juce::jmax(1, midiPPQ) + 0.0001, + "Actual normalized MIDI events retain types, channels, values and timing within one exported tick, with saved tempo/meter.", juce::var(midiReport)); + + juce::Array tempoDiagnostics; + std::set analyzedSources; + for (const auto& track : *rawTracks) + { + const auto name = track["name"].toString(); + if (name != "Audio 1" && name != "Dist-Gtr_05" && name != "Chor_01" && name != "All takes done_19.08.25") continue; + const auto* clips = track["clips"].getArray(); + if (clips == nullptr || clips->isEmpty()) continue; + const auto clip = (*clips)[0]; + const auto path = clip["filePath"].toString(); + if (!analyzedSources.insert(path).second) continue; + auto diagnostic = ClipTempoAnalyzer::analyze(juce::File(path), qualificationNumber(clip, "offset", 0), static_cast(clip["duration"])); + diagnostic.getDynamicObject()->setProperty("trackName", name); + diagnostic.getDynamicObject()->setProperty("clipId", clip["id"]); + diagnostic.getDynamicObject()->setProperty("filePath", path); + diagnostic.getDynamicObject()->setProperty("classification", "diagnostic_only"); + diagnostic.getDynamicObject()->setProperty("savedProjectBPM", project["tempo"]); + tempoDiagnostics.add(diagnostic); + } + report->setProperty("actualSourceTempoDiagnostics", tempoDiagnostics); + + } + + const double start = qualificationNumber(project["renderDialogOptions"], "startTime", 0.0); + const double end = qualificationNumber(project["renderDialogOptions"], "endTime", qualificationNumber(project["projectRange"], "end", 0.0)); + const double tailMs = qualificationNumber(project["renderDialogOptions"], "tailLength", 1000.0); + const bool addTail = static_cast(project["renderDialogOptions"]["addTail"]); + report->setProperty("startTime", start); report->setProperty("endTime", end); report->setProperty("tailMs", addTail ? tailMs : 0.0); + const auto render = [&](const juce::String& name, double rate, const juce::String& format, int bits) { + const auto target = outputDirectory.getChildFile(name); + juce::Logger::writeToLog("ProjectQualification: render " + name); + const bool rendered = renderProject("master", start, end, target.getFullPathName(), format, rate, bits, 2, + false, addTail, tailMs, false); + auto stats = qualificationAudioStats(target); + stats.getDynamicObject()->setProperty("sourceReadMisses", lastOfflineRenderSourceReadMisses.load()); + stats.getDynamicObject()->setProperty("sourceReadFailed", lastOfflineRenderSourceReadFailed.load()); + const double tolerance = format == "mp3" ? 0.15 : 2.0 / rate; + check("render_" + name, rendered && static_cast(stats["readable"]) + && std::abs(static_cast(stats["sampleRate"]) - rate) < 1.0 + && std::abs(static_cast(stats["durationSeconds"]) - (end - start + (addTail ? tailMs * 0.001 : 0.0))) <= tolerance + && lastOfflineRenderSourceReadMisses.load() == 0 && !lastOfflineRenderSourceReadFailed.load(), + "Full saved-project render has complete finite PCM, requested rate/duration and no concealed source reads.", stats); + renders.add(stats); + auto* progress = new juce::DynamicObject(); progress->setProperty("completedRenders", renders); + outputDirectory.getChildFile("progress.json").replaceWithText(juce::JSON::toString(juce::var(progress), true)); + return target; + }; + if (!stemsOnly) + { + const auto first48 = render("master-48000-a.wav", 48000.0, "wav", 32); + const auto second48 = render("master-48000-b.wav", 48000.0, "wav", 32); + const auto parity48 = qualificationComparePCM(first48, second48); + check("repeat_render_48000_pcm", static_cast(parity48["pass"]), "Independent render passes produce identical decoded PCM.", parity48); + const auto first44 = render("master-44100-a.wav", 44100.0, "wav", 32); + const auto second44 = render("master-44100-b.wav", 44100.0, "wav", 32); + const auto parity44 = qualificationComparePCM(first44, second44); + check("repeat_render_44100_pcm", static_cast(parity44["pass"]), "Independent render passes produce identical decoded PCM.", parity44); + render("saved-mixdown-48000-320.mp3", 48000.0, "mp3", 320); + + } + + bool savedPreview = false; + const bool anySolo = std::any_of(normalizedTracks->begin(), normalizedTracks->end(), [](const juce::var& track) { return static_cast(track["soloed"]); }); + for (const auto& track : *normalizedTracks) + { + if (static_cast(track["muted"]) || (anySolo && !static_cast(track["soloed"]) && !static_cast(track["soloSafe"]))) continue; + const auto id = track["id"].toString(); + const auto* clips = track["clips"].getArray(); + const auto* midi = track["midiClips"].getArray(); + if ((clips == nullptr || clips->isEmpty()) && (midi == nullptr || midi->isEmpty())) continue; + const auto selectedWindow = qualificationStemWindow(track); + const double windowStart = selectedWindow.startTime; + const double windowEnd = juce::jmin(end, windowStart + selectedWindow.duration); + const auto target = outputDirectory.getChildFile("stem-" + id + ".wav"); + const bool rendered = renderProject("stem:" + id, windowStart, windowEnd, target.getFullPathName(), "wav", 48000, 32, 2, false, false, 0, false); + auto stats = qualificationAudioStats(target); + auto* object = stats.getDynamicObject(); object->setProperty("trackId", id); object->setProperty("name", track["name"]); + object->setProperty("startTime", windowStart); object->setProperty("endTime", windowEnd); + object->setProperty("sourceScanPeak", selectedWindow.peak); object->setProperty("sourceScanStrongestRms", selectedWindow.strongestRms); + object->setProperty("sourceScanFrames", selectedWindow.scannedFrames); object->setProperty("windowReason", selectedWindow.reason); + object->setProperty("sourceReadMisses", lastOfflineRenderSourceReadMisses.load()); + object->setProperty("sourceReadFailed", lastOfflineRenderSourceReadFailed.load()); + const bool silentMIDI = track["type"].toString() == "midi"; + object->setProperty("expectedSilentMIDI", silentMIDI); + check("stem_" + id, selectedWindow.readable && rendered && static_cast(stats["readable"]) + && (silentMIDI || selectedWindow.peak == 0.0 || static_cast(stats["peak"]) > 0.0) + && lastOfflineRenderSourceReadMisses.load() == 0 && !lastOfflineRenderSourceReadFailed.load() + && (!silentMIDI || static_cast(stats["peak"]) == 0.0), "Saved track renders its window without missing reads; unassigned MIDI remains silent.", stats); + stems.add(stats); + if (!savedPreview && selectedWindow.peak > 0.0 && selectedWindow.clipId.isNotEmpty()) + { + double clipStart = 0.0; + for (const auto& clip : *clips) if (clip["id"].toString() == selectedWindow.clipId) clipStart = static_cast(clip["startTime"]); + const auto preview = getClipTempoPreviewData(id, selectedWindow.clipId, windowStart - clipStart, windowEnd - windowStart); + juce::MemoryOutputStream wav; + const bool decoded = juce::Base64::convertFromBase64(wav, preview["wavBase64"].toString()); + const auto previewFile = outputDirectory.getChildFile("tempo-preview-source.wav"); + savedPreview = decoded && previewFile.replaceWithData(wav.getData(), wav.getDataSize()) + && outputDirectory.getChildFile("tempo-preview-source.json").replaceWithText(juce::JSON::toString(preview, true)); + report->setProperty("drySourcePreviewArtifact", previewFile.getFullPathName()); + } + } + return finish(); +} diff --git a/Source/ProjectRateCopy.cpp b/Source/ProjectRateCopy.cpp new file mode 100644 index 00000000..a4d44ff5 --- /dev/null +++ b/Source/ProjectRateCopy.cpp @@ -0,0 +1,102 @@ +#include "ProjectRateCopy.h" +#include "AudioFileConversion.h" +#include + +namespace +{ +constexpr const char* quality = "swr64-phase10-cutoff0.97-f32-v1"; +class CancelledFileStream final : public juce::InputStream +{ +public: + CancelledFileStream(const juce::File& file, const std::function& alive) : input(file), keepRunning(alive) {} + juce::int64 getTotalLength() override { return input.getTotalLength(); } + juce::int64 getPosition() override { return input.getPosition(); } + bool setPosition(juce::int64 position) override { return active() && input.setPosition(position); } + bool isExhausted() override { return !active() || input.isExhausted(); } + int read(void* destination, int bytes) override { return active() ? input.read(destination, bytes) : 0; } + bool complete() { return active() && input.getStatus().wasOk() && getPosition() == getTotalLength(); } +private: + bool active() const { return !keepRunning || keepRunning(); } + juce::FileInputStream input; + const std::function& keepRunning; +}; +juce::String cancellableHash(const juce::File& file, const std::function& alive) +{ + CancelledFileStream stream(file, alive); + const auto hash = juce::SHA256(stream).toHexString(); + return stream.complete() ? hash : juce::String(); +} +} + +juce::var ProjectRateCopy::create(const juce::File& source, const juce::File& cacheDirectory, + int targetRate, const std::function& keepRunning) +{ + auto* result = new juce::DynamicObject(); + result->setProperty("success", false); result->setProperty("quality", quality); + const auto cancelled = [&] { return keepRunning && !keepRunning(); }; + const auto fail = [&](const juce::String& error) { + result->setProperty("error", error); result->setProperty("cancelled", cancelled()); return juce::var(result); + }; + if (cancelled()) return fail("Conversion cancelled"); + if (!source.existsAsFile() || targetRate < 8000 || targetRate > 384000) + return fail("The source or project sample rate is invalid"); + juce::AudioFormatManager formats; formats.registerBasicFormats(); + std::unique_ptr reader(formats.createReaderFor(source)); + if (!reader || !std::isfinite(reader->sampleRate) || reader->sampleRate < 8000 || reader->sampleRate > 384000 + || reader->lengthInSamples < 1 || reader->numChannels < 1 || reader->numChannels > 64) + return fail("The source audio could not be read"); + const double sourceRate = reader->sampleRate; + const int channels = static_cast(reader->numChannels); + const double expectedLength = static_cast(reader->lengthInSamples) * targetRate / sourceRate; + if (!std::isfinite(expectedLength) || expectedLength < 0.5 || expectedLength > 1.0e12) return fail("The source duration is unsupported"); + const auto expectedFrames = static_cast(std::llround(expectedLength)); + reader.reset(); + const auto sourceIdentity = cancellableHash(source, keepRunning); + if (sourceIdentity.isEmpty()) return fail("Source identity could not be verified or the conversion was cancelled"); + const auto identity = sourceIdentity + ":" + juce::String(targetRate) + ":" + quality; + const auto key = juce::SHA256(identity.toRawUTF8(), identity.getNumBytesAsUTF8()).toHexString(); + if (cacheDirectory.createDirectory().failed()) return fail("The project audio cache is not writable"); + const auto destination = cacheDirectory.getChildFile(key + ".wav"); + const auto metadataFile = destination.withFileExtension("json"); + const auto metadata = juce::JSON::parse(metadataFile); + bool cacheHit = metadata["sourceIdentity"].toString() == sourceIdentity + && metadata["quality"].toString() == quality && static_cast(metadata["sampleRate"]) == targetRate + && destination.existsAsFile(); + if (cacheHit) + { + const auto outputHash = cancellableHash(destination, keepRunning); + cacheHit = outputHash.isNotEmpty() && metadata["outputIdentity"].toString() == outputHash; + } + if (!cacheHit) + { + juce::TemporaryFile publication(destination, cacheDirectory.getNonexistentChildFile( + ".os-rate-" + juce::Uuid().toString().removeCharacters("-"), ".wav")); + AudioFileConversion::Options options; + options.sampleRate = targetRate; options.bitDepth = 32; options.channels = channels; + const auto converted = AudioFileConversion::convert(source, publication.getFile(), options, keepRunning); + if (converted.failed()) return fail(converted.getErrorMessage()); + if (cancellableHash(source, keepRunning) != sourceIdentity) + return fail("The source changed during conversion; no clip was replaced"); + const auto outputIdentity = cancellableHash(publication.getFile(), keepRunning); + if (outputIdentity.isEmpty() || cancelled()) return fail("Conversion cancelled"); + auto* manifest = new juce::DynamicObject(); + manifest->setProperty("sourceIdentity", sourceIdentity); manifest->setProperty("outputIdentity", outputIdentity); + manifest->setProperty("sampleRate", targetRate); manifest->setProperty("quality", quality); + manifest->setProperty("channels", channels); manifest->setProperty("frames", expectedFrames); + juce::TemporaryFile metadataPublication(metadataFile, cacheDirectory.getNonexistentChildFile( + ".os-rate-meta-" + juce::Uuid().toString().removeCharacters("-"), ".json")); + if (!metadataPublication.getFile().replaceWithText(juce::JSON::toString(juce::var(manifest), true)) || cancelled() + || !publication.overwriteTargetFileWithTemporary() || !metadataPublication.overwriteTargetFileWithTemporary()) + return fail("The converted copy could not be published"); + } + reader.reset(formats.createReaderFor(destination)); + if (cancelled()) return fail("Conversion cancelled"); + if (!reader || reader->sampleRate != targetRate || static_cast(reader->numChannels) != channels + || !reader->usesFloatingPointData || reader->bitsPerSample != 32 || reader->lengthInSamples != expectedFrames) + return fail("The cached copy failed sample-rate, channel, length or precision validation"); + result->setProperty("success", true); result->setProperty("filePath", destination.getFullPathName()); + result->setProperty("sourceIdentity", sourceIdentity); result->setProperty("cacheHit", cacheHit); + result->setProperty("sampleRate", targetRate); result->setProperty("numChannels", channels); + result->setProperty("duration", static_cast(reader->lengthInSamples) / reader->sampleRate); + return juce::var(result); +} diff --git a/Source/ProjectRateCopy.h b/Source/ProjectRateCopy.h new file mode 100644 index 00000000..c86353ad --- /dev/null +++ b/Source/ProjectRateCopy.h @@ -0,0 +1,10 @@ +#pragma once +#include +#include + +namespace ProjectRateCopy +{ +// Worker-only, content-addressed WAV float copies. Never writes to the source. +juce::var create(const juce::File& source, const juce::File& cacheDirectory, int targetRate, + const std::function& keepRunning); +} diff --git a/Source/RealtimeCallbackTrace.h b/Source/RealtimeCallbackTrace.h new file mode 100644 index 00000000..0a9ee682 --- /dev/null +++ b/Source/RealtimeCallbackTrace.h @@ -0,0 +1,124 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +// Optional diagnostics only. Plain payloads are safe because an unread slot is +// owned by the consumer: the producer drops on full and never overwrites it. +struct RealtimeCallbackTraceEvent +{ + std::uint64_t callbackCounter = 0; + std::int64_t startSamplePosition = 0; + std::int64_t startQpcTicks = 0, endQpcTicks = 0; + double startWallTimeMs = 0.0, measuredWallMs = 0.0, budgetMs = 0.0; + double sampleRate = 0.0; + std::array stageMs {}; + std::uint64_t threadId = 0, threadCycleDelta = 0; + std::uint32_t processorGroupStart = 0, processorNumberStart = 0; + std::uint32_t processorGroupEnd = 0, processorNumberEnd = 0; + int blockSize = 0; + bool playing = false, recording = false, stageTimingsAvailable = false; + bool processorIdsAvailable = false, threadCyclesRequested = false, threadCyclesValid = false; +}; +static_assert(std::is_trivially_copyable_v); + +struct RealtimeCallbackTraceStats +{ + std::uint64_t published = 0, consumed = 0, droppedFull = 0, pending = 0; + bool enabled = false, threadCyclesRequested = false; +}; + +template +class RealtimeCallbackTraceQueue final +{ +public: + static_assert(Capacity > 1 && (Capacity & (Capacity - 1)) == 0); + static_assert(std::atomic::is_always_lock_free); + static_assert(std::atomic::is_always_lock_free); + static constexpr std::size_t capacity = Capacity; + static constexpr std::uint64_t sampleInterval = 16; + enum : std::uint32_t { enabledFlag = 1, threadCyclesFlag = 2 }; + + // AudioEngine construction is a control-thread operation. Keep the fixed + // storage off callers' stacks and retain it until all callbacks have stopped. + RealtimeCallbackTraceQueue() : slots(std::make_unique>()) {} + + // Control thread only; never resets indices or reclaims callback storage. + void setEnabled(bool enabled, bool cycles = false) noexcept + { + options.store(enabled ? enabledFlag | (cycles ? threadCyclesFlag : 0u) : 0u, + std::memory_order_release); + } + std::uint32_t getOptions() const noexcept { return options.load(std::memory_order_acquire); } + static bool shouldCapture(std::uint32_t selectedOptions, std::uint64_t callback, + std::uint64_t captureInterval) noexcept + { + return (selectedOptions & enabledFlag) != 0 && captureInterval != 0 + && callback % captureInterval == 0; + } + static bool shouldPublish(std::uint32_t selectedOptions, std::uint64_t callback, + double wallMs, double budgetMs) noexcept + { + return (selectedOptions & enabledFlag) != 0 + && (shouldCapture(selectedOptions, callback, sampleInterval) || (budgetMs > 0.0 && wallMs > budgetMs)); + } + + // Single producer: the audio callback. No allocation, waiting, file work or + // retry loop. Release publication follows the complete payload assignment. + bool tryPush(const RealtimeCallbackTraceEvent& event) noexcept + { + if ((getOptions() & enabledFlag) == 0) return false; + const auto write = writeIndex.load(std::memory_order_relaxed); + const auto read = readIndex.load(std::memory_order_acquire); + if (write - read >= Capacity) + { + droppedFull.fetch_add(1, std::memory_order_relaxed); + return false; + } + (*slots)[static_cast(write & static_cast(Capacity - 1))] = event; + writeIndex.store(write + 1, std::memory_order_release); + return true; + } + + // Single consumer: RealtimeQualificationReporter on the message thread. + // Other diagnostic/bridge readers must not call this function. + std::size_t drain(RealtimeCallbackTraceEvent* destination, std::size_t maximum) noexcept + { + if (destination == nullptr || maximum == 0) return 0; + const auto read = readIndex.load(std::memory_order_relaxed); + const auto write = writeIndex.load(std::memory_order_acquire); + const auto count = static_cast(std::min( + static_cast(maximum), write - read)); + for (std::size_t index = 0; index < count; ++index) + destination[index] = (*slots)[static_cast( + (read + index) & static_cast(Capacity - 1))]; + // Producer may reuse these slots only after the consumer finished all + // reads. This ownership transfer avoids seqlocks over non-atomic data. + readIndex.store(read + count, std::memory_order_release); + return count; + } + + RealtimeCallbackTraceStats getStats() const noexcept + { + const auto selectedOptions = getOptions(); + const auto consumed = readIndex.load(std::memory_order_acquire); + const auto published = writeIndex.load(std::memory_order_acquire); + return { published, consumed, droppedFull.load(std::memory_order_relaxed), + published >= consumed ? published - consumed : 0, + (selectedOptions & enabledFlag) != 0, (selectedOptions & threadCyclesFlag) != 0 }; + } + +private: + std::unique_ptr> slots; + std::atomic writeIndex { 0 }; + std::atomic readIndex { 0 }; + std::atomic droppedFull { 0 }; + std::atomic options { 0 }; +}; + +using RealtimeCallbackTrace = RealtimeCallbackTraceQueue<4096>; diff --git a/Source/RealtimeCallbackTraceRegression.cpp b/Source/RealtimeCallbackTraceRegression.cpp new file mode 100644 index 00000000..7b24e321 --- /dev/null +++ b/Source/RealtimeCallbackTraceRegression.cpp @@ -0,0 +1,126 @@ +#include "RealtimeCallbackTraceRegression.h" +#include "RealtimeCallbackTrace.h" +#include +#include + +namespace +{ +RealtimeCallbackTraceEvent fixture(std::uint64_t counter) +{ + RealtimeCallbackTraceEvent event; + event.callbackCounter = counter; + event.startSamplePosition = static_cast(counter * 8); + event.startWallTimeMs = static_cast(counter) / 6.0; + event.measuredWallMs = 0.125; + event.budgetMs = 8.0 / 48.0; + event.sampleRate = 48000.0; + event.stageMs = { 0.025, 0.05, 0.01, 0.015, 0.025 }; + event.blockSize = 8; + event.threadId = counter + 100; + event.threadCycleDelta = counter * 19; + event.processorNumberStart = static_cast(counter % 16); + event.processorNumberEnd = event.processorNumberStart; + event.playing = true; + event.recording = (counter & 1) != 0; + event.stageTimingsAvailable = true; + event.processorIdsAvailable = true; + event.threadCyclesRequested = true; + event.threadCyclesValid = true; + return event; +} +bool coherent(const RealtimeCallbackTraceEvent& event) +{ + const auto expected = fixture(event.callbackCounter); + return event.startSamplePosition == expected.startSamplePosition + && event.startWallTimeMs == expected.startWallTimeMs + && event.measuredWallMs == expected.measuredWallMs && event.budgetMs == expected.budgetMs + && event.sampleRate == expected.sampleRate && event.stageMs == expected.stageMs + && event.blockSize == 8 && event.threadId == expected.threadId + && event.threadCycleDelta == expected.threadCycleDelta + && event.processorNumberStart == expected.processorNumberStart + && event.processorNumberEnd == expected.processorNumberEnd + && event.playing && event.recording == expected.recording && event.stageTimingsAvailable + && event.processorIdsAvailable && event.threadCyclesRequested && event.threadCyclesValid; +} +} + +void runRealtimeCallbackTraceRegression(const std::function& check) +{ + RealtimeCallbackTraceQueue<8> queue; + const bool defaultOff = !queue.getStats().enabled && !queue.tryPush(fixture(16)) + && !queue.shouldCapture(queue.getOptions(), 16, 16) && queue.getStats().published == 0; + check("callback_trace_default_disabled_without_publication", defaultOff); + queue.setEnabled(true, true); + bool exact = true; + for (std::uint64_t counter = 1; counter <= 8; ++counter) exact &= queue.tryPush(fixture(counter)); + const bool fullDrops = !queue.tryPush(fixture(9)) && queue.getStats().droppedFull == 1; + std::array batch; + auto count = queue.drain(batch.data(), batch.size()); + exact &= count == 8; + for (std::size_t index = 0; index < count; ++index) + exact &= coherent(batch[index]) && batch[index].callbackCounter == index + 1; + check("callback_trace_full_drops_without_overwriting_unread_payloads", fullDrops && exact); + + bool wraps = true; + for (std::uint64_t group = 1; group <= 100; ++group) + { + for (std::uint64_t index = 0; index < 5; ++index) wraps &= queue.tryPush(fixture(group * 16 + index)); + count = queue.drain(batch.data(), batch.size()); + wraps &= count == 5; + for (std::size_t index = 0; index < count; ++index) + wraps &= coherent(batch[index]) && batch[index].callbackCounter == group * 16 + index; + } + check("callback_trace_wraparound_preserves_sequence_and_paired_fields", wraps && queue.getStats().pending == 0); + queue.tryPush(fixture(1234)); + queue.setEnabled(false); + const bool disabled = !queue.tryPush(fixture(1235)) && queue.drain(batch.data(), batch.size()) == 1 + && coherent(batch[0]) && batch[0].callbackCounter == 1234 && !queue.getStats().threadCyclesRequested; + check("callback_trace_disable_keeps_published_sample_without_reset_race", disabled); + + queue.setEnabled(true); + std::size_t sampled = 0; + bool rates = true; + for (std::uint64_t counter = 1; counter <= 6000; ++counter) + if (queue.shouldCapture(queue.getOptions(), counter, 16)) + { + rates &= queue.tryPush(fixture(counter)) && queue.drain(batch.data(), batch.size()) == 1 + && coherent(batch[0]) && batch[0].callbackCounter == counter; + ++sampled; + } + check("callback_trace_eight_sample_cadence_and_rate_payload", rates && sampled == 375 + && !queue.getStats().threadCyclesRequested && !queue.shouldCapture(queue.getOptions(), 16, 0)); + check("callback_trace_captures_unsampled_deadline_misses", + queue.shouldPublish(queue.getOptions(), 17, 0.2, 8.0 / 48.0) + && !queue.shouldPublish(queue.getOptions(), 17, 0.1, 8.0 / 48.0) + && queue.shouldPublish(queue.getOptions(), 32, 0.1, 8.0 / 48.0) + && !queue.shouldPublish(0, 17, 0.2, 8.0 / 48.0)); + + RealtimeCallbackTraceQueue<64> concurrent; + concurrent.setEnabled(true, true); + constexpr std::uint64_t attempts = 100000; + std::atomic finished { false }; + std::uint64_t accepted = 0, received = 0, previous = 0; + bool publication = true; + std::thread producer([&] { + for (std::uint64_t counter = 1; counter <= attempts; ++counter) + if (concurrent.tryPush(fixture(counter))) ++accepted; + finished.store(true, std::memory_order_release); + }); + std::array concurrentBatch; + do + { + const auto receivedNow = concurrent.drain(concurrentBatch.data(), concurrentBatch.size()); + for (std::size_t index = 0; index < receivedNow; ++index) + { + const auto& event = concurrentBatch[index]; + publication &= coherent(event) && event.callbackCounter > previous; + previous = event.callbackCounter; ++received; + } + } + while (!finished.load(std::memory_order_acquire) || concurrent.getStats().pending != 0); + producer.join(); + const auto stats = concurrent.getStats(); + check("callback_trace_concurrent_publication_is_coherent_and_loss_accounted", + publication && accepted == received && received == stats.published && received == stats.consumed + && received + stats.droppedFull == attempts && stats.pending == 0); +} diff --git a/Source/RealtimeCallbackTraceRegression.h b/Source/RealtimeCallbackTraceRegression.h new file mode 100644 index 00000000..f035831e --- /dev/null +++ b/Source/RealtimeCallbackTraceRegression.h @@ -0,0 +1,3 @@ +#pragma once +#include +void runRealtimeCallbackTraceRegression(const std::function& check); diff --git a/Source/RealtimeSourceReadWindow.h b/Source/RealtimeSourceReadWindow.h new file mode 100644 index 00000000..b50ac937 --- /dev/null +++ b/Source/RealtimeSourceReadWindow.h @@ -0,0 +1,66 @@ +#pragma once +#include +#include + +// Amortize tiny callbacks' buffered-reader locking without delaying playback. +// Construct on the control thread before publishing the associated reader. +class RealtimeSourceReadWindow final +{ +public: + struct Counters { + std::atomic hits { 0 }, reads { 0 }, fallbacks { 0 }; + }; + static constexpr int capacity = 512; + explicit RealtimeSourceReadWindow(int channels, Counters* aggregate = nullptr) + : samples(channels, capacity), counters(aggregate != nullptr ? aggregate : &ownCounters) {} + + bool read(juce::AudioFormatReader& reader, juce::AudioBuffer& destination, + int count, juce::int64 start) + { + const int channels = static_cast(reader.numChannels); + if (count > 0 && count <= capacity && start >= 0 + && channels == samples.getNumChannels() + && destination.getNumChannels() >= channels) + { + if (start >= cachedStart && cachedSamples >= count + && start - cachedStart <= cachedSamples - count) + { + counters->hits.fetch_add(1, std::memory_order_relaxed); + copy(destination, channels, count, static_cast(start - cachedStart)); + return true; + } + const int ahead = static_cast(juce::jlimit(0, capacity, reader.lengthInSamples - start)); + if (ahead >= count) + { + counters->reads.fetch_add(1, std::memory_order_relaxed); + if (reader.read(&samples, 0, ahead, start, true, true)) + { + cachedStart = start; cachedSamples = ahead; + copy(destination, channels, count, 0); + return true; + } + cachedSamples = 0; + } + } + // A missing lookahead frame must never conceal audible frames which + // are already available. Preserve the exact original read contract. + counters->fallbacks.fetch_add(1, std::memory_order_relaxed); + return reader.read(&destination, 0, count, start, true, true); + } + + juce::uint64 getHits() const { return counters->hits.load(std::memory_order_relaxed); } + juce::uint64 getReads() const { return counters->reads.load(std::memory_order_relaxed); } + juce::uint64 getFallbacks() const { return counters->fallbacks.load(std::memory_order_relaxed); } + +private: + void copy(juce::AudioBuffer& destination, int channels, int count, int offset) const + { + for (int channel = 0; channel < channels; ++channel) + destination.copyFrom(channel, 0, samples, channel, offset, count); + } + juce::AudioBuffer samples; + juce::int64 cachedStart = -1; + int cachedSamples = 0; + Counters ownCounters; + Counters* counters; +}; diff --git a/Source/RealtimeSourceReadWindowRegression.cpp b/Source/RealtimeSourceReadWindowRegression.cpp new file mode 100644 index 00000000..33b9fb59 --- /dev/null +++ b/Source/RealtimeSourceReadWindowRegression.cpp @@ -0,0 +1,67 @@ +#include "RealtimeSourceReadWindowRegression.h" +#include "RealtimeSourceReadWindow.h" +#include + +namespace { +class FixtureReader final : public juce::AudioFormatReader +{ +public: + FixtureReader(int channels, double rate) : AudioFormatReader(nullptr, "Read-window fixture") + { numChannels = static_cast(channels); sampleRate = rate; lengthInSamples = 10003; bitsPerSample = 32; usesFloatingPointData = true; } + static float value(int channel, juce::int64 sample) { return static_cast((sample % 127) - 63) / 128.0f * (channel == 0 ? 1.0f : -0.5f); } + bool readSamples(int* const* destinations, int channels, int offset, juce::int64 start, int count) override + { + ++calls; + if (failAll || (onlyShortReads && count > 32)) return false; + for (int channel = 0; channel < channels; ++channel) + if (destinations[channel] != nullptr) + for (int index = 0; index < count; ++index) + reinterpret_cast(destinations[channel])[offset + index] = value(channel, start + index); + return true; + } + int calls = 0; + bool onlyShortReads = false, failAll = false; +}; +bool matches(const juce::AudioBuffer& data, int count, juce::int64 start) +{ + for (int channel = 0; channel < data.getNumChannels(); ++channel) + for (int index = 0; index < count; ++index) + if (data.getSample(channel, index) != FixtureReader::value(channel, start + index)) return false; + return true; +} +} + +void runRealtimeSourceReadWindowRegression(const std::function& check) +{ + bool exact = true, bounded = true; + for (const int channels : { 1, 2 }) for (const double rate : { 44100.0, 48000.0 }) + { + FixtureReader reader(channels, rate); + RealtimeSourceReadWindow window(channels); + juce::AudioBuffer block(channels, 1024); + for (int position = 0; position < 9600; position += 8) + exact &= window.read(reader, block, 12, position) && matches(block, 12, position); + // 1200 tiny reads require fewer than25 bounded reader acquisitions. + bounded &= reader.calls < 25 && window.getHits() > 1175 && window.getFallbacks() == 0; + for (const auto position : { 8300, 2, 701, 9900, 10001, 17 }) + { + const int count = juce::jmin(12, static_cast(reader.lengthInSamples) - position); + exact &= window.read(reader, block, count, position) && matches(block, count, position); + } + exact &= window.read(reader, block, 1024, 1024) && matches(block, 1024, 1024); + } + check("stream_window_bit_exact_channel_rate_seek_eof_and_large_blocks", exact); + check("stream_window_amortizes_eight_frame_reader_acquisitions", bounded); + FixtureReader shortReader(2, 48000); shortReader.onlyShortReads = true; + RealtimeSourceReadWindow shortWindow(2); juce::AudioBuffer block(2, 32); + check("stream_window_missing_lookahead_retains_ready_audible_frames", + shortWindow.read(shortReader, block, 12, 113) && matches(block, 12, 113) + && shortReader.calls == 2 && shortWindow.getFallbacks() == 1); + shortReader.failAll = true; + check("stream_window_preserves_real_read_failure", !shortWindow.read(shortReader, block, 12, 777)); + FixtureReader replacement(2, 48000); + RealtimeSourceReadWindow replacementWindow(2); + check("stream_window_replacement_starts_with_empty_identity_cache", + replacementWindow.read(replacement, block, 12, 99) && matches(block, 12, 99) + && replacement.calls == 1 && replacementWindow.getHits() == 0); +} diff --git a/Source/RealtimeSourceReadWindowRegression.h b/Source/RealtimeSourceReadWindowRegression.h new file mode 100644 index 00000000..657b2a0b --- /dev/null +++ b/Source/RealtimeSourceReadWindowRegression.h @@ -0,0 +1,3 @@ +#pragma once +#include +void runRealtimeSourceReadWindowRegression(const std::function& check); diff --git a/Source/RecordingDiskWriter.cpp b/Source/RecordingDiskWriter.cpp new file mode 100644 index 00000000..4707a375 --- /dev/null +++ b/Source/RecordingDiskWriter.cpp @@ -0,0 +1,68 @@ +#include "RecordingDiskWriter.h" + +RecordingDiskWriter::RecordingDiskWriter(std::unique_ptr destination, + juce::TimeSliceThread& thread, + int fifoSamples, + int flushInterval) + : fifo(fifoSamples), + buffer(static_cast(destination->getNumChannels()), fifoSamples), + backgroundThread(thread), writer(std::move(destination)), + flushIntervalSamples(juce::jmax(0, flushInterval)) +{ + jassert(fifoSamples >= 2); + buffer.clear(); + backgroundThread.addTimeSliceClient(this); // Control thread only. +} + +RecordingDiskWriter::~RecordingDiskWriter() +{ + // removeTimeSliceClient waits for an in-flight consumer to finish. The + // recorder has already unpublished the producer under its writer lock. + backgroundThread.removeTimeSliceClient(this); + while (drainOneBatch()) {} + writer->flush(); +} + +bool RecordingDiskWriter::write(const float* const* channels, int numSamples) noexcept +{ + if (numSamples <= 0) return true; + int start1 = 0, size1 = 0, start2 = 0, size2 = 0; + fifo.prepareToWrite(numSamples, start1, size1, start2, size2); + if (size1 + size2 < numSamples) return false; // Reject the whole block. + for (int channel = 0; channel < buffer.getNumChannels(); ++channel) + { + buffer.copyFrom(channel, start1, channels[channel], size1); + if (size2 > 0) + buffer.copyFrom(channel, start2, channels[channel] + size1, size2); + } + fifo.finishedWrite(numSamples); + return true; +} + +int RecordingDiskWriter::useTimeSlice() +{ + drainOneBatch(); + // Polling only changes when PCM reaches disk; input monitoring and preview + // publication do not wait for this consumer. No audio-thread notification. + return fifo.getNumReady() > 0 ? 0 : emptyPollMilliseconds; +} + +bool RecordingDiskWriter::drainOneBatch() +{ + int start1 = 0, size1 = 0, start2 = 0, size2 = 0; + fifo.prepareToRead(juce::jmax(1, fifo.getTotalSize() / 4), start1, size1, start2, size2); + if (size1 + size2 == 0) return false; + + // CheckedRecordingWriter records background failures and acknowledged PCM. + // Retire attempted ranges even after a disk fault, so stop always terminates. + if (size1 > 0) writer->writeFromAudioSampleBuffer(buffer, start1, size1); + if (size2 > 0) writer->writeFromAudioSampleBuffer(buffer, start2, size2); + fifo.finishedRead(size1 + size2); + samplesSinceFlush += size1 + size2; + if (flushIntervalSamples > 0 && samplesSinceFlush >= flushIntervalSamples) + { + samplesSinceFlush %= flushIntervalSamples; + writer->flush(); + } + return true; +} diff --git a/Source/RecordingDiskWriter.h b/Source/RecordingDiskWriter.h new file mode 100644 index 00000000..bf1f82bf --- /dev/null +++ b/Source/RecordingDiskWriter.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include + +// One audio producer, one disk consumer. Storage and client registration are +// prepared on the control thread; write() never wakes or locks the disk thread. +// The producer must be unpublished before control-thread destruction begins. +class RecordingDiskWriter final : private juce::TimeSliceClient +{ +public: + RecordingDiskWriter(std::unique_ptr destination, + juce::TimeSliceThread& thread, + int fifoSamples, + int flushInterval); + ~RecordingDiskWriter() override; + + bool write(const float* const* channels, int numSamples) noexcept; + +private: + int useTimeSlice() override; + bool drainOneBatch(); // Disk thread, or control thread after client removal. + + juce::AbstractFifo fifo; + juce::AudioBuffer buffer; + juce::TimeSliceThread& backgroundThread; + std::unique_ptr writer; + const int flushIntervalSamples; + juce::int64 samplesSinceFlush = 0; + static constexpr int emptyPollMilliseconds = 5; + + friend struct RecordingDiskWriterRegressionAccess; + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(RecordingDiskWriter) +}; diff --git a/Source/RecordingDiskWriterRegression.cpp b/Source/RecordingDiskWriterRegression.cpp new file mode 100644 index 00000000..c2d7a6e3 --- /dev/null +++ b/Source/RecordingDiskWriterRegression.cpp @@ -0,0 +1,211 @@ +#include "RecordingDiskWriterRegression.h" +#include "RecordingDiskWriter.h" +#include "RecordingWriterSafety.h" +#include + +struct RecordingDiskWriterRegressionAccess +{ + static int service(RecordingDiskWriter& writer) { return writer.useTimeSlice(); } + static int pending(const RecordingDiskWriter& writer) { return writer.fifo.getNumReady(); } +}; + +namespace +{ +struct ObservedPCM +{ + explicit ObservedPCM(int channels) : samples(static_cast(channels)) {} + std::vector> samples; + int writeCalls = 0, flushCalls = 0, failWriteCall = 0; + bool failFlush = false; +}; + +class ObservedWriter final : public juce::AudioFormatWriter +{ +public: + explicit ObservedWriter(std::shared_ptr state) + : AudioFormatWriter(nullptr, "FIFO regression PCM", 48000, + static_cast(state->samples.size()), 32), pcm(std::move(state)) + { usesFloatingPointData = true; } + bool write(const int** channels, int samples) override + { + ++pcm->writeCalls; + if (pcm->writeCalls == pcm->failWriteCall) return false; + for (size_t channel = 0; channel < pcm->samples.size(); ++channel) + { + const auto* values = reinterpret_cast(channels[channel]); + pcm->samples[channel].insert(pcm->samples[channel].end(), values, values + samples); + } + return true; + } + bool flush() override { ++pcm->flushCalls; return !pcm->failFlush; } +private: + std::shared_ptr pcm; +}; + +float sampleValue(int channel, int sample) +{ + // Exactly representable, channel-distinct PCM, repeated far beyond FIFO wrap. + return static_cast((sample % 1024) + 1) / (channel == 0 ? 2048.0f : -4096.0f); +} + +void fill(juce::AudioBuffer& block, int firstSample) +{ + for (int channel = 0; channel < block.getNumChannels(); ++channel) + for (int sample = 0; sample < block.getNumSamples(); ++sample) + block.setSample(channel, sample, sampleValue(channel, firstSample + sample)); +} + +bool exact(const ObservedPCM& pcm, int samples) +{ + for (size_t channel = 0; channel < pcm.samples.size(); ++channel) + { + if (pcm.samples[channel].size() != static_cast(samples)) return false; + for (int sample = 0; sample < samples; ++sample) + if (pcm.samples[channel][static_cast(sample)] + != sampleValue(static_cast(channel), sample)) return false; + } + return true; +} + +void drain(RecordingDiskWriter& writer) +{ + while (RecordingDiskWriterRegressionAccess::pending(writer) > 0) + RecordingDiskWriterRegressionAccess::service(writer); +} +} + +void runRecordingDiskWriterRegression(const std::function& check) +{ + // Stopped worker permits deterministic interleavings without timing claims. + juce::TimeSliceThread idleThread("RecordingFIFO-Deterministic"); + for (const int channels : { 1, 2 }) + { + auto pcm = std::make_shared(channels); + bool accepted = true; + { + RecordingDiskWriter writer(std::make_unique(pcm), idleThread, 65536, 240000); + juce::AudioBuffer block(channels, 8); + accepted &= idleThread.getNumClients() == 1; + for (int sample = 0; sample < 128; sample += 8) + { + fill(block, sample); + accepted &= writer.write(block.getArrayOfReadPointers(), 8); + } + } + check(channels == 1 ? "recording_fifo_eight_frame_mono_exact" : "recording_fifo_eight_frame_stereo_exact", + accepted && exact(*pcm, 128) && idleThread.getNumClients() == 0 && pcm->flushCalls > 0); + } + { + auto pcm = std::make_shared(2); + bool invariant = true; + { + RecordingDiskWriter writer(std::make_unique(pcm), idleThread, 33, 0); + juce::AudioBuffer block(2, 8); + for (int sample = 0; sample < 24; sample += 8) + { fill(block, sample); invariant &= writer.write(block.getArrayOfReadPointers(), 8); } + RecordingDiskWriterRegressionAccess::service(writer); // Free exactly eight frames. + for (int sample = 24; sample < 40; sample += 8) + { fill(block, sample); invariant &= writer.write(block.getArrayOfReadPointers(), 8); } + invariant &= RecordingDiskWriterRegressionAccess::pending(writer) == 32; + fill(block, 40); + invariant &= !writer.write(block.getArrayOfReadPointers(), 8) + && RecordingDiskWriterRegressionAccess::pending(writer) == 32; + drain(writer); + } + check("recording_fifo_wrap_and_full_reject_whole_block", invariant && exact(*pcm, 40)); + } + { + auto pcm = std::make_shared(2); + bool invariant = true; + { + RecordingDiskWriter writer(std::make_unique(pcm), idleThread, 65536, 240000); + juce::AudioBuffer block(2, 8); + for (int sample = 0; sample < 65528; sample += 8) + { fill(block, sample); invariant &= writer.write(block.getArrayOfReadPointers(), 8); } + invariant &= pcm->writeCalls == 0 && RecordingDiskWriterRegressionAccess::pending(writer) == 65528; + invariant &= !writer.write(block.getArrayOfReadPointers(), 8); + drain(writer); + } + check("recording_fifo_delayed_consumer_preserves_all_accepted_frames", invariant && exact(*pcm, 65528)); + } + { + auto pcm = std::make_shared(2); + bool accepted = true; + { + RecordingDiskWriter writer(std::make_unique(pcm), idleThread, 33, 0); + juce::AudioBuffer block(2, 8); + fill(block, 0); accepted &= writer.write(block.getArrayOfReadPointers(), 8); + RecordingDiskWriterRegressionAccess::service(writer); + fill(block, 8); accepted &= writer.write(block.getArrayOfReadPointers(), 8); + fill(block, 16); accepted &= writer.write(block.getArrayOfReadPointers(), 3); + // Destruction must drain both a complete callback and the exact tail. + } + check("recording_fifo_stop_drains_exact_partial_tail", accepted && exact(*pcm, 19)); + } + { + auto pcm = std::make_shared(1); + pcm->failWriteCall = 2; + auto status = std::make_shared(); + juce::MemoryOutputStream checkedStream; + bool accepted = true; + { + auto checked = std::make_unique(std::make_unique(pcm), status, &checkedStream); + RecordingDiskWriter writer(std::move(checked), idleThread, 33, 0); + juce::AudioBuffer block(1, 8); + for (int sample = 0; sample < 24; sample += 8) + { fill(block, sample); accepted &= writer.write(block.getArrayOfReadPointers(), 8); } + } + check("recording_fifo_failure_reports_only_acknowledged_frames", accepted && exact(*pcm, 8) + && status->writtenSamples.load() == 8 && status->finished.load() + && status->fault.load() == RecordingWriteStatus::writeFailed); + } + { + auto pcm = std::make_shared(1); + auto status = std::make_shared(); + juce::MemoryOutputStream checkedStream; + bool invariant = true; + { + auto checked = std::make_unique(std::make_unique(pcm), status, &checkedStream); + RecordingDiskWriter writer(std::move(checked), idleThread, 33, 16); + juce::AudioBuffer block(1, 8); + fill(block, 0); invariant &= writer.write(block.getArrayOfReadPointers(), 8); + invariant &= RecordingDiskWriterRegressionAccess::service(writer) == 5 && pcm->flushCalls == 0; + fill(block, 8); invariant &= writer.write(block.getArrayOfReadPointers(), 8); + pcm->failFlush = true; + RecordingDiskWriterRegressionAccess::service(writer); + invariant &= pcm->flushCalls == 1 && status->fault.load() == RecordingWriteStatus::flushFailed; + } + check("recording_fifo_periodic_flush_failure_and_empty_poll", invariant && exact(*pcm, 16) + && status->writtenSamples.load() == 16 && status->finished.load()); + } + { + auto pcm = std::make_shared(2); + juce::TimeSliceThread worker("RecordingFIFO-Publication"); + bool accepted = worker.startThread(juce::Thread::Priority::normal); + { + RecordingDiskWriter writer(std::make_unique(pcm), worker, 65536, 240000); + juce::AudioBuffer block(2, 8); + for (int sample = 0; sample < 40000; sample += 8) + { fill(block, sample); accepted &= writer.write(block.getArrayOfReadPointers(), 8); } + } + worker.stopThread(-1); + check("recording_fifo_concurrent_worker_publication_exact", accepted && exact(*pcm, 40000) + && worker.getNumClients() == 0); + } + { + auto pcm = std::make_shared(1); + bool invariant = true; + { + RecordingDiskWriter writer(std::make_unique(pcm), idleThread, 33, 48000 * 5); + juce::AudioBuffer block(1, 8); + for (int sample = 0; sample < 48000 * 5; sample += 8) + { + fill(block, sample); + invariant &= writer.write(block.getArrayOfReadPointers(), 8); + RecordingDiskWriterRegressionAccess::service(writer); + invariant &= pcm->flushCalls == (sample == 48000 * 5 - 8 ? 1 : 0); + } + } + check("recording_fifo_five_second_flush_at_48000", invariant && exact(*pcm, 48000 * 5)); + } +} diff --git a/Source/RecordingDiskWriterRegression.h b/Source/RecordingDiskWriterRegression.h new file mode 100644 index 00000000..3222b3e4 --- /dev/null +++ b/Source/RecordingDiskWriterRegression.h @@ -0,0 +1,3 @@ +#pragma once +#include +void runRecordingDiskWriterRegression(const std::function& check); diff --git a/Source/RecordingQualification.cpp b/Source/RecordingQualification.cpp new file mode 100644 index 00000000..23128a3f --- /dev/null +++ b/Source/RecordingQualification.cpp @@ -0,0 +1,147 @@ +#include "RecordingQualification.h" +#include "AudioRecorder.h" +#include +#include +#include +#include + +juce::var runRecordingQualification(const juce::File& source, + const juce::File& outputDirectory, + double seconds) +{ + auto* root = new juce::DynamicObject(); + juce::Array checks; + bool success = true; + const auto check = [&] (const char* id, bool pass, const juce::String& detail) + { + auto* item = new juce::DynamicObject(); + item->setProperty("id", id); item->setProperty("status", pass ? "pass" : "fail"); + item->setProperty("detail", detail); checks.add(juce::var(item)); success &= pass; + }; + root->setProperty("scope", "Paced native AudioRecorder capture of actual project PCM, concurrent live-peak queries and maintenance; no device opened."); + root->setProperty("hardwareRecording", "not_asserted"); + root->setProperty("electricalTiming", "not_asserted"); + root->setProperty("listeningAcceptance", "not_asserted"); + const auto finish = [&] { root->setProperty("success", success); root->setProperty("objectiveGateStatus", success ? "pass" : "fail"); root->setProperty("checks", checks); return juce::var(root); }; + if (!std::isfinite(seconds) || seconds < 1.0 || seconds > 600.0) + { check("bounded_duration", false, "Expected 1..600 seconds"); return finish(); } + if (!outputDirectory.createDirectory().wasOk()) + { check("output_directory", false, outputDirectory.getFullPathName()); return finish(); } + juce::AudioFormatManager formats; formats.registerBasicFormats(); + std::unique_ptr sourceReader(formats.createReaderFor(source)); + if (!sourceReader || !std::isfinite(sourceReader->sampleRate) + || sourceReader->sampleRate < 8000 || sourceReader->sampleRate > 192000) + { check("source_readable", false, source.getFullPathName()); return finish(); } + const double rate = sourceReader->sampleRate; + const int frames = static_cast(std::llround(rate * seconds)); + const int channels = juce::jlimit(1, 2, static_cast(sourceReader->numChannels)); + juce::AudioBuffer input(channels, frames); + if (sourceReader->lengthInSamples < frames || !sourceReader->read(&input, 0, frames, 0, true, channels > 1)) + { check("source_pcm_loaded", false, "Source must contain the whole requested capture"); return finish(); } + input.applyGain(0.25f); // Keep the 16-bit production recorder comfortably below clipping. + sourceReader.reset(); + AudioRecorder recorder(outputDirectory.getChildFile("recovery")); + const auto destination = outputDirectory.getNonexistentChildFile("paced-project-capture", ".wav"); + const auto start = juce::Time::getMillisecondCounterHiRes(); + const bool started = recorder.startRecording("qualification", destination, rate, channels); + root->setProperty("recordStartMilliseconds", juce::Time::getMillisecondCounterHiRes() - start); + check("recorder_started", started, destination.getFullPathName()); + if (!started) return finish(); + std::atomic published { 0 }; + std::atomic stop { false }; + int queryCount = 0, invalidResponses = 0, tailPeaks = 0; + std::vector queryTimes, callbackTimes; + constexpr int blockSize = 8; + root->setProperty("blockSize", blockSize); + callbackTimes.reserve(static_cast(frames / blockSize + 1)); + std::thread preview([&] + { + while (!stop.load(std::memory_order_acquire)) + { + recorder.maintainRecordingPreviews(); + const int available = published.load(std::memory_order_acquire); + // Exercise both overview aggregation and detailed, moving right edge. + const int stride = queryCount % 2 == 0 ? 256 : 8192; + const auto first = static_cast(juce::jmax(0, available / stride - 2048)) * stride; + const auto began = juce::Time::getMillisecondCounterHiRes(); + const auto peaks = recorder.getRecordingPeaks("qualification", stride, 8192, first); + queryTimes.push_back(juce::Time::getMillisecondCounterHiRes() - began); + if (const auto* values = peaks.getArray(); values != nullptr && !values->isEmpty()) + { + const int count = (values->size() - 1) / (channels * 2); + if (static_cast((*values)[0]) != channels || count > 8192) ++invalidResponses; + for (int index = 1; index < values->size(); ++index) + if (!std::isfinite(static_cast((*values)[index]))) ++invalidResponses; + if (available > static_cast(rate * 121.0)) tailPeaks = juce::jmax(tailPeaks, count); + } + ++queryCount; + std::this_thread::sleep_for(std::chrono::milliseconds(40)); + } + }); + using Clock = std::chrono::steady_clock; + const auto clockStart = Clock::now(); + for (int position = 0; position < frames;) + { + const int count = juce::jmin(blockSize, frames - position); + float* pointers[2] { input.getWritePointer(0, position), input.getWritePointer(channels - 1, position) }; + juce::AudioBuffer block(pointers, channels, count); + const auto began = juce::Time::getMillisecondCounterHiRes(); + recorder.writeBlock("qualification", block, count, 17.25 + position / rate); + callbackTimes.push_back(juce::Time::getMillisecondCounterHiRes() - began); + position += count; + published.store(position, std::memory_order_release); + std::this_thread::sleep_until(clockStart + std::chrono::duration_cast(std::chrono::duration(position / rate))); + } + root->setProperty("elapsedSeconds", std::chrono::duration(Clock::now() - clockStart).count()); + stop.store(true, std::memory_order_release); preview.join(); + const auto takes = recorder.stopAllRecordings(rate); + check("one_completed_take", takes.size() == 1, juce::String(static_cast(takes.size()))); + check("writer_lock_misses", recorder.getWriteLockMissCount() == 0, juce::String(recorder.getWriteLockMissCount())); + check("writer_fifo_overflows", recorder.getWriterBufferOverflowCount() == 0, juce::String(recorder.getWriterBufferOverflowCount())); + const auto failures = recorder.takeWriteFailures(); + check("disk_write_failures", failures.getArray() != nullptr && failures.getArray()->isEmpty(), juce::JSON::toString(failures)); + check("concurrent_live_peaks", queryCount > seconds * 10 && invalidResponses == 0, juce::String(queryCount) + " queries, invalid=" + juce::String(invalidResponses)); + if (seconds > 122.0) check("preview_after_120_second_chunk", tailPeaks > 0, juce::String(tailPeaks)); + if (!takes.empty()) + { + check("first_block_sample_placement", std::abs(takes[0].startTime - 17.25) < 1.0 / rate, juce::String(takes[0].startTime, 9)); + check("duration_no_dropped_frames", std::abs(takes[0].duration * rate - frames) < 0.5, juce::String(takes[0].duration, 9)); + } + std::unique_ptr recorded(formats.createReaderFor(destination)); + check("complete_pcm_file", recorded != nullptr && recorded->lengthInSamples == frames && static_cast(recorded->numChannels) == channels, destination.getFullPathName()); + double maxError = 0.0; + if (recorded) + { + juce::AudioBuffer block(channels, 8192); + bool readable = true; + for (int position = 0; position < frames; position += block.getNumSamples()) + { + const int count = juce::jmin(block.getNumSamples(), frames - position); + readable &= recorded->read(&block, 0, count, position, true, channels > 1); + for (int channel = 0; channel < channels; ++channel) + for (int index = 0; index < count; ++index) + maxError = juce::jmax(maxError, std::abs(static_cast(block.getSample(channel, index) - input.getSample(channel, position + index)))); + } + check("entire_capture_matches_source_within_pcm16_quantization", readable && maxError <= 1.1 / 32768.0, juce::String(maxError, 12)); + } + const auto timing = [] (std::vector& times) + { + auto* value = new juce::DynamicObject(); + std::sort(times.begin(), times.end()); + if (!times.empty()) + { + value->setProperty("count", static_cast(times.size())); + value->setProperty("p95Milliseconds", times[static_cast((times.size() - 1) * 0.95)]); + value->setProperty("p99Milliseconds", times[static_cast((times.size() - 1) * 0.99)]); + value->setProperty("maximumMilliseconds", times.back()); + } + return juce::var(value); + }; + root->setProperty("writeBlockTiming", timing(callbackTimes)); + root->setProperty("peakQueryTiming", timing(queryTimes)); + root->setProperty("timingClassification", "diagnostic_only: wall-clock timings depend on machine scheduling and Debug build"); + root->setProperty("sampleRate", rate); root->setProperty("channels", channels); + root->setProperty("frames", frames); root->setProperty("source", source.getFullPathName()); + root->setProperty("capture", destination.getFullPathName()); root->setProperty("maxPCMError", maxError); + return finish(); +} diff --git a/Source/RecordingQualification.h b/Source/RecordingQualification.h new file mode 100644 index 00000000..7fe1c7c1 --- /dev/null +++ b/Source/RecordingQualification.h @@ -0,0 +1,7 @@ +#pragma once +#include + +// Explicitly opted-in, paced software capture. Never opens a device or UI. +juce::var runRecordingQualification(const juce::File& source, + const juce::File& outputDirectory, + double seconds); diff --git a/Source/RecordingStartRegression.cpp b/Source/RecordingStartRegression.cpp new file mode 100644 index 00000000..7943e0e9 --- /dev/null +++ b/Source/RecordingStartRegression.cpp @@ -0,0 +1,186 @@ +#include "AudioEngine.h" +#include "AudioDeviceConfigurationRegression.h" +#include +#include + +juce::var AudioEngine::runPreparedRecordingStartRegression(const juce::File& outputDirectory) +{ + auto* result = new juce::DynamicObject(); + juce::Array checks; + const auto check = [&](const char* id, bool pass, const juce::String& detail) { + auto* item = new juce::DynamicObject(); + item->setProperty("id", id); item->setProperty("pass", pass); item->setProperty("detail", detail); + checks.add(item); + }; + auto engine = std::make_unique(DeviceStartup::offline); + engine->setRealtimeTrackWorkerCount(0); + AudioDeviceConfigurationRegression::Device device("Offline recording fixture", "Synthetic PCM", + std::make_shared(AudioDeviceConfigurationRegression::OpenMode::normal)); + device.open(juce::BigInteger(3), juce::BigInteger(3), 48000.0, 8); + engine->audioDeviceAboutToStart(&device); // Simulated descriptor only; no manager/device registration. + const auto trackId = engine->addTrack("prepared-recording-fixture", "audio"); + engine->setTrackInputChannels(trackId, 0, 2); + engine->setTrackRecordArm(trackId, true); + juce::AudioBuffer input(2, 256), output(2, 256); + input.clear(); input.setSample(0, 0, 0.5f); input.setSample(1, 0, -0.25f); + const float* inputs[] { input.getReadPointer(0), input.getReadPointer(1) }; + float* outputs[] { output.getWritePointer(0), output.getWritePointer(1) }; + const auto callback = [&] { + engine->audioDeviceIOCallbackWithContext(inputs, 2, outputs, 2, 8, {}); + }; + for (const double start : { 0.0, 17.25 }) + { + const auto directory = outputDirectory.getChildFile(start == 0.0 ? "prepared-start-zero" : "prepared-start-nonzero"); + engine->setProbeRecordingDirectory(directory); + engine->setTransportPosition(start); + int guards = 0; + bool accepted = false, positionHeld = true; + engine->startPreparedRecordingAsync([&](bool success) { accepted = success; }, [&] { + // On the second validity check the production writer is prepared, + // but playback is still stopped. Simulate 85ms of callback traffic. + if (++guards == 2) + for (int block = 0; block < 512; ++block) { callback(); positionHeld &= engine->getTransportPosition() == start; } + return true; + }); + callback(); + engine->setTransportPlaying(false); + engine->setTransportRecording(false); + const auto completed = engine->getLastCompletedClips(); + juce::AudioFormatManager formats; formats.registerBasicFormats(); + std::unique_ptr reader(completed.empty() ? nullptr : formats.createReaderFor(completed[0].file)); + juce::AudioBuffer recorded(2, 8); + const bool readable = reader && reader->read(&recorded, 0, 8, 0, true, true); + const bool exactStart = accepted && guards == 2 && positionHeld && completed.size() == 1 + && completed[0].startTime == start && reader && reader->lengthInSamples == 8 + && std::abs(completed[0].duration * 48000.0 - 8.0) < 0.5; + check(start == 0.0 ? "prepared_record_start_zero_no_missing_first_block" : "prepared_record_start_nonzero_no_stopped_capture", + exactStart && readable && std::abs(recorded.getSample(0, 0) - 0.5f) <= 1.0f / 32768.0f + && std::abs(recorded.getSample(1, 0) + 0.25f) <= 1.0f / 32768.0f, + "Writers prepare with 512 stopped 8-frame callbacks; first actual 8-frame input block and exact sample-origin metadata survive."); + } + engine->setProbeRecordingDirectory(outputDirectory.getChildFile("prepared-start-cancelled")); + engine->setTransportPosition(3.0); + int guards = 0; + bool cancelledAccepted = true; + engine->startPreparedRecordingAsync([&](bool success) { cancelledAccepted = success; }, [&] { return ++guards != 2; }); + callback(); + const auto cancelledTakes = engine->getLastCompletedClips(); + check("prepared_record_start_cancel_discards_unstarted_capture", + !cancelledAccepted && !engine->isTransportPlaying() && !engine->isTransportRecording() + && engine->getTransportPosition() == 3.0 + && std::all_of(cancelledTakes.begin(), cancelledTakes.end(), [](const auto& take) { return take.duration == 0.0; }), + "A superseded request after writer preparation cannot start playback or capture stopped input."); + + engine->setProbeRecordingDirectory(outputDirectory.getChildFile("prepared-start-punch")); + engine->setTransportPosition(8.0); + engine->setTransportPlaying(true); + callback(); + const double punchPosition = engine->getTransportPosition(); + bool freshAcceptedWhilePlaying = true; + engine->startPreparedRecordingAsync([&](bool success) { freshAcceptedWhilePlaying = success; }); + engine->setTransportRecording(true); // Existing rolling punch-in path. + callback(); + engine->setTransportPlaying(false); engine->setTransportRecording(false); + const auto punchTakes = engine->getLastCompletedClips(); + check("prepared_record_start_preserves_rolling_punch_in", + !freshAcceptedWhilePlaying && punchTakes.size() == 1 + && std::abs(punchTakes[0].startTime - punchPosition) < 1.0 / 48000.0 + && std::abs(punchTakes[0].duration * 48000.0 - 8.0) < 0.5, + "Fresh-start operation rejects an already rolling transport; ordinary punch-in retains its current sample cursor."); + + const auto invalidDirectory = outputDirectory.getChildFile("prepared-start-invalid-directory"); + invalidDirectory.replaceWithText("A file cannot be a recording directory."); + engine->setProbeRecordingDirectory(invalidDirectory); + engine->setTransportPosition(0.0); + bool failedAccepted = true; + engine->startPreparedRecordingAsync([&](bool success) { failedAccepted = success; }); + check("prepared_record_start_writer_failure_leaves_transport_stopped", + !failedAccepted && !engine->isTransportPlaying() && !engine->isTransportRecording() + && engine->getTransportPosition() == 0.0, + "Writer/destination failure is reported before playback publication."); + check("prepared_record_start_synthetic_fixture_opens_no_hardware", + engine->deviceManager.getCurrentAudioDevice() == nullptr && engine->hardwareCallbackRegistrationCount.load() == 0, + "AudioDeviceAboutToStart and callback calls use a synthetic descriptor and explicit PCM only."); + + // Default recording must remain independent of old clips, sends, monitor + // gain/mute and the fallback recorder lane used by a quarantined worker. + device.open(juce::BigInteger(3), juce::BigInteger(3), 48000.0, 8); + engine->audioDeviceAboutToStart(&device); + const auto sourceFile = outputDirectory.getChildFile("raw-record-distinct-playback.wav"); + juce::WavAudioFormat wav; + std::unique_ptr sourceStream = sourceFile.createOutputStream(); + auto sourceWriter = wav.createWriterFor(sourceStream, juce::AudioFormatWriterOptions() + .withSampleRate(48000).withNumChannels(2).withBitsPerSample(32)); + juce::AudioBuffer background(2, 512); + for (int sample = 0; sample < 512; ++sample) { background.setSample(0, sample, 0.25f); background.setSample(1, sample, -0.125f); } + const bool backgroundWritten = sourceWriter && sourceWriter->writeFromAudioSampleBuffer(background, 0, 512) && sourceWriter->flush(); + sourceWriter.reset(); + engine->playbackEngine.addClip(sourceFile, 0, 512.0 / 48000, trackId, 0, 0, 0, 0, "raw-record-old-clip"); + const auto sender = engine->addTrack("raw-record-send-fixture", "audio"); + engine->playbackEngine.addClip(sourceFile, 0, 512.0 / 48000, sender, 0, 0, 0, 0, "raw-record-send-clip"); + const int send = engine->addTrackSend(sender, trackId); + engine->setTrackSendLevel(sender, send, 0.5f); + for (int sample = 0; sample < 256; ++sample) { input.setSample(0, sample, 0.0625f); input.setSample(1, sample, -0.03125f); } + struct Case { const char* id; bool mono, quarantined, muted, punch; }; + int rawFixtureIndex = 0; + for (const auto& fixture : std::initializer_list { + { "raw_record_eight_frames_stereo_excludes_clips_and_sends", false, false, false, false }, + { "raw_record_eight_frames_mono_preserves_selected_input", true, false, false, false }, + { "raw_record_eight_frames_muted_monitor_still_captures_input", false, false, true, false }, + { "raw_record_eight_frames_punch_has_exact_sample_edges", false, false, false, true }, + { "raw_record_quarantined_lane_excludes_clips_and_sends", false, true, false, false }, + { "raw_record_quarantined_mono_preserves_selected_input", true, true, false, false }, + { "raw_record_quarantined_punch_matches_normal_lane", false, true, false, true } }) + { + // Keep take filenames within the platform path budget. The descriptive + // check ID is intentionally independent of the fixture directory name. + engine->setProbeRecordingDirectory(outputDirectory.getChildFile("raw-input-" + juce::String(++rawFixtureIndex))); + engine->setTrackInputChannels(trackId, fixture.mono ? 1 : 0, fixture.mono ? 1 : 2); + engine->setTrackMute(trackId, fixture.muted); + engine->setPunchRange(19.0 / 48000, 53.0 / 48000, fixture.punch); + engine->setTransportPosition(0); + bool accepted = false; + engine->startPreparedRecordingAsync([&](bool success) { accepted = success; }); + const auto snapshot = std::atomic_load(&engine->realtimeTrackSnapshot); + const auto entry = std::find_if(snapshot->tracks.begin(), snapshot->tracks.end(), [&](const auto& candidate) { return candidate.id == trackId; }); + for (int block = 0; block < 16; ++block) + { + if (fixture.quarantined && entry != snapshot->tracks.end()) + { + if (entry->sendAccumBuffer) + for (int channel = 0; channel < 2; ++channel) + for (int sample = 0; sample < 8; ++sample) + entry->sendAccumBuffer->setSample(channel, sample, channel == 0 ? 0.125f : -0.0625f); + engine->recordTrackHardwareInput(*entry, inputs, 2, 8, block * 8); + engine->currentSamplePosition.store((block + 1) * 8); + } + else engine->audioDeviceIOCallbackWithContext(inputs, 2, outputs, 2, 8, {}); + } + engine->setTransportPlaying(false); engine->setTransportRecording(false); + const auto takes = engine->getLastCompletedClips(); + juce::AudioFormatManager formats; formats.registerBasicFormats(); + std::unique_ptr reader(takes.empty() ? nullptr : formats.createReaderFor(takes[0].file)); + const int expectedFrames = fixture.punch ? 34 : 128; + const int expectedChannels = fixture.mono ? 1 : 2; + juce::AudioBuffer captured(expectedChannels, expectedFrames); + bool exact = accepted && backgroundWritten && send >= 0 && takes.size() == 1 && reader + && reader->lengthInSamples == expectedFrames && static_cast(reader->numChannels) == expectedChannels + && std::abs(takes[0].startTime * 48000 - (fixture.punch ? 19 : 0)) < 0.5 + && reader->read(&captured, 0, expectedFrames, 0, true, expectedChannels > 1); + for (int channel = 0; channel < expectedChannels && exact; ++channel) + for (int sample = 0; sample < expectedFrames; ++sample) + exact &= std::abs(captured.getSample(channel, sample) - input.getSample(fixture.mono ? 1 : channel, 0)) <= 1.0f / 32768.0f; + check(fixture.id, exact, "Selected hardware PCM only; distinct existing clips and incoming sends never enter the take, with identical normal/quarantined punch bounds."); + } + engine->setPunchRange(0, 0, false); + engine->setTrackMute(trackId, false); + engine->audioDeviceStopped(); + result->setProperty("checks", checks); + result->setProperty("primaryBlockSize", 8); + result->setProperty("sampleRate", 48000); + result->setProperty("preparedStoppedCallbackCount", 512); + result->setProperty("scope", "Synthetic native callbacks; electrical latency and listening quality not_asserted."); + const juce::var value(result); + outputDirectory.getChildFile("prepared-record-start-regression.json").replaceWithText(juce::JSON::toString(value, true)); + return value; +} diff --git a/Source/TempoDetectionRegression.cpp b/Source/TempoDetectionRegression.cpp new file mode 100644 index 00000000..2fbe90c2 --- /dev/null +++ b/Source/TempoDetectionRegression.cpp @@ -0,0 +1,74 @@ +#include "TempoDetectionRegression.h" +#include "ClipTempoAnalyzer.h" +#include + +namespace +{ +bool writeFixture(const juce::File& file, const juce::AudioBuffer& buffer) +{ + file.deleteFile(); + juce::WavAudioFormat format; + std::unique_ptr stream(file.createOutputStream()); + if (!stream) return false; + auto writer = format.createWriterFor(stream, juce::AudioFormatWriterOptions() + .withSampleRate(8000.0).withNumChannels(buffer.getNumChannels()).withBitsPerSample(24)); + return writer && writer->writeFromAudioSampleBuffer(buffer, 0, buffer.getNumSamples()); +} +void pulse(juce::AudioBuffer& buffer, double seconds, bool invertedStereo = false) +{ + const int position = juce::roundToInt(seconds * 8000.0); + for (int i = 0; i < 48 && position + i < buffer.getNumSamples(); ++i) + for (int channel = 0; channel < buffer.getNumChannels(); ++channel) + buffer.setSample(channel, position + i, 0.8f * std::exp(-static_cast(i) / 8.0f) + * ((i % 2 == 0) == (!invertedStereo || channel == 0) ? 1.0f : -1.0f)); +} +} + +void runTempoDetectionRegression(const juce::File& root, const std::function& check) +{ + juce::Array evidence; + const auto analyze = [&](const char* name, const juce::AudioBuffer& buffer) { + const auto file = root.getChildFile(juce::String(name) + ".wav"); + const bool written = writeFixture(file, buffer); + auto result = written ? ClipTempoAnalyzer::analyze(file, 0, buffer.getNumSamples() / 8000.0) : juce::var(); + auto* row = new juce::DynamicObject(); row->setProperty("fixture", name); row->setProperty("written", written); + row->setProperty("classification", "deterministic_invariants"); row->setProperty("analysis", result); evidence.add(row); + return result; + }; + juce::AudioBuffer single(1, 8 * 8000); single.clear(); pulse(single, 2.0); + const auto one = analyze("tempo-single-attack", single); + check("tempo_single_attack_has_no_repeated_pulse", !one["error"].isVoid() && static_cast(one["confidence"]) == 0.0); + + juce::AudioBuffer noise(1, 12 * 8000); + juce::uint32 seed = 456789u; float filtered = 0.0f; + for (int i = 0; i < noise.getNumSamples(); ++i) + { + seed = seed * 1664525u + 1013904223u; + filtered = 0.9f * filtered + 0.1f * (static_cast(static_cast(seed >> 8) - 8388608) / 16777216.0f); + noise.setSample(0, i, filtered); + } + const auto irregular = analyze("tempo-colored-unmetered-noise", noise); + check("tempo_colored_noise_is_not_confident_music", !irregular["error"].isVoid() && static_cast(irregular["confidence"]) == 0.0); + + juce::AudioBuffer sustained(1, 12 * 8000); + for (int i = 0; i < sustained.getNumSamples(); ++i) + sustained.setSample(0, i, 0.3f * std::sin(juce::MathConstants::twoPi * 220.0f * static_cast(i) / 8000.0f)); + const auto tone = analyze("tempo-unmetered-sustained-tone", sustained); + check("tempo_sustained_tone_does_not_gain_pulse_from_fft_leakage", !tone["error"].isVoid() && static_cast(tone["confidence"]) == 0.0); + + juce::AudioBuffer changing(1, 48 * 8000); changing.clear(); + for (double at = 0.2; at < 24.0; at += 60.0 / 90.0) pulse(changing, at); + for (double at = 24.2; at < 48.0; at += 60.0 / 150.0) pulse(changing, at); + const auto variable = analyze("tempo-changing-pulse", changing); + check("tempo_changing_sections_do_not_claim_constant_certainty", !variable["error"].isVoid() + || (static_cast(variable["confidence"]) <= 0.35 && static_cast(variable["tempoSpreadBpm"]) > 2.0)); + + juce::AudioBuffer antiPhase(2, 12 * 8000); antiPhase.clear(); + for (double at = 0.2; at < 12.0; at += 0.5) pulse(antiPhase, at, true); + const auto stereo = analyze("tempo-antiphase-clicks", antiPhase); + check("tempo_antiphase_stereo_does_not_cancel_pulse", stereo["error"].isVoid() + && std::abs(static_cast(stereo["bpm"]) - 120.0) < 2.5 + && static_cast(stereo["analyzedDurationSeconds"]) <= 60.0); + auto* report = new juce::DynamicObject(); report->setProperty("checks", evidence); report->setProperty("audibleQuality", "not_asserted"); + root.getChildFile("tempo-consensus-controls.json").replaceWithText(juce::JSON::toString(juce::var(report), true)); +} diff --git a/Source/TempoDetectionRegression.h b/Source/TempoDetectionRegression.h new file mode 100644 index 00000000..61fed64f --- /dev/null +++ b/Source/TempoDetectionRegression.h @@ -0,0 +1,6 @@ +#pragma once +#include +#include + +void runTempoDetectionRegression(const juce::File& root, + const std::function& check); diff --git a/Source/TrackProcessor.cpp b/Source/TrackProcessor.cpp index 928b2b17..ecf26dda 100644 --- a/Source/TrackProcessor.cpp +++ b/Source/TrackProcessor.cpp @@ -1,3 +1,4 @@ +#include "MixerGain.h" #include "PluginAutomationDelivery.h" #include "PreparedMIDINoteLifetimes.h" #include "MIDIParameterChase.h" @@ -14,6 +15,7 @@ #include #include #include +#include #include #include @@ -786,6 +788,8 @@ TrackProcessor::TrackProcessor() TrackProcessor::~TrackProcessor() { stopTimer(); + parallelNAMGraphCandidateGeneration.store(0, std::memory_order_release); + hasEmptyRealtimeGraphForAudio.store(false, std::memory_order_release); hasScheduledMIDIClipsForAudio.store( false, std::memory_order_release); scheduledMIDIClipsForAudio.store( @@ -1762,6 +1766,16 @@ void TrackProcessor::publishRealtimeStateSnapshots() realtimeGraphSnapshotForAudio.store( published.get(), std::memory_order_seq_cst); + // Publish after the graph. A discovery racing a newer publication can + // return an older candidate, which the worker's exact generation check + // rejects before touching DSP. Empty/hosted graphs need no reader epoch. + hasEmptyRealtimeGraphForAudio.store( + published->inputFX.empty() && published->trackFX.empty() && published->instrument == nullptr + && published->sends.empty() && published->sidechainSources.empty(), + std::memory_order_release); + parallelNAMGraphCandidateGeneration.store( + published->containsOnlyNativeNAM ? published->generation : 0, + std::memory_order_release); } } @@ -2152,7 +2166,7 @@ void TrackProcessor::recomputePanGains() { const float currentVolumeDb = trackVolumeDB.load(std::memory_order_relaxed); const float currentPan = trackPan.load(std::memory_order_relaxed); - const float volumeGain = juce::Decibels::decibelsToGain(currentVolumeDb); + const float volumeGain = MixerGain::fromDecibels(currentVolumeDb); float lGain = 1.0f; float rGain = 1.0f; computePanLawGains( @@ -2578,7 +2592,8 @@ bool TrackProcessor::tryProcessBlock(juce::AudioBuffer& buffer, juce::Mid OpenStudioCrashDiagnostics::RealtimeFault::trackBufferContract); return false; } - processBlockInternal(buffer, midiMessages); + if (!tryProcessTinyEmptyGraph(buffer, midiMessages)) + processBlockInternal(buffer, midiMessages); return true; } @@ -2591,9 +2606,11 @@ bool TrackProcessor::isParallelNAMGraphEligible(const RealtimeGraphSnapshot* gra uint64 TrackProcessor::getParallelNAMGraphGeneration() const noexcept { - const ScopedTrackRealtimeReader graphReadGuard(realtimeGraphAudioReaders); - const auto* const graph = realtimeGraphSnapshotForAudio.load(std::memory_order_seq_cst); - return isParallelNAMGraphEligible(graph) ? graph->generation : 0; + const auto candidate = parallelNAMGraphCandidateGeneration.load(std::memory_order_acquire); + return candidate != 0 + && trackType.load(std::memory_order_acquire) == TrackType::Audio + && araFXIndexForRealtime.load(std::memory_order_acquire) < 0 + ? candidate : 0; } bool TrackProcessor::tryProcessBlockParallel(juce::AudioBuffer& buffer, @@ -2632,6 +2649,257 @@ juce::var TrackProcessor::runParallelNAMEligibilityRegression() auto serial = std::make_unique(); auto parallel = std::make_unique(); const bool emptyRejected = parallel->getParallelNAMGraphGeneration() == 0; + const auto probeRegression = [] + { + // Model the eligibility-discovery work of 31 tracks at 48 kHz / 8 + // samples. This has no device or DSP timing claim: actual callback + // deadlines remain a separate hardware qualification. + constexpr int trackCount = 31; + constexpr int callbackBatches = 6000; + std::vector> probes; + probes.reserve(trackCount); + for (int track = 0; track < trackCount; ++track) probes.push_back(std::make_unique()); + std::vector guardedTimes, cachedTimes; + guardedTimes.reserve(callbackBatches); cachedTimes.reserve(callbackBatches); + juce::uint64 guardedSum = 0, cachedSum = 0; + juce::int64 guardedEpochEntries = 0, cachedQueries = 0; + const auto toMicroseconds = 1000000.0 / static_cast(juce::Time::getHighResolutionTicksPerSecond()); + for (int batch = 0; batch < callbackBatches; ++batch) + { + const auto guarded = [&] + { + const auto start = juce::Time::getHighResolutionTicks(); + for (const auto& probe : probes) + { + // Previous discovery path, retained only as a fixture + // reference. The worker still uses this ownership guard. + const ScopedTrackRealtimeReader guard(probe->realtimeGraphAudioReaders); + const auto* graph = probe->realtimeGraphSnapshotForAudio.load(std::memory_order_seq_cst); + guardedSum += probe->isParallelNAMGraphEligible(graph) ? graph->generation : 0; + ++guardedEpochEntries; + } + guardedTimes.push_back(static_cast(juce::Time::getHighResolutionTicks() - start) * toMicroseconds); + }; + const auto cached = [&] + { + const auto start = juce::Time::getHighResolutionTicks(); + for (const auto& probe : probes) + { + cachedSum += probe->getParallelNAMGraphGeneration(); + ++cachedQueries; + } + cachedTimes.push_back(static_cast(juce::Time::getHighResolutionTicks() - start) * toMicroseconds); + }; + if ((batch & 1) == 0) { guarded(); cached(); } + else { cached(); guarded(); } + } + bool readersRetired = true, cachesEmpty = true; + for (const auto& probe : probes) + { + readersRetired = readersRetired && probe->realtimeGraphAudioReaders.load(std::memory_order_seq_cst) == 0; + cachesEmpty = cachesEmpty && probe->parallelNAMGraphCandidateGeneration.load(std::memory_order_acquire) == 0; + } + const auto statistics = [] (std::vector samples) + { + std::sort(samples.begin(), samples.end()); + auto* values = new juce::DynamicObject(); + const auto percentile = [&] (double fraction) + { + return samples[static_cast(fraction * static_cast(samples.size() - 1))]; + }; + values->setProperty("p50Microseconds", percentile(0.5)); + values->setProperty("p95Microseconds", percentile(0.95)); + values->setProperty("p99Microseconds", percentile(0.99)); + return juce::var(values); + }; + const auto expectedQueries = static_cast(trackCount) * callbackBatches; + std::vector>> blocks; + for (auto& probe : probes) + { + probe->prepareToPlay(48000.0, 8); + auto block = std::make_unique>(2, 8); block->clear(); + blocks.push_back(std::move(block)); + } + std::vector generalDSPTimes, tinyDSPTimes; + generalDSPTimes.reserve(callbackBatches); tinyDSPTimes.reserve(callbackBatches); + juce::MidiBuffer emptyMIDI; + bool finiteDSP = true; + for (int batch = 0; batch < callbackBatches; ++batch) + { + const auto general = [&] + { + const auto start = juce::Time::getHighResolutionTicks(); + for (size_t track = 0; track < probes.size(); ++track) + { + finiteDSP = ProcessorSafety::isFinite(*blocks[track]) && finiteDSP; + probes[track]->processBlockInternal(*blocks[track], emptyMIDI); + } + generalDSPTimes.push_back(static_cast(juce::Time::getHighResolutionTicks() - start) * toMicroseconds); + }; + const auto tiny = [&] + { + const auto start = juce::Time::getHighResolutionTicks(); + for (size_t track = 0; track < probes.size(); ++track) + finiteDSP = probes[track]->tryProcessBlock(*blocks[track], emptyMIDI) && finiteDSP; + tinyDSPTimes.push_back(static_cast(juce::Time::getHighResolutionTicks() - start) * toMicroseconds); + }; + if ((batch & 1) == 0) { general(); tiny(); } + else { tiny(); general(); } + } + juce::uint64 tinyBlocks = 0; + for (auto& probe : probes) { tinyBlocks += probe->tinyEmptyGraphProcessedBlocks; probe->releaseResources(); } + auto* evidence = new juce::DynamicObject(); + evidence->setProperty("pass", guardedSum == 0 && cachedSum == 0 && cachesEmpty && readersRetired + && guardedEpochEntries == expectedQueries && cachedQueries == expectedQueries && finiteDSP + && tinyBlocks == static_cast(expectedQueries)); + evidence->setProperty("trackCount", trackCount); + evidence->setProperty("blockSize", 8); evidence->setProperty("sampleRate", 48000); + evidence->setProperty("callbackBatches", callbackBatches); + evidence->setProperty("referenceReaderEpochEntries", guardedEpochEntries); + evidence->setProperty("cachedDiscoveryQueries", cachedQueries); + evidence->setProperty("emptyGraphResultParity", guardedSum == cachedSum); + evidence->setProperty("allReaderEpochsRetired", readersRetired); + evidence->setProperty("guardedReferenceBatch", statistics(std::move(guardedTimes))); + evidence->setProperty("cachedDiscoveryBatch", statistics(std::move(cachedTimes))); + evidence->setProperty("generalTrackDSPBatch", statistics(std::move(generalDSPTimes))); + evidence->setProperty("tinyEmptyTrackDSPBatch", statistics(std::move(tinyDSPTimes))); + evidence->setProperty("tinyTrackDSPBlocks", static_cast(tinyBlocks)); + evidence->setProperty("timingClassification", "diagnostic_only; discovery microbenchmark, not hardware callback timing"); + return juce::var(evidence); + }(); + bool automationOrderParity = true; + int automationCases = 0; + for (const auto mode : { AutomationMode::Off, AutomationMode::Read, AutomationMode::Write, AutomationMode::Touch, AutomationMode::Latch }) + for (int touchState = 0; touchState < 3; ++touchState) + for (int dataState = 0; dataState < 5; ++dataState) + for (bool forceRead : { false, true }) + for (const auto interpolation : { AutomationInterpolation::Discrete, AutomationInterpolation::Linear, AutomationInterpolation::Exponential }) + { + AutomationList lane; + lane.setDefaultValue(0.3f); lane.setMode(mode); lane.setInterpolation(interpolation); + if (touchState > 0) lane.beginTouch(); + if (touchState > 1) lane.endTouch(); + if (dataState == 1 || dataState == 4) lane.setPoints({ { 0.0, 0.2f }, { 1.0, 0.7f } }); + if (dataState == 2 || dataState == 4) lane.setPreviewValue(0.4f); + if (dataState == 3 || dataState == 4) lane.setWrittenValue(0.6f); + serial->setForceAutomationReadForProcessing(forceRead); + const bool before = serial->shouldApplyAutomation(lane) && lane.hasPlaybackData(); + const bool after = lane.hasPlaybackData() && serial->shouldApplyAutomation(lane); + const bool expectedMode = forceRead ? mode != AutomationMode::Off + : mode == AutomationMode::Read || (mode == AutomationMode::Touch && touchState != 1) + || (mode == AutomationMode::Latch && touchState == 0); + const bool expected = dataState >= 2 || (dataState == 1 && expectedMode); + std::array reference, candidate; + reference.fill(0.5f); candidate.fill(0.5f); + if (before) lane.evalBlock(0.75, sampleRate, 8, reference.data()); + if (after) lane.evalBlock(0.75, sampleRate, 8, candidate.data()); + automationOrderParity = before == after && after == expected && reference == candidate && automationOrderParity; + ++automationCases; + } + serial->setForceAutomationReadForProcessing(false); + const auto tinyRegression = [] + { + bool exact = true, dispatch = true; + int blocks = 0; + juce::uint64 fastBlocks = 0; + const auto equalBuffer = [] (const juce::AudioBuffer& a, const juce::AudioBuffer& b) + { + if (a.getNumChannels() != b.getNumChannels() || a.getNumSamples() != b.getNumSamples()) return false; + for (int channel = 0; channel < a.getNumChannels(); ++channel) + for (int sample = 0; sample < a.getNumSamples(); ++sample) + if (a.getSample(channel, sample) != b.getSample(channel, sample)) return false; + return true; + }; + for (int scenario = 0; scenario < 10; ++scenario) + { + auto reference = std::make_unique(), candidate = std::make_unique(); + const auto configure = [scenario] (TrackProcessor& track) + { + track.setRecordArmed(true); track.setInputMonitoring(true); + track.setVolume(-4.0f); track.setPan(0.37f); track.setTrimVolume(-2.0f); + track.setStereoWidth(143.0f); track.setPhaseInvert(true); track.setDCOffsetRemoval(true); + track.setPanLaw(PanLaw::Minus4_5dB); + if (scenario == 1 || scenario == 9) + { + for (auto* lane : { &track.volumeAutomation, &track.panAutomation, &track.widthAutomation, + &track.trimVolumeAutomation, &track.muteAutomation }) + { + lane->setMode(scenario == 9 ? AutomationMode::Write : AutomationMode::Read); + lane->setPoints({ {0.0, 0.0f}, {0.004, 0.5f}, {0.012, 0.0f} }); + } + track.setForceAutomationReadForProcessing(scenario == 9); + } + if (scenario == 2) + { + track.volumeAutomation.setPreviewValue(-3.0f); + track.panAutomation.setWrittenValue(-0.25f); + track.trimVolumeAutomation.setWrittenValue(1.5f); + } + if (scenario == 3) track.setChannelStripEQParam(1, 3.0f); + if (scenario == 4) + { + track.preFXVolumeAutomation.setMode(AutomationMode::Read); + track.preFXVolumeAutomation.setPoints({ {0, -3.0f}, {1, -1.0f} }); + } + if (scenario == 5) track.addSend("tiny-parity-destination"); + if (scenario == 6) track.setTrackType(TrackType::MIDI); + if (scenario == 7) track.setTrackType(TrackType::Instrument); + if (scenario == 8) track.araFXIndexForRealtime.store(0, std::memory_order_release); + track.prepareToPlay(48000.0, 16); + }; + configure(*reference); configure(*candidate); + for (int block = 0; block < 192; ++block) + { + const int samples = block % 3 == 0 ? 1 : block % 3 == 1 ? 8 : 16; + juce::AudioBuffer expected(2, samples), actual(2, samples); + for (int channel = 0; channel < 2; ++channel) + for (int sample = 0; sample < samples; ++sample) + expected.setSample(channel, sample, 0.04f * static_cast((block + sample * 3 + channel * 7) % 17 - 8)); + actual.makeCopyOf(expected); + juce::MidiBuffer expectedMIDI, actualMIDI; + expectedMIDI.addEvent(juce::MidiMessage::controllerEvent(2, 7, 99), 0); + if (block == 0) expectedMIDI.addEvent(juce::MidiMessage::noteOn(2, 64, juce::uint8{80}), 0); + if (block == 48) expectedMIDI.addEvent(juce::MidiMessage::noteOff(2, 64), 0); + actualMIDI = expectedMIDI; + for (auto* track : { reference.get(), candidate.get() }) + { + track->setCurrentBlockPosition(static_cast(block * 8) / 48000.0); + track->setMute((block % 32) >= 28); + if (block % 48 == 0) track->setPDCDelay((block / 48) % 2 == 0 ? 0 : 13); + if (scenario == 3 && (block == 16 || block == 64)) track->setChannelStripEQEnabled(block == 16); + } + reference->processBlockInternal(expected, expectedMIDI); + exact = candidate->tryProcessBlock(actual, actualMIDI) && equalBuffer(expected, actual) + && reference->currentRMS.load() == candidate->currentRMS.load() + && reference->meterPeakAccum == candidate->meterPeakAccum + && reference->meterSampleCount == candidate->meterSampleCount + && reference->clipLatched.load() == candidate->clipLatched.load() + && equalBuffer(reference->preFaderBuffer, candidate->preFaderBuffer) && exact; + auto actualEvent = actualMIDI.begin(); + exact = expectedMIDI.getNumEvents() == actualMIDI.getNumEvents() && exact; + for (const auto expectedEvent : expectedMIDI) + { + if (actualEvent == actualMIDI.end()) { exact = false; break; } + const auto value = *actualEvent++; + exact = expectedEvent.samplePosition == value.samplePosition && expectedEvent.numBytes == value.numBytes + && std::memcmp(expectedEvent.data, value.data, static_cast(expectedEvent.numBytes)) == 0 && exact; + } + ++blocks; + } + const bool expectedFast = scenario <= 3 || scenario == 9; + dispatch = (candidate->tinyEmptyGraphProcessedBlocks > 0) == expectedFast && dispatch; + fastBlocks += candidate->tinyEmptyGraphProcessedBlocks; + reference->releaseResources(); candidate->releaseResources(); + } + auto* evidence = new juce::DynamicObject(); + evidence->setProperty("pass", exact && dispatch); + evidence->setProperty("exactAudioMidiMeterAndPreFaderParity", exact); + evidence->setProperty("eligibilityAndFallbackDispatch", dispatch); + evidence->setProperty("blocks", blocks); + evidence->setProperty("fastBlocks", static_cast(fastBlocks)); + evidence->setProperty("scope", "1/8/16-sample stereo input; static/automated/preview/written/forced gains, spatial controls, mute, PDC changes, strip EQ power ramps; pre-FX/send/MIDI/instrument/ARA fallbacks. No hardware timing or listening claim."); + return juce::var(evidence); + }(); bool configured = serial->addTrackFX(std::make_unique(), sampleRate, capacity) && parallel->addTrackFX(std::make_unique(), sampleRate, capacity); serial->prepareToPlay(sampleRate, capacity); @@ -2735,11 +3003,17 @@ juce::var TrackProcessor::runParallelNAMEligibilityRegression() const bool eligibilityRecovered = parallel->getParallelNAMGraphGeneration() != 0; parallel->releaseResources(); const bool releasedRejected = rejectsUnchanged(parallel->getParallelNAMGraphGeneration()); + parallel->removeTrackFX(0); + const bool emptyPublicationCleared = parallel->getParallelNAMGraphGeneration() == 0; auto* result = new juce::DynamicObject(); - result->setProperty("pass", emptyRejected && zeroRejected && parity && staleRejected && midiRejected + result->setProperty("pass", static_cast(tinyRegression["pass"]) && automationOrderParity && static_cast(probeRegression["pass"]) && emptyRejected && zeroRejected && parity && staleRejected && midiRejected && instrumentRejected && araRejected && busyRejected && nonNAMRejected && bypassedNonNAMRejected - && eligibilityRecovered && releasedRejected); + && eligibilityRecovered && releasedRejected && emptyPublicationCleared); result->setProperty("emptyAndZeroRejected", emptyRejected && zeroRejected); + result->setProperty("eightSampleDiscovery", probeRegression); + result->setProperty("automationDataFirstExactParity", automationOrderParity); + result->setProperty("automationEightSampleCases", automationCases); + result->setProperty("tinyEmptyGraph", tinyRegression); result->setProperty("serialWorkerExactParity", parity); result->setProperty("parityBlocks", blockCount); result->setProperty("staleGenerationRejectedWithoutProcessing", staleRejected); @@ -2748,154 +3022,610 @@ juce::var TrackProcessor::runParallelNAMEligibilityRegression() result->setProperty("nonNAMIdentityAndBypassRejected", nonNAMRejected && bypassedNonNAMRejected); result->setProperty("eligibilityRecovered", eligibilityRecovered); result->setProperty("releasedCapacityRejected", releasedRejected); + result->setProperty("emptyPublicationClearsCandidate", emptyPublicationCleared); result->setProperty("scope", "Device-free graph eligibility and single-owner entrypoint contracts; scheduler timing and concurrent lifecycle qualification are separate."); return juce::var(result); } -void TrackProcessor::processBlockInternal (juce::AudioBuffer& buffer, juce::MidiBuffer& midiMessages, - const RealtimeGraphSnapshot* workerGraph) +void TrackProcessor::resetEmptyGraphTailState() noexcept { - const int activeARAFXIndexForBlock = - workerGraph != nullptr ? -1 - : araFXIndexForRealtime.load(std::memory_order_acquire); - auto* const activeARAControllerForBlock = - activeARAFXIndexForBlock >= 0 - ? araController.get() - : nullptr; - // Only time the track when ARA diagnostics are enabled and an ARA plugin is active. - // QueryPerformanceCounter is cheap but not free — at 32-sample blocks this fires - // 1500×/sec, so we avoid it for non-ARA tracks (e.g. Amplitube, OpenStudio FX). - const bool isARATrack = kEnableARADebugDiagnostics - && activeARAControllerForBlock != nullptr; - const double trackProcessStartMs = isARATrack ? juce::Time::getMillisecondCounterHiRes() : 0.0; - juce::ScopedNoDenormals noDenormals; - auto totalNumInputChannels = getTotalNumInputChannels(); - auto totalNumOutputChannels = getTotalNumOutputChannels(); - const auto currentTrackType = workerGraph != nullptr - ? TrackType::Audio : trackType.load(std::memory_order_acquire); - // Avoid MSVC's process-wide atomic spin lock in the audio - // callback. Control-side publication retains replaced immutable graphs - // until this reader epoch has drained. - const ScopedTrackRealtimeReader graphReadGuard( - realtimeGraphAudioReaders, workerGraph == nullptr); - const auto* const graphSnapshot = - workerGraph != nullptr ? workerGraph - : realtimeGraphSnapshotForAudio.load(std::memory_order_seq_cst); - const uint64 graphGeneration = - graphSnapshot != nullptr ? graphSnapshot->generation : 0; - const auto* const inputFXSnapshot = - graphSnapshot != nullptr ? &graphSnapshot->inputFX : nullptr; - const auto* const trackFXSnapshot = - graphSnapshot != nullptr ? &graphSnapshot->trackFX : nullptr; - if (graphSnapshot) + realtimeFXTailActive.store(false, std::memory_order_release); + realtimeFXTailResetPending.store(false, std::memory_order_release); + realtimeFXTailHardSamplesRemaining = 0; + realtimeFXTailMinimumSamplesRemaining = 0; + realtimeFXTailQuietSamples = 0; + realtimeFXTailLastPublishedBudgetSamples = 0; + realtimeFXPreviousBlockHadInput = false; +} + +void TrackProcessor::processTrackPDC(juce::AudioBuffer& buffer) +{ + const int bufferChannels = buffer.getNumChannels(); + // Apply Plugin Delay Compensation (PDC) before FX chains. A model-rate + // change can alter another track's compensation by tens of samples. A + // hard DelayLine::setDelay() repeats or drops that history at one sample + // and sounds like a click, so crossfade the two already-warm integer taps. + if (pdcDelayDirty.exchange(false, std::memory_order_acq_rel)) { - for (size_t index = 0; index < graphSnapshot->inputFX.size(); ++index) - flushOpenStudioCLAPParameterEvents(graphSnapshot->inputFX[index].get(), graphSnapshot->inputSafety[index].get()); - for (size_t index = 0; index < graphSnapshot->trackFX.size(); ++index) - flushOpenStudioCLAPParameterEvents(graphSnapshot->trackFX[index].get(), graphSnapshot->trackSafety[index].get()); - flushOpenStudioCLAPParameterEvents(graphSnapshot->instrument.get(), graphSnapshot->instrumentSafety.get()); + pdcPendingDelaySamples = + juce::jlimit( + 0, + pdcDelayLine + .getMaximumDelayInSamples(), + pdcDelaySamples.load( + std::memory_order_relaxed)); } - const auto resolveSafety = [&] (bool input, int index) -> ProcessorSafety* + + // Process even at zero delay so the ring buffer always contains current + // audio. A later 0 -> positive PDC change then cannot replay stale samples. + const auto beginPendingPDCTransition = + [this] () noexcept { - if (!graphSnapshot) return nullptr; - if (index < 0) return graphSnapshot->instrumentSafety.get(); - const auto& states = input ? graphSnapshot->inputSafety : graphSnapshot->trackSafety; - return static_cast(index) < states.size() ? states[static_cast(index)].get() : nullptr; + if (pdcTransitionSamplesRemaining <= 0 + && pdcPendingDelaySamples + != pdcCurrentDelaySamples) + { + pdcTargetDelaySamples = + pdcPendingDelaySamples; + pdcTransitionSamplesRemaining = + pdcTransitionSamplesTotal; + } }; - const auto* const inputFXBypassSnapshot = - graphSnapshot != nullptr ? &graphSnapshot->inputFXBypass : nullptr; - const auto* const trackFXBypassSnapshot = - graphSnapshot != nullptr ? &graphSnapshot->trackFXBypass : nullptr; - const auto* const inputFXPrecisionOverrideSnapshot = - graphSnapshot != nullptr - ? &graphSnapshot->inputFXPrecisionOverrides - : nullptr; - const auto* const trackFXPrecisionOverrideSnapshot = - graphSnapshot != nullptr - ? &graphSnapshot->trackFXPrecisionOverrides - : nullptr; - auto* const instrumentSnapshot = - graphSnapshot != nullptr ? graphSnapshot->instrument.get() : nullptr; - const bool hasPluginAutomationRoutesForBlock = - hasPublishedPluginAutomationRoutes.load( - std::memory_order_acquire); - const ScopedTrackRealtimeReader auxReadGuard( - realtimeAuxAudioReaders, - hasPluginAutomationRoutesForBlock - || currentTrackType == TrackType::Instrument); - const auto* const pluginAutomationRoutesForBlock = - hasPluginAutomationRoutesForBlock - ? pluginAutomationSnapshotForAudio.load( - std::memory_order_seq_cst) - : nullptr; - const auto* const sidechainSnapshot = - graphSnapshot != nullptr ? &graphSnapshot->sidechainSources : nullptr; - const auto* const sendSnapshot = - graphSnapshot != nullptr ? &graphSnapshot->sends : nullptr; - const auto* const inputFXBypassDelaySnapshot = - graphSnapshot != nullptr - ? &graphSnapshot->inputFXBypassDelay - : nullptr; - const auto* const trackFXBypassDelaySnapshot = - graphSnapshot != nullptr - ? &graphSnapshot->trackFXBypassDelay - : nullptr; - const bool instrumentForceFloat = instrumentForceFloatOverride.load(std::memory_order_acquire); - const double blockTimeSeconds = this->blockStartTimeSeconds; - const ScopedPluginAutomationProcessing parameterCaptureContext(blockTimeSeconds, getSampleRate()); - const auto hasEnabledProcessor = [] ( - const ProcessorSnapshot* processors, - const BypassSnapshot* bypassState) noexcept + beginPendingPDCTransition(); + if (pdcTransitionSamplesRemaining <= 0) { - if (processors == nullptr) - return false; + for (int channel = 0; channel < juce::jmin(2, bufferChannels); ++channel) + pdcDelayLine.processChannel(channel, buffer.getWritePointer(channel), buffer.getNumSamples()); + return; + } + auto* const* channels = buffer.getArrayOfWritePointers(); + for (int sample = 0; + sample < buffer.getNumSamples(); + ++sample) + { + float transitionMix = 0.0f; + const bool transitioning = + pdcTransitionSamplesRemaining > 0; + if (transitioning) + { + const float linearProgress = + 1.0f + - static_cast( + pdcTransitionSamplesRemaining + - 1) + / static_cast( + pdcTransitionSamplesTotal); + transitionMix = + linearProgress + * linearProgress + * (3.0f + - 2.0f + * linearProgress); + } - for (int index = 0; - index < static_cast(processors->size()); - ++index) + for (int channel = 0; + channel < juce::jmin(2, bufferChannels); + ++channel) { - if ((*processors)[static_cast(index)] == nullptr) - continue; - const auto bypassIt = bypassState != nullptr - ? bypassState->find(index) - : BypassSnapshot::const_iterator {}; - if (bypassState == nullptr - || bypassIt == bypassState->end() - || ! bypassIt->second) + const float input = channels[channel][sample]; + pdcDelayLine.pushSample( + channel, input); + if (transitioning) { - return true; + const float previousTap = + pdcDelayLine.popSample( + channel, + static_cast( + pdcCurrentDelaySamples), + false); + const float nextTap = + pdcDelayLine.popSample( + channel, + static_cast( + pdcTargetDelaySamples), + true); + channels[channel][sample] = previousTap + + (nextTap - previousTap) + * transitionMix; } - } - return false; - }; - const bool hasEnabledRealtimeFX = - currentTrackType == TrackType::Audio - && (hasEnabledProcessor(inputFXSnapshot, inputFXBypassSnapshot) - || hasEnabledProcessor(trackFXSnapshot, trackFXBypassSnapshot)); - bool hasExternalAudioInput = false; - if (hasEnabledRealtimeFX) - { - for (int channel = 0; channel < buffer.getNumChannels(); ++channel) - { - const float inputPeak = buffer.getMagnitude( - channel, 0, buffer.getNumSamples()); - if (std::isfinite(inputPeak) && inputPeak > 0.0f) + else { - hasExternalAudioInput = true; - break; + channels[channel][sample] = pdcDelayLine.popSample(channel); } } - } - if (! hasEnabledRealtimeFX) - { - realtimeFXTailActive.store(false, std::memory_order_release); - realtimeFXTailResetPending.store(false, std::memory_order_release); - realtimeFXTailHardSamplesRemaining = 0; - realtimeFXTailMinimumSamplesRemaining = 0; - realtimeFXTailQuietSamples = 0; - realtimeFXTailLastPublishedBudgetSamples = 0; - realtimeFXPreviousBlockHadInput = false; + if (transitioning) + { + --pdcTransitionSamplesRemaining; + if (pdcTransitionSamplesRemaining + <= 0) + { + pdcCurrentDelaySamples = + pdcTargetDelaySamples; + pdcDelayLine.setDelay( + static_cast( + pdcCurrentDelaySamples)); + beginPendingPDCTransition(); + } + } + } + +} + +juce::var TrackProcessor::runPreparedPDCRegression() +{ + // Exercise the production helper/prepare/reset paths against the previous + // JUCE-backed implementation. This fixture never enters plugin/device I/O. + struct ReferencePDC + { + juce::dsp::DelayLine line { 96000 }; + int requested = 0, current = 0, target = 0, pending = 0, remaining = 0, total = 1; + bool dirty = false; + void prepare(double rate) + { + line.prepare({ rate, 512, 2 }); current = target = pending = requested; + remaining = 0; total = juce::jmax(1, juce::roundToInt(rate * 0.020)); + line.setDelay(static_cast(current)); + } + void request(int delay) { if (delay != requested) { requested = delay; dirty = true; } } + void reset() + { + line.reset(); current = target = pending = requested; remaining = 0; + line.setDelay(static_cast(current)); dirty = false; + } + void process(juce::AudioBuffer& buffer) + { + if (dirty) { pending = juce::jlimit(0, line.getMaximumDelayInSamples(), requested); dirty = false; } + const auto begin = [&] { if (remaining <= 0 && pending != current) { target = pending; remaining = total; } }; + begin(); + for (int sample = 0; sample < buffer.getNumSamples(); ++sample) + { + const bool transitioning = remaining > 0; + const float progress = transitioning ? 1.0f - static_cast(remaining - 1) / static_cast(total) : 0.0f; + const float mix = progress * progress * (3.0f - 2.0f * progress); + for (int channel = 0; channel < juce::jmin(2, buffer.getNumChannels()); ++channel) + { + line.pushSample(channel, buffer.getSample(channel, sample)); + if (transitioning) + { + const float before = line.popSample(channel, static_cast(current), false); + const float after = line.popSample(channel, static_cast(target), true); + buffer.setSample(channel, sample, before + (after - before) * mix); + } + else buffer.setSample(channel, sample, line.popSample(channel)); + } + if (transitioning && --remaining <= 0) + { + current = target; line.setDelay(static_cast(current)); begin(); + } + } + } + } reference; + auto actual = std::make_unique(); + juce::AudioBuffer actualBlock(2, 512), referenceBlock(2, 512); + constexpr int lengths[] { 1, 8, 16, 512 }; + constexpr int requestedTaps[] { 1, 6, 128, 96000, 0, 6, 128, 0 }; + constexpr int requestFrames[] { 16000, 32000, 48000, 112000, 144000, 160000, 176000, 192000 }; + bool exact = true, states = true; + int comparedBlocks = 0; juce::int64 comparedSamples = 0; + for (const double rate : { 48000.0, 44100.0 }) + { + actual->setPDCDelay(0); reference.request(0); + actual->prepareToPlay(rate, 512); reference.prepare(rate); + int frame = 0, requestIndex = 0; + while (frame < 200123) + { + if (requestIndex < static_cast(std::size(requestedTaps)) && frame >= requestFrames[requestIndex]) + { + actual->setPDCDelay(requestedTaps[requestIndex]); reference.request(requestedTaps[requestIndex]); ++requestIndex; + } + // Queue another target before a ramp finishes; the production + // helper must finish its existing ramp and consume the latest one. + if (requestIndex == 3 && actual->pdcTransitionSamplesRemaining > 0 && (comparedBlocks % 7) == 0) + { actual->setPDCDelay(6); reference.request(6); } + const int count = juce::jmin(lengths[comparedBlocks % 4], 200123 - frame); + const int channels = (comparedBlocks % 11) == 0 ? 1 : 2; + actualBlock.setSize(channels, count, false, false, true); + referenceBlock.setSize(channels, count, false, false, true); + for (int channel = 0; channel < channels; ++channel) + for (int sample = 0; sample < count; ++sample) + { + const float input = static_cast(((frame + sample + channel * 11) % 127) - 63) / 256.0f; + actualBlock.setSample(channel, sample, input); referenceBlock.setSample(channel, sample, input); + } + actual->processTrackPDC(actualBlock); reference.process(referenceBlock); + for (int channel = 0; channel < channels; ++channel) + exact = std::memcmp(actualBlock.getReadPointer(channel), referenceBlock.getReadPointer(channel), + static_cast(count) * sizeof(float)) == 0 && exact; + states = actual->pdcCurrentDelaySamples == reference.current && actual->pdcTargetDelaySamples == reference.target + && actual->pdcPendingDelaySamples == reference.pending && actual->pdcTransitionSamplesRemaining == reference.remaining && states; + frame += count; ++comparedBlocks; comparedSamples += static_cast(count) * channels; + if (frame >= 144000 && frame - count < 144000) { actual->resetPDCDelayState(); reference.reset(); } + } + actual->releaseResources(); + } + auto* result = new juce::DynamicObject(); + result->setProperty("pass", exact && states); + result->setProperty("exactPCMParity", exact); result->setProperty("transitionStateParity", states); + result->setProperty("comparedBlocks", comparedBlocks); result->setProperty("comparedChannelSamples", comparedSamples); + result->setProperty("scope", "Production TrackProcessor PDC helper and prepare/reset paths versus prior JUCE Linear reference; 1/8/16/512 blocks, mono/stereo, tap changes, warm history and 48/44.1 kHz reprepare. No hardware or audible-quality acceptance."); + return juce::var(result); +} + +float TrackProcessor::processTrackOutput(juce::AudioBuffer& buffer, bool capturePreFader, + double blockTimeSeconds, double processingSampleRate, PanLaw blockPanLaw) +{ + const int bufferChannels = buffer.getNumChannels(), numSamps = buffer.getNumSamples(); + // ===== DC OFFSET REMOVAL (after FX, before gain) ===== + if (dcOffsetRemoval.load(std::memory_order_acquire) + && bufferChannels >= 1) + { + double sr = getSampleRate(); + if (sr <= 0) sr = 44100.0; + float alpha = 1.0f - (2.0f * juce::MathConstants::pi * 5.0f / static_cast(sr)); + + // Left channel + { + float prevIn = dcPrevInputL; + float prevOut = dcFilterStateL; + for (int i = 0; i < buffer.getNumSamples(); ++i) + { + float input = buffer.getSample(0, i); + float output = input - prevIn + alpha * prevOut; + prevIn = input; + prevOut = output; + buffer.setSample(0, i, output); + } + dcPrevInputL = prevIn; + dcFilterStateL = prevOut; + } + + // Right channel + if (bufferChannels >= 2) + { + float prevIn = dcPrevInputR; + float prevOut = dcFilterStateR; + for (int i = 0; i < buffer.getNumSamples(); ++i) + { + float input = buffer.getSample(1, i); + float output = input - prevIn + alpha * prevOut; + prevIn = input; + prevOut = output; + buffer.setSample(1, i, output); + } + dcPrevInputR = prevIn; + dcFilterStateR = prevOut; + } + } + + // ===== PHASE INVERT (polarity flip) ===== + if (phaseInverted.load(std::memory_order_relaxed)) + { + for (int ch = 0; ch < bufferChannels; ++ch) + juce::FloatVectorOperations::negate( + buffer.getWritePointer(ch), + buffer.getReadPointer(ch), + buffer.getNumSamples()); + } + + // ===== POST-FX WIDTH ===== + const bool widthAutoActive = widthAutomation.hasPlaybackData() && shouldApplyAutomation(widthAutomation); + if (bufferChannels >= 2) + { + if (widthAutoActive) + { + auto* widthValues = automationGainBuffer.getWritePointer(3); + widthAutomation.evalBlock(blockTimeSeconds, processingSampleRate, numSamps, widthValues); + float* left = buffer.getWritePointer(0); + float* right = buffer.getWritePointer(1); + for (int i = 0; i < buffer.getNumSamples(); ++i) + { + const float widthPercent = backendWidthToPercent(widthValues[i]); + const float widthFactor = widthPercent / 100.0f; + const float mid = (left[i] + right[i]) * 0.5f; + const float side = (left[i] - right[i]) * 0.5f; + left[i] = mid + side * widthFactor; + right[i] = mid - side * widthFactor; + } + } + else + { + applyStereoWidthToBuffer(buffer, bufferChannels, buffer.getNumSamples(), + stereoWidth.load(std::memory_order_relaxed)); + } + } + + // Preserve stereo channel identity at the send taps. A silent channel is valid + // stereo content; mono expansion belongs to the declared source/plugin layout. + + // ===== CAPTURE PRE-FADER BUFFER (for pre-fader sends) ===== + if (capturePreFader) + { + int pfSamples = buffer.getNumSamples(); + preFaderBuffer.clear(); + for (int ch = 0; ch < bufferChannels; ++ch) + preFaderBuffer.copyFrom(ch, 0, buffer, ch, 0, pfSamples); + } + + // ===== AUTOMATION-AWARE FADER/TAIL APPLICATION ===== + bool volAutoActive = volumeAutomation.hasPlaybackData() && shouldApplyAutomation(volumeAutomation); + bool panAutoActive = panAutomation.hasPlaybackData() && shouldApplyAutomation(panAutomation); + bool trimAutoActive = trimVolumeAutomation.hasPlaybackData() && shouldApplyAutomation(trimVolumeAutomation); + bool muteAutoActive = muteAutomation.hasPlaybackData() && shouldApplyAutomation(muteAutomation); + + if (volAutoActive || panAutoActive) + { + float staticVolDB = trackVolumeDB.load(std::memory_order_relaxed); + float staticPan = trackPan.load(std::memory_order_relaxed); + auto* volumeValues = automationGainBuffer.getWritePointer(4); + auto* panValues = automationGainBuffer.getWritePointer(5); + if (volAutoActive) + volumeAutomation.evalBlock(blockTimeSeconds, processingSampleRate, numSamps, volumeValues); + if (panAutoActive) + panAutomation.evalBlock(blockTimeSeconds, processingSampleRate, numSamps, panValues); + + for (int i = 0; i < numSamps; ++i) + { + float volDB = volAutoActive ? volumeValues[i] : staticVolDB; + float pan = panAutoActive ? panValues[i] : staticPan; + + volDB = juce::jlimit(-60.0f, 12.0f, volDB); + pan = juce::jlimit(-1.0f, 1.0f, pan); + + float volumeGain = MixerGain::fromDecibels(volDB); + float lGain = 1.0f; + float rGain = 1.0f; + computePanLawGains( + blockPanLaw, + pan, + volumeGain, + lGain, + rGain); + + // Apply per-sample gain + if (bufferChannels >= 1) + buffer.setSample(0, i, buffer.getSample(0, i) * lGain); + if (bufferChannels >= 2) + buffer.setSample(1, i, buffer.getSample(1, i) * rGain); + for (int ch = 2; ch < bufferChannels; ++ch) + buffer.setSample(ch, i, buffer.getSample(ch, i) * (ch % 2 == 0 ? lGain : rGain)); + } + } + else + { + float leftGain = cachedPanL.load(std::memory_order_relaxed); + float rightGain = cachedPanR.load(std::memory_order_relaxed); + + if (bufferChannels >= 1) + buffer.applyGain(0, 0, numSamps, leftGain); + if (bufferChannels >= 2) + buffer.applyGain(1, 0, numSamps, rightGain); + for (int ch = 2; ch < bufferChannels; ++ch) + buffer.applyGain(ch, 0, numSamps, ch % 2 == 0 ? leftGain : rightGain); + } + + if (trimAutoActive) + { + auto* trimValues = automationGainBuffer.getWritePointer(6); + trimVolumeAutomation.evalBlock(blockTimeSeconds, processingSampleRate, numSamps, trimValues); + for (int i = 0; i < numSamps; ++i) + { + const float trimGain = juce::Decibels::decibelsToGain(juce::jlimit(-60.0f, 12.0f, trimValues[i])); + for (int channel = 0; channel < bufferChannels; ++channel) + buffer.setSample(channel, i, buffer.getSample(channel, i) * trimGain); + } + } + else if (const auto trimGain = trackTrimGain.load(std::memory_order_relaxed); trimGain != 1.0f) + buffer.applyGain(trimGain); + + if (muteAutoActive) + { + auto* muteValues = automationGainBuffer.getWritePointer(7); + muteAutomation.evalBlock(blockTimeSeconds, processingSampleRate, numSamps, muteValues); + for (int i = 0; i < numSamps; ++i) + { + const bool muted = muteValues[i] > 0.5f; + if (!muted) + continue; + for (int ch = 0; ch < bufferChannels; ++ch) + buffer.setSample(ch, i, 0.0f); + } + } + + // ---- REAPER-style peak metering with decimation ---- + // getMagnitude() uses FloatVectorOperations::findMinAndMax (SIMD, no sqrt), + // which is much cheaper than getRMSLevel() (which computes sqrt per channel). + // We accumulate the running peak over METER_UPDATE_SAMPLES then commit it + // to currentRMS. At 32-sample ASIO blocks this fires ~86 times/sec instead + // of 1378 times/sec — a 16× reduction in per-track metering overhead. + float peak = 0.0f; + for (int ch = 0; ch < bufferChannels; ++ch) + peak = juce::jmax (peak, buffer.getMagnitude (ch, 0, buffer.getNumSamples())); + + if (peak > 1.0f) + clipLatched.store(true, std::memory_order_relaxed); + + meterPeakAccum = juce::jmax (meterPeakAccum, peak); + meterSampleCount += buffer.getNumSamples(); + if (meterSampleCount >= METER_UPDATE_SAMPLES) + { + currentRMS.store (meterPeakAccum, std::memory_order_relaxed); + meterPeakAccum = 0.0f; + meterSampleCount = 0; + } + + return peak; +} + +bool TrackProcessor::tryProcessTinyEmptyGraph(juce::AudioBuffer& buffer, juce::MidiBuffer& midi) +{ + // No graph resources are borrowed here. The control-side publication is + // the linearization point for this scalar eligibility classification. + if (buffer.getNumSamples() > 16 || buffer.getNumChannels() != 2 + || !hasEmptyRealtimeGraphForAudio.load(std::memory_order_acquire) + || trackType.load(std::memory_order_acquire) != TrackType::Audio + || araFXIndexForRealtime.load(std::memory_order_acquire) >= 0 + || getTotalNumInputChannels() != 2 || getTotalNumOutputChannels() != 2 + || hasPublishedPluginAutomationRoutes.load(std::memory_order_acquire) + || preFXVolumeAutomation.hasPlaybackData() || preFXPanAutomation.hasPlaybackData() + || preFXWidthAutomation.hasPlaybackData()) return false; + + juce::ScopedNoDenormals noDenormals; + ++tinyEmptyGraphProcessedBlocks; + resetEmptyGraphTailState(); + const bool muteCanUnmute = muteAutomation.hasPlaybackData() && shouldApplyAutomation(muteAutomation); + if (isMuted.load() && !ignoreStaticMuteDuringProcessing.load(std::memory_order_relaxed) && !muteCanUnmute) + { + buffer.clear(); preFaderBuffer.clear(); currentRMS = 0.0f; + return true; + } + processTrackPDC(buffer); + const double rate = juce::jmax(1.0, getSampleRate()); + const auto law = panLaw.load(std::memory_order_acquire); + juce::AudioBuffer stripBuffer(buffer.getArrayOfWritePointers(), 2, buffer.getNumSamples()); + channelStripEQ.processBlock(stripBuffer, midi); + (void) processTrackOutput(buffer, false, blockStartTimeSeconds, rate, law); + return true; +} + +void TrackProcessor::processBlockInternal (juce::AudioBuffer& buffer, juce::MidiBuffer& midiMessages, + const RealtimeGraphSnapshot* workerGraph) +{ + const int activeARAFXIndexForBlock = + workerGraph != nullptr ? -1 + : araFXIndexForRealtime.load(std::memory_order_acquire); + auto* const activeARAControllerForBlock = + activeARAFXIndexForBlock >= 0 + ? araController.get() + : nullptr; + // Only time the track when ARA diagnostics are enabled and an ARA plugin is active. + // QueryPerformanceCounter is cheap but not free — at 32-sample blocks this fires + // 1500×/sec, so we avoid it for non-ARA tracks (e.g. Amplitube, OpenStudio FX). + const bool isARATrack = kEnableARADebugDiagnostics + && activeARAControllerForBlock != nullptr; + const double trackProcessStartMs = isARATrack ? juce::Time::getMillisecondCounterHiRes() : 0.0; + juce::ScopedNoDenormals noDenormals; + auto totalNumInputChannels = getTotalNumInputChannels(); + auto totalNumOutputChannels = getTotalNumOutputChannels(); + const auto currentTrackType = workerGraph != nullptr + ? TrackType::Audio : trackType.load(std::memory_order_acquire); + // Avoid MSVC's process-wide atomic spin lock in the audio + // callback. Control-side publication retains replaced immutable graphs + // until this reader epoch has drained. + const ScopedTrackRealtimeReader graphReadGuard( + realtimeGraphAudioReaders, workerGraph == nullptr); + const auto* const graphSnapshot = + workerGraph != nullptr ? workerGraph + : realtimeGraphSnapshotForAudio.load(std::memory_order_seq_cst); + const uint64 graphGeneration = + graphSnapshot != nullptr ? graphSnapshot->generation : 0; + const auto* const inputFXSnapshot = + graphSnapshot != nullptr ? &graphSnapshot->inputFX : nullptr; + const auto* const trackFXSnapshot = + graphSnapshot != nullptr ? &graphSnapshot->trackFX : nullptr; + if (graphSnapshot) + { + for (size_t index = 0; index < graphSnapshot->inputFX.size(); ++index) + flushOpenStudioCLAPParameterEvents(graphSnapshot->inputFX[index].get(), graphSnapshot->inputSafety[index].get()); + for (size_t index = 0; index < graphSnapshot->trackFX.size(); ++index) + flushOpenStudioCLAPParameterEvents(graphSnapshot->trackFX[index].get(), graphSnapshot->trackSafety[index].get()); + flushOpenStudioCLAPParameterEvents(graphSnapshot->instrument.get(), graphSnapshot->instrumentSafety.get()); + } + const auto resolveSafety = [&] (bool input, int index) -> ProcessorSafety* + { + if (!graphSnapshot) return nullptr; + if (index < 0) return graphSnapshot->instrumentSafety.get(); + const auto& states = input ? graphSnapshot->inputSafety : graphSnapshot->trackSafety; + return static_cast(index) < states.size() ? states[static_cast(index)].get() : nullptr; + }; + const auto* const inputFXBypassSnapshot = + graphSnapshot != nullptr ? &graphSnapshot->inputFXBypass : nullptr; + const auto* const trackFXBypassSnapshot = + graphSnapshot != nullptr ? &graphSnapshot->trackFXBypass : nullptr; + const auto* const inputFXPrecisionOverrideSnapshot = + graphSnapshot != nullptr + ? &graphSnapshot->inputFXPrecisionOverrides + : nullptr; + const auto* const trackFXPrecisionOverrideSnapshot = + graphSnapshot != nullptr + ? &graphSnapshot->trackFXPrecisionOverrides + : nullptr; + auto* const instrumentSnapshot = + graphSnapshot != nullptr ? graphSnapshot->instrument.get() : nullptr; + const bool hasPluginAutomationRoutesForBlock = + hasPublishedPluginAutomationRoutes.load( + std::memory_order_acquire); + const ScopedTrackRealtimeReader auxReadGuard( + realtimeAuxAudioReaders, + hasPluginAutomationRoutesForBlock + || currentTrackType == TrackType::Instrument); + const auto* const pluginAutomationRoutesForBlock = + hasPluginAutomationRoutesForBlock + ? pluginAutomationSnapshotForAudio.load( + std::memory_order_seq_cst) + : nullptr; + const auto* const sidechainSnapshot = + graphSnapshot != nullptr ? &graphSnapshot->sidechainSources : nullptr; + const auto* const sendSnapshot = + graphSnapshot != nullptr ? &graphSnapshot->sends : nullptr; + const auto* const inputFXBypassDelaySnapshot = + graphSnapshot != nullptr + ? &graphSnapshot->inputFXBypassDelay + : nullptr; + const auto* const trackFXBypassDelaySnapshot = + graphSnapshot != nullptr + ? &graphSnapshot->trackFXBypassDelay + : nullptr; + const bool instrumentForceFloat = instrumentForceFloatOverride.load(std::memory_order_acquire); + const double blockTimeSeconds = this->blockStartTimeSeconds; + const ScopedPluginAutomationProcessing parameterCaptureContext(blockTimeSeconds, getSampleRate()); + const auto hasEnabledProcessor = [] ( + const ProcessorSnapshot* processors, + const BypassSnapshot* bypassState) noexcept + { + if (processors == nullptr) + return false; + + for (int index = 0; + index < static_cast(processors->size()); + ++index) + { + if ((*processors)[static_cast(index)] == nullptr) + continue; + const auto bypassIt = bypassState != nullptr + ? bypassState->find(index) + : BypassSnapshot::const_iterator {}; + if (bypassState == nullptr + || bypassIt == bypassState->end() + || ! bypassIt->second) + { + return true; + } + } + return false; + }; + const bool hasEnabledRealtimeFX = + currentTrackType == TrackType::Audio + && (hasEnabledProcessor(inputFXSnapshot, inputFXBypassSnapshot) + || hasEnabledProcessor(trackFXSnapshot, trackFXBypassSnapshot)); + bool hasExternalAudioInput = false; + if (hasEnabledRealtimeFX) + { + for (int channel = 0; channel < buffer.getNumChannels(); ++channel) + { + const float inputPeak = buffer.getMagnitude( + channel, 0, buffer.getNumSamples()); + if (std::isfinite(inputPeak) && inputPeak > 0.0f) + { + hasExternalAudioInput = true; + break; + } + } + } + + if (! hasEnabledRealtimeFX) + { + resetEmptyGraphTailState(); } else if (hasExternalAudioInput) { @@ -2963,180 +3693,71 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer& buffer, juc { realtimeFXTailQuietSamples = juce::jmin( std::numeric_limits::max() - blockSamples, - realtimeFXTailQuietSamples) + blockSamples; - } - else - { - realtimeFXTailQuietSamples = 0; - } - } - - const int quietWindowSamples = juce::jmax( - 1, - juce::roundToInt( - static_cast(realtimeFXTailSampleRateHz.load( - std::memory_order_relaxed)) - * kRealtimeFXTailQuietWindowSeconds)); - if (realtimeFXTailHardSamplesRemaining <= 0 - || realtimeFXTailQuietSamples >= quietWindowSamples) - { - const auto generation = - realtimeFXTailActivityGeneration.load(std::memory_order_acquire); - realtimeFXTailResetGeneration.store( - generation, std::memory_order_release); - realtimeFXTailResetPending.store(true, std::memory_order_release); - } - }; - bool hasTrackBuiltInInstrument = false; - // Detect built-in generators without allocating a plugin name on the callback. - // Their intentional stereo output must survive the mono-source fallback below. - if (trackFXSnapshot) - { - for (const auto& plugin : *trackFXSnapshot) - { - if (isBuiltInInstrumentProcessor(plugin.get())) - { - hasTrackBuiltInInstrument = true; - break; - } - } - } - - if (activeARAControllerForBlock != nullptr) - activeARAControllerForBlock->updateTransportDebugState( - araTransportPlayingDebugState.load( - std::memory_order_acquire), - blockTimeSeconds); - - // Safety: only clear channels that actually exist in the buffer - int bufferChannels = buffer.getNumChannels(); - for (auto i = totalNumInputChannels; i < juce::jmin(totalNumOutputChannels, bufferChannels); ++i) - buffer.clear (i, 0, buffer.getNumSamples()); - - const bool muteAutomationCanUnmute = shouldApplyAutomation(muteAutomation) && muteAutomation.hasPlaybackData(); - // Static mute may only short-circuit if no mute automation can unmute this block. - if (isMuted.load() - && !ignoreStaticMuteDuringProcessing.load(std::memory_order_relaxed) - && !muteAutomationCanUnmute) - { - buffer.clear(); - preFaderBuffer.clear(); - currentRMS = 0.0f; - finishRealtimeFXTailDrain(0.0f); - return; - } - - // Apply Plugin Delay Compensation (PDC) before FX chains. A model-rate - // change can alter another track's compensation by tens of samples. A - // hard DelayLine::setDelay() repeats or drops that history at one sample - // and sounds like a click, so crossfade the two already-warm integer taps. - if (pdcDelayDirty.exchange(false, std::memory_order_acq_rel)) - { - pdcPendingDelaySamples = - juce::jlimit( - 0, - pdcDelayLine - .getMaximumDelayInSamples(), - pdcDelaySamples.load( - std::memory_order_relaxed)); - } - - // Process even at zero delay so the ring buffer always contains current - // audio. A later 0 -> positive PDC change then cannot replay stale samples. - const auto beginPendingPDCTransition = - [this] () noexcept - { - if (pdcTransitionSamplesRemaining <= 0 - && pdcPendingDelaySamples - != pdcCurrentDelaySamples) - { - pdcTargetDelaySamples = - pdcPendingDelaySamples; - pdcTransitionSamplesRemaining = - pdcTransitionSamplesTotal; - } - }; - beginPendingPDCTransition(); - for (int sample = 0; - sample < buffer.getNumSamples(); - ++sample) - { - float transitionMix = 0.0f; - const bool transitioning = - pdcTransitionSamplesRemaining > 0; - if (transitioning) - { - const float linearProgress = - 1.0f - - static_cast( - pdcTransitionSamplesRemaining - - 1) - / static_cast( - pdcTransitionSamplesTotal); - transitionMix = - linearProgress - * linearProgress - * (3.0f - - 2.0f - * linearProgress); - } - - for (int channel = 0; - channel < juce::jmin(2, bufferChannels); - ++channel) - { - const float input = - buffer.getSample( - channel, sample); - pdcDelayLine.pushSample( - channel, input); - if (transitioning) - { - const float previousTap = - pdcDelayLine.popSample( - channel, - static_cast( - pdcCurrentDelaySamples), - false); - const float nextTap = - pdcDelayLine.popSample( - channel, - static_cast( - pdcTargetDelaySamples), - true); - buffer.setSample( - channel, - sample, - previousTap - + (nextTap - previousTap) - * transitionMix); + realtimeFXTailQuietSamples) + blockSamples; } else { - buffer.setSample( - channel, - sample, - pdcDelayLine.popSample( - channel)); + realtimeFXTailQuietSamples = 0; } } - if (transitioning) + const int quietWindowSamples = juce::jmax( + 1, + juce::roundToInt( + static_cast(realtimeFXTailSampleRateHz.load( + std::memory_order_relaxed)) + * kRealtimeFXTailQuietWindowSeconds)); + if (realtimeFXTailHardSamplesRemaining <= 0 + || realtimeFXTailQuietSamples >= quietWindowSamples) { - --pdcTransitionSamplesRemaining; - if (pdcTransitionSamplesRemaining - <= 0) + const auto generation = + realtimeFXTailActivityGeneration.load(std::memory_order_acquire); + realtimeFXTailResetGeneration.store( + generation, std::memory_order_release); + realtimeFXTailResetPending.store(true, std::memory_order_release); + } + }; + bool hasTrackBuiltInInstrument = false; + // Detect built-in generators without allocating a plugin name on the callback. + // Their intentional stereo output must survive the mono-source fallback below. + if (trackFXSnapshot) + { + for (const auto& plugin : *trackFXSnapshot) + { + if (isBuiltInInstrumentProcessor(plugin.get())) { - pdcCurrentDelaySamples = - pdcTargetDelaySamples; - pdcDelayLine.setDelay( - static_cast( - pdcCurrentDelaySamples)); - beginPendingPDCTransition(); + hasTrackBuiltInInstrument = true; + break; } } } + if (activeARAControllerForBlock != nullptr) + activeARAControllerForBlock->updateTransportDebugState( + araTransportPlayingDebugState.load( + std::memory_order_acquire), + blockTimeSeconds); + + // Safety: only clear channels that actually exist in the buffer + int bufferChannels = buffer.getNumChannels(); + for (auto i = totalNumInputChannels; i < juce::jmin(totalNumOutputChannels, bufferChannels); ++i) + buffer.clear (i, 0, buffer.getNumSamples()); + + const bool muteAutomationCanUnmute = muteAutomation.hasPlaybackData() && shouldApplyAutomation(muteAutomation); + // Static mute may only short-circuit if no mute automation can unmute this block. + if (isMuted.load() + && !ignoreStaticMuteDuringProcessing.load(std::memory_order_relaxed) + && !muteAutomationCanUnmute) + { + buffer.clear(); + preFaderBuffer.clear(); + currentRMS = 0.0f; + finishRealtimeFXTailDrain(0.0f); + return; + } + + processTrackPDC(buffer); + bool hasAnyFX = (inputFXSnapshot && !inputFXSnapshot->empty()) || (trackFXSnapshot && !trackFXSnapshot->empty()); @@ -3956,9 +4577,9 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer& buffer, juc const float staticPreFXVolDb = 0.0f; const float staticPreFXPan = 0.0f; const float staticPreFXWidth = 100.0f; - const bool preFXVolAutoActive = shouldApplyAutomation(preFXVolumeAutomation) && preFXVolumeAutomation.hasPlaybackData(); - const bool preFXPanAutoActive = shouldApplyAutomation(preFXPanAutomation) && preFXPanAutomation.hasPlaybackData(); - const bool preFXWidthAutoActive = shouldApplyAutomation(preFXWidthAutomation) && preFXWidthAutomation.hasPlaybackData(); + const bool preFXVolAutoActive = preFXVolumeAutomation.hasPlaybackData() && shouldApplyAutomation(preFXVolumeAutomation); + const bool preFXPanAutoActive = preFXPanAutomation.hasPlaybackData() && shouldApplyAutomation(preFXPanAutomation); + const bool preFXWidthAutoActive = preFXWidthAutomation.hasPlaybackData() && shouldApplyAutomation(preFXWidthAutomation); if (preFXVolAutoActive || preFXPanAutoActive) { @@ -3981,7 +4602,7 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer& buffer, juc computePanLawGains( blockPanLaw, pan, - juce::Decibels::decibelsToGain(volDb), + MixerGain::fromDecibels(volDb), leftGain, rightGain); @@ -4375,204 +4996,8 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer& buffer, juc + " numSamples=" + juce::String(buffer.getNumSamples())); } - // ===== DC OFFSET REMOVAL (after FX, before gain) ===== - if (dcOffsetRemoval.load(std::memory_order_acquire) - && bufferChannels >= 1) - { - double sr = getSampleRate(); - if (sr <= 0) sr = 44100.0; - float alpha = 1.0f - (2.0f * juce::MathConstants::pi * 5.0f / static_cast(sr)); - - // Left channel - { - float prevIn = dcPrevInputL; - float prevOut = dcFilterStateL; - for (int i = 0; i < buffer.getNumSamples(); ++i) - { - float input = buffer.getSample(0, i); - float output = input - prevIn + alpha * prevOut; - prevIn = input; - prevOut = output; - buffer.setSample(0, i, output); - } - dcPrevInputL = prevIn; - dcFilterStateL = prevOut; - } - - // Right channel - if (bufferChannels >= 2) - { - float prevIn = dcPrevInputR; - float prevOut = dcFilterStateR; - for (int i = 0; i < buffer.getNumSamples(); ++i) - { - float input = buffer.getSample(1, i); - float output = input - prevIn + alpha * prevOut; - prevIn = input; - prevOut = output; - buffer.setSample(1, i, output); - } - dcPrevInputR = prevIn; - dcFilterStateR = prevOut; - } - } - - // ===== PHASE INVERT (polarity flip) ===== - if (phaseInverted.load(std::memory_order_relaxed)) - { - for (int ch = 0; ch < bufferChannels; ++ch) - juce::FloatVectorOperations::negate( - buffer.getWritePointer(ch), - buffer.getReadPointer(ch), - buffer.getNumSamples()); - } - - // ===== POST-FX WIDTH ===== - const bool widthAutoActive = shouldApplyAutomation(widthAutomation) && widthAutomation.hasPlaybackData(); - if (bufferChannels >= 2) - { - if (widthAutoActive) - { - auto* widthValues = automationGainBuffer.getWritePointer(3); - widthAutomation.evalBlock(blockTimeSeconds, processingSampleRate, numSamps, widthValues); - float* left = buffer.getWritePointer(0); - float* right = buffer.getWritePointer(1); - for (int i = 0; i < buffer.getNumSamples(); ++i) - { - const float widthPercent = backendWidthToPercent(widthValues[i]); - const float widthFactor = widthPercent / 100.0f; - const float mid = (left[i] + right[i]) * 0.5f; - const float side = (left[i] - right[i]) * 0.5f; - left[i] = mid + side * widthFactor; - right[i] = mid - side * widthFactor; - } - } - else - { - applyStereoWidthToBuffer(buffer, bufferChannels, buffer.getNumSamples(), - stereoWidth.load(std::memory_order_relaxed)); - } - } - - // Preserve stereo channel identity at the send taps. A silent channel is valid - // stereo content; mono expansion belongs to the declared source/plugin layout. - - // ===== CAPTURE PRE-FADER BUFFER (for pre-fader sends) ===== - if (sendSnapshot && !sendSnapshot->empty()) - { - int pfSamples = buffer.getNumSamples(); - preFaderBuffer.clear(); - for (int ch = 0; ch < bufferChannels; ++ch) - preFaderBuffer.copyFrom(ch, 0, buffer, ch, 0, pfSamples); - } - - // ===== AUTOMATION-AWARE FADER/TAIL APPLICATION ===== - bool volAutoActive = shouldApplyAutomation(volumeAutomation) && volumeAutomation.hasPlaybackData(); - bool panAutoActive = shouldApplyAutomation(panAutomation) && panAutomation.hasPlaybackData(); - bool trimAutoActive = shouldApplyAutomation(trimVolumeAutomation) && trimVolumeAutomation.hasPlaybackData(); - bool muteAutoActive = shouldApplyAutomation(muteAutomation) && muteAutomation.hasPlaybackData(); - - if (volAutoActive || panAutoActive) - { - float staticVolDB = trackVolumeDB.load(std::memory_order_relaxed); - float staticPan = trackPan.load(std::memory_order_relaxed); - auto* volumeValues = automationGainBuffer.getWritePointer(4); - auto* panValues = automationGainBuffer.getWritePointer(5); - if (volAutoActive) - volumeAutomation.evalBlock(blockTimeSeconds, processingSampleRate, numSamps, volumeValues); - if (panAutoActive) - panAutomation.evalBlock(blockTimeSeconds, processingSampleRate, numSamps, panValues); - - for (int i = 0; i < numSamps; ++i) - { - float volDB = volAutoActive ? volumeValues[i] : staticVolDB; - float pan = panAutoActive ? panValues[i] : staticPan; - - volDB = juce::jlimit(-60.0f, 12.0f, volDB); - pan = juce::jlimit(-1.0f, 1.0f, pan); - - float volumeGain = juce::Decibels::decibelsToGain(volDB); - float lGain = 1.0f; - float rGain = 1.0f; - computePanLawGains( - blockPanLaw, - pan, - volumeGain, - lGain, - rGain); - - // Apply per-sample gain - if (bufferChannels >= 1) - buffer.setSample(0, i, buffer.getSample(0, i) * lGain); - if (bufferChannels >= 2) - buffer.setSample(1, i, buffer.getSample(1, i) * rGain); - for (int ch = 2; ch < bufferChannels; ++ch) - buffer.setSample(ch, i, buffer.getSample(ch, i) * (ch % 2 == 0 ? lGain : rGain)); - } - } - else - { - float leftGain = cachedPanL.load(std::memory_order_relaxed); - float rightGain = cachedPanR.load(std::memory_order_relaxed); - - if (bufferChannels >= 1) - buffer.applyGain(0, 0, numSamps, leftGain); - if (bufferChannels >= 2) - buffer.applyGain(1, 0, numSamps, rightGain); - for (int ch = 2; ch < bufferChannels; ++ch) - buffer.applyGain(ch, 0, numSamps, ch % 2 == 0 ? leftGain : rightGain); - } - - if (trimAutoActive) - { - auto* trimValues = automationGainBuffer.getWritePointer(6); - trimVolumeAutomation.evalBlock(blockTimeSeconds, processingSampleRate, numSamps, trimValues); - for (int i = 0; i < numSamps; ++i) - { - const float trimGain = juce::Decibels::decibelsToGain(juce::jlimit(-60.0f, 12.0f, trimValues[i])); - for (int channel = 0; channel < bufferChannels; ++channel) - buffer.setSample(channel, i, buffer.getSample(channel, i) * trimGain); - } - } - else if (const auto trimGain = trackTrimGain.load(std::memory_order_relaxed); trimGain != 1.0f) - buffer.applyGain(trimGain); - - if (muteAutoActive) - { - auto* muteValues = automationGainBuffer.getWritePointer(7); - muteAutomation.evalBlock(blockTimeSeconds, processingSampleRate, numSamps, muteValues); - for (int i = 0; i < numSamps; ++i) - { - const bool muted = muteValues[i] > 0.5f; - if (!muted) - continue; - for (int ch = 0; ch < bufferChannels; ++ch) - buffer.setSample(ch, i, 0.0f); - } - } - - // ---- REAPER-style peak metering with decimation ---- - // getMagnitude() uses FloatVectorOperations::findMinAndMax (SIMD, no sqrt), - // which is much cheaper than getRMSLevel() (which computes sqrt per channel). - // We accumulate the running peak over METER_UPDATE_SAMPLES then commit it - // to currentRMS. At 32-sample ASIO blocks this fires ~86 times/sec instead - // of 1378 times/sec — a 16× reduction in per-track metering overhead. - float peak = 0.0f; - for (int ch = 0; ch < bufferChannels; ++ch) - peak = juce::jmax (peak, buffer.getMagnitude (ch, 0, buffer.getNumSamples())); - - if (peak > 1.0f) - clipLatched.store(true, std::memory_order_relaxed); - - meterPeakAccum = juce::jmax (meterPeakAccum, peak); - meterSampleCount += buffer.getNumSamples(); - if (meterSampleCount >= METER_UPDATE_SAMPLES) - { - currentRMS.store (meterPeakAccum, std::memory_order_relaxed); - meterPeakAccum = 0.0f; - meterSampleCount = 0; - } - + const float peak = processTrackOutput(buffer, sendSnapshot && !sendSnapshot->empty(), + blockTimeSeconds, processingSampleRate, blockPanLaw); finishRealtimeFXTailDrain(peak); } @@ -6558,12 +6983,12 @@ void TrackProcessor::applyMIDIAutomationToBuffer(juce::MidiBuffer& destination, if (numSamples <= 0 || sampleRate <= 0.0) return; - const bool velocityActive = shouldApplyAutomation(midiVelocityScaleAutomation) - && midiVelocityScaleAutomation.hasPlaybackData(); - const bool pitchBendActive = shouldApplyAutomation(midiPitchBendAutomation) - && midiPitchBendAutomation.hasPlaybackData(); - const bool channelPressureActive = shouldApplyAutomation(midiChannelPressureAutomation) - && midiChannelPressureAutomation.hasPlaybackData(); + const bool velocityActive = midiVelocityScaleAutomation.hasPlaybackData() + && shouldApplyAutomation(midiVelocityScaleAutomation); + const bool pitchBendActive = midiPitchBendAutomation.hasPlaybackData() + && shouldApplyAutomation(midiPitchBendAutomation); + const bool channelPressureActive = midiChannelPressureAutomation.hasPlaybackData() + && shouldApplyAutomation(midiChannelPressureAutomation); const bool hasCCRoutedAutomation = ccRoutes != nullptr && ! ccRoutes->empty(); diff --git a/Source/TrackProcessor.h b/Source/TrackProcessor.h index b75e17d4..b36d7a9c 100644 --- a/Source/TrackProcessor.h +++ b/Source/TrackProcessor.h @@ -2,6 +2,7 @@ #include "ProcessorSafety.h" #include +#include "PreparedIntegerDelayLine.h" #include "AutomationList.h" #include "SendAutomation.h" #include "FallbackInstrumentAutomation.h" @@ -474,8 +475,8 @@ class TrackProcessor : public juce::AudioProcessor, double getPlaybackOffset() const { return playbackOffsetMs.load(); } // Track Channel Count (internal processing channels, informational for now) - void setTrackChannelCount(int numChannels) { trackChannelCount = juce::jlimit(1, 8, numChannels); } - int getTrackChannelCount() const { return trackChannelCount; } + void setTrackChannelCount(int numChannels) { trackChannelCount.store(juce::jlimit(1, 8, numChannels), std::memory_order_release); } + int getTrackChannelCount() const { return trackChannelCount.load(std::memory_order_acquire); } // Per-track MIDI Output void setMIDIOutputDevice(const juce::String& deviceName); @@ -540,6 +541,7 @@ class TrackProcessor : public juce::AudioProcessor, bool tryProcessBlockParallel(juce::AudioBuffer&, juce::MidiBuffer&, uint64 expectedGeneration); static juce::var runParallelNAMEligibilityRegression(); + static juce::var runPreparedPDCRegression(); void setDiagnosticsTrackId(const juce::String& id) { diagnosticsTrackId = id; } int getProcessorFault(bool input, int index) const; @@ -626,6 +628,11 @@ class TrackProcessor : public juce::AudioProcessor, struct RealtimeGraphSnapshot; bool isParallelNAMGraphEligible(const RealtimeGraphSnapshot*) const noexcept; + bool tryProcessTinyEmptyGraph(juce::AudioBuffer&, juce::MidiBuffer&); + void processTrackPDC(juce::AudioBuffer&); + float processTrackOutput(juce::AudioBuffer&, bool capturePreFader, + double blockTimeSeconds, double processingSampleRate, PanLaw blockPanLaw); + void resetEmptyGraphTailState() noexcept; void processBlockInternal(juce::AudioBuffer&, juce::MidiBuffer&, const RealtimeGraphSnapshot* workerGraph = nullptr); void timerCallback() override; @@ -870,7 +877,7 @@ class TrackProcessor : public juce::AudioProcessor, retiredRealtimeAuxOwners; // Plugin Delay Compensation (PDC) - juce::dsp::DelayLine pdcDelayLine { 96000 }; // max 2 seconds at 48kHz + PreparedIntegerDelayLine pdcDelayLine { 96000 }; // max 2 seconds at 48kHz std::atomic pdcDelaySamples { 0 }; std::atomic pdcDelayDirty { false }; int pdcCurrentDelaySamples = 0; @@ -907,7 +914,7 @@ class TrackProcessor : public juce::AudioProcessor, std::atomic playbackOffsetMs { 0.0 }; // Track Channel Count (informational) - int trackChannelCount { 2 }; + std::atomic trackChannelCount { 2 }; // Pre-fader buffer (captured during processBlock for pre-fader sends) juce::AudioBuffer preFaderBuffer; @@ -965,6 +972,11 @@ class TrackProcessor : public juce::AudioProcessor, }; std::atomic realtimeGraphSnapshotForAudio { nullptr }; + // Discovery needs only a generation, never ownership of the graph. The + // worker entrypoint still validates the exact graph under its reader epoch. + std::atomic parallelNAMGraphCandidateGeneration { 0 }; + std::atomic hasEmptyRealtimeGraphForAudio { false }; + juce::uint64 tinyEmptyGraphProcessedBlocks = 0; // Callback-owned; read only by device-free tests. mutable std::atomic realtimeGraphAudioReaders { 0 }; juce::CriticalSection realtimeGraphPublicationLock; diff --git a/WORKFLOWS.md b/WORKFLOWS.md index d08546d7..622aa5d8 100644 --- a/WORKFLOWS.md +++ b/WORKFLOWS.md @@ -26,6 +26,13 @@ On Windows the Debug executable is `build/OpenStudio_artefacts/Debug/OpenStudio.exe`. Its packaged fallback is the copied `webui` directory beside the executable. +The Windows MSVC Debug host uses optimized code with debug symbols, `_DEBUG`, +the Debug CRT and assertions retained. This keeps shared inline audio helpers +optimized even when their linked definition comes from a UI or test file. +Stepping and local-variable inspection can be less direct than in unoptimized +code. The WebView2 JUCE module retains its `/O1` exception, and Debug links are +nonincremental so replaced helper definitions are selected afresh. + ## Manual testing handoff The user-facing command remains `python build.py dev --run`. After frontend or diff --git a/cmake/ApplyJUCEHeapBlockPatch.cmake b/cmake/ApplyJUCEHeapBlockPatch.cmake new file mode 100644 index 00000000..fe785ebb --- /dev/null +++ b/cmake/ApplyJUCEHeapBlockPatch.cmake @@ -0,0 +1,45 @@ +# A non-owning AudioBuffer still destroys an empty HeapBlock. MSVC's Debug +# free(nullptr) acquires the process-wide CRT heap lock before checking null. +# Keep empty views off that lock; actual allocations retain the original free. +set(JUCE_HEAP_BLOCK "${JUCE_SOURCE_DIR}/modules/juce_core/memory/juce_HeapBlock.h") +file(READ "${JUCE_HEAP_BLOCK}" JUCE_HEAP_SOURCE) +set(JUCE_HEAP_DESTRUCTOR_BEFORE [=[ ~HeapBlock() + { + std::free (data); + }]=]) +set(JUCE_HEAP_DESTRUCTOR_AFTER [=[ ~HeapBlock() + { + // OpenStudio: empty realtime buffer views must not enter the CRT heap lock. + if (data != nullptr) + std::free (data); + }]=]) +set(JUCE_HEAP_FREE_BEFORE [=[ void free() noexcept + { + std::free (data); + data = nullptr; + }]=]) +set(JUCE_HEAP_FREE_AFTER [=[ void free() noexcept + { + // OpenStudio: rebinding an empty buffer view also needs no heap operation. + if (data != nullptr) + std::free (data); + data = nullptr; + }]=]) +foreach(PART DESTRUCTOR FREE) + string(FIND "${JUCE_HEAP_SOURCE}" "${JUCE_HEAP_${PART}_BEFORE}" BEFORE_${PART}) + string(FIND "${JUCE_HEAP_SOURCE}" "${JUCE_HEAP_${PART}_AFTER}" AFTER_${PART}) +endforeach() +if(AFTER_DESTRUCTOR GREATER_EQUAL 0 AND AFTER_FREE GREATER_EQUAL 0 + AND BEFORE_DESTRUCTOR LESS 0 AND BEFORE_FREE LESS 0) + message(STATUS "JUCE empty HeapBlock realtime guard is already applied") +elseif(BEFORE_DESTRUCTOR GREATER_EQUAL 0 AND BEFORE_FREE GREATER_EQUAL 0 + AND AFTER_DESTRUCTOR LESS 0 AND AFTER_FREE LESS 0) + foreach(PART DESTRUCTOR FREE) + string(REPLACE "${JUCE_HEAP_${PART}_BEFORE}" "${JUCE_HEAP_${PART}_AFTER}" + JUCE_HEAP_SOURCE "${JUCE_HEAP_SOURCE}") + endforeach() + file(WRITE "${JUCE_HEAP_BLOCK}" "${JUCE_HEAP_SOURCE}") + message(STATUS "Applied JUCE empty HeapBlock realtime guard") +else() + message(FATAL_ERROR "Pinned JUCE HeapBlock context changed or guard is partial; refusing an unverified rewrite") +endif() diff --git a/cmake/ApplyJUCERealtimePatches.cmake b/cmake/ApplyJUCERealtimePatches.cmake index 7d4c19de..e22d6ad1 100644 --- a/cmake/ApplyJUCERealtimePatches.cmake +++ b/cmake/ApplyJUCERealtimePatches.cmake @@ -7,6 +7,8 @@ include("${CMAKE_CURRENT_LIST_DIR}/ApplyJUCEAutomationPatch.cmake") include("${CMAKE_CURRENT_LIST_DIR}/ApplyJUCELV2IdentifierPatch.cmake") include("${CMAKE_CURRENT_LIST_DIR}/ApplyJUCEMacBrowserNavigationPatch.cmake") +include("${CMAKE_CURRENT_LIST_DIR}/ApplyJUCEHeapBlockPatch.cmake") + set(JUCE_ASIO_DEVICE_SOURCE "${JUCE_SOURCE_DIR}/modules/juce_audio_devices/native/juce_ASIO_windows.cpp") if(NOT EXISTS "${JUCE_ASIO_DEVICE_SOURCE}") diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md index 9d8dce7d..74b8e7f4 100644 --- a/docs/USER_MANUAL.md +++ b/docs/USER_MANUAL.md @@ -236,6 +236,9 @@ After configuring audio, you are ready to begin: is undoable. MIDI currently combines source MIDI tracks into one clip per file. - Import references source audio files; it does not copy them into the project. Keyboard profiles and custom shortcuts can change or unbind these defaults. + - In the development checkout, a newly created audio track follows the file's + channel count (mono or stereo). Importing onto an existing compatible track + preserves that track's configuration. MIDI imports use MIDI-compatible tracks. 4. Press `Space` to play back. 5. Save your project with `Ctrl+S` (project files use the `.osproj` extension). @@ -377,7 +380,8 @@ The Timeline is the central canvas-based workspace where you arrange audio and M - **Playhead visibility**: the ruler marker and vertical line update together after zoom, scroll, or resize, including while stopped, whenever the playhead lies within the visible time range. - **Horizontal scroll**: Shift+scroll wheel, or use the horizontal scrollbar. - **Vertical scroll**: Scroll wheel when hovering over the track area. -- **Zoom to Fit**: `Ctrl+0` resets zoom to a standard overview level. +- **Zoom to Full Project**: `Ctrl+0` fits the timeline's clips, including the current recording extent, as far as the zoom limits allow. Long recordings can still require horizontal scrolling at the minimum zoom. The View menu command also works after using track or mixer controls. +- **Wide views**: bar labels and displayed grid lines thin out to stay readable. Their musical positions and the selected snap resolution stay unchanged; ruler beats follow the time signature's beat value. - **Zoom to Time Selection**: `Ctrl+Shift+E` zooms the view to fit the current time selection. - **Crosshair cursor**: Toggle via View menu for precise positioning guidance. @@ -688,6 +692,8 @@ When a track is armed, you can enable input monitoring to hear the live signal: Recorded audio files are saved as WAV files in the project directory. +**Live waveform navigation (development):** recording waveforms continue refreshing while you scroll or zoom, including a held ruler gesture. Newly revealed areas may briefly use an earlier preview while current detail arrives. Holding the ruler still keeps automatic following paused until the gesture ends. + ### 4.5 Record Modes OpenStudio offers three record modes, configurable via **Options > Record Mode**: @@ -741,6 +747,14 @@ OpenStudio automatically detects connected MIDI devices: - Select a MIDI device from the dropdown on a MIDI or Instrument track. - Available devices can be queried via Lua: `openstudio.getMIDIDevices()`. +In the October 28 development checkout, saved MIDI input and output names stay +selected while devices are checked. A name marked **(checking)** is awaiting +discovery, **(unavailable)** was absent from a successful device-list check, and +**(unverified)** means the list could not be read. The saved route is retained; +it does not automatically become **All Inputs** or **No Out**. Reconnect the saved +device and restart OpenStudio to refresh the list if needed, or explicitly choose +another listed device. + Track Routing > MIDI hardware output > Same-key overlap defaults to Raw note messages. Merge overlapping keys sends the first note-on and last note-off for each channel/key, suppressing intermediate retriggers. Changes take effect after held keys are released. This setting is saved with the track and supports Undo/Redo. It affects one track's hardware output; use separate MIDI channels for independent tracks sharing a receiver. Controllers and aftertouch pass through. Relative RPN reconstruction without a known absolute baseline is skipped rather than inventing receiver state. If the hardware MIDI queue loses messages, the sender discards the compromised backlog, releases sustain/sostenuto and notes on channels used by that track, resets overlap ownership, then resumes fresh events. Disconnecting or removing the track also releases its used channels. The routing window reports dropped messages, recovery resets and oversized messages; counts are temporary and reset when the track processor is recreated. Use separate channels for tracks sharing a receiver: channel-wide recovery can stop other voices using that same channel. This prevents retaining known stale ownership after a queue loss; it cannot restore discarded performance events or guarantee a device/driver response. @@ -807,7 +821,7 @@ MIDI Learn allows you to map physical MIDI controller knobs, faders, and buttons **Time selection:** - Primary+drag on the timeline background creates a time selection; Shift+drag on the ruler sets or extends it (`Primary` is Ctrl on Windows/Linux and Command on macOS). Plain horizontal ruler drag changes the separate project/loop range and leaves the edit selection unchanged. These are OpenStudio-profile defaults; custom mappings take priority. -- The time selection is highlighted as a shaded region. +- The time selection is highlighted as a blue shaded region. A timeline label shows its start, end and duration, with **Select clips inside**, **Set loop** and **Clear** actions. Cyan clip outlines indicate selected clips instead. In the default OpenStudio mouse profile, Ctrl/Command+drag on a clip body copies it; Ctrl/Command+Shift+drag slips its source contents. A persistent **Source offset +N s** label describes the clip trim, not an active gesture. - Time selections are used for punch recording, rendering bounds, and editing operations. ### 6.2 Moving Clips @@ -819,6 +833,11 @@ MIDI Learn allows you to map physical MIDI controller knobs, faders, and buttons - Hold `Shift` while dragging to lock movement to the first axis that crosses the drag threshold. - Hold `Alt`/`Option` while dragging to bypass snapping. - Multi-selected clips move together as a group. +- In the development checkout, dropping on a track body moves compatible clips + there. Dropping on a highlighted divider inserts destination tracks at that + position with one Undo; a selection spanning several source rows creates one + new row per source row in the same order. Dropping MIDI over an audio track + creates a compatible MIDI row below rather than changing the audio track. ### 6.3 Splitting Clips @@ -859,6 +878,29 @@ Each clip has adjustable fade-in and fade-out regions: **Auto-Crossfade**: When enabled (toggle in Main Toolbar or View menu), overlapping clips on the same track automatically create crossfades. +**Crossfade selected clips (development):** Select overlapping audio clips on one +track and use **Crossfade Selected Audio Clips** in the clip context menu, or +`Shift+X` in the OpenStudio Timeline profile. The clips remain separate, and the +whole operation is one Undo step. Adjoining clips hand off across their overlap; +a contained replacement fades in and back out while the underlying clip returns. +Same-start, same-end, coincident and nested/multiple overlaps share a consistent +handoff sequence, with later-starting clips taking foreground priority. Coincident +items follow their arrangement stack order. No source files are rewritten. + +**Edit Selected Crossfade...** opens the resulting gain-curve preview from any +member of an existing crossfade, including a split fragment. Choose incoming/outgoing curves and automatic or custom +handoff lengths; lengths are bounded to prevent colliding transitions. Automatic +uses the available overlap for adjoining clips and the configured default short +crossfade length for contained entry/exit. Crossfades are an independent gain +layer: existing manual clip fades and gain-envelope points remain active and +unchanged. Reapplying replaces the affected existing crossfades together. If linked +partners are not selected, the editor lists the affected clips before applying. +**Remove Crossfade** removes the listed linked layers and restores manual gain +behavior, with Undo. Layers survive project save/load, trim/untrim, move, split +and time stretch. Copies keep their curves in independent groups, so editing or +removing their crossfade does not change the originals. Other input profiles/custom +bindings can change the shortcut. + For an audio clip's gain envelope, Shift+click the clip to add a point. Drag an existing point to change its time and gain; right-click a point to remove it. @@ -998,6 +1040,48 @@ The playhead jumps to the next or previous transient position within the selecte When enabled via **View > Free Item Positioning**, clips can be placed at any vertical position within a track lane, rather than being confined to a single row. This is useful for arranging overlapping clips visually. +### 6.19 Detecting Clip Tempo (development) + +Right-click an audio or MIDI clip and choose **Detect Tempo...**. You can also +select one clip, open **Help > Command Palette** (`Ctrl+Shift+P` in the OpenStudio +keyboard profile), search for "tempo", and run **Detect Selected Clip Tempo...**. +An intersecting +timeline time selection initializes the range; otherwise the whole retained clip +is selected. Edit Start and End in seconds from the clip start, then **Analyze +range**. Audio analysis compares up to three audible sections with at most 60 +seconds of spectral analysis in total. Section buttons show the analyzed ranges +and choose a preview location. MIDI uses retained source tempo metadata when +available, otherwise it compares repeated beat grids in the selected notes. + +Review the editable BPM, pulse evidence, section spread and half/double or triplet +candidates. Pulse evidence is a heuristic rather than a probability; neither +automatic path identifies the intended downbeat. Sparse notes, syncopation and +changing tempos can remain ambiguous. **Cancel analysis** stops pending native +analysis without closing the dialog. Closing the dialog or changing its clip also +cancels that work. Tempo analysis uses a separate bounded worker so obsolete +analyses do not queue ahead of waveform requests. You can still enter BPM, **Tap pulse**, or expand +**Set tempo by counting beats in the selected range** when analysis finds no pulse. +For counting, set the range ends on beats and enter the number of intervals: +beat 1 to beat 5 spans four intervals. + +**Audition clip + click** plays a separate bounded dry preview, including clip +gain/fades but excluding track/master FX. Set Preview from, choose four or eight +seconds, and adjust First accented click and Group pulses by ear. Source only +keeps the source playing while you tap a new tempo. MIDI uses simple note guide +tones instead of impersonating the track's instrument or effects. Preview level +is bounded; high-rate files may produce a shorter excerpt to keep transfer size +bounded. Stop project playback/recording before starting this separate audition. +The preview uses the WebView/system audio output, which can differ from the +project's selected ASIO or other native audio-device route. + +Stop audition, Close, changing clips, hiding the page or leaving its window stops +the preview and rejects stale work. Preview does not seek or start project +transport, alter recording, write source files, or save a tempo/phase change. + +**Apply Project Tempo** changes the base BPM in one Undo step. It leaves clip +positions and audio intact and does not apply the estimated beat offset. Existing +tempo markers remain and can override the base tempo along the timeline. + --- ## 7. MIDI Editing @@ -1013,6 +1097,9 @@ The Piano Roll consists of several areas: - **Toolbar**: Tool selection (Draw, Select, Erase), step input controls, quantize, scale highlighting options. - **Piano keyboard**: Vertical piano keyboard on the left (128 notes, C-2 to G8). Click keys to preview notes. - **Note grid**: The main editing area where notes are displayed as colored rectangles. Color indicates velocity (blue = quiet, green = medium, yellow/red = loud). +- **Note hover (development)**: Hover a note to see its note name, MIDI pitch number + and velocity, including notes too narrow to contain a label. Leaving the grid or + starting an edit hides the tooltip. - **Velocity lane**: Bottom strip showing velocity bars for each note. Drag bars to adjust velocity. - **CC lane**: Additional lane for MIDI Continuous Controller editing. @@ -1119,6 +1206,20 @@ The Piano Roll supports editing multiple MIDI clips simultaneously: Toggle the Mixer Panel with `Ctrl+M` or **View > Show Mixer** or the mixer icon in the Main Toolbar. +In the development checkout, drag the grip in the docked mixer's header to resize +it. The Mixer title, grip, pop-out and close buttons share one row; the buttons +have separate hit areas and do not start resizing. Focus the grip and use +Up/Down (Shift for larger steps), Home/End for the +available limits, or double-click to restore the preferred 340 px height. The +preferred height is remembered on this computer and clamped to preserve timeline +space as other editors open or the window changes size. Detached mixer windows +retain their own window sizing. + +Pointer resizing leaves the previous Timeline or Piano Roll editing context in +place. Releasing or cancelling a drag releases pointer capture and the temporary +Escape handler; the grip does not keep keyboard focus. Tab navigation still +focuses the grip intentionally for keyboard resizing. + ### 8.2 Channel Strip Each track has a channel strip in the mixer that provides: @@ -1133,7 +1234,9 @@ Each track has a channel strip in the mixer that provides: - dB scale markings: +12, +6, 0, -6, -12, -24, -48, -inf - Gain staging indicator shows the current value. - **Peak meter**: Real-time level display updated at 10 Hz. -- **Solo (S), Mute (M), Record Arm (R)** buttons. +- **Solo (S), Mute (M), Record Arm** buttons. In the OpenStudio profile, + `Ctrl+Shift+R` arms selected tracks with track controls or Mixer focused; + `R` starts or finishes transport recording. Other profiles/custom bindings can differ. - **Phase invert** toggle for polarity correction. ### 8.3 Master Channel @@ -1143,7 +1246,7 @@ The Master channel strip is always visible on the left side of the Mixer: - Controls the final stereo output volume and pan. - Displays the master peak level meter. - Has its own FX chain (master FX). -- Does not have Solo, Mute, or Record Arm buttons. +- Includes Mute and Mono controls; it has no Solo or Record Arm button. ### 8.4 Volume and Pan @@ -1152,6 +1255,11 @@ The Master channel strip is always visible on the left side of the Mixer: the track header volume knob. A completed drag is one undoable edit. With a fader focused, Up/Down adjust the value and Home/End select its minimum/maximum. - Range: -60 dB (silence) to +12 dB. +- **Numeric level (development):** Click the dB readout below a track or master + fader and type a value. Enter or moving focus applies one Undo step; Escape + discards the draft. `-inf` or `-∞` sets silence. Invalid and out-of-range drafts + do not change the level. Typing leaves playback and recording shortcuts inactive + in the text field. - Double-click the fader to reset to 0 dB (unity gain). - Volume changes are smooth (no zipper noise) due to pre-computed gain caching. @@ -2162,7 +2270,7 @@ that rate while the playback path converts source files as required. |-----------------|------------------------------------------------------------------| | **Normalize** | Peak-normalizes the output to 0 dBFS | | **Dither** | Final integer quantization: flat TPDF, first/second-order shaped TPDF, or lower-noise RPDF | -| **Resample Quality** | UI placeholder only in the current build; backend support is pending | +| **Output sample rate** | Converts the rendered output to the selected target rate | Dither is applied once, after normalization, channel conversion and final sample-rate conversion, for 16/24-bit integer output. TPDF is the general-purpose choice. Shaped TPDF moves noise toward high frequencies; second order does so more strongly. RPDF has lower noise but its noise level varies with the signal. These are original quantizers, not a proprietary IDR emulation. Floating-point and lossy output disable dither. Queue jobs retain their selected mode, including secondary integer outputs. The limiter does not add a second dither stage. Near full scale, integer saturation can clip the dither; allow output headroom when this matters. @@ -2318,6 +2426,39 @@ Export all MIDI data in the project: - Go to **File > Export Project MIDI...** - A standard MIDI file is created containing all MIDI clips. +In the development checkout, project export writes the current project tempo, +time signature and tempo changes with the events. Clip context-menu **Export This +MIDI Clip...** and external MIDI file dragging export the visible source window, +including looped content and controller/channel data, starting at file time zero; +the project's tempo map is rebased to that window. The track's MIDI processing is +applied once. Source MIDI tempo metadata is retained for detection; exporting uses +the current project timing rather than silently reverting to that imported tempo. +Project and context-menu exports use 9,600 ticks per quarter note to retain closely +spaced controller changes, with standard quarter-note and notation metadata. +The receiving DAW can still quantize or combine controller events during its own export. + +To export several clips into one `.mid` file, Ctrl/Command+click the clips, +right-click one of the selected MIDI clips and choose **Export Selected MIDI +Clips (N)...** near the top of the menu. This command is also available by searching +the Command Palette; it has no default dedicated shortcut. Assign one in +**Keyboard, Mouse & Trackpad** if desired. Export keeps each source track as a MIDI +track in that file, retains gaps, and places the earliest selected clip at file +time zero. Muted clips and muted notes are excluded, matching playback. Audio +clips in a mixed selection are ignored. The blue time selection does not crop +this export. The clips, track MIDI effects and tempo map are captured together +before the save dialog opens. Export does not join or edit the arrangement. + +To join MIDI items, select two or more unlocked MIDI clips on one track and choose +**Join Selected MIDI Clips** from their context menu, or press `Ctrl+J` with the +OpenStudio Timeline profile active. This produces one finite clip spanning their +combined range and preserves event timing and gaps. Source loops are materialized +within the selected items' boundaries. Joining and restoring the original items +take one Undo step; this joins MIDI data rather than rendering an instrument. +The menu shows the selected count and explains unavailable joins (mixed media, +different tracks, locks or a frozen track). Overlapping same-pitch notes retain +their individual note-off, channel, release velocity and mute identity, including +after saving and reopening the project. + ### 13.14 Batch File Converter Convert multiple audio files between formats: @@ -2706,6 +2847,8 @@ platform keys without this portable notation. | Ungroup Selected Clips | `Ctrl+Shift+G` | | Toggle Clip Mute | `U` | | Duplicate Selected Clips | `Ctrl+D` (shared group offset) | +| Join Selected MIDI Clips | `Ctrl+J` (OpenStudio Timeline scope; development) | +| Crossfade Selected Audio Clips | `Shift+X` (OpenStudio Timeline scope; development) | | Nudge Left | `Left` | | Nudge Right | `Right` | | Nudge Left (Fine) | `Ctrl+Left` | @@ -2749,7 +2892,7 @@ combination in its own editor scope. `R` is reserved for global Record. | Zoom to Time Selection | `Ctrl+Shift+E` | | Zoom In | `+` / `=` / numpad `+` (also `Ctrl++`) | | Zoom Out | `-` / numpad `-` (also `Ctrl+-`) | -| Zoom to Fit | `Ctrl+0` | +| Zoom to Full Project | `Ctrl+0` | | Save Screenset 1 | `Ctrl+Shift+1` | | Save Screenset 2 | `Ctrl+Shift+2` | | Save Screenset 3 | `Ctrl+Shift+3` | @@ -3212,9 +3355,30 @@ OpenStudio generates `.ospeaks` sidecar files alongside audio files for efficien ### Sample Rate Handling -- OpenStudio handles sample rate conversion automatically when importing audio files recorded at different rates than the project's device rate. -- Linear interpolation is used for real-time sample rate conversion during playback. -- Offline rendering supports the selected target sample rate. The **Resample Quality** selector is currently a UI placeholder and does not yet change the backend conversion algorithm. +- By default, import keeps each source file at its original sample rate. A 48 kHz file in a + 44.1 kHz session is read using its own rate and converted during playback; the + original file is not rewritten to 44.1 kHz. Changing sample rate alone should + preserve duration and pitch, but it does not promise bit-identical samples. +- Cubic interpolation is used for real-time sample rate conversion during playback. +- Offline rendering supports the selected target sample rate. +- **Create [project rate] kHz Project-Rate Copy** in an audio clip's context menu + prepares a cached, band-limited 32-bit float WAV at the current project rate. + The original file remains available; the clip keeps its position, source offset, + musical duration, gain, fades and pitch-original source. A completed source + replacement is one Undo step, and Redo reuses the prepared file. +- Muted clips can also be converted without becoming audible. Unmuting uses the + converted file; Undo/Redo restores the selected source while retaining mute. + Tempo detection and explicit source audition also work on muted clips without + changing the timeline mute state. +- **File > Project Settings > Audio import sample rate** chooses between keeping + source rates and creating high-quality project-rate copies for new imports. + Existing projects and new projects default to keeping source files. The import + and any prepared copy share one Undo step. Cancelled or unavailable optional + conversion imports the original instead and reports that choice. +- Conversion progress offers Cancel while preparation is running. Once the atomic + source replacement begins, it finishes before the progress entry closes. The + former inactive Resample Quality dropdown is no longer shown; source-copy + conversion and the selected render output rate are separate controls. ### Audio Thread Safety diff --git a/docs/implemented_features.md b/docs/implemented_features.md index 331e5c4d..66feb269 100644 --- a/docs/implemented_features.md +++ b/docs/implemented_features.md @@ -26,6 +26,7 @@ Ratings: |---|---:|---:| | JUCE audio engine with React/WebView2 frontend bridge | H | H | | Real-time playback engine with sample-rate-aware clip mixing | H | H | +| Cancellable band-limited project-rate source copies, source Undo/Redo and optional import-copy policy; originals preserved (development) | H | H | | Audio device setup: driver, I/O, sample rate, buffer, channels | H | H | | Multitrack audio recording with armed tracks, monitoring, punch range | H | H | | Record modes, record-safe state, input-channel selection, and set-loop-to-time-selection workflow | H | M | @@ -43,6 +44,7 @@ Ratings: |---|---:|---:| | Konva-based timeline with ruler, grid, zoom, scroll, snap | H | H | | Audio and MIDI clip creation, import, drag/drop, move, trim, resize | H | H | +| Divider insertion preserves mixed source-row order with compatible new tracks and one Undo (development) | M | H | | Multi-clip and multi-track selection | H | M | | Split, cut, copy, paste, duplicate, delete, nudge, fine nudge | H | M | | Time selection editing: cut, copy, delete, insert silence | H | H | @@ -51,6 +53,9 @@ Ratings: | Clip fades, clip volume, gain envelopes, mute, lock, color | H | M | | Clip reverse, normalize, time stretch, pitch shift | H | H | | Auto-crossfade and crossfade editor | H | M | +| General selected-audio crossfades: contained/nested/same-edge handoffs, editable curves/lengths, preserved manual gain and one Undo (development) | H | M | +| Bounded multi-section audio/MIDI tempo analysis with pulse-family candidates, range/tap/count controls and explicit undoable base-tempo apply (development) | H | H | +| Isolated dry clip + click audition, adjustable phase/grouping and bounded MIDI guide tones; cancellation preserves project transport/source files (development) | M | H | | Takes: add, select active, explode to tracks, and implode clips | M | M | | Dynamic/transient split workflow | M | H | | Track spacers, empty audio/MIDI items, and clip launcher | M | M | @@ -63,6 +68,8 @@ Ratings: | Feature | Impact | Complexity | |---|---:|---:| | Mixer panel, channel strips, master strip, detached mixer | H | H | +| Numeric track/master dB entry with draft cancellation and one-step Undo (development) | M | M | +| Persisted docked mixer resizing with pointer/keyboard controls and workspace bounds (development) | M | M | | Track volume, pan, mute, solo, arm, monitor controls | H | M | | Master volume, pan, mute, mono, master automation | H | M | | Peak/RMS metering, master meter, clipping reset | H | H | @@ -124,6 +131,9 @@ See [built-in plugin contracts and limits](free-plugins.md); implemented feature | Quantize using last settings, reset quantize, and freeze quantize | M | M | | MIDI transforms: transpose/octave, velocity scale, reverse, invert, and scale snap | M | M | | MIDI import/export and project MIDI export | H | M | +| Join same-track MIDI clips with finite loop/source-window materialization and one Undo (development) | M | H | +| MIDI export retains channel/controller events and current project tempo/time-signature changes, rebased for clip exports (development) | H | H | +| Piano Roll note hover shows pitch name/number and velocity without pointer-driven React updates (development) | M | L | | Load/open virtual instrument on instrument tracks | H | H | | Built-in basic synth, piano, clean-guitar, drum, and fallback sampler paths | M | H | | Audio-to-MIDI creates an adjacent MIDI track with undo support | H | H | diff --git a/docs/input-profiles.md b/docs/input-profiles.md index 46915606..5e41d829 100644 --- a/docs/input-profiles.md +++ b/docs/input-profiles.md @@ -1,5 +1,22 @@ # Keyboard, Hotkey, Mouse, and Scroll Profiles +Maintainers can check the scoped source/documentation contract after installing +frontend dependencies: + +```bash +node tools/validate-input-docs.mjs +node tools/validate-input-docs.mjs --website-root ../openstudio-website +node --test tools/validate-input-docs.test.mjs +``` + +The optional website argument checks six development guides and the preview arm +tooltip. The validator reads the TypeScript action/profile/menu AST and the +document data; it does not start the app or import its browser store. It covers +OpenStudio Record/aliases, Timeline Join/Crossfade, F1 Help Reference and the +Keyboard, Mouse & Trackpad label. It does not claim to validate every vendor +profile, user override, translation or arbitrary prose. Changed source bindings +require matching scoped documentation; fixture tests verify stale claims fail. + OpenStudio can adopt the familiar input conventions of another DAW without changing the project or audio engine. Keyboard and mouse/scroll behavior are separate choices: for example, you can use Cubase-style keys with REAPER-style @@ -245,6 +262,18 @@ profiles, and OpenStudio adaptations are distinct. ## Navigation qualification +The November 4 development workflow shows **Time selection** with range/duration +and clear, select-clips and loop actions for the blue editing range. Under the +OpenStudio Windows mouse defaults, Ctrl+drag on empty space creates that range; +Ctrl+drag on a clip copies it, Ctrl+click toggles selection, and Ctrl+Shift+drag +slips its source. Feedback uses the resolved profile action. Persistent MIDI +**Source offset** labels describe retained content, not an active gesture. +**Join Selected MIDI Clips** shows its effective shortcut and disabled reason. +**Export Selected MIDI Clips...** is available in the clip menu and action catalog; +it has no new default shortcut and can be assigned in Keyboard, Mouse & Trackpad. +Pointer resizing of the mixer releases temporary shortcut ownership on completion; +Tab focus intentionally retains keyboard resizing until focus moves elsewhere. + Use `tools/input-profile-browser-regression.js` in a disposable browser session against the real App. Resolver/dispatch-table tests alone do not prove that a Timeline, Piano Roll or Pitch Editor viewport moves. Check all profile families, diff --git a/docs/issue-coverage-matrix.md b/docs/issue-coverage-matrix.md new file mode 100644 index 00000000..a3c37875 --- /dev/null +++ b/docs/issue-coverage-matrix.md @@ -0,0 +1,249 @@ +# Original issue coverage and qualification + +Latest staff-review follow-up: [4 November implementation and qualification](workflow-review-qualification.md). +The four correctness findings and MIDI/mixer/gesture workflows are implemented +with permanent regressions. A fresh native eight-sample capture passes file, +gesture and reopen checks but **still fails strict timing**; the report records +21 host overruns and 19 engine deadline misses in its measured recording interval. +Historical results below retain their original scope and dates. + +Audit date: 28 October 2026. This maps all 23 paragraphs of the supplied issue list, including the separate requests within paragraphs 6, 18 and 21. IDs follow the attachment's original order. Repeated symptoms remain identifiable even when they share an implementation. + +**The implementation is broader than the completed qualification. Passing the general test suite does not mean every reported symptom was reproduced and resolved in the supplied project.** The implementation-stage checkboxes in [the remediation plan](issue-remediation-plan.md) refer to implementation and the recorded generic checks; they do not establish long hardware-recording performance, physical Explorer drop behavior, electrical timing or subjective audio acceptance. + +Classification: + +- **Implemented:** a corresponding production change exists, with the specific evidence listed. This is not a universal or hardware guarantee. +- **Partial:** useful behavior exists, but an explicit feature limitation or an unresolved part of the report remains. +- **Existing behavior:** the relevant behavior predates this work; the change chiefly clarifies, exposes or verifies it. +- **Unverified:** the reported outcome has not been established by an appropriate reproduction/measurement. This can accompany an implemented change. + +## 4 November review comments: tempo discovery and mixer header + +- **pass:** audio and MIDI tempo detection are reachable through both the clip context menu and Help > Command Palette > Detect Selected Clip Tempo. The manual and development website guide now describe the searchable route. Analysis does not change project tempo until Apply. +- **pass:** track/master dB readouts are editable text inputs; commit, invalid input, Escape, stored precision and Undo remain covered. +- **pass:** the docked mixer's title, resize grip, pop-out and close controls share one 28 px header row, with separate non-overlapping hit areas. Pointer/keyboard resize, reset, persistence, workspace limits and focus were checked at 1280x900 and 800x600. Escape during resize now restores the original height without deselecting tracks; stable parent callbacks keep the temporary shortcut owner registered through height changes. +- **pass:** 10 targeted browser cases and 47 related unit cases, e2e TypeScript, frontend build and CMake Debug build. Visual review also used the supplied project's 31 normalized tracks. Pop-out verification here covers the browser-to-native request binding with a mocked native response; it does not requalify OS window lifecycle. +- The existing actual-song render evidence was reviewed, not rerun for this UI change: 74 native checks, full 409.870 s song plus 1 s tail at both WAV rates and MP3, identical repeated exports, and the independent 4,213-window source comparison with zero missing-audio windows. Subjective mixdown acceptance remains **not_asserted**; hardware deadline failures recorded below are unchanged. + +Evidence: [review artifacts](../output/shared-project-qualification/nov04-review-comments/), including `browser-verified.log`, `unit-tests.log`, `visual-review.json`, `frontend-build-final.log` and `debug-build-final.log`. These local changes are not a published release. + +## 4 November music-input recording and live-preview follow-up + +The user supplied streaming music to Audient Analogue 1 and confirmed that the source itself became quiet around 45–90 seconds. The initial capture uses the previously qualified Release binary/frontend; the later preview change is separately qualified below. The original project is tested through isolated copies. + +- **Recording/file pass:** **191.446833 seconds / 9,189,448 mono frames**, Audient ASIO **48 kHz / 8 samples**, optional callback tracing off. All **27/27** file/session checks pass, including unchanged original clips, exact saved WAV/clip frames, peak-cache publication and zero reported writer overflow, write-lock misses, read underruns or device interruption. [Recording report](../output/shared-project-qualification/nov04-music-eight/measurement/recording-analysis.json). +- **Strict timing fail:** the recording-only deltas are **83 host overruns / 71 engine deadline misses**. Driver-only xrun reporting remains unsupported. These are timing failures, not a measured count of audible glitches. [Capture summary](../output/shared-project-qualification/nov04-music-eight/summary.json). +- **Music diagnostics, diagnostic_only:** recorded peak is **−6.33 dBFS**, with no digital full-scale samples or repeated nonconstant eight-sample blocks meeting the documented detector thresholds. The longest exact-zero run is 183 samples / 3.8125 ms, within the user-confirmed quiet section; its boundary step is one 16-bit quantization unit. Constant/near-silent repetitions remain in the raw report and are not reclassified as proven DAW dropouts. Source PCM was unavailable, so sample-aligned continuity, electrical alignment and listening acceptance remain **not_asserted**. [Signal diagnostics and thresholds](../output/shared-project-qualification/nov04-music-eight/music-analysis.json), [source confirmation](../output/shared-project-qualification/nov04-music-eight/source-confirmation.json). +- **Scroll/zoom diagnostics:** **475** observed wheel events cover plain/Control/Shift/Alt, restoring the view and rapid modifier changes. These phases contain no browser long tasks, long animation frames or visible rAF intervals over 50 ms. Main-draw p95 for the four sustained modes is **1.2/19.5/18.9/1.2 ms**. Browser-created wheel dispatch p95 is around 50 ms and is retained separately; drawing completion is not physical input-to-presentation latency. The saved music waveform and original MIDI were visually inspected; no frontend page errors were observed. [UI report](../output/shared-project-qualification/nov04-music-eight/measurement/analysis.json), [saved view](../output/shared-project-qualification/nov04-music-eight/music-saved-take.png). +- **Additional reproduced issue and implementation:** live peak polling previously paused for the entire scrolling/zooming gesture, leaving the growing recording blank or stale until idle. Interactive polls now retain a 250 ms batch budget and one in-flight request. Valid completed samples retain their source coordinates across viewport changes; recording lifecycle generations still reject Stop/new-take/unmount completions. Track rotation prevents a slow obsolete viewport batch from starving another armed track. Held ruler gestures continue to suspend automatic following while live data progresses. +- **Post-fix native pass for preview progress and files; timing fail:** the rebuilt Release frontend reopens the saved music project and records **99.8875 seconds / 4,794,600 mono frames** at **48 kHz / 8 samples**. All **27/27** file/session checks pass against that music-project baseline. During the active portions of 50 plain/Control/Shift/Alt events each, **6/2/1/20** nonempty native peak responses complete before the gestures end; request concurrency stays at one and peak-work budgets pass. Coarse zoom/cached windows need fewer new bins. Including view restoration, 227 wheels have no browser long tasks, long animation frames or visible rAF intervals over 50 ms. Draw p95 is **1.4/19.3/19.6/1.5 ms**, still diagnostic rather than physical presentation latency. No observed page errors; visible saved music and MIDI remain. Host/engine recording-only deltas are **42/39**, so strict timing still fails. Continuous input was not requested again for this UI-focused follow-up. [Preview progress](../output/shared-project-qualification/nov04-preview-eight/preview-progress-report.json), [recording checks](../output/shared-project-qualification/nov04-preview-eight/measurement/recording-analysis.json), [UI diagnostics](../output/shared-project-qualification/nov04-preview-eight/measurement/analysis.json), [saved view](../output/shared-project-qualification/nov04-preview-eight/preview-saved.png). +- **Software pass:** **3,115 tests / 265 frontend files**, **21 distinct focused browser cases** (17 recording/zoom plus four actual-project/source-geometry cases), frontend build/type checks, warning-free CMake Debug/Release builds, website build/prerender and the eight-document input contract. The old browser assertions requiring a total polling pause were updated to assert bounded progress while retaining anchor/cancellation/stop coverage. The actual-project cases were explicitly rerun with the required project path; earlier skips are not counted as passes. No C++ audio code changed in this follow-up. [Frontend tests](../output/shared-project-qualification/nov04-music-eight/frontend-tests.log), [recording/zoom browser](../output/shared-project-qualification/nov04-music-eight/preview-browser-final.log), [actual-project browser](../output/shared-project-qualification/nov04-music-eight/actual-project-browser.log). + +Final verification: all 148 frontend distribution files match both Debug and Release packaged assets; the original project SHA256 is unchanged. Task-owned applications, collectors and ports 5183/5179/9234 are closed. A pre-existing Playwright test server started on 3 November was left untouched. The app manual and unpublished website guide describe the new preview behavior; the guide was inspected at 390 and 1,440 px widths. `python build.py dev --run` needs no pre-running server. [Verification](../output/shared-project-qualification/nov04-music-eight/final-verification.json). + +## 2 November four-step follow-up + +This section supersedes the earlier same-day software counts, short-recording limits and external-MIDI scope below. Continuous input remains deferred by the user. **File/session consistency and UI improvements do not turn the remaining eight-sample deadline misses into a pass.** All four requested follow-up investigations and their targeted changes/tests have run; overall acceptance is still open for the specific failures and unverified boundaries below. + +- **Implemented and pass:** realtime track snapshots cache playback continuity keys, removing repeated string hashing from eight-frame callbacks. Native-scroll publication shares the pending frame update with wheel navigation and preserves horizontal anchors. Project restoration yields between bounded track batches. Indexed MIDI release pairing removes the measured quadratic first-scroll work while retaining source order, wildcard channels, strict release times and tie order. The supplied project's normalized track/backend MIDI payload is exactly unchanged. [Payload parity](../output/shared-project-qualification/nov02-deep-index-normalization-parity.json). +- **Software pass:** **3,114 frontend tests / 265 files**, **20 focused browser cases**, **416 native checks**, and **74 actual-project checks**. The browser count combines 18 passing cases with two actual-project cases rerun using the required project path; the original two skips are not counted as passes. All five full-song audio exports and the SMF remain byte-identical to the preceding deterministic outputs. The instant and macrotask project-load fixtures have zero React update-depth warnings; expected mocked-native startup/schedule reports remain scoped to the fixture. Full frontend, warning-free CMake Debug and CMake Release builds completed. [Frontend](../output/shared-project-qualification/nov02-density-frontend-tests.log), [browser](../output/shared-project-qualification/nov02-density-browser.log), [actual-project browser](../output/shared-project-qualification/nov02-density-actual-project-browser.log), [native](../output/shared-project-qualification/nov02-deep-native/20261102_013603_prepared-track-key/render_export_regression_result.json), [project](../output/shared-project-qualification/nov02-deep-project/project_qualification_result.json), [export parity](../output/shared-project-qualification/nov02-deep-project/export-parity.json). +- **Additional display fix and pass:** the long run reproduced crowded ruler labels and excessive bar lines at minimum zoom. Shared ruler density now spaces labels and ticks by whole-bar groups, accounts for long bar numbers and meter denominators, and preserves fine subdivisions at close zoom. The grid thins only displayed lines; the selected snap interval is unchanged. Unit and real-browser geometry checks cover distant positions and several meters. The final Release recording screenshot shows readable labels, preserved time-selection placement, source waveforms and MIDI. Zoom to Full Project remains constrained by the existing 1–1,000 px/second range, so a 31-minute take still needs horizontal scrolling in this window. [Final recording view](../output/shared-project-qualification/nov02-density-reopen-eight/recording-settled-view.png). +- **Audio attribution, diagnostic_only:** synchronized ETW has zero lost events. Of 132 observed recording misses, 79 overlap more than 10 µs of interrupts, two overlap more than 10 µs off CPU, and 52 retain residual time above the 166.7 µs budget. These sets overlap; residual time is not a pure application CPU measurement. Graphics-related interrupt stacks are prominent, while symbolized application samples justify the cached-key change. The trace predates that change; it does not prove all remaining misses are external or that they are audible dropouts. [Correlation](../output/shared-project-qualification/nov02-deep-scheduling/correlation.json), [symbolized samples](../output/shared-project-qualification/nov02-deep-scheduling/symbolized-summary.json). +- **Cold-scroll improvement, diagnostic_only:** complete native CPU/browser captures of fresh Debug sessions with the supplied project, recording and 100 plain-wheel events show the longest script task decreasing **55.865 → 14.586 ms** and longest animation callback **56.629 → 5.277 ms**. The earlier stall's CPU samples identify MIDI pairing; after indexing, no captured script task exceeds 16 ms. Both traces include compositor events, but captured dropped-frame events remain and the captures differ in wall duration. This is not an end-to-end physical-input/presentation guarantee. [Comparison](../output/shared-project-qualification/nov02-cold-ui-comparison.json), [post-fix trace completion](../output/shared-project-qualification/nov02-deep-cold-ui-indexed/trace-status.json). +- **Final Debug/Release trace-off recording, file/session pass and strict timing fail:** fresh copies produced **206.528667 seconds / 9,913,376 mono frames** in Debug and **213.9835 seconds / 10,271,208 frames** in Release, both at **48 kHz / 8 samples**. Each passes **27/27** file/session checks with zero writer overflow, recording lock misses, read-ahead underruns, device interruptions or changes to original clip data. Recording-only host/engine deadline increases are **829/808** and **621/600**, respectively. Driver-only xrun support is unavailable. Both runs observe 100 plain, 100 Control, 100 Shift and 115 Alt wheel events, with zero browser long tasks, long animation frames or visible rAF intervals above 50 ms during those phases. Debug next-main-draw p95 is 1.3/20.3/20.2/2.9 ms; Release is 37.8/21.0/20.8/1.5 ms, with a 118.9 ms Release plain-scroll maximum. Native scroll can reuse existing canvas content, so missing/delayed redraw is not equivalent to delayed presentation. The Release gesture sequence started later after a QA-helper rebind, and its automatic-update banner later reduced workspace height; these are diagnostic comparisons, not controlled speed claims. Visible waveforms/MIDI and frontend-error checks pass. [Combined evidence and limits](../output/shared-project-qualification/nov02-final-eight-summary.json). +- **Actual REAPER round trip, partial:** installed REAPER 7.19 imported both tracks and the tempo map, then exported type-1 SMF at 9,600 PPQ. Notes and exact note times per track, track separation, tempo/meter and final controller values at each tick pass. **Full event identity and controller state at note onset fail:** REAPER removes 27 intermediate controller events and reorders same-time controller/note events, changing the controller context at **284 note starts**. OpenStudio's export retains the original ordered events. Do not describe this round trip as lossless or seamless. [Round-trip comparison](../output/shared-project-qualification/nov02-deep-midi/reaper-roundtrip-report.json). + +- **Long Release recording, file/session pass and strict timing fail:** **30 minutes 59.384 seconds / 89,250,448 mono frames**, actual Audient ASIO **48 kHz / 8 samples**, optional callback tracing disabled. All **27/27** checks pass: exact WAV/header/clip frames, peak sidecar publication, original track/clip data preserved, no writer overflow, recording lock misses, read underruns or device interruption. Recording-only increases are **2,658 host overruns / 2,565 engine deadline misses**. The original song ends around 410 seconds; the remainder is a lighter playback/recording soak. Repeated wheel groups, rapid modifier changes and minimization/recovery were exercised. The user restored the minimized window after the desktop tool could not; recording continued. Native telemetry's maximum recording gap is **3.016 seconds**, with none over five seconds. Browser input/screenshot waits around occlusion are not counted as foreground response times. This run predates only the final ruler/grid density change. [Recording checks](../output/shared-project-qualification/nov02-long-release-eight/measurement/recording-analysis.json), [saved long session](../output/shared-project-qualification/nov02-long-release-eight/test.osproj). +- **Long-run resources, diagnostic_only:** captured file size grows monotonically. Settled native private memory moves from about **445 to 477 MB**; the initially enumerated WebView process tree moves from about **794 to 1,124 MB**, peaking around **1,175 MB**. Stop-time peak generation, caches and garbage collection affect these figures; no leak-free claim follows. Desktop background workload was not controlled. [Resource evidence and limits](../output/shared-project-qualification/nov02-long-release-eight/resource-analysis.json). +- **Final density-build reopen/record, pass for files and UI behavior; timing fail:** reopen the saved long session and record another **75.906333 seconds / 3,643,504 mono frames** at **48 kHz / 8 samples**. All **27/27** checks pass against that saved baseline, including preservation of the original clips and long take. The 227 observed wheel events cover all four modes and restoring the view. Their phases have zero browser long tasks, long animation frames or visible rAF intervals over 50 ms; next-main-draw p95 for plain/Control/Shift/Alt is **34.0/19.8/19.8/1.4 ms**. These remain diagnostic draw timings, not physical presentation latency. No observed frontend page errors; saved waveforms/MIDI/ruler inspected. Recording-only host/engine deadline increases are **169/169**, so strict timing remains **fail**. [Recording checks](../output/shared-project-qualification/nov02-density-reopen-eight/measurement/recording-analysis.json), [UI diagnostics](../output/shared-project-qualification/nov02-density-reopen-eight/measurement/analysis.json), [saved view](../output/shared-project-qualification/nov02-density-reopen-eight/saved-final-view.png). + +**Still open:** zero-miss eight-sample timing and complete native input-to-presentation responsiveness require further controlled attribution; they are not waiting on continuous input alone. REAPER's simultaneous-event normalization prevents lossless interchange in this project. Known-signal continuity, electrical/metronome alignment, subjective listening, physical TD-17 performance and physical Explorer-to-WebView drops remain **not_asserted**. The last two lack a qualified hardware/tool path in this session. No buffer increase, hidden monitoring delay, counter suppression, or arbitrary MIDI timing shift was used to turn a failure into a pass. + +The original user project remains SHA256 `C4C8140DF666A15D52A56D065D1B6390B0136BAE9DFDCE35822323018D68EE81`. Both packaged builds contain the current frontend (matching index SHA256 `10CFBD179B4EBEEF5731862878158BD648F5E74832FD7FA97F4D86EBFA074590`). The local website build/prerender and eight-document input contract pass; nothing was published. Test applications/collectors and task-owned ports 5183/5179/9234 are closed. `python build.py dev --run` requires no pre-running server. + +## 2 November follow-up: input-independent work + +This section records the preceding same-day build. **Eight-sample timing acceptance still fails.** Continuous-input comparison remains deferred at the user's request; a nonzero recorded level alone is not an identified continuous source. + +- **Implemented and regression pass:** playback caches prepared source bindings rather than resolving three source maps for every eight-frame chunk. Generation invalidation and source-key resets cover replacement, cached-key restoration, preview publication, reader eviction and project reload. Corrected-source playback no longer queries filesystem existence in the callback. The native suite passes **415/415**, including five new PCM/lifetime/cache-reuse checks. [Native results](../output/shared-project-qualification/nov02-native/20261102_002752_source-bindings/render_export_regression_result.json). +- **Zoom bug reproduced and corrected:** View → Zoom to Full Project previously did nothing while the menu owned modal focus. Explicit project/selection zoom now targets Timeline; scale and origin publish together and supersede queued horizontal wheel/follow work. The usual manual-navigation grace period precedes resumed transport following. Full-project menu fitting during recording, selection fitting from the command palette, and recording restart after deep zoom were verified in the native WebView. [Settled recording view](../output/shared-project-qualification/nov02-final-eight/fit-settled.png), [selection fit](../output/shared-project-qualification/nov02-final-eight/selection-fit-confirmed.png). +- **Software pass:** **3,107 frontend tests**, **14 recording/zoom browser tests**, **four actual-project/source-geometry browser cases**, and **74 native actual-project checks**. All five full-song exports match the previous verified outputs byte for byte. The project browser diagnostic retains its mocked-IPC startup/schedule warnings; no clean-console claim is inferred for that fixture. Native interaction runs recorded no frontend page errors. [Frontend](../output/shared-project-qualification/nov02-frontend.json), [recording browser](../output/shared-project-qualification/nov02-zoom-browser.log), [project browser](../output/shared-project-qualification/nov02-project-browser.log), [project native](../output/shared-project-qualification/nov02-project/project_qualification_result.json), [export parity](../output/shared-project-qualification/nov02-project/export-parity.json). +- **Trace-off recording file/session pass, strict timing fail:** **174.962333 seconds / 8,398,192 mono frames**, actual Audient ASIO **48 kHz / 8 samples**, all 27 file/session invariants pass. No reported writer overflow, read miss, lock miss, device interruption or original-clip change. Pre-start through stop adds **477 host overruns / 453 independent engine deadline misses**. Driver-only xrun reporting remains unsupported. This is the current C++ source-binding build with the pre-menu-fix frontend. [Recording report](../output/shared-project-qualification/nov02-eight/measurement/recording-analysis.json). +- **Final frontend native run, diagnostic_only:** a **94.088833-second** take plus a separate **20.426667-second** restart take use 8 samples. The restart passes seven file/route checks; the first take passes 26 recording checks but deliberately fails the trace-off gate because callback tracing was enabled. It adds **277 host overruns / 262 engine deadline misses**. All four wheel modes have 100 browser-trusted events, plus 15 Alt events restoring height. Handler-to-next-main-draw p95 is **1.0 / 21.0 / 20.3 / 1.3 ms** for plain/Control/Shift/Alt. Plain scrolling includes two long tasks and two frame intervals over 50 ms. All visible waveforms settle after project fit; MIDI thumbnails remain present. These are diagnostic draw timings, not physical-input-to-presentation acceptance. [Recording](../output/shared-project-qualification/nov02-final-eight/measurement/recording-analysis.json), [restart](../output/shared-project-qualification/nov02-final-eight/restart-report.json), [UI](../output/shared-project-qualification/nov02-final-eight/measurement/analysis.json). +- **Callback profiling, diagnostic_only:** regular samples give median **16.3 µs**, p95 **33.8 µs**, p99 **63.2 µs**, versus the earlier same-day median **18.4 µs**, p95 **34.3 µs**, p99 **56.8 µs**. Sessions differ in duration/workload; tails and nonzero deadline counts prevent a performance acceptance claim. Source-map reuse is deterministically tested, but this is not a controlled proof that rare misses improved. [Candidate trace](../output/shared-project-qualification/nov02-final-eight/callback-analysis.json), [baseline](../output/shared-project-qualification/nov02-baseline/callback-analysis.json). +- **External MIDI partial:** the current SMF's notes and exact quarter-note positions pass REAPER 7.19's native parser/readback. Merged controller-state changes match. Exact event identity fails because REAPER reduces controller messages and adds end all-notes-off. Separate-track routing, current SMF re-export, tempo-map import and hardware playback are not asserted by this API check; the native Windows dialog could not be controlled reliably. [Independent import report](../output/shared-project-qualification/nov02-reaper/native-import-report.json). + +The original project SHA256 remains `C4C8140DF666A15D52A56D065D1B6390B0136BAE9DFDCE35822323018D68EE81`. Current frontend and warning-free CMake Debug builds completed; packaged/frontend index hashes match and no pre-running server is required. The manual and website use the actual Zoom to Full Project label; the website build/prerender, eight-document input contract and 390/768/1440 px guide checks pass. Nothing was published. Diagnostic apps and task-owned servers on 5183/5179/9234 are closed. Known-signal continuity/listening, electrical timing, unavailable TD-17 recording, physical Explorer drops and the conditional 30-minute acceptance run remain unqualified. Strict audio timing and complete native presentation acceptance remain open software/performance work, not items awaiting continuous input alone. + +## 31 October implementation follow-up + +### Resumed qualification, 31 October + +The current Debug binary (`6c598a55e94e21aae1362efe4639f391638785fbe02194c458a6ec4d960b3471`) +passes [410 native checks](../output/shared-project-qualification/resume-native/20261031_140236_block-access/render_export_regression_result.json) +and [74 actual-project checks](../output/shared-project-qualification/resume-project/project_qualification_result.json). +The clean project harness completed and verified the original project hash unchanged. +All five full-song export hashes are identical to the previous verified outputs; +see [export parity](../output/shared-project-qualification/resume-project/export-parity.json). +The Debug build completed without compiler warnings and copied the matching frontend distribution. + +- **Implementation and parity pass:** stable delay-compensation blocks now reuse channel pointers while maintaining warm history, wrap positions and the original interpolation arithmetic. Transitions still use the existing smooth tap change. Independent JUCE comparisons cover mono/stereo, reset/reprepare, fractional/integer taps, signed zero, overflow, mixed block/sample calls and wrap boundaries. Playback likewise reuses validated source/mix/output pointers without changing its sample calculation or continuity policy. +- **File/session pass, timing fail:** [the latest native recording](../output/shared-project-qualification/resume-eight-signal/measurement/recording-analysis.json) saves **179.7235 seconds / 8,626,728 mono frames** at **48 kHz / eight ASIO samples**. All 27 recording invariants pass, including zero writer overflow, recording lock misses, playback read misses, device interruptions and changes to original clip properties. Pre-start through stop counters increased by **815 host overruns / 772 independent engine deadline misses**. Driver-only xrun reporting remains unsupported. These are process-wall timing measurements, not a count of proven audible dropouts. +- **Diagnostic UI evidence:** [the latest UI report](../output/shared-project-qualification/resume-eight-signal/measurement/analysis.json) includes 100 plain, 100 Control, 100 Shift and 100 Alt wheel events during recording. Every event has a following main-canvas draw within 250 ms. Handler-to-draw p95 is respectively **1.1 / 19.3 / 19.1 / 1.2 ms**; the largest observed value is 28.4 ms. The visible waveform/MIDI content remained present, and a post-zoom menu interaction completed with no recorded frontend errors. Input was delivered through CDP; these timings are not physical-input-to-presentation latency or a universal smoothness guarantee. The plain and Control phases each contain one rAF interval over 50 ms. +- **Known-signal acceptance not asserted:** the latest take contains only approximately two 16-bit quantization steps of peak input (0.000061), RMS 0.0000216. The user subsequently confirmed that no source was playing during this test. This run cannot establish musical continuity or listening acceptance. The earlier non-silent Analogue 1 run retains its separate scope below; a continuous source is needed only for the separate input-continuity check. +- **Post-heap-guard ETW, diagnostic only:** [the saved trace correlation](../output/shared-project-qualification/implementation-heap-trace/correlation.json) has zero lost Windows events and aligns 206 recorded deadline misses. Three overlap more than 10 microseconds off CPU, 97 overlap more than 10 microseconds of interrupts, and 112 have residual wall time above the callback budget after measured interruption subtraction. Sets overlap; instrumentation and unsampled work remain in the residual. The matched callback stack summary no longer contains the earlier empty-`HeapBlock` free path. This trace predates the channel-pointer optimization and is not a paired performance acceptance run. +- **MIDI export pass internally:** the latest source SMF uses 9,600 PPQ and preserves all 12,720 events, with maximum decoded time error 20.3 microseconds. Event positions now use the same integral microseconds-per-quarter-note clock that is encoded in SMF, and meter metadata uses the standard 24/8 auxiliary fields. The earlier REAPER round trip preserved all notes, final controller state at each tick, tempo and meter; REAPER combined 27 intermediate same-tick CC4 messages, so exact all-event identity did not pass. The latest external rerun remains unverified: the user reported dismissing the startup dialog, then desktop control stopped after detecting the physical Escape key, before import could be verified. + +The preceding 312.102-second post-heap-guard take is retained in +`implementation-heap-eight`, but its observer was accidentally launched without the telemetry file argument. +Its UI observations and saved file remain available; it is **not** a complete recording telemetry qualification. +The resumed run above explicitly collected continuous telemetry and supersedes that attempt. + +**Still open:** native eight-sample timing acceptance, a meaningful latest-build signal/loopback and audition, +physical TD-17 capture (device unavailable), physical Explorer-to-WebView dragging +(the desktop tool rejects cross-process endpoints), and electrical/metronome alignment. +The conditional 30-minute acceptance run is not passed. No larger-buffer result substitutes for eight samples. + +The [research-backed follow-up plan and implementation evidence](issue-remediation-plan.md#implementation-evidence--31-october-2026) supersede the historical software counts below. The three failing review probes are now permanent passing regressions; the full frontend suite passes 3,106 tests, native regression passes 405 checks, and the latest relevant browser runs pass 48 cases (40 Chromium and eight WebKit). + +- **Item 11 implementation/regression pass:** affected crossfade groups are replaced atomically; any split member exposes removal, and copies own independent groups. Tests cover partial/expanded selections, split/delete/save, duplicate/paste, locked partners and Undo/Redo. Audible listening remains separate. +- **Item 15 implementation/regression pass:** conversion works on muted and active clips, handles concurrent mute/unmute/delete/source changes, and serializes rapid Undo/Redo. Muted tempo analysis and source preview now pass native invariants without adding the clip to playback. +- **Items 4, 8, 14, 16 and 20 remain open for native acceptance:** strict eight-sample timing still fails, and native wheel/modifier presentation evidence is insufficient. The plan keeps recording at 48 kHz / 8 samples and adds non-silent signal, sustained recording and native presentation checks. + +The latest run also bounds waveform work to visible rows and four bridge requests, batches completed cache updates and preallocates preview-segment scratch outside audio processing. These changes are implemented, but do not close the hardware/presentation gates by themselves. A 156.879-second native recording with 100 events in each wheel mode has diagnostic frame/CPU evidence; the input was silent and callback tracing was enabled. Three accepted non-silent short sessions and the conditional 30-minute run remain unqualified. The original project has not been modified. + +### Analogue 1 and synchronized Windows trace + +- **pass, file/session invariants:** the supplied project copy recorded **178.621167 seconds / 8,573,816 mono frames** of the user's Analogue 1 signal at 48 kHz / eight samples, with callback tracing disabled. All 27 checks in [the recording report](../output/shared-project-qualification/implementation-analogue-one/measurement/recording-analysis.json) pass: original clips preserved, exact saved duration/header/data agreement, no reported writer overflow, playback cache miss or device interruption. The analyzer initially assumed stereo; its explicit expected-channel argument now validates the selected mono route. This was a test defect, not missing recorded frames. +- **fail, strict host timing:** the same run accumulated 1,484 host overruns and 1,459 independently measured engine deadline misses from the pre-recording snapshot through the first stopped snapshot. Driver xrun reporting is unsupported. Nonzero input is established (peak 0.4028, RMS 0.0361), but source identity/reference, audible dropout acceptance and electrical latency remain **not_asserted**. +- **diagnostic_only, native interaction:** [the UI report](../output/shared-project-qualification/implementation-analogue-one/measurement/analysis.json) contains 110 Windows wheel events and 100 events for each Control/Shift/Alt mode. Plain-wheel event delivery p95 was 29.1 ms and listener-to-next-main-canvas-draw p95 was 8.4 ms. Modifier input used browser automation and includes its dispatch overhead. These timings are not compositor presentation or physical-input latency acceptance. +- **diagnostic_only, current-build system trace:** the administrator-approved collector saved [the synchronized trace analysis](../output/shared-project-qualification/implementation-synchronized-trace/correlation.json) with zero lost Windows events. Raw QPC timestamps align 605 recorded deadline misses with scheduling and interrupts; 234 overlap more than 10 microseconds off CPU, and 169 overlap more than 10 microseconds of interrupts (sets overlap). 363 still exceed the budget after measured interruption intervals are subtracted. These residuals include instrumentation and are not pure CPU-time measurements. +- **new targeted fix under qualification:** matching stacks expose `TrackProcessor::tryProcessTinyEmptyGraph` destroying non-owning audio views through Debug CRT `free(nullptr)`. The installed Windows SDK's `debug_heap.cpp` confirms that `_free_dbg` acquires the process-wide heap lock before its null check. The pinned JUCE patch skips `std::free` only for null `HeapBlock` storage in destruction and explicit release. Owned storage still follows normal release. Patch idempotence/partial-context rejection and native ownership/view-lifetime tests protect the change; post-change hardware results must be recorded before claiming improvement. + +## Previous acceptance record (before the follow-up implementation) + +Measurement correction: historical `audioDeviceXRunCount` values come from JUCE AudioDeviceManager, which adds its host load-measurer count to any supported driver count. They are not driver-only ASIO signals. Historical captures cannot be split retrospectively; native callback deadline misses remain an independent measurement. The user reports reliable eight-sample operation with the same Audient setup in another DAW. Follow-up work therefore profiles OpenStudio's fixed callback costs and recording handoff rather than substituting a larger buffer. + +**Final software and eight-sample recording/file checks pass; the strict eight-sample host-deadline gate still fails. Native UI timings remain diagnostic, and no blanket "all fixed" or smoothness guarantee is supported.** Larger-buffer results cannot replace the required 48 kHz / eight-sample ASIO route (approximately 0.1667 ms per callback). + +- **pass: latest frontend:** [261 files / 3,088 unit tests](../output/shared-project-qualification/final-packed-frontend-tests.log), production/e2e TypeScript, the production frontend build and both Chromium [packed-waveform integration cases](../frontend/e2e/packed-waveform-tiles.spec.ts). The latter use a mocked native backend to verify shared tiles during playback, source invalidation, zoom, and late retired-generation responses. The focused run passes 18 waveform/recording-preview checks; the [frontend-stage verification manifest](../output/shared-project-qualification/packed-waveform-ui-verification.json) records the completed commands, times, dist hashes and Playwright last-run snapshot without relabeling older logs. The [earlier 38 workflow cases: 30 Chromium + 8 WebKit](../output/shared-project-qualification/final-workflow-regression.log) retain their recorded-build scope; they are not a fresh whole-workflow run on the post-ETW candidate. Vite reports large chunks and ineffective dynamic imports; no TypeScript or notice-check errors remain. +- **pass: final critical browser qualification:** [17 relevant Chromium cases](../output/shared-project-qualification/final-critical-browser-verification.json): 13 recording-zoom cases, three shared-project/load/deep-scroll cases and the source-geometry/trim/insertion/DPI case. The [fresh actual-project report](../output/playwright/shared-project-qualification/report.json) contains **44 explicit checks** using the real 31-track / 33-audio-clip / 9-MIDI-clip project; its original SHA256 remains unchanged, with no page errors or React warnings in that geometry run. The first targeted run passed [16 cases](../output/shared-project-qualification/final-critical-browser-regression.log) and exposed one stale test assertion requiring prefix growth. A fixture-only correction now checks fetched/drawn expanded-viewport coverage, complete bounded tile reuse and no duplicate transfer; the [isolated source-geometry rerun passes](../output/shared-project-qualification/final-source-geometry-regression.log). Production sources/assets were unchanged. E2E TypeScript passes. Earlier 45-check/profile evidence is preserved [separately](../output/shared-project-qualification/browser-before-final-critical-20261028_1815/shared-project-qualification/report.json); native audio calls remain mocked in these browser fixtures. +- **diagnostic_only: isolated Timeline timing:** [the final phase report](../output/playwright/shared-project-qualification/recording-performance.json) records steady recording and five settled zooms at approximately **16.7-16.8 ms p95/p99**. Plain/Ctrl/Shift/Alt wheel p95 is approximately **33.3-33.4 ms**; p99 is respectively **33.4 / 50 / 83.3 / 33.4 ms**. Menu first-paint times are **41.1 / 32.8 / 12.9 ms**. Both live previews remain present; peak/MIDI requests stay single-flight, peak payload is at most 1,503 entries, and 46,872 retained MIDI events are exercised. Real peak mipmaps are preloaded before timing, the live native boundary/transport are simulated, and cold completed-source fetches still cross Playwright CDP. Cold zoom transitions reach about **83 ms p95 / 150 ms p99**. This does **not** establish native WebView or whole-app 60 fps. +- **pass: final objective native checks:** [399/399 named, unique regression checks](../output/shared-project-qualification/optimized-debug/20261028_173242_audited-verified/render_export_regression_result.json), including six exact packed-waveform and seven native cache-generation/ownership controls; [74/74 fresh actual-project checks](../output/shared-project-qualification/native-audited-verified/project_qualification_result.json); and [all five unchanged full export hashes](../output/shared-project-qualification/audited-render-hash-parity.json). This supersedes the intermediate 386/392-check runs, including the corrected initial packed-check labels. The [latest realtime self-test](../output/shared-project-qualification/audited-realtime-self-test.json) includes 450 automation cases and 1,920 tiny-path parity blocks. [Independent whole-song comparison](../output/shared-project-qualification/independent-master-eight-verified.json) passes 4,213 windows with maximum error 8.94e-8 and no missing samples; [27 audible stem windows](../output/shared-project-qualification/independent-stem-eight-verified.json) retain their original artifact scope. These are objective file/state checks, not subjective sound acceptance. +- **pass: actual native packed-waveform integration:** [the read-only native WebView check](../output/shared-project-qualification/native-ui-after-performance/packed-native-integration.json) calls the public packed/legacy endpoints and loaded public NativeBridge instance. Stereo 32- and 64-peak responses have correct dimensions and exact decoded Float32 parity, with no fallback warning. It changes no transport/project state and does not establish how every historical UI request was routed. +- **implemented and checked: callback cost and recording handoff:** Playback avoids unnecessary boundary rounding for clips covering the callback. A prepared integer delay line preserves the previous JUCE delay history and transitions while reducing fixed PDC work. The embedded EQ has a guarded dry path with exact general-path parity. Recording uses an app-owned FIFO whose audio producer does not call the mutex-taking disk-thread wakeup in JUCE's ThreadedWriter; the disk consumer polls independently and drains the stop tail. Isolated benchmarks are **diagnostic_only**. Optional paired callback tracing remains disabled by default. [The instrumented baseline](../output/shared-project-qualification/callback-profile-before-analysis.json) includes long wall-time samples with ordinary raw thread-cycle counts; this cannot identify scheduler/preemption causes without a system trace. +- **pass: final eight-sample saved file/session invariants; fail: strict host-deadline budget:** [the final trace-disabled native capture](../output/shared-project-qualification/native-ui-after-performance/measurement/recording-analysis.json) records **176.191 seconds of observed continuous recording without a detected seek**, on the supplied project copy at **48 kHz / eight samples**. The saved stereo PCM16 WAV contains **8,505,104 frames / 177.1896667 seconds**, with clip start **0**, exact header/clip frame counts and a published peak sidecar. Original clip IDs/core properties remain intact; exactly one Audio 1 take was added. Reader/cache/continuity, FIFO/write-lock, device restart/error and non-finite-output deltas are zero. During the observed recording interval, independent host deadline misses increase by **1,001** and JUCE combined/derived-host overload counts by **1,091**. These are recording-only deltas, not the larger start-through-stop totals. The driver count remains **-1 / unsupported**; driver-only dropout acceptance is **not_asserted**. The physical-input PCM is **all zero**, so known-signal capture, audible continuity and electrical latency are **not_asserted**. The user's earlier no-glitch observation belongs only to the ETW baseline. [The preceding 151.922-second FIFO capture](../output/shared-project-qualification/native-ui-fifo-eight-capture/analysis.json), its balanced virtual-MIDI events and earlier 138/187-second results remain historical evidence. TD-17 is still unavailable. Host-wall deadline misses are not automatically audible dropouts, and their remaining cause is not assigned solely to hardware or Windows. +- **pass: missing MIDI-device feedback:** [three browser cases](../output/shared-project-qualification/midi-device-availability-browser.log), TypeScript and [that frontend build](../output/shared-project-qualification/midi-device-availability-frontend-build.log) verify explicit checking/unavailable/unverified labels for retained saved device names. A missing TD-17 no longer appears as All Inputs. Routing data is preserved. [The final Debug build](../output/shared-project-qualification/final-fixture-debug-build.log) has zero compiler warnings and copied frontend assets match dist (index SHA256 starts `EDE4B35F`). +- **not_asserted: physical Explorer drop:** [the recorded attempt](../output/shared-project-qualification/physical-explorer-drop.json) is rejected by the computer-use API because its endpoint lies over the DAW WebView rather than Explorer. No imported row/clip is observed. The temporary topmost-window experiment was removed from production source. Simulated native/DOM handler checks pass; OS drag negotiation remains unverified. +- **pass: targeted documentation drift validation:** [eight checked app/website documents](../output/shared-project-qualification/input-doc-contracts.json) and [nine stale-contract fixture tests](../output/shared-project-qualification/input-doc-contract-tests.log) pass. The validator checks default Record aliases, Join/Crossfade, F1/window labels, development provenance and the arm tooltip, with an explicit sibling-website root. It is not exhaustive across all vendor profiles, custom bindings or translations. + +Remaining boundaries: eight-sample overload/deadline performance and full native UI/plugin workload (4, 8, 14, 16, 20); sustained physical MIDI recording with the unavailable TD-17 (3); physical Explorer drops (9, 18b); electrical peak/metronome/round-trip placement (10); external-DAW MIDI interchange (21c); and intended beat-unit/subjective acceptance of exact tempo, crossfade, conversion and mixdown artifacts (1, 11, 15, 23). Completed browser viewport checks and objective native regressions are no longer pending gates. + +### ETW follow-up: native UI work and Debug code generation + +The [ETW follow-up](../output/shared-project-qualification/etw-follow-up.md) records the user's **no audible glitches** observation for the original 48 kHz/eight-sample ETW session, alongside slow UI and delayed scrolling. Its matching take is 55.858 seconds. That observation applies to this baseline only; it does not certify the new build, electrical timing, or the earlier failed timing captures. + +The [phase analysis](../output/shared-project-qualification/etw-analysis/phase-analysis.json) separates recording, thread execution and interrupt activity. It records zero lost ETW events, successful MMCSS registration and observed audio-thread priority 25. Scheduler coverage ends before the final 21.794 seconds of header/sample coverage; off-CPU periods include normal waits. These diagnostics do not identify individual engine misses or assign a sole driver/Windows cause. + +Linked-helper disassembly and [COMDAT origins](../output/shared-project-qualification/etw-symbols/comdat-origin-before.json) confirmed that shared bounds/sample/UTF-8 helpers came from `/Od` Debug objects despite optimized audio translation units. The [post-link audit](../output/shared-project-qualification/etw-symbols/comdat-origin-after.json) verifies optimized bodies after consistent Debug host optimization and nonincremental linking. Symbols, `_DEBUG`, assertions and Debug CRT remain, and the WebView `/O1` exception is retained. [PDC/EQ throughput comparisons](../output/shared-project-qualification/etw-symbols/optimized-debug-benchmark-comparison.json) preserve exact parity but remain **diagnostic_only**. + +Matched UI stacks identify waveform completion and numeric JSON serialization: of 22,350 UI samples, 11,891 have matched stacks; 10,321 include waveform completion and 8,958 include waveform JSON. These overlapping counts are not exclusive CPU time. Canonical 4,096-peak tiles now share prefetch/visible data and use bound-specific keys only at trimmed edges. Versioned Float32LE/base64 transport moves cache lookup, edge reads and packing to a worker, preserves exact stored float values and retains the legacy API. Invalid packed/legacy data surfaces an error or valid fallback. Source generation checks and the existing 32 MB source-cache budget remain. + +The [packed completion benchmark](../output/shared-project-qualification/optimized-debug/20261028_171319_packed-verified/packed-waveform-benchmark.json) measures eight 4,096-peak stereo tiles: legacy numeric JSON takes 379.8747 ms / 1,770,272 bytes, versus packed completion JSON at 70.8206 ms / 700,200 bytes; worker packing takes 5.8691 ms. This is a **diagnostic_only** serialization measurement, not native gesture latency or hardware acceptance. + +The native cache-generation/ownership guard is implemented and all seven deterministic controls pass in the final 399-check run. Stale cache loads, edge publications and queued completion/retirement work cannot replace or retire a newer source generation. The [baseline observer](../output/shared-project-qualification/native-ui-before-performance/measurement/analysis.json) and [post-change observer](../output/shared-project-qualification/native-ui-after-performance/measurement/analysis.json) each contain only **four trusted plain-wheel events**. The baseline also included a timeline click/seek, while the final recording has no observed seek; these are not matched statistical trials. rAF and CPU canvas-draw observations remain **diagnostic_only**, not paint proof, complete modifier-zoom coverage or a smoothness/60 fps guarantee. The final eight-sample file/session checks pass, while its independent host-deadline budget still fails as recorded above. + +The [native UI comparison](../output/shared-project-qualification/native-ui-after-performance/ui-comparison.json) uses the same 1,280 x 800 viewport and DPR 1.5, but only four trusted plain-wheel samples per run. It describes timeline-layer draw p95 of 0.5 to 0.1 ms and native-process CPU median of 3.636% to 0.976%; the latter excludes separate WebView renderer processes. Sampled wheel-response distributions are similar and do not establish latency improvement. The baseline click/seek and reader underruns confound audio A/B interpretation. These figures remain **diagnostic_only**, not paint/60 fps proof or a universal smoothness claim. + +## Item-by-item matrix + +| Original item and phrase | Classification | What is covered | Evidence and remaining qualification | +|---|---|---|---| +| **1.** “figure out tempo from a properly recorded track” / clip context menu / “support midi clips as well” | **Implemented; constant-tempo scope** | Detect Tempo is available from audio/MIDI clip menus and the action catalog. Audio compares up to three separated audible sections using whitened multiband onsets; MIDI uses retained metadata or repeated beat grids. The selected range, editable BPM, pulse-family alternatives, section spread, tapping and beat-interval counting support correction. Bounded dry source + click or simple MIDI-guide + click auditions have adjustable start/phase/accent grouping, Stop and stale-result cancellation. Apply changes base BPM in one Undo without moving clips. | Audio has a **60-second total spectral-work budget**, preceded by nine brief energy probes. It estimates constant tempo and heuristic evidence strength, not a complete tempo map or intended downbeat. Preview excludes track/master FX; MIDI guide tones do not reproduce the instrument. Native preview-data generation and browser scheduling are separate boundaries. Current focused tests pass 28/28 and tempo Chromium workflows 3/3; the final native controls and embedded/hardware qualification are recorded separately. All nine actual MIDI clips were diagnosed without using project BPM as a prior: eight estimate 153.98–154.04 BPM and one rejects insufficient pulse evidence. Musical accuracy remains **diagnostic_only**. | +| **2.** “peak graphs ... disappear ... clips ... on the right side” | **Implemented; representative project views verified** | Fractional sample/pixel projection, cache coverage expansion, source invalidation, real cached-peak fallback and bounded retained data address the reproduced long-source gap. | Chromium regression reproduces the previous 374 px gap at 997.9 px/s and large scroll, then checks full coverage and viewport expansion. Native peak fixtures cover source boundaries. This is stronger than a source-only hypothesis, but is not exhaustive coverage of every clip/view combination in the supplied session. | +| **3.** “long period ... midi take clip notes were moving around as the playhead moves” | **Implemented; physical long MIDI recording unverified** | Recording thumbnails use a fixed pitch band; completed thumbnails derive pitch range before sampling. Incremental channel-aware pairing and sampled visible-block queries bound drawn/cloned output. | The final project browser profile uses 46,872 retained events with continuous MIDI polling and visible live previews. Six index tests include exact 50,000-note sampled-query parity, held/channel notes, append closure, edits and boundaries. These are simulated live delivery and deterministic tests, not a sustained MIDI-controller recording. | +| **4.** “clips would jitters and lags ... CPU bottleneck? ... recording just started” | **Implemented; UI causes traced; strict eight-sample budget fails** | Profiling identified whole-clip mute-stripe node creation and full-project canvas buffering/resizing. Viewport-batched hatching, waveform buffer avoidance, a viewport-sized sticky Stage with full DOM scroll extent, an isolated recording layer and bounded MIDI queries reduce this work. | Current Debug/Release and 30-minute Release runs pass file/session invariants at 48 kHz / 8 samples, but strict callback deadlines still fail. Source binding/continuity-key reuse and the measured MIDI first-scroll stall are addressed. See the 2 November four-step results above for exact durations, counters and attribution limits; startup jitter is not universally closed. | +| **5.** “all zoom levels ... small part ... removed from the end ... gets fixed on its own” | **Implemented; exhaustive zoom qualification unverified** | Native partial edge aggregates exclude trimmed samples; final partial peaks survive. Frontend bounded edge requests work even without legacy `sourceLength`; preview fallback is withheld when source extent is unknown. | Native exact-trim/tail-extrema checks and source-geometry/browser regressions pass. A peak remains an aggregate over a source interval at coarse zoom; subpixel sample identity is not promised. All rates, trims, reverse/stretch states and zoom levels have not been exhaustively auditioned. | +| **6a.** “text input for the mixer levels” | **Implemented** | Track/master dB inputs support validation, Enter/blur application, Escape cancellation, silence entry and one volume transaction. Untouched values retain their original precision. | `mixer-entry-resize.spec.ts` and mixer-level unit tests cover commit, invalid draft, cancellation, Undo and precision. Hardware automation/group workflows are not inferred from these input tests alone. | +| **6b.** “mixer panel height should be adjustable ... midiEditor” | **Implemented** | Docked mixer separator supports pointer/keyboard resizing, reset, preferred-height persistence and workspace clamps. Pointer work is coalesced per frame; preference persistence occurs on release. | Real Chromium geometry/focus/keyboard/pointer/persistence checks in `mixer-entry-resize.spec.ts`. This does not qualify every small-screen/native-window configuration. | +| **7.** “crop ... doesn't stop ... follow the cursor ... after dropping it” | **Implemented** | Finalizing gestures immediately reject further previews; the release result stays fixed while native synchronization is pending. The edit remains one Undo transaction. | Browser reproduction holds synchronization pending after mouse release, moves the pointer again and asserts unchanged duration. This directly covers the reproduced defect. | +| **8.** “whole app ... slow while recording ... zooming ... slow and jittery” | **Partial; UI causes addressed; strict native budget fails** | Viewport-sized canvases, independent live-preview painting, batched hatching, bounded peak work and sampled MIDI queries address measured frontend costs. ETW-directed shared source tiles, worker-packed waveform transfer and consistent Debug helper optimization address additional native UI costs. | The native cold-scroll script maximum decreased from 55.865 to 14.586 ms after indexed MIDI pairing. Final Debug/Release wheel phases contain no browser tasks or visible frame intervals above 50 ms, but compositor/physical-input acceptance is not established and strict eight-sample audio timing fails. The long session and final reopen preserve recordings and visible content; no universal whole-app smoothness claim. | +| **9.** “test importing by dragging ... sits exactly where it was dropped” | **Implemented target fixes; physical drop unverified** | Frozen drop time/target, DPI-normalized native coordinates, source-index mapping and sequential multi-file insertion preserve the intended position/order. Snap intentionally changes the time when enabled. | Native-event fixtures cover 100/150/200% scaling, scroll and two-file insertion order. They call the event handlers with simulated native events: **a physical Windows Explorer-to-WebView drag was not qualified**. More combinations such as folders/hidden rows and snap on/off remain to be checked against real native events. | +| **10.** “seeing a peak ... in a bar marker ... hear it ... same position ... metronome” | **Partial; electrical/listening timing unverified** | Corrected source projection and clipped aggregates remove known display errors. Native source/read/scheduling fixtures provide deterministic timing evidence. | Display correctness does not prove recording placement or monitoring latency. No electrical loopback, hardware round-trip calibration, or user audition established peak-to-metronome alignment in this session. Coarse bins represent intervals, not exact visible sample locations. | +| **11.** “two clips ... overlap ... crossfade hotkey ... not merge into a single clip” | **Implemented, including general overlaps; browser editor qualification passes** | **Shift+X** creates independent gain handoffs while keeping clips separate. Contained clips enter and return to the underlying clip; same-edge/coincident/nested/chained selections share one ownership timeline. The editor supports two or more compatible same-track clips, curve previews, custom bounded lengths and removal. Manual gain envelopes/fades remain unchanged; one Undo covers the operation. | `clipCrossfades.test.ts` covers handoffs, gain composition, idempotency/removal/Undo, parsing/Compare, snapshot isolation, trim/untrim/move/split and native envelope clearing. `crossfade-editor.spec.ts` passes in the final 38-case browser run, including contained entry/exit and grouped editable handoffs with atomic Undo/removal. Native envelopes use the composed linear points. Subjective "sounds like one clip" acceptance is not asserted. | +| **12.** “on top of a track ... moved ... between two tracks ... new track ... between” | **Implemented, including mixed/multiple source rows** | Shared divider hit bands/hysteresis, insertion preview and one compound transaction create destination rows at the divider. Multi-track selections retain their source-row ordering and audio/MIDI compatibility, with one new row per selected source row. Track-body drops retain movement semantics. | Divider tests, mixed-row mouse/browser preview/drop/Undo, and store all-reserved-row failure rollback/Undo/Redo tests pass. A selection spanning several tracks creates several destination rows rather than combining different track types into one track. | +| **13.** “zoom level ... audio clip audible peaks ... or midi notes, at any level” | **Implemented targeted corrections; universal claim unverified** | Shares source-coordinate, cache-edge and MIDI-stability changes in 2, 3 and 5. | Fractional zoom/large-scroll/viewport and MIDI fixtures pass. There is no exhaustive proof over every level, profile, source type and edit state. This broad expectation remains an ongoing invariant to test, not a completed universal guarantee. | +| **14.** “long period ... peaks vanished ... shown with a latency ... app ... slow” | **Partial; bounded preview/file checks pass; strict native budget fails** | Cache coverage/edge fixes, independent 15 Hz recording geometry and single-flight 250 ms live-peak polling bound display work. Stop/finalization retains preview continuity while completed clips are published. | The 30-minute 59.384-second eight-sample Release take passes all 27 file/session checks and produces its peak sidecar; maximum native telemetry gap is 3.016 seconds. Reopening preserves the long take and a further 75.906-second take passes the same checks. Waveforms and MIDI remain visible in inspected views. The long run is audio-only, its source-song playback ends around 410 seconds, and the input is not a known continuous reference. Strict deadlines fail and end-to-end preview latency remains unqualified. | +| **15.** “48khz ... project ... 44.1khz ... automatically convert ... Ideally ... conversion” | **Existing source-preserving playback plus implemented optional high-quality copies/import policy** | Default import keeps the original rate; cubic ratio conversion preserves duration/pitch during playback and export uses its chosen target rate. A cancellable clip command and project-level import policy now prepare cached, band-limited project-rate float WAV copies while preserving originals and clip timing/gain/fades. Optional conversion stays inside the import's one Undo; completed manual copies support source Undo/Redo. | Native copy/service checks, conversion browser progress/Cancel/source Undo/Redo, import Undo-during-conversion/Redo, failure fallback and actual project save/load policy tests pass in the focused runs. The inactive Render Resample Quality dropdown was removed; legacy stored values remain readable. Real-music conversion quality and any exact audible target remain subject to audition, not inferred from file/rate checks. | +| **16.** “recording or playing ... normal ... ctrl ... alt ... shift ... zoom ... glitchy” | **Implemented interactions; measured browser gains; native timing fails** | Viewport-stage projection, exact retained peak coordinates, bounded recording work, profile-specific wheel routing and ruler termination preserve plain/Ctrl/Alt/Shift behavior during the tested live preview. | Current native Debug/Release runs each exercise 415 plain/Control/Shift/Alt wheel events while recording at eight samples; the final density-build reopen adds 227 events. All four final gesture phases have zero browser long tasks, long animation frames or visible rAF intervals above 50 ms. Draw timings and their limitations are recorded above; they are not physical-input-to-presentation measurements. Strict audio timing still fails. | +| **17.** “timerange ... brown/orange ... overlaps ... bar markers ... gesture ... stays the same” | **Implemented** | Range artwork occupies the top visual lane; bar labels begin below it. Existing ruler drag/hit semantics remain. | Ruler geometry/interaction regressions preserve selection hit semantics. The final density fix additionally prevents crowded labels at minimum zoom, uses the meter denominator, and keeps snap intervals unchanged. Real-browser label bounds and the native final recording view pass. | +| **18a.** “audio ... stereo or mono ... new track accordingly ... existing track ... stay” | **Implemented for new imports; existing sessions deliberately preserved** | Probed channel metadata configures newly created tracks and native layout; Undo/Redo retains it. Existing targets keep their layout. Native source adaptation supports mono/stereo tracks without destroying stereo FX output. | Mono-import/target-preservation/Undo tests plus native adaptation/stereo-FX checks pass. The supplied project's existing stereo-configured tracks are **not automatically migrated** to mono, and its file is not rewritten. | +| **18b.** “Midi clips shouldn't be droppable in a audio track ... cancel ... or ... new track beneath” | **Implemented; some rejection behavior already existed** | Store compatibility guards remain; supported incompatible single-track drops create a MIDI track below the hit track, with a preview. Existing audio tracks are not silently converted. | Target/store tests and simulated drop checks. Real Explorer MIDI import with a loaded third-party instrument is not a qualified end-to-end path here. | +| **19.** “record ... ctrl + r to only r ... docs ... website ... any other hotkeys” | **Implemented documentation update and targeted drift validation** | Main manual/website guides describe **R**, with **Ctrl/Cmd+R and numpad * retained as aliases**. The stale website Record Arm tooltip was corrected. Join/Crossfade shortcuts and independent keyboard/mouse profiles are documented as development behavior. | [The TypeScript-AST validator](../tools/validate-input-docs.mjs) passes the app manual and six explicitly supplied website guides plus preview tooltip ([eight documents](../output/shared-project-qualification/input-doc-contracts.json)); [nine fixture tests](../output/shared-project-qualification/input-doc-contract-tests.log) reject stale keys/labels/profile/source/provenance. It validates selected semantic contracts rather than all prose. Record was not changed to "R only"; all vendor profiles, custom bindings and translations are not exhaustively asserted. | +| **20.** “any zoom feels too slow and after zlt + zoom other actions ... slow” | **Frontend improvement measured; broader/native acceptance remains partial** | The measured unbounded hatch/canvas work is removed; world-coordinate viewport layers and isolated recording updates preserve deep-scroll edits and post-zoom interactions. | The traced cold MIDI-pairing stall, stale scroll publication and menu-owned Zoom to Full Project failure are corrected. Final browser/native view recovery, waveform/MIDI visibility and post-zoom menus pass. The complete captures and final draw distributions above remain diagnostic; strict eight-sample callback timing and universal native presentation responsiveness are still open. | +| **21a.** “midi clip ... another track ... don't show drag animation” | **Implemented** | The single MIDI anchor now follows the destination row, matching multi-clip/audio placement previews. | Timeline code and drag/insertion integration checks cover the missing branch. No hardware timing claim follows from this display change. | +| **21b.** “midi joining ... neither it's hotkey” | **Implemented** | **Ctrl/Cmd+J** joins compatible same-track selected MIDI clips, preserving gaps and finite visible trim/loop/CC/channel/note-off content in one Undo. | `timelineEnhancements.test.ts` compares serialized output before/after and covers Undo/Redo/locks. Joining materializes source loops and deterministic probability/velocity variation; independent source tempo metadata is not retained on the composite. Same-pitch/channel overlap and interchange with each external DAW are not exhaustively qualified. | +| **21c.** “midiExport should also be seamless” | **Existing export workflow enhanced; external interchange partial** | Clip/project export already existed. Selected multi-clip export and rebased tempo-map/meter metadata are added; imports retain program/pressure/release velocity and source tempo information. | Native SMF tempo/meter/seconds round trip and frontend source-window/event-family tests pass. The current actual-project REAPER import/export preserves notes/times per track, tempo/meter and final controller values per tick, but changes controller context at 284 note starts through same-time reordering; lossless interchange fails. See the current four-step results above. The project supports a global meter, not arbitrary time-signature-change maps. | +| **22.** “midiEditor ... note onHover ... know which note it is” | **Implemented** | A lightweight hover overlay shows note name, velocity and duration without editing data. It reuses the existing hit test and clears during gestures/leave. | Real-browser Piano Roll hover test passes, including narrow notes. | +| **23.** “mixdown render ... tracks ... partially audible ... cut off ... playing fine” | **Implemented probable source-I/O fix; exact reported cutouts unverified** | Offline source reads use a bounded complete-read policy instead of zero-timeout realtime concealment; missing required sources fail explicitly. Render snapshots now retain important clip/track state. This directly addresses a strong source-level explanation for the supplied session's long streaming files. | Native long-source, failure policy, trim/envelope/fade/offset/gain/channel and render-format tests pass. The supplied project has now passed full native renders, exact repeatability and independent source comparisons described below. No missing-audio windows were found. Subjective correspondence with hardware playback and acceptance of the exact mixdown remain **not_asserted** until audition. | + +## Current source audit and deliberate limits + +Corresponding production changes exist for the 23 original items. Their presence +does not close the acceptance gates above. The unresolved items are the specific qualification +boundaries in the table, plus deliberate scope: tempo analysis estimates a +constant beat grid, separate audition uses the WebView/system output rather than +the selected native/ASIO route, crossfades operate on compatible unlocked clips +on one track, MIDI joins materialize finite visible content, and import-rate +copies are optional/non-destructive. These limits must not be represented as +hardware acceptance or universal accuracy. Live waveform refresh remains 250 ms; +recording geometry is independently limited to 15 Hz while the playhead remains +imperative. A targeted app/website documentation drift validator now passes the checked +contracts; its scope and artifacts are recorded above. Scoped review corrected +the stale arm/SRC descriptions; exhaustive profile/custom/translation coverage is +not asserted. + +## Earlier completed generic evidence + +- [Frontend test log](../output/remediation-frontend-tests.log): **255 files / 3,030 tests passed**. +- [Frontend build log](../output/remediation-frontend-build.log) and [Debug build log](../output/remediation-debug-build.log): production frontend and Debug executable/assets built; the recorded Debug build has zero compiler warnings. +- [Native result](../output/remediation-native-result.json) and [native log](../output/remediation-native-regression.log): **280/280 objective checks**, `claimLevel=objective_only`, `subjectiveAudioQuality=not_asserted`. The offline harness retained zero opened devices, device starts, callbacks and callback registrations. +- Browser implementations/tests: [source geometry](../frontend/e2e/timeline-source-geometry.spec.ts), [recording zoom](../frontend/e2e/recording-zoom.spec.ts), [mixer/hover](../frontend/e2e/mixer-entry-resize.spec.ts), [tempo modal](../frontend/e2e/tempo-detection.spec.ts). The native bridge is mocked in these browser fixtures. Test source alone is not a new run result; completed runs are recorded in the remediation plan and corresponding artifacts. +- Store/data tests: [compound edits/import/tempo](../frontend/src/__tests__/timelineEnhancements.test.ts), [MIDI thumbnail index](../frontend/src/__tests__/midiThumbnailIndex.test.ts), [insertion targets](../frontend/src/__tests__/timelineInsertionTarget.test.ts). + +## Supplied-project qualification boundary + +Project: `C:\Users\srvds\Music\mick records\songi hobe naki.osproj`. + +The initial review inspected its metadata and source files: 31 tracks, 33 audio clips, 9 MIDI clips; 32 distinct referenced files existed; 31 WAV sources were 48 kHz while the project selected 44.1 kHz; 23 sources exceeded the previous full-decode threshold. This makes the offline streaming diagnosis plausible. **Metadata inspection is not a project playback/render test.** + +Actual-project qualification was subsequently performed on a byte-identical copy. The fresh 44 source-backed checks cover: 18 waveform projections, 24 MIDI geometry cases, recorded-clip trim/release/Undo, and same-track MIDI joining with exact serialized-event parity and Undo. See [the browser report](../output/playwright/shared-project-qualification/report.json). The real frontend project loader completed with 31 tracks and no restore error; the native boundary remained mocked. An update-depth warning occurred only with instantaneous mock responses, and disappeared when the mocks yielded like asynchronous IPC; see [the scheduling diagnostic](../output/playwright/shared-project-qualification/load-scheduling-diagnostic.json). A browser loaded with real project data but mocked native audio verifies editing/visual geometry; it does not verify the native audio route. A headless render of the real source files can verify read completeness/output invariants; it cannot prove audible likeness to hardware playback or user-approved sound quality. + +The final post-change native audio-only capture uses the supplied project copy at 48 kHz/eight samples, with 176.191 observed continuous seconds and a 177.1896667-second saved take. File/session invariants pass, but the independent host deadline budget still fails; final input PCM is silent. The earlier ETW baseline alone has the session-specific no-glitch observation. Native UI timing remains diagnostic, and the preceding audio/virtual-MIDI runs retain their original scope. Isolated frontend frame-time and deep-scroll checks pass. Sustained physical MIDI recording, full-app performance acceptance, physical Explorer drops, electrical timing, intended audio tempo accuracy, external-DAW MIDI interchange and subjective mixdown acceptance remain separate. The confirmed same-device/eight-sample result in another DAW is recorded; it does not establish the cause of OpenStudio's remaining host deadline misses. + +## Earlier review follow-up + +The earlier targeted review findings were implemented: legacy right-edge requests no longer depend on `sourceLength`; unknown-source previews are withheld; spectral artwork cache keys include scale/floor; MIDI pairing includes channel and equal-length edits rebuild the index; multi-file drops capture one base index and run sequentially. Dedicated fixture checks cover several of these changes. They do not expand into hardware/project qualification automatically. + +The initial matrix's project-rate-copy and general-overlap-crossfade implementation gaps have now been addressed, with focused tests as recorded above; the final expanded qualification is being assembled. The original user project remains outside any automatic mono-track migration. Broad “all fixed” or “thoroughly tested with the same project” claims still require the exact final project artifacts, scope and remaining audition/hardware boundaries to be recorded. + +## Additional defect found by the actual-song reference + +The first native project run passed its file/read/format/repeatability gates, but an independent source-file mix found a one-frame stereo discrepancy at 405.244125 seconds, at the final recorded vocal source frame. Native source-end counting omitted that valid frame. A follow-up check also exposed accumulated floating-point render-clock drift admitting a frame at the exclusive clip end. Source-end counting and near-integer sample boundaries were corrected, and offline block time now derives from integer frames. The final endpoint window passes the unchanged 5e-5 sample-error tolerance with error below 6e-10; the final whole-song source comparison also passes with maximum absolute error 8.943e-8 and zero missing-audio windows. That follow-up generic native suite passed 289/289, including endpoint/partition and silent-lead-in tempo cases. + +The observed tempo-analysis failure on long silent introductions first led to a bounded audible-window selector. The current completion pass supersedes that single-window implementation with up to three separated windows and a 60-second total spectral-work budget. Real-song BPM/confidence remains diagnostic, not a calibrated accuracy claim. + +## Earlier supplied-project results, before the completion pass + +- **pass:** [74 native project checks](../output/shared-project-qualification/native-final/project_qualification_result.json), with the real frontend-normalized 31 tracks, 33 audio clips, 9 MIDI clips and 12,720 scheduled events. Required sources read successfully with zero misses/failures; no audio device, callbacks or application window opened. +- **pass:** full 409.870-second song plus one-second tail rendered twice at 48 kHz and twice at 44.1 kHz; each pair has identical decoded PCM. The saved 48 kHz/320 kbps MP3 format also rendered successfully. +- **pass:** [independent whole-song source mix](../output/shared-project-qualification/independent-master-final.json): 25 active audio tracks / 31 audible clips, 4,213 checked windows, no missing-audio windows and no samples above the unchanged 5e-5 tolerance. Maximum absolute sample error is 8.943e-8. +- **pass:** [27 independent selected track-window comparisons](../output/shared-project-qualification/independent-stem-final.json), maximum error 2.973e-8. The two unassigned MIDI tracks correctly contribute silence to audio; all three MIDI tracks in this saved project have no software instrument configured. Assign an instrument or record hardware audio when an audible MIDI mixdown is intended. +- **pass:** actual-project SMF export preserves all 12,720 event identities and timing within one PPQ tick, with 154 BPM / 4/4 metadata. This is an internal decode comparison, not an external-DAW interchange test. +- **historical pass:** 44 source-backed browser checks covered representative waveform/MIDI views and actual clip trim/join/Undo; the final 44-check geometry report above reruns them; the separately preserved earlier 45-check report also included the recording-performance profile. +- **pass:** final Debug build has zero compiler warnings; the expanded generic headless suite passes [289/289](../output/shared-project-qualification/generic-final-result.json). Original project SHA-256 remains `c4c8140df666a15d52a56d065d1b6390b0136bae9dfdce35822323018d68ee81`. +- **diagnostic_only, historical detector:** the earlier full-mix source estimate was approximately 155.22 BPM with heuristic confidence 0.59 against the project's configured 154 BPM. This predates the multisection/whitening completion pass and is retained as historical evidence, not its current result. An estimate cannot establish the intended beat unit or musical accuracy. +- **historical not_asserted:** hardware recording/performance was not qualified by this earlier source-render run. The current acceptance record above now includes the failed eight-sample capture and measured isolated Timeline performance; physical Explorer dragging, electrical timing, external-DAW MIDI interchange, embedded native UI performance and subjective mixdown acceptance remain separate. + +[Machine-readable summary](../output/shared-project-qualification/summary.json). [Exact test mixdown for audition](../output/shared-project-qualification/native-final/saved-mixdown-48000-320.mp3). diff --git a/docs/issue-remediation-plan.md b/docs/issue-remediation-plan.md new file mode 100644 index 00000000..8fe746ef --- /dev/null +++ b/docs/issue-remediation-plan.md @@ -0,0 +1,450 @@ +# Recording, timeline, and editing remediation plan + +Started 28 October 2026 against `e8f9a65a`. Scope: the supplied issue list and the source/browser findings recorded in `output/issue-review-2026-10-28.md`. Preserve the original user project; use isolated fixtures or copies for validation. + +## Staff review and workflow follow-up — 4 November 2026 + +**Status: implemented and qualified to the limits below on 4 November 2026.** +The four correctness findings and mixer/MIDI/gesture follow-ups now have production +changes and permanent regressions. See [the implementation report](workflow-review-qualification.md) +for exact test results and remaining work. Review evidence and the original three +failing reproductions are in `output/staff-review-nov04/`. The fresh supplied-project +recording still fails strict eight-sample timing; this is not an all-issues-closed +claim. The following checklist records the agreed implementation and acceptance scope. + +### 1. Preserve MIDI identity when joining + +- First reproduce the reported missing menu item using actual Ctrl+clicks on + separate MIDI clips, followed by right-click on a selected clip. Verify the + loaded frontend and active mouse/keyboard profiles, selection before/after + right-click, and whether the MIDI or background menu opens. Include clip-body + and edge hit targets. The current source includes Join for every MIDI menu, + disabling it for invalid selections; the reason it was absent in the user's + session is not yet established. Do not substitute store-seeded selection or + source-string assertions for this interaction regression. +- Promote the two failing overlap/mute reproductions into permanent regression + tests before changing the join. Preserve note-on/off pairing, channel, release + velocity, expression and mute identity when independent clips become one clip. +- Carry any new optional pair identity through copy, split, trim, loop expansion, + stretch, project validation and save/load. Old clips without identity must keep + their current interpretation. Verify migration/round-trip behavior before any + schema change; do not add a schema bump merely for an optional field. +- Retain the command's same-track/unlocked requirements, materialize the visible + source windows, preserve gaps, and keep the operation one Undo step. If a case + cannot be preserved, reject it before mutation with a specific explanation. +- Keep **Join Selected MIDI Clips** in the clip menu and Command Palette. Show + the selected count and a useful disabled reason for mixed media, different + tracks, locked items or frozen tracks. Display the actual profile binding. + +Acceptance: nested same-key notes with both audible and mixed mute states, +different channels, source offsets, loops, gaps, CC/expression, note releases, +Undo/Redo, save/reopen and native event scheduling preserve the intended content. + +### 2. Make selected MIDI export discoverable and consistent + +- Extract a typed shared export action used by the menu and existing + **Export Selected MIDI Clips...** palette command. Avoid duplicating the + serializer and tempo-map calculations in Timeline. +- On right-clicking an already selected clip, retain the selection. For multiple + selected MIDI clips show **Export Selected MIDI Clips (N)...** prominently; + keep an explicitly named **Export This MIDI Clip...** alternative. Right-click + on an unselected clip should follow the existing selection rules. +- Produce one `.mid` file without requiring a join or altering the arrangement. + Preserve each source track as a MIDI track within that file, preserve gaps and + relative positions, and start the earliest selected clip at file time zero. + Preserve channels, visible trims/loops, tempo-map/time-signature metadata and + the existing rule that MIDI processing is applied once. State mute semantics + clearly. Snapshot the export selection and timing together before the dialog. +- Distinguish clip selection from time selection; selected-clip export exports + the selected clips' visible contents, without silently cropping to a blue range. + +Acceptance: actual context-menu multi-selection exports every selected clip and +no unselected clip; single-file/multiple-track readback, controller ordering, +tempo changes, mixed selections, mute cases, dialog cancellation and project +changes are covered. Verify the supplied project's selected MIDI and independent +SMF parsing; report receiving-DAW normalization separately. + +### 3. Repair mixer resize ownership and simplify its appearance + +- Use a subtle resize affordance within the existing single header row, with a + generous hit target and no extra row or broad persistent colored strip. Keep + title, pop-out and close targets distinct. Use Tailwind and theme tokens. +- A pointer resize must not permanently activate the mixer context or focus the + separator. Capture the prior editing context before parent capture handlers + can replace it; prevent those handlers from claiming the resize interaction. +- Use temporary ownership only for the active drag/Escape cancellation. Release + pointer capture and temporary shortcut ownership on drop, Escape, cancellation, + capture loss, window blur and unmount. Restore the previous live editing owner + after pointer completion without stealing focus from a subsequently chosen + control. Remove actual focus/context ownership, not just its highlight. +- Preserve intentional keyboard access through Tab, arrows, Home/End and a + visible focus indicator. Pointer completion and keyboard navigation should have + distinct, documented focus behavior. Persist only committed sizes. + +Acceptance: after a pointer drop the handle is not the active element/shortcut +owner; arrows no longer resize it, and Timeline or Piano Roll shortcuts return +to the previous editor. Cover both editors, repeated drags, Escape, loss of +capture, closing/detaching, small windows, keyboard resizing and size persistence. + +### 4. Fix silence semantics and asynchronous job correctness + +- Define one fader-floor/silence conversion shared across track/master static + gain, automation and offline rendering. Keep persisted data finite and make + the existing `-inf` representation agree with actual zero gain. Test existing + project values and levels immediately above the floor before changing behavior. +- Revalidate the project rate immediately before publishing an imported rate + copy. Reuse a typed validity predicate across polling and final publication; + choose an explicit original-file fallback or a bounded retry when the rate + changed. Promote the failing completion-before-poll test into the normal suite. +- Move the source-replacement transaction into checked TypeScript with typed + options/results; preserve the serialized queue, source compare-and-swap, + project epoch, mute handling, stale-result reconciliation and Undo guarantees. +- Give tempo analysis native request IDs and cooperative cancellation. Cancel + on Stop Analysis, modal close, target/project replacement and shutdown; skip + obsolete queued work. Bound the analysis queue and keep interactive waveform + requests from waiting behind obsolete analyses. Measure service latency rather + than claiming an unmeasured hardware-performance improvement. +- Name audio-copy jobs by clip/track and make all jobs accessible for cancellation + instead of exposing only three indistinguishable rate labels. + +Acceptance: nonzero PCM produces exact silence at the silence setting in both +realtime-size and offline paths; adjacent numeric levels retain expected gains. +Test rate changes, cancellation, clip removal, project replacement, rapid +Undo/Redo and native failures. Repeated Analyze/Cancel must retire work and leave +waveform requests responsive; verify shutdown/lifetime safety. + +### 5. Explain timeline gestures and finish targeted quality work + +- Preserve existing mouse-profile/custom mappings. In the OpenStudio Windows + defaults, Ctrl+drag on empty timeline space creates an editing time selection; + Ctrl+drag on a clip body copies it; Ctrl+click toggles clip selection; plain + background drag selects clips with a rectangle. Ctrl+Shift+drag slips content. +- Add concise feedback while gesturing, such as **Time selection** with range + and duration, **Copy clips**, or **Slip contents**, derived from the resolved + action. Make the blue time-selection range visually distinguishable from + selected clip outlines and expose its clear/select-clips/loop actions. +- Clarify persistent MIDI **Slip +N s** labels as source offsets; they are not + evidence of an active drag. Use clearer wording/tooltips where space permits. +- Move ordinary tooltip/static viewport presentation to Tailwind and use existing + theme tokens. Retain component CSS for runtime geometry and artwork. Check + responsive placement, focus, hit targets and tooltip clipping in a real browser. +- Measure the existing bundle warnings before changing module boundaries. Extract + new regression scaffolding into focused translation units first; evaluate a + separate qualification target sharing production code as a distinct build + change. Provide a separate unoptimized diagnostic configuration if introduced, + while keeping the measured realtime/default Debug optimization behavior. +- Update the app manual, shortcut/mouse reference and relevant website guides + after behavior is implemented and verified. Mark development-only behavior + accurately; do not publish or release as part of this plan. + +### 6. Qualify the completed changes + +- Run permanent regression tests for each change, then the full frontend suite, + frontend/e2e type checks, dependency notices, frontend build, native offline + regression suite and CMake Debug build. Check Release runtime readiness when + native/window/build changes affect that path; compilation alone is insufficient. +- Browser tests must exercise real selection, menus, keyboard routing, pointer + completion and DOM focus/context state, not only CSS or mocked success flags. + Include actual-project layout and selected-MIDI export workflows. +- Use an isolated copy of `songi hobe naki.osproj`. Verify original-file hash, + save/reopen, audio renders and selected MIDI round trips. Re-run recording and + plain/Control/Shift/Alt scrolling and zooming with Audient ASIO at **8 samples**; + record actual device settings. A larger buffer is not an acceptance substitute. +- First run tests that need no continuous signal. Non-silent continuity/listening + checks require an actual signal; coordinate only that remaining physical step. + Keep callback tracing disabled for acceptance and use a separate trace run if + diagnosis is needed. Compare controlled repeated runs and distinguish engine + deadlines, host/driver xruns, file invariants and physical UI latency. +- Report pass/fail/diagnostic_only/not_asserted per gate. Retain unresolved timing + failures even if the recording file and UI checks pass. Before a manual-testing + handoff, refresh Debug/webui assets and stop only task-owned servers/browsers; + `python build.py dev --run` must need no pre-running server. + +## Music-input follow-up — 4 November 2026 + +The user supplied streaming music to Analogue 1. The 191.446833-second eight-sample take passes all 27 recording/file checks, while strict host/engine timing remains fail. The user confirmed the quiet passage in the source; reference-free signal checks remain diagnostic rather than listening or electrical acceptance. + +The sustained-wheel test exposed live waveform polling being suspended throughout gestures. Implement bounded progress during gestures, retain source-correct in-flight data across viewport changes, preserve Stop/new-take rejection and rotate armed tracks for fairness. The focused scheduler checks, 3,115-test frontend suite and 21 distinct browser cases pass; Debug and Release assets have been rebuilt. A separate native eight-sample follow-up verifies live peak requests during all four wheel modes and saving/reopening the existing music take. Detailed results and remaining timing/continuity limits are in the [coverage matrix](issue-coverage-matrix.md#4-november-music-input-recording-and-live-preview-follow-up). + +## Four-step completion pass — 2 November 2026 + +The user authorized all four follow-up steps and deferred continuous physical input. This work keeps the original song unchanged and uses actual Audient ASIO 48 kHz / 8 samples; a larger buffer is not an acceptance substitute. + +1. **Attribute audio tails and remove a measured callback cost.** The new synchronized Windows trace has zero lost events. Of 132 observed recording deadline misses, 79 overlap more than 10 µs of DPC/ISR work, two overlap more than 10 µs off CPU, and 52 retain residual wall time above budget (overlapping sets; residual includes instrumentation/unobserved work). Symbolized samples identify repeated track-ID hashing. Track continuity keys are now prepared with the realtime snapshot, with eight-frame PCM/seek parity coverage. Strict zero-miss acceptance is still required separately; neither this trace nor a pass on file invariants establishes audible continuity. +2. **Repair the traced first-scroll stall and publication races.** Native scroll events share the pending frame update with horizontal wheel navigation instead of publishing stale horizontal positions synchronously. Project loading yields during long track batches. MIDI note pairing now indexes release events instead of scanning the entire event list for each onset, preserving source-order consumption, channel compatibility and equal-time ordering. The cold native recording/scroll trace falls from a 55.865 ms longest script task to 14.586 ms, with no task above 16 ms in that post-change capture. This is a diagnostic comparison, not physical-input-to-presentation acceptance. +3. **Qualify Debug and Release, longer recording and reopening.** Both configurations completed the same initial 415-wheel sequence at eight samples. Release then saved a 30-minute 59.384-second take with 89,250,448 exact mono frames and passed all 27 file/session checks. Its 2,658 host overruns / 2,565 engine deadline misses still fail strict timing. The song ends around 410 seconds, so the rest is a lighter playback workload. Memory/file growth and minimization/recovery were observed; these are not a leak-free or audio-continuity certification. This long run exposed crowded minimum-zoom ruler labels. Shared density-aware ruler/grid spacing now fixes that display without changing snap intervals, with denominator-aware musical alignment and browser geometry coverage. A separate final Release run reopens the long take and records again with this view change; the long run predates only this final ruler/grid change. Known-signal continuity and electrical timing remain deferred. +4. **Complete the actual REAPER import/export round trip.** REAPER 7.19 imported both MIDI tracks and the tempo map, then exported type-1 SMF at 9,600 PPQ. Notes, exact note times, track separation, tempo/meter and final controller values at each tick pass. Lossless interchange fails: REAPER removes 27 intermediate controller events and reorders simultaneous controller/note events, changing controller state at 284 note starts. Keep original event order in OpenStudio; do not silently move notes/controllers to disguise the external normalization. + +Software qualification: 3,114 frontend tests across 265 files, 20 focused browser cases, 416 native checks and 74 actual-project checks pass. The browser count combines 18 cases from the main batch and the two actual-project cases rerun with the required project path; the original batch's skips are not counted as passes. All five full-song renders and the SMF match the previous deterministic outputs; normalized project/backend MIDI payloads remain exactly equal after the indexing change. Frontend, warning-free Debug/Release and local website builds completed. Final hardware outcomes are recorded in the [coverage matrix](issue-coverage-matrix.md), with pass/fail/diagnostic-only boundaries retained. + +All four investigation/implementation/test steps have been carried out. **Overall acceptance remains open:** zero-miss eight-sample timing and complete native input-to-presentation responsiveness are not established, and the external REAPER round trip changes same-time controller ordering. These are not blocked by the deferred physical input. The next audio change needs a controlled post-fix capture isolating the remaining application execution tails from interrupt/scheduling interference; do not change the buffer size, add hidden monitoring delay, or suppress counters to obtain a pass. Known-signal/listening/electrical tests, unavailable TD-17 performance and a physical Explorer drop are separate outstanding qualification items. + +## Eight-sample follow-up — 2 November 2026 + +User authorized completing the remaining work without waiting for continuous input. +Keep Audient ASIO at 48 kHz / 8 samples and preserve the original project. + +1. Capture the current native recording/wheel baseline, with timing instrumentation explicitly labelled diagnostic. +2. Remove repeated source-map resolution for unchanged clips/preview segments and filesystem queries from corrected-source playback. Protect resource lifetime across replacement, eviction, clear/reload and cached-key restoration; verify exact PCM and steady lookup reuse. +3. Build Debug, run native regressions and the actual-project render/export checks. Compare full-song exports against the accepted numerical reference. +4. Repeat native recording with tracing disabled, plain/Ctrl/Shift/Alt wheel input, post-zoom menu/trim interaction, and visible waveform/MIDI checks. Retain timing failures instead of inferring audible dropout or changing the buffer requirement. +5. Complete available MIDI interchange and physical interaction checks. Electrical alignment, physical MIDI performance with the unavailable TD-17, and known-signal continuity remain explicitly separate. + +QA inventory: source replacement/preview/reload PCM; 8-sample route throughout recording; saved take duration and original-clip preservation; writer/read/device error counters; wheel routing/anchors and visible waveform/MIDI geometry; menu response after zoom; recording start/stop/restart; source-export parity and external MIDI round trip. Explore rapid direction/modifier changes and stop/restart after a deep zoom. No full-session smoothness or listening claim follows from isolated browser timings. + +**Outcome:** steps 1–3 completed. Step 4 uncovered and fixed an additional menu-focus/zoom-routing defect, now covered by unit, browser and native checks; recording stop/restart and source-backed geometry pass. Strict eight-frame deadlines and complete presentation acceptance still fail/remain unverified. Step 5 establishes native REAPER import note/controller-state parity, not a full current-export round trip. See the [dated coverage results](issue-coverage-matrix.md#2-november-follow-up-input-independent-work). Do not broaden unrelated DSP changes or relax buffer/telemetry gates to turn these findings into a pass. The next performance change needs attribution of the remaining long callbacks and plain-scroll stalls; a known input separately qualifies continuity. + +## Research-backed follow-up — 31 October 2026 + +**Status: editing/source fixes and bounded waveform delivery implemented; native eight-sample acceptance remains in progress.** The [coverage matrix](issue-coverage-matrix.md) tracks all 23 original issue paragraphs. Earlier results below retain their recorded-build scope. + +### Implementation evidence — 31 October 2026 + +- The three permanent reproduction cases now pass. Crossfades use persistent group identity and actual owners; reapply replaces the affected closure, split reconciles membership, copies fork their groups, and the editor exposes removal from a single member. Linked unselected partners are listed before applying. Removal covers the listed connected groups together. Manual gains and fades are preserved. +- Muted conversion commits the source document without audible publication. Active replacement rechecks current project/source/mute state inside and after the serialized transaction; stale native completion reconciles to current intent. Undo/Redo changes document intent synchronously and queues publication. Read-only tempo analysis and preview accept a validated source descriptor, including a muted clip absent from native playback. +- Completed waveform requests prioritize visible rows, limit bridge concurrency to four and pending work to 128, discard obsolete queued views/generations, and batch cache publication per animation frame. A browser regression exposed and fixed old-generation requests blocking their replacements. Recording preview cadence is unchanged. +- Preview-segment boundary scratch is reserved when publishing segments, never in the callback. The eight-frame regression exercises 40 distinct segments (more than the old 64-boundary reserve), checks their count, PCM parity and unchanged callback reserve counters. +- Optional callback traces retain every measured miss plus regular samples, raw QPC boundaries and honest engine/caller timing scope. The analyzer reports regular samples separately to avoid bias from retaining all misses. This is diagnostic instrumentation, not a claim that scheduling overruns have been eliminated. +- **pass:** [3,106 frontend tests](../output/shared-project-qualification/implementation-full-frontend-final.json), [405 native checks](../output/shared-project-qualification/implementation-native-current/render_export_regression_result.json), [16 recording/shared-project browser cases](../output/shared-project-qualification/implementation-project-browser-final.log), [32 additional browser workflows including eight WebKit cases](../output/shared-project-qualification/implementation-workflow-final.log), six trace-analyzer tests, current frontend build and warning-free CMake Debug build. The native tempo-preview browser case uses the actual generated WAV payload and now runs rather than skipping. +- **pass:** [supplied-project native qualification](../output/shared-project-qualification/implementation-project/project_qualification_result.json), including five full renders. The original project is preserved. The separate instant-mock load diagnostic still reports a React nesting warning; the macrotask/native-like mock and source-geometry run do not. This warning is not silently counted as a clean-console pass. +- **diagnostic_only:** the [native 156.879-second recording/400-wheel session](../output/shared-project-qualification/implementation-native-loaded/interaction/analysis.json) ran at 48 kHz / eight frames with optional tracing enabled. All four wheel modes had 100 browser-trusted events. Compositor evidence is retained, but browser injection added substantial pre-dispatch delay; a separate Windows wheel sample had 2.8 ms event-to-listener delay. These observations do not establish three-run presentation acceptance. CPU accounting includes WebView child processes. +- **pass, subsequent recording invariants:** the user supplied Analogue 1 signal. A trace-disabled 178.621167-second mono take at 48 kHz / eight frames passes all 27 file/session checks, including exact frame count and zero reported writer overflow. Strict host timing still fails; audio-source reference comparison, electrical placement and user listening remain **not_asserted**. See [the latest recording and scheduling evidence](issue-coverage-matrix.md#analogue-1-and-synchronized-windows-trace). +- **follow-up from synchronized ETW:** the approved current-build Windows trace has no lost events and exposes Debug CRT heap-lock acquisition while destroying non-owning audio views. `HeapBlock` now skips destruction/release calls only when its data pointer is null, with exact-context dependency verification. The installed SDK implementation and matching trace stacks support this targeted change. Three patch tests pass; post-change native and hardware qualification remains pending. Microsoft documents the null-pointer no-op contract for [`_free_dbg`](https://learn.microsoft.com/en-us/cpp/c-runtime-library/reference/free-dbg). + +### Evidence and scope + +The [review probes](../output/shared-project-qualification/review-20261031-probes.json) reproduce three defects, with an unmuted-conversion control passing. The [existing targeted suite](../output/shared-project-qualification/review-20261031-existing-tests.json) passes 180/180. Thus the missing cases need permanent regressions, not another unchanged run of the passing suite. + +| Work item | Established problem | Relevant implementation | +|---|---|---| +| Crossfade reapplication | Applying A+B, then A+B+C retains both groups. With A=[0,6], B=[2,8], C=[4,10], the linear gain sum at t=5 is 0.8125 instead of 1. | `timelineEnhancements.ts` filters only the selection-derived group ID; `clipCrossfades.ts` multiplies retained layers. | +| Crossfade lifecycle | Splitting generates new clip IDs but retains the old group metadata. Reconstructing a group ID from the new selection makes removal fail. | `clipEditing.ts`, `clipCrossfades.ts`, `CrossfadeEditor.tsx`. Copy/duplicate/delete variants require investigation; they are not additional reproduced defects yet. | +| Muted source conversion | A muted clip is absent from the native playback publication; source replacement expects an existing native clip and rejects the completed conversion. | `audioConversion.ts`, `clips.ts`, `PlaybackEngine.cpp`. Source inspection also shows tempo/preview lookup depending on native playback membership; verify that related case. | +| Eight-sample recording | The final trace-disabled 48 kHz / 8-frame run records +1,001 independent host deadline misses over 176.191 observed seconds, despite passing file/session invariants. | `AudioEngine.cpp`, `PlaybackEngine.cpp`, `TrackProcessor.cpp`, `AudioRecorder.cpp`, `RealtimeCallbackTrace.h`, JUCE/ASIO callback boundary. | +| Native responsiveness | The user experienced delayed scrolling. Four native plain-wheel observations per run and host-process-only CPU measurements cannot qualify all modifier gestures or WebView presentation. | `Timeline.tsx`, waveform delivery/cache, store subscriptions, native bridge and WebView child processes. | + +The three editing defects are controlled reproductions, not claims that the untouched supplied song already contains those edit states. Preserve `C:\Users\srvds\Music\mick records\songi hobe naki.osproj`; use separate project/media output directories. Its recorded SHA256 is `C4C8140DF666A15D52A56D065D1B6390B0136BAE9DFDCE35822323018D68EE81`. + +### Research and design decisions + +These are OpenStudio design recommendations inferred from primary documentation and the local evidence, not claims about other DAWs' internal data structures. + +- **Keep crossfades editable independently of manual volume.** Live documents clip fades separately from automation; Cubase permits removal through a selected participating event. Retain OpenStudio's separate gain layers, expose existing groups from any member, and keep Shift+X. Stable group identity is our implementation choice. [Ableton Live 12 Arrangement](https://www.ableton.com/en/manual/arrangement-view/), [Cubase 15 removal](https://www.steinberg.help/r/cubase-artist/15.0/en/cubase_nuendo/topics/fades_crossfades_and_envelopes/fades_crossfades_removing_t.html). +- **Resolve overlapping transitions together.** Ardour explicitly handles arbitrary layered overlaps. Its fade guidance distinguishes correlated material from uncorrelated material. Reuse OpenStudio's ownership/handoff planner; replace affected prior groups atomically instead of multiplying an obsolete plan by a new one. Preserve existing curve defaults. Equal gain and equal power have different mathematical contracts and neither guarantees constant perceived loudness for arbitrary phase relationships. [Ardour fades and crossfades](https://manual.ardour.org/editing-and-arranging/create-region-fades-and-crossfades/). +- **Separate source edits from playback activation.** Region/source editing is non-destructive in Ardour. For OpenStudio, the project document should identify a muted clip's source; active playback membership should determine whether it sounds. Keep the existing background converter and immutable source files. Different-rate output is not expected to be bit-identical to the original. [Ardour regions](https://manual.ardour.org/working-with-regions/), [Ableton audio fact sheet](https://www.ableton.com/en/manual/audio-fact-sheet/). +- **Optimize callback tails with measured attribution.** At 48 kHz, 8 frames allow 166.667 microseconds per callback, about 6,000 callbacks/second. Callback guidance excludes allocation, I/O and potentially blocking operations; Windows scheduling analysis distinguishes execution, ready time and DPC/ISR interference. The captured audio thread already had MMCSS priority 25, so registration is not a missing fix. [PortAudio callback guidance](https://files.portaudio.com/docs/v19-doxydocs/writing_a_callback.html), [Microsoft CPU analysis](https://learn.microsoft.com/en-us/windows-hardware/test/wpt/cpu-analysis), [MMCSS](https://learn.microsoft.com/en-us/windows/win32/procthread/multimedia-class-scheduler-service). +- **Measure what the user sees.** WebView2 has separate browser/renderer/GPU processes. Browser frame traces distinguish fully presented, partial and dropped frames; rAF completion is insufficient. INP excludes scrolling and zooming, so wheel latency requires explicit event-to-presentation measurement. [WebView2 performance](https://learn.microsoft.com/en-us/microsoft-edge/webview2/concepts/performance), [Chrome frame analysis](https://developer.chrome.com/docs/devtools/performance/reference), [INP scope](https://web.dev/articles/inp). +- **Separate recording placement from monitoring latency.** A correct waveform coordinate does not prove physical input/output alignment. Driver-error compensation adjusts recorded placement, not live monitoring delay. Measure both with a known signal and the actual route before changing compensation. [Ableton latency explanation](https://help.ableton.com/hc/en-us/articles/360010545559-How-Latency-Works), [recording alignment](https://help.ableton.com/hc/en-us/articles/19450890686876-Recordings-are-out-of-sync). + +### Ordered implementation plan + +#### 0. Establish permanent failures and a reproducible baseline + +- Move the retained probe cases into `frontend/src/__tests__/clipCrossfades.test.ts` and `audioConversion.test.ts`. Keep the native-membership-aware mock and unmuted control. Record the failing behavior before edits. +- Freeze the project copy, media references, executable/dist/PDB hashes, device route, 48 kHz / 8 samples, viewport/DPI, plugin state and gesture sequence. Save pre-change callback and native UI distributions with the same workload used after changes. +- Keep trace-on diagnosis separate from trace-off acceptance. Verify that selected input actually contains a nonzero test signal before undertaking long recording qualification. The earlier all-zero take cannot qualify signal continuity. + +#### 1. Repair crossfade identity and group lifecycle together + +**Files:** `frontend/src/utils/clipCrossfades.ts`, `store/actions/timelineEnhancements.ts`, `store/actions/clipEditing.ts`, relevant duplicate/paste actions, `components/CrossfadeEditor.tsx`, `utils/projectValidation.ts`. + +- Use a persistent opaque group ID in the existing `CrossfadeLayer.id` field. Selection is an operation input, not identity. Resolve actual group owners from the clips carrying that ID, including legacy IDs, and maintain member references centrally. +- On reapply, compute the closure of affected existing groups. Replace their generated layers in one command and rebuild the intended handoff across the affected clips. Preserve unrelated groups and all manual fades/gain envelopes. Never recover manual gain by dividing by the previous generated curve: zero-gain sections make that unsafe. +- If the operation includes unselected partners, show the affected clips in the existing editor before applying. A fully selected, unambiguous group keeps the direct shortcut workflow. Removal is available from any member, including a surviving split fragment or orphan; the editor identifies which group will be removed when several are present. +- Split preserves the evaluated gain exactly, using existing window/offset behavior, while atomically replacing membership references throughout the group. Copying a complete group creates a new identity and remapped members. A partial copy keeps its evaluated fade but has independent, discoverable/removable provenance; it must not edit the original group. Cover track duplication and clipboard import too. +- Trim, move and deletion preserve existing audible curve semantics by default; update references and keep residual layers removable. Reapplying explicitly recomputes handoffs. Do not silently renormalize old projects on load. +- Capture the whole affected set in one Undo/Redo transaction. Read legacy groups without changing their coefficients. Prefer the existing persistence shape; any required shape change needs old-project, portable state and Compare round trips before a schema bump. + +**Exit checks:** both crossfade probes pass; linear handoff gain sum is 1 within the existing envelope approximation tolerance (1e-4); paired equal-power curves meet their squared-gain contract. Repeating unchanged settings adds neither attenuation nor an Undo entry. Split preserves rendered output; removal leaves only original manual gain/fades. Test expanded/reduced/partial selections, contained/coincident/nested/chained overlaps, trim/untrim, move, split on a boundary, copy/paste, duplicate, delete, locked members, save/reopen, Undo/Redo, and native playback/export envelope parity. Curve contracts do not assert subjective loudness. + +#### 2. Make source replacement independent of mute state + +**Files:** `frontend/src/store/actions/audioConversion.ts`, `clips.ts`, `services/NativeBridge.ts`, `Source/PlaybackEngine.cpp`, `AudioEngine.cpp`, `ClipPreviewData.cpp`. + +- Keep one serialized source-publication transaction with explicit outcomes for an active native clip, an intentionally absent muted clip, cancellation, stale source/project and genuine failure. Do not turn every failed native replacement into success or briefly unmute the clip. +- Resolve current mute/publication state inside the existing queue. Validate project epoch, clip existence and expected source revision immediately before committing; recheck after asynchronous native work. If mute/unmute or another edit races the operation, reconcile the native publication to the latest desired document state before completing. Avoid recursively awaiting another task on the same queue. +- For a muted clip, update the document's source without publishing audible playback. Later unmute publishes the converted source. For an active clip, retain prepared-reader replacement and expected-source checks. Conversion failure leaves the original source and a clear retryable error. +- Make source Undo/Redo replay through the same serialized desired-state path. Preserve timing, mute, pitch provenance, fades and unrelated concurrent changes. Define rollback/history behavior for replay failure so the Undo label never claims a source change that did not occur. +- Give read-only analysis/preview an immutable, validated source descriptor derived from the project, with project/source generation guards. Verify muted tempo analysis and explicit preview through it. A user-requested audition is separate from unmuting the timeline. Prefer this narrow change over keeping all muted clips/readers active in the engine. + +**Exit checks:** muted and unmuted conversion both pass; no transient audible publication for mute; source hash preserved; converted rate/channel/duration correct; unmute plays the new file. Cover mute/unmute during conversion, rapid Undo/Redo, cancel, delete, replacement, project load, stale completions, cache reuse, missing file, import-policy conversion and two clips sharing one source. Add a native integration test: the mock alone cannot qualify this boundary. Check conversion quality against documented signal fixtures and audition separately. + +#### 3. Attribute and reduce the remaining eight-sample deadline misses + +**Files:** `Source/AudioEngine.cpp`, `RealtimeCallbackTrace.h`, `PlaybackEngine.cpp`, `TrackProcessor.cpp`, `AudioRecorder.cpp`, prepared state declarations and existing native regression harnesses. Inspect the pinned JUCE ASIO/device-manager path before considering a dependency patch. + +- Correlate callback sequence, QPC interval, thread/process identity and phase with ETW context switches, ready time, DPC/ISR and matching symbols. Include work before/after the current engine timer and distinguish host callback work from the complete ASIO entry/exit interval. Raw thread-cycle counts are not milliseconds. +- Capture deadline-missing callbacks explicitly; sampling only every sixteenth stage can miss the events being explained. Use a bounded preallocated trace buffer, visible dropped-event counts and deferred draining. Measure instrumentation cost and rerun acceptance with tracing off. +- Audit remaining callback allocation, locking and fixed cost. One concrete source candidate is `reusableChunkBoundaries.reserve(...)` when rendered-preview segment count exceeds prepared capacity. Prepare capacity/spans on the control side and exercise this path with allocation detection. This is an audit target, not an established cause in the user's project. +- Use attributed profiles to select the next optimization: repeated string/hash lookups, per-track dispatch/diagnostic atomics, coincident reader-window refills, or an outer JUCE/device lock. Prebind immutable render handles and use safe off-thread retirement only if lookup/publication costs warrant it. Preserve prepared PDC/EQ parity, reader continuity and FIFO correctness. Make one measured change at a time. +- Maintain the actual 8-frame monitoring path. Larger device blocks, hidden extra monitoring buffers, suppressed counters, global real-time priority/affinity tweaks or relaxed acceptance thresholds do not resolve this task. Preserve existing Debug optimization and validate Release separately without conflating the two. + +**Exit checks:** native audio invariants and exports retain their expected parity; trace-off runs meet the existing strict host deadline gate and zero reader/FIFO/device-error deltas. Report callback p50/p95/p99/max, missed-deadline count and longest run as well as average CPU. Driver xruns remain **unsupported** when the API reports -1. If the gate still fails, keep it failed and use the newly attributed cause for the next change; do not label it a driver fault from arrival gaps alone. + +#### 4. Bound waveform work and qualify native input response + +**Files:** `frontend/src/components/Timeline.tsx`, waveform cache/request helpers, `services/NativeBridge.ts`, relevant store selectors and `Source/MainComponent.cpp` delivery paths. + +- Profile trusted plain/Ctrl/Alt/Shift wheel input from receipt through JS, React/Konva, layout/drawing and frame presentation, during record start and sustained recording. Include native host plus WebView browser/renderer/GPU CPU and memory. Compare cold and warm caches separately at the same viewport/DPI. +- Prioritize currently visible rows and source ranges over playhead lookahead. Inspect the current all-track prefetch loop; bound queued/in-flight work, cancel obsolete queued generations and reject stale completions without disturbing newer ownership. Pause speculative work under interactive backlog; existing peak data stays visible until valid replacement arrives. +- If measurements confirm completion bursts, batch cache publication per frame and bound decoding work. Measure actual decoded/object heap cost in addition to the logical cache-byte budget. Keep packed Float32 transfer, canonical tiles and exact trimmed edges. +- Keep wheel handlers small; preserve accumulated deltas, cursor anchors, follow suppression and final gesture state when coalescing updates. Maintain shallow subscriptions and isolated meter/recording updates. Do not hide latency by further lowering the live-preview update rate. + +**Exit checks:** at least three identical runs with 100 trusted events per wheel/modifier mode, including sustained gestures, mixed packets, deep scrolling, cold-cache transitions and immediate menu/trim actions afterwards. Proposed project acceptance targets: event-to-first-correct-presentation p95 <=50 ms, p99 <=100 ms; active warm-gesture frame intervals p95 <=2 display periods and p99 <=3; no warm interaction stall >100 ms. Record all outliers, dropped/partial frames and cold-loading response separately. These are chosen product targets, not vendor standards or a 60 fps guarantee. If presentation cannot be measured, retain **diagnostic_only** rather than substituting rAF timings. + +#### 5. Requalify the complete original workflow on the supplied song + +- Run the focused regressions after each change; once stable, run the full frontend suite, `npm run build`, relevant real-browser workflows, a fresh warning-free CMake Debug build, native regression/project checks and full-song/stem render comparisons. Preserve exact hashes only where processing is expected to be unchanged; new SRC/fade processing needs its own numerical contract. Check packaged main/detached WebView readiness for any Release configuration being evaluated. +- At **48 kHz / 8 samples**, run three 2–3 minute recording/gesture sessions, then a 30-minute session once the short sessions pass. Use the supplied project copy, non-silent known input and recorded route details. Cross the 120-second preview boundary, seek/stop/restart in separately labelled phases, verify saved PCM/header/clip frame counts and stop-tail drainage, and reopen the saved copy. Evaluate audible continuity separately from host deadline counters. +- Use a known transient/sequence to compare displayed source coordinates, rendered samples and captured hardware placement against the metronome. Distinguish calibrated round-trip offset, drift and missing/repeated segments. Analog loopback is not a bit-exact oracle. Never route a test output into its own monitored feedback path. +- Revisit every original matrix row: tempo menu/audio/MIDI/half-double ambiguity; waveform trim/zoom edges; live MIDI thumbnails; mixer entry/resize; crop release; insertion/mono-stereo/media compatibility; ruler; all wheel modes; shortcuts/docs; MIDI drag/join/export; hover; full mixdown. Add crossfade and muted-source scenarios to a disposable project variant, since the original song does not exercise all edit states. +- Tempo estimates need an independently annotated beat grid and phase/half-double review; retain explicit ambiguity and audition controls instead of claiming exact musical intent from confidence scores. For MIDI interchange, export/reimport representative song clips in an available external DAW and compare note timing, channels, CC, tempo/meter and duration. +- Exercise real Explorer drops and an available physical MIDI route when possible. The unavailable TD-17 and the computer-use cross-window restriction remain separate qualification limits; simulated input does not certify either boundary. Request a specific physical action only if available tools cannot perform it, explaining the precise limitation. +- Update the manual and corresponding website guides for changed public behavior, then the 23-item matrix with artifact paths, exact builds and **pass / fail / diagnostic_only / not_asserted**. No blanket completion while a strict gate fails. Audition of the exact new audio artifacts remains separate from deterministic tests. +- Before a manual handoff, build current frontend assets and CMake Debug, stop Codex-started servers/harnesses, verify port 5183 is free, and confirm that `python build.py dev --run` needs no pre-running server. + +### Dependencies and stop conditions + +Steps 1 and 2 require the permanent failures from step 0. Steps 3 and 4 need a matched baseline and converge before final project qualification; a measured UI workload may affect audio scheduling, so qualify them together. Each change must preserve unrelated working-tree edits. Existing ETW evidence is already available; administrator access is not currently a blocker to planning or the deterministic fixes. A new system trace is requested only if the required correlation cannot be obtained from existing evidence and normal process tracing. + +The unresolved engineering question is which callback/queue activity accounts for the remaining tails, not whether a larger buffer works. Do not repeat unchanged failed hardware experiments indefinitely: follow the repository's three-attempt rule and explain any concrete external-access requirement. Physical signal/routing, unavailable MIDI hardware and subjective audition can limit acceptance, but they do not block the code corrections or the software regression work above. + +## Previous acceptance record (before the 31 October review) + +Measurement correction: historical `audioDeviceXRunCount` values come from JUCE AudioDeviceManager, which adds its host load-measurer count to any supported driver count. They are not driver-only ASIO signals. Historical captures cannot be split retrospectively; native callback deadline misses remain an independent measurement. The user reports reliable eight-sample operation with the same Audient setup in another DAW. Follow-up work therefore profiles OpenStudio's fixed callback costs and recording handoff rather than substituting a larger buffer. + +**Final software and recording/file checks at 48 kHz / eight samples pass; the strict host-deadline gate still fails. Native UI timings remain diagnostic. The work is not accepted as universally smooth or "all fixed."** A larger ASIO buffer cannot satisfy the user's eight-sample requirement. + +- **pass: latest frontend:** [261 files / 3,088 tests](../output/shared-project-qualification/final-packed-frontend-tests.log), production/e2e TypeScript, the production frontend build and both Chromium [packed-waveform cases](../frontend/e2e/packed-waveform-tiles.spec.ts). Eighteen focused waveform/recording-preview checks also pass. The [frontend-stage verification manifest](../output/shared-project-qualification/packed-waveform-ui-verification.json) records the completed commands, times, dist hashes and Playwright last-run snapshot; passing browser cases did not retain failure-only screenshots/traces. The new browser cases use a mocked native backend and cover stable shared tiles, source-ready invalidation, zoom and rejected stale responses without releasing a newer request. The [earlier 38 workflow cases, 30 Chromium + 8 WebKit](../output/shared-project-qualification/final-workflow-regression.log), and [three MIDI-device availability cases](../output/shared-project-qualification/midi-device-availability-browser.log) retain their recorded-build scope. Saved input/output names remain visible with checking, unavailable or unverified status rather than silently changing routes. Vite retains large-chunk and ineffective-dynamic-import warnings; no TypeScript or dependency-notice errors remain. +- **pass: final critical browser qualification:** [17 relevant Chromium cases](../output/shared-project-qualification/final-critical-browser-verification.json): 13 recording-zoom cases, three shared-project/load/deep-scroll cases and the source-geometry/trim/insertion/DPI case. The [fresh actual-project report](../output/playwright/shared-project-qualification/report.json) contains **44 explicit checks** using the real 31-track / 33-audio-clip / 9-MIDI-clip project; its original SHA256 remains unchanged, with no page errors or React warnings in that geometry run. The first targeted run passed [16 cases](../output/shared-project-qualification/final-critical-browser-regression.log) and exposed one stale test assertion requiring prefix growth. A fixture-only correction now checks fetched/drawn expanded-viewport coverage, complete bounded tile reuse and no duplicate transfer; the [isolated source-geometry rerun passes](../output/shared-project-qualification/final-source-geometry-regression.log). Production sources/assets were unchanged. E2E TypeScript passes. Earlier 45-check/profile evidence is preserved [separately](../output/shared-project-qualification/browser-before-final-critical-20261028_1815/shared-project-qualification/report.json); native audio calls remain mocked in these browser fixtures. +- **diagnostic_only:** [isolated Timeline measurements](../output/playwright/shared-project-qualification/recording-performance.json) use the real project and peak data with simulated live transport/native delivery. Steady recording and five settled zooms are approximately 16.7-16.8 ms p95/p99; wheel p95 is approximately 33 ms, with a Shift p99 of 83.3 ms. Menus paint in 12.9-41.1 ms. Cold zoom transitions reach about 83 ms p95 / 150 ms p99. Both live previews and continuous MIDI polling are verified; these numbers do not establish whole-app/native WebView 60 fps or audio deadline acceptance. +- **pass: final native regression and project checks:** [399/399 named, unique objective controls](../output/shared-project-qualification/optimized-debug/20261028_173242_audited-verified/render_export_regression_result.json), including six packed-waveform and seven cache-generation/ownership controls; [74/74 fresh project checks](../output/shared-project-qualification/native-audited-verified/project_qualification_result.json); and [all five identical full export hashes](../output/shared-project-qualification/audited-render-hash-parity.json). The intermediate 386/392-check runs are superseded, including the corrected packed-check IDs. The [latest realtime parity](../output/shared-project-qualification/audited-realtime-self-test.json) passes; earlier [whole-song reference](../output/shared-project-qualification/independent-master-eight-verified.json) and [27 stem-window comparisons](../output/shared-project-qualification/independent-stem-eight-verified.json) retain their original artifact scope; no subjective sound acceptance follows from these file checks. +- **pass: actual native packed-waveform integration:** [the read-only native WebView check](../output/shared-project-qualification/native-ui-after-performance/packed-native-integration.json) calls the public packed/legacy endpoints and loaded public NativeBridge instance. Stereo 32- and 64-peak responses have correct dimensions and exact decoded Float32 parity, with no fallback warning. It changes no transport/project state and does not establish how every historical UI request was routed. +- **pass: measured small-block implementation controls; diagnostic_only: timing:** prepared PDC retains JUCE Linear's arithmetic, warm history and transition behavior, with [2,984 production-helper parity blocks / 763,976 channel samples](../output/shared-project-qualification/c8/20261028_154918_verified/integer-pdc-benchmark.json). Its 31-track/eight-frame zero-delay median changes from 8.3 to 6.2 microseconds. The embedded-EQ dry shortcut preserves the existing peak calculation and falls back for power/mode transitions, draft/spectrum requests and unsupported layouts; 36,912 parity blocks and 186,000 measured fast dispatches pass. That run's full-disabled/guarded medians are 6.0/1.3 microseconds per 31-track batch. These microbenchmarks do not establish hardware deadline acceptance. The recorder's prepared single-producer FIFO now hands PCM to a polling disk client without a per-block disk-thread wake or mutex. Its five-millisecond empty poll affects disk service only: monitoring and live peak publication retain the eight-frame producer path. The recorder registry still uses a nonblocking try-lock; the whole recorder is not claimed lock-free. Stop drains accepted partial tails, and failed disk writes report acknowledged frame counts. +- **pass: final eight-sample session/file invariants; fail: strict host timing:** [the final trace-disabled native capture](../output/shared-project-qualification/native-ui-after-performance/measurement/recording-analysis.json) observes **176.191 seconds of continuous recording without a detected seek**, with the Audient route at **48 kHz / eight samples** throughout. The saved stereo PCM16 take is **8,505,104 frames / 177.1896667 seconds**, starts at **0**, has exact saved-clip/header frame counts and a peak sidecar, and preserves all original clip IDs/core properties. Reader/cache/continuity, recording-FIFO/write-lock, device restart/error and non-finite-output deltas are zero. The recording-only independent host deadline count nevertheless increases by **1,001**, and JUCE combined/derived-host overload counts by **1,091**; start-through-stop totals have a different scope. Driver-only telemetry is **-1 / unsupported**. The final physical-input PCM is **all zero**, so known-signal recording continuity, listening and electrical timing remain **not_asserted**. The user's no-glitch observation applies only to the earlier ETW baseline. The [151.922-second FIFO capture](../output/shared-project-qualification/native-ui-fifo-eight-capture/analysis.json) and its virtual-MIDI event checks are previous-build evidence, not this audio-only run; TD-17 remains unavailable. Eight-sample host timing remains a failed engineering gate, not proof of audible dropouts or a driver-only fault. +- **diagnostic_only: paired callback profiling:** [the preceding opt-in profile](../output/shared-project-qualification/callback-profile-before-analysis.json) pairs stage timings with callback outcomes. Raw QueryThreadCycleTime counts are not CPU milliseconds or proof of descheduling; clock frequency, processor migration and measurement overhead remain relevant. The latest acceptance capture above disables that trace. Arrival-gap heuristics alone do not fail the gate, and the remaining cause is not assigned solely to the driver or Windows. +- **not_asserted: physical Explorer drop:** [the actual attempt](../output/shared-project-qualification/physical-explorer-drop.json) is rejected by the computer-use API's cross-window endpoint check. The temporary topmost experiment did not overcome that restriction and was removed; no physical import is claimed from the passing simulated handlers. +- **pass:** [the targeted input-documentation validator](../output/shared-project-qualification/input-doc-contracts.json) checks eight app/website documents; [nine stale-contract fixture tests](../output/shared-project-qualification/input-doc-contract-tests.log) also pass. This covers selected default key/label/provenance contracts, not every profile, custom binding or translation. + +The [coverage matrix](issue-coverage-matrix.md) retains item-specific limits. Eight-sample host timing remains a failed engineering acceptance gate; physical Explorer drops, sustained physical MIDI recording, electrical timing, external-DAW MIDI interchange and subjective acceptance remain separate. The user has confirmed reliable eight-sample operation with the same Audient setup in another DAW. That answer informed the measured PDC/EQ/recording-handoff work; it does not establish the remaining OpenStudio overrun cause. Further retries must respect the repository's repeated-failure rule and use new evidence. Completed viewport/browser and objective native checks are no longer pending. + +### ETW-directed follow-up and remaining gates + +The user reports **no audible glitches in the original eight-sample ETW baseline**, but slow UI and delayed scrolling. Its matching take is 55.858 seconds. This listening observation applies only to that session, not later builds or electrical timing. The [ETW follow-up](../output/shared-project-qualification/etw-follow-up.md) and [phase analysis](../output/shared-project-qualification/etw-analysis/phase-analysis.json) preserve the trace identity, recording boundaries, zero lost events, successful MMCSS/observed priority 25 and scheduler-coverage limits. DPC/ISR time and normal driver waits are separated; the trace does not identify individual engine misses or prove a sole operating-system/driver cause. + +1. **Implemented; linked code verified:** consistent Windows Debug host optimization and nonincremental linking prevent shared helpers being selected from `/Od` objects. [Before](../output/shared-project-qualification/etw-symbols/comdat-origin-before.json)/[after](../output/shared-project-qualification/etw-symbols/comdat-origin-after.json) PDB/disassembly evidence verifies the bounds, sample and UTF-8 helper bodies. Symbols, `_DEBUG`, assertions and Debug CRT remain; WebView retains `/O1`. Release configuration is unchanged. The final 399 objective controls, 74 project checks and five export hashes above pass; [microbenchmarks](../output/shared-project-qualification/etw-symbols/optimized-debug-benchmark-comparison.json) remain **diagnostic_only**. +2. **Implemented; frontend checks pass:** canonical 4,096-peak tiles eliminate growing-prefix replacements and share interior data across prefetch/render and trim ranges. Boundary-specific tiles retain native exact trim aggregates. Versioned lossless Float32LE/base64 responses prepare cache lookup, edge reads and packing off the message thread; legacy calls remain compatible. Strict format, dimension, length, byte-order and finite-value validation surfaces failures or valid legacy fallback. Cache generation invalidation and the 32 MB source-cache budget remain. Native packed round-trip, clipped-edge and partial-tail controls pass in the final 399-check run above. The [eight-tile benchmark](../output/shared-project-qualification/optimized-debug/20261028_171319_packed-verified/packed-waveform-benchmark.json) records 379.8747 ms / 1,770,272 bytes for legacy numeric JSON, 70.8206 ms / 700,200 bytes for packed completion JSON and 5.8691 ms of worker packing. These are **diagnostic_only** serialization costs, not native UI/hardware acceptance. +3. **Implemented; seven native controls pass:** generation/ownership guards protect native cache loading, edge publication, queued work and completion/retirement against invalidation and rebuild. An old sidecar load or completion can no longer replace or retire a newer native source generation. Deterministic delayed-load, queued-generation and ownership-race controls pass in the final 399-check run. +4. **Completed with separate outcomes:** final native regressions, 74 actual-project checks and five export hashes pass. The final trace-disabled eight-sample capture passes saved-file/session invariants over 176.191 observed seconds but still fails the strict host deadline budget (+1,001 independent misses; +1,091 JUCE host counts); silent input prevents a known-signal/listening/electrical claim. The [baseline](../output/shared-project-qualification/native-ui-before-performance/measurement/analysis.json) and [post-change](../output/shared-project-qualification/native-ui-after-performance/measurement/analysis.json) observers each contain only four trusted plain-wheel events. The baseline click/seek differs from the final continuous recording. rAF/canvas CPU timings remain **diagnostic_only**, not presentation/paint proof, complete modifier-zoom acceptance or a smoothness/60 fps guarantee. [The final Debug build](../output/shared-project-qualification/final-fixture-debug-build.log) has zero compiler warnings and copies the current dist (index SHA256 starts `EDE4B35F`). + +The [native UI comparison](../output/shared-project-qualification/native-ui-after-performance/ui-comparison.json) uses the same 1,280 x 800 viewport and DPR 1.5, but only four trusted plain-wheel samples per run. It describes timeline-layer draw p95 of 0.5 to 0.1 ms and native-process CPU median of 3.636% to 0.976%; the latter excludes separate WebView renderer processes. Sampled wheel-response distributions are similar and do not establish latency improvement. The baseline click/seek and reader underruns confound audio A/B interpretation. These figures remain **diagnostic_only**, not paint/60 fps proof or a universal smoothness claim. + +## Implementation stages + +The [resumed 31 October acceptance record](issue-coverage-matrix.md#resumed-qualification-31-october) +records the current 410 native checks, clean 74-check project run, unchanged five export hashes, +and complete 179.7235-second native recording with 400 wheel events at eight samples. +The file/session invariants pass; 772 engine deadline misses leave strict timing acceptance open. +Input was effectively silent. Do not promote the implementation checklist to hardware or listening acceptance. +The latest targeted optimization retains exact JUCE delay-line parity while avoiding repeated per-sample +buffer lookups; the preceding trace establishes the separate empty-heap-release issue and remaining interruption costs. + +1. **Export reliability.** Give offline source reading an explicit non-realtime policy with bounded memory, complete reads, cancellation and surfaced errors. Preserve clip envelopes, fade curves, active state and track playback offsets in offline renders. Add a regression that exceeds the full-decode threshold. Correct MIDI export tempo and meter information. +2. **Waveform and gesture correctness.** Use exact sample/time coordinates at fractional zoom and large scroll offsets. Track cache coverage, retain real peaks during interaction, and aggregate all source bins belonging to a pixel. Correct final partial peak aggregates and clipped edge bins. Freeze clip gestures at mouse release before asynchronous backend work. +3. **Recording performance.** Stabilize MIDI thumbnail pitch coordinates, incrementally cache paired notes, bound visible drawing work by viewport, and retain the bounded recording-peak scheduler. Validate zoom/scroll during simulated long recording and inspect native qualification counters rather than inferring CPU load from UI jitter. +4. **Drop and import consistency.** Share destination semantics for internal/external drops, including between-track insertion and a truthful ghost preview. Keep MIDI/audio track compatibility. Configure new tracks from probed source channels, preserving existing track layout and undoing the entire import/move atomically. +5. **Editing and mixer workflows.** Add selected-audio crossfade and same-track MIDI join actions, accessible context-menu entries and conflict-checked shortcuts. Expose crossfade editing. Add numeric dB entry, a persisted accessible mixer resize separator, note-hover information, and a ruler range lane that leaves bar labels legible. +6. **Tempo detection and documentation.** Add asynchronous audio/MIDI tempo analysis with confidence, half/double alternatives, beat-offset preview and explicit project-tempo application. Retain imported MIDI tempo metadata; report ambiguous inference honestly. Update app/website guides and stale shortcut labels, marking source-checkout behavior as development work. + +## Execution and ownership + +- Native backend: offline source policy, render parity, peak-cache edges, native MIDI interchange, tempo analysis. +- Store/actions: undo-aware clip commands, import channel configuration, export/tempo bridge contracts. +- Timeline: source/sample projection, waveform cache/rendering, recording thumbnails, gesture termination, drop previews and context menus. +- UI: mixer editing/resizing, Piano Roll tooltip, tempo analysis presentation, documentation integration. + +Changes remain targeted. Reuse action/profile registries, existing command-manager transactions, native async jobs and lower-zone layout conventions. Do not change persistence schemas without old-project round-trip coverage. Keep analysis, decoding and allocation away from the realtime audio thread. + +## Validation gates + +- Add regressions for the reproduced 374 px waveform gap and trim mutation after mouse release with delayed native synchronization. +- Cover shared-source cache expansion, partial edge bins, fractional zoom, large source offsets, resized viewports and retained peaks during interaction. +- Cover MIDI historical-note stability and viewport-bounded drawing, single/multiple drag previews, insertion target geometry and one-step undo. +- Cover join/crossfade/mono import/tempo mutations through undo and redo; test MIDI trim/loop/CC and tempo/meter round trips. +- Run focused tests while implementing, then `npm run build` and the relevant Chromium browser integration tests. Check focus, names, hit targets, overflow and pane clamps in a real browser. +- Build `cmake --build build --config Debug` after the frontend production build so copied fallback assets are current. Run appropriate headless native regressions against that fresh executable, with long-source coverage. +- Use a copy of the user's project for any project-level export validation. Do not claim subjective audio quality from metrics or synthetic fixtures. Report `pass`, `fail`, `diagnostic_only`, and `not_asserted` separately. +- Close Codex browsers/dev servers and verify port 5183 is free before handoff. The user must only need `python build.py dev --run`. + +## Progress + +## Completion pass requested by the user + +The completion-pass production implementation now includes general/contained/multiple-overlap crossfades with editable handoffs and Undo, high-quality cancellable project-rate copies plus an optional import policy that preserve originals, and multisection tempo analysis with source/click audition. Mixed-track divider insertion retains one new row per selected source row and compatible media types. Integration and migration checks precede final qualification. Native sustained recording qualification uses paced project PCM, concurrent preview queries and the 120-second preview boundary. The real-project browser qualification profiles idle, record-start, steady recording and modifier-wheel phases with bounded IPC assertions. Later measured PDC/EQ and disk-FIFO changes preserve their objective parity controls, and saved missing MIDI routes now display honestly. These software runs do not stand in for electrical loopback or subjective listening. The latest trace-disabled eight-sample native capture still fails host timing, as recorded above; earlier counts below are historical. + +The original checklist records the first implementation/qualification pass; it does not mark every completion-pass hardware or subjective acceptance check as complete. + +- [x] Review issues, inspect project metadata, and reproduce waveform/trim defects. +- [x] Record the implementation sequence and acceptance criteria. +- [x] Complete and validate native export fixes. +- [x] Complete and validate waveform/gesture/recording fixes. +- [x] Complete and validate drop/import and clip editing actions. +- [x] Complete and validate mixer, ruler, hover and tempo UI. +- [x] Update app and website documentation. +- [x] Complete frontend/native builds, regressions and cleanup. + +## Implementation and verification record + +- Waveform projection uses exact source sample coordinates; cache entries track requested coverage, bounded memory and source invalidation generations. Real cached peaks remain available during gestures, clipped edge aggregates exclude removed audio, and partial final aggregates are retained. Static waveform artwork is reused during recording redraws. +- Released gestures stop accepting previews while native synchronization is pending. Divider drop targets have hysteresis; inserted tracks and movements share one undo transaction. Native-event drop fixtures cover 100/150/200% scaling and sequential multi-file placement. These are simulated native events, not an Explorer-to-WebView hardware qualification. +- MIDI recording thumbnails use a stable full pitch band, incremental channel-aware note pairing and visible note blocks. Completed thumbnails derive pitch range independently from sampling. Same-length edits invalidate the note index. +- New OpenStudio timeline shortcuts: Ctrl/Cmd+J joins selected same-track MIDI clips; Shift+X crossfades selected audio clips. Vendor profiles leave these new actions unassigned. General crossfades now cover contained, same-edge, coincident, nested and multiple overlaps through independent gain layers, with editable handoff lengths/curves and one Undo. Manual fades/envelopes remain intact; the expanded editor browser case passes in the final 38-case run. +- Mixer numeric edits use one volume transaction and preserve untouched numeric precision. Mixer height is persisted and accessible by pointer/keyboard. Piano Roll note information uses an imperative hover overlay. +- Tempo analysis is explicit and asynchronous. Audio uses adaptive spectral whitening and multiband onset evidence across up to three audible sections, with at most 60 seconds of spectral work in total. MIDI prefers retained source metadata, otherwise compares repeated beat grids across the selected notes. Both retain an independent fixed listener preference rather than using project tempo as an answer prior. Range selection, editable BPM, half/double/triplet candidates, section spread, tapping and beat-interval counting support correction. Four/eight-second dry source + click or simple MIDI-guide + click auditions support phase/grouping edits and cancellation without moving transport. Preview excludes track/master FX; guide tones do not reproduce the instrument. The diagram shows beat spacing, not a detected downbeat. Apply changes base BPM with Undo; existing tempo markers and absolute clip positions remain. +- Optional project-rate copies use a bounded band-limited worker conversion to float WAV with progress/Cancel, source identity guards and Undo/Redo. Project import policy can prepare copies inside the original import transaction; cancellation/failure falls back to the original source. The normal import default preserves original sample rates. Playback performs ratio conversion and export uses its selected output rate; subjective conversion quality is not inferred from rate/file tests. The inactive export quality dropdown was removed. +- MIDI export carries tempo-map/meter information, including clip-relative rebasing. Source tempo metadata survives import and follows time-stretch scaling. Native-rate audio originals remain intact. +- Application manual, feature inventory and relevant website guides describe development behavior after base commit `e8f9a65a`; no release or publication is implied. + +Earlier first-pass checks: frontend production build; 255 frontend unit-test files / 3,030 tests; 14 recording/source-geometry Chromium tests, plus the mixer/hover/tempo browser cases and existing ruler/copy integration. The existing inline-copy integration had one transient failure during simultaneous browser runs sharing an artifact directory; its isolated rerun passed. Subsequent browser runs use separate output directories. + +That first-pass Debug build completed with **zero compiler warnings**, and its packaged frontend matched `frontend/dist`. The first-pass expanded headless suite passed **280/280 checks**, including long-source reads, missing-source policies, clip state parity, track trim/polarity, exact trim/tail extrema, channel adaptation, stereo FX preservation, MIDI tempo/meter round trips, known-tempo clicks, silence and unmetered noise. It retained zero opened devices, device starts, callbacks and callback registrations throughout. + +The first native run passed 278/280: its MIDI timing oracle assumed the wrong PPQ despite a correct exported file, and the noise fixture exposed an insufficient short-clip regularity gate. The corrected oracle checks file PPQ and an independent seconds round trip; the detector now requires duration-dependent regularity evidence. Both checks pass in the final fresh executable. + +Website build and zero-warning lint passed. The full website suite initially passed 239/240 with one stale local release-metadata checksum failure. The supported refresh fetched and validated published v0.1.08 inputs; the affected test file then passed all three checks. No release metadata was fabricated, gate bypassed or site published. + +For that earlier implementation pass, **pass** covered its build, objective native gates, frontend suite and browser checks. **diagnostic_only** covered confidence/phase on real music and extrapolation from fixtures. **not_asserted** covered subjective sound quality and the then-unqualified actual-project/hardware route. The current acceptance record above supersedes that earlier evidence boundary. Audio tempo analysis is constant-tempo estimation over at most 60 seconds, not a full tempo-map/downbeat detector. + +Final evidence: `output/remediation-native-result.json`, `output/remediation-native-regression.log`, `output/remediation-debug-build.log`, `output/remediation-frontend-build.log`, `output/remediation-frontend-tests.log`, and `output/playwright/remediation-final-browser`. Website evidence is in `../openstudio-website/output/review/issue-ui-*.log`. + +At that earlier handoff, task-owned browsers/servers were closed and ports 5183/5190 were checked clear. The original user project was not edited. Current native build/copy and device acceptance are separate gates above; a final manual-testing handoff must refresh Debug assets and clear task-owned servers. Its single command remains `python build.py dev --run`, with no pre-running server required. + +## Earlier actual supplied-project qualification follow-up + +The initial handoff did not load/render the shared song. The later qualification corrected that gap using a byte-identical copy and the genuine frontend loader/serialization. Results and remaining limits are tracked item by item in [the coverage matrix](issue-coverage-matrix.md). + +The actual project passed 74 native checks and 44 source-backed browser checks. Repeated full-length 48/44.1 kHz WAVs are PCM-identical, saved-format MP3 is valid, and all 12,720 exported MIDI events retain identity/timing within one tick. An independent raw-source whole-song mix passes with maximum error 8.943e-8, 4,213 windows and no missing audio; 27 independent selected stem-window comparisons also pass. The project and source metadata remained unchanged. + +This deeper comparison found and corrected an additional recorded-source endpoint omission plus floating-point render-clock drift. Its generic suite passed 289/289, and that Debug build was warning-free with matching packaged frontend assets. Long silent lead-ins selected an audible analysis window. These counts predate the current completion pass; real-song tempo confidence remains diagnostic, and hardware performance/listening acceptance are not inferred from these artifacts. See `output/shared-project-qualification/summary.json` for those exact artifacts. + +## Earlier completion-pass UI/tempo evidence + +- **pass:** 28 focused tests for shared profiled range controls, MIDI pulse estimation, phase/tap/count math and isolated audition lifecycle; production and e2e TypeScript checks. +- **pass:** three Chromium tempo-dialog workflows after the final source-only and profiled-slider changes, including delayed analysis/preview cancellation, target changes, native source guards, range forwarding, source-only tapping, explicit one-Undo Apply, and unchanged transport. Artifact directory: `output/playwright/tempo-final-tests`. Native audio calls and audio output are mocked in these workflow tests. +- **pass:** the actual native dry-preview RFC4648 payload matches the saved float WAV byte for byte. Chromium decodes its 48 kHz/stereo/96,000 frames with zero sample error. Real OfflineAudioContext rendering through the production audition player preserves the bounded source gain within 6.71e-9; click residual occurs only in independently calculated click windows, with zero pre-roll/outside-window residual. No speaker output or subjective quality is asserted. This boundary check exposed and corrected a frontend signature-guard mismatch: native `sourceOffsetSeconds` is the retained clip origin, and `rangeStartSeconds` is validated separately. Four Chromium cases (actual decode plus the three modal workflows strengthened with a nonzero preview start) pass; e2e TypeScript passes. Evidence: `output/playwright/native-tempo-preview/report.json` and `output/playwright/native-tempo-preview-tests`. +- **diagnostic_only:** `tools/qualify-midi-tempo.mjs` evaluated all nine actual MIDI clips without passing configured project BPM to the estimator. Eight produced 153.98–154.04 BPM and one rejected insufficient repeated evidence. This is not intended-beat-unit or external interchange acceptance. Evidence: `output/tempo-research/midi-project-report.json`. +- **pass, recorded binary:** the objective native suite passes **333/333** with `claimLevel=objective_only` and subjective quality `not_asserted`. It remains device-free. Exact artifact: `output/shared-project-qualification/prepared-recording-regression/20261028_111217_eight-sample-optimizations/render_export_regression_result.json`. +- **pass:** the fresh realtime self-test preserves exact serial/worker audio across 96 blocks of 1/8/16 samples, all graph eligibility/invalidation contracts, and 450 eight-sample automation cases covering data, modes, touch/latch, force-read and interpolation. A 31-track/6,000-batch eligibility discovery microbenchmark preserves all results; its timing is `diagnostic_only`, not a hardware deadline proof. Artifact: `output/shared-project-qualification/eight-sample-realtime-self-test.json`. +- **pass:** six MIDI thumbnail-index tests, including exact dense 50,000-note sampled-query parity with bounded cloned output, held/channel notes, append closure, edits and boundaries. This does not establish sustained physical MIDI recording acceptance. +- **pass:** viewport-stage actual-project geometry and deep-scroll clip/fade/marquee/Undo checks, plus track/MIDI automation, modifier routing, recording cancellation and source-cache/drop integration in the final browser runs linked above. These tests use mocked native boundaries. +- **historical fail/pending:** this earlier eight-sample capture failed timing despite start 0. The final implementation now has the separate 399-check and latest native capture evidence above; current strict host timing still fails. Full native UI/plugin workload and physical Explorer drops remain separate. Independent whole-song/stem checks retain their exact artifact scope and do not constitute subjective listening acceptance. + +## Native retry and remaining acceptance + +The required route remains **48 kHz / eight ASIO samples**, approximately **0.1667 ms per callback**. The final trace-disabled native capture saves 177.1896667 seconds with **fresh-start placement pass at 0** and passes file/session invariants. Its 176.191-second observed continuous recording interval still adds **1,001 independent host deadline misses and 1,091 JUCE host counts**, so the strict timing gate fails. Silent physical input leaves listening/electrical acceptance unasserted. A 256-sample diagnostic cannot replace it. Driver-only reporting is unsupported on this route; combined JUCE counts and arrival-gap heuristics must not be relabeled as physical driver/audio loss. + +Raw-input-only capture, tiny-block/empty-graph dispatch, clip indexing, sample-rate playback, prepared PDC, guarded embedded-EQ and the disk FIFO now have the separate objective parity and 386-check evidence linked above. Their native eight-sample host-timing acceptance remains **fail**. The earlier 333/346-check artifacts predate later paths and must not be presented as their validation. The recorded PDC/EQ microbenchmark savings and nonzero captured PCM do not establish physical input identity, electrical alignment, usable held-note timing or subjective sound quality. + +The viewport Timeline integration is **verified in the final browser runs**: native DOM scroll extent remains full-height while canvases use the visible height and world-coordinate offsets. Recording repaint state has its own layer; muted hatching and sampled MIDI output are bounded. Deep-scroll clip/fade/marquee and atomic Undo pass. Track/MIDI automation, ruler/modifier recovery, source-window expansion and simulated drop coordinates pass in their own fixtures. The representative actual-project profile retains both live previews and continuous MIDI polling. Its timings remain isolated headless-browser diagnostics with a simulated live native boundary, not a native hardware or whole-app 60 fps claim. + +Physical Explorer drag/drop, sustained physical MIDI recording, electrical loopback/round-trip placement, external-DAW MIDI interchange and user audition of exact artifacts remain explicit boundaries. The targeted cross-repository shortcut documentation validator passes its recorded scope. Append subsequent native results with their exact artifacts rather than replacing these historical measurements with an unsupported "all fixed" statement. diff --git a/docs/workflow-review-qualification.md b/docs/workflow-review-qualification.md new file mode 100644 index 00000000..49fce7ba --- /dev/null +++ b/docs/workflow-review-qualification.md @@ -0,0 +1,105 @@ +# Workflow review implementation and qualification + +4 November 2026; development branch `test-run-1-bugfixes`, based on `e8f9a65a`. +This report covers the staff-review follow-up and final checks of the accumulated +issue remediation. It is not release qualification. Earlier hardware and receiving-DAW +results remain in [the coverage matrix](issue-coverage-matrix.md). + +## Implemented + +- Joining MIDI clips preserves overlapping same-key note pairs, mute state, + release velocity and expression. Optional pair identity survives old/new project + validation, editing, copying and visible trim/loop materialization without a + schema bump. Metadata edits cannot change an adjacent joined note. +- Real Ctrl+click selection is covered at clip bodies and resize edges. The edge + path could collapse selection; it now toggles once. Right-click retains selected + clips. Join shows count, actual shortcut and a disabled reason. The user's exact + earlier missing-menu session was not captured, so its cause is not asserted. +- **Export Selected MIDI Clips (N)...** and the action catalog share one typed + export operation. It snapshots content/tempo before the dialog, writes one file + with source tracks and relative gaps, and leaves the arrangement untouched. + **Export This MIDI Clip...** is the explicit single-clip alternative. +- The mixer grip shares its existing header row. Pointer resizing does not take + permanent focus/context; drop, Escape, cancel and blur release ownership. + Intentional Tab/keyboard resizing remains available. +- Shared fader-floor conversion makes `-inf` exact silence in static, automation + and offline paths. Project-rate import publication revalidates the target rate; + the source replacement transaction is checked TypeScript. All conversion jobs + have accessible names and cancellation, including beyond the first three. +- Tempo work has native request IDs, cooperative cancellation and a dedicated + queue bounded to one active and one pending request. Closing/changing targets + retires obsolete work. Waveform requests use their separate service. +- Timeline feedback identifies the blue time range, copying and slipping; range + actions expose clear, select-clips and loop. Persistent MIDI labels say + **Source offset**. Slip feedback ends on release. Ordinary tooltip/viewport + styling uses Tailwind; component CSS retains runtime geometry. + +## Verification + +Evidence below is local under ignored `output/`; recordings, source media and +trace files are not part of the PR. + +| Check | Result and scope | +|---|---| +| Frontend | **pass:** 3,131 tests in 268 files; production and e2e TypeScript; dependency notices and Vite build. `output/staff-review-nov04/implementation-unit-tests-complete.log` | +| Workflow browser | **pass:** 24 menu/export/mixer/tempo/rate-copy cases, followed by five final MIDI/range/slip cases after the final feedback fix. Actual pointer and keyboard events; native boundaries mocked. `implementation-browser-tests-2.log`, `implementation-midi-browser-final-2.log` | +| Supplied-project browser | **pass:** 23 source-backed project, recording-preview, packed-waveform and source-geometry cases. `implementation-project-browser.log` | +| Additional feature browser | **pass:** five crossfade/device-availability/prepared-recording cases and one real native preview-payload decode/click-mix case. `implementation-feature-browser-final.log`, `implementation-native-preview-browser.log` | +| Native regression | **pass:** 421 objective checks, including exact fader-floor silence at 8 and 512 frames and bounded/cancelled analysis work. No audio device opened. `native/20261104_111627_workflow-fixes/render_export_regression_result.json` | +| Native recording writer | **pass:** 30 seconds of actual project PCM, 180,000 eight-frame writes, 648 concurrent peak queries, 10 checks, no FIFO overflow/write failure or lost frames; output matches source within PCM16 quantization. Device-free; timing is diagnostic only. `paced-eight/result.json` | +| Supplied-project render/MIDI | **pass:** 74 native checks, five full-song renders, repeatable 48/44.1 kHz WAVs, valid MP3, MIDI event readback. `project-renders/project_qualification_result.json` | +| Independent render comparison | **pass:** 19,721,766 frames / 4,213 windows; maximum absolute source-mix error 8.943e-8, no missing audio windows. Exact sound-quality acceptance remains **not_asserted**. `project-renders/source-reference.json` | +| Native tempo cancellation | **pass:** ten Analyze/Cancel cycles on a retained source in the supplied project; every cancelled completion and concurrent peak request returned. Peak service 0.7–2.2 ms in this small sample is **diagnostic_only**. `native-tempo-cancel.json` | +| Build/runtime | **pass:** Windows Debug and Release CMake builds; current packaged assets copied. Release main WebView reached the usable project UI and completed recording/save/reopen. This does not qualify every detached-window or platform path. | + +## Fresh native eight-sample run + +The original project was copied, never saved over. Its SHA256 remains +`c4c8140df666a15d52a56d065d1b6390b0136bae9dfdce35822323018d68ee81`. +Actual device: Audient USB Audio ASIO Driver, 48 kHz, **8 samples** before and after. + +- **pass:** 75.037333 seconds / 3,601,792 mono frames saved at position zero; + 42 existing clips preserved, one added take, all 31 tracks and 43 clip IDs + retained after a genuine native save/reopen. Captured PCM is finite and nonzero; + the physical source was not identified during this run. +- **pass, functional:** 32 trusted wheel events, eight each plain/Control/Shift/Alt; + every event changed its intended scroll/zoom/height control while recording + remained active. No device interruption, nonfinite output, playback lock failure + or continuity-concealment increment in the measured recording interval. +- **fail, strict timing:** during the 59.979-second steady recording interval, + host overrun counts increased **21** and engine deadline misses **19**. These + counters are not proof of physical driver packet loss or audible glitches; + driver-only xrun telemetry is unsupported on this route. +- **diagnostic_only:** browser event creation-to-listener p95 **47.9 ms**, maximum + **50.2 ms**. Listener-to-next-main-canvas draw p95 **1.3 ms**. This observer adds + overhead and does not measure physical input-to-display presentation. Fast canvas + work does not negate the earlier event-delivery delay. Record/stop completion + was **267.4/77.3 ms**, not electrical latency. +- **not_asserted:** source continuity against a known reference, electrical or + metronome alignment, subjective listening and a guarantee of smooth presentation. + +Evidence: `output/shared-project-qualification/nov04-workflow-final-eight/workflow-summary.json` +and `output/staff-review-nov04/native-reopen.json`. The prior user-confirmed music +capture remains separate evidence; this run does not replace its audition boundary. + +## Remaining engineering and acceptance work + +Strict zero-miss eight-sample operation and complete input-to-presentation latency +remain open. Controlled callback/scheduler attribution is needed for the deadline +outliers, and a browser/native presentation trace is needed to attribute the +event-delivery delay. Continuous music alone does not resolve either attribution +gap. Do not raise the buffer or suppress counters to qualify this work. + +The measured minified bundle still has a 1,329,584-byte entry chunk (325,899 gzip) +and a 1,040,784-byte FX panel chunk (299,780 gzip). Static consumers make several +dynamic imports ineffective. No warning threshold was raised. Changing shared +store/action boundaries is a separate load-time project, not an evidenced cure +for wheel latency. New native review tests are in their own translation unit; +splitting all historical harnesses into a separate target and adding an unoptimized +diagnostic configuration are deferred build changes, not requirements for these +correctness fixes. The existing optimized Debug behavior is preserved. + +Physical Explorer drops, sustained physical MIDI performance, electrical alignment, +subjective audio acceptance and lossless receiving-DAW normalization retain their +individual limits in the coverage matrix. This branch should remain a draft PR +while the strict timing acceptance gate is unresolved. diff --git a/frontend/e2e/crossfade-editor.spec.ts b/frontend/e2e/crossfade-editor.spec.ts new file mode 100644 index 00000000..9ab837d4 --- /dev/null +++ b/frontend/e2e/crossfade-editor.spec.ts @@ -0,0 +1,79 @@ +import { expect, test } from "@playwright/test"; +import type { useDAWStore } from "../src/store/useDAWStore"; +import type { commandManager } from "../src/store/commands"; +declare global { interface Window { __crossfadeQA: { store: typeof useDAWStore; history: typeof commandManager } } } + +test("contained and grouped crossfades have editable, matching gain previews and atomic undo", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto("/"); + await page.getByText("Starting OpenStudio...", { exact: true }).waitFor({ state: "hidden" }); + await page.evaluate(async () => { + const source = await (await fetch("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/src/App.tsx")).text(); + const storeUrl = source.match(/from "([^"]*useDAWStore\.ts[^"]*)"/)?.[1]; + if (!storeUrl) throw new Error("App store module missing"); + const { useDAWStore: store, createDefaultTrack } = await import(/* @vite-ignore */ storeUrl) as typeof import("../src/store/useDAWStore"); + const commandUrl = "/src/store/commands/index.ts"; + const { commandManager: history } = await import(/* @vite-ignore */ commandUrl) as typeof import("../src/store/commands"); + const track = createDefaultTrack("crossfade-ui", "Crossfade UI"); + track.clips = [ + { id: "bed", name: "Original performance", filePath: "C:/bed.wav", startTime: 0, duration: 10, + offset: 0, color: "#abc", sampleRate: 48000, volumeDB: 0, fadeIn: 0.3, fadeOut: 0, + gainEnvelope: [{ time: 0, gain: 0.5 }, { time: 10, gain: 1.5 }] }, + { id: "insert", name: "Contained replacement", filePath: "C:/insert.wav", startTime: 2, duration: 2, + offset: 0, color: "#abc", sampleRate: 48000, volumeDB: 0, fadeIn: 0, fadeOut: 0 }, + { id: "third", name: "Another replacement", filePath: "C:/third.wav", startTime: 6, duration: 2, + offset: 0, color: "#abc", sampleRate: 48000, volumeDB: 0, fadeIn: 0, fadeOut: 0 }, + ]; + history.clear(); + store.setState({ tracks: [track], selectedClipIds: ["bed", "insert"], selectedClipId: "insert", + showMixer: false, showPianoRoll: false, showPitchEditor: false, showGettingStarted: false, + inputProfileOnboardingSeen: true, autoBackupEnabled: false, globalLocked: false, + lockSettings: { ...store.getState().lockSettings, items: false }, syncClipsWithBackend: async () => {}, + transport: { ...store.getState().transport, isPlaying: false, isRecording: false } }); + window.__crossfadeQA = { store, history }; + if (!store.getState().editSelectedCrossfade()) throw new Error("Contained crossfade editor unavailable"); + }); + const dialog = page.getByRole("dialog", { name: "Crossfade Editor" }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByText(/2 clips · 2 handoffs/)).toBeVisible(); + await expect(dialog.getByRole("img", { name: /Resulting clip gain curves/ })).toBeVisible(); + await dialog.getByLabel("Automatic transition lengths").uncheck(); + await dialog.getByLabel("Crossfade transition length in seconds").fill("0.25"); + await dialog.getByLabel("Outgoing Curve").selectOption("1"); + await dialog.getByLabel("Incoming Curve").selectOption("1"); + await dialog.getByRole("button", { name: "Apply", exact: true }).click(); + await expect(dialog).toBeHidden(); + const applied = await page.evaluate(() => { + const state = window.__crossfadeQA.store.getState(), bed = state.tracks[0].clips[0]; + return { fadeIn: bed.fadeIn, base: bed.gainEnvelope, layer: bed.crossfadeLayers?.[0], undo: window.__crossfadeQA.history.getUndoStack().length }; + }); + expect(applied).toMatchObject({ fadeIn: 0.3, base: [{ time: 0, gain: 0.5 }, { time: 10, gain: 1.5 }], + layer: { settings: { transitionLength: 0.25, fadeInShape: 1, fadeOutShape: 1 } }, undo: 1 }); + await page.evaluate(() => window.__crossfadeQA.store.getState().undo()); + expect(await page.evaluate(() => window.__crossfadeQA.store.getState().tracks[0].clips[0].crossfadeLayers)).toBeUndefined(); + await page.evaluate(() => { + const store = window.__crossfadeQA.store; + store.setState({ selectedClipIds: ["bed", "insert", "third"] }); + if (!store.getState().editSelectedCrossfade()) throw new Error("Grouped crossfade editor unavailable"); + }); + await expect(dialog.getByText(/3 clips · 4 handoffs/)).toBeVisible(); + await dialog.getByRole("button", { name: "Apply", exact: true }).click(); + expect(await page.evaluate(() => window.__crossfadeQA.store.getState().tracks[0].clips.every(clip => clip.crossfadeLayers?.length === 1))).toBe(true); + await page.evaluate(() => window.__crossfadeQA.store.getState().editSelectedCrossfade()); + await dialog.getByRole("button", { name: "Remove Crossfade", exact: true }).click(); + expect(await page.evaluate(() => window.__crossfadeQA.store.getState().tracks[0].clips.every(clip => !clip.crossfadeLayers?.length))).toBe(true); + await page.evaluate(() => { + const store = window.__crossfadeQA.store; + store.getState().undo(); + store.getState().splitClipAtPosition("bed", 1); + const fragment = store.getState().tracks[0].clips.find(clip => clip.name === "Original performance")!; + store.setState({ selectedClipIds: [fragment.id], selectedClipId: fragment.id }); + if (!store.getState().editSelectedCrossfade()) throw new Error("Split member lost its crossfade editor"); + }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByText(/also affects the linked clips/)).toBeVisible(); + await expect(dialog.getByRole("button", { name: "Remove Crossfade", exact: true })).toBeEnabled(); + await page.screenshot({ path: "../output/shared-project-qualification/crossfade-split-editor.png" }); + await dialog.getByRole("button", { name: "Remove Crossfade", exact: true }).click(); + expect(await page.evaluate(() => window.__crossfadeQA.store.getState().tracks[0].clips.every(clip => !clip.crossfadeLayers?.length))).toBe(true); +}); diff --git a/frontend/e2e/midi-device-availability.spec.ts b/frontend/e2e/midi-device-availability.spec.ts new file mode 100644 index 00000000..ece36b8a --- /dev/null +++ b/frontend/e2e/midi-device-availability.spec.ts @@ -0,0 +1,88 @@ +import { expect, test, type Page } from "@playwright/test"; +import type { useDAWStore } from "../src/store/useDAWStore"; + +declare global { interface Window { __midiAvailabilityQA: { + store: typeof useDAWStore; + resolveInputs: ((devices: string[]) => void) | null; + rejectInputs: (() => void) | null; +} } } + +async function ready(page: Page, availableOutputs = ["Available output"]) { + await page.goto("/"); + await page.getByText("Starting OpenStudio...", { exact: true }).waitFor({ state: "hidden" }); + await page.evaluate(async (outputs) => { + const source = await (await fetch("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/src/App.tsx")).text(); + const storeUrl = source.match(/from "([^"]*useDAWStore\.ts[^"]*)"/)?.[1]; + if (!storeUrl) throw new Error("Store URL missing"); + const { useDAWStore: store, createDefaultTrack } = await import(/* @vite-ignore */ storeUrl) as typeof import("../src/store/useDAWStore"); + const bridgeUrl = "/src/services/NativeBridge.ts"; + const { nativeBridge } = await import(/* @vite-ignore */ bridgeUrl) as typeof import("../src/services/NativeBridge"); + const qa = window.__midiAvailabilityQA = { store, + resolveInputs: null as ((devices: string[]) => void) | null, rejectInputs: null as (() => void) | null }; + nativeBridge.getMIDIInputDevices = async () => await new Promise((resolve, reject) => { + qa.resolveInputs = resolve; qa.rejectInputs = () => reject(new Error("Device enumeration unavailable")); + }); + nativeBridge.getMIDIOutputDevices = async () => outputs; + nativeBridge.getOpenMIDIDevices = async () => []; + const track = createDefaultTrack("missing-midi-route", "MIDI route", "#a855f7", "midi", []); + track.midiInputDevice = "TD-17"; track.midiOutputDevice = "Saved synth"; + store.setState({ tracks: [track], selectedTrackId: track.id, selectedTrackIds: [track.id], + showGettingStarted: false, inputProfileOnboardingSeen: true, showMixer: false, autoBackupEnabled: false, + syncClipsWithBackend: async () => {} }); + }, availableOutputs); +} + +const routes = (page: Page) => page.evaluate(() => { + const track = window.__midiAvailabilityQA.store.getState().tracks[0]; + return { input: track.midiInputDevice, output: track.midiOutputDevice }; +}); + +test("missing saved MIDI routes remain visibly selected through checking and successful enumeration", async ({ page }) => { + await ready(page); + const checkingInput = page.getByTitle("TD-17 (checking)", { exact: true }); + await expect(checkingInput).toHaveValue("TD-17"); + await expect(page.getByTitle("MIDI out: Saved synth (checking)", { exact: true })).toHaveValue("Saved synth"); + expect(await checkingInput.evaluate((node: HTMLSelectElement) => ({ label: node.selectedOptions[0].label, + disabled: node.selectedOptions[0].disabled }))).toEqual({ label: "TD-17 (checking)", disabled: true }); + await expect.poll(() => page.evaluate(() => !!window.__midiAvailabilityQA.resolveInputs)).toBe(true); + await page.evaluate(() => window.__midiAvailabilityQA.resolveInputs?.(["Available keyboard"])); + const unavailable = page.getByTitle("TD-17 (unavailable)", { exact: true }); + await expect(unavailable).toHaveValue("TD-17"); + await expect(page.getByTitle("MIDI out: Saved synth (unavailable)", { exact: true })).toHaveValue("Saved synth"); + expect(await unavailable.evaluate((node: HTMLSelectElement) => ({ label: node.selectedOptions[0].label, + disabled: node.selectedOptions[0].disabled }))).toEqual({ label: "TD-17 (unavailable)", disabled: true }); + expect(await routes(page)).toEqual({ input: "TD-17", output: "Saved synth" }); + await page.evaluate(() => window.__midiAvailabilityQA.store.setState(state => ({ + tracks: state.tracks.map(track => ({ ...track, midiInputDevice: "Available keyboard", midiOutputDevice: "Available output" })) }))); + await expect(page.getByTitle("Available keyboard", { exact: true })).toHaveValue("Available keyboard"); + await expect(page.getByTitle("MIDI out: Available output", { exact: true })).toHaveValue("Available output"); + expect(await page.getByTitle("Available keyboard", { exact: true }).evaluate((node: HTMLSelectElement) => node.selectedOptions[0].disabled)).toBe(false); + await page.evaluate(() => window.__midiAvailabilityQA.store.setState(state => ({ + tracks: state.tracks.map(track => ({ ...track, midiInputDevice: "", midiOutputDevice: "" })) }))); + await expect(page.getByTitle("All MIDI inputs", { exact: true })).toHaveValue(""); + await expect(page.getByTitle("No MIDI output", { exact: true })).toHaveValue(""); +}); + +test("available legacy saved device names retain their normal selectable labels", async ({ page }) => { + await ready(page, ["Saved synth"]); + await expect.poll(() => page.evaluate(() => !!window.__midiAvailabilityQA.resolveInputs)).toBe(true); + await page.evaluate(() => window.__midiAvailabilityQA.resolveInputs?.(["TD-17"])); + const input = page.getByTitle("TD-17", { exact: true }); + const output = page.getByTitle("MIDI out: Saved synth", { exact: true }); + await expect(input).toHaveValue("TD-17"); await expect(output).toHaveValue("Saved synth"); + expect(await input.evaluate((node: HTMLSelectElement) => ({ label: node.selectedOptions[0].label, + disabled: node.selectedOptions[0].disabled }))).toEqual({ label: "TD-17", disabled: false }); + expect(await output.evaluate((node: HTMLSelectElement) => node.selectedOptions[0].disabled)).toBe(false); + expect(await routes(page)).toEqual({ input: "TD-17", output: "Saved synth" }); +}); + +test("failed enumeration labels saved MIDI routes unverified without remapping", async ({ page }) => { + await ready(page); + await expect.poll(() => page.evaluate(() => !!window.__midiAvailabilityQA.rejectInputs)).toBe(true); + await page.evaluate(() => window.__midiAvailabilityQA.rejectInputs?.()); + const input = page.getByTitle("TD-17 (unverified)", { exact: true }); + await expect(input).toHaveValue("TD-17"); + await expect(page.getByTitle("MIDI out: Saved synth (unverified)", { exact: true })).toHaveValue("Saved synth"); + expect(await input.evaluate((node: HTMLSelectElement) => node.selectedOptions[0].label)).toBe("TD-17 (unverified)"); + expect(await routes(page)).toEqual({ input: "TD-17", output: "Saved synth" }); +}); diff --git a/frontend/e2e/midi-join-export.spec.ts b/frontend/e2e/midi-join-export.spec.ts new file mode 100644 index 00000000..9ce20ac9 --- /dev/null +++ b/frontend/e2e/midi-join-export.spec.ts @@ -0,0 +1,123 @@ +import { expect, test, type Page } from "@playwright/test"; +import type { useDAWStore } from "../src/store/useDAWStore"; +import type { nativeBridge } from "../src/services/NativeBridge"; + +declare global { + interface Window { + __midiWorkflowQA: { + store: typeof useDAWStore; + exports: Parameters[]; + }; + } +} + +async function ready(page: Page) { + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto("/"); + await page.getByText("Starting OpenStudio...", { exact: true }).waitFor({ state: "hidden" }); + await page.evaluate(async () => { + const source = await (await fetch("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/src/App.tsx")).text(); + const storeUrl = source.match(/from "([^"]*useDAWStore\.ts[^"]*)"/)?.[1]; + if (!storeUrl) throw new Error("App store import not found"); + const { useDAWStore: store, createDefaultTrack } = await import(/* @vite-ignore */ storeUrl) as typeof import("../src/store/useDAWStore"); + const bridgeUrl = "/src/services/NativeBridge.ts"; + const { nativeBridge } = await import(/* @vite-ignore */ bridgeUrl) as typeof import("../src/services/NativeBridge"); + const track = createDefaultTrack("midi-workflow", "MIDI workflow", "#c66091", "midi", []); + track.midiClips = ["a", "b", "c"].map((id, i) => ({ id, name: `Phrase ${id}`, + startTime: 1 + i * 3, duration: 2, offset: 0, color: track.color, loopEnabled: false, + events: [{ type: "noteOn", note: 60 + i, channel: 1, velocity: 90, timestamp: 0 }, + { type: "noteOff", note: 60 + i, channel: 1, velocity: 30, timestamp: 1.5 }] })); + window.__midiWorkflowQA = { store, exports: [] }; + nativeBridge.showSaveDialog = async () => "C:/qa/selected.mid"; + nativeBridge.exportProjectMIDI = async (...args) => { window.__midiWorkflowQA.exports.push(args); return true; }; + store.setState({ tracks: [track], showGettingStarted: false, inputProfileOnboardingSeen: true, + autoBackupEnabled: false, showMixer: false, showPianoRoll: false, showPitchEditor: false, + selectedClipIds: [], selectedClipId: null, selectedTrackIds: [], selectedTrackId: null, + mouseBehaviorProfileId: "openstudio", keyboardShortcutProfileId: "openstudio", + mouseModifiers: {}, customShortcuts: {}, pixelsPerSecond: 100, scrollX: 0, scrollY: 0, + trackHeight: 100, toolMode: "select", transport: { ...store.getState().transport, isPlaying: false, isRecording: false } }); + }); + await expect.poll(() => page.evaluate(() => window.Konva?.stages.some(s => s.height() > 50 && s.container().closest(".timeline-container")) ?? false)).toBe(true); + return page.evaluate(() => { + const stage = window.Konva.stages.find(s => s.height() > 50 && s.container().closest(".timeline-container"))!; + const box = stage.container().getBoundingClientRect(); + return { x: box.x, y: box.y + 45 }; + }); +} + +async function selectTwo(page: Page, point: { x: number; y: number }, edge = false) { + await page.keyboard.down("Control"); + await page.mouse.click(point.x + (edge ? 102 : 200), point.y); + await page.mouse.click(point.x + (edge ? 402 : 500), point.y); + await page.keyboard.up("Control"); + await expect.poll(() => page.evaluate(() => window.__midiWorkflowQA.store.getState().selectedClipIds)).toEqual(["a", "b"]); + await page.mouse.click(point.x + 500, point.y, { button: "right" }); + await expect.poll(() => page.evaluate(() => window.__midiWorkflowQA.store.getState().selectedClipIds)).toEqual(["a", "b"]); +} + +test("Ctrl+click followed by right-click exposes Join and preserves the selected clips", async ({ page }) => { + const point = await ready(page); + await selectTwo(page, point); + await expect(page.getByText(/^Join Selected MIDI Clips/)).toBeVisible(); + await page.getByText(/^Join Selected MIDI Clips/).click(); + await expect.poll(() => page.evaluate(() => window.__midiWorkflowQA.store.getState().tracks[0].midiClips.length)).toBe(2); + await page.getByRole("button", { name: "Undo", exact: true }).click(); + await expect.poll(() => page.evaluate(() => window.__midiWorkflowQA.store.getState().tracks[0].midiClips.length)).toBe(3); +}); + +test("Ctrl+click at clip edges also toggles selection without an accidental resize", async ({ page }) => { + const point = await ready(page); + await selectTwo(page, point, true); + await expect(page.getByText(/^Join Selected MIDI Clips/)).toBeVisible(); +}); + +test("the selected export menu writes every selected clip to one file without joining", async ({ page }) => { + const point = await ready(page); + await selectTwo(page, point); + await page.getByText("Export Selected MIDI Clips (2)...", { exact: true }).click(); + await expect.poll(() => page.evaluate(() => window.__midiWorkflowQA.exports.length)).toBe(1); + const exported = await page.evaluate(() => window.__midiWorkflowQA.exports[0]); + expect(exported[1]).toHaveLength(1); + expect(exported[1][0].clips.map((c: { id: string; startTime: number }) => [c.id, c.startTime])).toEqual([["a", 0], ["b", 3]]); + expect(await page.evaluate(() => window.__midiWorkflowQA.store.getState().tracks[0].midiClips.length)).toBe(3); +}); + +test("empty-space range feedback explains the blue region and exposes Clear and Set loop", async ({ page }) => { + const point = await ready(page); + await page.keyboard.down("Control"); + await page.mouse.move(point.x + 150, point.y + 130); + await page.mouse.down(); + await page.mouse.move(point.x + 600, point.y + 130, { steps: 5 }); + await page.mouse.up(); + await page.keyboard.up("Control"); + await expect(page.getByLabel("Timeline gesture feedback")).toContainText("Time selection:"); + expect(await page.evaluate(() => window.__midiWorkflowQA.store.getState().selectedClipIds)).toEqual([]); + await page.getByRole("button", { name: "Set loop", exact: true }).click(); + const state = await page.evaluate(() => { + const s = window.__midiWorkflowQA.store.getState(); + return { range: s.timeSelection, loopStart: s.transport.loopStart, loopEnd: s.transport.loopEnd }; + }); + expect(state.loopStart).toBe(state.range!.start); + expect(state.loopEnd).toBe(state.range!.end); + await page.getByRole("button", { name: "Clear time selection", exact: true }).click(); + await expect(page.getByLabel("Timeline gesture feedback")).toBeHidden(); +}); + +test("slip feedback ends on release while the retained source offset remains visible", async ({ page }) => { + const point = await ready(page); + await page.evaluate(() => { + const store = window.__midiWorkflowQA.store; + store.setState({ tracks: store.getState().tracks.map(t => ({ ...t, midiClips: t.midiClips.map(c => ({ ...c, offset: 1, sourceLength: 5 })) })) }); + }); + await page.keyboard.down("Control"); + await page.keyboard.down("Shift"); + await page.mouse.move(point.x + 200, point.y); + await page.mouse.down(); + await page.mouse.move(point.x + 230, point.y, { steps: 5 }); + await expect(page.getByLabel("Timeline gesture feedback")).toHaveText("Slip contents"); + await page.mouse.up(); + await page.keyboard.up("Shift"); + await page.keyboard.up("Control"); + await expect(page.getByLabel("Timeline gesture feedback")).toBeHidden(); + expect(await page.evaluate(() => window.__midiWorkflowQA.store.getState().tracks[0].midiClips[0].offset)).toBeCloseTo(0.7, 3); +}); diff --git a/frontend/e2e/mixer-entry-resize.spec.ts b/frontend/e2e/mixer-entry-resize.spec.ts new file mode 100644 index 00000000..71330f45 --- /dev/null +++ b/frontend/e2e/mixer-entry-resize.spec.ts @@ -0,0 +1,233 @@ +import { expect, test, type Page } from "@playwright/test"; +import type { useDAWStore } from "../src/store/useDAWStore"; +import type { commandManager } from "../src/store/commands"; + +declare global { + interface Window { + __mixerUiQA: { store: typeof useDAWStore; history: typeof commandManager }; + } +} + +async function ready(page: Page) { + await page.goto("/"); + await page.getByText("Starting OpenStudio...", { exact: true }).waitFor({ state: "hidden" }); + await page.evaluate(async () => { + const source = await (await fetch("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/src/App.tsx")).text(); + const storeUrl = source.match(/from "([^"]*useDAWStore\.ts[^"]*)"/)?.[1]; + if (!storeUrl) throw new Error("App store import not found"); + const { useDAWStore: store, createDefaultTrack } = await import(/* @vite-ignore */ storeUrl) as typeof import("../src/store/useDAWStore"); + const commandUrl = "/src/store/commands/index.ts"; + const { commandManager: history } = await import(/* @vite-ignore */ commandUrl) as typeof import("../src/store/commands"); + const track = createDefaultTrack("precision-track", "Precision", "#4361ee", "audio", []); + history.clear(); + store.setState({ tracks: [track], showMixer: true, showPianoRoll: false, showPitchEditor: false, + showClipLauncher: false, showGettingStarted: false, inputProfileOnboardingSeen: true, + detachedPanels: [], mixerHeight: 340, autoBackupEnabled: false, masterVolume: 1, + selectedTrackIds: [track.id], selectedTrackId: track.id, canUndo: false, canRedo: false, + transport: { ...store.getState().transport, isPlaying: false, isRecording: false } }); + window.__mixerUiQA = { store, history }; + }); + await expect(page.getByRole("textbox", { name: "Volume in decibels for Precision", exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Hide essential controls", exact: true }).click(); +} + +for (const editor of ["timeline", "piano_roll"] as const) { + for (const termination of ["release", "escape", "cancel", "blur"] as const) { + test(`pointer resize returns ${editor} ownership after ${termination}`, async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 1200 }); + await ready(page); + if (editor === "piano_roll") { + await page.evaluate(() => { + const store = window.__mixerUiQA.store; + const track = { ...store.getState().tracks[0], type: "midi" as const, + midiClips: [{ id: "resize-note", name: "Phrase", startTime: 0, duration: 4, color: "#c66091", events: [] }] }; + store.setState({ tracks: [track], lowerZoneHeight: 440, mixerHeight: 220 }); + store.getState().openPianoRoll(track.id, "resize-note"); + }); + } + const editorSurface = page.locator(editor === "timeline" ? ".timeline-container" : '[data-qa="piano-roll-grid-stage"]'); + await editorSurface.click({ position: { x: 100, y: 50 } }); + const activeContext = () => page.evaluate(async () => { + const moduleUrl = "/src/utils/shortcutContext.ts"; + return (await import(/* @vite-ignore */ moduleUrl)).getActiveShortcutContext(); + }); + const before = await activeContext(); + expect(before.kind).toBe(editor); + const handle = page.getByRole("separator", { name: "Resize mixer", exact: true }); + const box = (await handle.boundingBox())!; + const previousHeight = Number(await handle.getAttribute("aria-valuenow")); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 2, box.y - 35, { steps: 4 }); + if (termination === "escape") await page.keyboard.press("Escape"); + if (termination === "cancel") await handle.dispatchEvent("pointercancel"); + if (termination === "blur") await page.evaluate(() => window.dispatchEvent(new Event("blur"))); + await page.mouse.up(); + await expect(handle).not.toBeFocused(); + expect(await activeContext()).toEqual(before); + expect(await page.evaluate(() => document.body.style.cursor)).toBe(""); + const after = await handle.getAttribute("aria-valuenow"); + if (termination === "escape") expect(Number(after)).toBe(previousHeight); + await page.keyboard.press("ArrowUp"); + await expect(handle).toHaveAttribute("aria-valuenow", after!); + await page.screenshot({ path: `../output/playwright/mixer-${editor}-${termination}.png` }); + }); + } +} + +test("numeric track/master levels commit once, reject invalid drafts and cancel with Escape", async ({ page }) => { + await ready(page); + const level = page.getByRole("textbox", { name: "Volume in decibels for Precision", exact: true }); + await level.fill("-6.25"); + expect(await page.evaluate(() => window.__mixerUiQA.store.getState().tracks[0].volumeDB)).toBe(0); + await level.press("Enter"); + await expect(level).toHaveValue("-6.3"); + expect(await page.evaluate(() => window.__mixerUiQA.store.getState().tracks[0].volumeDB)).toBe(-6.25); + expect(await page.evaluate(() => window.__mixerUiQA.history.getUndoStack().length)).toBe(1); + await page.getByRole("button", { name: "Undo", exact: true }).click(); + await expect(level).toHaveValue("0.0"); + await page.getByRole("button", { name: "Redo", exact: true }).click(); + // Focusing a rounded readout must preserve the finer stored precision. + await level.focus(); + await level.press("Tab"); + expect(await page.evaluate(() => window.__mixerUiQA.store.getState().tracks[0].volumeDB)).toBe(-6.25); + expect(await page.evaluate(() => window.__mixerUiQA.history.getUndoStack().length)).toBe(1); + await level.fill("r"); + await level.press("Enter"); + await expect(level).toHaveAttribute("aria-invalid", "true"); + expect(await page.evaluate(() => window.__mixerUiQA.store.getState().transport.isRecording)).toBe(false); + await level.press("Escape"); + await expect(level).toHaveValue("-6.3"); + expect(await page.evaluate(() => window.__mixerUiQA.history.getUndoStack().length)).toBe(1); + await level.fill("-inf"); + await level.press("Tab"); + await expect(level).toHaveValue("-∞"); + const master = page.getByRole("textbox", { name: "Volume in decibels for MASTER", exact: true }); + await master.fill("-3.5"); + await master.press("Enter"); + expect(await page.evaluate(() => window.__mixerUiQA.store.getState().masterVolume)).toBeCloseTo(10 ** (-3.5 / 20), 10); + await page.getByRole("button", { name: "Undo", exact: true }).click(); + await expect(master).toHaveValue("0.0"); +}); + +test("mixer resizing supports keyboard, pointer release, persistence and workspace bounds", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await ready(page); + const handle = page.getByRole("separator", { name: "Resize mixer", exact: true }); + await handle.focus(); + await handle.press("ArrowUp"); + await expect(handle).toHaveAttribute("aria-valuenow", "356"); + const box = (await handle.boundingBox())!; + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 2, box.y - 60, { steps: 4 }); + await page.mouse.up(); + const after = Number(await handle.getAttribute("aria-valuenow")); + expect(after).toBeGreaterThan(400); + await page.mouse.move(box.x + 20, box.y - 120); + await expect(handle).toHaveAttribute("aria-valuenow", String(after)); + expect(await page.evaluate(() => localStorage.getItem("openstudio_mixerHeight"))).toBe(String(after)); + await handle.press("End"); + const bounded = Number(await handle.getAttribute("aria-valuenow")); + expect(bounded).toBeLessThanOrEqual(Number(await handle.getAttribute("aria-valuemax"))); + const workspace = await page.locator(".workspace").boundingBox(); + expect(workspace!.height).toBeGreaterThanOrEqual(179); + await handle.press("Home"); + await expect(handle).toHaveAttribute("aria-valuenow", "96"); + await handle.dblclick(); + await expect(handle).toHaveAttribute("aria-valuenow", "340"); + expect(await page.evaluate(() => window.__mixerUiQA.history.getUndoStack().length)).toBe(0); +}); + +for (const width of [1280, 800]) { + test(`mixer header shares one row with separate resize and window controls at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: width === 1280 ? 900 : 600 }); + await ready(page); + const mixer = page.getByRole("region", { name: "Mixer", exact: true }); + const header = mixer.locator("header"); + const handle = mixer.getByRole("separator", { name: "Resize mixer", exact: true }); + const detach = mixer.getByRole("button", { name: "Detach mixer into separate window" }); + const close = mixer.getByRole("button", { name: "Close mixer panel" }); + const bounds = (await header.boundingBox())!; + const row = await Promise.all([header.getByText("Mixer", { exact: true }), handle, detach, close] + .map(async control => (await control.boundingBox())!)); + for (const box of row) { + expect(box.y).toBeGreaterThanOrEqual(bounds.y); + expect(box.y + box.height).toBeLessThanOrEqual(bounds.y + bounds.height); + } + for (let i = 1; i < row.length; i++) expect(row[i].x).toBeGreaterThanOrEqual(row[i - 1].x + row[i - 1].width); + expect(bounds.y).toBeLessThanOrEqual((await mixer.boundingBox())!.y + 2); + expect(row[1].height).toBeGreaterThanOrEqual(24); + for (const control of [handle, detach, close]) { + expect(await control.evaluate(element => { + const box = element.getBoundingClientRect(); + return element.contains(document.elementFromPoint(box.x + box.width / 2, box.y + box.height / 2)); + })).toBe(true); + } + await handle.focus(); + await expect(handle).toBeFocused(); + await page.screenshot({ path: `../output/shared-project-qualification/nov04-review-comments/mixer-header-${width}.png` }); + const originalHeight = await handle.getAttribute("aria-valuenow"); + await page.mouse.move(row[1].x + row[1].width / 2, row[1].y + row[1].height / 2); + await page.mouse.down(); + await page.mouse.move(row[1].x + row[1].width / 2, row[1].y - 40, { steps: 4 }); + await page.keyboard.press("Escape"); + await page.mouse.up(); + await expect(handle).toHaveAttribute("aria-valuenow", originalHeight!); + expect(await page.evaluate(() => window.__mixerUiQA.store.getState().selectedTrackIds)).toEqual(["precision-track"]); + await handle.press("Tab"); + await expect(detach).toBeFocused(); + await detach.press("Tab"); + await expect(close).toBeFocused(); + await close.press("Enter"); + await expect(mixer).toBeHidden(); + expect(await page.evaluate(() => window.__mixerUiQA.store.getState().mixerHeight)).toBe(Number(originalHeight)); + // Check the pop-out bridge binding without opening an OS window from a browser fixture. + await page.evaluate(async () => { + const bridgeUrl = "/src/services/NativeBridge.ts"; + const { nativeBridge } = await import(/* @vite-ignore */ bridgeUrl) as typeof import("../src/services/NativeBridge"); + nativeBridge.openMixerWindow = async () => true; + window.__mixerUiQA.store.setState({ showMixer: true }); + }); + await detach.click(); + await expect(mixer).toBeHidden(); + expect(await page.evaluate(() => window.__mixerUiQA.store.getState().detachedPanels)).toContain("mixer"); + expect(await page.evaluate(() => window.__mixerUiQA.store.getState().mixerHeight)).toBe(Number(originalHeight)); + }); +} + +test("piano roll note hover identifies pitch and velocity without editing the clip", async ({ page }) => { + await ready(page); + await page.evaluate(() => { + const store = window.__mixerUiQA.store; + const track = { ...store.getState().tracks[0], type: "midi" as const, + midiClips: [{ id: "hover-clip", name: "Hover notes", startTime: 0, duration: 4, + offset: 0, color: "#4361ee", sourceLength: 4, events: [ + { type: "noteOn" as const, note: 84, velocity: 80, timestamp: 0.5 }, + { type: "noteOff" as const, note: 84, velocity: 0, timestamp: 1.5 }, + ] }] }; + store.setState({ tracks: [track], showMixer: false, lowerZoneHeight: 440, pixelsPerSecond: 100 }); + store.getState().openPianoRoll(track.id, "hover-clip"); + }); + const grid = page.locator('[data-qa="piano-roll-grid-stage"]'); + await expect(grid).toBeVisible(); + await expect.poll(() => page.evaluate(() => { + const stage = window.Konva?.stages.find(candidate => candidate.container().closest('[data-qa="piano-roll-grid-stage"]')); + return stage?.find("Rect").some(node => node.opacity() === 0.85) ?? false; + })).toBe(true); + const note = await page.evaluate(() => { + const stage = window.Konva.stages.find(candidate => candidate.container().closest('[data-qa="piano-roll-grid-stage"]'))!; + const rect = stage.find("Rect").find(node => node.opacity() === 0.85)!; + const box = stage.container().getBoundingClientRect(); + return { x: box.left + rect.x() + rect.width() / 2, y: box.top + rect.y() + rect.height() / 2 }; + }); + await page.mouse.move(note.x, note.y); + const tooltip = grid.getByRole("tooltip"); + await expect(tooltip).toHaveText(/\(84\).*Velocity 80/); + await expect(tooltip).toBeVisible(); + await page.mouse.move(note.x + 2, note.y); + await expect(tooltip).toHaveText(/\(84\).*Velocity 80/); + await page.mouse.move(10, 10); + await expect(tooltip).toBeHidden(); + expect(await page.evaluate(() => window.__mixerUiQA.history.getUndoStack().length)).toBe(0); +}); diff --git a/frontend/e2e/native-tempo-preview.spec.ts b/frontend/e2e/native-tempo-preview.spec.ts new file mode 100644 index 00000000..67d1825f --- /dev/null +++ b/frontend/e2e/native-tempo-preview.spec.ts @@ -0,0 +1,92 @@ +import { test, expect } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; +import crypto from "node:crypto"; +import type { ClipTempoPreviewData } from "../src/services/NativeBridge"; + +const payloadPath = process.env.OPENSTUDIO_TEMPO_PREVIEW_DATA; + +test("actual native preview decodes without PCM changes and mixes a bounded click on one offline clock", async ({ page }) => { + test.skip(!payloadPath, "Opt in with OPENSTUDIO_TEMPO_PREVIEW_DATA pointing to a native qualification JSON artifact"); + const resolved = path.resolve(payloadPath!); + const payload = JSON.parse(fs.readFileSync(resolved, "utf8")) as ClipTempoPreviewData; + const wavePath = resolved.replace(/\.json$/i, ".wav"); + const wave = fs.readFileSync(wavePath); + expect(payload.error).toBeUndefined(); + expect(payload.wavBase64).toMatch(/^[A-Za-z0-9+/]+={0,2}$/); + expect(Buffer.from(payload.wavBase64!, "base64").equals(wave)).toBe(true); + let format = 0, channels = 0, sampleRate = 0, dataOffset = 0, dataBytes = 0; + for (let at = 12; at + 8 <= wave.length;) { + const id = wave.toString("ascii", at, at + 4), count = wave.readUInt32LE(at + 4); + if (id === "fmt ") { format = wave.readUInt16LE(at + 8); channels = wave.readUInt16LE(at + 10); sampleRate = wave.readUInt32LE(at + 12); } + if (id === "data") { dataOffset = at + 8; dataBytes = count; break; } + at += 8 + count + count % 2; + } + expect(format).toBe(3); expect(channels).toBe(payload.numChannels); expect(sampleRate).toBe(payload.sampleRate); + const frames = dataBytes / 4 / channels; + expect(frames).toBeGreaterThan(0); expect(frames / sampleRate).toBe(payload.durationSeconds); + const expected = Array.from({ length: channels }, (_, channel) => Array.from({ length: frames }, (_, frame) => wave.readFloatLE(dataOffset + (frame * channels + channel) * 4))); + await page.goto("/"); + const browser = await page.evaluate(async ({ payload, expected, sampleRate, frames, channels }) => { + const binary = atob(payload.wavBase64!), bytes = Uint8Array.from(binary, value => value.charCodeAt(0)); + const decoding = new OfflineAudioContext(channels, frames, sampleRate); + const decoded = await decoding.decodeAudioData(bytes.buffer); + let decodeError = 0, peak = 0, finite = true; + for (let channel = 0; channel < channels; channel++) { + const pcm = decoded.getChannelData(channel); + for (let frame = 0; frame < frames; frame++) { + decodeError = Math.max(decodeError, Math.abs(pcm[frame] - expected[channel][frame])); + peak = Math.max(peak, Math.abs(pcm[frame])); finite &&= Number.isFinite(pcm[frame]); + } + } + const moduleUrl = "/src/utils/tempoAudition.ts"; + const { TempoAuditionPlayer } = await import(/* @vite-ignore */ moduleUrl) as typeof import("../src/utils/tempoAudition"); + const render = async (clickLevel: number) => { + const context = new OfflineAudioContext(channels, Math.ceil((payload.durationSeconds! + 0.2) * sampleRate), sampleRate); + // This facade changes only lifecycle methods. All decoding, AudioNodes, + // parameter automation, clocks and rendering are the browser's real API. + const facade = new Proxy(context, { get(target, property) { + if (property === "state") return "running"; + if (property === "resume" || property === "close") return () => Promise.resolve(); + const value = Reflect.get(target, property, target) as unknown; + return typeof value === "function" ? value.bind(target) : value; + } }); + const player = new TempoAuditionPlayer(() => facade as unknown as AudioContext); + await player.play({ bpm: 137.23, phaseSeconds: 0.217, rangeStartSeconds: payload.rangeStartSeconds!, + durationSeconds: payload.durationSeconds!, wavBase64: payload.wavBase64, clickLevel }); + const output = await context.startRendering(); player.dispose(); return output; + }; + const source = await render(0), mixed = await render(0.2); + const gain = Math.min(0.65, 0.7 / Math.max(1e-8, peak)), leadFrames = Math.round(0.05 * sampleRate); + let sourceError = 0, preRollPeak = 0, outsideClickPeak = 0, differencePeak = 0; + const period = 60 / 137.23, phase = 0.217, start = payload.rangeStartSeconds!, duration = payload.durationSeconds!; + const clickTimes: number[] = []; + for (let beat = Math.ceil((start - phase) / period); phase + beat * period < start + duration; beat++) clickTimes.push(0.05 + phase + beat * period - start); + const clickEnergy = clickTimes.map(() => 0); + for (let channel = 0; channel < channels; channel++) { + const dry = source.getChannelData(channel), sum = mixed.getChannelData(channel); + for (let frame = 0; frame < source.length; frame++) { + const original = frame >= leadFrames && frame < leadFrames + frames ? expected[channel][frame - leadFrames] * gain : 0; + sourceError = Math.max(sourceError, Math.abs(dry[frame] - original)); + if (frame < leadFrames) preRollPeak = Math.max(preRollPeak, Math.abs(sum[frame])); + const difference = sum[frame] - dry[frame], seconds = frame / sampleRate; + differencePeak = Math.max(differencePeak, Math.abs(difference)); + const tick = clickTimes.findIndex(time => seconds >= time - 2 / sampleRate && seconds < time + 0.026); + if (tick >= 0) clickEnergy[tick] += difference * difference; + else outsideClickPeak = Math.max(outsideClickPeak, Math.abs(difference)); + } + } + return { decoded: { channels: decoded.numberOfChannels, sampleRate: decoded.sampleRate, frames: decoded.length }, + finite, decodeError, sourceError, preRollPeak, outsideClickPeak, differencePeak, clickTimes, clickEnergy, + output: "OfflineAudioContext only; no speaker output", subjectiveAudioQuality: "not_asserted" }; + }, { payload, expected, sampleRate, frames, channels }); + expect(browser.decoded).toEqual({ channels, sampleRate, frames }); + expect(browser.finite).toBe(true); expect(browser.decodeError).toBe(0); + expect(browser.sourceError).toBeLessThan(1e-6); expect(browser.preRollPeak).toBe(0); + expect(browser.outsideClickPeak).toBeLessThan(1e-7); expect(browser.differencePeak).toBeGreaterThan(0.01); + expect(browser.clickEnergy.length).toBeGreaterThan(0); expect(browser.clickEnergy.every(energy => energy > 1e-6)).toBe(true); + const output = path.resolve("../output/playwright/native-tempo-preview"); fs.mkdirSync(output, { recursive: true }); + fs.writeFileSync(path.join(output, "report.json"), JSON.stringify({ artifact: resolved, + nativeWavSha256: crypto.createHash("sha256").update(wave).digest("hex"), bytes: wave.length, + rangeStartSeconds: payload.rangeStartSeconds, sourceOffsetSeconds: payload.sourceOffsetSeconds, ...browser }, null, 2)); +}); diff --git a/frontend/e2e/packed-waveform-tiles.spec.ts b/frontend/e2e/packed-waveform-tiles.spec.ts new file mode 100644 index 00000000..e1402102 --- /dev/null +++ b/frontend/e2e/packed-waveform-tiles.spec.ts @@ -0,0 +1,152 @@ +import { expect, test, type Page } from "@playwright/test"; +import type { useDAWStore } from "../src/store/useDAWStore"; +import type {} from "./support/interactionFixtureTypes"; + +interface Request { file: string; spp: number; start: number; count: number; first: number; last: number } +declare global { + interface Window { + __packedWaveformQA: { + calls: Request[]; + legacyCalls: number; + hold: boolean; + pending: Array<{ resolve: (value: unknown) => void; payload: unknown }>; + listeners: Map void }>; + store: typeof useDAWStore; + invalidate: () => void; + }; + } +} + +async function ready(page: Page) { + await page.setViewportSize({ width: 1280, height: 900 }); + await page.addInitScript(() => { + localStorage.setItem("openstudio.inputProfiles.v1", JSON.stringify({ + schemaVersion: 1, keyboardProfileId: "openstudio", mouseProfileId: "openstudio", onboardingSeen: true, + })); + const listeners = new Map void }>(); + const qa = { calls: [] as Request[], legacyCalls: 0, listeners, hold: false, + pending: [] as Array<{ resolve: (value: unknown) => void; payload: unknown }>, + store: undefined as unknown as typeof useDAWStore, + invalidate: () => { + for (const item of listeners.values()) if (item.name === "peaksReady") item.callback({ filePath: "tiled.wav" }); + } }; + window.__packedWaveformQA = qa; + let listenerId = 0; + window.__JUCE__ = { backend: { + addTrack: async (id?: string) => id ?? "unused", + emitEvent: () => {}, + reportFrontendStartupState: async () => true, + addEventListener: (name: string, callback: (value: unknown) => void) => { + const token = String(++listenerId); + listeners.set(token, { name, callback }); return token; + }, + removeEventListener: (id: string) => { listeners.delete(id); }, + getWaveformPeaksPacked: async (file, spp, start, count, first = 0, last = -1) => { + qa.calls.push({ file, spp, start, count, first, last }); + const peaks = Math.max(0, Math.min(count, Math.ceil(((last < 0 ? 48000 * 120 : last) - start) / spp))); + const bytes = new Uint8Array(peaks * 2 * 2 * 4); + const view = new DataView(bytes.buffer); + for (let peak = 0; peak < peaks; peak++) { + for (let channel = 0; channel < 2; channel++) { + view.setFloat32((peak * 4 + channel * 2) * 4, -0.25 * (channel + 1), true); + view.setFloat32((peak * 4 + channel * 2 + 1) * 4, 0.25 * (channel + 1), true); + } + } + const payload = { format: "openstudio.waveform.f32", version: 1, byteOrder: "little", channels: 2, + peakCount: peaks, byteLength: bytes.length, data: btoa(String.fromCharCode(...bytes)) }; + return qa.hold ? new Promise((resolve) => qa.pending.push({ resolve, payload })) : payload; + }, + getWaveformPeaks: async () => { qa.legacyCalls++; throw new Error("Unexpected legacy waveform request"); }, + } }; + }); + await page.goto("/?platform=windows&windowChrome=native"); + await expect(page.getByText("Starting OpenStudio...", { exact: true })).toBeHidden(); + const source = await (await page.request.get("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/src/App.tsx")).text(); + const storeUrl = source.match(/from "([^"]*useDAWStore\.ts[^"]*)"/)?.[1]; + if (!storeUrl) throw new Error("Store module missing"); + await page.addScriptTag({ type: "module", content: ` + import { useDAWStore as store, createDefaultTrack } from ${JSON.stringify(storeUrl)}; + const qa = window.__packedWaveformQA; + qa.store = store; + const a = createDefaultTrack("tile-a", "Tiles A", "#4361ee", "audio", []); + const b = createDefaultTrack("tile-b", "Tiles B", "#4cc9f0", "audio", [a]); + const clip = { filePath: "tiled.wav", name: "Stereo source", startTime: 0, duration: 120, + offset: 0, color: "#4361ee", volumeDB: 0, fadeIn: 0, fadeOut: 0, sampleRate: 48000 }; + a.clips = [{ ...clip, id: "tile-clip-a" }]; + b.clips = [{ ...clip, id: "tile-clip-b" }]; + store.setState({ tracks: [a, b], selectedTrackId: a.id, selectedTrackIds: [a.id], selectedClipIds: [], + showMixer: false, showGettingStarted: false, showPianoRoll: false, showPitchEditor: false, + inputProfileOnboardingSeen: true, pixelsPerSecond: 100, scrollX: 0, scrollY: 0, + autoBackupEnabled: false, autoScrollDuringPlayback: false, projectRange: { start: 0, end: 120 }, + transport: { ...store.getState().transport, currentTime: 0, isPlaying: false, isRecording: false } }); + ` }); + await expect.poll(() => page.evaluate(() => window.__packedWaveformQA.calls.length)).toBeGreaterThan(0); +} + +test("packed waveform tiles are shared, remain stable during playback, and refetch after source invalidation", async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await ready(page); + await expect.poll(() => page.evaluate(() => { + const stage = window.Konva.stages.find((item) => item.height() > 50 && item.container().closest(".workspace")); + return stage?.find("Line").filter((node) => node instanceof window.Konva.Line && node.closed()).length ?? 0; + })).toBeGreaterThanOrEqual(4); + const before = await page.evaluate(() => window.__packedWaveformQA.calls.length); + await page.evaluate(() => { + const { store } = window.__packedWaveformQA; + store.setState({ transport: { ...store.getState().transport, currentTime: 1, isPlaying: true } }); + }); + // The old growing-prefix prefetch issued another full response each second. + await page.waitForTimeout(2300); + await page.evaluate(() => { + const { store } = window.__packedWaveformQA; + store.setState({ transport: { ...store.getState().transport, isPlaying: false } }); + }); + expect(await page.evaluate(() => window.__packedWaveformQA.calls.length)).toBe(before); + const calls = await page.evaluate(() => window.__packedWaveformQA.calls); + expect(calls.every((call) => call.count === 4096)).toBe(true); + expect(new Set(calls.map((call) => JSON.stringify(call))).size).toBe(calls.length); + expect(await page.evaluate(() => window.__packedWaveformQA.legacyCalls)).toBe(0); + await page.evaluate(() => window.__packedWaveformQA.invalidate()); + await expect.poll(() => page.evaluate(() => window.__packedWaveformQA.calls.length)).toBeGreaterThan(before); + // Exercise actual viewport geometry at another zoom without changing source bounds. + await page.evaluate(() => window.__packedWaveformQA.store.setState({ pixelsPerSecond: 500, scrollX: 12000 })); + await expect.poll(() => page.evaluate(() => window.__packedWaveformQA.calls.some((call) => call.spp < 256 && call.start > 0))).toBe(true); + expect(errors).toEqual([]); +}); + +test("late packed responses cannot publish a retired source generation or release a newer request", async ({ page }) => { + await ready(page); + await page.evaluate(() => { + const qa = window.__packedWaveformQA; + qa.hold = true; + qa.invalidate(); + }); + await expect.poll(() => page.evaluate(() => window.__packedWaveformQA.pending.length)).toBeGreaterThan(0); + const firstBatch = await page.evaluate(() => window.__packedWaveformQA.pending.length); + await page.evaluate(() => window.__packedWaveformQA.invalidate()); + await expect.poll(() => page.evaluate(() => window.__packedWaveformQA.pending.length)).toBeGreaterThan(firstBatch); + await page.evaluate((count) => { + for (const item of window.__packedWaveformQA.pending.splice(0, count)) item.resolve(item.payload); + }, firstBatch); + await expect.poll(() => page.evaluate(() => { + const stage = window.Konva.stages.find((item) => item.height() > 50 && item.container().closest(".workspace")); + return stage?.find("Line").filter((node) => node instanceof window.Konva.Line && node.closed() && node.opacity() === 0.6).length ?? 0; + })).toBe(0); + const heldCalls = await page.evaluate(() => window.__packedWaveformQA.calls.length); + await page.evaluate(() => { + const qa = window.__packedWaveformQA; + qa.store.setState({ scrollY: 1 }); + }); + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + expect(await page.evaluate(() => window.__packedWaveformQA.calls.length)).toBe(heldCalls); + await page.evaluate(() => { + const qa = window.__packedWaveformQA; + qa.hold = false; + for (const item of qa.pending.splice(0)) item.resolve(item.payload); + }); + await expect.poll(() => page.evaluate(() => { + const stage = window.Konva.stages.find((item) => item.height() > 50 && item.container().closest(".workspace")); + return stage?.find("Line").filter((node) => node instanceof window.Konva.Line && node.closed() && node.opacity() === 0.6).length ?? 0; + })).toBe(4); +}); diff --git a/frontend/e2e/prepared-recording-start.spec.ts b/frontend/e2e/prepared-recording-start.spec.ts new file mode 100644 index 00000000..4bd6b96b --- /dev/null +++ b/frontend/e2e/prepared-recording-start.spec.ts @@ -0,0 +1,53 @@ +import { expect, test } from "@playwright/test"; +import type { useDAWStore } from "../src/store/useDAWStore"; + +declare global { interface Window { __preparedRecordingQA: { store: typeof useDAWStore; + release: ((success: boolean) => void) | null; starts: number } } } + +test("record preparation keeps the playhead stopped and supports cancellation before native commit", async ({ page }) => { + await page.goto("/"); + await page.getByText("Starting OpenStudio...", { exact: true }).waitFor({ state: "hidden" }); + await page.evaluate(async () => { + const source = await (await fetch("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/src/App.tsx")).text(); + const storeUrl = source.match(/from "([^"]*useDAWStore\.ts[^"]*)"/)?.[1]; + if (!storeUrl) throw new Error("Store URL missing"); + const { useDAWStore: store, createDefaultTrack } = await import(/* @vite-ignore */ storeUrl) as typeof import("../src/store/useDAWStore"); + const bridgeUrl = "/src/services/NativeBridge.ts"; + const { nativeBridge } = await import(/* @vite-ignore */ bridgeUrl) as typeof import("../src/services/NativeBridge"); + const qa = window.__preparedRecordingQA = { store, release: null as ((success: boolean) => void) | null, starts: 0 }; + nativeBridge.startPreparedRecording = async () => { qa.starts++; return new Promise(resolve => { qa.release = resolve; }); }; + nativeBridge.addTrack = async id => id ?? "recording-ui-track"; + nativeBridge.setTransportPlaying = async () => true; + nativeBridge.setTransportRecording = async () => true; + nativeBridge.getLastCompletedClips = async () => []; + nativeBridge.getLastCompletedMIDIClips = async () => []; + const track = createDefaultTrack("recording-ui-track", "Input"); track.armed = true; + store.setState({ tracks: [track], showGettingStarted: false, inputProfileOnboardingSeen: true, autoBackupEnabled: false, + showMixer: false, syncClipsWithBackend: async () => {}, + transport: { ...store.getState().transport, currentTime: 17.25, isPlaying: false, isRecording: false, loopEnabled: false } }); + }); + const transport = page.getByRole("contentinfo", { name: "Transport controls" }); + await transport.getByRole("button", { name: "Record", exact: true }).click(); + await expect.poll(() => page.evaluate(() => window.__preparedRecordingQA.starts)).toBe(1); + await expect(transport.getByRole("status", { name: "Transport status: Preparing recording" })).toBeVisible(); + await expect(transport.getByRole("button", { name: "Play", exact: true })).toBeDisabled(); + await expect(transport.getByRole("button", { name: "Stop", exact: true })).toBeEnabled(); + await expect(page.getByRole("button", { name: "Cancel recording preparation" })).toHaveCount(2); + await page.waitForTimeout(350); + expect(await page.evaluate(() => { + const state = window.__preparedRecordingQA.store.getState(); + return { currentTime: state.transport.currentTime, playing: state.transport.isPlaying, + recording: state.transport.isRecording, clips: state.recordingClips.length }; + })).toEqual({ currentTime: 17.25, playing: false, recording: false, clips: 0 }); + await transport.getByRole("button", { name: "Stop", exact: true }).click(); + await page.evaluate(() => window.__preparedRecordingQA.release?.(true)); + await expect(transport.getByRole("status", { name: "Transport status: Stopped" })).toBeVisible(); + expect(await page.evaluate(() => window.__preparedRecordingQA.store.getState().transport.isRecording)).toBe(false); + + await transport.getByRole("button", { name: "Record", exact: true }).click(); + await expect.poll(() => page.evaluate(() => window.__preparedRecordingQA.starts)).toBe(2); + await page.evaluate(() => window.__preparedRecordingQA.release?.(true)); + await expect(transport.getByRole("status", { name: "Transport status: Recording" })).toBeVisible(); + expect(await page.evaluate(() => window.__preparedRecordingQA.store.getState().recordingClips)).toHaveLength(1); + await transport.getByRole("button", { name: "Stop", exact: true }).click(); +}); diff --git a/frontend/e2e/project-rate-copy.spec.ts b/frontend/e2e/project-rate-copy.spec.ts new file mode 100644 index 00000000..99161a50 --- /dev/null +++ b/frontend/e2e/project-rate-copy.spec.ts @@ -0,0 +1,66 @@ +import { expect, test } from "@playwright/test"; +import type { useDAWStore } from "../src/store/useDAWStore"; +import type { commandManager } from "../src/store/commands"; +import type { ProjectRateCopyResult } from "../src/services/NativeBridge"; + +declare global { interface Window { __rateCopyQA: { store: typeof useDAWStore; history: typeof commandManager; + resolve: ((result: ProjectRateCopyResult) => void) | null; cancelled: string[] } } } + +test("project-rate copies expose cancellation, preserve trim and support source Undo/Redo", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto("/"); + await page.getByText("Starting OpenStudio...", { exact: true }).waitFor({ state: "hidden" }); + await page.evaluate(async () => { + const source = await (await fetch("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/src/App.tsx")).text(); + const storeUrl = source.match(/from "([^"]*useDAWStore\.ts[^"]*)"/)?.[1]; + if (!storeUrl) throw new Error("App store module missing"); + const { useDAWStore: store, createDefaultTrack } = await import(/* @vite-ignore */ storeUrl) as typeof import("../src/store/useDAWStore"); + const bridgeUrl = "/src/services/NativeBridge.ts", historyUrl = "/src/store/commands/index.ts"; + const { nativeBridge } = await import(/* @vite-ignore */ bridgeUrl) as typeof import("../src/services/NativeBridge"); + const { commandManager: history } = await import(/* @vite-ignore */ historyUrl) as typeof import("../src/store/commands"); + const track = createDefaultTrack("rate-copy-track", "Recording"); + track.clips = [{ id: "rate-copy-clip", name: "48 kHz recording", filePath: "C:/source.wav", startTime: 0, + duration: 10, offset: 1, sourceLength: 12, sampleRate: 48000, color: "#4361ee", volumeDB: -3, fadeIn: 0.2, fadeOut: 0.4 }]; + const qa = window.__rateCopyQA = { store, history, resolve: null as ((value: ProjectRateCopyResult) => void) | null, cancelled: [] as string[] }; + nativeBridge.createProjectRateCopy = async () => await new Promise(resolve => { qa.resolve = resolve; }); + nativeBridge.cancelProjectRateCopy = async id => { qa.cancelled.push(id); qa.resolve?.({ success: false, cancelled: true }); return true; }; + nativeBridge.replacePlaybackClipSource = async () => true; + nativeBridge.refreshWaveformPeaks = async () => true; + nativeBridge.getWaveformPeaks = async () => []; + history.clear(); + store.setState({ tracks: [track], selectedClipId: "rate-copy-clip", selectedClipIds: ["rate-copy-clip"], + projectSampleRate: 44100, pixelsPerSecond: 100, scrollX: 0, scrollY: 0, trackHeight: 100, + showMixer: false, showGettingStarted: false, inputProfileOnboardingSeen: true, autoBackupEnabled: false, + showPitchEditor: false, globalLocked: false, lockSettings: { ...store.getState().lockSettings, items: false }, + syncClipsWithBackend: async () => {} }); + }); + const openClipMenu = async () => { + const point = await page.evaluate(() => { + const stage = window.Konva.stages.find(stage => stage.height() > 50 && stage.container().closest(".timeline-container"))!; + const box = stage.container().getBoundingClientRect(); return { x: box.x + 200, y: box.y + 45 }; + }); + await page.mouse.click(point.x, point.y, { button: "right" }); + await expect(page.getByText("Create 44.1 kHz Project-Rate Copy", { exact: true })).toBeVisible(); + await page.getByText("Create 44.1 kHz Project-Rate Copy", { exact: true }).click(); + }; + await openClipMenu(); + const cancel = page.getByRole("button", { name: "Cancel 44.1 kHz audio copy" }); + await expect(cancel).toBeVisible(); + await cancel.click(); + await expect(cancel).toBeHidden(); + expect(await page.evaluate(() => ({ path: window.__rateCopyQA.store.getState().tracks[0].clips[0].filePath, + cancelled: window.__rateCopyQA.cancelled.length, undo: window.__rateCopyQA.history.getUndoStack().length }))) + .toEqual({ path: "C:/source.wav", cancelled: 1, undo: 0 }); + await openClipMenu(); + await expect(cancel).toBeVisible(); + await page.evaluate(() => window.__rateCopyQA.resolve?.({ success: true, filePath: "C:/copy44100.wav", sampleRate: 44100, duration: 12, numChannels: 1 })); + await expect(cancel).toBeHidden(); + const clip = () => page.evaluate(() => window.__rateCopyQA.store.getState().tracks[0].clips[0]); + await expect.poll(async () => (await clip()).filePath).toBe("C:/copy44100.wav"); + expect(await clip()).toMatchObject({ originalFilePath: "C:/source.wav", sampleRate: 44100, offset: 1, startTime: 0, duration: 10, volumeDB: -3, fadeIn: 0.2, fadeOut: 0.4 }); + expect(await page.evaluate(() => window.__rateCopyQA.history.getUndoStack().length)).toBe(1); + await page.evaluate(() => window.__rateCopyQA.store.getState().undo()); + await expect.poll(async () => (await clip()).filePath).toBe("C:/source.wav"); + await page.evaluate(() => window.__rateCopyQA.store.getState().redo()); + await expect.poll(async () => (await clip()).filePath).toBe("C:/copy44100.wav"); +}); diff --git a/frontend/e2e/recording-zoom.spec.ts b/frontend/e2e/recording-zoom.spec.ts index cfa36266..8fae8c14 100644 --- a/frontend/e2e/recording-zoom.spec.ts +++ b/frontend/e2e/recording-zoom.spec.ts @@ -189,6 +189,16 @@ async function holdNextRequest(page: Page) { return page.evaluate(() => window.__recordingZoomIntegration.requests.length); } +async function expectBoundedPreviewProgress(page: Page, before: number, since: number) { + await expect.poll(() => page.evaluate(() => window.__recordingZoomIntegration.requests.length)).toBeGreaterThan(before); + const current = await page.evaluate(() => ({ + count: window.__recordingZoomIntegration.requests.length, + maxPending: window.__recordingZoomIntegration.maxPending, now: performance.now(), + })); + expect(current.count - before).toBeLessThanOrEqual(Math.ceil((current.now - since) / 250) * 2 + 4); + expect(current.maxPending).toBe(1); +} + async function dragRuler(page: Page, dy: number, dx = 0) { const bounds = await page.locator(".workspace-sticky-ruler canvas").first().boundingBox(); if (!bounds) throw new Error("Visible workspace ruler is missing"); @@ -213,6 +223,119 @@ test.afterEach(async ({ page }) => { await page.evaluate(() => window.__recordingZoomIntegration?.cleanup()); }); +test("live waveforms keep updating during sustained scrolling and modifier zoom", async ({ page }) => { + test.setTimeout(45000); + await ready(page); + await page.evaluate(() => { + const { store, createDefaultTrack } = window.__recordingZoomModules; + store.setState({ autoScrollDuringPlayback: false, + tracks: [...store.getState().tracks, ...Array.from({ length: 12 }, (_, i) => + createDefaultTrack(`live-scroll-${i}`, `Scroll ${i}`, "#4361ee", "audio", []))] }); + }); + await paint(page); + for (const modifier of [null, "Control", "Shift", "Alt"]) { + await page.evaluate(() => { + const store = window.__recordingZoomIntegration.store; + store.setState({ pixelsPerSecond: 80, scrollX: (store.getState().transport.currentTime - 2) * 80 }); + }); + await paint(page); + await page.mouse.move(850, 240); + const before = await page.evaluate(() => ({ count: window.__recordingZoomIntegration.requests.length, time: performance.now() })); + if (modifier) await page.keyboard.down(modifier); + try { + for (let n = 0; n < 32; n++) { + await page.mouse.wheel(0, n % 2 ? 20 : -20); + await page.evaluate(() => { + const store = window.__recordingZoomIntegration.store; + store.getState().setCurrentTime(store.getState().transport.currentTime + 0.05); + }); + await page.waitForTimeout(45); + } + // Inspect before releasing the gesture or its 200 ms idle debounce. + const during = await page.evaluate(start => { + const f = window.__recordingZoomIntegration; + return { requests: f.requests.slice(start.count), elapsed: performance.now()-start.time, + maxPending: f.maxPending, recording: f.store.getState().transport.isRecording }; + }, before); + for (const trackId of ["record-zoom-a", "record-zoom-b"]) + expect(during.requests.filter(request => request.trackId === trackId && request.completed).length, + `${modifier ?? "plain"} live data for ${trackId}`).toBeGreaterThan(1); + expect(during.requests.length).toBeLessThanOrEqual(Math.ceil(during.elapsed / 250) * 2 + 4); + expect(during.maxPending).toBe(1); + expect(during.recording).toBe(true); + } finally { + if (modifier) await page.keyboard.up(modifier); + } + } +}); + +test("native vertical scrolling shares the queued zoom publication and preserves the latest horizontal view", async ({ page }) => { + await ready(page); + await page.evaluate(() => { + const { store, createDefaultTrack } = window.__recordingZoomModules; + store.setState({ autoScrollDuringPlayback: false, + tracks: [...store.getState().tracks, ...Array.from({ length: 12 }, (_, i) => + createDefaultTrack(`scroll-${i}`, `Scroll ${i}`, "#4361ee", "audio", []))] }); + }); + await paint(page); + const before = await geometry(page); + const anchorX = 300; + const immediate = await page.locator(".timeline-container canvas").first().evaluate((element, anchor) => { + const store = window.__recordingZoomIntegration.store; + const workspace = element.closest(".workspace") as HTMLElement; + const bounds = element.getBoundingClientRect(); + // A view command may publish after React's last commit, immediately before + // native scrolling arrives. The scroll listener must read that newer view. + const latestX = store.getState().scrollX + 137; + store.setState({ scrollX: latestX }); + element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, + ctrlKey: true, deltaY: -80, clientX: bounds.left + anchor, clientY: bounds.top + 20 })); + workspace.scrollTop = 120; + workspace.dispatchEvent(new Event("scroll")); + workspace.scrollTop = 180; + workspace.dispatchEvent(new Event("scroll")); + return { latestX, scrollX: store.getState().scrollX, scrollY: store.getState().scrollY, + nativeY: workspace.scrollTop }; + }, anchorX); + expect(immediate.nativeY).toBe(180); + expect(immediate.scrollX).toBe(immediate.latestX); + expect(immediate.scrollY).toBe(0); + await paint(page); + const after = await geometry(page); + expect(after.zoom).toBeGreaterThan(before.zoom); + expect((after.scrollX + anchorX) / after.zoom) + .toBeCloseTo((immediate.latestX + anchorX) / before.zoom, 6); + expect(await page.evaluate(() => window.__recordingZoomIntegration.store.getState().scrollY)).toBe(180); +}); + +test("View menu fits the full project after track controls and modifier zoom during recording", async ({ page }) => { + await ready(page); + const before = await geometry(page); + const trackControls = page.getByRole("region", { name: "Track control panel", exact: true }); + await trackControls.getByRole("combobox").first().focus(); + await page.mouse.move(850, 240); + await page.keyboard.down("Control"); + await page.mouse.wheel(0, -60); + await page.keyboard.up("Control"); + await paint(page); + await page.getByRole("menuitem", { name: "View menu", exact: true }).click(); + await page.getByRole("menuitem", { name: "Zoom to Full Project", exact: true }).click(); + await expect.poll(() => geometry(page).then(value => value.zoom)).toBeLessThan(3); + await paint(page); + const after = await geometry(page); + expect(after.zoom).toBeLessThan(before.zoom); + expect(after.scrollX).toBe(0); + expect(after.recording).toBe(true); + expect(after.lines).toHaveLength(2); + expect(after.lines.every(line => line.pointCount >= 4)).toBe(true); + // Following is allowed to resume once the normal manual-navigation grace + // period ends; it must keep the requested project scale. + await page.waitForTimeout(300); + const resumed = await geometry(page); + expect(resumed.zoom).toBe(after.zoom); + expect(resumed.scrollX).toBeLessThan(resumed.width); +}); + test("long two-track recording keeps ruler zoom bounded, anchored and sample-aligned", async ({ page }, testInfo) => { await ready(page); const callsAtHold = await holdNextRequest(page); @@ -362,10 +485,11 @@ for (const termination of ["vertical zoom", "mouseup", "pointercancel", "blur"] const pressed = await geometry(page); const press = await page.evaluate(() => window.__recordingZoomIntegration.press!); expect(pressed.time * press.zoom - press.scrollX).toBeGreaterThan(pressed.width); - await page.evaluate(() => { + const releasedAt = await page.evaluate(() => { const fixture = window.__recordingZoomIntegration; fixture.hold = false; fixture.release(); + return performance.now(); }); await expect.poll(() => page.evaluate(() => window.__recordingZoomIntegration.pending)).toBe(0); // Wait for advancing transport, past the ordinary 200ms zoom idle period. @@ -373,7 +497,7 @@ for (const termination of ["vertical zoom", "mouseup", "pointercancel", "blur"] const pending = await geometry(page); expect(pending.zoom).toBe(press.zoom); expect(pending.scrollX).toBeCloseTo(press.scrollX, 6); - expect(await page.evaluate(() => window.__recordingZoomIntegration.requests.length)).toBe(callsAtHold); + await expectBoundedPreviewProgress(page, callsAtHold, releasedAt); if (termination === "vertical zoom") { await page.mouse.move(bounds.x + anchorX, bounds.y + 108); @@ -405,8 +529,41 @@ for (const termination of ["vertical zoom", "mouseup", "pointercancel", "blur"] }); } +test("long recording views keep ruler text separated and displayed grid work bounded", async ({ page }) => { + await ready(page); + for (const scenario of [ + { tempo: 154, numerator: 4, denominator: 4, scrollX: 0 }, + { tempo: 220, numerator: 7, denominator: 8, scrollX: 400000 }, + { tempo: 40, numerator: 3, denominator: 2, scrollX: 10000 }, + ]) { + await page.evaluate(scenario => { + const { store } = window.__recordingZoomIntegration; + store.setState({ pixelsPerSecond: 1, scrollX: scenario.scrollX, autoScrollDuringPlayback: false, + snapEnabled: true, gridSize: "1/16", timeSignature: { numerator: scenario.numerator, denominator: scenario.denominator }, + transport: { ...store.getState().transport, tempo: scenario.tempo } }); + }, scenario); + await paint(page); + const geometry = await page.evaluate(() => { + const ruler = window.Konva.stages.find(stage => stage.container().closest(".workspace-sticky-ruler")); + const timeline = window.Konva.stages.find(stage => stage.height() > 50 && stage.container().closest(".workspace")); + if (!ruler || !timeline) throw new Error("Timeline surfaces unavailable"); + const labels = ruler.find("Text").map(node => ({ text: node.getAttr("text") as string, + ...node.getClientRect({ relativeTo: ruler }) })).filter(box => /^\d+$/.test(box.text) && box.x >= 0 && box.x + box.width <= ruler.width()).sort((a, b) => a.x - b.x); + const gridLines = timeline.find("Line").filter(node => node instanceof window.Konva.Line + && !node.closed() && (node.stroke() === "#ffffff" || node.stroke() === "#10b981")).length; + return { labels, gridLines, width: timeline.width(), rulerTicks: ruler.find("Line").length }; + }); + expect(geometry.labels.length).toBeGreaterThan(3); + for (let index = 1; index < geometry.labels.length; index++) + expect(geometry.labels[index].x - (geometry.labels[index - 1].x + geometry.labels[index - 1].width)).toBeGreaterThanOrEqual(4); + expect(geometry.rulerTicks).toBeLessThanOrEqual(Math.ceil(geometry.width / 8) + 6); + expect(geometry.gridLines).toBeLessThanOrEqual(Math.ceil(geometry.width / 8 + geometry.width / 18) + 12); + expect(await page.evaluate(() => window.__recordingZoomIntegration.store.getState().transport.isRecording)).toBe(true); + } +}); + for (const termination of ["mouseup", "pointercancel", "blur"] as const) { - test(`held ruler zoom pauses follow and preview polling until ${termination}`, async ({ page }) => { + test(`held ruler zoom preserves its anchor and bounded live previews until ${termination}`, async ({ page }) => { await ready(page); const callsAtHold = await holdNextRequest(page); const bounds = await page.locator(".workspace-sticky-ruler canvas").first().boundingBox(); @@ -422,10 +579,11 @@ for (const termination of ["mouseup", "pointercancel", "blur"] as const) { expect(held.zoom).toBeGreaterThan(before.zoom); // Ensure follow would actually trigger if the held gesture were forgotten. expect(held.time * held.zoom - held.scrollX).toBeGreaterThan(held.width); - await page.evaluate(() => { + const releasedAt = await page.evaluate(() => { const fixture = window.__recordingZoomIntegration; fixture.hold = false; fixture.release(); + return performance.now(); }); await expect.poll(() => page.evaluate(() => window.__recordingZoomIntegration.pending)).toBe(0); await expect.poll(() => geometry(page).then(value => value.time)).toBeGreaterThan(held.time + 0.45); @@ -434,7 +592,7 @@ for (const termination of ["mouseup", "pointercancel", "blur"] as const) { expect(paused.scrollX).toBeCloseTo(held.scrollX, 6); expect((paused.scrollX + anchorX) / paused.zoom) .toBeCloseTo((before.scrollX + anchorX) / before.zoom, 6); - expect(await page.evaluate(() => window.__recordingZoomIntegration.requests.length)).toBe(callsAtHold); + await expectBoundedPreviewProgress(page, callsAtHold, releasedAt); if (termination === "mouseup") await page.mouse.up(); else { diff --git a/frontend/e2e/shared-project-qualification.spec.ts b/frontend/e2e/shared-project-qualification.spec.ts new file mode 100644 index 00000000..bc0862c8 --- /dev/null +++ b/frontend/e2e/shared-project-qualification.spec.ts @@ -0,0 +1,874 @@ +import { test, expect, type Page } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; +import crypto from "node:crypto"; +import type { AudioClip, MIDIClip, Track, useDAWStore } from "../src/store/useDAWStore"; +import type { commandManager } from "../src/store/commands"; +import type { WaveformPeak } from "../src/services/NativeBridge"; + +const originalPath = process.env.OPENSTUDIO_QUALIFICATION_PROJECT; +const artifactRoot = path.resolve("../output/playwright/shared-project-qualification"); +const sha256 = (data: Buffer) => crypto.createHash("sha256").update(data).digest("hex").toUpperCase(); +interface PeakFile { + source: string; sampleRate: number; channels: number; samples: number; + levels: Array<{ stride: number; count: number; data: Buffer }>; + sourceSize: number; sourceMtime: number; +} +interface Project { tracks: Track[]; tempo: number; timeSignature: { numerator: number; denominator: number }; projectRange: { start: number; end: number } } +interface PeakRequest { file: string; spp: number; start: number; count: number; rangeStart: number; rangeEnd: number } + +/** Read-only sidecar service. It does not use a native engine or synthetic peaks. */ +class SourcePeakService { + files = new Map(); + requests: PeakRequest[] = []; + boundaryFallbacks = new Set(); + waveInfo = new Map(); + constructor(sources: string[]) { + for (const source of sources) { + const sidecar = fs.readFileSync(`${source}.ospeaks`); + const stat = fs.statSync(source); + if (sidecar.readUInt32LE(0) !== 0x4f53504b || sidecar.readUInt32LE(4) !== 1) throw new Error(`Invalid peak cache: ${source}`); + const file: PeakFile = { source, sampleRate: sidecar.readDoubleLE(24), channels: sidecar.readInt32LE(32), + samples: Number(sidecar.readBigInt64LE(40)), sourceSize: Number(sidecar.readBigInt64LE(8)), + sourceMtime: Number(sidecar.readBigInt64LE(16)), levels: [] }; + if (stat.size !== file.sourceSize || Math.abs(Math.trunc(stat.mtimeMs) - file.sourceMtime) > 1) throw new Error(`Stale peak cache: ${source}`); + if (sidecar.readInt32LE(48) !== 4) throw new Error("Unexpected peak levels"); + let offset = 56; + for (const stride of [64, 256, 1024, 4096]) { + const count = sidecar.readInt32LE(offset + 4); + if (sidecar.readInt32LE(offset) !== stride || count !== Math.ceil(file.samples / stride)) throw new Error("Invalid peak level footprint"); + const bytes = count * file.channels * 8; + file.levels.push({ stride, count, data: sidecar.subarray(offset + 8, offset + 8 + bytes) }); + offset += bytes + 8; + } + if (offset !== sidecar.length) throw new Error("Unexpected peak file tail"); + this.files.set(source, file); + } + } + + rawBoundary(file: PeakFile, first: number, last: number): Array<{ min: number; max: number }> { + let info = this.waveInfo.get(file.source); + if (!info) { + const fd = fs.openSync(file.source, "r"); + const header = Buffer.alloc(12); + fs.readSync(fd, header, 0, 12, 0); + if (header.toString("ascii", 0, 4) !== "RIFF" || header.toString("ascii", 8, 12) !== "WAVE") { + fs.closeSync(fd); + this.boundaryFallbacks.add(file.source); + const level = file.levels[0]; + return Array.from({ length: file.channels }, (_, channel) => { + let min = Infinity, max = -Infinity; + for (let bucket = Math.floor(first / 64); bucket < Math.min(level.count, Math.ceil(last / 64)); bucket++) { + const offset = (bucket * file.channels * 2 + channel * 2) * 4; + min = Math.min(min, level.data.readFloatLE(offset)); max = Math.max(max, level.data.readFloatLE(offset + 4)); + } + return { min: Number.isFinite(min) ? min : 0, max: Number.isFinite(max) ? max : 0 }; + }); + } + let offset = 12, format = 0, bits = 0, frame = 0, data = 0; + while (offset + 8 <= file.sourceSize) { + const chunk = Buffer.alloc(8); fs.readSync(fd, chunk, 0, 8, offset); + const size = chunk.readUInt32LE(4), id = chunk.toString("ascii", 0, 4); + if (id === "fmt ") { + const fmt = Buffer.alloc(size); fs.readSync(fd, fmt, 0, size, offset + 8); + format = fmt.readUInt16LE(0); bits = fmt.readUInt16LE(14); frame = fmt.readUInt16LE(12); + if (format === 0xfffe && size >= 40) format = fmt.readUInt16LE(24); + } + if (id === "data") { data = offset + 8; break; } + offset += 8 + size + (size % 2); + } + if (!data || !frame || ![1, 3].includes(format)) throw new Error(`Unsupported WAV boundary format: ${file.source}`); + info = { fd, data, format, bits, frame }; this.waveInfo.set(file.source, info); + } + const bytes = Buffer.alloc((last - first) * info.frame); + fs.readSync(info.fd, bytes, 0, bytes.length, info.data + first * info.frame); + return Array.from({ length: file.channels }, (_, channel) => { + let min = Infinity, max = -Infinity; + for (let sample = 0; sample < last - first; sample++) { + const offset = sample * info!.frame + channel * info!.bits / 8; + const value = info!.format === 3 ? bytes.readFloatLE(offset) + : info!.bits === 16 ? bytes.readInt16LE(offset) / 32768 + : info!.bits === 24 ? bytes.readIntLE(offset, 3) / 8388608 + : info!.bits === 32 ? bytes.readInt32LE(offset) / 2147483648 : (bytes.readUInt8(offset) - 128) / 128; + min = Math.min(min, value); max = Math.max(max, value); + } + return { min: Number.isFinite(min) ? min : 0, max: Number.isFinite(max) ? max : 0 }; + }); + } + + query(request: PeakRequest, log = true): WaveformPeak[] { + if (log) this.requests.push(request); + const file = this.files.get(request.file); + if (!file) throw new Error(`Unlisted source requested: ${request.file}`); + const { spp, start, count } = request; + const rangeStart = Math.max(0, request.rangeStart), rangeEnd = request.rangeEnd < 0 ? file.samples : Math.min(file.samples, request.rangeEnd); + if (start >= rangeEnd) return []; + const eligible = file.levels.filter(candidate => candidate.stride <= spp); + const level = eligible[eligible.length - 1] ?? file.levels[0]; + const edges = new Map>(); + return Array.from({ length: Math.max(0, Math.min(count, Math.ceil((rangeEnd - start) / spp))) }, (_, pixel) => { + const first = Math.max(start + pixel * spp, rangeStart), last = Math.min(start + (pixel + 1) * spp, rangeEnd); + const channels = Array.from({ length: file.channels }, () => ({ min: Infinity, max: -Infinity })); + for (let bucket = Math.floor(first / level.stride); first < last && bucket < Math.min(level.count, Math.ceil(last / level.stride)); bucket++) { + const bucketFirst = bucket * level.stride, bucketLast = Math.min(file.samples, bucketFirst + level.stride); + const retainedFirst = Math.max(bucketFirst, rangeStart), retainedLast = Math.min(bucketLast, rangeEnd); + let edge: Array<{ min: number; max: number }> | undefined; + if (retainedFirst !== bucketFirst || retainedLast !== bucketLast) { + edge = edges.get(bucket); + if (!edge) { edge = this.rawBoundary(file, retainedFirst, retainedLast); edges.set(bucket, edge); } + } + channels.forEach((channel, index) => { + const offset = (bucket * file.channels * 2 + index * 2) * 4; + channel.min = Math.min(channel.min, edge ? edge[index].min : level.data.readFloatLE(offset)); + channel.max = Math.max(channel.max, edge ? edge[index].max : level.data.readFloatLE(offset + 4)); + }); + } + return { channels: channels.map(channel => ({ min: Number.isFinite(channel.min) ? channel.min : 0, max: Number.isFinite(channel.max) ? channel.max : 0 })) }; + }); + } + close() { for (const { fd } of this.waveInfo.values()) fs.closeSync(fd); } +} + +declare global { + interface Window { + __sharedProjectQA: { store: typeof useDAWStore; history: typeof commandManager; holdSync: boolean; releaseSync: (() => void) | null }; + __readSharedSourcePeaks: (request: PeakRequest) => Promise; + __writeNormalizedSharedProject: (value: Record) => Promise; + __reportSharedProjectWarning: (value: Record) => Promise; + } +} + +async function paint(page: Page) { await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); } + +test("actual project source-backed waveform and MIDI geometry, trim and undo", async ({ page }, testInfo) => { + test.skip(!originalPath, "Set OPENSTUDIO_QUALIFICATION_PROJECT to explicitly opt into a local project."); + // Actual seven-minute source files and 42 geometry cases precede the live + // profile. Keep their read/trace cost outside the profile's measured phases. + test.setTimeout(process.env.OPENSTUDIO_PROFILE_RECORDING === "1" ? 420_000 : 240_000); + fs.mkdirSync(artifactRoot, { recursive: true }); + const original = fs.readFileSync(originalPath!); + const beforeHash = sha256(original); + const copyPath = path.join(artifactRoot, "input-copy.osproj"); + fs.writeFileSync(copyPath, original); + const project = JSON.parse(original.toString("utf8").replace(/^\uFEFF/, "")) as Project; + const sources = [...new Set(project.tracks.flatMap(track => track.clips.map(clip => clip.filePath)))]; + const peaks = new SourcePeakService(sources); + const report: Record = { scope: "Actual saved project data rendered by real Timeline in an isolated headless browser; bridge mutations mocked, peak data read from real source sidecars and WAV boundary samples.", + projectPath: originalPath, copyPath, originalSha256Before: beforeHash, copySha256: sha256(fs.readFileSync(copyPath)), + tracks: project.tracks.length, audioClips: project.tracks.reduce((sum, track) => sum + track.clips.length, 0), + midiClips: project.tracks.reduce((sum, track) => sum + track.midiClips.length, 0), + sourceFiles: [...peaks.files.values()].map(file => ({ path: file.source, sampleRate: file.sampleRate, channels: file.channels, samples: file.samples, peakLevels: file.levels.map(level => ({ stride: level.stride, count: level.count })) })), + audioQuality: "not_asserted", nativePlayback: "not_asserted", nativePeakService: "not_asserted", frameTimes: "diagnostic_only" }; + const checks: Record[] = []; + const pageErrors: string[] = []; + const viteMessages: string[] = []; + page.on("console", message => { if (message.text().includes("[vite]")) viteMessages.push(message.text()); }); + const reactWarnings: Record[] = []; + page.on("pageerror", error => pageErrors.push(error.message)); + try { + await page.exposeFunction("__readSharedSourcePeaks", (request: PeakRequest) => peaks.query(request)); + await page.exposeFunction("__writeNormalizedSharedProject", (value: Record) => { + const normalizedPath = path.resolve("../output/shared-project-qualification/frontend-normalized.json"); + fs.mkdirSync(path.dirname(normalizedPath), { recursive: true }); + fs.writeFileSync(normalizedPath, JSON.stringify(value, null, 2)); + report.normalizedFrontendStatePath = normalizedPath; + }); + await page.exposeFunction("__reportSharedProjectWarning", (value: Record) => { + reactWarnings.push(value); + fs.writeFileSync(path.join(artifactRoot, "react-warnings.json"), JSON.stringify(reactWarnings, null, 2)); + }); + await page.addInitScript(() => { + const original = console.error.bind(console); + console.error = (...args: unknown[]) => { + if (args.some(value => String(value).includes("Maximum update depth"))) { + const state = window.__sharedProjectQA?.store.getState(); + void window.__reportSharedProjectWarning({ message: args.map(String), stack: new Error("React update-depth warning capture").stack, + phase: state?.projectLoadingMessage, loading: state?.isProjectLoading, loadedTracks: state?.tracks.length }); + } + original(...args); + }; + }); + await page.setViewportSize({ width: 1600, height: 1000 }); + // This fixture mounts the production Timeline itself below. Starting App as + // a second, hidden root would duplicate native polling and timeline work, + // invalidating the timing diagnostic and the single-in-flight assertions. + await page.route(/\/src\/main\.tsx(?:\?.*)?$/, route => route.fulfill({ + contentType: "application/javascript", + body: 'import "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/src/index.css";', + })); + await page.goto("/"); + await expect(page.locator("#root")).toBeEmpty(); + const normalized = await page.evaluate(async ({ saved, copyPath, sourceProjectSha256 }) => { + const componentUrl = "/src/components/Timeline.tsx"; + const { Timeline } = await import(/* @vite-ignore */ componentUrl) as typeof import("../src/components/Timeline"); + const source = await (await fetch(componentUrl)).text(); + const storeUrl = source.match(/from "([^"]*useDAWStore\.ts[^"]*)"/)?.[1]; + if (!storeUrl) throw new Error("Timeline store import missing"); + const { useDAWStore: store } = await import(/* @vite-ignore */ storeUrl) as typeof import("../src/store/useDAWStore"); + const bridgeUrl = "/src/services/NativeBridge.ts"; + const { nativeBridge } = await import(/* @vite-ignore */ bridgeUrl) as typeof import("../src/services/NativeBridge"); + const commandUrl = "/src/store/commands/index.ts"; + const { commandManager: history } = await import(/* @vite-ignore */ commandUrl) as typeof import("../src/store/commands"); + const qa = window.__sharedProjectQA = { store, history, holdSync: false, releaseSync: null as (() => void) | null }; + const reactUrl = "/node_modules/.vite/deps/react.js", domUrl = "/node_modules/.vite/deps/react-dom_client.js"; + const reactModule = await import(/* @vite-ignore */ reactUrl) as typeof import("react") & { default?: typeof import("react") }; + const React = reactModule.default || reactModule; + const rootModule = await import(/* @vite-ignore */ domUrl) as typeof import("react-dom/client") & { default?: typeof import("react-dom/client") }; + const { createRoot } = rootModule.default || rootModule; + // Real project normalization; only the native boundary is mocked. No MIDI + // input device or file writer is invoked by this browser qualification. + nativeBridge.loadProjectFromFile = async () => JSON.stringify(saved); + nativeBridge.openMIDIDevice = async () => true; + nativeBridge.saveProjectToFile = async () => { throw new Error("Read-only qualification forbids saving"); }; + store.setState({ autoBackupEnabled: false, isModified: false }); + const loaded = await store.getState().loadProject(copyPath, { bypassFX: true, recoveryCopy: true }); + if (!loaded) throw new Error(`Actual frontend loadProject failed: ${store.getState().projectRestoreError}`); + const midiUrl = "/src/utils/midiClipSerialization.ts"; + const { serializeMIDIClipsForBackend } = await import(/* @vite-ignore */ midiUrl) as typeof import("../src/utils/midiClipSerialization"); + const state = store.getState(); + const normalized = JSON.parse(JSON.stringify({ sourceProjectSha256, + tracks: state.tracks, + backendMIDIClipsByTrackId: Object.fromEntries(state.tracks.map(track => [track.id, serializeMIDIClipsForBackend(track.midiClips, track.midiEffects || [])])), + tempo: state.transport.tempo, timeSignature: state.timeSignature, + processingPrecision: state.processingPrecision, masterVolume: state.masterVolume, + masterPan: state.masterPan, masterTrimVolumeDB: state.masterTrimVolumeDB, + isMasterMuted: state.isMasterMuted, masterMono: state.masterMono, + qualification: { loader: "useDAWStore.loadProject", bypassFX: true, recoveryCopy: true, + nativeWrites: "mocked", midiDevices: "not_opened", saveFiles: "forbidden", + isProjectLoading: state.isProjectLoading, projectRestoreError: state.projectRestoreError }, + })); + await window.__writeNormalizedSharedProject(normalized); + nativeBridge.getWaveformPeaks = (file, spp, start, count, rangeStart = 0, rangeEnd = -1) => window.__readSharedSourcePeaks({ file, spp, start, count, rangeStart, rangeEnd }); + history.clear(); + store.setState({ projectName: "READ-ONLY qualification copy", projectPath: null, + showGettingStarted: false, inputProfileOnboardingSeen: true, autoBackupEnabled: false, + showMixer: false, showPianoRoll: false, showPitchEditor: false, showClipLauncher: false, + canUndo: false, canRedo: false, isModified: false, trackHeight: 100, + timeSignature: saved.timeSignature, projectRange: saved.projectRange, + pixelsPerSecond: 100, scrollX: 0, scrollY: 0, snapEnabled: false, selectedClipIds: [], selectedClipId: null, + mouseBehaviorProfileId: "openstudio", mouseModifiers: {}, globalLocked: false, + syncClipsWithBackend: async () => { if (qa.holdSync) await new Promise(resolve => { qa.releaseSync = resolve; }); }, + transport: { ...store.getState().transport, tempo: saved.tempo, currentTime: 0, isPlaying: false, isRecording: false } }); + const host = document.createElement("div"); host.id = "shared-project-timeline"; host.className = "workspace"; + Object.assign(host.style, { position: "fixed", inset: "0", zIndex: "1", display: "flex", background: "#121212" }); + document.body.append(host); + const root = createRoot(host); + const render = () => root.render(React.createElement(Timeline, { tracks: store.getState().tracks, showRuler: true })); + store.subscribe((state, previous) => { if (state.tracks !== previous.tracks) render(); }); render(); + return normalized; + }, { saved: project, copyPath, sourceProjectSha256: beforeHash.toLowerCase() }); + const normalizedPath = path.resolve("../output/shared-project-qualification/frontend-normalized.json"); + fs.mkdirSync(path.dirname(normalizedPath), { recursive: true }); + fs.writeFileSync(normalizedPath, JSON.stringify(normalized, null, 2)); + report.normalizedFrontendStatePath = normalizedPath; + project.tracks = normalized.tracks; + await expect(page.locator("#shared-project-timeline canvas").last()).toBeVisible(); + + const geometry = async (trackIndex: number, clip: AudioClip) => page.evaluate(({ trackIndex, clip }) => { + const { store } = window.__sharedProjectQA; + const state = store.getState(); + const stage = window.Konva.stages.find(candidate => candidate.container().closest("#shared-project-timeline") && candidate.height() > 100)!; + const x = clip.startTime * state.pixelsPerSecond - state.scrollX, width = clip.duration * state.pixelsPerSecond; + const lines = stage.find("Line").filter((node): node is InstanceType => node instanceof window.Konva.Line).filter(line => { + if (line.opacity() !== 0.6 || !line.closed() || line.strokeWidth() !== 1) return false; + const rect = line.getParent()?.findOne("Rect"); + return rect && Math.abs(rect.x() - x) < 0.001 && Math.abs(rect.width() - width) < 0.001 + && rect.y() >= trackIndex * 100 && rect.y() < (trackIndex + 1) * 100; + }).map(line => ({ points: line.points(), box: line.getParent()!.findOne("Rect")!.getAttrs() })); + return { zoom: state.pixelsPerSecond, scroll: state.scrollX, stageWidth: stage.width(), lines }; + }, { trackIndex, clip }); + + if (process.env.OPENSTUDIO_PROFILE_ONLY !== "1") { + // One completed source spans almost seven minutes, making source-origin + // drift measurable at fractional zoom and late timeline positions. + const trackIndex = 6, clip = project.tracks[trackIndex].clips[0]; + const file = peaks.files.get(clip.filePath)!; + for (const zoom of [1, 7.25, 34.784, 100, 333.333, 997.9]) { + for (const time of [0, 80.125, 300.375]) { + await page.evaluate(({ zoom, time, trackIndex }) => window.__sharedProjectQA.store.setState({ + pixelsPerSecond: zoom, scrollX: time * zoom, scrollY: trackIndex * 100, + }), { zoom, time, trackIndex }); + await paint(page); + await expect.poll(async () => (await geometry(trackIndex, clip)).lines.length, { timeout: 15_000 }).toBeGreaterThan(0); + await page.waitForTimeout(280); // Shared production zoom idle/refinement boundary. + const view = await geometry(trackIndex, clip); + const line = view.lines[0], points = line.points as number[]; + const topCount = points.length / 4; + expect(points[0]).toBeGreaterThanOrEqual(-1); + expect(points[0]).toBeLessThanOrEqual(1); + const expectedRight = Math.min(view.stageWidth, (clip.startTime + clip.duration - time) * zoom); + expect(points[(topCount - 1) * 2]).toBeGreaterThanOrEqual(expectedRight - 2); + const spp = Math.max(64, 2 ** Math.round(Math.log2(file.sampleRate / zoom))); + const aligned = Math.floor(((clip.offset ?? 0) + time - clip.startTime) * file.sampleRate / (4096 * spp)) * 4096 * spp; + const sourceRangeStart = time === 0 && (clip.offset ?? 0) > 0 ? Math.round((clip.offset ?? 0) * file.sampleRate) : 0; + const sourceRangeEnd = expectedRight < view.stageWidth ? Math.round(((clip.offset ?? 0) + clip.duration) * file.sampleRate) : -1; + const expected = peaks.query({ file: clip.filePath, spp, start: Math.max(0, aligned), count: Math.ceil(view.stageWidth * file.sampleRate / zoom / spp) + 4098, rangeStart: sourceRangeStart, rangeEnd: sourceRangeEnd }, false); + const box = line.box as { y: number; height: number }; + const waveHeight = Math.max(8, box.height - 10), channelHeight = waveHeight / file.channels; + const center = box.y + 5 + channelHeight / 2, halfHeight = channelHeight / 2 - 2; + let maxError = 0; + for (const index of [0, Math.floor(topCount / 4), Math.floor(topCount / 2), Math.max(0, topCount - 2)]) { + const px = points[index * 2]; + const sample = ((clip.offset ?? 0) + (px + time * zoom) / zoom - clip.startTime) * file.sampleRate; + const bucket = Math.floor((sample - Math.max(0, aligned)) / spp); + const endBucket = Math.ceil((sample + file.sampleRate / zoom - Math.max(0, aligned)) / spp); + let max = expected[bucket]?.channels[0]?.max ?? 0; + for (let bin = bucket + 1; bin < endBucket; bin++) max = Math.max(max, expected[bin]?.channels[0]?.max ?? max); + const gain = 10 ** ((clip.volumeDB ?? 0) / 20) * (project.tracks[trackIndex].waveformZoom ?? 1); + const y = center - Math.max(-1, Math.min(1, max * gain)) * halfHeight; + maxError = Math.max(maxError, Math.abs(y - points[index * 2 + 1])); + } + expect(maxError, `real-source waveform projection at ${zoom}px/s, ${time}s`).toBeLessThan(0.001); + checks.push({ check: "real_waveform_projection", zoom, viewportStartSeconds: time, drawnPoints: points.length / 2, firstX: points[0], rightX: points[(topCount - 1) * 2], maxErrorPixels: maxError, status: "pass" }); + } + } + + await page.screenshot({ path: path.join(artifactRoot, "fractional-zoom-late-source.png") }); + + for (const midiTrackIndex of [2, 30]) { + for (const zoom of [7.25, 34.784, 333.333, 997.9]) { + for (const time of [70.125, 190.375, 380.125]) { + await page.evaluate(({ zoom, time, midiTrackIndex }) => window.__sharedProjectQA.store.setState({ + pixelsPerSecond: zoom, scrollX: time * zoom, scrollY: midiTrackIndex * 100, + }), { zoom, time, midiTrackIndex }); + await paint(page); + const midiGeometry = await page.evaluate(async ({ midiTrackIndex }) => { + const state = window.__sharedProjectQA.store.getState(); + const track = state.tracks[midiTrackIndex]; + const stage = window.Konva.stages.find(candidate => candidate.container().closest("#shared-project-timeline") && candidate.height() > 100)!; + const sourceUrl = "/src/utils/midiClipSerialization.ts"; + const { getVisibleMIDIEventsForClip } = await import(/* @vite-ignore */ sourceUrl) as typeof import("../src/utils/midiClipSerialization"); + let rectangles = 0, clipsChecked = 0, maxError = 0; + for (const clip of track.midiClips) { + const x = clip.startTime * state.pixelsPerSecond - state.scrollX, width = clip.duration * state.pixelsPerSecond; + if (x + width < 0 || x > stage.width()) continue; + const group = stage.find("Group").filter((node): node is InstanceType => node instanceof window.Konva.Group).find(candidate => { + const background = candidate.getChildren().find(child => child.getClassName() === "Rect"); + return background && Math.abs(background.x() - x) < 0.001 && Math.abs(background.width() - width) < 0.001 + && background.y() >= midiTrackIndex * 100 && background.y() < (midiTrackIndex + 1) * 100; + }); + if (!group) throw new Error(`Missing actual MIDI clip group: ${clip.id}`); + const background = group.getChildren().find(child => child.getClassName() === "Rect")!; + const bars: Array<{ note: number; start: number; end: number }> = []; + const pending = new Map>(); + for (const event of getVisibleMIDIEventsForClip(clip)) { + if (event.note === undefined) continue; + const key = `${event.channel ?? 1}:${event.note}`; + if (event.type === "noteOn" && (event.velocity ?? 1) > 0) { + const bar = { note: event.note, start: event.timestamp, end: clip.duration }; + bars.push(bar); const stack = pending.get(key) ?? []; stack.push(bar); pending.set(key, stack); + } else if (event.type === "noteOff" || (event.type === "noteOn" && event.velocity === 0)) { + const bar = pending.get(key)?.shift(); if (bar) bar.end = Math.max(bar.start, event.timestamp); + } + } + const min = Math.min(...bars.map(bar => bar.note)), max = Math.max(...bars.map(bar => bar.note)); + const range = Math.max(1, max - min), previewHeight = Math.max(8, background.height() - 10); + const visible = bars.filter(bar => bar.end >= Math.max(0, -x / state.pixelsPerSecond) + && bar.start <= Math.min(clip.duration, (stage.width() - x) / state.pixelsPerSecond)); + const expected = visible.map(bar => ({ x: x + bar.start * state.pixelsPerSecond, + y: background.y() + 5 + (max - bar.note) / range * (previewHeight - 4), + width: Math.max(width < 60 ? 1 : 2, (bar.end - bar.start) * state.pixelsPerSecond) })); + const notes = group.getChildren().filter(child => child.getClassName() === "Rect" && child.height() <= 4.01 + && [0.4, 0.45, 0.8, 0.9].includes(child.opacity())); + if (visible.length && notes.length === 0) throw new Error(`No actual MIDI notes drawn: ${clip.id}`); + if (notes.length > Math.max(64, Math.floor(Math.min(stage.width(), width) / 4))) throw new Error("MIDI node budget exceeded"); + for (const note of notes) { + const nearest = Math.min(...expected.map(bar => Math.max(Math.abs(bar.x - note.x()), Math.abs(bar.y - note.y()), Math.abs(bar.width - note.width())))); + maxError = Math.max(maxError, nearest); + } + rectangles += notes.length; clipsChecked++; + } + return { rectangles, clipsChecked, maxErrorPixels: maxError }; + }, { midiTrackIndex }); + expect(midiGeometry.maxErrorPixels).toBeLessThan(0.001); + checks.push({ check: "actual_midi_thumbnail_geometry", trackIndex: midiTrackIndex, zoom, viewportStartSeconds: time, ...midiGeometry, status: "pass" }); + } + } + } + + // Exercise the actual recorded clip's edge with a deliberately pending + // mocked bridge sync, then prove release and Undo semantics independently. + const trimTrackIndex = 0, trimClip = project.tracks[0].clips[0]; + await page.evaluate(() => window.__sharedProjectQA.store.setState({ pixelsPerSecond: 20, scrollX: 50 * 20, scrollY: 0 })); + await paint(page); + await expect.poll(async () => (await geometry(trimTrackIndex, trimClip)).lines.length).toBeGreaterThan(0); + const trimView = await geometry(trimTrackIndex, trimClip); + const trimBox = trimView.lines[0].box as { x: number; y: number; width: number; height: number }; + const stageBox = await page.locator("#shared-project-timeline .konvajs-content").last().boundingBox(); + if (!stageBox) throw new Error("Actual timeline stage bounds unavailable"); + await page.evaluate(() => { window.__sharedProjectQA.history.clear(); window.__sharedProjectQA.holdSync = true; }); + const px = stageBox.x + trimBox.x + trimBox.width - 2, py = stageBox.y + trimBox.y + trimBox.height / 2; + await page.mouse.move(px, py); await page.mouse.down(); await page.mouse.move(px - 80, py, { steps: 6 }); await page.mouse.up(); + await expect.poll(() => page.evaluate(() => Boolean(window.__sharedProjectQA.releaseSync))).toBe(true); + const releasedDuration = await page.evaluate(id => window.__sharedProjectQA.store.getState().tracks[0].clips.find(clip => clip.id === id)!.duration, trimClip.id); + expect(releasedDuration).toBeCloseTo(trimClip.duration - 4, 7); + await page.mouse.move(px - 180, py, { steps: 5 }); + expect(await page.evaluate(id => window.__sharedProjectQA.store.getState().tracks[0].clips.find(clip => clip.id === id)!.duration, trimClip.id)).toBe(releasedDuration); + await page.evaluate(() => { const qa = window.__sharedProjectQA; qa.holdSync = false; qa.releaseSync?.(); }); + await paint(page); + expect(await page.evaluate(() => window.__sharedProjectQA.history.getUndoStack().length)).toBe(1); + await page.evaluate(() => window.__sharedProjectQA.store.getState().undo()); + expect(await page.evaluate(id => window.__sharedProjectQA.store.getState().tracks[0].clips.find(clip => clip.id === id), trimClip.id)).toEqual(trimClip); + checks.push({ check: "actual_recording_trim_release_and_single_undo", clipId: trimClip.id, originalDuration: trimClip.duration, releasedDuration, status: "pass" }); + + const midiAnalysis = await page.evaluate(async () => { + const state = window.__sharedProjectQA.store.getState(); + const analysisUrl = "/src/utils/midiTempoAnalysis.ts", midiUrl = "/src/utils/midiClipSerialization.ts"; + const { analyzeMIDIClipTempo } = await import(/* @vite-ignore */ analysisUrl) as typeof import("../src/utils/midiTempoAnalysis"); + const { serializeMIDIClipsForBackend } = await import(/* @vite-ignore */ midiUrl) as typeof import("../src/utils/midiClipSerialization"); + const diagnostics = state.tracks.flatMap(track => track.midiClips.map(clip => ({ trackId: track.id, clipId: clip.id, + clipName: clip.name, result: analyzeMIDIClipTempo(clip), classification: "diagnostic_only" }))); + const track = state.tracks[2], selected = track.midiClips.slice(0, 2); + const absolute = (clips: MIDIClip[]) => serializeMIDIClipsForBackend(clips, track.midiEffects || []).flatMap(clip => clip.events.map(event => ({ + ...event, timestamp: Math.round((clip.startTime + event.timestamp) * 1e8) / 1e8, + }))).sort((a, b) => a.timestamp - b.timestamp || (a.channel ?? 1) - (b.channel ?? 1) || (a.note ?? -1) - (b.note ?? -1) || a.type.localeCompare(b.type)); + const before = absolute(selected), original = JSON.parse(JSON.stringify(track.midiClips)); + window.__sharedProjectQA.history.clear(); + state.selectClip(selected[0].id); + window.__sharedProjectQA.store.setState({ selectedClipIds: selected.map(clip => clip.id), selectedClipId: selected[0].id }); + const joined = window.__sharedProjectQA.store.getState().joinSelectedMIDIClips(); + const afterTrack = window.__sharedProjectQA.store.getState().tracks[2]; + const joinedClip = afterTrack.midiClips.find(clip => clip.id === selected[0].id)!; + const after = absolute([joinedClip]); + const undoSteps = window.__sharedProjectQA.history.getUndoStack().length; + window.__sharedProjectQA.store.getState().undo(); + const restored = window.__sharedProjectQA.store.getState().tracks[2].midiClips; + return { diagnostics, join: { applied: joined, beforeEvents: before.length, afterEvents: after.length, + serializedAbsoluteParity: JSON.stringify(before) === JSON.stringify(after), undoSteps, + undoRestoredOriginalClips: JSON.stringify(original) === JSON.stringify(restored) } }; + }); + report.midiTempoDiagnostics = midiAnalysis.diagnostics; + report.midiJoin = midiAnalysis.join; + expect(midiAnalysis.diagnostics.length).toBe(9); + expect(midiAnalysis.join.applied).toBeTruthy(); + expect(midiAnalysis.join.serializedAbsoluteParity).toBe(true); + expect(midiAnalysis.join.undoSteps).toBe(1); + expect(midiAnalysis.join.undoRestoredOriginalClips).toBe(true); + checks.push({ check: "actual_midi_join_serialized_absolute_parity_and_undo", ...midiAnalysis.join, status: "pass" }); + } + if (process.env.OPENSTUDIO_PROFILE_RECORDING === "1") { + let profiler = ["1", "wheel"].includes(process.env.OPENSTUDIO_PROFILE_CPU || "") ? await page.context().newCDPSession(page) : null; + if (profiler) { await profiler.send("Profiler.enable"); if (process.env.OPENSTUDIO_PROFILE_CPU === "1") await profiler.send("Profiler.start"); } + const source = project.tracks[6].clips[0].filePath; + const sourceFile = peaks.files.get(source)!; + const sampleRate = sourceFile.sampleRate; + const sourcePeakLevels = sourceFile.levels.filter(level => level.stride >= 256) + .map(level => ({ stride: level.stride, count: level.count, base64: level.data.toString("base64") })); + const setup = await page.evaluate(async ({ sampleRate, sourcePeakLevels, sourceChannels, sourceSamples }) => { + const bridgeUrl = "/src/services/NativeBridge.ts"; + const { nativeBridge } = await import(/* @vite-ignore */ bridgeUrl) as typeof import("../src/services/NativeBridge"); + const { store } = window.__sharedProjectQA; + const state = store.getState(); + // Preload actual validated mipmaps before measuring. Sending every live + // result through Playwright's exposedFunction adds CDP object-walking + // cost that is unrelated to the production WebView/native bridge. + const levels = sourcePeakLevels.map(level => ({ ...level, + values: new Float32Array(Uint8Array.from(atob(level.base64), character => character.charCodeAt(0)).buffer) })); + const audio = state.tracks[6], midi = state.tracks[2]; + // Reuse this song's retained events four times to represent a long take. + const originalEvents = midi.midiClips.flatMap(clip => clip.events.map(event => ({ ...event, timestamp: event.timestamp + clip.startTime }))); + const history = Array.from({ length: 4 }, (_, index) => originalEvents.map(event => ({ ...event, timestamp: event.timestamp / 4 + index * 100 }))).flat() + .sort((first, second) => first.timestamp - second.timestamp); + const perf = { pending: 0, maximumPending: 0, peakRequests: 0, maximumPeakPayload: 0, midiRequests: 0, + midiPending: 0, maximumMIDIPending: 0, maximumMIDIDeltaEvents: 0, + transitions: [] as Record[], + phase: "idle", frames: [] as { phase: string; milliseconds: number; recording: boolean; midiRequests: number }[], tasks: [] as { phase: string; milliseconds: number }[], + stop: false, previous: performance.now(), raf: 0 }; + Object.assign(window, { __sharedProjectPerformance: perf }); + store.subscribe((next, previous) => { + if (next.recordingClips !== previous.recordingClips || next.transport.isRecording !== previous.transport.isRecording) + perf.transitions.push({ phase: perf.phase, recording: next.transport.isRecording, clips: next.recordingClips.length, + stack: new Error("Recording fixture transition").stack }); + }); + const observer = new PerformanceObserver(list => list.getEntries().forEach(entry => perf.tasks.push({ phase: perf.phase, milliseconds: entry.duration }))); + observer.observe({ type: "longtask", buffered: false }); + nativeBridge.getRecordingPeaks = async (_track, spp, count, start = 0) => { + perf.maximumPending = Math.max(perf.maximumPending, ++perf.pending); ++perf.peakRequests; + try { + await new Promise(resolve => setTimeout(resolve, 4)); + const sourceStart = start % Math.floor(400 * sampleRate); + const eligible = levels.filter(level => level.stride <= spp); + const level = eligible[eligible.length - 1] ?? levels[0]; + const value = Array.from({ length: Math.max(0, Math.min(count, Math.ceil((sourceSamples - sourceStart) / spp))) }, (_, pixel) => { + const first = sourceStart + pixel * spp, last = Math.min(sourceSamples, first + spp); + const channels = Array.from({ length: sourceChannels }, () => ({ min: Infinity, max: -Infinity })); + for (let bin = Math.floor(first / level.stride); bin < Math.min(level.count, Math.ceil(last / level.stride)); bin++) { + for (let channel = 0; channel < sourceChannels; channel++) { + const offset = (bin * sourceChannels + channel) * 2; + channels[channel].min = Math.min(channels[channel].min, level.values[offset]); + channels[channel].max = Math.max(channels[channel].max, level.values[offset + 1]); + } + } + return { channels: channels.map(channel => ({ min: Number.isFinite(channel.min) ? channel.min : 0, max: Number.isFinite(channel.max) ? channel.max : 0 })) }; + }); + perf.maximumPeakPayload = Math.max(perf.maximumPeakPayload, value.length); return value; + } finally { --perf.pending; } + }; + nativeBridge.getActiveRecordingMIDIPreviews = async requests => { + ++perf.midiRequests; perf.maximumMIDIPending = Math.max(perf.maximumMIDIPending, ++perf.midiPending); + try { + await new Promise(resolve => setTimeout(resolve, 4)); + const result = requests.map(request => ({ trackId: request.trackId, generation: 1, recordingStartTime: 0, + totalEventCount: history.length, deltaEvents: request.knownEventCount ? [] : history, activeNotes: [] })); + perf.maximumMIDIDeltaEvents = Math.max(perf.maximumMIDIDeltaEvents, ...result.map(value => value.deltaEvents.length)); + return result; + } finally { --perf.midiPending; } + }; + const device = state.audioDeviceSetup ?? { deviceType: "Qualification mock (no device opened)", + inputDevice: "", outputDevice: "", sampleRate, bufferSize: 8, + numInputChannels: sourceChannels, numOutputChannels: sourceChannels }; + store.setState({ scrollY: 100, scrollX: 300 * 7.25, pixelsPerSecond: 7.25, autoScrollDuringPlayback: false, + recordingClips: [], recordingMIDIPreviews: {}, audioDeviceSetup: { ...device, sampleRate }, + projectRange: { start: 0, end: 1800 }, + transport: { ...state.transport, currentTime: 390, isRecording: false, isPlaying: false } }); + const tick = (now: number) => { + if (perf.stop) { observer.disconnect(); return; } + perf.frames.push({ phase: perf.phase, milliseconds: now - perf.previous, + recording: store.getState().transport.isRecording, midiRequests: perf.midiRequests }); perf.previous = now; + if (perf.phase !== "idle") { + const current = store.getState(); + store.setState({ transport: { ...current.transport, currentTime: current.transport.currentTime + 1 / 60 } }); + } + perf.raf = requestAnimationFrame(tick); + }; + perf.raf = requestAnimationFrame(tick); + return { audioTrackId: audio.id, midiTrackId: midi.id, retainedMIDIEvents: history.length }; + }, { sampleRate, sourcePeakLevels, sourceChannels: sourceFile.channels, sourceSamples: sourceFile.samples }); + await page.waitForTimeout(6000); + await page.evaluate(({ audioTrackId, midiTrackId }) => { + const perf = (window as unknown as { __sharedProjectPerformance: { phase: string } }).__sharedProjectPerformance; + perf.phase = "recording-start"; + const { store } = window.__sharedProjectQA, state = store.getState(); + store.setState({ recordingClips: [{ trackId: audioTrackId, startTime: 0 }, { trackId: midiTrackId, startTime: 0 }], + recordSession: { id: "project-profile", startTime: 0, trackIds: [audioTrackId, midiTrackId] }, + transport: { ...state.transport, isRecording: true, isPlaying: true } }); + }, setup); + await page.waitForTimeout(3000); + await page.evaluate(() => { (window as unknown as { __sharedProjectPerformance: { phase: string } }).__sharedProjectPerformance.phase = "recording-steady"; }); + await page.waitForTimeout(9000); + const menuLatencies: number[] = []; + const checkMenu = async () => { + await page.evaluate(() => { + const timing = { start: 0, visible: 0 }; + Object.assign(window, { __sharedProjectMenuTiming: timing }); + document.addEventListener("contextmenu", () => { + timing.start = performance.now(); + const observer = new MutationObserver(() => { + if (!document.querySelector('[data-context-menu="true"]')) return; + observer.disconnect(); + requestAnimationFrame(() => { timing.visible = performance.now(); }); + }); + observer.observe(document.body, { childList: true, subtree: true }); + }, { once: true, capture: true }); + }); + await page.mouse.click(780, 450, { button: "right" }); + await expect(page.locator('[data-context-menu="true"]').first()).toBeVisible(); + await paint(page); + const elapsed = await page.evaluate(() => { + const timing = (window as unknown as { __sharedProjectMenuTiming: { start: number; visible: number } }).__sharedProjectMenuTiming; + return timing.visible - timing.start; + }); + expect(elapsed).toBeGreaterThan(0); + menuLatencies.push(elapsed); + await page.keyboard.press("Escape"); + await expect(page.locator('[data-context-menu="true"]')).toHaveCount(0); + }; + await checkMenu(); + if (profiler && process.env.OPENSTUDIO_PROFILE_CPU === "wheel") await profiler.send("Profiler.start"); + await page.mouse.move(780, 450); + for (const modifier of [null, "Control", "Shift", "Alt"] as const) { + await page.evaluate(modifier => { + (window as unknown as { __sharedProjectPerformance: { phase: string } }).__sharedProjectPerformance.phase = `recording-wheel-${modifier ?? "plain"}`; + }, modifier); + if (modifier) await page.keyboard.down(modifier); + for (let index = 0; index < 16; index++) { await page.mouse.wheel(0, index < 8 ? -24 : 24); await page.waitForTimeout(55); } + if (modifier) await page.keyboard.up(modifier); + } + await page.waitForTimeout(1500); + if (profiler && process.env.OPENSTUDIO_PROFILE_CPU === "wheel") { + const { profile } = await profiler.send("Profiler.stop"); + fs.writeFileSync(path.join(artifactRoot, "recording-wheel-cpu-profile.json"), JSON.stringify(profile)); + await profiler.detach(); profiler = null; + } + await checkMenu(); + // Fixed zooms complement modifier gestures: cover both the minimum zoom + // and nearly sample-level drawing while the same long take is growing. + for (const zoom of [1, 34.784, 100, 333.333, 997.9]) { + await page.evaluate(zoom => { + (window as unknown as { __sharedProjectPerformance: { phase: string } }).__sharedProjectPerformance.phase = `recording-zoom-${zoom}-change`; + window.__sharedProjectQA.store.setState({ pixelsPerSecond: zoom, scrollX: 380 * zoom, scrollY: 100, trackHeight: 100 }); + }, zoom); + await page.waitForTimeout(1000); + await page.evaluate(zoom => { + (window as unknown as { __sharedProjectPerformance: { phase: string } }).__sharedProjectPerformance.phase = `recording-zoom-${zoom}-steady`; + }, zoom); + await page.waitForTimeout(2000); + } + await checkMenu(); + const measurement = await page.evaluate(() => { + const perf = (window as unknown as { __sharedProjectPerformance: { stop: boolean; pending: number; maximumPending: number; peakRequests: number; maximumPeakPayload: number; midiRequests: number; maximumMIDIPending: number; maximumMIDIDeltaEvents: number; transitions: Record[]; frames: { phase: string; milliseconds: number; recording: boolean; midiRequests: number }[]; tasks: { phase: string; milliseconds: number }[] } }).__sharedProjectPerformance; + perf.stop = true; + const state = window.__sharedProjectQA.store.getState(); + const stage = window.Konva.stages.find(candidate => candidate.container().closest("#shared-project-timeline") && candidate.height() > 100)!; + const canvasBudget = { lineNodes: stage.find("Line").length, batchedMuteHatches: stage.find(".clip-mute-hatch").length, + livePreviewGroups: stage.find(".recording-preview").length }; + window.__sharedProjectQA.store.setState({ recordingClips: [], recordSession: null, transport: { ...state.transport, isRecording: false, isPlaying: false } }); + return { canvasBudget, transitions: perf.transitions, + finalView: { recordingClipCount: state.recordingClips.length, recordingPreviewTrackIds: Object.keys(state.recordingMIDIPreviews), + trackHeight: state.trackHeight, scrollY: state.scrollY, stageHeight: stage.height() }, + maximumPending: perf.maximumPending, peakRequests: perf.peakRequests, maximumPeakPayload: perf.maximumPeakPayload, midiRequests: perf.midiRequests, + maximumMIDIPending: perf.maximumMIDIPending, maximumMIDIDeltaEvents: perf.maximumMIDIDeltaEvents, + phases: [...new Set(perf.frames.map(frame => frame.phase))].map(phase => { + const samples = perf.frames.filter(frame => frame.phase === phase); + const values = perf.frames.filter(frame => frame.phase === phase).map(frame => frame.milliseconds).slice(2).sort((a, b) => a - b); + return { phase, frames: values.length, p95Milliseconds: values[Math.floor(values.length * .95)], p99Milliseconds: values[Math.floor(values.length * .99)], maximumMilliseconds: values[values.length - 1], + recordingFrames: samples.filter(frame => frame.recording).length, samples: samples.length, + midiRequestsDuringPhase: samples[samples.length - 1].midiRequests - samples[0].midiRequests, + longTasks: perf.tasks.filter(task => task.phase === phase) }; + }) }; + }); + report.recordingPerformance = { ...setup, ...measurement, menuVisibleLatenciesMilliseconds: menuLatencies, status: "diagnostic_only", scope: "Production Timeline, actual normalized 31-track project and validated real peak mipmaps preloaded before timing; paced transport and4ms async live native boundary simulated in-page to exclude Playwright CDP serialization; headless Chromium timings, no second App root." }; + if (profiler) { + const { profile } = await profiler.send("Profiler.stop"); + fs.writeFileSync(path.join(artifactRoot, "recording-cpu-profile.json"), JSON.stringify(profile)); + await profiler.detach(); + } + fs.writeFileSync(path.join(artifactRoot, "recording-performance.json"), JSON.stringify(report.recordingPerformance, null, 2)); + expect(Math.max(...menuLatencies)).toBeLessThan(1000); + expect(measurement.maximumPending).toBeLessThanOrEqual(1); + expect(measurement.maximumMIDIPending).toBeLessThanOrEqual(1); + expect(measurement.canvasBudget.batchedMuteHatches).toBeGreaterThan(0); + expect(measurement.canvasBudget.lineNodes).toBeLessThan(2000); + expect(measurement.canvasBudget.livePreviewGroups).toBe(2); + expect(measurement.phases.filter(phase => phase.phase !== "idle").every(phase => phase.recordingFrames === phase.samples)).toBe(true); + expect(measurement.phases.find(phase => phase.phase === "recording-steady")!.midiRequestsDuringPhase).toBeGreaterThan(0); + expect(measurement.maximumPeakPayload).toBeLessThanOrEqual(8192); + expect(measurement.peakRequests).toBeGreaterThan(0); + expect(measurement.midiRequests).toBeGreaterThan(0); + expect(measurement.phases.every(phase => phase.frames > (phase.phase.includes("-change") || phase.phase.includes("-wheel") ? 0 : 30))).toBe(true); + checks.push({ check: "actual_project_recording_bounded_work", status: "pass", maximumPending: measurement.maximumPending, maximumPeakPayload: measurement.maximumPeakPayload }); + } + report.checks = checks; + report.status = "pass"; + } catch (error) { + report.status = "fail"; + report.error = error instanceof Error ? error.stack : String(error); + throw error; + } finally { + report.checks = checks; + report.pageErrors = pageErrors; + report.viteMessages = viteMessages; + report.reactWarnings = reactWarnings; + report.sourcePeakRequests = peaks.requests; + report.lossyBoundaryFallbacks = [...peaks.boundaryFallbacks]; + report.originalSha256After = sha256(fs.readFileSync(originalPath!)); + report.originalUnchanged = report.originalSha256After === beforeHash; + report.sourcesMetadataUnchanged = [...peaks.files.values()].every(file => { + const stat = fs.statSync(file.source); return stat.size === file.sourceSize && Math.abs(Math.trunc(stat.mtimeMs) - file.sourceMtime) <= 1; + }); + peaks.close(); + fs.writeFileSync(path.join(artifactRoot, process.env.OPENSTUDIO_PROFILE_ONLY === "1" ? "recording-profile-only-report.json" : "report.json"), JSON.stringify(report, null, 2)); + await testInfo.attach("source-backed-project-report", { body: JSON.stringify(report, null, 2), contentType: "application/json" }); + expect(report.originalUnchanged).toBe(true); + expect(report.sourcesMetadataUnchanged).toBe(true); + } +}); + +test("actual project load scheduling diagnostic with mocked IPC macrotasks", async ({ page }, testInfo) => { + test.skip(!originalPath, "Set OPENSTUDIO_QUALIFICATION_PROJECT to explicitly opt into a local project."); + test.setTimeout(90_000); + fs.mkdirSync(artifactRoot, { recursive: true }); + const original = fs.readFileSync(originalPath!), beforeHash = sha256(original); + const saved = JSON.parse(original.toString("utf8").replace(/^\uFEFF/, "")); + const warnings: Record[] = []; + const results: Record[] = []; + await page.exposeFunction("__recordIpcWarning", (value: Record) => warnings.push(value)); + await page.addInitScript(() => { + const original = console.error.bind(console); + console.error = (...args: unknown[]) => { + if (args.some(value => String(value).includes("Maximum update depth"))) { + const state = window.__sharedProjectQA?.store.getState(); + const bridge = window as unknown as { __recordIpcWarning: (value: Record) => Promise }; + void bridge.__recordIpcWarning({ message: args.map(String), stack: new Error("IPC scheduling diagnostic warning").stack, + phase: state?.projectLoadingMessage, loading: state?.isProjectLoading, tracks: state?.tracks.length }); + } + original(...args); + }; + }); + try { + for (const mode of ["instant", "macrotask"] as const) { + const warningsBefore = warnings.length; + await page.goto("/"); + await page.getByText("Starting OpenStudio...", { exact: true }).waitFor({ state: "hidden" }); + const result = await page.evaluate(async ({ saved, mode }) => { + const source = await (await fetch("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/src/App.tsx")).text(); + const storeUrl = source.match(/from "([^"]*useDAWStore\.ts[^"]*)"/)?.[1]; + if (!storeUrl) throw new Error("App store import missing"); + const { useDAWStore: store } = await import(/* @vite-ignore */ storeUrl) as typeof import("../src/store/useDAWStore"); + const commandUrl = "/src/store/commands/index.ts", bridgeUrl = "/src/services/NativeBridge.ts"; + const { commandManager: history } = await import(/* @vite-ignore */ commandUrl) as typeof import("../src/store/commands"); + const { nativeBridge } = await import(/* @vite-ignore */ bridgeUrl) as typeof import("../src/services/NativeBridge"); + window.__sharedProjectQA = { store, history, holdSync: false, releaseSync: null }; + const wrapped: string[] = []; + if (mode === "macrotask") { + for (const name of Object.getOwnPropertyNames(Object.getPrototypeOf(nativeBridge))) { + const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(nativeBridge), name); + if (!/^(set|add|remove|replace|clear|reset|panic|open|close)/.test(name) || typeof descriptor?.value !== "function") continue; + const original = descriptor.value as (...args: unknown[]) => unknown; + Object.defineProperty(nativeBridge, name, { configurable: true, value: async (...args: unknown[]) => { + await new Promise(resolve => setTimeout(resolve, 0)); + return Reflect.apply(original, nativeBridge, args); + } }); + wrapped.push(name); + } + } + nativeBridge.loadProjectFromFile = async () => { + if (mode === "macrotask") await new Promise(resolve => setTimeout(resolve, 0)); + return JSON.stringify(saved); + }; + nativeBridge.openMIDIDevice = async () => true; + nativeBridge.getWaveformPeaks = async () => []; + nativeBridge.saveProjectToFile = async () => { throw new Error("Read-only qualification forbids saving"); }; + store.setState({ autoBackupEnabled: false, inputProfileOnboardingSeen: true, showGettingStarted: false, isModified: false }); + const started = performance.now(); + const loaded = await store.getState().loadProject("qualification-memory-copy.osproj", { bypassFX: true, recoveryCopy: true }); + const completed = performance.now(); + const state = store.getState(); + return { mode, loaded, tracks: state.tracks.length, isProjectLoading: state.isProjectLoading, + projectRestoreError: state.projectRestoreError, loadMilliseconds: completed - started, + wrappedMethods: wrapped, scope: "Headless full App; native methods mocked, waveform service returns pending/empty, MIDI devices unopened; timing diagnostic_only" }; + }, { saved, mode }); + const warningsAfterLoad = warnings.length; + await page.waitForTimeout(2200); // Observe two AudioDeviceStatus timer ticks after the load. + results.push({ ...result, loadWarnings: warningsAfterLoad - warningsBefore, + idleWarnings: warnings.length - warningsAfterLoad, warnings: warnings.slice(warningsBefore) }); + expect(result.loaded).toBe(true); + expect(result.tracks).toBe(31); + expect(result.projectRestoreError).toBe(""); + expect(warnings.slice(warningsBefore)).toEqual([]); + } + } finally { + const report = { status: "diagnostic_only", sourceProjectSha256: beforeHash, results, + originalUnchanged: sha256(fs.readFileSync(originalPath!)) === beforeHash }; + fs.writeFileSync(path.join(artifactRoot, "load-scheduling-diagnostic.json"), JSON.stringify(report, null, 2)); + await testInfo.attach("load-scheduling-diagnostic", { body: JSON.stringify(report, null, 2), contentType: "application/json" }); + expect(report.originalUnchanged).toBe(true); + } +}); + + +test("long muted clips keep hatch artwork bounded and anchored while zooming", async ({ page }, testInfo) => { + await page.setViewportSize({ width: 1280, height: 700 }); + await page.route(/\/src\/main\.tsx(?:\?.*)?$/, route => route.fulfill({ + contentType: "application/javascript", body: 'import "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/src/index.css";', + })); + await page.goto("/"); + await page.evaluate(async () => { + const componentUrl = "/src/components/Timeline.tsx"; + const { Timeline } = await import(/* @vite-ignore */ componentUrl) as typeof import("../src/components/Timeline"); + const source = await (await fetch(componentUrl)).text(); + const storeUrl = source.match(/from "([^"]*useDAWStore\.ts[^"]*)"/)?.[1]; + if (!storeUrl) throw new Error("Timeline store module missing"); + const { useDAWStore: store, createDefaultTrack } = await import(/* @vite-ignore */ storeUrl) as typeof import("../src/store/useDAWStore"); + const commandUrl = "/src/store/commands/index.ts"; + const { commandManager: history } = await import(/* @vite-ignore */ commandUrl) as typeof import("../src/store/commands"); + window.__sharedProjectQA = { store, history, holdSync: false, releaseSync: null }; + const track = createDefaultTrack("muted-hatch-qa", "Long muted recording"); + track.clips = [{ id: "long-muted", name: "Long muted recording", filePath: "", color: "#00cc88", + startTime: 0, duration: 1800, offset: 0, sampleRate: 48000, volumeDB: 0, fadeIn: 0, fadeOut: 0, muted: true }]; + const tracks = Array.from({ length: 60 }, (_, index) => createDefaultTrack(`viewport-${index}`, `Row ${index + 1}`)); + tracks[0] = track; + tracks[40].clips = [{ ...track.clips[0], id: "deep-clip", name: "Scrolled drag", startTime: 700.2, duration: 0.8 }]; + store.setState({ tracks, trackHeight: 100, pixelsPerSecond: 1000, scrollX: 700125, scrollY: 0, + selectedClipIds: [], selectedClipId: null, autoBackupEnabled: false, + snapEnabled: false, mouseBehaviorProfileId: "openstudio", globalLocked: false, syncClipsWithBackend: async () => {}, + transport: { ...store.getState().transport, currentTime: 0, isPlaying: false, isRecording: false } }); + const reactUrl = "/node_modules/.vite/deps/react.js", domUrl = "/node_modules/.vite/deps/react-dom_client.js"; + const reactModule = await import(/* @vite-ignore */ reactUrl) as typeof import("react") & { default?: typeof import("react") }; + const React = reactModule.default || reactModule; + const domModule = await import(/* @vite-ignore */ domUrl) as typeof import("react-dom/client") & { default?: typeof import("react-dom/client") }; + const { createRoot } = domModule.default || domModule; + const host = document.createElement("div"); host.className = "workspace"; host.id = "shared-project-timeline"; + Object.assign(host.style, { position: "fixed", inset: "0", display: "flex" }); document.body.append(host); + const root = createRoot(host); + const render = () => root.render(React.createElement(Timeline, { tracks: store.getState().tracks, showRuler: true })); + store.subscribe((next, previous) => { if (next.tracks !== previous.tracks) render(); }); render(); + }); + const checks = []; + for (const zoom of [1, 34.784, 333.333, 1000]) { + await page.evaluate(zoom => window.__sharedProjectQA.store.setState({ pixelsPerSecond: zoom, scrollX: 700.125 * zoom }), zoom); + await paint(page); + const artwork = await page.evaluate(() => { + const state = window.__sharedProjectQA.store.getState(); + const stage = window.Konva.stages.find(candidate => candidate.height() > 100)!; + const hatches = stage.find(".clip-mute-hatch"); + const origins: number[] = []; + const shape = hatches[0] as unknown as { sceneFunc: () => (context: unknown, shape: unknown) => void }; + if (shape) shape.sceneFunc()({ beginPath() {}, moveTo(x: number) { origins.push(x); }, lineTo() {}, strokeShape() {} }, shape); + return { zoom: state.pixelsPerSecond, clipX: -state.scrollX, width: stage.width(), height: stage.height(), + hatches: hatches.length, lineNodes: stage.find("Line").length, origins }; + }); + expect(artwork.hatches).toBe(1); + expect(artwork.height).toBeLessThanOrEqual(700); + expect(artwork.lineNodes).toBeLessThan(1000); + expect(artwork.origins.length).toBeGreaterThan(1); + expect(artwork.origins.length).toBeLessThan(Math.ceil((artwork.width + 100) / 12) + 3); + for (const origin of artwork.origins) { + expect((origin - artwork.clipX) / 12).toBeCloseTo(Math.round((origin - artwork.clipX) / 12), 6); + expect(origin).toBeGreaterThanOrEqual(-12); + expect(origin).toBeLessThanOrEqual(artwork.width + 112); + } + checks.push(artwork); + } + // The canvas stays in the visible workspace while its layers project deep + // world rows. Exercise a real pointer drag there, then one atomic Undo. + await page.evaluate(() => { + window.__sharedProjectQA.history.clear(); + document.querySelector("#shared-project-timeline")!.scrollTop = 4000; + }); + await expect.poll(() => page.evaluate(() => window.__sharedProjectQA.store.getState().scrollY)).toBe(4000); + await paint(page); + const viewport = await page.locator(".timeline-canvas-viewport").boundingBox(); + if (!viewport) throw new Error("Sticky timeline viewport unavailable"); + expect(viewport.y).toBeGreaterThanOrEqual(0); + const sourceX = viewport.x + 475, sourceY = viewport.y + 70; + await page.mouse.move(sourceX, sourceY); await page.mouse.down(); + await page.mouse.move(sourceX + 40, sourceY + 100, { steps: 7 }); await page.mouse.up(); + await expect.poll(() => page.evaluate(() => window.__sharedProjectQA.store.getState().tracks[41].clips[0]?.id)).toBe("deep-clip"); + const moved = await page.evaluate(() => ({ clip: window.__sharedProjectQA.store.getState().tracks[41].clips[0], + undo: window.__sharedProjectQA.history.getUndoStack().length })); + expect(moved.clip.startTime).toBeCloseTo(700.24, 5); + expect(moved.undo).toBe(1); + await page.evaluate(() => window.__sharedProjectQA.store.getState().undo()); + expect(await page.evaluate(() => window.__sharedProjectQA.store.getState().tracks[40].clips[0]?.startTime)).toBe(700.2); + expect(await page.evaluate(() => window.__sharedProjectQA.store.getState().tracks[41].clips.length)).toBe(0); + await page.evaluate(() => { + const { store, history } = window.__sharedProjectQA; + store.setState({ tracks: store.getState().tracks.map((track, index) => index === 40 ? { + ...track, clips: track.clips.map(clip => ({ ...clip, fadeIn: 0.1, fadeOut: 0.1 })), + } : track), selectedClipIds: ["deep-clip"], selectedClipId: "deep-clip" }); + history.clear(); + }); + await paint(page); + await page.mouse.move(viewport.x + 175, viewport.y + 9); await page.mouse.down(); + await page.mouse.move(viewport.x + 225, viewport.y + 9, { steps: 6 }); await page.mouse.up(); + await expect.poll(() => page.evaluate(() => window.__sharedProjectQA.store.getState().tracks[40].clips[0].fadeIn)).toBeCloseTo(0.15, 4); + expect(await page.evaluate(() => window.__sharedProjectQA.history.getUndoStack().length)).toBe(1); + await page.evaluate(() => window.__sharedProjectQA.store.getState().undo()); + expect(await page.evaluate(() => window.__sharedProjectQA.store.getState().tracks[40].clips[0].fadeIn)).toBe(0.1); + await page.mouse.move(viewport.x + 30, viewport.y + 20); await page.mouse.down(); + await page.mouse.move(viewport.x + 930, viewport.y + 100, { steps: 6 }); + const marquee = await page.evaluate(() => { + const stage = window.Konva.stages.find(candidate => candidate.container().closest("#shared-project-timeline") && candidate.height() > 100)!; + const node = stage.findOne(".timeline-marquee"); + return node ? { position: node.getAbsolutePosition(), attrs: node.getAttrs() } : null; + }); + expect(marquee).not.toBeNull(); + expect(marquee!.position.x).toBeCloseTo(30, 1); + expect(marquee!.position.y).toBeCloseTo(20, 1); + expect(marquee!.attrs.width).toBeCloseTo(900, 1); + expect(marquee!.attrs.height).toBeCloseTo(80, 1); + await page.mouse.up(); + expect(await page.evaluate(() => window.__sharedProjectQA.store.getState().selectedClipIds)).toContain("deep-clip"); + await testInfo.attach("bounded-muted-hatch-geometry", { body: JSON.stringify(checks), contentType: "application/json" }); +}); diff --git a/frontend/e2e/tempo-detection.spec.ts b/frontend/e2e/tempo-detection.spec.ts new file mode 100644 index 00000000..4b43889f --- /dev/null +++ b/frontend/e2e/tempo-detection.spec.ts @@ -0,0 +1,214 @@ +import { expect, test, type Page } from "@playwright/test"; +import type { useDAWStore } from "../src/store/useDAWStore"; +import type { commandManager } from "../src/store/commands"; +import type { ClipTempoAnalysis } from "../src/services/NativeBridge"; + +declare global { + interface Window { + __tempoUiQA: { + store: typeof useDAWStore; + history: typeof commandManager; + pending: Array<(result: ClipTempoAnalysis) => void>; + ranges: Array<{ start?: number; duration?: number }>; + previews: Array<{ start: number; duration: number }>; + resolvePreview: ((data: import("../src/services/NativeBridge").ClipTempoPreviewData) => void) | null; + cancellations: string[]; + audio: { starts: number; stops: number; closes: number }; + }; + } +} + +async function ready(page: Page, openDialog = true) { + await page.goto("/"); + await page.getByText("Starting OpenStudio...", { exact: true }).waitFor({ state: "hidden" }); + await page.evaluate(async (openDialog) => { + const source = await (await fetch("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/src/App.tsx")).text(); + const storeUrl = source.match(/from "([^"]*useDAWStore\.ts[^"]*)"/)?.[1]; + if (!storeUrl) throw new Error("App store import not found"); + const { useDAWStore: store, createDefaultTrack } = await import(/* @vite-ignore */ storeUrl) as typeof import("../src/store/useDAWStore"); + const commandUrl = "/src/store/commands/index.ts"; + const { commandManager: history } = await import(/* @vite-ignore */ commandUrl) as typeof import("../src/store/commands"); + const bridgeUrl = "/src/services/NativeBridge.ts"; + const { nativeBridge } = await import(/* @vite-ignore */ bridgeUrl) as typeof import("../src/services/NativeBridge"); + const track = createDefaultTrack("tempo-track", "Tempo source", "#4361ee", "audio", []); + track.clips = ["a", "b"].map((id) => ({ id, name: `Clip ${id}`, filePath: "C:/tempo-fixture.wav", + duration: 8, startTime: id === "a" ? 3 : 12, offset: 0.5, sourceLength: 20, + sampleRate: 48000, volumeDB: 0, fadeIn: 0, fadeOut: 0, color: track.color })); + const pending: Array<(result: ClipTempoAnalysis) => void> = []; + const ranges: Array<{ start?: number; duration?: number }> = []; + const qa = window.__tempoUiQA = { store, history, pending, ranges, + previews: [] as Array<{ start: number; duration: number }>, resolvePreview: null as ((data: import("../src/services/NativeBridge").ClipTempoPreviewData) => void) | null, + cancellations: [] as string[], audio: { starts: 0, stops: 0, closes: 0 } }; + nativeBridge.cancelClipTempoAnalysis = async id => { qa.cancellations.push(id); return true; }; + // This checks scheduling/cancellation without using the computer's speakers. + class SilentContext { + state = "running"; currentTime = 0; sampleRate = 48000; destination = {}; + async resume() { this.state = "running"; } + async close() { this.state = "closed"; qa.audio.closes++; } + createBuffer(channels: number, count: number, sampleRate: number) { + const data = Array.from({ length: channels }, () => new Float32Array(count)); + return { numberOfChannels: channels, duration: count / sampleRate, getChannelData: (channel: number) => data[channel] }; + } + async decodeAudioData() { return this.createBuffer(1, 4 * 48000, 48000); } + createGain() { return { gain: { value: 1, setValueAtTime() {}, linearRampToValueAtTime() {} }, connect() {}, disconnect() {} }; } + createBufferSource() { return { buffer: null, playbackRate: { value: 1 }, onended: null, + connect() {}, disconnect() {}, start() { qa.audio.starts++; }, stop() { qa.audio.stops++; } }; } + } + Object.defineProperty(window, "AudioContext", { configurable: true, value: SilentContext }); + nativeBridge.getClipTempoPreviewData = async (_trackId, _clipId, start = 0, duration = 4) => { + qa.previews.push({ start, duration }); + return new Promise(resolve => { qa.resolvePreview = resolve; }); + }; + history.clear(); + store.setState({ tracks: [track], showGettingStarted: false, inputProfileOnboardingSeen: true, + autoBackupEnabled: false, canUndo: false, canRedo: false, tempoDetectionTarget: null, + analyzeTempoDetectionTarget: (start, duration) => { ranges.push({ start, duration }); return new Promise(resolve => pending.push(resolve)); }, + transport: { ...store.getState().transport, tempo: 120, isPlaying: false, isRecording: false } }); + if (openDialog) store.getState().openTempoDetection(track.id, "a"); + }, openDialog); + if (openDialog) await expect(page.getByRole("dialog", { name: "Detect Clip Tempo" })).toBeVisible(); +} + +test("cancel, close and clip replacement retire native analysis request IDs", async ({ page }) => { + await ready(page); + await expect.poll(() => page.evaluate(() => window.__tempoUiQA.pending.length)).toBe(1); + await page.getByRole("button", { name: "Cancel analysis", exact: true }).click(); + await expect.poll(() => page.evaluate(() => window.__tempoUiQA.cancellations.length)).toBe(1); + await page.getByRole("button", { name: "Analyze range", exact: true }).click(); + await expect.poll(() => page.evaluate(() => window.__tempoUiQA.pending.length)).toBe(2); + await page.getByRole("button", { name: "Close", exact: true }).click(); + await expect.poll(() => page.evaluate(() => window.__tempoUiQA.cancellations.length)).toBe(2); + await page.evaluate(() => window.__tempoUiQA.store.getState().openTempoDetection("tempo-track", "a")); + await expect.poll(() => page.evaluate(() => window.__tempoUiQA.pending.length)).toBe(3); + await page.evaluate(() => window.__tempoUiQA.store.getState().openTempoDetection("tempo-track", "b")); + await expect.poll(() => page.evaluate(() => window.__tempoUiQA.cancellations.length)).toBe(3); + expect(await page.evaluate(() => new Set(window.__tempoUiQA.cancellations).size)).toBe(3); +}); + +for (const kind of ["audio", "midi"] as const) { + test(`${kind} tempo detection is reachable from the clip menu and searchable Command Palette`, async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await ready(page, false); + await page.evaluate(kind => { + const store = window.__tempoUiQA.store; + const original = store.getState().tracks[0]; + const track = kind === "audio" ? original : { ...original, type: "midi" as const, clips: [], + midiClips: [{ id: "a", name: "Clip a", startTime: 3, duration: 8, offset: 0, + color: original.color, sourceLength: 8, events: [ + { type: "noteOn" as const, note: 60, velocity: 80, timestamp: 0 }, + { type: "noteOff" as const, note: 60, velocity: 0, timestamp: 1 }, + ] }] }; + store.setState({ tracks: [track], pixelsPerSecond: 100, scrollX: 0, scrollY: 0, trackHeight: 100, + showMixer: false, showPianoRoll: false, showPitchEditor: false }); + }, kind); + const point = await page.evaluate(() => { + const stage = window.Konva.stages.find(stage => stage.height() > 50 && stage.container().closest(".timeline-container"))!; + const box = stage.container().getBoundingClientRect(); + return { x: box.x + 400, y: box.y + 45 }; + }); + await page.mouse.click(point.x, point.y, { button: "right" }); + await page.getByText("Detect Tempo...", { exact: true }).click(); + const dialog = page.getByRole("dialog", { name: "Detect Clip Tempo" }); + await expect(dialog).toBeVisible(); + await dialog.getByRole("button", { name: "Close", exact: true }).click(); + await page.mouse.click(point.x, point.y); + await page.getByRole("menuitem", { name: "Help menu", exact: true }).click(); + await page.getByText("Command Palette", { exact: true }).click(); + await page.getByPlaceholder("Type a command...").fill("tempo"); + await page.getByText("Detect Selected Clip Tempo...", { exact: true }).click(); + await expect(dialog).toBeVisible(); + expect(await page.evaluate(() => window.__tempoUiQA.store.getState().tempoDetectionTarget)) + .toEqual({ trackId: "tempo-track", clipId: "a" }); + expect(await page.evaluate(() => window.__tempoUiQA.store.getState().transport.tempo)).toBe(120); + expect(await page.evaluate(() => window.__tempoUiQA.history.getUndoStack().length)).toBe(0); + }); +} + +test("tempo dialog ignores canceled work and applies only the chosen candidate with one undo", async ({ page }) => { + await ready(page); + await expect(page.getByRole("status", { name: "Tempo analysis status" })).toContainText("Analyzing"); + await page.getByRole("button", { name: "Close", exact: true }).click(); + await page.evaluate(() => window.__tempoUiQA.pending[0]({ bpm: 123, confidence: 0.8, beatOffsetSeconds: 0.1, candidates: [], source: "audio" })); + await expect(page.getByRole("dialog", { name: "Detect Clip Tempo" })).toBeHidden(); + expect(await page.evaluate(() => window.__tempoUiQA.store.getState().transport.tempo)).toBe(120); + await page.evaluate(() => window.__tempoUiQA.store.getState().openTempoDetection("tempo-track", "a")); + await expect.poll(() => page.evaluate(() => window.__tempoUiQA.pending.length)).toBe(2); + await page.evaluate(() => window.__tempoUiQA.pending[1]({ bpm: 95, confidence: 0.82, beatOffsetSeconds: 0.25, candidates: [], source: "audio" })); + await expect(page.getByLabel("Detected tempo in BPM")).toHaveValue("95"); + await expect(page.getByRole("img", { name: /Visual beat spacing/ })).toBeVisible(); + await expect(page.getByLabel("Tempo click phase")).toHaveValue("0.250"); + await page.getByRole("button", { name: "190 BPM (2×)", exact: true }).click(); + expect(await page.evaluate(() => window.__tempoUiQA.store.getState().transport.tempo)).toBe(120); + const before = await page.evaluate(() => window.__tempoUiQA.store.getState().tracks[0].clips); + await page.getByRole("button", { name: "Apply Project Tempo", exact: true }).click(); + expect(await page.evaluate(() => window.__tempoUiQA.store.getState().transport.tempo)).toBe(190); + expect(await page.evaluate(() => window.__tempoUiQA.store.getState().tracks[0].clips)).toEqual(before); + expect(await page.evaluate(() => window.__tempoUiQA.history.getUndoStack().length)).toBe(1); + await page.getByRole("button", { name: "Undo", exact: true }).click(); + expect(await page.evaluate(() => window.__tempoUiQA.store.getState().transport.tempo)).toBe(120); +}); + +test("changing clips retires the previous result and low-confidence MIDI estimates remain editable", async ({ page }) => { + await ready(page); + await expect.poll(() => page.evaluate(() => window.__tempoUiQA.pending.length)).toBe(1); + await page.evaluate(() => window.__tempoUiQA.store.getState().openTempoDetection("tempo-track", "b")); + await expect.poll(() => page.evaluate(() => window.__tempoUiQA.pending.length)).toBe(2); + await page.evaluate(() => window.__tempoUiQA.pending[0]({ bpm: 145, confidence: 0.9, beatOffsetSeconds: 0, candidates: [], source: "audio" })); + await expect(page.getByRole("status", { name: "Tempo analysis status" })).toContainText("Analyzing"); + await page.evaluate(() => window.__tempoUiQA.pending[1]({ bpm: 80, confidence: 0.3, beatOffsetSeconds: 0.5, candidates: [], source: "midi-estimate", warning: "Note patterns may imply several pulses." })); + await expect(page.getByLabel("Detected tempo in BPM")).toHaveValue("80"); + await expect(page.getByText("MIDI beat-grid estimate", { exact: true })).toBeVisible(); + const apply = page.getByRole("button", { name: "Apply Project Tempo", exact: true }); + await expect(page.getByText(/This pulse is uncertain/)).toBeVisible(); + await expect(apply).toBeEnabled(); + await page.getByLabel("Detected tempo in BPM").fill("999"); + await expect(apply).toBeDisabled(); + expect(await page.evaluate(() => window.__tempoUiQA.store.getState().transport.tempo)).toBe(120); +}); + +test("manual count, range analysis and source-click audition remain cancellable without transport changes", async ({ page }) => { + await ready(page); + await expect.poll(() => page.evaluate(() => window.__tempoUiQA.pending.length)).toBe(1); + await page.evaluate(() => window.__tempoUiQA.pending[0]({ bpm: 0, confidence: 0, beatOffsetSeconds: 0, candidates: [], source: "audio", error: "No repeated pulse" })); + await expect(page.getByRole("button", { name: "Audition clip + click", exact: true })).toBeDisabled(); + await expect(page.getByRole("button", { name: "Source only", exact: true })).toBeEnabled(); + await page.getByLabel("Tempo analysis range start").fill("1"); + await page.getByLabel("Tempo analysis range end").fill("3"); + await page.getByText("Set tempo by counting beats in the selected range", { exact: true }).click(); + await page.getByLabel("Beat intervals in tempo range").fill("5"); + await page.getByRole("button", { name: /Use counted tempo/ }).click(); + await expect(page.getByLabel("Detected tempo in BPM")).toHaveValue("150.00"); + await expect(page.getByLabel("Tempo click phase")).toHaveValue("1.000"); + await page.getByRole("button", { name: "Analyze range", exact: true }).click(); + await expect.poll(() => page.evaluate(() => window.__tempoUiQA.pending.length)).toBe(2); + expect(await page.evaluate(() => window.__tempoUiQA.ranges[1])).toEqual({ start: 1, duration: 2 }); + await page.getByRole("button", { name: "Cancel analysis", exact: true }).click(); + await page.evaluate(() => window.__tempoUiQA.pending[1]({ bpm: 99, confidence: 0.9, beatOffsetSeconds: 0, candidates: [], source: "audio" })); + await expect(page.getByLabel("Detected tempo in BPM")).toHaveValue("150.00"); + await page.getByLabel("Tempo preview start").fill("1.25"); + await page.getByRole("button", { name: "Audition clip + click", exact: true }).click(); + await expect.poll(() => page.evaluate(() => window.__tempoUiQA.previews.length)).toBe(1); + await page.getByRole("button", { name: "Stop audition", exact: true }).click(); + await page.evaluate(() => window.__tempoUiQA.resolvePreview?.({ wavBase64: "AA==", durationSeconds: 4, rangeStartSeconds: 1.25, filePath: "C:/tempo-fixture.wav", sourceOffsetSeconds: 0.5, clipDurationSeconds: 8 })); + expect(await page.evaluate(() => window.__tempoUiQA.audio.starts)).toBe(0); + await page.getByRole("button", { name: "Audition clip + click", exact: true }).click(); + await expect.poll(() => page.evaluate(() => window.__tempoUiQA.previews.length)).toBe(2); + await page.evaluate(() => window.__tempoUiQA.resolvePreview?.({ wavBase64: "AA==", durationSeconds: 4, rangeStartSeconds: 1.25, filePath: "C:/tempo-fixture.wav", sourceOffsetSeconds: 0.5, clipDurationSeconds: 8 })); + await expect(page.getByRole("status", { name: "Tempo audition status" })).toContainText("Playing"); + expect(await page.evaluate(() => window.__tempoUiQA.audio.starts)).toBeGreaterThan(1); + await page.getByLabel("Tempo click phase").fill("0.1"); + await expect(page.getByRole("status", { name: "Tempo audition status" })).toHaveText(""); + const calls = await page.evaluate(() => window.__tempoUiQA.previews.length); + await page.getByRole("button", { name: "Source only", exact: true }).click(); + await expect(page.getByRole("status", { name: "Tempo audition status" })).toContainText("Playing"); + expect(await page.evaluate(() => window.__tempoUiQA.previews.length)).toBe(calls); + await page.getByLabel("Detected tempo in BPM").fill("155"); + await expect(page.getByRole("status", { name: "Tempo audition status" })).toContainText("Playing"); + await page.getByRole("button", { name: "Close", exact: true }).click(); + await expect(page.getByRole("dialog", { name: "Detect Clip Tempo" })).toBeHidden(); + const state = await page.evaluate(() => ({ audio: window.__tempoUiQA.audio, + transport: window.__tempoUiQA.store.getState().transport, history: window.__tempoUiQA.history.getUndoStack().length })); + expect(state.audio.closes).toBeGreaterThanOrEqual(1); + expect(state.audio.stops).toBeGreaterThanOrEqual(state.audio.starts); + expect(state.transport.isPlaying).toBe(false); expect(state.transport.tempo).toBe(120); expect(state.history).toBe(0); +}); diff --git a/frontend/e2e/timeline-source-geometry.spec.ts b/frontend/e2e/timeline-source-geometry.spec.ts new file mode 100644 index 00000000..39968636 --- /dev/null +++ b/frontend/e2e/timeline-source-geometry.spec.ts @@ -0,0 +1,18 @@ +import { expect, test } from "@playwright/test"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +test("long completed clips preserve source coordinates and trim freezes on release", async ({ page }, testInfo) => { + await page.addInitScript(() => { + localStorage.setItem("openstudio.inputProfiles.v1", JSON.stringify({ schemaVersion: 1, + keyboardProfileId: "openstudio", mouseProfileId: "openstudio", onboardingSeen: true })); + localStorage.setItem("openstudio_essentialControlsDismissed", "true"); + }); + await page.goto("/?platform=windows&windowChrome=native"); + const source = readFileSync(resolve(testInfo.config.rootDir, "../../tools/timeline-source-geometry-browser-regression.js"), "utf8"); + const run = new Function(`return (${source}\n);`)() as (page: import("@playwright/test").Page) => Promise<{ status: string; checks: string[] }>; + const result = await run(page); + await testInfo.attach("source-geometry-report", { body: JSON.stringify(result, null, 2), contentType: "application/json" }); + expect(result.status).toBe("pass"); + expect(result.checks).toHaveLength(5); +}); diff --git a/frontend/e2e/tsconfig.json b/frontend/e2e/tsconfig.json index 7f0057ca..3a833091 100644 --- a/frontend/e2e/tsconfig.json +++ b/frontend/e2e/tsconfig.json @@ -7,7 +7,14 @@ "support/*.ts", "editor-gesture-regressions.spec.ts", "timeline-controls.spec.ts", + "mixer-entry-resize.spec.ts", + "midi-join-export.spec.ts", + "project-rate-copy.spec.ts", + "tempo-detection.spec.ts", + "native-tempo-preview.spec.ts", + "shared-project-qualification.spec.ts", "recording-zoom.spec.ts", + "packed-waveform-tiles.spec.ts", "release-review-ui.spec.ts", "audio-settings-switching.spec.ts", "nam-fuzz-workflow.spec.ts", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1c4f5d3d..0cbbd31d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,6 @@ import { startPitchEditorSessionController, usePitchWindowState, detachPitchEditor, dockPitchEditor, recoverPitchCheckpoint } from "./utils/pitchEditorSession"; import { usePanelLayout } from "./utils/usePanelLayout"; +import { AudioConversionStatus } from "./components/AudioConversionStatus"; import React, { useCallback, useEffect, useMemo, useRef, useState, Suspense } from "react"; import { useShallow } from "zustand/shallow"; import { ExternalLink, GripHorizontal, X } from "lucide-react"; @@ -111,6 +112,7 @@ const HelpOverlay = React.lazy(() => import("./components/HelpOverlay").then(m = const GettingStartedGuide = React.lazy(() => import("./components/GettingStartedGuide").then(m => ({ default: m.GettingStartedGuide }))); const StemSeparationModal = React.lazy(() => import("./components/StemSeparationModal")); const AIClipGenerationModal = React.lazy(() => import("./components/AIClipGenerationModal")); +const TempoDetectionModal = React.lazy(() => import("./components/TempoDetectionModal").then(m => ({ default: m.TempoDetectionModal }))); const AiToolsSetupModal = React.lazy(() => import("./components/AiToolsSetupModal")); import { DndContext, @@ -170,6 +172,9 @@ function App() { closePianoRoll, lowerZoneHeight, setLowerZoneHeight, + mixerHeight, + setMixerHeight, + tempoDetectionTarget, showUndoHistory, showCommandPalette, showRegionMarkerManager, @@ -246,6 +251,9 @@ function App() { closePianoRoll: state.closePianoRoll, lowerZoneHeight: state.lowerZoneHeight, setLowerZoneHeight: state.setLowerZoneHeight, + mixerHeight: state.mixerHeight, + setMixerHeight: state.setMixerHeight, + tempoDetectionTarget: state.tempoDetectionTarget, showUndoHistory: state.showUndoHistory, showCommandPalette: state.showCommandPalette, showRegionMarkerManager: state.showRegionMarkerManager, @@ -354,7 +362,7 @@ function App() { const { rootRef: layoutRootRef, heights: panelHeights, available: panelAvailable } = usePanelLayout({ ...(pitchVisible ? { pitch: lowerZoneHeight } : {}), ...(midiVisible ? { midi: lowerZoneHeight } : {}), - ...(showMixer && !isMixerDetached ? { mixer: 340 } : {}), + ...(showMixer && !isMixerDetached ? { mixer: mixerHeight } : {}), ...(showClipLauncher ? { launcher: 256 } : {}), }); const panelHeightStyle = (height: number): React.CSSProperties => ({ "--pane-height": `${height}px` } as React.CSSProperties); @@ -1919,11 +1927,14 @@ function App() {
{ void handleDetachMixer(); }} + onDetach={handleDetachMixer} onAttach={() => { void nativeBridge.closeMixerWindow(); }} - onClose={() => { void handleToggleMixerVisibility(); }} + onClose={handleToggleMixerVisibility} />
@@ -2107,6 +2118,12 @@ function App() { )} + {tempoDetectionTarget && ( + + + + )} + {/* Channel Strip EQ Modal */} {showChannelStripEQ && ( @@ -2288,6 +2305,7 @@ function App() { )} {/* Toast Notification */} + {toastVisible && (
{ } }); + it("routes explicit project zoom commands to Timeline while a menu or another surface owns focus", () => { + const timeline = vi.fn(() => "handled" as const); + const other = vi.fn(() => "handled" as const); + const unregisterTimeline = registerScopedActionExecutor({ kind: "timeline" }, timeline); + const unregisterModal = registerScopedActionExecutor({ kind: "modal" }, other); + const unregisterMixer = registerScopedActionExecutor({ kind: "mixer" }, other); + try { + for (const kind of ["modal", "mixer", "track_control_panel", "application"] as const) { + activateShortcutContext({ kind }); + getRegisteredAction("view.zoomToFit")?.execute(); + getRegisteredAction("view.zoomToSelection")?.execute(); + } + expect(timeline.mock.calls).toEqual(Array.from({ length: 4 }, () => [ + ["view.zoomToFit"], ["view.zoomToSelection"], + ]).flat()); + expect(other).not.toHaveBeenCalled(); + } finally { + unregisterMixer(); unregisterModal(); unregisterTimeline(); + activateShortcutContext({ kind: "application" }); + } + }); + it("falls through co-owned surface executors until one recognizes the action", () => { const owner = vi.fn(() => "handled" as const); const unrelatedOwner = vi.fn(() => "unmatched" as const); diff --git a/frontend/src/__tests__/audioConversion.test.ts b/frontend/src/__tests__/audioConversion.test.ts new file mode 100644 index 00000000..38a7cfb6 --- /dev/null +++ b/frontend/src/__tests__/audioConversion.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; +import { nativeBridge, type MediaFileInfo, type ProjectRateCopyResult } from "../services/NativeBridge"; +import { audioConversionActions, prepareImportedProjectRateCopy } from "../store/actions/audioConversion"; +import { commandManager } from "../store/commands"; +import { resetSyncCache } from "../store/actions/clips"; +import { advanceProjectEpoch } from "../utils/projectLifetime"; +import type { DAWActions, DAWState } from "../store/useDAWStore"; +vi.mock("../store/useDAWStore", () => ({ + createDefaultTrack: vi.fn(), + useDAWStore: { getState: vi.fn(), setState: vi.fn(), subscribe: vi.fn(() => () => {}) }, +})); + +function fixture() { + const clip = { id: "clip", filePath: "C:/audio/source.wav", name: "Take", startTime: 12.5, + duration: 0.8, offset: 0.2, sourceLength: 1.5, sampleRate: 48000, color: "red", volumeDB: -3, + fadeIn: 0.1, fadeOut: 0.2, fadeInShape: 1, fadeOutShape: 2, + gainEnvelope: [{ time: 0, gain: 0.5 }, { time: 0.8, gain: 1 }] }; + let state = { tracks: [{ id: "track", clips: [clip], midiClips: [] }], projectSampleRate: 44100, + projectPath: "C:/project/song.osproj", showPitchEditor: false, audioConversionJobs: {}, + showToast: vi.fn(), syncClipsWithBackend: vi.fn().mockResolvedValue(undefined), isModified: false } as unknown as DAWState & DAWActions; + const get = () => state; + const set = (patch: object | ((previous: typeof state) => object)) => { + state = { ...state, ...(typeof patch === "function" ? patch(state) : patch) }; + }; + Object.assign(state, audioConversionActions(set, get)); + return { get, set, clip }; +} +const converted: ProjectRateCopyResult = { success: true, filePath: "C:/project/Audio/RateCopies/44100.wav", + sampleRate: 44100, duration: 1.5, numChannels: 2, originalSourceFilePath: "C:/project/Audio/RateCopies/44100.wav" }; +const info: MediaFileInfo = { filePath: "C:/audio/source.wav", sampleRate: 48000, duration: 1.5, numChannels: 2, format: "WAV" }; + +beforeEach(async () => { + commandManager.clear(); await resetSyncCache(); + vi.spyOn(nativeBridge, "createProjectRateCopy").mockResolvedValue(converted); + vi.spyOn(nativeBridge, "replacePlaybackClipSource").mockResolvedValue(true); + vi.spyOn(nativeBridge, "cancelProjectRateCopy").mockResolvedValue(true); + vi.spyOn(nativeBridge, "refreshWaveformPeaks").mockResolvedValue(true); +}); +afterEach(async () => { await resetSyncCache(); vi.restoreAllMocks(); commandManager.clear(); }); + +describe("project-rate source copies", () => { + it("preserves seconds, gains and fades and restores source fields through Undo/Redo", async () => { + const { get, clip } = fixture(); + await get().createProjectRateCopy("track", "clip"); + const after = get().tracks[0].clips[0]; + expect(after).toMatchObject({ ...clip, filePath: converted.filePath, sampleRate: 44100, originalFilePath: clip.filePath }); + expect(nativeBridge.replacePlaybackClipSource).toHaveBeenCalledWith("track", "clip", clip.filePath, + converted.filePath, converted.filePath, clip.offset); + expect(commandManager.serialize().undoStack).toHaveLength(1); + commandManager.undo(); + await vi.waitFor(() => expect(get().tracks[0].clips[0].filePath).toBe(clip.filePath)); + commandManager.redo(); + await vi.waitFor(() => expect(get().tracks[0].clips[0].filePath).toBe(converted.filePath)); + expect(get().tracks[0].clips[0].offset).toBe(clip.offset); + expect(get().audioConversionJobs).toEqual({}); + }); + + it("carries a separately converted immutable pitch source and its offset", async () => { + const { get } = fixture(); + Object.assign(get().tracks[0].clips[0], { pitchCorrectionSourceFilePath: "C:/audio/immutable.wav", pitchCorrectionSourceOffset: 0.7 }); + vi.mocked(nativeBridge.createProjectRateCopy).mockResolvedValue({ ...converted, originalSourceFilePath: "C:/project/Audio/RateCopies/original44100.wav" }); + await get().createProjectRateCopy("track", "clip"); + expect(get().tracks[0].clips[0]).toMatchObject({ pitchCorrectionSourceFilePath: "C:/project/Audio/RateCopies/original44100.wav", pitchCorrectionSourceOffset: 0.7, offset: 0.2 }); + }); + + it("does not replace a clip after cancellation or a project epoch change", async () => { + for (const changeEpoch of [false, true]) { + const { get } = fixture(); + let finish!: (value: ProjectRateCopyResult) => void; + vi.mocked(nativeBridge.createProjectRateCopy).mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + const pending = get().createProjectRateCopy("track", "clip"); + if (changeEpoch) advanceProjectEpoch(); else get().cancelProjectRateCopy("clip"); + finish(converted); await pending; + expect(get().tracks[0].clips[0].filePath).toBe(info.filePath); + } + expect(nativeBridge.replacePlaybackClipSource).not.toHaveBeenCalled(); + expect(commandManager.canUndo()).toBe(false); + }); + + it("ignores completed conversion when the source geometry changed", async () => { + const { get } = fixture(); + let finish!: (value: ProjectRateCopyResult) => void; + vi.mocked(nativeBridge.createProjectRateCopy).mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + const pending = get().createProjectRateCopy("track", "clip"); + get().tracks[0].clips[0].offset = 0.3; finish(converted); await pending; + expect(nativeBridge.replacePlaybackClipSource).not.toHaveBeenCalled(); + expect(get().tracks[0].clips[0].offset).toBe(0.3); + }); + + it("retains original import on conversion failure and aborts a removed target", async () => { + const { get, set } = fixture(); + vi.mocked(nativeBridge.createProjectRateCopy).mockRejectedValueOnce(new Error("runtime unavailable")); + expect(await prepareImportedProjectRateCopy(set, get, "track", "clip", info, () => true)).toEqual(info); + await expect(prepareImportedProjectRateCopy(set, get, "track", "clip", info, () => false)).rejects.toMatchObject({ name: "AbortError" }); + expect(commandManager.canUndo()).toBe(false); + }); + + it("prepares import media without adding another Undo and preserves musical duration", async () => { + const { get, set } = fixture(); + vi.mocked(nativeBridge.createProjectRateCopy).mockResolvedValue({ ...converted, duration: 1.50001 }); + const ready = await prepareImportedProjectRateCopy(set, get, "track", "clip", info, () => true); + expect(ready).toMatchObject({ filePath: converted.filePath, sampleRate: 44100, duration: info.duration, numChannels: 2, originalFilePath: info.filePath }); + expect(commandManager.canUndo()).toBe(false); + }); +}); diff --git a/frontend/src/__tests__/clipCrossfades.test.ts b/frontend/src/__tests__/clipCrossfades.test.ts new file mode 100644 index 00000000..4f6e72cb --- /dev/null +++ b/frontend/src/__tests__/clipCrossfades.test.ts @@ -0,0 +1,228 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createDefaultTrack, resetSyncCache, useDAWStore, type AudioClip } from "../store/useDAWStore"; +import { commandManager } from "../store/commands"; +import { nativeBridge } from "../services/NativeBridge"; +import { cloneTracksForTimelineUndo } from "../store/actions/clipEditing"; +import { parseValidatedProject } from "../utils/projectValidation"; +import { crossfadeGainAtTime, getClipPlaybackGainEnvelope, interpolateClipGain, planClipCrossfades } from "../utils/clipCrossfades"; + +const initial = useDAWStore.getState(); +const clip = (id: string, startTime: number, duration: number, properties: Partial = {}): AudioClip => ({ + id, name: id, filePath: `C:/${id}.wav`, startTime, duration, offset: 0, sampleRate: 48000, + color: "#abc", volumeDB: 0, fadeIn: 0, fadeOut: 0, ...properties, +}); +function planned(clips: AudioClip[], options = {}) { + const plan = planClipCrossfades(clips, options)!; + expect(plan).not.toBeNull(); + return { plan, clips: clips.map(item => ({ ...item, crossfadeLayers: [plan.layers.get(item.id)!] })) }; +} +function gainsAt(clips: AudioClip[], absolute: number) { + return clips.filter(item => absolute >= item.startTime && absolute < item.startTime + item.duration) + .map(item => crossfadeGainAtTime(item, absolute - item.startTime)); +} +beforeEach(() => { + commandManager.clear(); + useDAWStore.setState({ ...initial, globalLocked: false, lockSettings: { ...initial.lockSettings, items: false }, + selectedClipIds: [], tracks: [], syncClipsWithBackend: vi.fn().mockResolvedValue(undefined) }); + vi.spyOn(nativeBridge, "setClipGainEnvelope").mockResolvedValue(true); +}); +afterEach(() => { vi.restoreAllMocks(); useDAWStore.setState(initial); commandManager.clear(); }); + +describe("general overlap crossfades", () => { + it("returns to the containing clip through separate entry/exit transitions", () => { + const { clips, plan } = planned([clip("bed", 0, 10), clip("insert", 2, 2)]); + expect(plan.transitions).toHaveLength(2); + expect(plan.transitions.map(item => [item.fromClipId, item.toClipId])).toEqual([["bed", "insert"], ["insert", "bed"]]); + expect(gainsAt(clips, 1)).toEqual([1]); + expect(gainsAt(clips, 2.025)).toEqual(expect.arrayContaining([expect.closeTo(0.5, 6)])); + expect(gainsAt(clips, 3)).toEqual([0, 1]); + expect(gainsAt(clips, 3.975)).toEqual(expect.arrayContaining([expect.closeTo(0.5, 6)])); + expect(gainsAt(clips, 5)).toEqual([1]); + }); + it.each([ + [clip("long", 0, 10), clip("short", 0, 4)], + [clip("long", 0, 10), clip("short", 6, 4)], + [clip("a", 0, 10), clip("b", 0, 10)], + [clip("a", 0, 10), clip("b", 0, 10), clip("c", 0, 10)], + [clip("bed", 0, 10), clip("b", 2, 2), clip("c", 6, 2)], + [clip("bed", 0, 10), clip("b", 2, 6), clip("c", 4, 2)], + [clip("a", 0, 6), clip("b", 2, 6), clip("c", 4, 6)], + ])("keeps a unity linear gain handoff for same edges, coincident, nested and chained overlaps: %j", (...input) => { + const { clips } = planned(input as AudioClip[]); + for (let sample = 1; sample < 10000; sample++) { + const time = sample / 1000; + const gains = gainsAt(clips, time); + if (gains.length) expect(gains.reduce((sum, gain) => sum + gain, 0)).toBeCloseTo(1, 5); + } + }); + it("uses complementary equal-power curves with a bounded linear-envelope approximation", () => { + const { clips } = planned([clip("bed", 0, 10), clip("insert", 2, 2)], { fadeInShape: 1, fadeOutShape: 1 }); + for (let sample = 1; sample < 10000; sample++) { + const gains = gainsAt(clips, sample / 1000); + expect(gains.reduce((sum, gain) => sum + gain * gain, 0)).toBeCloseTo(1, 3); + } + expect(clips.every(item => getClipPlaybackGainEnvelope(item).length < 1000)).toBe(true); + }); + it("places short handoffs within the overlap instead of leaving a double-gain middle", () => { + const { clips, plan } = planned([clip("a", 0, 5), clip("b", 3, 5)], { transitionLength: 0.2 }); + expect(plan.transitions[0]).toMatchObject({ startTime: 3, endTime: 3.2 }); + expect(gainsAt(clips, 3.1)).toEqual([expect.closeTo(0.5, 6), expect.closeTo(0.5, 6)]); + expect(gainsAt(clips, 4)).toEqual([0, 1]); + }); + it("preserves manual fades and gain automation and composes at their union breakpoints", () => { + const base = [{ time: 0, gain: 0.2 }, { time: 1, gain: 1.8 }, { time: 7, gain: 0.4 }]; + const original = [clip("bed", 0, 10, { gainEnvelope: base, fadeIn: 0.4, fadeOut: 0.6, fadeInShape: 3, fadeOutShape: 4 }), clip("insert", 2, 2)]; + const { clips } = planned(original, { fadeInShape: 4, fadeOutShape: 2 }); + expect(clips[0].gainEnvelope).toBe(base); + expect(clips[0]).toMatchObject({ fadeIn: 0.4, fadeOut: 0.6, fadeInShape: 3, fadeOutShape: 4 }); + const actual = getClipPlaybackGainEnvelope(clips[0]); + for (let sample = 0; sample <= 10000; sample++) { + const time = sample / 1000; + expect(Math.abs(interpolateClipGain(actual, time) + - interpolateClipGain(base, time) * crossfadeGainAtTime(clips[0], time))).toBeLessThan(0.00015); + } + expect(original[0].crossfadeLayers).toBeUndefined(); + }); +}); + +describe("crossfade transactions and retained state", () => { + function mount() { + const bed = clip("bed", 0, 10, { gainEnvelope: [{ time: 0, gain: 0.5 }, { time: 10, gain: 1.5 }], fadeIn: 0.3 }); + const insert = clip("insert", 2, 2); + useDAWStore.setState({ tracks: [{ ...createDefaultTrack("t", "T"), clips: [bed, insert] }], selectedClipIds: [bed.id, insert.id] }); + return { bed, insert }; + } + it("applies/replaces/removes as atomic undo steps without repeatedly attenuating the same group", () => { + const { bed } = mount(); + const state = useDAWStore.getState(); + expect(state.crossfadeSelectedClips()).toBe(true); + const first = useDAWStore.getState().tracks[0].clips; + expect(state.crossfadeSelectedClips()).toBe(false); + expect(commandManager.getUndoStack()).toHaveLength(1); + expect(state.applyClipCrossfade("bed", "insert", { transitionLength: 0.2, fadeInShape: 1, fadeOutShape: 1 })).toBe(true); + expect(useDAWStore.getState().tracks[0].clips[0].crossfadeLayers).toHaveLength(1); + state.undo(); + expect(useDAWStore.getState().tracks[0].clips).toEqual(first); + expect(state.clearClipCrossfades(["bed", "insert"])).toBe(true); + expect(useDAWStore.getState().tracks[0].clips[0]).toMatchObject({ gainEnvelope: bed.gainEnvelope, fadeIn: bed.fadeIn }); + expect(useDAWStore.getState().tracks[0].clips[0].crossfadeLayers).toBeUndefined(); + state.undo(); + expect(useDAWStore.getState().tracks[0].clips).toEqual(first); + }); + it("opens the complete group from a partial selection and protects locked partners", () => { + mount(); useDAWStore.getState().crossfadeSelectedClips(); + useDAWStore.setState({ selectedClipIds: ["insert"] }); + expect(useDAWStore.getState().editSelectedCrossfade()).toBe(true); + expect(useDAWStore.getState().crossfadeSelectedClips()).toBe(false); + expect(useDAWStore.getState().showCrossfadeEditor).toBe(true); + const tracks = useDAWStore.getState().tracks; + useDAWStore.setState({ tracks: tracks.map(track => ({ ...track, clips: track.clips.map(item => item.id === "bed" ? { ...item, locked: true } : item) })) }); + expect(useDAWStore.getState().clearClipCrossfades(["insert"])).toBe(false); + useDAWStore.setState({ tracks }); + expect(useDAWStore.getState().clearClipCrossfades(["insert"])).toBe(true); + expect(useDAWStore.getState().tracks[0].clips.every(item => !item.crossfadeLayers?.length)).toBe(true); + }); + it.each(["duplicate", "copyToPosition", "paste"])("forks a complete crossfade on %s without touching its source", method => { + mount(); useDAWStore.getState().crossfadeSelectedClips(); + const original = useDAWStore.getState().tracks[0].clips; + const oldGroup = original[0].crossfadeLayers![0].id; + if (method === "duplicate") useDAWStore.getState().duplicateSelectedClips(); + else if (method === "copyToPosition") useDAWStore.getState().duplicateClipsToPositions(original.map(item => ({ clipId: item.id, targetTrackId: "t", targetStartTime: item.startTime + 12 }))); + else { useDAWStore.getState().copySelectedClips(); useDAWStore.getState().pasteClips("t", 12); } + const copies = useDAWStore.getState().tracks[0].clips.filter(item => !["bed", "insert"].includes(item.id)); + expect(copies).toHaveLength(2); + expect(copies[0].crossfadeLayers![0].id).not.toBe(oldGroup); + expect(copies[1].crossfadeLayers![0].id).toBe(copies[0].crossfadeLayers![0].id); + expect(new Set(copies[0].crossfadeLayers![0].memberClipIds)).toEqual(new Set(copies.map(item => item.id))); + expect(useDAWStore.getState().clearClipCrossfades([copies[0].id])).toBe(true); + expect(useDAWStore.getState().tracks[0].clips.filter(item => ["bed", "insert"].includes(item.id))).toEqual(original); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks[0].clips.filter(item => !["bed", "insert"].includes(item.id))).toEqual(copies); + }); + it("keeps a partial duplicate removable without linking it to the original", () => { + mount(); useDAWStore.getState().crossfadeSelectedClips(); + const original = useDAWStore.getState().tracks[0].clips; + const id = useDAWStore.getState().duplicateClip("insert")!; + const copy = useDAWStore.getState().tracks[0].clips.find(item => item.id === id)!; + expect(copy.crossfadeLayers![0].memberClipIds).toEqual([id]); + for (let i = 0; i < 100; i++) expect(crossfadeGainAtTime(copy, i / 50)).toBe(crossfadeGainAtTime(original[1], i / 50)); + expect(useDAWStore.getState().clearClipCrossfades([id])).toBe(true); + expect(useDAWStore.getState().tracks[0].clips.slice(0, 2)).toEqual(original); + }); + it("maintains actual membership through split, legacy IDs, deletion and save/reopen", () => { + mount(); useDAWStore.getState().crossfadeSelectedClips(); + const tracks = useDAWStore.getState().tracks; + useDAWStore.setState({ tracks: tracks.map(track => ({ ...track, clips: track.clips.map(item => ({ ...item, + crossfadeLayers: item.crossfadeLayers!.map(layer => ({ ...layer, id: "crossfade:bed|insert" })) })) })) }); + useDAWStore.getState().splitClipAtPosition("bed", 3); + const split = useDAWStore.getState().tracks[0].clips; + for (const item of split) expect(new Set(item.crossfadeLayers![0].memberClipIds)).toEqual(new Set(split.map(clip => clip.id))); + useDAWStore.getState().removeClip("t", "insert"); + const document = parseValidatedProject(JSON.stringify({ version: "1.2.0", tracks: useDAWStore.getState().tracks })); + useDAWStore.setState({ tracks: document.tracks as unknown as typeof tracks }); + expect(useDAWStore.getState().clearClipCrossfades([split.find(item => item.id !== "insert")!.id])).toBe(true); + expect(useDAWStore.getState().tracks[0].clips.every(item => !item.crossfadeLayers?.length)).toBe(true); + }); + it("survives legacy/new project parsing and snapshot cloning without aliasing, and Compare sees the change", async () => { + const { bed, insert } = mount(); + const previous = JSON.stringify({ version: "1.2.0", tracks: [{ ...createDefaultTrack("t", "T"), clips: [bed, insert] }] }); + expect((parseValidatedProject(previous).tracks[0].clips as AudioClip[])[0].crossfadeLayers).toBeUndefined(); + useDAWStore.getState().crossfadeSelectedClips(); + const tracks = useDAWStore.getState().tracks; + const document = parseValidatedProject(JSON.stringify({ version: "1.2.0", tracks })); + expect(document.version).toBe("1.2.0"); + expect(parseValidatedProject(JSON.stringify(document))).toEqual(document); + const snapshot = cloneTracksForTimelineUndo(tracks); + snapshot[0].clips[0].crossfadeLayers[0].points[0].gain = 0; + expect(tracks[0].clips[0].crossfadeLayers![0].points[0].gain).toBe(1); + useDAWStore.setState({ projectPath: "C:/example.osproj" }); + vi.spyOn(nativeBridge, "loadProjectFromFile").mockResolvedValue(previous); + await useDAWStore.getState().compareWithSavedProject(); + expect(useDAWStore.getState().projectCompareData?.clipsDiff).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: "bed", details: expect.stringContaining("crossfade handoffs changed") }), + ])); + }); + it("retains crossfade gain through trim/untrim, move, split and undo", async () => { + mount(); + useDAWStore.getState().crossfadeSelectedClips(); + const original = useDAWStore.getState().tracks[0].clips[0]; + const gain = (item: AudioClip, time: number) => crossfadeGainAtTime(item, time); + useDAWStore.getState().resizeClip("bed", 1, 8, 1); + const trimmed = useDAWStore.getState().tracks[0].clips[0]; + expect(gain(trimmed, 1.025)).toBeCloseTo(gain(original, 2.025), 6); + useDAWStore.getState().resizeClip("bed", 0, 10, 0); + expect(gain(useDAWStore.getState().tracks[0].clips[0], 2.025)).toBeCloseTo(gain(original, 2.025), 6); + const target = createDefaultTrack("target", "Target"); + useDAWStore.setState({ tracks: [...useDAWStore.getState().tracks, target], autoCrossfade: false }); + await useDAWStore.getState().moveClipToTrack("bed", "target", 20); + expect(gain(useDAWStore.getState().tracks[1].clips[0], 2.025)).toBeCloseTo(gain(original, 2.025), 6); + useDAWStore.getState().undo(); + useDAWStore.setState({ selectedClipIds: ["bed"] }); + useDAWStore.getState().splitClipAtPosition("bed", 3); + const children = useDAWStore.getState().tracks[0].clips.filter(item => item.id !== "insert"); + expect(children).toHaveLength(2); + for (const child of children) for (let sample = 0; sample < 30; sample++) { + const time = child.duration * sample / 30; + expect(gain(child, time)).toBeCloseTo(gain(original, child.startTime + time), 6); + } + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks[0].clips.find(item => item.id === "bed")?.crossfadeLayers).toEqual(original.crossfadeLayers); + }); + it("publishes composed envelopes and explicitly restores the native base/empty envelopes on Undo", async () => { + const { bed } = mount(); + await resetSyncCache(); + vi.spyOn(nativeBridge, "clearPlaybackClips").mockResolvedValue(true); + vi.spyOn(nativeBridge, "addPlaybackClipsBatch").mockResolvedValue(true); + useDAWStore.setState({ syncClipsWithBackend: initial.syncClipsWithBackend }); + useDAWStore.getState().crossfadeSelectedClips(); + await vi.waitFor(() => expect(nativeBridge.setClipGainEnvelope).toHaveBeenCalledTimes(2)); + const insertEnvelope = vi.mocked(nativeBridge.setClipGainEnvelope).mock.calls.find(call => call[1] === "insert")![2]; + expect(interpolateClipGain(insertEnvelope, 1)).toBe(1); + expect(interpolateClipGain(insertEnvelope, 0)).toBe(0); + useDAWStore.getState().undo(); + await vi.waitFor(() => expect(nativeBridge.setClipGainEnvelope).toHaveBeenCalledTimes(4)); + expect(nativeBridge.setClipGainEnvelope).toHaveBeenCalledWith("t", "bed", bed.gainEnvelope); + expect(nativeBridge.setClipGainEnvelope).toHaveBeenLastCalledWith("t", "insert", []); + await resetSyncCache(); + }); +}); diff --git a/frontend/src/__tests__/interactionSafetyGuards.test.ts b/frontend/src/__tests__/interactionSafetyGuards.test.ts index 1279c211..5296c565 100644 --- a/frontend/src/__tests__/interactionSafetyGuards.test.ts +++ b/frontend/src/__tests__/interactionSafetyGuards.test.ts @@ -122,7 +122,7 @@ describe("interaction safety guards", () => { "isTimelineClipCopyAction(modifierAction)", ); expect(timelineSource).toContain("shouldStartTimelineCopyDrag(true, dragType, clipEditLocked)"); - expect(timelineSource).toContain("if (!copyOnDrag && !isAlreadyInMultiSelection)"); + // Selection preservation is exercised by actual body/edge Ctrl+clicks in midi-join-export.spec.ts. expect(timelineSource).toContain("selectClip(clip.id, { ctrl: true });"); expect(timelineSource).toContain("previewStartTime: clip.startTime"); }); diff --git a/frontend/src/__tests__/joinedMIDIIdentity.test.ts b/frontend/src/__tests__/joinedMIDIIdentity.test.ts new file mode 100644 index 00000000..817f9f66 --- /dev/null +++ b/frontend/src/__tests__/joinedMIDIIdentity.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import type { MIDIClip, MIDIEvent } from "../store/useDAWStore"; +import { joinMIDIClips } from "../utils/joinMIDIClips"; +import { applyNoteMetadataValueToEvents, clipboardItemsFromPairs, eventsFromClipboardItems, parseMIDINotePairs, rebuildMIDIEventsForNotes } from "../utils/midiNotes"; +import { getVisibleMIDIEventsForClip, serializeMIDIClipsForBackend } from "../utils/midiClipSerialization"; +import { parseValidatedProject } from "../utils/projectValidation"; + +function clip(id: string, start: number, duration: number, channel = 1): MIDIClip { + return { id, name: id, startTime: start, duration, offset: 0, loopEnabled: false, color: "#abcdef", + events: [{ type: "noteOn", note: 60, channel, timestamp: 0, velocity: 80, pressure: 0.3, slide: 0.7 }, + { type: "noteOff", note: 60, channel, timestamp: duration, velocity: 25, releaseVelocity: 25 }] }; +} +function audible(clips: MIDIClip[]) { + return serializeMIDIClipsForBackend(clips).flatMap(c => c.events.map(e => ({ ...e, timestamp: e.timestamp + c.startTime }))) + .sort((a, b) => a.timestamp - b.timestamp || a.channel - b.channel || (a.velocity ?? 0) - (b.velocity ?? 0)); +} + +describe("joined note identities", () => { + for (const muted of [false, true]) for (const channel of [1, 2]) { + it(`preserves nested pairs, releases and playback with muted=${muted}, channel=${channel}`, () => { + const clips = [clip("long", 3, 4), { ...clip("short", 4, 1, channel), muted }]; + const joined = joinMIDIClips(clips)!; + expect(audible([joined])).toEqual(audible(clips)); + expect(parseMIDINotePairs(joined.events).map(n => [n.startTime, n.duration, n.muted, n.releaseVelocity, n.pressure, n.slide])) + .toEqual([[0, 4, false, 25, 0.3, 0.7], [1, 1, muted, 25, 0.3, 0.7]]); + }); + } + + it("preserves explicit pairs across legacy project validation and JSON round trips without a schema change", () => { + const legacy = clip("legacy", 0, 4); + const joined = joinMIDIClips([legacy, clip("short", 1, 1)])!; + for (const source of [legacy, joined]) { + const parsed = parseValidatedProject(JSON.stringify({ tracks: [{ id: "track", type: "midi", clips: [], midiClips: [source] }] })); + const restored = (parsed.tracks[0].midiClips as MIDIClip[])[0]; + expect(restored.events).toEqual(source.events); + expect(audible([restored])).toEqual(audible([source])); + } + }); + + it("retains pairs through trim, stretch, looping and repeated joining", () => { + const joined = joinMIDIClips([clip("long", 0, 4), clip("short", 1, 1)])!; + const trimmed = { ...joined, offset: 0.5, duration: 3 }; + expect(parseMIDINotePairs(getVisibleMIDIEventsForClip(trimmed)).map(n => [n.startTime, n.duration])) + .toEqual([[0, 3], [0.5, 1]]); + const stretched = { ...joined, duration: 8, events: joined.events.map(e => ({ ...e, timestamp: e.timestamp * 2 })) }; + expect(parseMIDINotePairs(stretched.events).map(n => n.duration)).toEqual([8, 2]); + const looped = { ...joined, loopEnabled: true, sourceLength: 4, loopLength: 4, duration: 8 }; + const repeated = joinMIDIClips([looped, { ...joined, id: "copy", startTime: 10 }])!; + expect(parseMIDINotePairs(repeated.events).map(n => [n.startTime, n.duration])) + .toEqual([[0, 4], [1, 1], [4, 4], [5, 1], [10, 4], [11, 1]]); + }); + + it("edits and copies one simultaneous same-key note without modifying its neighbour", () => { + const joined = joinMIDIClips([clip("long", 0, 4), clip("short", 0, 1)])!; + const pairs = parseMIDINotePairs(joined.events, joined.id); + expect(pairs[0].id).not.toBe(pairs[1].id); + const edited = rebuildMIDIEventsForNotes(joined.events, joined.id, [pairs[1].id], () => ({ velocity: 42 })); + expect(parseMIDINotePairs(edited.events).map(n => [n.velocity, n.duration, n.releaseVelocity])).toEqual([[80, 4, 25], [42, 1, 25]]); + const copied = eventsFromClipboardItems(joined.id, clipboardItemsFromPairs([pairs[1]]), 0.25, 4); + expect(copied.events[0].notePairId).not.toBe(pairs[1].noteOn.notePairId); + expect(parseMIDINotePairs([...joined.events, ...copied.events]).map(n => n.duration)).toEqual([4, 1, 1]); + }); + + it("does not pair orphan tagged events with unrelated legacy releases", () => { + const events: MIDIEvent[] = [{ type: "noteOn", note: 60, timestamp: 0, velocity: 80, notePairId: "lost" }, + { type: "noteOff", note: 60, timestamp: 2 }]; + expect(parseMIDINotePairs(events)).toEqual([]); + }); + + it("keeps metadata edits isolated for simultaneous joined notes with identical releases", () => { + const joined = joinMIDIClips([clip("first", 0, 1), clip("second", 0, 1)])!; + const selected = parseMIDINotePairs(joined.events, joined.id)[1]; + let events = applyNoteMetadataValueToEvents(joined.events, selected, "noteOffVelocity", 48); + events = applyNoteMetadataValueToEvents(events, selected, "chance", 30); + events = applyNoteMetadataValueToEvents(events, selected, "velocityVariance", 12); + expect(parseMIDINotePairs(events).map(n => [n.releaseVelocity, n.probability, n.velocityVariance])) + .toEqual([[25, undefined, undefined], [48, 0.3, 12]]); + }); +}); diff --git a/frontend/src/__tests__/midiNotePairIndex.test.ts b/frontend/src/__tests__/midiNotePairIndex.test.ts new file mode 100644 index 00000000..99f78c7c --- /dev/null +++ b/frontend/src/__tests__/midiNotePairIndex.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { parseMIDINotePairs } from "../utils/midiNotes"; +import type { MIDIEvent } from "../store/useDAWStore"; + +// Retain the prior exhaustive matcher as an independent compatibility oracle. +function reference(events: MIDIEvent[]) { + const used = new Set(); + const pairs: number[][] = []; + events.forEach((on, onIndex) => { + if (on.type !== "noteOn" || on.note === undefined) return; + let offIndex = -1; + events.forEach((off, index) => { + if (used.has(index) || off.type !== "noteOff" || off.note !== on.note + || (off.channel ?? on.channel ?? 1) !== (on.channel ?? off.channel ?? 1) + || off.timestamp <= on.timestamp) return; + if (offIndex < 0 || off.timestamp < events[offIndex].timestamp) offIndex = index; + }); + if (offIndex >= 0) { used.add(offIndex); pairs.push([onIndex, offIndex]); } + }); + return pairs; +} + +describe("indexed MIDI note pairing", () => { + it("preserves source-order consumption, wildcard channels, strict release times and tie order", () => { + let seed = 1234567; + const random = (limit: number) => { seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; return seed % limit; }; + for (let run = 0; run < 60; run++) { + const events: MIDIEvent[] = Array.from({ length: 240 }, () => ({ + type: random(3) === 0 ? "noteOn" : "noteOff", timestamp: random(25) / 10, + note: 60 + random(4), channel: [undefined, 1, 2, 3][random(4)], velocity: random(128), + })); + const before = structuredClone(events); + const pairs = parseMIDINotePairs(events, "fixture"); + expect(pairs.map(pair => [pair.onIndex, pair.offIndex])).toEqual(reference(events)); + expect(events).toEqual(before); + for (const pair of pairs) { + expect(pair.noteOn).toBe(events[pair.onIndex]); + expect(pair.noteOff).toBe(events[pair.offIndex]); + } + } + }); + + it("retains legacy behavior for malformed nonfinite input", () => { + const events: MIDIEvent[] = [ + { type: "noteOn", timestamp: 0, note: 60 }, + { type: "noteOff", timestamp: NaN, note: 60 }, + { type: "noteOff", timestamp: 1, note: 60 }, + { type: "noteOn", timestamp: 2, note: 60, channel: NaN }, + { type: "noteOff", timestamp: 3, note: 60, channel: NaN }, + ]; + expect(parseMIDINotePairs(events).map(pair => [pair.onIndex, pair.offIndex])).toEqual(reference(events)); + }); + + it("bounds event reads for a long, densely overlapping same-pitch recording", () => { + const count = 10000; + let timestampReads = 0; + const events: MIDIEvent[] = Array.from({ length: count * 2 }, (_, i) => ({ + type: i < count ? "noteOn" : "noteOff", note: 60, channel: 1, + get timestamp() { timestampReads++; return i / 10000; }, + })); + const pairs = parseMIDINotePairs(events); + expect(pairs).toHaveLength(count); + expect(pairs[0].offIndex).toBe(count); + expect(pairs[pairs.length - 1].offIndex).toBe(count * 2 - 1); + // A generous input-size budget rejects the former quadratic full scans + // without depending on CPU speed or wall-clock test timing. + expect(timestampReads).toBeLessThan(events.length * 100); + }); +}); diff --git a/frontend/src/__tests__/midiSelectionExport.test.ts b/frontend/src/__tests__/midiSelectionExport.test.ts new file mode 100644 index 00000000..c3787cac --- /dev/null +++ b/frontend/src/__tests__/midiSelectionExport.test.ts @@ -0,0 +1,68 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { nativeBridge } from "../services/NativeBridge"; +import { createDefaultTrack, useDAWStore, type MIDIClip } from "../store/useDAWStore"; +import { exportMIDISelection } from "../utils/midiSelectionExport"; + +afterEach(() => vi.restoreAllMocks()); +function fixture() { + const clip = (id: string, startTime: number, muted = false): MIDIClip => ({ id, name: id, startTime, + duration: 2, offset: 0.5, color: "#abc", muted, loopEnabled: false, + events: [{ type: "noteOn", note: 60, channel: 2, velocity: 80, timestamp: 0 }, + { type: "noteOff", note: 60, channel: 2, velocity: 30, timestamp: 2 }] }); + return { ...useDAWStore.getState(), selectedClipIds: ["a", "b", "muted"], selectedClipId: "a", + transport: { ...useDAWStore.getState().transport, tempo: 120 }, timeSignature: { numerator: 3, denominator: 4 }, + tempoMarkers: [{ id: "first", time: 2, tempo: 90 }, { id: "change", time: 7, tempo: 100 }], + tracks: [{ ...createDefaultTrack("first", "First", "#abc", "midi"), midiClips: [clip("a", 4), clip("excluded", 0)] }, + { ...createDefaultTrack("second", "Second", "#abc", "midi"), midiClips: [clip("b", 9), clip("muted", 12, true)], + midiEffects: [{ id: "pitch", type: "pitch" as const, enabled: true, semitones: 12 }] }], showToast: vi.fn() }; +} + +it("exports one file with separate source tracks, rebased tempo, gaps, trims and MIDI effects applied once", async () => { + const state = fixture(); + const before = JSON.stringify(state.tracks); + vi.spyOn(nativeBridge, "showSaveDialog").mockResolvedValue("selected.mid"); + const write = vi.spyOn(nativeBridge, "exportProjectMIDI").mockResolvedValue(true); + await exportMIDISelection(state); + expect(write).toHaveBeenCalledOnce(); + const [path, tracks, options] = write.mock.calls[0]; + expect(path).toBe("selected.mid"); + expect(tracks.map(t => [t.name, t.clips.map((c: { id: string; startTime: number }) => [c.id, c.startTime])])) + .toEqual([["First", [["a", 0]]], ["Second", [["b", 5]]]]); + expect(tracks[0].clips[0].events.map((e: { timestamp: number }) => e.timestamp)).toEqual([0, 1.5]); + expect(tracks[1].clips[0].events.map((e: { note: number }) => e.note)).toEqual([72, 72]); + expect(options).toEqual({ tempo: 90, tempoMarkers: [{ time: 3, bpm: 100 }], timeSignature: { numerator: 3, denominator: 4 } }); + expect(JSON.stringify(state.tracks)).toBe(before); +}); + +it("snapshots the complete export before the save dialog, even if the project changes", async () => { + const state = fixture(); + let resolve!: (path: string) => void; + vi.spyOn(nativeBridge, "showSaveDialog").mockImplementation(() => new Promise(done => { resolve = done; })); + const write = vi.spyOn(nativeBridge, "exportProjectMIDI").mockResolvedValue(true); + const pending = exportMIDISelection(state); + state.tracks[0].midiClips[0].events[0].note = 70; + state.timeSignature.numerator = 7; + state.tempoMarkers[0].tempo = 200; + state.selectedClipIds = []; + resolve("saved.mid"); + await pending; + expect(write.mock.calls[0][1][0].clips[0].events[0].note).toBe(60); + expect(write.mock.calls[0][2]).toMatchObject({ tempo: 90, timeSignature: { numerator: 3 } }); +}); + +it("cancelling the dialog leaves the document and file writer untouched", async () => { + vi.spyOn(nativeBridge, "showSaveDialog").mockResolvedValue(""); + const write = vi.spyOn(nativeBridge, "exportProjectMIDI"); + const state = fixture(); + await exportMIDISelection(state); + expect(write).not.toHaveBeenCalled(); + expect(state.showToast).not.toHaveBeenCalled(); +}); + +it("reports a rejected native export without an unhandled promise", async () => { + vi.spyOn(nativeBridge, "showSaveDialog").mockResolvedValue("saved.mid"); + vi.spyOn(nativeBridge, "exportProjectMIDI").mockRejectedValue(new Error("Disk full")); + const state = fixture(); + await exportMIDISelection(state); + expect(state.showToast).toHaveBeenCalledWith("Failed to export MIDI clips: Disk full", "error"); +}); diff --git a/frontend/src/__tests__/midiTempoAnalysis.test.ts b/frontend/src/__tests__/midiTempoAnalysis.test.ts new file mode 100644 index 00000000..cf21c775 --- /dev/null +++ b/frontend/src/__tests__/midiTempoAnalysis.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { analyzeMIDIClipTempo } from "../utils/midiTempoAnalysis"; +import type { MIDIClip, MIDIEvent } from "../store/useDAWStore"; + +function clipFor(onsets: number[], duration: number, chord = false): MIDIClip { + const events: MIDIEvent[] = onsets.flatMap((timestamp, index) => (chord ? [48, 60, 67] : [60]).flatMap(note => [ + { type: "noteOn" as const, note, timestamp, velocity: 80 + index % 4 }, + { type: "noteOff" as const, note, timestamp: timestamp + 0.08, velocity: 0 }, + ])).sort((a, b) => a.timestamp - b.timestamp); + return { id: "tempo-fixture", name: "Tempo fixture", startTime: 23.75, duration, offset: 0, + sourceLength: duration, loopEnabled: false, color: "#123456", events }; +} + +describe("MIDI repeated beat-grid analysis", () => { + it.each([90, 120, 137.23, 187.4])("retains a %s BPM pulse without using the project tempo", bpm => { + const onsets = Array.from({ length: 64 }, (_, index) => 0.2 + index * 60 / bpm + (index % 3 - 1) * 0.001); + const result = analyzeMIDIClipTempo(clipFor(onsets, onsets[onsets.length - 1] + 0.5)); + expect(result.error).toBeUndefined(); + expect(result.bpm).toBeCloseTo(bpm, 0); + expect(result.confidence).toBeGreaterThan(0.4); + expect(result.beatOffsetSeconds).toBeGreaterThanOrEqual(0); + expect(result.beatOffsetSeconds).toBeLessThan(60 / result.bpm); + }); + + it("does not count chord pitches as independent beats", () => { + const onsets = Array.from({ length: 48 }, (_, index) => index * 0.5); + const single = analyzeMIDIClipTempo(clipFor(onsets, 24)); + const chords = analyzeMIDIClipTempo(clipFor(onsets, 24, true)); + expect(chords.bpm).toBe(single.bpm); + expect(chords.confidence).toBeCloseTo(single.confidence, 8); + }); + + it("reports changing pulses conservatively instead of inventing certainty", () => { + const slow = Array.from({ length: 24 }, (_, index) => index * 60 / 90); + const fast = Array.from({ length: 40 }, (_, index) => 16 + index * 60 / 150); + const result = analyzeMIDIClipTempo(clipFor([...slow, ...fast], 32)); + expect(Boolean(result.error) || result.confidence <= 0.35).toBe(true); + if (!result.error) expect(result.warning).toContain("Different sections"); + }); + + it("rejects unmetered random onsets", () => { + let seed = 123456; + const onsets = Array.from({ length: 600 }, () => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; + return seed / 2 ** 32 * 40; + }).sort((a, b) => a - b); + const result = analyzeMIDIClipTempo(clipFor(onsets, 40)); + expect(result.error).toBeTruthy(); + expect(result.confidence).toBe(0); + }); + + it("analyzes only the requested retained range", () => { + const slow = Array.from({ length: 20 }, (_, index) => index * 0.75); + const fast = Array.from({ length: 32 }, (_, index) => 16 + index * 0.4); + const result = analyzeMIDIClipTempo(clipFor([...slow, ...fast], 30), { startSeconds: 16, durationSeconds: 12 }); + expect(result.bpm).toBeCloseTo(150, 0); + expect(result.analysisStartSeconds).toBe(16); + expect(result.analysisDurationSeconds).toBe(12); + }); +}); diff --git a/frontend/src/__tests__/midiThumbnailIndex.test.ts b/frontend/src/__tests__/midiThumbnailIndex.test.ts new file mode 100644 index 00000000..42d1bb41 --- /dev/null +++ b/frontend/src/__tests__/midiThumbnailIndex.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { MIDIThumbnailIndex, sampleMIDIThumbnailBars } from "../utils/midiPreview"; +import type { MIDIEvent } from "../store/useDAWStore"; + +const note = (timestamp: number, pitch = 60): MIDIEvent[] => [ + { type: "noteOn", timestamp, note: pitch, velocity: 100 }, + { type: "noteOff", timestamp: timestamp + 0.5, note: pitch, velocity: 0 }, +]; + +describe("indexed MIDI thumbnails", () => { + it("keeps the full pitch range independent of width-dependent sampling", () => { + const index = new MIDIThumbnailIndex(); + index.update(Array.from({ length: 1000 }, (_, i) => note(i, i === 499 ? 84 : 60)).flat()); + for (const width of [64, 256, 260, 1024]) { + expect(index.pitchRange).toEqual({ min: 60, max: 84 }); + expect(sampleMIDIThumbnailBars(index.visible(0, 1000, 1000), width).length) + .toBeLessThanOrEqual(Math.max(64, width / 4)); + } + }); + + it("appends event deltas, grows held notes and closes them without duplicates", () => { + const index = new MIDIThumbnailIndex(); + const first: MIDIEvent = { type: "noteOn", timestamp: 2, note: 64, velocity: 100 }; + const initial = [first]; + index.update(initial); + expect(index.visible(0, 8, 4)).toEqual([{ note: 64, start: 2, end: 4 }]); + index.update([...initial, { type: "noteOff", timestamp: 5, note: 64 }]); + expect(index.visible(0, 8, 8)).toEqual([{ note: 64, start: 2, end: 5 }]); + expect(index.visible(6, 8, 8)).toEqual([]); + }); + + it("queries only visible notes and rebuilds after replacing source events", () => { + const index = new MIDIThumbnailIndex(); + index.update(Array.from({ length: 20000 }, (_, i) => note(i)).flat()); + expect(index.visible(19000, 19010, 20000)).toHaveLength(11); + index.update(note(1, 72)); + expect(index.visible(0, 10, 10)).toEqual([{ note: 72, start: 1, end: 1.5 }]); + expect(index.pitchRange).toEqual({ min: 72, max: 72 }); + }); + + it("pairs overlapping pitches by channel and observes middle-event edits", () => { + const index = new MIDIThumbnailIndex(); + const events: MIDIEvent[] = [ + { type: "noteOn", timestamp: 0, note: 60, channel: 1 }, + { type: "noteOn", timestamp: 1, note: 60, channel: 2 }, + { type: "noteOff", timestamp: 2, note: 60, channel: 2 }, + { type: "noteOff", timestamp: 4, note: 60, channel: 1 }, + ]; + index.update(events); + expect(index.visible(0, 5, 5)).toEqual([{ note: 60, start: 0, end: 4 }, { note: 60, start: 1, end: 2 }]); + index.update([events[0], events[1], { ...events[2], timestamp: 3 }, events[3]]); + expect(index.visible(0, 5, 5)[1].end).toBe(3); + }); + + it("samples dense visible history exactly while cloning only the selected result", () => { + const index = new MIDIThumbnailIndex(); + index.update(Array.from({ length: 50000 }, (_, i) => note(i / 100, i % 128)).flat()); + // The old allocating query must not be used by the sampled query. + const oldVisible = index.visible.bind(index); + index.visible = () => { throw new Error("sampleVisible materialized the whole result"); }; + for (const [start, end, duration] of [[0, 500, 500], [3.17, 490.09, 500], [489, 489.2, 500], [499.9, 501, 500], [600, 610, 700]]) { + for (const width of [0, 63, 256, 257, 999.9, 1600]) { + const expected = sampleMIDIThumbnailBars(oldVisible(start, end, duration), width); + const actual = index.sampleVisible(start, end, duration, width); + expect(actual).toEqual(expected); + expect(actual.length).toBeLessThanOrEqual(Math.max(64, Math.floor(width / 4))); + } + } + const sampled = index.sampleVisible(0, 500, 500, 256); + sampled[0].end = -1; + expect(index.sampleVisible(0, 500, 500, 256)[0].end).toBe(0.5); + }); + + it("preserves held notes, channels, append closure, edits and boundary inclusivity when sampling", () => { + const index = new MIDIThumbnailIndex(); + const events: MIDIEvent[] = [ + { type: "noteOn", timestamp: 0, note: 60, channel: 1 }, + { type: "noteOn", timestamp: 1, note: 60, channel: 2 }, + ...Array.from({ length: 1000 }, (_, i) => note(2 + i / 100, i % 128)).flat(), + ]; + const check = () => { + for (const [start, end, duration] of [[0, 20, 20], [10, 10, 10], [10.1, 11, 12], [12, 20, 20], [0, 20, 0.5]]) + for (const width of [64, 800, 10000]) expect(index.sampleVisible(start, end, duration, width)) + .toEqual(sampleMIDIThumbnailBars(index.visible(start, end, duration), width)); + }; + index.update(events); check(); + index.update([...events, { type: "noteOff", timestamp: 14, note: 60, channel: 2 }, { type: "noteOff", timestamp: 15, note: 60, channel: 1 }]); check(); + index.update(note(4, 72)); check(); + expect(index.sampleVisible(0, 10, 10, NaN)).toHaveLength(1); + }); +}); diff --git a/frontend/src/__tests__/mixerLevel.test.ts b/frontend/src/__tests__/mixerLevel.test.ts new file mode 100644 index 00000000..3dcff44b --- /dev/null +++ b/frontend/src/__tests__/mixerLevel.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { formatMixerLevel, parseMixerLevel } from "../utils/mixerLevel"; + +describe("numeric mixer level entry", () => { + it.each([["-6.25", -6.25], [" +3 dB ", 3], [".5", 0.5], ["-60", -60], ["12", 12], ["-inf", -60], ["-Infinity", -60], ["-∞", -60]])( + "accepts the bounded level %s", (draft, expected) => expect(parseMixerLevel(String(draft))).toBe(expected), + ); + + it.each(["", " ", "-", "6abc", "3,2", "1e2", "Infinity", "NaN", "12.1", "-60.01", "0x10"])( + "keeps invalid draft %s out of the audio engine", (draft) => expect(parseMixerLevel(draft)).toBeNull(), + ); + + it("displays silence and matches the fader precision without changing the stored value", () => { + expect(formatMixerLevel(-60)).toBe("-∞"); + expect(formatMixerLevel(-6.25)).toBe("-6.3"); + expect(formatMixerLevel(0)).toBe("0.0"); + }); +}); diff --git a/frontend/src/__tests__/packedWaveformPeaks.test.ts b/frontend/src/__tests__/packedWaveformPeaks.test.ts new file mode 100644 index 00000000..fd2a55af --- /dev/null +++ b/frontend/src/__tests__/packedWaveformPeaks.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { decodeLegacyWaveformPeaks, decodePackedWaveformPeaks } from "../utils/packedWaveformPeaks"; +import { nativeBridge } from "../services/NativeBridge"; + +function packed(samples: number[], channels = 1) { + const bytes = new Uint8Array(samples.length * 4); + const view = new DataView(bytes.buffer); + samples.forEach((value, index) => view.setFloat32(index * 4, value, true)); + return { format: "openstudio.waveform.f32", version: 1, byteOrder: "little", channels, + peakCount: channels === 0 ? 0 : samples.length / (channels * 2), byteLength: bytes.length, + data: btoa(String.fromCharCode(...bytes)) }; +} + +afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); (nativeBridge as unknown as { isNative: boolean }).isNative = false; }); + +describe("lossless packed waveform transport", () => { + it("preserves stereo Float32 extrema including signed zero, subnormals and extremes", () => { + const values = [-0, 0, -Math.fround(2 ** -149), Math.fround(2 ** -149), + -Math.fround(3.4028234663852886e38), Math.fround(3.4028234663852886e38), -0.125, 0.75]; + const decoded = decodePackedWaveformPeaks(packed(values, 2)); + const actual = decoded.flatMap((peak) => peak.channels.flatMap((channel) => [channel.min, channel.max])); + expect(actual.length).toBe(values.length); + actual.forEach((value, index) => expect(Object.is(value, values[index])).toBe(true)); + }); + + it("accepts explicit empty data while rejecting unknown versions, byte order, dimensions and truncated data", () => { + expect(decodePackedWaveformPeaks(packed([], 0))).toEqual([]); + const valid = packed([-0.5, 0.75]); + for (const patch of [{ version: 2 }, { byteOrder: "big" }, { channels: -1 }, { channels: 65 }, + { channels: 0 }, { peakCount: 0.5 }, { peakCount: 32769 }, { byteLength: 7 }, + { data: "!!!=" }, { data: valid.data.slice(0, -4) }, { error: "source failed" }]) { + expect(() => decodePackedWaveformPeaks({ ...valid, ...patch })).toThrow(); + } + expect(() => decodePackedWaveformPeaks(packed([NaN, 1]))).toThrow(/Non-finite/); + expect(() => decodePackedWaveformPeaks(packed([-1, Infinity]))).toThrow(/Non-finite/); + }); + + it("uses packed native data without invoking the legacy bridge and retains all range arguments", async () => { + const modern = vi.fn().mockResolvedValue(packed([-0.125, 0.75], 1)); + const legacy = vi.fn(); + vi.stubGlobal("window", { __JUCE__: { backend: { getWaveformPeaksPacked: modern, getWaveformPeaks: legacy } } }); + (nativeBridge as unknown as { isNative: boolean }).isNative = true; + expect(await nativeBridge.getWaveformPeaks("trimmed.wav", 1024, 4096, 4096, 4133, 9011)) + .toEqual([{ channels: [{ min: -0.125, max: 0.75 }] }]); + expect(modern).toHaveBeenCalledWith("trimmed.wav", 1024, 4096, 4096, 4133, 9011); + expect(legacy).not.toHaveBeenCalled(); + }); + + it("falls back to exact legacy data after malformed packed data, and fails when no fallback exists", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + const modern = vi.fn().mockResolvedValue({ format: "unknown" }); + const legacy = vi.fn().mockResolvedValue([1, -0.5, 0.25]); + vi.stubGlobal("window", { __JUCE__: { backend: { getWaveformPeaksPacked: modern, getWaveformPeaks: legacy } } }); + (nativeBridge as unknown as { isNative: boolean }).isNative = true; + expect(await nativeBridge.getWaveformPeaks("legacy.wav", 256, 0, 4096)) + .toEqual([{ channels: [{ min: -0.5, max: 0.25 }] }]); + expect(legacy).toHaveBeenCalledOnce(); + legacy.mockResolvedValue([1, NaN, 1]); + await expect(nativeBridge.getWaveformPeaks("legacy.wav", 256, 0, 4096)).rejects.toThrow(/Invalid legacy/); + delete window.__JUCE__!.backend.getWaveformPeaks; + await expect(nativeBridge.getWaveformPeaks("legacy.wav", 256, 0, 4096)).rejects.toThrow(/Unsupported/); + }); + + it("rejects invalid legacy fallback dimensions instead of supplying zero or invented peaks", () => { + for (const value of [null, [0], [65], [2, 0, 1], [1, -1, NaN], [1, "0", 1]]) { + expect(() => decodeLegacyWaveformPeaks(value)).toThrow(); + } + expect(decodeLegacyWaveformPeaks([])).toEqual([]); + }); +}); diff --git a/frontend/src/__tests__/projectSaveSafety.test.ts b/frontend/src/__tests__/projectSaveSafety.test.ts index 459a5a8f..a139f12f 100644 --- a/frontend/src/__tests__/projectSaveSafety.test.ts +++ b/frontend/src/__tests__/projectSaveSafety.test.ts @@ -5,6 +5,7 @@ import { commandManager } from "../store/commands"; import { createDefaultTrack, useDAWStore } from "../store/useDAWStore"; import { advanceProjectEpoch } from "../utils/projectLifetime"; import { sendAutomationParamId } from "../store/automationParams"; +import { interpolateClipGain } from "../utils/clipCrossfades"; const initial = useDAWStore.getState(); const flush = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); }; @@ -36,6 +37,36 @@ describe("project save concurrency and recovery", () => { useDAWStore.setState(initial); }); + it("round-trips optional crossfade layers/import policy and loads older projects with preserve-source defaults", async () => { + const track = createDefaultTrack("track", "Track", "#fff", "audio"); + const base = { name: "Source", filePath: "C:/source.wav", offset: 0, sampleRate: 48000, color: "#fff", volumeDB: 0, fadeIn: 0, fadeOut: 0 }; + track.clips = [{ ...base, id: "bed", startTime: 0, duration: 10, + gainEnvelope: [{ time: 0, gain: 0.5 }, { time: 10, gain: 1.5 }] }, + { ...base, id: "insert", startTime: 2, duration: 2 }]; + useDAWStore.setState({ tracks: [track], selectedClipIds: ["bed", "insert"], syncClipsWithBackend: vi.fn().mockResolvedValue(undefined) }); + vi.spyOn(nativeBridge, "setClipGainEnvelope").mockResolvedValue(true); + expect(useDAWStore.getState().crossfadeSelectedClips()).toBe(true); + useDAWStore.getState().setProjectAudioImportPolicy("project-rate-copy"); + const expectedLayers = useDAWStore.getState().tracks[0].clips[0].crossfadeLayers; + expect(await useDAWStore.getState().saveProject()).toBe(true); + const json = vi.mocked(nativeBridge.saveProjectToFile).mock.calls[0][1]; + expect(JSON.parse(json).version).toBe("1.2.0"); + expect(JSON.parse(json).projectAudioImportPolicy).toBe("project-rate-copy"); + const load = vi.spyOn(nativeBridge, "loadProjectFromFile").mockResolvedValue(json); + expect(await useDAWStore.getState().loadProject("C:/roundtrip.osproj")).toBe(true); + expect(useDAWStore.getState().projectAudioImportPolicy).toBe("project-rate-copy"); + expect(useDAWStore.getState().tracks[0].clips[0].crossfadeLayers).toEqual(expectedLayers); + const envelope = vi.mocked(nativeBridge.setClipGainEnvelope).mock.calls.find(call => call[1] === "bed")![2]; + expect(interpolateClipGain(envelope, 3)).toBe(0); + const legacy = JSON.parse(json); + delete legacy.projectAudioImportPolicy; + for (const clip of legacy.tracks[0].clips) delete clip.crossfadeLayers; + load.mockResolvedValue(JSON.stringify(legacy)); + expect(await useDAWStore.getState().loadProject("C:/older.osproj")).toBe(true); + expect(useDAWStore.getState().projectAudioImportPolicy).toBe("preserve"); + expect(useDAWStore.getState().tracks[0].clips[0].crossfadeLayers).toBeUndefined(); + }); + it.each(["master", "monitor"] as const)("round trips a Safe-only %s control and blocks changed parameter meaning", async chain => { const param = `plugin_${chain}_instance_fingerprint:0`; const stage = [fxSlot({ automationKey: "instance", pluginPath: "vendor.vst3", type: "plugin", state: "opaque" })]; diff --git a/frontend/src/__tests__/recordingWaveformPreview.test.ts b/frontend/src/__tests__/recordingWaveformPreview.test.ts index be784be6..d13a4a6f 100644 --- a/frontend/src/__tests__/recordingWaveformPreview.test.ts +++ b/frontend/src/__tests__/recordingWaveformPreview.test.ts @@ -24,19 +24,21 @@ function deferred() { async function settle() { for (let index = 0; index < 8; index++) await Promise.resolve(); } function fixture() { let current: RecordingPreviewView | null = view(); - let suspended = false; + let interacting = false; + let now = 0; let cache: RecordingWaveformCache = new Map(); const request = vi.fn(async (_track: string, _spp: number, count: number, _start: number) => peaks(count)); const publish = vi.fn((next: RecordingWaveformCache) => { cache = next; }); const onError = vi.fn(); const scheduler = new RecordingPreviewScheduler({ - readView: () => current, isSuspended: () => suspended, request, publish, onError, + readView: () => current, isInteracting: () => interacting, now: () => now, request, publish, onError, }); return { scheduler, request, publish, onError, get cache() { return cache; }, setView(next: RecordingPreviewView | null) { current = next; }, - suspend(value: boolean) { suspended = value; }, + interact(value: boolean) { interacting = value; }, + advanceTime(milliseconds: number) { now += milliseconds; }, }; } @@ -95,8 +97,8 @@ describe("live recording request lifecycle", () => { await running; await settle(); expect(f.request).toHaveBeenCalledTimes(3); - expect(f.request.mock.calls.map(call => call[0])).toEqual(["a", "a", "b"]); - expect(f.publish).toHaveBeenCalledTimes(2); + expect(f.request.mock.calls.map(call => call[0])).toEqual(["a", "b", "a"]); + expect(f.publish).toHaveBeenCalledTimes(3); const final = planRecordingPreviewWindow({ ...view(), pixelsPerSecond: 39, scrollX: 550 * 39 }, view().clips[0])!; expect(f.cache.get("a")?.samplesPerPixel).toBe(final.samplesPerPixel); }); @@ -159,13 +161,23 @@ describe("live recording request lifecycle", () => { } }); - it("suspends fetching during a gesture and retires removed or newly recorded tracks", async () => { + it("updates growing previews during sustained gestures at a bounded cadence", async () => { const f = fixture(); - f.suspend(true); + f.interact(true); await f.scheduler.poll(); - expect(f.request).not.toHaveBeenCalled(); - f.suspend(false); + expect(f.request).toHaveBeenCalledTimes(2); + const before = f.cache.get("a")!.peaks.length; + f.setView({ ...view(), currentTime: 557 }); + for (let n = 0; n < 10; n++) { + f.advanceTime(20); + await f.scheduler.poll(); + } + expect(f.request).toHaveBeenCalledTimes(2); + f.advanceTime(50); await f.scheduler.poll(); + expect(f.request).toHaveBeenCalledTimes(4); + expect(f.cache.get("a")!.peaks.length).toBeGreaterThan(before); + f.interact(false); f.setView({ ...view(), clips: [{ trackId: "a", startTime: 555 }] }); f.scheduler.invalidate(); await f.scheduler.poll(); @@ -173,4 +185,37 @@ describe("live recording request lifecycle", () => { expect(f.cache.get("a")?.recordingStartTime).toBe(555); expect(f.cache.get("a")?.startSample).toBe(0); }); + + it("retains source-correct delayed data during navigation without spinning or crossing a take boundary", async () => { + const f = fixture(); + f.interact(true); + const pending = deferred(); + f.request.mockImplementationOnce(() => pending.promise); + const running = f.scheduler.poll(); + f.setView({ ...view(), pixelsPerSecond: 50, scrollX: 27500 }); + f.scheduler.invalidate(); + pending.resolve(peaks(10)); + await running; + await settle(); + expect(f.request).toHaveBeenCalledTimes(1); + expect(f.cache.get("a")?.samplesPerPixel).toBe(512); + const data = f.cache.get("a")!; + expect(recordingPeakX(data, 0, 50, 27500)).toBeCloseTo(data.startSample / 44100 * 50 - 27500); + f.advanceTime(250); + await f.scheduler.poll(); + expect(f.request.mock.calls[1][0]).toBe("b"); // Slow navigation must not starve another armed track. + expect(f.cache.get("a")?.samplesPerPixel).toBe(1024); + + f.advanceTime(250); + f.setView({ ...view(), pixelsPerSecond: 50, scrollX: 27500, currentTime: 557 }); + const oldTake = deferred(); + f.request.mockImplementationOnce(() => oldTake.promise); + const oldRun = f.scheduler.poll(); + f.scheduler.invalidate(true); // Stop, followed by a new take at the same time. + const published = f.publish.mock.calls.length; + oldTake.resolve(peaks(10)); + await oldRun; + expect(f.publish).toHaveBeenCalledTimes(published); + expect(f.cache.size).toBe(0); + }); }); diff --git a/frontend/src/__tests__/remediationIntegration.test.ts b/frontend/src/__tests__/remediationIntegration.test.ts new file mode 100644 index 00000000..da1a45af --- /dev/null +++ b/frontend/src/__tests__/remediationIntegration.test.ts @@ -0,0 +1,149 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { createDefaultTrack, resetSyncCache, useDAWStore, type AudioClip } from "../store/useDAWStore"; +import { nativeBridge } from "../services/NativeBridge"; +import { commandManager } from "../store/commands"; +import { crossfadeGainAtTime } from "../utils/clipCrossfades"; +import type { ProjectRateCopyResult } from "../services/NativeBridge"; + +const initial = useDAWStore.getState(); +const clip = (id: string, startTime: number, duration: number): AudioClip => ({ + id, name: id, filePath: `C:/${id}.wav`, startTime, duration, offset: 0, + sampleRate: 48000, color: "#abc", volumeDB: 0, fadeIn: 0, fadeOut: 0, +}); +beforeEach(() => { + commandManager.clear(); + useDAWStore.setState({ ...initial, globalLocked: false, autoCrossfade: false, + lockSettings: { ...initial.lockSettings, items: false }, selectedClipIds: [], tracks: [], + syncClipsWithBackend: vi.fn().mockResolvedValue(undefined) }); +}); +afterEach(async () => { await resetSyncCache(); useDAWStore.setState(initial); commandManager.clear(); vi.restoreAllMocks(); }); + +it("can remove a selected crossfade after splitting a member", () => { + useDAWStore.setState({ tracks: [{ ...createDefaultTrack("t", "T"), + clips: [clip("bed", 0, 10), clip("insert", 2, 2)] }], selectedClipIds: ["bed", "insert"] }); + expect(useDAWStore.getState().crossfadeSelectedClips()).toBe(true); + useDAWStore.getState().splitClipAtPosition("bed", 1); + const ids = useDAWStore.getState().tracks[0].clips.map(item => item.id); + expect(useDAWStore.getState().clearClipCrossfades(ids)).toBe(true); + expect(useDAWStore.getState().tracks[0].clips.every(item => !item.crossfadeLayers?.length)).toBe(true); +}); + +it("replanning an expanded crossfade selection preserves unity linear handoffs", () => { + useDAWStore.setState({ tracks: [{ ...createDefaultTrack("t", "T"), + clips: [clip("a", 0, 6), clip("b", 2, 6), clip("c", 4, 6)] }], selectedClipIds: ["a", "b"] }); + expect(useDAWStore.getState().crossfadeSelectedClips()).toBe(true); + useDAWStore.setState({ selectedClipIds: ["a", "b", "c"] }); + expect(useDAWStore.getState().crossfadeSelectedClips()).toBe(true); + const gain = useDAWStore.getState().tracks[0].clips.reduce((sum, item) => + sum + crossfadeGainAtTime(item, 5 - item.startTime), 0); + expect(gain).toBeCloseTo(1, 6); +}); + +it.each([false, true])("creates a project-rate copy without changing muted=%s", async muted => { + await resetSyncCache(); + const published = new Map(); + vi.spyOn(nativeBridge, "clearPlaybackClips").mockImplementation(async () => { published.clear(); return true; }); + vi.spyOn(nativeBridge, "addPlaybackClipsBatch").mockImplementation(async clips => { + for (const item of clips) published.set(item.clipId!, item.filePath); + return true; + }); + // Mirror the native source replacement's required existing ID/path match. + vi.spyOn(nativeBridge, "replacePlaybackClipSource").mockImplementation(async (_track, id, expected, replacement) => { + if (published.get(id) !== expected) return false; + published.set(id, replacement); return true; + }); + vi.spyOn(nativeBridge, "createProjectRateCopy").mockResolvedValue({ success: true, + filePath: "C:/converted.wav", sampleRate: 44100, duration: 10, numChannels: 2 }); + vi.spyOn(nativeBridge, "refreshWaveformPeaks").mockResolvedValue(true); + useDAWStore.setState({ tracks: [{ ...createDefaultTrack("t", "T"), clips: [{ ...clip("a", 0, 10), muted }] }], + projectSampleRate: 44100, audioConversionJobs: {}, showToast: vi.fn(), + syncClipsWithBackend: initial.syncClipsWithBackend }); + await useDAWStore.getState().syncClipsWithBackend(); + await useDAWStore.getState().createProjectRateCopy("t", "a"); + expect(useDAWStore.getState().tracks[0].clips[0]).toMatchObject({ filePath: "C:/converted.wav", muted }); +}); + +async function publicationFixture(muted = false) { + await resetSyncCache(); + const published = new Map(); + vi.spyOn(nativeBridge, "clearPlaybackClips").mockImplementation(async () => { published.clear(); return true; }); + vi.spyOn(nativeBridge, "addPlaybackClipsBatch").mockImplementation(async clips => { + for (const item of clips) published.set(item.clipId!, item.filePath); + return true; + }); + vi.spyOn(nativeBridge, "removePlaybackClipById").mockImplementation(async (_track, id) => { published.delete(id); return true; }); + vi.spyOn(nativeBridge, "replacePlaybackClipSource").mockImplementation(async (_track, id, expected, replacement) => { + if (published.get(id) !== expected) return false; + published.set(id, replacement); return true; + }); + vi.spyOn(nativeBridge, "createProjectRateCopy").mockResolvedValue({ success: true, + filePath: "C:/converted.wav", sampleRate: 44100, duration: 10, numChannels: 2 }); + vi.spyOn(nativeBridge, "refreshWaveformPeaks").mockResolvedValue(true); + useDAWStore.setState({ tracks: [{ ...createDefaultTrack("t", "T"), clips: [{ ...clip("a", 0, 10), muted }] }], + projectSampleRate: 44100, audioConversionJobs: {}, showToast: vi.fn(), syncClipsWithBackend: initial.syncClipsWithBackend }); + await useDAWStore.getState().syncClipsWithBackend(); + return published; +} + +it("publishes the latest rapid source Undo/Redo intent, including mute and unmute", async () => { + const published = await publicationFixture(true); + await useDAWStore.getState().createProjectRateCopy("t", "a"); + expect(published.size).toBe(0); + commandManager.undo(); commandManager.redo(); commandManager.undo(); + await useDAWStore.getState().syncClipsWithBackend(); + expect(useDAWStore.getState().tracks[0].clips[0].filePath).toBe("C:/a.wav"); + expect(published.size).toBe(0); + commandManager.redo(); + useDAWStore.getState().toggleClipMute("a"); + await useDAWStore.getState().syncClipsWithBackend(); + expect(published.get("a")).toBe("C:/converted.wav"); +}); + +it.each(["mute", "changeSource", "delete"])("reconciles a %s while native source publication is pending", async change => { + const published = await publicationFixture(); + let finish!: () => void; + vi.mocked(nativeBridge.replacePlaybackClipSource).mockImplementationOnce(async (_track, id, _expected, replacement) => { + await new Promise(resolve => { finish = resolve; }); + published.set(id, replacement); return true; + }); + const converting = useDAWStore.getState().createProjectRateCopy("t", "a"); + await vi.waitFor(() => expect(finish).toBeTypeOf("function")); + if (change === "mute") useDAWStore.getState().toggleClipMute("a"); + else useDAWStore.setState({ tracks: useDAWStore.getState().tracks.map(track => ({ ...track, + clips: change === "delete" ? [] : track.clips.map(item => ({ ...item, filePath: "C:/other.wav" })) })) }); + finish(); await converting; await useDAWStore.getState().syncClipsWithBackend(); + if (change === "mute") { + expect(useDAWStore.getState().tracks[0].clips[0]).toMatchObject({ muted: true, filePath: "C:/converted.wav" }); + expect(published.size).toBe(0); + } else { + expect(published.get("a")).toBe(change === "delete" ? undefined : "C:/other.wav"); + expect(commandManager.getUndoStack().some(command => command.type === "CREATE_PROJECT_RATE_COPY")).toBe(false); + } +}); + +it("uses current publication state when a muted source is unmuted during conversion", async () => { + const published = await publicationFixture(true); + let finish!: (value: ProjectRateCopyResult) => void; + vi.mocked(nativeBridge.createProjectRateCopy).mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + const converting = useDAWStore.getState().createProjectRateCopy("t", "a"); + useDAWStore.getState().toggleClipMute("a"); + await useDAWStore.getState().syncClipsWithBackend(); + expect(published.get("a")).toBe("C:/a.wav"); + finish({ success: true, filePath: "C:/converted.wav", sampleRate: 44100, duration: 10, numChannels: 2 }); + await converting; + expect(published.get("a")).toBe("C:/converted.wav"); + expect(useDAWStore.getState().tracks[0].clips[0].muted).toBe(false); +}); + +it("reads a muted tempo source without adding it to playback and rejects stale analysis", async () => { + const published = await publicationFixture(true); + let finish!: (value: Awaited>) => void; + const analyze = vi.spyOn(nativeBridge, "analyzeClipTempo").mockImplementation(() => new Promise(resolve => { finish = resolve; })); + useDAWStore.getState().openTempoDetection("t", "a"); + const pending = useDAWStore.getState().analyzeTempoDetectionTarget(1, 5, "analysis-test"); + expect(analyze).toHaveBeenCalledWith("t", "a", 1, 5, expect.objectContaining({ filePath: "C:/a.wav", duration: 10, offset: 0 }), "analysis-test"); + expect(published.size).toBe(0); + useDAWStore.getState().removeClip("t", "a"); + finish({ bpm: 120, confidence: 1, beatOffsetSeconds: 0, candidates: [], source: "audio" }); + expect((await pending).error).toMatch(/changed/); +}); diff --git a/frontend/src/__tests__/staffReviewRegressions.test.ts b/frontend/src/__tests__/staffReviewRegressions.test.ts new file mode 100644 index 00000000..3d656540 --- /dev/null +++ b/frontend/src/__tests__/staffReviewRegressions.test.ts @@ -0,0 +1,48 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { joinMIDIClips } from "../utils/joinMIDIClips"; +import { parseMIDINotePairs } from "../utils/midiNotes"; +import { serializeMIDIClipsForBackend } from "../utils/midiClipSerialization"; +import { prepareImportedProjectRateCopy } from "../store/actions/audioConversion"; +import { nativeBridge } from "../services/NativeBridge"; +import type { DAWActions, DAWState, MIDIClip } from "../store/useDAWStore"; + +vi.mock("../store/useDAWStore", () => ({ createDefaultTrack: vi.fn(), + useDAWStore: { getState: vi.fn(), setState: vi.fn(), subscribe: vi.fn(() => () => {}) } })); +afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); }); + +const first: MIDIClip = { id: "audible", name: "Audible", startTime: 0, duration: 4, color: "red", loopEnabled: false, + events: [{ type: "noteOn", timestamp: 0, note: 60, velocity: 90, channel: 1 }, + { type: "noteOff", timestamp: 4, note: 60, velocity: 0, channel: 1 }] }; +const second: MIDIClip = { id: "nested", name: "Nested", startTime: 1, duration: 1, color: "blue", loopEnabled: false, + events: [{ type: "noteOn", timestamp: 0, note: 60, velocity: 70, channel: 1 }, + { type: "noteOff", timestamp: 1, note: 60, velocity: 0, channel: 1 }] }; + +it("joining a muted nested clip preserves the audible note", () => { + const input = [first, { ...second, muted: true }]; + const before = serializeMIDIClipsForBackend(input).flatMap(clip => clip.events); + const joined = joinMIDIClips(input)!; + const after = serializeMIDIClipsForBackend([joined]).flatMap(clip => clip.events); + expect(after).toEqual(before); +}); + +it("joining nested same-key notes preserves note durations", () => { + const joined = joinMIDIClips([first, second])!; + const notes = parseMIDINotePairs(joined.events).map(note => [note.startTime, note.duration]); + expect(notes).toEqual([[0, 4], [1, 1]]); +}); + +it("revalidates project rate when an import conversion finishes before the polling timer", async () => { + vi.useFakeTimers(); + const state = { projectSampleRate: 44100, projectPath: "C:/project/song.osproj", audioConversionJobs: {}, + showToast: vi.fn() } as unknown as DAWState & DAWActions; + const get = () => state; + const set = (patch: object | ((previous: typeof state) => object)) => Object.assign(state, + typeof patch === "function" ? patch(state) : patch); + let resolve!: (value: any) => void; + vi.spyOn(nativeBridge, "createProjectRateCopy").mockImplementation(() => new Promise(yes => { resolve = yes; })); + const input = { filePath: "C:/audio/source.wav", sampleRate: 48000, duration: 1, numChannels: 2, format: "WAV" }; + const pending = prepareImportedProjectRateCopy(set, get, "track", "clip", input, () => true); + state.projectSampleRate = 96000; + resolve({ success: true, filePath: "C:/project/old44100.wav", sampleRate: 44100 }); + expect(await pending).toEqual(input); +}); diff --git a/frontend/src/__tests__/tempoAudition.test.ts b/frontend/src/__tests__/tempoAudition.test.ts new file mode 100644 index 00000000..b51a81b4 --- /dev/null +++ b/frontend/src/__tests__/tempoAudition.test.ts @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { TempoAuditionPlayer, tempoClickTimes, tempoFromBeatIntervals, tempoFromTapTimes } from "../utils/tempoAudition"; + +afterEach(() => vi.useRealTimers()); + +describe("tempo alignment controls", () => { + it("keeps click phase in clip time across late preview windows and fractional BPM", () => { + for (const bpm of [90, 137.23, 154.3, 239.97]) { + const ticks = tempoClickTimes(bpm, 0.217, 300.125, 8); + expect(ticks.length).toBeGreaterThan(0); + for (const tick of ticks) { + expect(tick.seconds).toBeGreaterThanOrEqual(0); + expect(tick.seconds).toBeLessThan(8); + expect(300.125 + tick.seconds).toBeCloseTo(0.217 + tick.beat * 60 / bpm, 10); + } + } + expect(tempoClickTimes(NaN, 0, 0, 4)).toEqual([]); + }); + it("uses median tap intervals and rejects invalid or stale taps", () => { + expect(tempoFromTapTimes([0, 500, 1001, 1500, 2000])).toBeCloseTo(120, 1); + expect(tempoFromTapTimes([0])).toBeNull(); + expect(tempoFromTapTimes([0, 50])).toBeNull(); + expect(tempoFromTapTimes([0, 5000])).toBeNull(); + }); + it("counts spaces between beats rather than inclusive beat labels", () => { + expect(tempoFromBeatIntervals(10, 12, 4)).toBe(120); + expect(tempoFromBeatIntervals(10, 12, 5)).toBe(150); + expect(tempoFromBeatIntervals(10, 10, 4)).toBeNull(); + expect(tempoFromBeatIntervals(0, 4, 1.5)).toBeNull(); + }); +}); + +class FakeSource { + buffer: unknown; type = "sine"; frequency = { value: 440 }; playbackRate = { value: 1 }; + onended: (() => void) | null = null; + start = vi.fn(); stop = vi.fn(); connect = vi.fn(); disconnect = vi.fn(); +} +class FakeContext { + state = "running"; sampleRate = 48000; currentTime = 10; destination = {}; + sources: FakeSource[] = []; + close = vi.fn(async () => { this.state = "closed"; }); + resume = vi.fn(async () => { this.state = "running"; }); + decodeAudioData = vi.fn(async (_data: ArrayBuffer) => this.createBuffer(1, 4 * 48000, 48000)); + createBuffer(channels: number, count: number, sampleRate: number) { + const data = Array.from({ length: channels }, () => new Float32Array(count)); + return { numberOfChannels: channels, duration: count / sampleRate, getChannelData: (channel: number) => data[channel] }; + } + createBufferSource() { const source = new FakeSource(); this.sources.push(source); return source; } + createOscillator() { return this.createBufferSource(); } + createGain() { return { gain: { value: 1, setValueAtTime: vi.fn(), linearRampToValueAtTime: vi.fn() }, connect: vi.fn(), disconnect: vi.fn() }; } +} + +describe("isolated preview lifecycle", () => { + it("plays source-only without a tempo and permits short windows between clicks", async () => { + const context = new FakeContext(); + const player = new TempoAuditionPlayer(() => context as unknown as AudioContext); + expect(await player.play({ bpm: NaN, phaseSeconds: NaN, rangeStartSeconds: 0, durationSeconds: 4, wavBase64: "AA==", clickLevel: 0 })).toBe(true); + expect(context.sources).toHaveLength(1); + player.stop(); + expect(await player.play({ bpm: 20, phaseSeconds: 1, rangeStartSeconds: 0, durationSeconds: 0.1, wavBase64: "AA==" })).toBe(true); + expect(context.sources).toHaveLength(2); + player.dispose(); + }); + it("retires a pending decode on Stop and never starts its late sources", async () => { + const context = new FakeContext(); + let resolve!: (buffer: ReturnType) => void; + context.decodeAudioData.mockImplementation(() => new Promise(yes => { resolve = yes; })); + const player = new TempoAuditionPlayer(() => context as unknown as AudioContext); + const pending = player.play({ bpm: 120, phaseSeconds: 0.1, rangeStartSeconds: 0, durationSeconds: 4, wavBase64: "AA==" }); + await Promise.resolve(); await Promise.resolve(); + player.stop(); resolve(context.createBuffer(1, 4 * 48000, 48000)); + expect(await pending).toBe(false); + expect(context.sources).toHaveLength(0); + player.dispose(); expect(context.close).toHaveBeenCalledOnce(); + }); + it("schedules source and click on one clock and cancels every source", async () => { + vi.useFakeTimers(); + const context = new FakeContext(), ended = vi.fn(); + const player = new TempoAuditionPlayer(() => context as unknown as AudioContext); + expect(await player.play({ bpm: 120, phaseSeconds: 0.1, rangeStartSeconds: 0, durationSeconds: 4, wavBase64: "AA==", onEnded: ended })).toBe(true); + expect(context.sources[0].start).toHaveBeenCalledWith(10.05, 0, 4); + expect(context.sources[1].start).toHaveBeenCalledWith(10.15); + expect(context.sources[2].start.mock.calls[0][0]).toBeCloseTo(10.65, 10); + player.stop(); + expect(context.sources.every(source => source.stop.mock.calls.length === 1)).toBe(true); + vi.advanceTimersByTime(5000); expect(ended).not.toHaveBeenCalled(); + player.dispose(); + }); + it("bounds guide-note work and automatically retires a completed audition", async () => { + vi.useFakeTimers(); + const context = new FakeContext(), ended = vi.fn(); + const player = new TempoAuditionPlayer(() => context as unknown as AudioContext); + await player.play({ bpm: 120, phaseSeconds: 0, rangeStartSeconds: 0, durationSeconds: 4, + notes: Array.from({ length: 10_000 }, () => ({ startSeconds: 0, durationSeconds: 4, pitch: 60, velocity: 80 })), onEnded: ended }); + expect(context.sources.length).toBeLessThanOrEqual(24); + vi.advanceTimersByTime(4100); expect(ended).toHaveBeenCalledOnce(); + expect(context.sources.every(source => source.stop.mock.calls.length >= 1 && source.disconnect.mock.calls.length >= 1)).toBe(true); + player.dispose(); + }); +}); diff --git a/frontend/src/__tests__/timelineEnhancements.test.ts b/frontend/src/__tests__/timelineEnhancements.test.ts new file mode 100644 index 00000000..46747a20 --- /dev/null +++ b/frontend/src/__tests__/timelineEnhancements.test.ts @@ -0,0 +1,307 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createDefaultTrack, resetSyncCache, useDAWStore, type AudioClip, type MIDIClip } from "../store/useDAWStore"; +import { commandManager } from "../store/commands"; +import { nativeBridge } from "../services/NativeBridge"; +import { serializeMIDIClipsForBackend } from "../utils/midiClipSerialization"; +import { analyzeMIDIClipTempo } from "../utils/midiTempoAnalysis"; +import { midiExportOptions } from "../utils/midiExportOptions"; +import { getEffectiveActionShortcut } from "../store/actionRegistry"; +import { createStretchedMIDIClip } from "../utils/timelineClipStretch"; +import { getClipPlaybackGainEnvelope, interpolateClipGain } from "../utils/clipCrossfades"; + +const initial = useDAWStore.getState(); +const midi = (id: string, startTime = 0, changes: Partial = {}): MIDIClip => ({ + id, name: id, startTime, duration: 2, loopEnabled: false, color: "#abc", + events: [{ type: "noteOn", timestamp: 0, note: 60, velocity: 90, channel: 2 }, + { type: "noteOff", timestamp: 1, note: 60, velocity: 12, releaseVelocity: 12, channel: 2 }], ...changes, +}); +const audio = (id: string, startTime: number, duration: number): AudioClip => ({ + id, name: id, filePath: `C:/${id}.wav`, startTime, duration, offset: 0, volumeDB: 0, + fadeIn: 0, fadeOut: 0, color: "#abc", sampleRate: 48000, +}); +beforeEach(() => { + commandManager.clear(); + useDAWStore.setState({ ...initial, tracks: [], selectedClipIds: [], selectedClipId: null, globalLocked: false, + lockSettings: { ...initial.lockSettings, items: false }, keyboardShortcutProfileId: "openstudio", customShortcuts: {} }); + vi.spyOn(nativeBridge, "addTrack").mockImplementation(async id => id!); + vi.spyOn(nativeBridge, "removeTrack").mockResolvedValue(true); + vi.spyOn(nativeBridge, "setTrackInputChannels").mockResolvedValue(true); + vi.spyOn(nativeBridge, "setTrackChannelCount").mockResolvedValue(true); + vi.spyOn(nativeBridge, "reorderTrack").mockResolvedValue(true); + vi.spyOn(nativeBridge, "setTrackMIDIClips").mockResolvedValue(true); + useDAWStore.setState({ syncClipsWithBackend: vi.fn().mockResolvedValue(undefined), syncMIDITrackToBackend: vi.fn().mockResolvedValue(undefined) }); +}); +afterEach(() => { vi.restoreAllMocks(); useDAWStore.setState(initial); commandManager.clear(); }); + +describe("timeline compound edits", () => { + it("joins trimmed, looped MIDI windows while retaining gaps, channels, CC and clipped note-offs in one undo", () => { + const first = midi("first", 4, { duration: 3, offset: 0.5, sourceLength: 2, loopLength: 2, loopEnabled: true, + ccEvents: [{ cc: 64, value: 127, time: 0.75, channel: 2 }] }); + const second = midi("second", 9, { events: [{ type: "noteOn", timestamp: 0.25, note: 65, velocity: 80, channel: 3 }, + { type: "noteOff", timestamp: 5, note: 65, velocity: 44, channel: 3 }] }); + const track = { ...createDefaultTrack("m", "MIDI", "#abc", "midi"), midiClips: [first, second] }; + useDAWStore.setState({ tracks: [track], selectedClipIds: [first.id, second.id] }); + const before = serializeMIDIClipsForBackend(track.midiClips).flatMap(clip => clip.events.map(event => ({ ...event, timestamp: event.timestamp + clip.startTime }))) + .sort((a, b) => a.timestamp - b.timestamp); + expect(useDAWStore.getState().joinSelectedMIDIClips()).toBe(true); + const joined = useDAWStore.getState().tracks[0].midiClips[0]; + expect(joined).toMatchObject({ id: first.id, startTime: 4, duration: 7, offset: 0, loopEnabled: false }); + const after = serializeMIDIClipsForBackend([joined]).flatMap(clip => clip.events.map(event => ({ ...event, timestamp: event.timestamp + clip.startTime }))) + .sort((a, b) => a.timestamp - b.timestamp); + expect(after).toEqual(before); + expect(commandManager.serialize().undoStack).toHaveLength(1); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks[0].midiClips).toMatchObject([first, second]); + useDAWStore.getState().redo(); + expect(useDAWStore.getState().tracks[0].midiClips).toHaveLength(1); + }); + it("does not join clips across tracks or through an item lock", () => { + const first = midi("a"), second = midi("b", 2); + useDAWStore.setState({ tracks: [{ ...createDefaultTrack("a", "A", "#abc", "midi"), midiClips: [first] }, + { ...createDefaultTrack("b", "B", "#abc", "midi"), midiClips: [second] }], selectedClipIds: ["a", "b"] }); + expect(useDAWStore.getState().joinSelectedMIDIClips()).toBe(false); + expect(commandManager.canUndo()).toBe(false); + }); + it("crossfades the complete overlap without merging media and replays both curves in one undo", () => { + const first = audio("a", 0, 5), second = audio("b", 4, 5); + useDAWStore.setState({ tracks: [{ ...createDefaultTrack("t", "Audio"), clips: [first, second] }], selectedClipIds: ["a", "b"] }); + expect(useDAWStore.getState().applyClipCrossfade("a", "b", { fadeInShape: 1, fadeOutShape: 1 })).toBe(true); + const [outgoing, incoming] = useDAWStore.getState().tracks[0].clips; + expect(outgoing).toMatchObject({ id: "a", fadeOut: 0, crossfadeLayers: [{ memberClipIds: ["a", "b"] }] }); + expect(incoming).toMatchObject({ id: "b", fadeIn: 0, crossfadeLayers: [{ memberClipIds: ["a", "b"] }] }); + expect(interpolateClipGain(getClipPlaybackGainEnvelope(outgoing), 4.5)).toBeCloseTo(Math.SQRT1_2, 3); + expect(interpolateClipGain(getClipPlaybackGainEnvelope(incoming), 0.5)).toBeCloseTo(Math.SQRT1_2, 3); + expect(commandManager.serialize().undoStack).toHaveLength(1); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks[0].clips).toEqual([first, second]); + expect(useDAWStore.getState().editSelectedCrossfade()).toBe(true); + expect(useDAWStore.getState().crossfadeEditorClipIds).toEqual(["a", "b"]); + }); + it("inserts a track at the requested index and moves its clip in a single reversible operation", async () => { + const clip = audio("clip", 1, 3); + const source = { ...createDefaultTrack("a", "A"), clips: [clip], trackChannelCount: 1 }; + const destination = createDefaultTrack("b", "B"); + useDAWStore.setState({ tracks: [source, destination], selectedClipIds: ["clip"] }); + const id = await useDAWStore.getState().moveTimelineClipsToNewTrack({ placements: [{ clipId: "clip", startTime: 8 }], insertIndex: 1, type: "audio" }); + expect(id).toBeTruthy(); + expect(useDAWStore.getState().tracks.map(track => track.id)).toEqual(["a", id, "b"]); + expect(useDAWStore.getState().tracks[1]).toMatchObject({ inputType: "mono", trackChannelCount: 1, clips: [{ id: "clip", startTime: 8 }] }); + expect(nativeBridge.reorderTrack).toHaveBeenCalledWith(id, 1); + expect(commandManager.serialize().undoStack).toHaveLength(1); + useDAWStore.getState().undo(); + await vi.waitFor(() => expect(nativeBridge.removeTrack).toHaveBeenCalledWith(id)); + expect(useDAWStore.getState().tracks).toMatchObject([source, destination]); + useDAWStore.getState().redo(); + await vi.waitFor(() => expect(nativeBridge.addTrack).toHaveBeenCalledTimes(2)); + expect(useDAWStore.getState().tracks[1].clips[0].startTime).toBe(8); + }); + it("rejects a failed native track insertion without changing arrangement or history", async () => { + const track = { ...createDefaultTrack("a", "A"), clips: [audio("clip", 1, 3)] }; + useDAWStore.setState({ tracks: [track] }); + vi.mocked(nativeBridge.addTrack).mockResolvedValueOnce(""); + expect(await useDAWStore.getState().moveTimelineClipsToNewTrack({ placements: [{ clipId: "clip", startTime: 8 }], insertIndex: 0, type: "audio" })).toBeNull(); + expect(useDAWStore.getState().tracks).toEqual([track]); + expect(commandManager.canUndo()).toBe(false); + }); + it("inserts mixed selections as ordered separate rows and restores every row in one Undo", async () => { + const first = { ...createDefaultTrack("a", "Mono"), trackChannelCount: 1, clips: [audio("audio", 1, 3)] }; + const gap = createDefaultTrack("gap", "Unselected"); + const second = { ...createDefaultTrack("m", "Notes", "#abc", "midi"), midiClips: [{ ...midi("midi", 3), ccEvents: [] }] }; + const before = [first, gap, second]; + useDAWStore.setState({ tracks: before, selectedClipIds: ["audio", "midi"] }); + const id = await useDAWStore.getState().moveTimelineClipsToNewTrack({ insertIndex: 1, type: "audio", + placements: [{ clipId: "audio", startTime: 8 }, { clipId: "midi", startTime: 10 }] }); + const state = useDAWStore.getState(), destinations = state.tracks.slice(1, 3); + expect(id).toBe(destinations[0].id); + expect(state.tracks.map(track => track.id)).toEqual(["a", destinations[0].id, destinations[1].id, "gap", "m"]); + expect(destinations[0]).toMatchObject({ type: "audio", inputType: "mono", trackChannelCount: 1, clips: [{ id: "audio", startTime: 8 }], midiClips: [] }); + expect(destinations[1]).toMatchObject({ type: "midi", clips: [], midiClips: [{ id: "midi", startTime: 10 }] }); + expect(commandManager.serialize().undoStack).toHaveLength(1); + useDAWStore.getState().undo(); + await vi.waitFor(() => expect(nativeBridge.removeTrack).toHaveBeenCalledWith(destinations[1].id)); + expect(JSON.parse(JSON.stringify(useDAWStore.getState().tracks))).toEqual(JSON.parse(JSON.stringify(before))); + useDAWStore.getState().redo(); + await vi.waitFor(() => expect(nativeBridge.addTrack).toHaveBeenCalledTimes(4)); + expect(useDAWStore.getState().tracks.slice(1, 3)).toEqual(destinations); + }); + it("rolls back all reserved native rows if a later row cannot be created", async () => { + const before = [{ ...createDefaultTrack("a", "A"), clips: [audio("a", 0, 3)] }, + { ...createDefaultTrack("b", "B"), clips: [audio("b", 1, 3)] }]; + useDAWStore.setState({ tracks: before }); + vi.mocked(nativeBridge.addTrack).mockImplementationOnce(async id => id ?? "").mockResolvedValueOnce(""); + expect(await useDAWStore.getState().moveTimelineClipsToNewTrack({ insertIndex: 0, type: "audio", + placements: [{ clipId: "a", startTime: 4 }, { clipId: "b", startTime: 5 }] })).toBeNull(); + const reserved = vi.mocked(nativeBridge.addTrack).mock.calls[0][0]; + expect(nativeBridge.removeTrack).toHaveBeenCalledWith(reserved); + expect(useDAWStore.getState().tracks).toEqual(before); + expect(commandManager.canUndo()).toBe(false); + }); + it("assigns new editing shortcuts only in OpenStudio's keyboard profile", () => { + expect(getEffectiveActionShortcut("clip.joinSelectedMidi")).toBe("Ctrl+J"); + expect(getEffectiveActionShortcut("clip.crossfadeSelected")).toBe("Shift+X"); + useDAWStore.setState({ keyboardShortcutProfileId: "reaper" }); + expect(getEffectiveActionShortcut("clip.joinSelectedMidi")).toBe(""); + expect(getEffectiveActionShortcut("clip.crossfadeSelected")).toBe(""); + }); + it("synchronizes a curve-only edit through the playback diff cache", async () => { + await resetSyncCache(); + const clip = audio("a", 0, 3); + const batch = vi.spyOn(nativeBridge, "addPlaybackClipsBatch").mockResolvedValue(true); + vi.spyOn(nativeBridge, "clearPlaybackClips").mockResolvedValue(true); + useDAWStore.setState({ tracks: [{ ...createDefaultTrack("t", "T"), clips: [clip] }], syncClipsWithBackend: initial.syncClipsWithBackend }); + await useDAWStore.getState().syncClipsWithBackend(); + expect(batch.mock.calls[0][0][0]).toMatchObject({ fadeInCurve: 0, fadeOutCurve: 0 }); + useDAWStore.getState().setClipFadeInShape("a", 1); + await vi.waitFor(() => expect(batch).toHaveBeenCalledTimes(2)); + expect(batch.mock.calls[1][0][0]).toMatchObject({ fadeInCurve: 1, fadeOutCurve: 0 }); + await resetSyncCache(); + }); +}); + +describe("media import layout and MIDI retention", () => { + it("applies the optional project-rate import policy within the original import Undo and reuses the copy on Redo", async () => { + useDAWStore.setState({ projectAudioImportPolicy: "project-rate-copy", projectSampleRate: 44100 }); + vi.spyOn(nativeBridge, "probeMediaFile").mockResolvedValue({ filePath: "C:/mono.wav", duration: 3, sampleRate: 48000, numChannels: 1, format: "WAV" }); + const copy = vi.spyOn(nativeBridge, "createProjectRateCopy").mockResolvedValue({ success: true, filePath: "C:/cache/mono-44100.wav", sampleRate: 44100, duration: 3, numChannels: 1 }); + const playback = vi.spyOn(nativeBridge, "addPlaybackClip").mockResolvedValue(true); + vi.spyOn(nativeBridge, "removePlaybackClipById").mockResolvedValue(true); + await useDAWStore.getState().importExternalMediaAtTimeline({ filePath: "C:/mono.wav", startTime: 7 }); + expect(useDAWStore.getState().tracks[0].clips[0]).toMatchObject({ filePath: "C:/cache/mono-44100.wav", + originalFilePath: "C:/mono.wav", sampleRate: 44100, startTime: 7, duration: 3, importStatus: "ready" }); + expect(commandManager.getUndoStack()).toHaveLength(1); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks).toHaveLength(0); + useDAWStore.getState().redo(); + await vi.waitFor(() => expect(playback).toHaveBeenCalledTimes(2)); + expect(copy).toHaveBeenCalledTimes(1); + expect(useDAWStore.getState().tracks[0].clips[0].filePath).toBe("C:/cache/mono-44100.wav"); + }); + it("can Undo an import while its rate copy is pending, then Redo without a stranded placeholder", async () => { + useDAWStore.setState({ projectAudioImportPolicy: "project-rate-copy", projectSampleRate: 44100 }); + vi.spyOn(nativeBridge, "probeMediaFile").mockResolvedValue({ filePath: "C:/mono.wav", duration: 3, sampleRate: 48000, numChannels: 1, format: "WAV" }); + let finish!: (value: { success: boolean; filePath: string; sampleRate: number }) => void; + const result = { success: true, filePath: "C:/cache/mono-44100.wav", sampleRate: 44100 }; + const copy = vi.spyOn(nativeBridge, "createProjectRateCopy").mockResolvedValue(result) + .mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + const cancel = vi.spyOn(nativeBridge, "cancelProjectRateCopy").mockResolvedValue(true); + const playback = vi.spyOn(nativeBridge, "addPlaybackClip").mockResolvedValue(true); + vi.spyOn(nativeBridge, "removePlaybackClipById").mockResolvedValue(true); + const importing = useDAWStore.getState().importExternalMediaAtTimeline({ filePath: "C:/mono.wav", startTime: 7 }); + await vi.waitFor(() => expect(copy).toHaveBeenCalledOnce()); + useDAWStore.getState().undo(); + expect(cancel).toHaveBeenCalledOnce(); + finish(result); + await importing; + expect(useDAWStore.getState().tracks).toHaveLength(0); + expect(playback).not.toHaveBeenCalled(); + useDAWStore.getState().redo(); + await vi.waitFor(() => expect(playback).toHaveBeenCalledOnce()); + expect(useDAWStore.getState().tracks[0].clips[0]).toMatchObject({ sampleRate: 44100, importStatus: "ready" }); + }); + it("keeps the original import usable if optional conversion fails or is cancelled", async () => { + useDAWStore.setState({ projectAudioImportPolicy: "project-rate-copy", projectSampleRate: 44100 }); + vi.spyOn(nativeBridge, "probeMediaFile").mockResolvedValue({ filePath: "C:/mono.wav", duration: 3, sampleRate: 48000, numChannels: 1, format: "WAV" }); + vi.spyOn(nativeBridge, "createProjectRateCopy").mockRejectedValue(new Error("conversion worker unavailable")); + vi.spyOn(nativeBridge, "addPlaybackClip").mockResolvedValue(true); + await useDAWStore.getState().importExternalMediaAtTimeline({ filePath: "C:/mono.wav", startTime: 7 }); + expect(useDAWStore.getState().tracks[0].clips[0]).toMatchObject({ filePath: "C:/mono.wav", sampleRate: 48000, importStatus: "ready" }); + expect(commandManager.getUndoStack()).toHaveLength(1); + expect(useDAWStore.getState().audioConversionJobs).toEqual({}); + }); + it("uses probed mono layout for a new track and retains it through Undo/Redo", async () => { + vi.spyOn(nativeBridge, "probeMediaFile").mockResolvedValue({ filePath: "C:/mono.wav", duration: 3, sampleRate: 48000, numChannels: 1, format: "WAV" }); + vi.spyOn(nativeBridge, "addPlaybackClip").mockResolvedValue(true); + vi.spyOn(nativeBridge, "removePlaybackClipById").mockResolvedValue(true); + await useDAWStore.getState().importExternalMediaAtTimeline({ filePath: "C:/mono.wav", startTime: 7, insertIndex: 0 }); + const track = useDAWStore.getState().tracks[0]; + expect(track).toMatchObject({ inputType: "mono", inputChannelCount: 1, trackChannelCount: 1, clips: [{ startTime: 7, sampleRate: 48000 }] }); + expect(nativeBridge.setTrackChannelCount).toHaveBeenLastCalledWith(track.id, 1); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks).toHaveLength(0); + useDAWStore.getState().redo(); + await vi.waitFor(() => expect(nativeBridge.addTrack).toHaveBeenCalledTimes(2)); + expect(useDAWStore.getState().tracks[0]).toMatchObject({ inputType: "mono", trackChannelCount: 1 }); + }); + it("keeps an existing target track's channel layout when importing mono", async () => { + const track = createDefaultTrack("t", "Stereo"); + useDAWStore.setState({ tracks: [track] }); + vi.spyOn(nativeBridge, "probeMediaFile").mockResolvedValue({ filePath: "C:/mono.wav", duration: 3, sampleRate: 48000, numChannels: 1, format: "WAV" }); + vi.spyOn(nativeBridge, "addPlaybackClip").mockResolvedValue(true); + await useDAWStore.getState().importExternalMediaAtTimeline({ filePath: "C:/mono.wav", trackId: "t", startTime: 2 }); + expect(useDAWStore.getState().tracks[0]).toMatchObject({ inputType: "stereo", inputChannelCount: 2, trackChannelCount: 2 }); + expect(nativeBridge.setTrackChannelCount).not.toHaveBeenCalled(); + expect(nativeBridge.addTrack).not.toHaveBeenCalled(); + }); + it("retains imported tempo metadata and the full MIDI event families through Undo/Redo", async () => { + const sourceTempoInfo = { tempo: 96, tempoMarkers: [{ time: 2, bpm: 112 }], timeSignature: { numerator: 6, denominator: 8 } }; + await useDAWStore.getState().importExternalMIDIAtTimeline({ filePath: "C:/phrase.mid", startTime: 5, sourceTempoInfo, + parsedTracks: [{ name: "Phrase", events: [ + { type: "programChange", timestamp: 0, value: 12, channel: 3 }, + { type: "noteOn", timestamp: 0.1, note: 0, velocity: 90, channel: 3 }, + { type: "pitchBend", timestamp: 0.2, value: 0, channel: 3 }, + { type: "channelPressure", timestamp: 0.3, value: 66, channel: 3 }, + { type: "polyPressure", timestamp: 0.4, value: 55, note: 0, channel: 3 }, + { type: "noteOff", timestamp: 1, note: 0, releaseVelocity: 40, channel: 3 }, + ] }] }); + const clip = useDAWStore.getState().tracks[0].midiClips[0]; + expect(clip.sourceTempoInfo).toEqual(sourceTempoInfo); + expect(clip.events).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: "noteOn", note: 0, channel: 3 }), + expect.objectContaining({ type: "noteOff", note: 0, releaseVelocity: 40 }), + expect.objectContaining({ type: "pitchBend", value: 0 }), + expect.objectContaining({ type: "programChange", value: 12 }), + expect.objectContaining({ type: "channelPressure", value: 66 }), + expect.objectContaining({ type: "polyPressure", value: 55, note: 0 }), + ])); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks).toHaveLength(0); + useDAWStore.getState().redo(); + expect(useDAWStore.getState().tracks[0].midiClips[0].sourceTempoInfo).toEqual(sourceTempoInfo); + expect(JSON.parse(JSON.stringify(useDAWStore.getState().tracks[0].midiClips[0])).sourceTempoInfo).toEqual(sourceTempoInfo); + }); +}); + +describe("tempo analysis and portable maps", () => { + it("reads imported tempo metadata at a trimmed visible source start", () => { + const clip = midi("m", 0, { offset: 2.25, duration: 2, sourceTempoInfo: { tempo: 120, + tempoMarkers: [{ time: 2, bpm: 90 }], timeSignature: { numerator: 3, denominator: 4 } } }); + expect(analyzeMIDIClipTempo(clip)).toMatchObject({ source: "midi-metadata", bpm: 90, confidence: 1 }); + expect(analyzeMIDIClipTempo(clip).beatOffsetSeconds).toBeCloseTo(5 / 12); + }); + it("estimates a regular onset pattern with explicit half/double ambiguity", () => { + const events = Array.from({ length: 16 }, (_, index) => [ + { type: "noteOn" as const, timestamp: index * 0.5, note: 60, velocity: 80 }, + { type: "noteOff" as const, timestamp: index * 0.5 + 0.1, note: 60, velocity: 0 }, + ]).flat(); + const report = analyzeMIDIClipTempo(midi("m", 0, { duration: 8, events })); + expect(report).toMatchObject({ source: "midi-estimate", bpm: 120 }); + expect(report.confidence).toBeLessThanOrEqual(0.65); + expect(report.candidates.map(candidate => candidate.bpm)).toEqual(expect.arrayContaining([60, 120, 240])); + expect(report.warning).toContain("beat unit"); + }); + it("does not invent a tempo for a sustained chord", () => { + expect(analyzeMIDIClipTempo(midi("m"))).toMatchObject({ bpm: 0, confidence: 0, candidates: [] }); + }); + it("keeps imported musical metadata aligned with an existing MIDI time-stretch", () => { + const clip = midi("m", 0, { duration: 4, offset: 1, sourceTempoInfo: { tempo: 120, + tempoMarkers: [{ time: 2, bpm: 100 }], timeSignature: { numerator: 4, denominator: 4 } } }); + const stretched = createStretchedMIDIClip(clip, 0, 8); + expect(stretched.sourceTempoInfo).toEqual({ tempo: 60, tempoMarkers: [{ time: 4, bpm: 50 }], timeSignature: { numerator: 4, denominator: 4 } }); + expect(analyzeMIDIClipTempo(stretched)).toMatchObject({ source: "midi-metadata", bpm: 60, confidence: 1 }); + expect(clip.sourceTempoInfo?.tempo).toBe(120); + }); + it("rebases exported tempo changes and changes base BPM with one undo without moving clips", () => { + const clip = audio("a", 6, 3); + useDAWStore.setState({ tracks: [{ ...createDefaultTrack("a", "A"), clips: [clip] }], + tempoMarkers: [{ id: "a", time: 2, tempo: 90 }, { id: "b", time: 8, tempo: 100 }, { id: "c", time: 20, tempo: 110 }] }); + expect(midiExportOptions(useDAWStore.getState(), 6, 12)).toEqual({ tempo: 90, tempoMarkers: [{ time: 2, bpm: 100 }], timeSignature: initial.timeSignature }); + vi.spyOn(nativeBridge, "setTempo").mockResolvedValue(true); + expect(useDAWStore.getState().applyDetectedTempo(128)).toBe(true); + expect(useDAWStore.getState().transport.tempo).toBe(128); + expect(useDAWStore.getState().tracks[0].clips[0]).toEqual(clip); + expect(useDAWStore.getState().isModified).toBe(true); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().transport.tempo).toBe(initial.transport.tempo); + expect(commandManager.canUndo()).toBe(false); + }); +}); diff --git a/frontend/src/__tests__/timelineInsertionTarget.test.ts b/frontend/src/__tests__/timelineInsertionTarget.test.ts new file mode 100644 index 00000000..9dd7ee5f --- /dev/null +++ b/frontend/src/__tests__/timelineInsertionTarget.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { resolveTimelineInsertionIndex } from "../utils/timelineDragAxisLock"; + +describe("shared timeline divider targets", () => { + it("distinguishes track bodies, dividers and the bottom, including variable rows", () => { + const rows = [0, 100, 340]; + expect(resolveTimelineInsertionIndex(50, rows, 440)).toBeNull(); + expect(resolveTimelineInsertionIndex(98, rows, 440)).toBe(1); + expect(resolveTimelineInsertionIndex(342, rows, 440)).toBe(2); + expect(resolveTimelineInsertionIndex(500, rows, 440)).toBe(3); + expect(resolveTimelineInsertionIndex(-20, rows, 440)).toBeNull(); + }); + it("holds an acquired divider through small pointer noise", () => { + expect(resolveTimelineInsertionIndex(106, [0, 100], 200)).toBeNull(); + expect(resolveTimelineInsertionIndex(106, [0, 100], 200, 1)).toBe(1); + expect(resolveTimelineInsertionIndex(110, [0, 100], 200, 1)).toBeNull(); + }); +}); diff --git a/frontend/src/__tests__/timelineRulerDensity.test.ts b/frontend/src/__tests__/timelineRulerDensity.test.ts new file mode 100644 index 00000000..7b5eb280 --- /dev/null +++ b/frontend/src/__tests__/timelineRulerDensity.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { getTimelineRulerDensity } from "../utils/timelineRulerDensity"; +import { calculateGridInterval, resolveVisualGrid } from "../utils/snapToGrid"; + +describe("wide timeline density", () => { + it("keeps bar labels readable and bounds ticks across zoom, meter and distant positions", () => { + for (const tempo of [40, 154, 300]) for (const meter of [ + { numerator: 4, denominator: 4 }, { numerator: 7, denominator: 8 }, { numerator: 3, denominator: 2 }, + ]) for (const zoom of [1, 2, 10, 100, 1000]) for (const scroll of [0, 400000]) { + const density = getTimelineRulerDensity(zoom, tempo, meter, scroll, 1280); + expect(density.secondsPerBar * zoom * density.labelEveryBars).toBeGreaterThanOrEqual(48); + expect(density.secondsPerBar * zoom * density.barStep).toBeGreaterThanOrEqual(8); + expect(density.labelEveryBars % density.barStep).toBe(0); + expect(density.secondsPerBar).toBeCloseTo(60 / tempo * 4 / meter.denominator * meter.numerator); + } + }); + + it("retains fine subdivisions at close zoom", () => { + const density = getTimelineRulerDensity(1000, 120, { numerator: 4, denominator: 4 }, 700, 800); + expect(density.mode).toBe("division"); + expect(density.divisionsPerBeat).toBe(8); + expect(density.barStep).toBe(1); + }); + + it("coarsens only displayed lines, keeping exact snap intervals and bar alignment", () => { + for (const meter of [{ numerator: 4, denominator: 4 }, { numerator: 7, denominator: 8 }]) { + const visual = resolveVisualGrid(154, meter, "1/16", { + pixelsPerSecond: 1, viewportPixels: 1280, maxVisibleLines: 100, minPixelsPerGrid: 18, + }); + expect(visual.gridInterval).toBe(calculateGridInterval(154, meter, "1/16")); + expect(visual.visualInterval).toBeGreaterThanOrEqual(18); + expect(visual.visualInterval / visual.barInterval).toBe(Math.round(visual.visualInterval / visual.barInterval)); + expect(Math.ceil(1280 / visual.visualInterval)).toBeLessThanOrEqual(100); + } + }); +}); diff --git a/frontend/src/__tests__/timelineRulerVisibility.test.tsx b/frontend/src/__tests__/timelineRulerVisibility.test.tsx index 0b383be9..77ab9e3c 100644 --- a/frontend/src/__tests__/timelineRulerVisibility.test.tsx +++ b/frontend/src/__tests__/timelineRulerVisibility.test.tsx @@ -16,7 +16,7 @@ vi.mock("react-konva", () => { return { Stage: container, Layer: container, Rect: () => null, Line: ({ points }: { points: number[] }) => React.createElement("i", { "data-x": points[0] }), - Text: ({ text }: { text: string }) => React.createElement("span", null, text), + Text: ({ text, x }: { text: string; x: number }) => React.createElement("span", { "data-x": x }, text), }; }); vi.mock("../components/Playhead", () => ({ MemoizedPlayhead: () => null })); @@ -40,4 +40,9 @@ describe("zoomed timeline ruler", () => { it("keeps subdivisions when their beat starts offscreen", () => { expect(render(100)).toContain(">1.1.3"); }); + it("places compound-meter bars at the same time as the snap grid", () => { + useDAWStore.setState({ pixelsPerSecond: 1000, scrollX: 1000, + timeSignature: { numerator: 6, denominator: 8 }, transport: { ...original.transport, tempo: 120 } }); + expect(renderToStaticMarkup()).toContain('2'); + }); }); diff --git a/frontend/src/__tests__/transportRecording.test.ts b/frontend/src/__tests__/transportRecording.test.ts index a1a2827b..e071ea29 100644 --- a/frontend/src/__tests__/transportRecording.test.ts +++ b/frontend/src/__tests__/transportRecording.test.ts @@ -3,6 +3,7 @@ import { nativeBridge } from "../services/NativeBridge"; import { createDefaultTrack, type Track, useDAWStore } from "../store/useDAWStore"; import transportBarSource from "../components/TransportBar.tsx?raw"; import { dispatchGlobalShortcut } from "../utils/globalShortcutDispatcher"; +import { advanceProjectEpoch } from "../utils/projectLifetime"; import { activateShortcutContext, registerShortcutSurface, @@ -38,6 +39,7 @@ describe("transport recording", () => { vi.spyOn(nativeBridge, "setTransportPosition").mockResolvedValue(true); vi.spyOn(nativeBridge, "setTransportPlaying").mockResolvedValue(true); vi.spyOn(nativeBridge, "setTransportRecording").mockResolvedValue(true); + vi.spyOn(nativeBridge, "startPreparedRecording").mockResolvedValue(true); vi.spyOn(nativeBridge, "setPunchRange").mockResolvedValue(true); vi.spyOn(nativeBridge, "getLastCompletedClips").mockResolvedValue([]); vi.spyOn(nativeBridge, "getLastCompletedMIDIClips").mockResolvedValue([]); @@ -123,6 +125,7 @@ describe("transport recording", () => { it.each([false, true])("restores playback after rejected recording (already playing: %s)", async (isPlaying) => { const showToast = vi.fn(); vi.mocked(nativeBridge.setTransportRecording).mockResolvedValue(false); + vi.mocked(nativeBridge.startPreparedRecording).mockResolvedValue(false); useDAWStore.setState({ tracks: [armedInstrumentTrack()], transport: { ...initialState.transport, isPlaying, isRecording: false }, @@ -142,11 +145,11 @@ describe("transport recording", () => { let rejectStart!: (accepted: boolean) => void; const start = new Promise((resolve) => { rejectStart = resolve; }); const showToast = vi.fn(); - vi.mocked(nativeBridge.setTransportRecording).mockImplementation(async recording => recording ? start : true); + vi.mocked(nativeBridge.startPreparedRecording).mockReturnValue(start); useDAWStore.setState({ tracks: [armedInstrumentTrack()], showToast, syncClipsWithBackend: vi.fn().mockResolvedValue(undefined) }); const pending = useDAWStore.getState().record(); - await vi.waitFor(() => expect(nativeBridge.setTransportRecording).toHaveBeenCalledWith(true)); + await vi.waitFor(() => expect(nativeBridge.startPreparedRecording).toHaveBeenCalled()); await useDAWStore.getState().stop(); rejectStart(false); await pending; @@ -154,6 +157,71 @@ describe("transport recording", () => { expect(showToast).not.toHaveBeenCalledWith("Failed to start recording", "error"); }); + it("keeps the playhead and live clips stopped until native writer preparation commits", async () => { + let finishPreparation!: (accepted: boolean) => void; + vi.mocked(nativeBridge.startPreparedRecording).mockReturnValue(new Promise(resolve => { finishPreparation = resolve; })); + useDAWStore.setState({ tracks: [armedInstrumentTrack()], + transport: { ...initialState.transport, currentTime: 4, isPlaying: false, isRecording: false }, + syncClipsWithBackend: vi.fn().mockResolvedValue(undefined) }); + const pending = useDAWStore.getState().record(); + await vi.waitFor(() => expect(nativeBridge.startPreparedRecording).toHaveBeenCalledOnce()); + expect(useDAWStore.getState()).toMatchObject({ isPreparingToRecord: true, recordSession: null, recordingClips: [], + transport: { currentTime: 4, isPlaying: false, isRecording: false } }); + expect(nativeBridge.setTransportPosition).toHaveBeenCalledWith(4); + expect(nativeBridge.setTransportPlaying).not.toHaveBeenCalledWith(true); + expect(nativeBridge.setTransportRecording).not.toHaveBeenCalledWith(true); + finishPreparation(true); + await pending; + expect(useDAWStore.getState()).toMatchObject({ isPreparingToRecord: false, + transport: { currentTime: 4, isPlaying: true, isRecording: true }, + recordingClips: [{ trackId: "track-midi", startTime: 4 }] }); + }); + + it("does not publish a late successful prepared start after Stop", async () => { + let finishPreparation!: (accepted: boolean) => void; + vi.mocked(nativeBridge.startPreparedRecording).mockReturnValue(new Promise(resolve => { finishPreparation = resolve; })); + useDAWStore.setState({ tracks: [armedInstrumentTrack()], syncClipsWithBackend: vi.fn().mockResolvedValue(undefined) }); + const pending = useDAWStore.getState().record(); + await vi.waitFor(() => expect(nativeBridge.startPreparedRecording).toHaveBeenCalledOnce()); + await useDAWStore.getState().stop(); + finishPreparation(true); + await pending; + expect(useDAWStore.getState()).toMatchObject({ isPreparingToRecord: false, recordSession: null, recordingClips: [], + transport: { isPlaying: false, isRecording: false } }); + expect(nativeBridge.setTransportPlaying).toHaveBeenCalledWith(false); + expect(nativeBridge.setTransportRecording).toHaveBeenCalledWith(false); + }); + + it("does not publish a prepared start into a replacement project", async () => { + let finishPreparation!: (accepted: boolean) => void; + vi.mocked(nativeBridge.startPreparedRecording).mockReturnValue(new Promise(resolve => { finishPreparation = resolve; })); + useDAWStore.setState({ tracks: [armedInstrumentTrack()], syncClipsWithBackend: vi.fn().mockResolvedValue(undefined) }); + const pending = useDAWStore.getState().record(); + await vi.waitFor(() => expect(nativeBridge.startPreparedRecording).toHaveBeenCalledOnce()); + advanceProjectEpoch(); + useDAWStore.setState({ tracks: [], isPreparingToRecord: false }); + finishPreparation(true); + await pending; + expect(useDAWStore.getState()).toMatchObject({ tracks: [], isPreparingToRecord: false, recordSession: null, + transport: { isPlaying: false, isRecording: false } }); + }); + + it("allows a replacement project to record while the old preparation resolves", async () => { + let finishOldPreparation!: (accepted: boolean) => void; + vi.mocked(nativeBridge.startPreparedRecording).mockReturnValueOnce(new Promise(resolve => { finishOldPreparation = resolve; })); + useDAWStore.setState({ tracks: [armedInstrumentTrack()], syncClipsWithBackend: vi.fn().mockResolvedValue(undefined) }); + const oldStart = useDAWStore.getState().record(); + await vi.waitFor(() => expect(nativeBridge.startPreparedRecording).toHaveBeenCalledTimes(1)); + advanceProjectEpoch(); + useDAWStore.setState({ tracks: [armedInstrumentTrack({ id: "replacement-track" })], isPreparingToRecord: false }); + await useDAWStore.getState().toggleRecord(); + expect(nativeBridge.startPreparedRecording).toHaveBeenCalledTimes(2); + finishOldPreparation(true); + await oldStart; + expect(useDAWStore.getState()).toMatchObject({ isPreparingToRecord: false, + transport: { isPlaying: true, isRecording: true }, recordingClips: [{ trackId: "replacement-track", startTime: 0 }] }); + }); + it("punches in at the playhead captured when record is pressed", async () => { const calls: string[] = []; vi.mocked(nativeBridge.setTransportRecording).mockImplementation(async (recording) => { diff --git a/frontend/src/__tests__/transportUpdateOrdering.test.ts b/frontend/src/__tests__/transportUpdateOrdering.test.ts index 0b16790e..e931db30 100644 --- a/frontend/src/__tests__/transportUpdateOrdering.test.ts +++ b/frontend/src/__tests__/transportUpdateOrdering.test.ts @@ -12,6 +12,19 @@ beforeEach(() => { }); afterEach(() => { bridge.isNative = originalNative; bridge.transportRequestToken = ""; bridge.transportRequestsPending = 0; vi.unstubAllGlobals(); vi.restoreAllMocks(); useDAWStore.setState(initial); }); describe("native transport request ordering", () => { + it("uses one tokenized prepared start and fails closed with an older backend", async () => { + const calls: unknown[][] = []; + vi.stubGlobal("window", { __JUCE__: { backend: { + setTransportStateWithToken: async (...args: unknown[]) => { calls.push(args); return true; }, + } } }); + expect(await nativeBridge.startPreparedRecording()).toBe(true); + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual(["preparedRecording", true, expect.any(String)]); + vi.stubGlobal("window", { __JUCE__: { backend: { setTransportPlaying: vi.fn(), setTransportRecording: vi.fn() } } }); + expect(await nativeBridge.startPreparedRecording()).toBe(false); + expect(window.__JUCE__!.backend.setTransportPlaying).not.toHaveBeenCalled(); + expect(window.__JUCE__!.backend.setTransportRecording).not.toHaveBeenCalled(); + }); it.each(["pause", "stop"] as const)("does not end a new write session while the old %s position request is pending", async action => { const pending = gate(); vi.spyOn(nativeBridge, "setTransportPlaying").mockResolvedValue(true); diff --git a/frontend/src/__tests__/waveformRequestQueue.test.ts b/frontend/src/__tests__/waveformRequestQueue.test.ts new file mode 100644 index 00000000..a089c429 --- /dev/null +++ b/frontend/src/__tests__/waveformRequestQueue.test.ts @@ -0,0 +1,29 @@ +import { expect, it } from "vitest"; +import { WaveformRequestQueue } from "../utils/waveformRequestQueue"; + +it("bounds running work, promotes visible tiles and discards an obsolete viewport", async () => { + const queue = new WaveformRequestQueue(1), order: string[] = []; + let release!: () => void, current = true; + queue.enqueue("first", 0, async () => { order.push("first"); await new Promise(resolve => { release = resolve; }); }, () => true); + await new Promise(resolve => setTimeout(resolve, 0)); + queue.enqueue("ahead", 1, async () => { order.push("ahead"); }, () => true); + queue.enqueue("old", 0, async () => { order.push("old"); }, () => current); + queue.enqueue("visible", 1, async () => { order.push("wrong"); }, () => true); + queue.enqueue("visible", 0, async () => { order.push("visible"); }, () => true); + expect(queue.activeCount).toBe(1); current = false; release(); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(order).toEqual(["first", "visible", "ahead"]); + expect(queue.queuedCount).toBe(0); +}); + +it("bounds queued lookahead and runs a new source generation after an old in-flight request", async () => { + const queue = new WaveformRequestQueue(1, 3), order: number[] = []; + let release!: () => void; + queue.enqueue("same", 0, async () => { order.push(1); await new Promise(resolve => { release = resolve; }); }, () => true); + await new Promise(resolve => setTimeout(resolve, 0)); + for (let i = 0; i < 10; i++) queue.enqueue(`ahead-${i}`, 1, async () => {}, () => true); + expect(queue.queuedCount).toBe(3); + queue.enqueue("same", 0, async () => { order.push(2); }, () => true); + release(); await new Promise(resolve => setTimeout(resolve, 0)); + expect(order).toEqual([1, 2]); expect(queue.activeCount).toBe(0); +}); diff --git a/frontend/src/__tests__/waveformTiles.test.ts b/frontend/src/__tests__/waveformTiles.test.ts new file mode 100644 index 00000000..2a88eca1 --- /dev/null +++ b/frontend/src/__tests__/waveformTiles.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { planWaveformTiles, readWaveformTileWindow, WAVEFORM_TILE_PEAKS } from "../utils/waveformTiles"; +import type { WaveformPeak } from "../services/NativeBridge"; + +const peak = (value: number): WaveformPeak => ({ channels: [{ min: -value, max: value }] }); + +describe("canonical source waveform tiles", () => { + it("reuses fixed complete requests as the prefetch endpoint advances, without growing prefixes", () => { + const observed = new Map(); + for (let end = 1000; end < 8000; end += 73) { + for (const tile of planWaveformTiles("source.wav", 1, 0, end, 0, 90000)) { + observed.set(tile.key, tile.numPixels); + } + } + expect([...observed.values()]).toEqual([WAVEFORM_TILE_PEAKS, WAVEFORM_TILE_PEAKS]); + expect([...observed.keys()]).toEqual(["source.wav-1-0", "source.wav-1-4096"]); + }); + + it("shares interior tiles across trim ranges while both consumers use the same exact boundary keys", () => { + const prefetch = planWaveformTiles("source.wav", 64, 33, 900000, 33, 890111); + const visible = planWaveformTiles("source.wav", 64, 33, 400000, 33, 890111); + expect(visible).toEqual(prefetch.slice(0, visible.length)); + expect(prefetch[0].sourceRangeStartSample).toBe(33); + expect(prefetch[0].sourceRangeEndSample).toBe(-1); + expect(prefetch[1].key).toBe("source.wav-64-262144"); + expect(prefetch[prefetch.length - 1].sourceRangeStartSample).toBe(0); + expect(prefetch[prefetch.length - 1].sourceRangeEndSample).toBe(890111); + const anotherClip = planWaveformTiles("source.wav", 64, 262144, 600000, 91, 999999); + expect(anotherClip[0].key).toBe(prefetch[1].key); + }); + + it("assembles absolute source order without copying peak objects and waits for every requested tile", () => { + const requests = planWaveformTiles("source.wav", 1, 4000, 5000); + const first = [peak(0.1), peak(0.2)], last = [peak(0.3)]; + const sources = new Map([[requests[0].key, first]]); + const windows = new Map(); + expect(readWaveformTileWindow(requests, sources, windows)).toBeUndefined(); + sources.set(requests[1].key, last); + const result = readWaveformTileWindow(requests, sources, windows)!; + expect(result).toEqual([...first, ...last]); + expect(result[0]).toBe(first[0]); + expect(result[2]).toBe(last[0]); + expect(readWaveformTileWindow(requests, sources, windows)).toBe(result); + // New source generation owns a fresh memo, so an old assembled window + // cannot keep invalidated/evicted data visible. + const replaced = new Map([[requests[0].key, [peak(0.9)]], [requests[1].key, last]]); + expect(readWaveformTileWindow(requests, replaced, new Map())![0]).toEqual(peak(0.9)); + }); + + it("does not request beyond a clip end and bounds pathological viewport work", () => { + expect(planWaveformTiles("source.wav", 1, 8000, 20000, 0, 9000).map((tile) => tile.startSample)).toEqual([4096, 8192]); + expect(planWaveformTiles("source.wav", 1, 0, 1e12)).toHaveLength(16); + expect(planWaveformTiles("", 64, 0, 100)).toEqual([]); + expect(planWaveformTiles("source.wav", 0, 0, 100)).toEqual([]); + expect(planWaveformTiles("source.wav", 64, 0, NaN)).toEqual([]); + }); +}); diff --git a/frontend/src/components/AudioConversionStatus.tsx b/frontend/src/components/AudioConversionStatus.tsx new file mode 100644 index 00000000..9e7815d2 --- /dev/null +++ b/frontend/src/components/AudioConversionStatus.tsx @@ -0,0 +1,24 @@ +import { useShallow } from "zustand/react/shallow"; +import { useDAWStore } from "../store/useDAWStore"; + +export function AudioConversionStatus() { + const { jobs, cancel } = useDAWStore(useShallow(state => ({ + jobs: state.audioConversionJobs, cancel: state.cancelProjectRateCopy, + }))); + const entries = Object.entries(jobs); + if (!entries.length) return null; + return
+ {entries.map(([clipId, job]) =>
+ + {job.label ?? clipId} + {job.stage === "cancelling" ? "Cancelling audio copy…" : job.stage === "publishing" ? "Applying audio source…" : `Creating ${job.targetRate / 1000} kHz audio copy…`} + + +
)} +
; +} diff --git a/frontend/src/components/ChannelStrip.tsx b/frontend/src/components/ChannelStrip.tsx index 199fd0e0..12daca3e 100644 --- a/frontend/src/components/ChannelStrip.tsx +++ b/frontend/src/components/ChannelStrip.tsx @@ -3,6 +3,7 @@ import classNames from "classnames"; import { ChevronDown, Power } from "lucide-react"; import { PeakMeter } from "./PeakMeter"; import { MasterPeakMeterCluster } from "./MasterPeakMeterCluster"; +import { MixerLevelInput } from "./MixerLevelInput"; import { resolveTrackMeterPresentation } from "../utils/trackMeterPresentation"; import { useDAWStore, @@ -828,7 +829,13 @@ export const ChannelStrip = React.memo(function ChannelStrip({ : undefined } > - {formatVolume(effectiveVolumeDB)} dB +
{/* M/S/R Buttons for tracks */} diff --git a/frontend/src/components/ContextMenu.tsx b/frontend/src/components/ContextMenu.tsx index 694157ea..2dd990a4 100644 --- a/frontend/src/components/ContextMenu.tsx +++ b/frontend/src/components/ContextMenu.tsx @@ -11,6 +11,7 @@ export interface MenuItem { swatchColor?: string; shortcut?: string; disabled?: boolean; + disabledReason?: string; divider?: boolean; submenu?: MenuItem[]; onClick?: () => void; @@ -93,6 +94,7 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
)} - {item.label} + {item.label} + {item.disabled && item.disabledReason && {item.disabledReason}} +
{item.shortcut && ( diff --git a/frontend/src/components/CrossfadeEditor.tsx b/frontend/src/components/CrossfadeEditor.tsx index 7537021a..34fe03b3 100644 --- a/frontend/src/components/CrossfadeEditor.tsx +++ b/frontend/src/components/CrossfadeEditor.tsx @@ -1,163 +1,117 @@ -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useDAWStore } from "../store/useDAWStore"; import { useShallow } from "zustand/react/shallow"; import { Modal, Button, Select } from "./ui"; -import { Slider } from "./ui"; +import { resolveCrossfadeSelection, getClipPlaybackGainEnvelope, interpolateClipGain, planClipCrossfades } from "../utils/clipCrossfades"; +import { fadeCurve } from "../utils/fadeUtils"; const FADE_SHAPES = [ - { value: 0, label: "Linear" }, - { value: 1, label: "Equal Power" }, - { value: 2, label: "S-Curve" }, - { value: 3, label: "Logarithmic" }, - { value: 4, label: "Exponential" }, + { value: 0, label: "Linear" }, { value: 1, label: "Equal Power" }, + { value: 2, label: "S-Curve" }, { value: 3, label: "Logarithmic" }, { value: 4, label: "Exponential" }, ]; - -// Generate curve preview points -function getCurvePoints(shapeType: number, numPoints: number = 50): Array<{ x: number; y: number }> { - const points: Array<{ x: number; y: number }> = []; - for (let i = 0; i <= numPoints; i++) { - const t = i / numPoints; - let y: number; - switch (shapeType) { - case 1: y = Math.sqrt(t); break; - case 2: y = 3 * t * t - 2 * t * t * t; break; - case 3: y = Math.log10(1 + 9 * t); break; - case 4: y = (Math.exp(3 * t) - 1) / (Math.exp(3) - 1); break; - default: y = t; break; - } - points.push({ x: t, y }); - } - return points; -} - -interface CrossfadeEditorProps { - isOpen: boolean; - onClose: () => void; -} +const COLORS = ["#ef4444", "#60a5fa", "#4ade80", "#fbbf24", "#c084fc", "#2dd4bf"]; +interface CrossfadeEditorProps { isOpen: boolean; onClose: () => void } export function CrossfadeEditor({ isOpen, onClose }: CrossfadeEditorProps) { - const { crossfadeEditorClipIds } = useDAWStore(useShallow((s) => ({ - crossfadeEditorClipIds: s.crossfadeEditorClipIds, + const { ids, tracks, defaultLength, apply, clear } = useDAWStore(useShallow(state => ({ + ids: state.crossfadeEditorClipIds, tracks: state.tracks, defaultLength: state.defaultCrossfadeLength, + apply: state.applyClipCrossfades, clear: state.clearClipCrossfades, }))); - - // Find the two clips - const clip1 = crossfadeEditorClipIds - ? useDAWStore.getState().tracks.flatMap((t) => t.clips).find((c) => c.id === crossfadeEditorClipIds[0]) - : null; - const clip2 = crossfadeEditorClipIds - ? useDAWStore.getState().tracks.flatMap((t) => t.clips).find((c) => c.id === crossfadeEditorClipIds[1]) - : null; - - const [fadeInShape, setFadeInShape] = useState(clip2?.fadeInShape ?? 0); - const [fadeOutShape, setFadeOutShape] = useState(clip1?.fadeOutShape ?? 0); - const [fadeLength, setFadeLength] = useState(() => { - if (clip1 && clip2) { - // Calculate overlap region - const overlapStart = Math.max(clip1.startTime, clip2.startTime); - const overlapEnd = Math.min(clip1.startTime + clip1.duration, clip2.startTime + clip2.duration); - return Math.max(0, overlapEnd - overlapStart); - } - return 0.5; + const affected = useMemo(() => resolveCrossfadeSelection(tracks.flatMap(track => track.clips), ids ?? []), [ids, tracks]); + const { clips, groupIds } = affected; + const groupId = [...groupIds].sort()[0]; + const existing = clips.flatMap(clip => clip.crossfadeLayers ?? []).find(layer => layer.id === groupId); + const [fadeInShape, setFadeInShape] = useState(0); + const [fadeOutShape, setFadeOutShape] = useState(0); + const [automaticLength, setAutomaticLength] = useState(true); + const [lengthDraft, setLengthDraft] = useState("0.05"); + const [error, setError] = useState(""); + useEffect(() => { + const settings = existing?.settings; + setFadeInShape(settings?.fadeInShape ?? 0); + setFadeOutShape(settings?.fadeOutShape ?? 0); + setAutomaticLength(settings?.transitionLength === undefined); + const initialPlan = planClipCrossfades(clips, settings, defaultLength); + setLengthDraft(String(settings?.transitionLength ?? Math.min(defaultLength, + initialPlan?.maximumTransitionLength ?? defaultLength))); + setError(""); + }, [ids]); + const transitionLength = Number(lengthDraft); + const validLength = automaticLength || (lengthDraft.trim() !== "" && Number.isFinite(transitionLength) && transitionLength > 0); + const options = { fadeInShape, fadeOutShape, ...(automaticLength ? {} : { transitionLength }) }; + const sameTrack = tracks.some(track => clips.length > 0 && clips.every(clip => track.clips.some(item => item.id === clip.id))); + const plan = useMemo(() => validLength && sameTrack ? planClipCrossfades(clips, options, defaultLength, groupId) : null, + [clips, fadeInShape, fadeOutShape, automaticLength, transitionLength, validLength, defaultLength, groupId, sameTrack]); + const start = clips.length ? Math.min(...clips.map(clip => clip.startTime)) : 0; + const end = clips.length ? Math.max(...clips.map(clip => clip.startTime + clip.duration)) : 1; + const duration = Math.max(0.001, end - start); + const unchanged = groupIds.size === 1 && Boolean(plan) && clips.every(clip => JSON.stringify(clip.crossfadeLayers?.find(layer => layer.id === groupId)) + === JSON.stringify(plan?.layers.get(clip.id))); + const previews = clips.map(clip => { + const layer = plan?.layers.get(clip.id); + const preview = plan ? { ...clip, crossfadeLayers: [...(clip.crossfadeLayers ?? []).filter(item => !groupIds.has(item.id)), ...(layer ? [layer] : [])] } : clip; + const envelope = getClipPlaybackGainEnvelope(preview); + const times = [...new Set([0, clip.duration, ...envelope.map(point => point.time), + ...Array.from({ length: 129 }, (_, index) => clip.duration * index / 128)])].sort((a, b) => a - b); + return times.map(time => { + let gain = interpolateClipGain(envelope, time); + if (clip.fadeIn > 0 && time < clip.fadeIn) gain *= fadeCurve(time / clip.fadeIn, clip.fadeInShape ?? 0); + if (clip.fadeOut > 0 && time > clip.duration - clip.fadeOut) gain *= fadeCurve((clip.duration - time) / clip.fadeOut, clip.fadeOutShape ?? 0); + return `${(clip.startTime + time - start) / duration * 600},${100 - Math.min(2, gain) * 45}`; + }).join(" "); }); - - const handleApply = () => { - if (!crossfadeEditorClipIds) return; - const store = useDAWStore.getState(); - const [id1, id2] = crossfadeEditorClipIds; - - // Set fade out shape on clip1, fade in shape on clip2 - store.setClipFadeOutShape(id1, fadeOutShape); - store.setClipFadeInShape(id2, fadeInShape); - - // Set fade lengths - store.setClipFades(id1, clip1?.fadeIn ?? 0, fadeLength); - store.setClipFades(id2, fadeLength, clip2?.fadeOut ?? 0); - - onClose(); - }; - - const fadeInPoints = getCurvePoints(fadeInShape); - const fadeOutPoints = getCurvePoints(fadeOutShape); - return ( - -
- {/* Crossfade visualization */} -
- {/* Fade Out curve (clip 1) */} - - `L ${p.x * 100} ${(1 - p.y) * 100}`).join(" ")} L 100 100 L 0 100 Z`} - fill="rgba(239, 68, 68, 0.3)" - stroke="rgb(239, 68, 68)" - strokeWidth="1" - vectorEffect="non-scaling-stroke" - /> - - {/* Fade In curve (clip 2) */} - - `L ${p.x * 100} ${(1 - p.y) * 100}`).join(" ")} L 100 0 L 100 100 Z`} - fill="rgba(59, 130, 246, 0.3)" - stroke="rgb(59, 130, 246)" - strokeWidth="1" - vectorEffect="non-scaling-stroke" - /> - - {/* Labels */} - - {clip1?.name || "Clip 1"} (fade out) - - - {clip2?.name || "Clip 2"} (fade in) - + +
+

{clips.length} clips · {plan?.transitions.length ?? 0} handoffs. Contained clips fade in and back out; the underlying clip returns between edits.

+ {affected.expanded &&

This crossfade also affects the linked clips listed below. Applying or removing it updates the complete group in one Undo step.

} + {groupIds.size > 1 &&

{groupIds.size} connected crossfades will be replaced together. Remove clears these crossfades from all listed clips.

} + {!sameTrack &&

Move the clips onto one track to reapply a crossfade. You can still remove their existing crossfades.

} + + + + {plan?.transitions.map((transition, index) => )} + {previews.map((points, index) => )} + {start.toFixed(3)} s + {end.toFixed(3)} s + +
+ {clips.map((clip, index) => + + {clip.name} + )}
- - {/* Shape selectors */} -
- setFadeInShape(Number(val))} - options={FADE_SHAPES} - /> -
- - {/* Crossfade length */} -
- - setFadeLength(val)} - defaultValue={0.5} - className="w-full" - /> +
+
setFadeInShape(Number(value))} options={FADE_SHAPES} />
- - {/* Actions */} -
- - + + +

Automatic uses the available overlap for adjoining clips and short entry/exit transitions for contained clips. Custom lengths are bounded to keep handoffs separate. Existing clip fades and gain automation stay active.

+ {error &&

{error}

} +
+ {existing && } + +
diff --git a/frontend/src/components/MIDIDeviceSelector.tsx b/frontend/src/components/MIDIDeviceSelector.tsx index 8ed7624f..4a50abb9 100644 --- a/frontend/src/components/MIDIDeviceSelector.tsx +++ b/frontend/src/components/MIDIDeviceSelector.tsx @@ -12,6 +12,7 @@ export function MIDIDeviceSelector({ trackId }: MIDIDeviceSelectorProps) { const [availableInputDevices, setAvailableInputDevices] = useState([]); const [availableOutputDevices, setAvailableOutputDevices] = useState([]); const [openDevices, setOpenDevices] = useState([]); + const [deviceListState, setDeviceListState] = useState<"checking" | "ready" | "unverified">("checking"); const { track, setTrackMIDIOutput } = useDAWStore( useShallow((state) => ({ track: state.tracks.find((t) => t.id === trackId), @@ -25,14 +26,21 @@ export function MIDIDeviceSelector({ trackId }: MIDIDeviceSelectorProps) { }, []); const loadDevices = async () => { + setDeviceListState("checking"); try { const devices = await nativeBridge.getMIDIInputDevices(); const outputs = await nativeBridge.getMIDIOutputDevices(); const open = await nativeBridge.getOpenMIDIDevices(); + if (!Array.isArray(devices) || !devices.every((device) => typeof device === "string") + || !Array.isArray(outputs) || !outputs.every((device) => typeof device === "string")) { + throw new Error("MIDI device lists are unavailable"); + } setAvailableInputDevices(Array.isArray(devices) ? devices : []); setAvailableOutputDevices(Array.isArray(outputs) ? outputs : []); setOpenDevices(Array.isArray(open) ? open : []); + setDeviceListState("ready"); } catch (error) { + setDeviceListState("unverified"); console.error("Failed to load MIDI devices:", error); } }; @@ -126,13 +134,26 @@ export function MIDIDeviceSelector({ trackId }: MIDIDeviceSelectorProps) { // Empty input is DAW-style omni input. The record path opens all available // MIDI inputs for armed tracks in this mode. + // These bridge lists and saved routes both use the backend's device names, + // including legacy saved names. Retain an absent name as an option so the + // native select cannot visually fall back to All Inputs/No Out and imply a + // route change. Only successful enumeration establishes unavailability. + const inputName = track.midiInputDevice || ""; + const outputName = track.midiOutputDevice || ""; + const missingInput = inputName !== "" && !availableInputDevices.includes(inputName); + const missingOutput = outputName !== "" && !availableOutputDevices.includes(outputName); + const missingStatus = deviceListState === "ready" ? "unavailable" : deviceListState; + const inputLabel = missingInput ? `${inputName} (${missingStatus})` : inputName; + const outputLabel = missingOutput ? `${outputName} (${missingStatus})` : outputName; const deviceOptions = [ { value: "", label: "All Inputs" }, ...availableInputDevices.map((device) => ({ value: device, label: device })), + ...(missingInput ? [{ value: inputName, label: inputLabel, disabled: true }] : []), ]; const outputOptions = [ { value: "", label: "No Out" }, ...availableOutputDevices.map((device) => ({ value: device, label: device })), + ...(missingOutput ? [{ value: outputName, label: outputLabel, disabled: true }] : []), ]; // Build channel options: 0 = "All", 1-16 = "Ch 1" through "Ch 16" @@ -153,7 +174,7 @@ export function MIDIDeviceSelector({ trackId }: MIDIDeviceSelectorProps) { options={deviceOptions} value={track.midiInputDevice || ""} onChange={(val) => handleDeviceChange(String(val))} - title={track.midiInputDevice || "All MIDI inputs"} + title={inputLabel || "All MIDI inputs"} className="w-[122px] max-w-[122px] truncate" /> @@ -174,7 +195,7 @@ export function MIDIDeviceSelector({ trackId }: MIDIDeviceSelectorProps) { options={outputOptions} value={track.midiOutputDevice || ""} onChange={(val) => handleOutputChange(String(val))} - title={track.midiOutputDevice ? `MIDI out: ${track.midiOutputDevice}` : "No MIDI output"} + title={outputLabel ? `MIDI out: ${outputLabel}` : "No MIDI output"} className="w-[104px] max-w-[104px] truncate" />
diff --git a/frontend/src/components/MainToolbar.tsx b/frontend/src/components/MainToolbar.tsx index a59e5183..d1ea1054 100644 --- a/frontend/src/components/MainToolbar.tsx +++ b/frontend/src/components/MainToolbar.tsx @@ -108,6 +108,7 @@ export function MainToolbar({ isPlaying, isPaused, isRecording, + isPreparingToRecord, loopEnabled, play, toggleRecord, @@ -144,6 +145,7 @@ export function MainToolbar({ isPlaying: s.transport.isPlaying, isPaused: s.transport.isPaused, isRecording: s.transport.isRecording, + isPreparingToRecord: s.isPreparingToRecord, loopEnabled: s.transport.loopEnabled, play: s.play, toggleRecord: s.toggleRecord, @@ -357,10 +359,10 @@ export function MainToolbar({ variant="danger" size="icon-lg" active={isRecording} - disabled={!hasArmedTracks && !isRecording} + disabled={!hasArmedTracks && !isRecording && !isPreparingToRecord} onClick={() => toggleRecord()} - title={isRecording || hasArmedTracks ? `Record (${recordShortcut})` : "Arm a track to record"} - aria-label={isRecording || hasArmedTracks ? "Record" : "Arm a track to record"} + title={isPreparingToRecord ? "Cancel recording preparation" : isRecording || hasArmedTracks ? `Record (${recordShortcut})` : "Arm a track to record"} + aria-label={isPreparingToRecord ? "Cancel recording preparation" : isRecording || hasArmedTracks ? "Record" : "Arm a track to record"} > @@ -368,6 +370,7 @@ export function MainToolbar({ variant="success" size="icon-lg" active={isPlaying} + disabled={isPreparingToRecord} onClick={() => play()} title="Play (Space)" aria-label="Play" @@ -377,7 +380,7 @@ export function MainToolbar({
-
+ {/* Mixer Snapshots Toolbar */}
diff --git a/frontend/src/components/PanelResizeHandle.tsx b/frontend/src/components/PanelResizeHandle.tsx new file mode 100644 index 00000000..b458b2b2 --- /dev/null +++ b/frontend/src/components/PanelResizeHandle.tsx @@ -0,0 +1,123 @@ +import { useCallback, useEffect, useRef } from "react"; +import { getActiveShortcutContext, registerShortcutSurface } from "../utils/shortcutContext"; + +interface PanelResizeHandleProps { + label: string; + controls: string; + height: number; + min: number; + max: number; + defaultHeight: number; + onResize: (height: number, persist?: boolean) => void; +} + +/** Pointer work is coalesced per frame; preference writes happen on release. */ +export function PanelResizeHandle({ label, controls, height, min, max, defaultHeight, onResize }: PanelResizeHandleProps) { + const gesture = useRef<{ + startY: number; startHeight: number; nextHeight: number; + cursor: string; userSelect: string; + element: HTMLDivElement; pointerId: number; + releaseShortcutScope?: () => void; + } | null>(null); + const frame = useRef(null); + const clampHeight = (value: number) => Math.max(min, Math.min(max, value)); + const finish = useCallback((cancel = false) => { + const active = gesture.current; + if (!active) return; + gesture.current = null; + active.releaseShortcutScope?.(); + if (active.element.hasPointerCapture(active.pointerId)) active.element.releasePointerCapture(active.pointerId); + if (document.activeElement === active.element) active.element.blur(); + if (frame.current !== null) cancelAnimationFrame(frame.current); + frame.current = null; + document.body.style.cursor = active.cursor; + document.body.style.userSelect = active.userSelect; + onResize(cancel ? active.startHeight : active.nextHeight, true); + }, [onResize]); + + useEffect(() => { + const onBlur = () => finish(); + window.addEventListener("blur", onBlur); + return () => { + window.removeEventListener("blur", onBlur); + finish(); + }; + }, [finish]); + + return ( +
{ + if (event.button !== 0) return; + event.preventDefault(); + event.stopPropagation(); + finish(); + event.currentTarget.setPointerCapture(event.pointerId); + gesture.current = { + startY: event.clientY, startHeight: height, nextHeight: height, + cursor: document.body.style.cursor, userSelect: document.body.style.userSelect, + element: event.currentTarget, pointerId: event.pointerId, + }; + // The app dispatches shortcuts at window capture, before onKeyDown. + // Temporarily own Escape so cancelling a drag cannot deselect tracks. + const context = getActiveShortcutContext(); + gesture.current.releaseShortcutScope = registerShortcutSurface(context, (keyEvent) => { + if (keyEvent.key !== "Escape" || keyEvent.ctrlKey || keyEvent.metaKey || keyEvent.altKey || keyEvent.shiftKey) return "unmatched"; + finish(true); + return "handled"; + }, context); + document.body.style.cursor = "row-resize"; + document.body.style.userSelect = "none"; + }} + onPointerMove={(event) => { + const active = gesture.current; + if (!active) return; + active.nextHeight = clampHeight(active.startHeight + active.startY - event.clientY); + if (frame.current === null) { + frame.current = requestAnimationFrame(() => { + frame.current = null; + if (gesture.current) onResize(gesture.current.nextHeight, false); + }); + } + }} + onPointerUp={(event) => { + const active = gesture.current; + if (active) active.nextHeight = clampHeight(active.startHeight + active.startY - event.clientY); + finish(); + }} + onPointerCancel={() => finish()} + onLostPointerCapture={() => finish()} + onDoubleClick={() => onResize(clampHeight(defaultHeight), true)} + onKeyDown={(event) => { + if (event.key === "Escape" && gesture.current) { + event.preventDefault(); + event.stopPropagation(); + finish(true); + return; + } + const step = event.shiftKey ? 48 : 16; + const next = event.key === "ArrowUp" ? height + step + : event.key === "ArrowDown" ? height - step + : event.key === "Home" ? min + : event.key === "End" ? max : null; + if (next === null) return; + event.preventDefault(); + event.stopPropagation(); + onResize(clampHeight(next), true); + }} + > +
+ ); +} diff --git a/frontend/src/components/PianoRoll.css b/frontend/src/components/PianoRoll.css index 8f3d69c4..2265333e 100644 --- a/frontend/src/components/PianoRoll.css +++ b/frontend/src/components/PianoRoll.css @@ -9,6 +9,11 @@ color: #d6dbe3; } +.piano-roll-note-hover { + left: var(--note-hover-x, 4px); + top: var(--note-hover-y, 4px); +} + .piano-roll-key-strip { position: relative; display: flex; @@ -415,6 +420,7 @@ } .piano-roll-stage-wrap { + position: relative; min-width: 0; min-height: 0; } diff --git a/frontend/src/components/PianoRoll.tsx b/frontend/src/components/PianoRoll.tsx index 21ea6889..4f503177 100644 --- a/frontend/src/components/PianoRoll.tsx +++ b/frontend/src/components/PianoRoll.tsx @@ -555,6 +555,12 @@ function getPianoRollClipEventsSnapshot( export function PianoRoll({ clipId, trackId, sessionId, additionalClipIds = [], isDetached = false, onDetach }: PianoRollProps) { const containerRef = useRef(null); + const noteHoverRef = useRef(null); + const hoveredNoteIdRef = useRef(null); + const clearNoteHover = useCallback(() => { + if (noteHoverRef.current) noteHoverRef.current.hidden = true; + hoveredNoteIdRef.current = null; + }, []); const toolbarRef = useRef(null); const controllerLaneSelectorRef = useRef(null); const scrollbarRef = useRef(null); @@ -1411,7 +1417,7 @@ export function PianoRoll({ clipId, trackId, sessionId, additionalClipIds = [], }, [clipStartTime, pixelsPerSecond, timelineScrollX]); const pianoRollHitNotes = useMemo(() => notePairs.map((pair) => ({ - id: noteIdFor(clipId, pair.startTime, pair.noteNumber), + id: noteIdFor(clipId, pair.startTime, pair.noteNumber, pair.noteOn.notePairId), x: PIANO_WIDTH + midiSourceTimeToProjectX(pair.startTime, { pixelsPerSecond, scrollX: timelineScrollX, @@ -1422,6 +1428,11 @@ export function PianoRoll({ clipId, trackId, sessionId, additionalClipIds = [], height: noteHeight, })), [clipId, clipStartTime, getNoteY, noteHeight, notePairs, pixelsPerSecond, timelineScrollX]); + const hoverNotePairs = useMemo(() => new Map(notePairs.map((pair) => [ + noteIdFor(clipId, pair.startTime, pair.noteNumber, pair.noteOn.notePairId), pair, + ])), [clipId, notePairs]); + useEffect(() => clearNoteHover(), [clearNoteHover, pianoRollHitNotes]); + const pianoRollHitLanes = useMemo(() => { if (!activeLane) return []; const activeLaneIsVelocity = activeLane.kind === "velocity"; @@ -1797,7 +1808,7 @@ export function PianoRoll({ clipId, trackId, sessionId, additionalClipIds = [], const currentEvents = getLatestClipEvents(); const currentNoteId = matchingTarget?.currentNoteId ?? wheelHit.noteId; const pair = parseNotePairs(currentEvents).find((candidate) => - noteIdFor(clipId, candidate.startTime, candidate.noteNumber) === currentNoteId, + noteIdFor(clipId, candidate.startTime, candidate.noteNumber, candidate.noteOn.notePairId) === currentNoteId, ); if (!pair) return; const nextStart = Math.max(0, pair.startTime + direction * stepDurationSeconds); @@ -1864,7 +1875,7 @@ export function PianoRoll({ clipId, trackId, sessionId, additionalClipIds = [], const oldEvents = getLatestClipEvents(); const currentNoteId = matchingTarget?.currentNoteId ?? wheelHit.noteId; const pair = parseNotePairs(oldEvents).find((candidate) => - noteIdFor(clipId, candidate.startTime, candidate.noteNumber) === currentNoteId, + noteIdFor(clipId, candidate.startTime, candidate.noteNumber, candidate.noteOn.notePairId) === currentNoteId, ); if (!pair) return; const integerStep = Math.trunc(stepCount); @@ -2090,14 +2101,14 @@ export function PianoRoll({ clipId, trackId, sessionId, additionalClipIds = [], const anchorId = selectedNoteIds[selectedNoteIds.length - 1]; if (!anchorId) return "claimed_noop"; const currentIndex = orderedPairs.findIndex((pair) => ( - noteIdFor(clipId, pair.startTime, pair.noteNumber) === anchorId + noteIdFor(clipId, pair.startTime, pair.noteNumber, pair.noteOn.notePairId) === anchorId )); const nextIndex = currentIndex + (actionId === "midi.selectNextNote" ? 1 : -1); if (currentIndex < 0 || nextIndex < 0 || nextIndex >= orderedPairs.length) { return "claimed_noop"; } const next = orderedPairs[nextIndex]; - setSelectedNoteIds([noteIdFor(clipId, next.startTime, next.noteNumber)]); + setSelectedNoteIds([noteIdFor(clipId, next.startTime, next.noteNumber, next.noteOn.notePairId)]); setMIDIEditRange(null); return "handled"; } @@ -2109,7 +2120,7 @@ export function PianoRoll({ clipId, trackId, sessionId, additionalClipIds = [], } if (actionId === "midi.loopFromSelectedNotes") { const actionPairs = parseNotePairs(getLatestClipEvents()).filter((pair) => - selectedNoteIds.includes(noteIdFor(clipId, pair.startTime, pair.noteNumber)), + selectedNoteIds.includes(noteIdFor(clipId, pair.startTime, pair.noteNumber, pair.noteOn.notePairId)), ); if (actionPairs.length === 0) return "claimed_noop"; const start = Math.min(...actionPairs.map((pair) => pair.startTime)); @@ -2127,7 +2138,7 @@ export function PianoRoll({ clipId, trackId, sessionId, additionalClipIds = [], } if (actionId === "midi.noteProperties") { const selectedPair = parseNotePairs(getLatestClipEvents()).find((pair) => - selectedNoteIds.includes(noteIdFor(clipId, pair.startTime, pair.noteNumber)), + selectedNoteIds.includes(noteIdFor(clipId, pair.startTime, pair.noteNumber, pair.noteOn.notePairId)), ); if (!selectedPair) return "claimed_noop"; setTransformDialog({ type: "velocity", value: selectedPair.velocity }); @@ -2259,7 +2270,7 @@ export function PianoRoll({ clipId, trackId, sessionId, additionalClipIds = [], if (nextIds.length > 0) { setSelectedNoteIds(nextIds); const nextPair = parseNotePairs(getLatestClipEvents()).find((pair) => - nextIds.includes(noteIdFor(clipId, pair.startTime, pair.noteNumber)), + nextIds.includes(noteIdFor(clipId, pair.startTime, pair.noteNumber, pair.noteOn.notePairId)), ); if (nextPair) auditionNote(nextPair.noteNumber, nextPair.velocity); } @@ -3467,7 +3478,7 @@ export function PianoRoll({ clipId, trackId, sessionId, additionalClipIds = [], }); if (!pair) return; - const id = noteIdFor(clipId, pair.startTime, pair.noteNumber); + const id = noteIdFor(clipId, pair.startTime, pair.noteNumber, pair.noteOn.notePairId); const velocity = getVelocityFromLaneY(pos.y); setSelectedNoteIds([id]); setVelocityEdit({ @@ -3632,7 +3643,7 @@ export function PianoRoll({ clipId, trackId, sessionId, additionalClipIds = [], const handleNoteMouseDown = useCallback((event: KonvaEvent, pair: NotePair) => { event.cancelBubble = true; const nativeEvent = event.evt as MouseEvent; - const id = noteIdFor(clipId, pair.startTime, pair.noteNumber); + const id = noteIdFor(clipId, pair.startTime, pair.noteNumber, pair.noteOn.notePairId); const pos = getPointer(event); const noteX = PIANO_WIDTH + pair.startTime * pixelsPerSecond - scrollX; const noteWidth = Math.max(4, pair.duration * pixelsPerSecond); @@ -3695,7 +3706,7 @@ export function PianoRoll({ clipId, trackId, sessionId, additionalClipIds = [], const pairChannel = pair.channel ?? 1; const nextEvents = sortEvents([ ...oldEvents.filter((event) => { - if (event.note !== pair.noteNumber || (event.channel ?? 1) !== pairChannel) return true; + if (event.notePairId !== pair.noteOn.notePairId || event.note !== pair.noteNumber || (event.channel ?? 1) !== pairChannel) return true; if (event.type === "noteOn" && Math.abs(event.timestamp - pair.startTime) < 0.000001) return false; if (event.type === "noteOff" && Math.abs(event.timestamp - noteEnd) < 0.000001) return false; return true; @@ -3707,8 +3718,8 @@ export function PianoRoll({ clipId, trackId, sessionId, additionalClipIds = [], ]); commitMIDIClipEvents(trackId, clipId, oldEvents, nextEvents, "Split MIDI note"); setSelectedNoteIds([ - noteIdFor(clipId, pair.startTime, pair.noteNumber), - noteIdFor(clipId, splitTime, pair.noteNumber), + noteIdFor(clipId, pair.startTime, pair.noteNumber, pair.noteOn.notePairId), + noteIdFor(clipId, splitTime, pair.noteNumber, pair.noteOn.notePairId), ]); auditionNote(pair.noteNumber, pair.velocity); return; @@ -3844,7 +3855,7 @@ export function PianoRoll({ clipId, trackId, sessionId, additionalClipIds = [], event.evt.preventDefault(); event.cancelBubble = true; if (shouldSuppressWorkspaceContextMenu(event.evt.target)) return; - const id = noteIdFor(clipId, pair.startTime, pair.noteNumber); + const id = noteIdFor(clipId, pair.startTime, pair.noteNumber, pair.noteOn.notePairId); if (!selectedNoteIds.includes(id)) { setSelectedNoteIds([id]); } @@ -3865,6 +3876,7 @@ export function PianoRoll({ clipId, trackId, sessionId, additionalClipIds = [], ]); const handleStageMouseDown = useCallback((event: KonvaEvent) => { + clearNoteHover(); const pos = getPointer(event); if (!pos) return; const hitTarget = hitTestPianoRoll(pos.x, pos.y, { @@ -4036,6 +4048,7 @@ export function PianoRoll({ clipId, trackId, sessionId, additionalClipIds = [], ccLaneHeight, ccLaneY, clearMIDIEditRange, + clearNoteHover, clip, clipDuration, contentDuration, @@ -4111,6 +4124,7 @@ export function PianoRoll({ clipId, trackId, sessionId, additionalClipIds = [], const container = stage?.container?.(); if (container) container.style.cursor = cursor; }; + if ((event.evt?.buttons ?? 0) !== 0) clearNoteHover(); if (laneResizeState) { setStageCursor("row-resize"); @@ -4247,6 +4261,21 @@ export function PianoRoll({ clipId, trackId, sessionId, additionalClipIds = [], loopEndX: loopBoundaryEndX, loopBoundaryHitWidth: 6, }); + const hoverNote = hoverTarget.kind === "note" ? hoverNotePairs.get(hoverTarget.noteId) : undefined; + const tooltip = noteHoverRef.current; + if (hoverTarget.kind === "note" && hoverNote && tooltip && (event.evt?.buttons ?? 0) === 0) { + // The existing canvas hit test owns hover. Update only on note entry, + // keeping pointer movement out of React state and reconciliation. + if (hoveredNoteIdRef.current !== hoverTarget.noteId) { + hoveredNoteIdRef.current = hoverTarget.noteId; + tooltip.textContent = `${getNoteNameFromPitch(hoverNote.noteNumber)} (${hoverNote.noteNumber}) · Velocity ${hoverNote.velocity}`; + tooltip.style.setProperty("--note-hover-x", `${Math.max(4, Math.min(pos.x + 12, stageWidth - 210))}px`); + tooltip.style.setProperty("--note-hover-y", `${Math.max(4, Math.min(pos.y + 18, stageHeight - 28))}px`); + tooltip.hidden = false; + } + } else { + clearNoteHover(); + } setStageCursor(hoverTarget.kind === "loop-boundary" && clip?.loopEnabled ? "ew-resize" : ""); }, [ activeControllerLane, @@ -4254,6 +4283,7 @@ export function PianoRoll({ clipId, trackId, sessionId, additionalClipIds = [], ccLaneHeight, ccLaneY, ccDrawState, + clearNoteHover, clip, clipDuration, clipId, @@ -4263,6 +4293,7 @@ export function PianoRoll({ clipId, trackId, sessionId, additionalClipIds = [], getLatestCCEvents, getLatestClipEvents, getNoteFromY, + hoverNotePairs, getTimeFromX, getVelocityFromLaneY, isVelocityLaneActive, @@ -4284,7 +4315,6 @@ export function PianoRoll({ clipId, trackId, sessionId, additionalClipIds = [], snapTime, setTimelineScroll, setPianoRollEditCursorTime, - stageWidth, timelineScrollX, timelineScrollY, trackId, @@ -4299,13 +4329,16 @@ export function PianoRoll({ clipId, trackId, sessionId, additionalClipIds = [], velocityEdit, velocityLaneHeight, velocityLaneY, + stageHeight, + stageWidth, ]); const handleStageMouseLeave = useCallback((event: KonvaEvent) => { + clearNoteHover(); const stage = event.target?.getStage?.(); const container = stage?.container?.(); if (container) container.style.cursor = ""; - }, []); + }, [clearNoteHover]); const handleStageMouseUp = useCallback(() => { const activeCCDrawState = ccDrawStateRef.current; @@ -4420,7 +4453,7 @@ export function PianoRoll({ clipId, trackId, sessionId, additionalClipIds = [], if (velocityEdit) { const finalPair = parseNotePairs(getLatestClipEvents()).find((pair) => - noteIdFor(clipId, pair.startTime, pair.noteNumber) === velocityEdit.noteId + noteIdFor(clipId, pair.startTime, pair.noteNumber, pair.noteOn.notePairId) === velocityEdit.noteId || (Math.abs(pair.startTime - velocityEdit.timestamp) < 0.001 && pair.noteNumber === velocityEdit.noteNumber), ); commitMIDIClipEvents(trackId, clipId, velocityEdit.originalEvents, getLatestClipEvents(), "Edit MIDI velocity"); @@ -4632,7 +4665,7 @@ export function PianoRoll({ clipId, trackId, sessionId, additionalClipIds = [], const localStartTime = Math.max(0, pair.startTime - pair.timeOffset); openPianoRoll(trackId, pair.clipId); useDAWStore.getState().setSelectedNoteIds([ - noteIdFor(pair.clipId, localStartTime, pair.noteNumber), + noteIdFor(pair.clipId, localStartTime, pair.noteNumber, pair.noteOn.notePairId), ]); }, [openPianoRoll, trackId]); @@ -4670,7 +4703,7 @@ export function PianoRoll({ clipId, trackId, sessionId, additionalClipIds = [], const renderPrimaryNotes = () => { return notePairs.map((pair) => { - const id = noteIdFor(clipId, pair.startTime, pair.noteNumber); + const id = noteIdFor(clipId, pair.startTime, pair.noteNumber, pair.noteOn.notePairId); const x = PIANO_WIDTH + pair.startTime * pixelsPerSecond - scrollX; const y = getNoteY(pair.noteNumber); const width = Math.max(4, pair.duration * pixelsPerSecond); @@ -4878,7 +4911,7 @@ export function PianoRoll({ clipId, trackId, sessionId, additionalClipIds = [], width={width} height={barHeight} fill={velocityColor(pair.velocity)} - opacity={pair.muted ? 0.25 : selectedNoteIds.includes(noteIdFor(clipId, pair.startTime, pair.noteNumber)) ? 1 : 0.8} + opacity={pair.muted ? 0.25 : selectedNoteIds.includes(noteIdFor(clipId, pair.startTime, pair.noteNumber, pair.noteOn.notePairId)) ? 1 : 0.8} cornerRadius={1} />, ); @@ -4953,7 +4986,7 @@ export function PianoRoll({ clipId, trackId, sessionId, additionalClipIds = [], const rawValue = noteMetadataValueForPair(pair, selectedNoteMetadataLaneType); const barHeight = (rawValue / maxValue) * (ccLaneHeight - 4); const y = ccLaneY + ccLaneHeight - barHeight - 2; - const id = noteIdFor(clipId, pair.startTime, pair.noteNumber); + const id = noteIdFor(clipId, pair.startTime, pair.noteNumber, pair.noteOn.notePairId); const selected = selectedNoteIds.includes(id); elements.push( - selectedNoteIds.includes(noteIdFor(clipId, pair.startTime, pair.noteNumber)), + selectedNoteIds.includes(noteIdFor(clipId, pair.startTime, pair.noteNumber, pair.noteOn.notePairId)), ); const selectedMuted = selectedPairs.length > 0 && selectedPairs.every((pair) => pair.noteOn.muted || pair.noteOff.muted); const inspectedNotePair = selectedPairs.length === 1 ? selectedPairs[0] : null; @@ -5265,7 +5298,7 @@ export function PianoRoll({ clipId, trackId, sessionId, additionalClipIds = [], const editInspectedNote = useCallback((field: "note" | "start" | "duration" | "velocity", value: number) => { if (!inspectedNotePair) return; - const id = noteIdFor(clipId, inspectedNotePair.startTime, inspectedNotePair.noteNumber); + const id = noteIdFor(clipId, inspectedNotePair.startTime, inspectedNotePair.noteNumber, inspectedNotePair.noteOn.notePairId); if (field === "note") { const nextNote = clamp(Math.round(value), 0, 127); const ids = moveMIDINotes(trackId, clipId, [id], 0, nextNote - inspectedNotePair.noteNumber); @@ -6291,6 +6324,7 @@ export function PianoRoll({ clipId, trackId, sessionId, additionalClipIds = [], /> +
+
- {/* Resample Quality */} -
- Resample Quality: - { stopAudition(); setRangeStartDraft(event.target.value); }} /> + + + +
+ {!validRange &&

Choose an increasing range inside this {duration.toFixed(3)} s clip.

} + + {analyzing &&

Analyzing repeated pulses across the range…

} + {error &&

{error}

} + {result && <> +
{sourceLabel}{confidence < 0.5 ? "Weak" : confidence >= 0.8 ? "Strong" : "Moderate"} pulse evidence ({Math.round(confidence * 100)}%)
+

{result.segments?.length ?? 1} section(s) compared.{result.tempoSpreadBpm !== undefined ? ` Pulse-family spread: ${result.tempoSpreadBpm.toFixed(2)} BPM.` : ""} Evidence strength is not a calibrated probability or a detected downbeat.

+ {result.segments &&
{result.segments.map(segment => )}
} + } +
+ + +
+
{choices.map(value => )}
+ {result?.warning &&

{result.warning}

} + {result && confidence < 0.5 &&

This pulse is uncertain. Audition the candidates, tap, or count beats before applying.

} +
Set tempo by counting beats in the selected range

Set Start and End exactly on beats, then count the spaces between them: beat 1 to beat 5 is 4 intervals. This also works when automatic analysis is uncertain.

+
Audition and align the click +
+ + + + + +
+ {ticks.map(tick => )}{Number.isFinite(previewStart) ? previewStart.toFixed(2) : "0"} s{Number.isFinite(previewStart + previewDuration) ? (previewStart + previewDuration).toFixed(2) : "0"} s +
{auditionState === "preparing" ? "Preparing preview…" : auditionState === "playing" ? "Playing bounded preview" : ""}
+ +

Audition uses your system audio output, which may differ from the DAW audio device.

+

{isMidi ? "MIDI preview uses simple guide tones, not the track’s instrument or FX." : "Dry clip audio includes its gain and fades; track/master FX are excluded."} Use {isMidi ? "Guide only" : "Source only"} while tapping. Close, Stop, changing clips or leaving this window stops the preview. Project transport stays in place.

+ {busyTransport &&

Stop project playback or recording before starting this separate preview.

} + {previewError &&

{previewError}

} +
+

Apply changes the project’s base tempo from {projectTempo} BPM. It does not stretch or move clips, change beat phase, or create tempo markers.

+ {hasTempoMap &&

Existing tempo markers remain and can override the base tempo.

} +
+ ; +} diff --git a/frontend/src/components/Timeline.css b/frontend/src/components/Timeline.css new file mode 100644 index 00000000..4776062b --- /dev/null +++ b/frontend/src/components/Timeline.css @@ -0,0 +1,8 @@ +.timeline-canvas-spacer { + height: var(--timeline-content-height); +} + +.timeline-canvas-viewport { + top: var(--timeline-sticky-top); + height: var(--timeline-viewport-height); +} diff --git a/frontend/src/components/Timeline.tsx b/frontend/src/components/Timeline.tsx index c0376a9a..f98e6cb4 100644 --- a/frontend/src/components/Timeline.tsx +++ b/frontend/src/components/Timeline.tsx @@ -1,5 +1,7 @@ -import React, { useRef, useEffect, useState, useCallback, useMemo, useTransition } from "react"; -import { Stage, Layer, Rect, Line, Text, Group, Circle } from "react-konva"; +import { exportMIDISelection, selectedMIDIExportEntries } from "../utils/midiSelectionExport"; +import "./Timeline.css"; +import React, { useRef, useEffect, useState, useCallback, useMemo, useSyncExternalStore } from "react"; +import { Stage, Layer, Rect, Line, Text, Group, Circle, Shape } from "react-konva"; import type Konva from "konva"; // eslint-disable-next-line @typescript-eslint/no-explicit-any type KonvaEvent = any; // Konva events access .evt.shiftKey, .target.getStage() etc. — full typing requires 70+ null guards @@ -31,8 +33,10 @@ import { WaveformPeak, type ExternalMediaDragEvent, type MIDIImportTrack, + type MIDIExportOptions, } from "../services/NativeBridge"; import { ContextMenu } from "./ContextMenu"; +import { getClipPlaybackGainEnvelope, interpolateClipGain, windowCrossfadeLayers } from "../utils/clipCrossfades"; import { HorizontalScrollbar } from "./HorizontalScrollbar"; import { MemoizedPlayhead as Playhead } from "./Playhead"; import { @@ -43,7 +47,9 @@ import { ticksToSeconds, } from "../utils/snapToGrid"; import { getRulerClickSnapTime } from "../utils/rulerClickSnap"; +import { getTimelineRulerDensity } from "../utils/timelineRulerDensity"; import { getRulerDragZoom, resolveRulerDragAxis } from "../utils/timelineRulerDrag"; +import { planWaveformTiles, readWaveformTileWindow, type WaveformTileRequest } from "../utils/waveformTiles"; import { RecordingPreviewScheduler, recordingPeakX, @@ -62,6 +68,7 @@ import { import { fadeInCurvePoints, fadeOutCurvePoints, + fadeCurve, } from "../utils/fadeUtils"; import { getAutomationColor, @@ -78,7 +85,7 @@ import { type InsertableTrackType, } from "../utils/trackCreation"; import { buildScrollbarOverview } from "../utils/scrollbarOverview"; -import { buildMIDIThumbnailBars, sampleMIDIThumbnailBars } from "../utils/midiPreview"; +import { buildMIDIThumbnailBars, MIDIThumbnailIndex, sampleMIDIThumbnailBars } from "../utils/midiPreview"; import { getMIDIClipSourceLoopLength, getVisibleMIDIEventsForClip, serializeMIDIClipsForBackend } from "../utils/midiClipSerialization"; import { buildTimelineClipHitMap, @@ -94,6 +101,7 @@ import { } from "../utils/timelineClipGestures"; import { resolveTimelineDropTrackIndex, + resolveTimelineInsertionIndex, type TimelineDragAxisLock, } from "../utils/timelineDragAxisLock"; import { @@ -189,6 +197,10 @@ import { createTimelineGestureUndoCommand, reconcileTimelineTrackTopology, } from "../utils/timelineGestureUndo"; +import { midiExportOptions } from "../utils/midiExportOptions"; +import { getProjectEpoch } from "../utils/projectLifetime"; +import { WaveformRequestQueue } from "../utils/waveformRequestQueue"; +import { canEditSelectedCrossfade, midiJoinSelection, selectedCrossfadePairs } from "../store/actions/timelineEnhancements"; // Constants const RULER_HEIGHT = 30; @@ -286,6 +298,7 @@ type ExternalMediaDragContext = { sampleRate?: number; peaks?: WaveformPeak[]; midiTracks?: MIDIImportTrack[]; + sourceTempoInfo?: MIDIExportOptions; midiEvents?: MIDIEvent[]; midiTrackCount?: number; }; @@ -402,6 +415,7 @@ type TimelineDragState = { clipId: string | null; trackIndex: number | null; targetTrackIndex: number | null; + insertTrackIndex?: number; startX: number; startY: number; startTime: number; @@ -568,6 +582,7 @@ const cloneTimelineGestureTracks = (tracks: Track[]): Track[] => clips: track.clips.map((clip) => ({ ...clip, gainEnvelope: clip.gainEnvelope ? clip.gainEnvelope.map((point) => ({ ...point })) : clip.gainEnvelope, + crossfadeLayers: clip.crossfadeLayers?.map((layer) => ({ ...layer, memberClipIds: [...layer.memberClipIds], points: layer.points.map(point => ({ ...point })), settings: { ...layer.settings } })), })), midiClips: track.midiClips.map((clip) => ({ ...clip, @@ -672,6 +687,315 @@ const interpolateAutomationDrawSegment = ( return points; }; +interface RecordingWaveformStore { + getSnapshot: () => RecordingWaveformCache; + subscribe: (listener: () => void) => () => void; + publish: (cache: RecordingWaveformCache) => void; +} + +function createRecordingWaveformStore(): RecordingWaveformStore { + let cache: RecordingWaveformCache = new Map(); + const listeners = new Set<() => void>(); + return { + getSnapshot: () => cache, + subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener); }; }, + publish: (next) => { cache = next; listeners.forEach(listener => listener()); }, + }; +} + +interface RecordingClipsLayerProps { + tracks: Track[]; + trackYs: number[]; + trackHeight: number; + pixelsPerSecond: number; + scrollX: number; + scrollY: number; + viewportWidth: number; + viewportHeight: number; + waveforms: RecordingWaveformStore; + renderCompactMIDIThumbnail: ( + events: MIDIEvent[], clipDuration: number, x: number, width: number, + clipY: number, previewHeight: number, clipColor: string, isMuted: boolean, + activeNotes?: { note: number; startTimestamp: number }[], cacheId?: string, + ) => React.ReactNode; +} + +// Growing takes repaint their own canvas. Completed clip artwork and its event +// handlers do not need React reconciliation or canvas redraw at preview cadence. +const RecordingClipsLayer = React.memo(function RecordingClipsLayer({ + tracks, trackYs, trackHeight, pixelsPerSecond, scrollX, scrollY, + viewportWidth, viewportHeight, waveforms, renderCompactMIDIThumbnail, +}: RecordingClipsLayerProps) { + const { recordingClips, recordingMIDIPreviews, isRecording } = useDAWStore(useShallow(state => ({ + recordingClips: state.recordingClips, + recordingMIDIPreviews: state.recordingMIDIPreviews, + isRecording: state.transport.isRecording, + }))); + const recordingWaveformCache = useSyncExternalStore(waveforms.subscribe, waveforms.getSnapshot, waveforms.getSnapshot); + const [recordingRenderTime, setRecordingRenderTime] = useState( + () => useDAWStore.getState().transport.currentTime, + ); + + useEffect(() => { + if (!isRecording || recordingClips.length === 0) { + setRecordingRenderTime(useDAWStore.getState().transport.currentTime); + return; + } + + // Clip growth does not need the playhead's 60 Hz React update rate. + // Playhead keeps its independent imperative transport subscription. + const updateGeometry = () => setRecordingRenderTime(useDAWStore.getState().transport.currentTime); + updateGeometry(); + const timer = window.setInterval(updateGeometry, RECORDING_GEOMETRY_INTERVAL_MS); + return () => window.clearInterval(timer); + }, [isRecording, recordingClips.length]); + + useEffect(() => { + const recordingMIDITrackIds = recordingClips + .map((clip) => clip.trackId) + .filter((trackId) => { + const track = tracks.find((candidate) => candidate.id === trackId); + return track?.type === "midi" || track?.type === "instrument"; + }); + + if (!isRecording || recordingMIDITrackIds.length === 0) { + if (Object.keys(useDAWStore.getState().recordingMIDIPreviews).length > 0) { + useDAWStore.setState({ recordingMIDIPreviews: {} }); + } + return; + } + + let cancelled = false; + let inFlight = false; + + const fetchRecordingMIDIPreviews = async () => { + if (inFlight) return; + inFlight = true; + + try { + const state = useDAWStore.getState(); + const requests = recordingMIDITrackIds.map((trackId) => { + const preview = state.recordingMIDIPreviews[trackId]; + return { + trackId, + generation: preview?.generation ?? 0, + knownEventCount: preview?.totalEventCount ?? 0, + }; + }); + + const previews = await nativeBridge.getActiveRecordingMIDIPreviews(requests); + if (cancelled || previews.length === 0) return; + + useDAWStore.setState((currentState) => { + const nextPreviews = Object.fromEntries( + recordingMIDITrackIds + .filter((trackId) => currentState.recordingMIDIPreviews[trackId] !== undefined) + .map((trackId) => [trackId, currentState.recordingMIDIPreviews[trackId]]), + ) as typeof currentState.recordingMIDIPreviews; + + for (const preview of previews) { + const existing = currentState.recordingMIDIPreviews[preview.trackId]; + const shouldReset = + !existing || + existing.generation !== preview.generation || + existing.totalEventCount > preview.totalEventCount || + existing.recordingStartTime !== preview.recordingStartTime; + + const deltaEvents: MIDIEvent[] = preview.deltaEvents.map((event) => ({ + timestamp: event.timestamp, + type: event.type as MIDIEvent["type"], + note: event.note, + velocity: event.velocity, + channel: event.channel, + })); + + nextPreviews[preview.trackId] = { + generation: preview.generation, + recordingStartTime: preview.recordingStartTime, + totalEventCount: preview.totalEventCount, + events: shouldReset ? deltaEvents : deltaEvents.length ? [...existing.events, ...deltaEvents] : existing.events, + activeNotes: preview.activeNotes, + }; + } + + return { recordingMIDIPreviews: nextPreviews }; + }); + } finally { + inFlight = false; + } + }; + + void fetchRecordingMIDIPreviews(); + const intervalId = window.setInterval(() => { + void fetchRecordingMIDIPreviews(); + }, 150); + + return () => { + cancelled = true; + window.clearInterval(intervalId); + }; + }, [isRecording, recordingClips, tracks]); + + // Render recording clip with live waveform + const renderRecordingClip = ( + clip: RecordingClip, + trackY: number, + trackColor: string, + ) => { + const track = tracks.find((candidate) => candidate.id === clip.trackId); + const isMIDIRecordingTrack = + track?.type === "midi" || track?.type === "instrument"; + const rowMetrics = getTimelineRowMetrics( + track ?? { + type: "audio", + showAutomation: false, + automationLanes: [], + }, + trackHeight, + ); + const clipY = trackY + rowMetrics.clipInsetY; + const clipHeight = rowMetrics.clipHeight; + const x = clip.startTime * pixelsPerSecond - scrollX; + const width = (recordingRenderTime - clip.startTime) * pixelsPerSecond; + const previewHeight = Math.max(8, clipHeight - 10); + + if (width <= 0) return null; + + // Get recording waveform data if available + const recordingData = recordingWaveformCache.get(clip.trackId); + const midiPreview = recordingMIDIPreviews[clip.trackId]; + + // Generate waveform visualization from recording peaks + const renderRecordingWaveform = (): React.ReactNode[] => { + if (!recordingData || recordingData.peaks.length === 0) return []; + + if (recordingData.recordingStartTime !== clip.startTime) return []; + const { peaks: recordingPeaks } = recordingData; + const numChannels = recordingPeaks[0]?.channels?.length || 1; + const waveforms: React.ReactNode[] = []; + const waveformHeight = Math.max(8, clipHeight - 10); + const pixelsPerPeak = recordingData.samplesPerPixel / recordingData.sampleRate * pixelsPerSecond; + const waveformX = recordingPeakX(recordingData, 0, pixelsPerSecond, scrollX); + const firstVisiblePeak = Math.max( + 0, + Math.floor((-waveformX) / Math.max(pixelsPerPeak, 0.001)) - 1, + ); + const lastVisiblePeak = Math.min( + recordingPeaks.length, + Math.ceil((viewportWidth - waveformX) / Math.max(pixelsPerPeak, 0.001)) + 1, + ); + + for (let ch = 0; ch < numChannels; ch++) { + const points: number[] = []; + const channelHeight = waveformHeight / numChannels; + const channelY = clipY + 5 + ch * channelHeight; + const centerY = channelY + channelHeight / 2; + const halfHeight = channelHeight / 2 - 2; + + // Reproject source sample bins at the current zoom, even while a new + // resolution is pending. Cached peaks never retain obsolete pixel widths. + for (let i = firstVisiblePeak; i < lastVisiblePeak; i++) { + const channelData = recordingPeaks[i].channels[ch]; + if (!channelData) continue; + + const px = waveformX + i * pixelsPerPeak; + const py = centerY - channelData.max * halfHeight; + points.push(px, py); + } + + // Draw bottom half (min values, reversed) + for (let i = lastVisiblePeak - 1; i >= firstVisiblePeak; i--) { + const channelData = recordingPeaks[i].channels[ch]; + if (!channelData) continue; + + const px = waveformX + i * pixelsPerPeak; + const py = centerY - channelData.min * halfHeight; + points.push(px, py); + } + + if (points.length > 0) { + waveforms.push( + , + ); + } + } + + return waveforms; + }; + + return ( + + {/* Background */} + + {/* Live waveform or MIDI preview visualization */} + {isMIDIRecordingTrack + ? renderCompactMIDIThumbnail( + midiPreview?.events || [], + Math.max(0.001, recordingRenderTime - clip.startTime), + x, + width, + clipY, + previewHeight, + trackColor, + !!track?.muted, + midiPreview?.activeNotes ?? [], + `recording:${clip.trackId}:${midiPreview?.generation ?? 0}`, + ) + : renderRecordingWaveform()} + {/* Recording indicator text */} + + {/* Recording indicator dot (pulsing effect via opacity) */} + + + ); + }; + + return + {recordingClips.map(clip => { + const index = tracks.findIndex(track => track.id === clip.trackId); + if (index < 0) return null; + const track = tracks[index], y = trackYs[index]; + if (y + getEffectiveTrackHeight(track, trackHeight) < scrollY || y > scrollY + viewportHeight) return null; + return renderRecordingClip(clip, y, track.color); + })} + ; +}); + export function Timeline({ tracks, masterAutomation, @@ -681,10 +1005,14 @@ export function Timeline({ }: TimelineProps) { const containerRef = useRef(null); const timelineStageRef = useRef(null); - const [dimensions, setDimensions] = useState({ width: 800, height: 400 }); + const [dimensions, setDimensions] = useState({ width: 800, height: 400, stickyHeaderHeight: 0 }); const [waveformCache, setWaveformCache] = useState(new Map()); + const waveformWindows = useMemo(() => new Map(), [waveformCache]); const [waveformPreviewCache, setWaveformPreviewCache] = useState(new Map()); - const [recordingWaveformCache, setRecordingWaveformCache] = useState(new Map()); + const [recordingWaveformStore] = useState(createRecordingWaveformStore); + const midiThumbnailIndicesRef = useRef(new Map()); + const midiVisibleEventsRef = useRef(new WeakMap()); + const waveformArtworkRef = useRef(new Map()); // Canvas-only per-track view state: these scale-strip gestures do not edit // project/audio data, so they intentionally stay outside command undo. const [waveformScaleViews, setWaveformScaleViews] = useState>({}); const waveformCacheRef = useRef(waveformCache); waveformCacheRef.current = waveformCache; - // Marks recording-waveform updates as low-priority so React won't interrupt - // the current RAF frame (playhead animation) to process them. - const [, startRecordingWaveformTransition] = useTransition(); // Zooming flag — suppresses waveform fetches during active zoom const isZoomingRef = useRef(false); @@ -727,10 +1052,10 @@ export function Timeline({ currentTime: state.transport.currentTime, }; }, - isSuspended: () => isZoomingRef.current || isScrollingRef.current, + isInteracting: () => isZoomingRef.current || isScrollingRef.current, request: (trackId, samplesPerPixel, numPixels, startSample) => nativeBridge.getRecordingPeaks(trackId, samplesPerPixel, numPixels, startSample), - publish: (cache) => startRecordingWaveformTransition(() => setRecordingWaveformCache(cache)), + publish: recordingWaveformStore.publish, onError: (error) => console.error(`${AUDIO_RECORD_LOG_PREFIX} waveformFetch:error`, { error }), }); } @@ -777,6 +1102,21 @@ export function Timeline({ // In-flight waveform fetches — prevents duplicate concurrent requests for same cache key const inFlightRef = useRef>(new Set()); + const waveformRequestTokensRef = useRef(new Map()); + const waveformGenerationRef = useRef(new Map()); + const waveformQueueRef = useRef(new WaveformRequestQueue()); + const waveformCommitRef = useRef(new Map()); + const waveformCommitFrameRef = useRef(null); + const waveformMountedRef = useRef(true); + useEffect(() => { + waveformMountedRef.current = true; + return () => { + waveformMountedRef.current = false; + waveformQueueRef.current.clear(); waveformCommitRef.current.clear(); + if (waveformCommitFrameRef.current !== null) cancelAnimationFrame(waveformCommitFrameRef.current); + waveformCommitFrameRef.current = null; + }; + }, []); // Hovered automation point — for tooltip display const [hoveredAutoPoint, setHoveredAutoPoint] = useState<{ @@ -1038,6 +1378,7 @@ export function Timeline({ fadeHandleGestureRef.current = null; clipVolumeGestureRef.current = null; slipEditRef.current = null; + setSlipFeedbackActive(false); if (hadTimelineDrag && !activeGesture.isFadeDrag) { restoreTimelineGestureUndo(); @@ -1076,6 +1417,7 @@ export function Timeline({ isMidi: boolean, nextValues: { startTime: number; duration: number; offset: number }, ) => { + const original = timelineGestureUndoRef.current?.tracks.flatMap(track => track.clips).find(clip => clip.id === clipId); return runTimelineClipGestureMutation( useDAWStore.getState(), [clipId], @@ -1084,7 +1426,9 @@ export function Timeline({ ...track, clips: isMidi ? track.clips - : track.clips.map((clip) => clip.id === clipId ? { ...clip, ...nextValues } : clip), + : track.clips.map((clip) => clip.id === clipId ? { ...clip, ...nextValues, + crossfadeLayers: windowCrossfadeLayers((original ?? clip).crossfadeLayers, + nextValues.startTime - (original ?? clip).startTime, nextValues.duration) } : clip), midiClips: isMidi ? track.midiClips.map((clip) => clip.id === clipId ? { ...clip, ...nextValues } : clip) : track.midiClips, @@ -1326,6 +1670,7 @@ export function Timeline({ const [crosshairPos, setCrosshairPos] = useState<{ x: number; y: number } | null>(null); // Slip editing state (the active mouse profile selects the gesture) + const [slipFeedbackActive, setSlipFeedbackActive] = useState(false); const slipEditRef = useRef<{ clipId: string; trackId: string; @@ -1369,7 +1714,7 @@ export function Timeline({ // Use useShallow to prevent re-renders when unrelated state changes (like currentTime) const { recordingClips, - recordingMIDIPreviews, + audioConversionJobs, pixelsPerSecond, setZoom, seekTo, @@ -1436,7 +1781,7 @@ export function Timeline({ } = useDAWStore( useShallow((state) => ({ recordingClips: state.recordingClips, - recordingMIDIPreviews: state.recordingMIDIPreviews, + audioConversionJobs: state.audioConversionJobs, pixelsPerSecond: state.pixelsPerSecond, setZoom: state.setZoom, seekTo: state.seekTo, @@ -1511,112 +1856,18 @@ export function Timeline({ // currentTime is handled via Zustand subscribe for playhead animation only const tempo = useDAWStore((state) => state.transport.tempo); const loopEnabled = useDAWStore((state) => state.transport.loopEnabled); - const loopStart = useDAWStore((state) => state.transport.loopStart); - const loopEnd = useDAWStore((state) => state.transport.loopEnd); - const isPlaying = useDAWStore((state) => state.transport.isPlaying); - const isRecording = useDAWStore((state) => state.transport.isRecording); - const [recordingRenderTime, setRecordingRenderTime] = useState( - () => useDAWStore.getState().transport.currentTime, - ); - - useEffect(() => { - if (!isRecording || recordingClips.length === 0) { - setRecordingRenderTime(useDAWStore.getState().transport.currentTime); - return; - } - - // Clip growth does not need the playhead's 60 Hz React update rate. - // Playhead keeps its independent imperative transport subscription. - const updateGeometry = () => setRecordingRenderTime(useDAWStore.getState().transport.currentTime); - updateGeometry(); - const timer = window.setInterval(updateGeometry, RECORDING_GEOMETRY_INTERVAL_MS); - return () => window.clearInterval(timer); - }, [isRecording, recordingClips.length]); - - useEffect(() => { - const recordingMIDITrackIds = recordingClips - .map((clip) => clip.trackId) - .filter((trackId) => { - const track = tracks.find((candidate) => candidate.id === trackId); - return track?.type === "midi" || track?.type === "instrument"; - }); - - if (!isRecording || recordingMIDITrackIds.length === 0) { - if (Object.keys(useDAWStore.getState().recordingMIDIPreviews).length > 0) { - useDAWStore.setState({ recordingMIDIPreviews: {} }); - } - return; - } - - let cancelled = false; - let inFlight = false; - - const fetchRecordingMIDIPreviews = async () => { - if (inFlight) return; - inFlight = true; - - try { - const state = useDAWStore.getState(); - const requests = recordingMIDITrackIds.map((trackId) => { - const preview = state.recordingMIDIPreviews[trackId]; - return { - trackId, - generation: preview?.generation ?? 0, - knownEventCount: preview?.totalEventCount ?? 0, - }; - }); - - const previews = await nativeBridge.getActiveRecordingMIDIPreviews(requests); - if (cancelled || previews.length === 0) return; - - useDAWStore.setState((currentState) => { - const nextPreviews = Object.fromEntries( - recordingMIDITrackIds - .filter((trackId) => currentState.recordingMIDIPreviews[trackId] !== undefined) - .map((trackId) => [trackId, currentState.recordingMIDIPreviews[trackId]]), - ) as typeof currentState.recordingMIDIPreviews; - - for (const preview of previews) { - const existing = currentState.recordingMIDIPreviews[preview.trackId]; - const shouldReset = - !existing || - existing.generation !== preview.generation || - existing.totalEventCount > preview.totalEventCount || - existing.recordingStartTime !== preview.recordingStartTime; - - const deltaEvents: MIDIEvent[] = preview.deltaEvents.map((event) => ({ - timestamp: event.timestamp, - type: event.type as MIDIEvent["type"], - note: event.note, - velocity: event.velocity, - })); - - nextPreviews[preview.trackId] = { - generation: preview.generation, - recordingStartTime: preview.recordingStartTime, - totalEventCount: preview.totalEventCount, - events: shouldReset ? deltaEvents : [...existing.events, ...deltaEvents], - activeNotes: preview.activeNotes, - }; - } - - return { recordingMIDIPreviews: nextPreviews }; - }); - } finally { - inFlight = false; - } - }; - - void fetchRecordingMIDIPreviews(); - const intervalId = window.setInterval(() => { - void fetchRecordingMIDIPreviews(); - }, 150); - - return () => { - cancelled = true; - window.clearInterval(intervalId); - }; - }, [isRecording, recordingClips, tracks]); + const loopStart = useDAWStore((state) => state.transport.loopStart); + const loopEnd = useDAWStore((state) => state.transport.loopEnd); + const isPlaying = useDAWStore((state) => state.transport.isPlaying); + const isRecording = useDAWStore((state) => state.transport.isRecording); + const [recordingExtentTime, setRecordingExtentTime] = useState(() => useDAWStore.getState().transport.currentTime); + useEffect(() => { + if (!isRecording) return; + const update = () => setRecordingExtentTime(useDAWStore.getState().transport.currentTime); + update(); + const timer = window.setInterval(update, 1000); + return () => window.clearInterval(timer); + }, [isRecording]); // Calculate stage height: fill available viewport space, or grow for content const bottomSpacerHeight = footerHeight + BOTTOM_INTERACTION_BUFFER; @@ -1636,7 +1887,11 @@ export function Timeline({ targetRowIndex: dragState.targetTrackIndex ?? dragState.trackIndex ?? 0, }) : null; }, [dragState, tracks]); - const copyGhostHeight = (copyPreviewPlan?.newTracks.length ?? 0) * trackHeight; + const insertedMoveSourceIds = dragState.insertTrackIndex === undefined ? [] + : [...new Set(dragState.multiClipInfo?.length + ? [...dragState.multiClipInfo].sort((a, b) => a.trackIndex - b.trackIndex).map(info => info.sourceTrackId) + : [tracks[dragState.trackIndex ?? -1]?.id].filter(Boolean))]; + const copyGhostHeight = Math.max(copyPreviewPlan?.newTracks.length ?? 0, insertedMoveSourceIds.length) * trackHeight; // Master automation lanes add height below all tracks const masterVisibleLanes = useMemo( () => (masterAutomation?.showAutomation ? masterAutomation.lanes.filter((l) => l.visible) : []), @@ -1772,10 +2027,10 @@ export function Timeline({ : container.clientHeight; setDimensions((prev) => { - if (prev.width === newWidth && prev.height === newHeight) { + if (prev.width === newWidth && prev.height === newHeight && prev.stickyHeaderHeight === stickyHeaderHeight) { return prev; } - return { width: newWidth, height: newHeight }; + return { width: newWidth, height: newHeight, stickyHeaderHeight }; }); }; @@ -1831,29 +2086,13 @@ export function Timeline({ workspace.clientHeight - (stickyHeader?.offsetHeight ?? 0), ); setDimensions((prev) => { - if (prev.width === newWidth && prev.height === newHeight) return prev; - return { width: newWidth, height: newHeight }; + if (prev.width === newWidth && prev.height === newHeight && prev.stickyHeaderHeight === (stickyHeader?.offsetHeight ?? 0)) return prev; + return { width: newWidth, height: newHeight, stickyHeaderHeight: stickyHeader?.offsetHeight ?? 0 }; }); }); return unsub; }, []); - // Sync native vertical scroll (workspace's scrollTop) → store's scrollY - // The workspace div has overflow-y: auto and handles vertical scrolling natively. - // We need to sync scrollTop to scrollY so viewport culling and hit-testing work. - useEffect(() => { - const workspace = containerRef.current?.closest(".workspace"); - if (!workspace) return; - const handleScroll = () => { - const newScrollY = workspace.scrollTop; - if (Math.abs(newScrollY - scrollY) > 0.5) { - setScroll(scrollX, newScrollY); - } - }; - workspace.addEventListener("scroll", handleScroll, { passive: true }); - return () => workspace.removeEventListener("scroll", handleScroll); - }, [scrollX, scrollY, setScroll]); - // Handle scroll wheel for zoom with native listener to prevent browser zoom // Use requestAnimationFrame for smooth scrolling and zooming const pendingScrollRef = useRef<{ x: number; y: number; isFollow?: boolean } | null>(null); @@ -1967,6 +2206,24 @@ export function Timeline({ [queueScroll], ); + // Native scrolling and modifier-wheel gestures share one publication per + // frame. Do not broadcast the entire store from a DOM scroll event, or use + // a render-time scrollX that can overwrite a queued horizontal zoom/pan. + useEffect(() => { + const workspace = containerRef.current?.closest(".workspace"); + if (!workspace) return; + const handleScroll = () => { + const state = useDAWStore.getState(); + const pending = pendingScrollRef.current; + const newScrollY = workspace.scrollTop; + if (Math.abs(newScrollY - (pending?.y ?? state.scrollY)) > 0.5) { + scheduleScroll(pending?.x ?? state.scrollX, newScrollY); + } + }; + workspace.addEventListener("scroll", handleScroll, { passive: true }); + return () => workspace.removeEventListener("scroll", handleScroll); + }, [scheduleScroll]); + // Auto-scroll variant: batches via RAF like scheduleScroll but does NOT // suppress waveform fetches. Playback auto-scroll is incremental and // predictable — suppressing fetches here keeps isScrollingRef permanently @@ -2357,17 +2614,10 @@ export function Timeline({ const currentTrackHeight = trackHeightRef.current; if (currentTracks.length === 0) return { kind: "insertTrack", insertIndex: 0 }; - const halfBand = EXTERNAL_TRACK_INSERT_BAND_PX / 2; - for (let index = 1; index < currentTracks.length; index += 1) { - const dividerY = currentTrackYs[index] ?? 0; - if (Math.abs(absoluteY - dividerY) <= halfBand) { - return { kind: "insertTrack", insertIndex: index }; - } - } - - if (absoluteY >= contentHeightRef.current - halfBand) { - return { kind: "insertTrack", insertIndex: currentTracks.length }; - } + const insertion = resolveTimelineInsertionIndex(absoluteY, currentTrackYs, contentHeightRef.current, + externalMediaPreviewRef.current?.target.kind === "insertTrack" ? externalMediaPreviewRef.current.target.insertIndex : undefined, + EXTERNAL_TRACK_INSERT_BAND_PX); + if (insertion !== null) return { kind: "insertTrack", insertIndex: insertion }; const hit = getTrackAtY(absoluteY, currentTracks, currentTrackYs, currentTrackHeight); if (!hit) { @@ -2379,7 +2629,7 @@ export function Timeline({ !isExternalTrackCompatible(hitTrack, mediaKind); return needsTrackInsertion - ? { kind: "insertTrack", insertIndex: hit.trackIndex } + ? { kind: "insertTrack", insertIndex: hit.trackIndex + 1 } : { kind: "existingTrack", trackIndex: hit.trackIndex }; }, [isExternalTrackCompatible]); @@ -2422,10 +2672,11 @@ export function Timeline({ const container = containerRef.current; if (!file || !container) return null; - const rect = container.getBoundingClientRect(); + const viewport = timelineStageRef.current?.container(); + const rect = (viewport ?? container).getBoundingClientRect(); const point = getNormalizedExternalDragPoint(event, rect); const localX = point.localX; - const stageY = point.localY - (showRuler ? RULER_HEIGHT : 0); + const stageY = point.localY - (!viewport && showRuler ? RULER_HEIGHT : 0); if (localX < 0 || localX > rect.width || stageY < 0) return null; const absoluteY = stageY + scrollYRef.current; @@ -2508,6 +2759,7 @@ export function Timeline({ const context = externalMediaDragContextRef.current; if (context?.dragId === dragId && context.filePath === filePath) { context.midiTracks = nonEmptyTracks; + context.sourceTempoInfo = info.sourceTempoInfo; context.midiTrackCount = Math.max(1, nonEmptyTracks.length); context.midiEvents = midiEvents; context.duration = duration; @@ -2629,8 +2881,19 @@ export function Timeline({ target: preview.target, startTime: Number(preview.startTime.toFixed(3)), }); + const actualInsertIndex = (visibleIndex: number) => { + const next = tracksRef.current[visibleIndex]; + return next ? useDAWStore.getState().tracks.findIndex(track => track.id === next.id) + : useDAWStore.getState().tracks.length; + }; - mediaFiles.forEach((file, index) => { + const dropEpoch = getProjectEpoch(); + const baseInsertIndex = actualInsertIndex(preview.target.kind === "insertTrack" + ? preview.target.insertIndex : preview.target.trackIndex); + void (async () => { + for (let index = 0; index < mediaFiles.length; index++) { + if (getProjectEpoch() !== dropEpoch) return; + const file = mediaFiles[index]; const mediaKind = getExternalMediaKind(file); const name = file.name.replace(/\.[^.]+$/, "") || file.name; if (mediaKind === "midi") { @@ -2643,12 +2906,12 @@ export function Timeline({ targetTrack && isExternalTrackCompatible(targetTrack, "midi"); const insertIndex = preview.target.kind === "insertTrack" - ? preview.target.insertIndex + index + ? baseInsertIndex + index : preview.target.kind === "existingTrack" - ? preview.target.trackIndex + index + ? baseInsertIndex + index : tracksRef.current.length + index; - void importExternalMIDIAtTimeline({ + await importExternalMIDIAtTimeline({ filePath: file.path, name, targetTrackId: useExisting ? targetTrack.id : undefined, @@ -2657,8 +2920,9 @@ export function Timeline({ parsedTracks: index === 0 && context.filePath === file.path && context.midiTracks && context.midiTracks.length > 0 ? context.midiTracks : undefined, + sourceTempoInfo: index === 0 ? context.sourceTempoInfo : undefined, }).catch((error) => console.error("[Timeline] External MIDI drop import failed", error)); - return; + continue; } const targetTrack = @@ -2670,12 +2934,12 @@ export function Timeline({ targetTrack && isExternalTrackCompatible(targetTrack, "audio"); const insertIndex = preview.target.kind === "insertTrack" - ? preview.target.insertIndex + index + ? baseInsertIndex + index : preview.target.kind === "existingTrack" - ? preview.target.trackIndex + index + ? baseInsertIndex + index : tracksRef.current.length + index; - void importExternalMediaAtTimeline({ + await importExternalMediaAtTimeline({ filePath: file.path, name, trackId: useExisting ? targetTrack.id : undefined, @@ -2685,7 +2949,8 @@ export function Timeline({ sampleRate: index === 0 ? preview.sampleRate : undefined, waveformStatus: index === 0 && preview.peaks ? "preview" : "building", }).catch((error) => console.error("[audio.import] external drop import failed", error)); - }); + } + })().catch((error) => console.error("[Timeline] Media drop batch failed", error)); clearDragContext(preview.dragId); }; @@ -2780,7 +3045,8 @@ export function Timeline({ }]; void (async () => { - const prepared = await nativeBridge.prepareExternalMIDIFileDrag(defaultFileName, midiTracks); + const prepared = await nativeBridge.prepareExternalMIDIFileDrag(defaultFileName, midiTracks, + midiExportOptions(state, midiClip.startTime, midiClip.startTime + midiClip.duration)); if (!prepared.success || !prepared.filePath) { useDAWStore.getState().showToast(prepared.error || "Failed to prepare MIDI drag export", "error"); return; @@ -2821,7 +3087,7 @@ export function Timeline({ liveModifiers?: PointerModifierEventLike, ) => { const gesture = dragStateRef.current; - if (!gesture.clipId || gesture.type === null || gesture.isFadeDrag) return; + if (finalizingTimelineClipGestureRef.current || !gesture.clipId || gesture.type === null || gesture.isFadeDrag) return; if (isTimelineGestureEditLocked(gesture)) { cancelActiveTimelineClipGesture(); return; @@ -2885,12 +3151,25 @@ export function Timeline({ constrainedDrag.axisLock, ); const effectiveStageY = gesture.startY + constrainedDrag.deltaY; + let insertTrackIndex = !gesture.copyOnDrag + && (!profiledDrag.axisLockRequested || constrainedDrag.axisLock === "y") + ? resolveTimelineInsertionIndex(effectiveStageY + scrollYRef.current, trackYsRef.current, + contentHeightRef.current, gesture.insertTrackIndex) ?? undefined + : undefined; let targetTrackIdx = getTimelineDropTrackIndex(effectiveStageY + scrollYRef.current, latestTracks, trackYsRef.current, trackHeightRef.current); + const destination = latestTracks[targetTrackIdx]; + if (insertTrackIndex === undefined && !gesture.copyOnDrag && destination && targetTrackIdx !== gesture.trackIndex + && (isMidi ? destination.type !== "midi" && destination.type !== "instrument" + : destination.type === "midi" || destination.type === "instrument") + && (!profiledDrag.axisLockRequested || constrainedDrag.axisLock === "y")) { + insertTrackIndex = targetTrackIdx + 1; + } + if (insertTrackIndex !== undefined) targetTrackIdx = insertTrackIndex; if (profiledDrag.axisLockRequested && constrainedDrag.axisLock !== "y") { targetTrackIdx = gesture.trackIndex ?? targetTrackIdx; } // Keep a multi-track group intact when the pointer moves above its first row. - if (!gesture.copyOnDrag && gesture.multiClipInfo?.length) { + if (insertTrackIndex === undefined && !gesture.copyOnDrag && gesture.multiClipInfo?.length) { const firstTrack = Math.min(...gesture.multiClipInfo.map((info) => info.trackIndex)); targetTrackIdx = Math.max(targetTrackIdx, (gesture.trackIndex ?? found.trackIndex) - firstTrack); } @@ -3050,9 +3329,9 @@ export function Timeline({ return; } - const needsGhost = multi + const needsGhost = insertTrackIndex !== undefined || (multi ? Math.max(...gesture.multiClipInfo!.map((info) => info.trackIndex)) + trackDelta >= latestTracks.length - : targetTrackIdx >= latestTracks.length; + : targetTrackIdx >= latestTracks.length); setShowGhostTrack(needsGhost); if (snapActive && (edgeSnapMatch || Math.abs(timeDelta - rawTimeDelta) * pps > 0.1)) { @@ -3077,8 +3356,10 @@ export function Timeline({ setSnapGhostRender(null); } + const movingIds = new Set(multi ? gesture.multiClipInfo!.map(info => info.clipId) : [gesture.clipId]); useDAWStore.setState((state) => { - let nextTracks = state.tracks.map((track) => ({ + let nextTracks = state.tracks.map((track) => !track.clips.some(clip => movingIds.has(clip.id)) + && !track.midiClips.some(clip => movingIds.has(clip.id)) ? track : ({ ...track, clips: track.clips.map((clip) => { const info = gesture.multiClipInfo?.find((candidate) => candidate.clipId === clip.id && !candidate.isMidi); @@ -3122,11 +3403,13 @@ export function Timeline({ return { tracks: nextTracks, isModified: true }; }); - if (clampedTarget !== gesture.targetTrackIndex || constrainedDrag.axisLock !== (gesture.axisLock ?? null)) { + if (clampedTarget !== gesture.targetTrackIndex || insertTrackIndex !== gesture.insertTrackIndex + || constrainedDrag.axisLock !== (gesture.axisLock ?? null)) { setTimelineDragState((previous) => ({ ...previous, axisLock: constrainedDrag.axisLock, targetTrackIndex: clampedTarget, + insertTrackIndex, })); } }, [ @@ -3176,6 +3459,7 @@ export function Timeline({ clearTimelineGestureUndo(); slipEditRef.current = null; + setSlipFeedbackActive(false); return true; }, [cancelActiveTimelineClipGesture, clearTimelineGestureUndo, slipEditClip]); finalizeSlipTimelineGestureRef.current = finalizeSlipTimelineGesture; @@ -3259,6 +3543,20 @@ export function Timeline({ const latestTracks = useDAWStore.getState().tracks; const trackDelta = targetIdx - anchorTrackIdx; + if (!gesture.copyOnDrag && gesture.type === "move" && gesture.insertTrackIndex !== undefined) { + const ids = gesture.multiClipInfo?.length ? gesture.multiClipInfo.map(info => info.clipId) : [gesture.clipId]; + const placements = useDAWStore.getState().tracks.flatMap(track => [...track.clips, ...track.midiClips]) + .filter(clip => ids.includes(clip.id)).map(clip => ({ clipId: clip.id, startTime: clip.startTime })); + const nextVisibleTrack = tracksRef.current[gesture.insertTrackIndex]; + const insertIndex = nextVisibleTrack + ? latestTracks.findIndex(track => track.id === nextVisibleTrack.id) : latestTracks.length; + restoreTimelineGestureUndo(); + resetDragState(); + return Boolean(await useDAWStore.getState().moveTimelineClipsToNewTrack({ + placements, insertIndex, type: isMidi ? "midi" : "audio", + })); + } + if (gesture.type === "resize-left" || gesture.type === "resize-right") { if (gesture.resizeAction === "stretch") { const finalValues = { @@ -3578,6 +3876,7 @@ export function Timeline({ if (!slipEdit.dragActivated) { if (!hasTimelineClipDragStarted(point.x - slipEdit.startX, 0)) return; slipEdit.dragActivated = true; + setSlipFeedbackActive(true); } const nextOffset = computeSlipOffset(slipEdit.originalOffset, deltaTime, slipEdit.maxOffset); useDAWStore.setState((state) => ({ @@ -3634,9 +3933,10 @@ export function Timeline({ const handleWheel = (e: WheelEvent) => { if (isEditorWheelOwnedTarget(e.target, container)) return; - const rect = container.getBoundingClientRect(); + const viewport = timelineStageRef.current?.container(); + const rect = (viewport ?? container).getBoundingClientRect(); const stageX = e.clientX - rect.left; - const stageY = e.clientY - rect.top - (showRuler ? RULER_HEIGHT : 0); + const stageY = e.clientY - rect.top - (!viewport && showRuler ? RULER_HEIGHT : 0); const mouseY = stageY + scrollYRef.current; const trackHit = getTrackAtY(mouseY, tracksRef.current, trackYsRef.current, trackHeightRef.current); const hoveredTrack = trackHit ? tracksRef.current[trackHit.trackIndex] : undefined; @@ -4380,7 +4680,7 @@ export function Timeline({ const stage = e.target.getStage(); const pointerPos = stage?.getPointerPosition(); if (pointerPos) { - setCrosshairPos({ x: pointerPos.x, y: pointerPos.y }); + setCrosshairPos({ x: pointerPos.x, y: pointerPos.y + scrollY }); } } @@ -4407,6 +4707,7 @@ export function Timeline({ if (!slipEditRef.current.dragActivated) { if (!hasTimelineClipDragStarted(deltaX, 0)) return; slipEditRef.current.dragActivated = true; + setSlipFeedbackActive(true); } const deltaTime = deltaX / pixelsPerSecond; // Moving right moves source content right (decreases offset); moving left increases offset. @@ -4510,8 +4811,8 @@ export function Timeline({ const dx = tlX - marqueeRef.current.startX; const dy = tlY - marqueeRef.current.startY; if (Math.abs(dx) > 3 || Math.abs(dy) > 3) { - const left = Math.min(marqueeRef.current.startX, tlX) - scrollX; - const top = Math.min(marqueeRef.current.startY, tlY) - scrollY; + const left = Math.min(marqueeRef.current.startX, tlX); + const top = Math.min(marqueeRef.current.startY, tlY); const w = Math.abs(dx); const h = Math.abs(dy); setMarqueeRect({ x: left, y: top, width: w, height: h }); @@ -4602,7 +4903,18 @@ export function Timeline({ } }; - // Only the last interacted editing surface receives its local shortcuts. + // Local keyboard shortcuts retain their editing-surface scope; explicit + // project-view commands from menus/palette target this Timeline directly. + const applyExplicitTimelineView = (view: { pixelsPerSecond: number; scrollX: number }) => { + // A menu command supersedes queued horizontal wheel/follow work. Publish + // scale and origin together, as the wheel path does, and allow the normal + // brief manual-navigation grace period before transport following resumes. + pendingScrollRef.current = null; + pendingZoomRef.current = null; + accZoomDeltaRef.current = 0; + manualScrollLockUntilRef.current = performance.now() + 250; + useDAWStore.setState({ pixelsPerSecond: view.pixelsPerSecond, scrollX: view.scrollX }); + }; const timelineActionExecutorRef = useRef< (actionId: string) => ReturnType >(() => "unmatched"); @@ -4617,8 +4929,7 @@ export function Timeline({ MAX_PIXELS_PER_SECOND, ); if (!view) return "claimed_noop"; - state.setZoom(view.pixelsPerSecond); - state.setScroll(view.scrollX, state.scrollY); + applyExplicitTimelineView(view); return "handled"; } if (actionId === "view.zoomToSelection") { @@ -4634,8 +4945,7 @@ export function Timeline({ ) : null; if (!view) return "claimed_noop"; - state.setZoom(view.pixelsPerSecond); - state.setScroll(view.scrollX, state.scrollY); + applyExplicitTimelineView(view); return "handled"; } if (actionId !== "clip.splitAtPointer") return "unmatched"; @@ -4870,16 +5180,20 @@ export function Timeline({ const lookAheadSec = (viewWidth * 2) / pps; const aheadTime = curTime + lookAheadSec; - for (const track of daw.tracks) { + // Lookahead is useful only for rows the user can see. Active viewport + // requests have priority in the shared bounded queue. + for (const [index, track] of tracksRef.current.entries()) { + const y = trackYsRef.current[index] ?? 0; + if (y + getEffectiveTrackHeight(track, trackHeightRef.current) < daw.scrollY + || y > daw.scrollY + dimensions.height) continue; for (const clip of track.clips) { // Skip clips not in the look-ahead range if (clip.startTime > aheadTime || clip.startTime + clip.duration < curTime) continue; if (!clip.filePath) continue; const fileSR = clip.sampleRate || 44100; - const renderSpp = Math.max(1, Math.round(fileSR / pps)); + const renderSpp = Math.max(1, fileSR / pps); const cacheSpp = quantizeSpp(renderSpp); - const tileSamples = 4096 * cacheSpp; // TILE_PEAKS = 4096 // Compute which tiles cover the look-ahead range within this clip const clipOffset = clip.offset || 0; @@ -4888,13 +5202,11 @@ export function Timeline({ const startSampleInFile = Math.floor((clipOffset + rangeStartInClip) * fileSR); const endSampleInFile = Math.floor((clipOffset + rangeEndInClip) * fileSR); - for (let tileSample = Math.floor(startSampleInFile / tileSamples) * tileSamples; - tileSample < endSampleInFile; - tileSample += tileSamples) { - const cacheKey = `${clip.filePath}-${cacheSpp}-${tileSample}`; - if (!waveformCacheRef.current.has(cacheKey) && !inFlightRef.current.has(cacheKey)) { - const numPeaks = Math.min(8000, Math.ceil((endSampleInFile - tileSample) / cacheSpp) + 4096); - fetchWaveformDataRef.current(clip.filePath, cacheSpp, tileSample, numPeaks); + const tiles = planWaveformTiles(clip.filePath, cacheSpp, startSampleInFile, endSampleInFile, + Math.round(clipOffset * fileSR), Math.round((clipOffset + clip.duration) * fileSR)); + for (const tile of tiles) { + if (!waveformCacheRef.current.has(tile.key) && !inFlightRef.current.has(tile.key)) { + fetchWaveformDataRef.current(tile, 1); } } } @@ -4931,17 +5243,21 @@ export function Timeline({ useEffect(() => { const unsubscribe = nativeBridge.onPeaksReady((filePath: string) => { if (!filePath) return; + waveformGenerationRef.current.set(filePath, (waveformGenerationRef.current.get(filePath) ?? 0) + 1); // Clear in-flight guards so the file can be re-fetched for (const key of [...inFlightRef.current]) { - if (key.startsWith(filePath)) { + if (key.startsWith(`${filePath}-`)) { inFlightRef.current.delete(key); } } + for (const key of waveformRequestTokensRef.current.keys()) { + if (key.startsWith(`${filePath}-`)) waveformRequestTokensRef.current.delete(key); + } // Clear cached (empty) waveform data to trigger re-fetch on next render setWaveformCache((prev) => { const next = new Map(prev); for (const key of next.keys()) { - if (key.startsWith(filePath)) { + if (key.startsWith(`${filePath}-`)) { next.delete(key); } } @@ -4955,51 +5271,24 @@ export function Timeline({ return unsubscribe; }, []); - const rulerDensity = useMemo(() => { - const beatsPerBar = timeSignature.numerator; - const secondsPerBeat = 60 / tempo; - const pixelsPerBeat = secondsPerBeat * pixelsPerSecond; - const pixelsPerBar = pixelsPerBeat * beatsPerBar; - - if (pixelsPerBeat >= 140) { - return { - mode: "division" as const, - divisionsPerBeat: pixelsPerBeat >= 260 ? 8 : 4, - labelEveryBars: pixelsPerBar < 90 ? 2 : 1, - }; - } - - if (pixelsPerBeat >= 32) { - return { - mode: "beat" as const, - divisionsPerBeat: 1, - labelEveryBars: pixelsPerBar < 60 ? 2 : 1, - }; - } - - return { - mode: "bar" as const, - divisionsPerBeat: 1, - labelEveryBars: - pixelsPerBar < 30 ? 8 : pixelsPerBar < 45 ? 4 : pixelsPerBar < 70 ? 2 : 1, - }; - }, [pixelsPerSecond, tempo, timeSignature.numerator]); + const rulerDensity = useMemo(() => getTimelineRulerDensity( + pixelsPerSecond, tempo, timeSignature, scrollX, dimensions.width, + ), [pixelsPerSecond, tempo, timeSignature, scrollX, dimensions.width]); const gridLines = useMemo(() => { const lines: React.ReactNode[] = []; const beatsPerBar = timeSignature.numerator; - const secondsPerBeat = 60 / tempo; - const secondsPerBar = secondsPerBeat * beatsPerBar; + const { secondsPerBeat, secondsPerBar, barStep } = rulerDensity; const visibleStartTime = Math.max( 0, scrollX / pixelsPerSecond - secondsPerBar, ); const visibleEndTime = (scrollX + dimensions.width) / pixelsPerSecond + secondsPerBar; - const startBar = Math.floor(visibleStartTime / secondsPerBar); + const startBar = Math.floor(visibleStartTime / secondsPerBar / barStep) * barStep; const endBar = Math.ceil(visibleEndTime / secondsPerBar); - for (let bar = startBar; bar <= endBar; bar += 1) { + for (let bar = startBar; bar <= endBar; bar += barStep) { const barTime = bar * secondsPerBar; const barX = barTime * pixelsPerSecond - scrollX; lines.push( @@ -5070,15 +5359,14 @@ export function Timeline({ const rulerMarks = useMemo(() => { const marks: React.ReactNode[] = []; const beatsPerBar = timeSignature.numerator; - const secondsPerBeat = 60 / tempo; - const secondsPerBar = secondsPerBeat * beatsPerBar; + const { secondsPerBeat, secondsPerBar, barStep } = rulerDensity; const startTime = Math.max(0, scrollX / pixelsPerSecond - secondsPerBar); const endTime = (scrollX + dimensions.width) / pixelsPerSecond + secondsPerBar; - const startBar = Math.floor(startTime / secondsPerBar); + const startBar = Math.floor(startTime / secondsPerBar / barStep) * barStep; const endBar = Math.ceil(endTime / secondsPerBar); - for (let bar = startBar; bar <= endBar; bar += 1) { + for (let bar = startBar; bar <= endBar; bar += barStep) { const barTime = bar * secondsPerBar; const barX = barTime * pixelsPerSecond - scrollX; // A bar starting offscreen can still contain visible beats. @@ -5098,7 +5386,7 @@ export function Timeline({ { - if (!filePath) return; + // Prefetch and visible rendering request the same complete source tiles. + // Only boundary tiles carry trim bounds; interior data is shared by clips. + const fetchWaveformData = (tile: WaveformTileRequest, priority = 0) => { + const { key: cacheKey, filePath, samplesPerPixel, startSample, numPixels, + sourceRangeStartSample, sourceRangeEndSample } = tile; if (isZoomingRef.current || isScrollingRef.current) return; - - const cacheKey = `${filePath}-${cacheSpp}-${startSample}`; - if (inFlightRef.current.has(cacheKey)) return; - + if (waveformCacheRef.current.has(cacheKey) || inFlightRef.current.has(cacheKey) || waveformCommitRef.current.has(cacheKey)) return; + const viewport = useDAWStore.getState(), epoch = getProjectEpoch(); + const generation = waveformGenerationRef.current.get(filePath) ?? 0; + const relevant = () => { + const state = useDAWStore.getState(); + return waveformMountedRef.current && getProjectEpoch() === epoch + && (waveformGenerationRef.current.get(filePath) ?? 0) === generation + && state.pixelsPerSecond === viewport.pixelsPerSecond && state.scrollX === viewport.scrollX && state.scrollY === viewport.scrollY + && !isZoomingRef.current && !isScrollingRef.current; + }; + waveformQueueRef.current.enqueue(`${cacheKey}@${generation}`, priority, async () => { + if (waveformCacheRef.current.has(cacheKey) || inFlightRef.current.has(cacheKey) || waveformCommitRef.current.has(cacheKey)) return; inFlightRef.current.add(cacheKey); + const requestToken = Symbol(cacheKey); + waveformRequestTokensRef.current.set(cacheKey, requestToken); + const release = () => { + if (waveformRequestTokensRef.current.get(cacheKey) !== requestToken) return; + waveformRequestTokensRef.current.delete(cacheKey); + inFlightRef.current.delete(cacheKey); + }; + let waitingForPeaks = false; try { - const peaks = await nativeBridge.getWaveformPeaks(filePath, cacheSpp, startSample, numPixels); + const peaks = await nativeBridge.getWaveformPeaks(filePath, samplesPerPixel, startSample, numPixels, sourceRangeStartSample, sourceRangeEndSample); + if (!relevant()) return; if (peaks && peaks.length > 0) { - setWaveformCache((prev) => { - const next = new Map(prev); - next.set(cacheKey, peaks); - // Prune cache if it grows too large (> 200 entries) — keep most recent - if (next.size > 200) { + waveformCommitRef.current.set(cacheKey, { filePath, generation, peaks }); + if (waveformCommitFrameRef.current !== null) return; + waveformCommitFrameRef.current = requestAnimationFrame(() => { + waveformCommitFrameRef.current = null; + if (!waveformMountedRef.current) return; + const next = new Map(waveformCacheRef.current); + for (const [key, pending] of waveformCommitRef.current) { + if ((waveformGenerationRef.current.get(pending.filePath) ?? 0) !== pending.generation) continue; + next.delete(key); next.set(key, pending.peaks); + } + waveformCommitRef.current.clear(); + // Bound retained source data rather than the number of variable-sized windows. + let bytes = 0; + for (const data of next.values()) bytes += data.length * (32 + (data[0]?.channels.length ?? 1) * 16); + while (bytes > 32 * 1024 * 1024 && next.size > 1) { const oldest = next.keys().next().value; - if (oldest) next.delete(oldest); + if (!oldest) break; + const data = next.get(oldest)!; + bytes -= data.length * (32 + (data[0]?.channels.length ?? 1) * 16); + next.delete(oldest); } - return next; + waveformCacheRef.current = next; + setWaveformCache(next); }); - // Always clear inFlight so evicted cache entries can be re-fetched - inFlightRef.current.delete(cacheKey); return; } // Empty = peaks still generating async; retry after 2s and force a re-render + waitingForPeaks = true; setTimeout(() => { - inFlightRef.current.delete(cacheKey); + if (!waveformMountedRef.current || (waveformGenerationRef.current.get(filePath) ?? 0) !== generation) return; + release(); forceRender((n) => n + 1); }, 2000); return; } catch (e) { console.error("Failed to fetch waveform:", e); + } finally { + if (!waitingForPeaks) release(); + if (!relevant() && waveformMountedRef.current && epoch === getProjectEpoch()) forceRender(n => n + 1); } - inFlightRef.current.delete(cacheKey); + }, relevant); }; // Keep a ref so the pre-fetch interval always calls the latest version const fetchWaveformDataRef = useRef(fetchWaveformData); fetchWaveformDataRef.current = fetchWaveformData; + const insertionPreviewY = (clipId: string, sourceTrackId: string) => { + if (dragState.type !== "move" || dragState.copyOnDrag || dragState.insertTrackIndex === undefined + || !(dragState.clipId === clipId || dragState.multiClipInfo?.some(info => info.clipId === clipId))) return undefined; + const row = insertedMoveSourceIds.indexOf(sourceTrackId); + return row < 0 ? undefined : (trackYs[dragState.insertTrackIndex] ?? contentHeight) + row * trackHeight; + }; + // Render individual clip with waveform const renderClip = ( clip: AudioClip, @@ -5315,6 +5632,7 @@ export function Timeline({ } } + visualTrackY = insertionPreviewY(clip.id, trackId) ?? visualTrackY; const clipY = visualTrackY + rowMetrics.clipInsetY; const waveformScaleView = waveformScaleViews[trackId] ?? DEFAULT_TIMELINE_VERTICAL_SCALE_VIEW; @@ -5331,8 +5649,8 @@ export function Timeline({ // Fetch waveform peaks for the VISIBLE PORTION only (viewport-tiled). const fileSR = clip.sampleRate || 44100; - const renderSpp = Math.max(1, Math.round(fileSR / pixelsPerSecond)); - const cacheSpp = quantizeSpp(renderSpp); + const renderSpp = Math.max(1, fileSR / pixelsPerSecond); + let cacheSpp = quantizeSpp(renderSpp); // Tile-aligned start sample: snap the visible clip start to TILE_PEAKS boundaries // so small scrolls within a tile reuse the same cache entry. @@ -5340,26 +5658,56 @@ export function Timeline({ const visibleEndPx = Math.min(width, dimensions.width - x); const clipOffsetSamples = Math.floor((clip.offset || 0) * fileSR); const visibleStartSampleInFile = clipOffsetSamples + Math.floor(visibleStartPx / pixelsPerSecond * fileSR); - const tileSamples = TILE_PEAKS * cacheSpp; - const alignedStartSample = Math.floor(visibleStartSampleInFile / tileSamples) * tileSamples; - - // How many peaks to fetch: visible clip width + extra to cover tile-alignment gap - const peakScale = renderSpp / cacheSpp; - const alignmentGapPeaks = Math.ceil((visibleStartSampleInFile - alignedStartSample) / cacheSpp); - const visiblePeaks = Math.ceil((visibleEndPx - visibleStartPx) / peakScale); - const numPeaksToFetch = Math.min(visiblePeaks + alignmentGapPeaks + TILE_PEAKS, 8000); - - const cacheKey = `${clip.filePath}-${cacheSpp}-${alignedStartSample}`; - const waveformData = isNarrowClip ? undefined : waveformCache.get(cacheKey); + const visibleEndSampleInFile = clipOffsetSamples + Math.ceil(visibleEndPx / pixelsPerSecond * fileSR); + let peakScale = renderSpp / cacheSpp; + const sourceStart = Math.round((clip.offset || 0) * fileSR); + const sourceEnd = Math.round(((clip.offset || 0) + clip.duration) * fileSR); + const waveformTiles = planWaveformTiles(clip.filePath ?? "", cacheSpp, visibleStartSampleInFile, + visibleEndSampleInFile + cacheSpp * 2, sourceStart, sourceEnd); + let alignedStartSample = waveformTiles[0]?.startSample ?? visibleStartSampleInFile; + let waveformData = isNarrowClip ? undefined : readWaveformTileWindow(waveformTiles, waveformCache, waveformWindows); const previewWaveformData = isNarrowClip ? undefined : waveformPreviewCache.get(clip.filePath); if (!isNarrowClip && clip.filePath && !waveformData) { - fetchWaveformData(clip.filePath, cacheSpp, alignedStartSample, numPeaksToFetch); + for (const tile of waveformTiles) { + if (!waveformCache.has(tile.key)) void fetchWaveformData(tile); + } + } + // During interaction, keep projecting actual source peaks at their original + // resolution until the new window arrives. Never substitute invented peaks. + if (!waveformData && !isNarrowClip) { + let bestDistance = Infinity; + for (const [key, data] of waveformCache) { + if (!key.startsWith(`${clip.filePath}-`)) continue; + const match = /-(\d+)-(\d+)(?:\|bounds:(\d+):(-?\d+))?$/.exec(key); + if (!match) continue; + const stride = Number(match[1]); + const start = Number(match[2]); + const boundedStart = Number(match[3] ?? 0); + const boundedEnd = Number(match[4] ?? -1); + // A fallback may never reintroduce trimmed source transients or use a + // narrower clip's edge aggregate for this clip's retained source. + if (start < sourceStart && boundedStart !== sourceStart) continue; + if (start + data.length * stride > sourceEnd && boundedEnd !== sourceEnd) continue; + if (boundedStart > sourceStart || (boundedEnd >= 0 && boundedEnd < sourceEnd)) continue; + if (start >= visibleEndSampleInFile || start + data.length * stride <= visibleStartSampleInFile) continue; + const distance = Math.abs(Math.log2(stride / cacheSpp)); + if (distance >= bestDistance) continue; + bestDistance = distance; + waveformData = data; + alignedStartSample = start; + peakScale = renderSpp / stride; + } + if (waveformData && bestDistance < Infinity) { + // peakScale retains the exact source-time mapping for this cached stride. + cacheSpp = renderSpp / peakScale; + } } // Generate waveform points for Line drawing. // Only renders the visible portion of the clip within the viewport, // so large clips don't generate tens of thousands of off-screen points. + const playbackGainEnvelope = getClipPlaybackGainEnvelope(clip); const generateWaveformPoints = (): React.ReactNode[] => { if (!waveformData || waveformData.length === 0) return []; @@ -5374,8 +5722,8 @@ export function Timeline({ // waveformData starts at alignedStartSample (not sample 0). // clipStartInData: offset (in peaks) from waveformData[0] to the clip's audio start. // Can be negative when alignedStartSample > clipStartSampleInFile (clip starts before tile). - const clipStartSampleInFile = Math.floor((clip.offset || 0) * fileSR); - const clipStartInData = Math.floor((clipStartSampleInFile - alignedStartSample) / cacheSpp); + const clipStartSampleInFile = (clip.offset || 0) * fileSR; + const clipStartInData = (clipStartSampleInFile - alignedStartSample) / cacheSpp; const totalAvailablePeaks = Math.max(0, waveformData.length - Math.max(0, clipStartInData)); // When clipStartInData < 0, the fetched data starts this many clip-pixels after pixel 0. // totalClipPeaks must account for this offset so visibleEnd is computed correctly. @@ -5401,48 +5749,56 @@ export function Timeline({ for (let i = visibleStart; i < visibleEnd; i++) { // dataIndex correctly maps pixel i → waveformData entry, accounting for // both clip.offset and the tile-alignment gap (clipStartInData can be negative). - const dataIndex = clipStartInData + Math.floor(i * peakScale); + const dataIndex = Math.floor(clipStartInData + i * peakScale); if (dataIndex < 0 || dataIndex >= waveformData.length) continue; const channelData = waveformData[dataIndex]?.channels[ch]; if (!channelData) continue; + let maximum = channelData.max; + for (let bin = dataIndex + 1; bin < Math.min(waveformData.length, Math.ceil(clipStartInData + (i + 1) * peakScale)); bin++) { + maximum = Math.max(maximum, waveformData[bin]?.channels[ch]?.max ?? maximum); + } // Per-pixel fade attenuation const timeInClip = i / pixelsPerSecond; let fadeMult = 1; if (clip.fadeIn > 0 && timeInClip < clip.fadeIn) { - fadeMult *= timeInClip / clip.fadeIn; + fadeMult *= fadeCurve(timeInClip / clip.fadeIn, clip.fadeInShape ?? 0); } if (clip.fadeOut > 0 && timeInClip > clip.duration - clip.fadeOut) { - fadeMult *= (clip.duration - timeInClip) / clip.fadeOut; + fadeMult *= fadeCurve((clip.duration - timeInClip) / clip.fadeOut, clip.fadeOutShape ?? 0); } - const pixelGain = gainFactor * fadeMult; + const pixelGain = gainFactor * fadeMult * interpolateClipGain(playbackGainEnvelope, timeInClip); const px = x + i; - const scaledMax = Math.max(-1, Math.min(1, channelData.max * pixelGain)); + const scaledMax = Math.max(-1, Math.min(1, maximum * pixelGain)); const py = centerY - scaledMax * halfHeight; points.push(px, py); } // Draw bottom half (min values, reversed) — only visible peaks for (let i = visibleEnd - 1; i >= visibleStart; i--) { - const dataIndex = clipStartInData + Math.floor(i * peakScale); + const dataIndex = Math.floor(clipStartInData + i * peakScale); if (dataIndex < 0 || dataIndex >= waveformData.length) continue; const channelData = waveformData[dataIndex]?.channels[ch]; if (!channelData) continue; + let minimum = channelData.min; + for (let bin = dataIndex + 1; bin < Math.min(waveformData.length, Math.ceil(clipStartInData + (i + 1) * peakScale)); bin++) { + minimum = Math.min(minimum, waveformData[bin]?.channels[ch]?.min ?? minimum); + } // Per-pixel fade attenuation const timeInClip = i / pixelsPerSecond; let fadeMult = 1; if (clip.fadeIn > 0 && timeInClip < clip.fadeIn) { - fadeMult *= timeInClip / clip.fadeIn; + fadeMult *= fadeCurve(timeInClip / clip.fadeIn, clip.fadeInShape ?? 0); } if (clip.fadeOut > 0 && timeInClip > clip.duration - clip.fadeOut) { - fadeMult *= (clip.duration - timeInClip) / clip.fadeOut; + fadeMult *= fadeCurve((clip.duration - timeInClip) / clip.fadeOut, clip.fadeOutShape ?? 0); } - const pixelGain = gainFactor * fadeMult; + const pixelGain = gainFactor * fadeMult * interpolateClipGain(playbackGainEnvelope, timeInClip); const px = x + i; - const scaledMin = Math.max(-1, Math.min(1, channelData.min * pixelGain)); + const scaledMin = Math.max(-1, Math.min(1, minimum * pixelGain)); const py = centerY - scaledMin * halfHeight; points.push(px, py); } @@ -5452,13 +5808,14 @@ export function Timeline({ waveforms.push( { - if (!previewWaveformData || previewWaveformData.length === 0) return []; + if (!previewWaveformData || previewWaveformData.length === 0 || !clip.sourceLength) return []; const numChannels = previewWaveformData[0]?.channels?.length || 1; const waveforms: React.ReactNode[] = []; const visibleStart = Math.max(0, Math.floor(-x)); @@ -5540,13 +5897,17 @@ export function Timeline({ + waveformScaleView.verticalOffset * (channelHeight / 2 - 2); const halfHeight = channelHeight / 2 - 2; for (let i = visibleStart; i < visibleEnd; i += 1) { - const dataIndex = Math.min(previewWaveformData.length - 1, Math.floor((i / Math.max(1, width)) * previewWaveformData.length)); + const sourceDuration = Math.max(clip.sourceLength || 0, (clip.offset || 0) + clip.duration); + const dataIndex = Math.min(previewWaveformData.length - 1, + Math.floor(((clip.offset || 0) + i / pixelsPerSecond) / sourceDuration * previewWaveformData.length)); const channelData = previewWaveformData[dataIndex]?.channels[ch]; if (!channelData) continue; points.push(x + i, centerY - Math.max(-1, Math.min(1, channelData.max)) * halfHeight); } for (let i = visibleEnd - 1; i >= visibleStart; i -= 1) { - const dataIndex = Math.min(previewWaveformData.length - 1, Math.floor((i / Math.max(1, width)) * previewWaveformData.length)); + const sourceDuration = Math.max(clip.sourceLength || 0, (clip.offset || 0) + clip.duration); + const dataIndex = Math.min(previewWaveformData.length - 1, + Math.floor(((clip.offset || 0) + i / pixelsPerSecond) / sourceDuration * previewWaveformData.length)); const channelData = previewWaveformData[dataIndex]?.channels[ch]; if (!channelData) continue; points.push(x + i, centerY - Math.max(-1, Math.min(1, channelData.min)) * halfHeight); @@ -5555,6 +5916,7 @@ export function Timeline({ waveforms.push( t.id === trackId)?.spectralView; // Skip expensive waveform generation during active zoom or for narrow clips — just show clip rect - const waveformShapes = (isZoomingRef.current || isNarrowClip) - ? [] - : waveformData - ? (isSpectral ? generateSpectralView() : generateWaveformPoints()) - : generatePreviewWaveformPoints(); + const artworkKey = [x, width, clipY, clipHeight, dimensions.width, cacheSpp, alignedStartSample, + waveformScaleView.verticalOffset, waveformScaleView.spectrogramScale, waveformScaleView.spectrogramDbFloor, + track?.waveformZoom, isSpectral, isNarrowClip, trackColor].join(":"); + const artwork = waveformArtworkRef.current.get(clip.id); + let waveformShapes: React.ReactNode[]; + if (artwork?.key === artworkKey && artwork.clip === clip && artwork.data === waveformData + && artwork.preview === previewWaveformData) { + waveformShapes = artwork.shapes; + } else { + waveformShapes = isNarrowClip ? [] : waveformData + ? (isSpectral ? generateSpectralView() : generateWaveformPoints()) : generatePreviewWaveformPoints(); + waveformArtworkRef.current.set(clip.id, { key: artworkKey, clip, data: waveformData, preview: previewWaveformData, shapes: waveformShapes }); + if (waveformArtworkRef.current.size > 256) waveformArtworkRef.current.delete(waveformArtworkRef.current.keys().next().value!); + } const EDGE_THRESHOLD = 8; @@ -5590,7 +5961,7 @@ export function Timeline({ const pointerPos = stage.getPointerPosition(); const timeInClip = (pointerPos.x + scrollX) / pixelsPerSecond - clip.startTime; if (timeInClip >= 0 && timeInClip <= clip.duration) { - const relativeY = pointerPos.y - clipY; + const relativeY = pointerPos.y + scrollY - clipY; const gain = Math.max(0, Math.min(2, 2 * (1 - relativeY / clipHeight))); addClipGainPoint(clip.id, timeInClip, gain); } @@ -5679,7 +6050,7 @@ export function Timeline({ const pointerPos = stage.getPointerPosition(); const relativeX = pointerPos.x - x; const clipTopY = clipY; - const relativeY = pointerPos.y - clipTopY; + const relativeY = pointerPos.y + scrollY - clipTopY; if (toolModeRef.current === "smart") { // Smart tool: position-dependent cursor @@ -5709,6 +6080,7 @@ export function Timeline({ const handleMouseDown = (e: KonvaEvent) => { if ((e.evt?.button ?? 0) !== 0) return; + if (finalizingTimelineClipGestureRef.current) return; // Split tool mode: click splits the clip at the clicked position if (toolModeRef.current === "split") { @@ -5733,7 +6105,7 @@ export function Timeline({ const pointerPos = stage.getPointerPosition(); const relativeX = pointerPos.x - x; const dragType = classifyTimelineClipGesture(relativeX, width, EDGE_THRESHOLD); - const relativeY = pointerPos.y - clipY; + const relativeY = pointerPos.y + scrollY - clipY; const smartFadeType = toolModeRef.current === "smart" && relativeY < 15 ? relativeX < 15 ? "resize-left" @@ -5807,7 +6179,7 @@ export function Timeline({ // preserve the entire group for both move and copy gestures. const currentSelectedIds = useDAWStore.getState().selectedClipIds; const isAlreadyInMultiSelection = currentSelectedIds.length > 1 && currentSelectedIds.includes(clip.id); - if (!copyOnDrag && !isAlreadyInMultiSelection) { + if (!isTimelineClipCopyAction(resolveLiveMouseModifierAction(e.evt || {}, "clip_drag")) && !isAlreadyInMultiSelection) { selectClip(clip.id); } @@ -5963,11 +6335,11 @@ export function Timeline({ dragState.type === "resize-left" || dragState.type === "resize-right" ) { - return { x: 0, y: 0 }; // Lock position - we manage clip position via state + return { x: 0, y: -scrollY }; // Lock position - we manage clip position via state } // For move operations, also return 0,0 because we update state directly // and the Group re-renders at the new position - return { x: 0, y: 0 }; + return { x: 0, y: -scrollY }; }} > {/* Clip background with gradient-like layering */} @@ -5994,36 +6366,17 @@ export function Timeline({ listening={false} /> )} - {/* Waveform skeleton — shown while peaks are loading (skip for narrow clips) */} - {!clip.muted && !isNarrowClip && clip.filePath && waveformShapes.length === 0 && (() => { - const barCount = Math.min(32, Math.max(4, Math.floor(width / 6))); - const barSpacing = width / barCount; - const barWidth = Math.max(1.5, barSpacing * 0.4); - const skeletonBars: React.ReactNode[] = []; - // Deterministic pseudo-random heights based on clip id - const seed = clip.id.charCodeAt(0) + clip.id.charCodeAt(clip.id.length - 1); - for (let i = 0; i < barCount; i++) { - const pseudoRand = Math.abs(Math.sin(seed * (i + 1) * 2.654)) * 0.6 + 0.2; - const barH = waveformHeight * pseudoRand; - const barY = clipY + 5 + (waveformHeight - barH) / 2; - skeletonBars.push( - - ); - } - return skeletonBars; - })()} + {/* A neutral loading label never impersonates source waveform peaks. */} + {!clip.muted && !isNarrowClip && clip.filePath && waveformShapes.length === 0 && ( + + )} {/* Waveform visualization */} {!clip.muted && waveformShapes} + {clip.crossfadeLayers?.length ? [x + point.time * pixelsPerSecond, clipY + clipHeight * (1 - Math.min(2, point.gain) / 2)])} + stroke="#67e8f9" strokeWidth={1.5} opacity={0.85} listening={false} + /> : null} {/* Clip gain envelope line */} {clip.gainEnvelope && clip.gainEnvelope.length >= 2 && (() => { const clipH = clipHeight; @@ -6177,14 +6530,29 @@ export function Timeline({ ctx.roundRect(x, clipY, width, clipHeight, 3); }} > - {Array.from({ length: Math.ceil(width / 12) + 1 }).map((_, i) => ( - - ))} + { + // A long clip can be hundreds of thousands of pixels wide. + // Keep one node and draw only stripes crossing the viewport, + // retaining their phase relative to the original clip edge. + const left = Math.max(0, x); + const right = Math.min(dimensions.width, x + width); + if (right <= left) return; + const first = Math.max(0, Math.floor((left - x) / 12)); + const last = Math.min(Math.ceil(width / 12), Math.ceil((right + clipHeight - x) / 12)); + ctx.beginPath(); + for (let stripe = first; stripe <= last; stripe++) { + const stripeX = x + stripe * 12; + ctx.moveTo(stripeX, clipY); + ctx.lineTo(stripeX - clipHeight, clipY + clipHeight); + } + ctx.strokeShape(shape); + }} + /> )} @@ -6263,7 +6631,7 @@ export function Timeline({ } const stage = e.target.getStage(); const pointerPos = stage.getPointerPosition(); - const relativeY = pointerPos.y - clipY; + const relativeY = pointerPos.y + scrollY - clipY; const normalizedY = 1 - relativeY / clipHeight; const clampedY = Math.max(0, Math.min(1, normalizedY)); const newVolumeDB = clampedY * volumeRange - 60; @@ -6310,7 +6678,7 @@ export function Timeline({ onDragEnd={handleVolumeDragEnd} dragBoundFunc={(pos: any) => ({ x: x, // Lock horizontal position - y: Math.max(clipY, Math.min(clipY + clipHeight, pos.y)), + y: Math.max(clipY - scrollY, Math.min(clipY + clipHeight - scrollY, pos.y)), })} style={{ cursor: "ns-resize" }} /> @@ -6456,7 +6824,7 @@ export function Timeline({ }} dragBoundFunc={(pos: any) => ({ x: Math.max(x, Math.min(x + maxFadeWidth, pos.x)), - y: clipY + 5, + y: clipY + 5 - scrollY, })} /> {/* Fade out handle - circle at fade position */} @@ -6491,7 +6859,7 @@ export function Timeline({ }} dragBoundFunc={(pos: any) => ({ x: Math.max(x + width - maxFadeWidth, Math.min(x + width, pos.x)), - y: clipY + 5, + y: clipY + 5 - scrollY, })} /> @@ -6537,18 +6905,28 @@ export function Timeline({ clipColor: string, isMuted: boolean, activeNotes?: { note: number; startTimestamp: number }[], + cacheId = "", ) => { const safeDuration = Math.max(clipDuration, 0.001); - const bars = sampleMIDIThumbnailBars( - buildMIDIThumbnailBars(events, safeDuration, activeNotes), - width, - ); + let index = midiThumbnailIndicesRef.current.get(cacheId); + if (!index) { + index = new MIDIThumbnailIndex(); + midiThumbnailIndicesRef.current.set(cacheId, index); + if (midiThumbnailIndicesRef.current.size > 256) { + midiThumbnailIndicesRef.current.delete(midiThumbnailIndicesRef.current.keys().next().value!); + } + } + index.update(events); + const visibleStart = Math.max(0, -x / pixelsPerSecond); + const visibleEnd = Math.min(safeDuration, (dimensions.width - x) / pixelsPerSecond); + const bars = index.sampleVisible(visibleStart, visibleEnd, safeDuration, Math.min(dimensions.width, width)); if (bars.length === 0) return null; - const notes = bars.map((bar) => bar.note); - const minNote = Math.min(...notes); - const maxNote = Math.max(...notes); + // A recording uses a fixed band, so new notes and growing width can never + // move historical notes. Completed clips use their entire source range. + const minNote = activeNotes ? 0 : index.pitchRange.min; + const maxNote = activeNotes ? 127 : index.pitchRange.max; const noteRange = Math.max(1, maxNote - minNote); const isNarrowMidi = width < 60; @@ -6578,152 +6956,6 @@ export function Timeline({ }); }; - // Render recording clip with live waveform - const renderRecordingClip = ( - clip: RecordingClip, - trackY: number, - trackColor: string, - ) => { - const track = tracks.find((candidate) => candidate.id === clip.trackId); - const isMIDIRecordingTrack = - track?.type === "midi" || track?.type === "instrument"; - const rowMetrics = getTimelineRowMetrics( - track ?? { - type: "audio", - showAutomation: false, - automationLanes: [], - }, - trackHeight, - ); - const clipY = trackY + rowMetrics.clipInsetY; - const clipHeight = rowMetrics.clipHeight; - const x = clip.startTime * pixelsPerSecond - scrollX; - const width = (recordingRenderTime - clip.startTime) * pixelsPerSecond; - const previewHeight = Math.max(8, clipHeight - 10); - - if (width <= 0) return null; - - // Get recording waveform data if available - const recordingData = recordingWaveformCache.get(clip.trackId); - const midiPreview = recordingMIDIPreviews[clip.trackId]; - - // Generate waveform visualization from recording peaks - const renderRecordingWaveform = (): React.ReactNode[] => { - if (!recordingData || recordingData.peaks.length === 0) return []; - - if (recordingData.recordingStartTime !== clip.startTime) return []; - const { peaks: recordingPeaks } = recordingData; - const numChannels = recordingPeaks[0]?.channels?.length || 1; - const waveforms: React.ReactNode[] = []; - const waveformHeight = Math.max(8, clipHeight - 10); - const pixelsPerPeak = recordingData.samplesPerPixel / recordingData.sampleRate * pixelsPerSecond; - const waveformX = recordingPeakX(recordingData, 0, pixelsPerSecond, scrollX); - const firstVisiblePeak = Math.max( - 0, - Math.floor((-waveformX) / Math.max(pixelsPerPeak, 0.001)) - 1, - ); - const lastVisiblePeak = Math.min( - recordingPeaks.length, - Math.ceil((dimensions.width - waveformX) / Math.max(pixelsPerPeak, 0.001)) + 1, - ); - - for (let ch = 0; ch < numChannels; ch++) { - const points: number[] = []; - const channelHeight = waveformHeight / numChannels; - const channelY = clipY + 5 + ch * channelHeight; - const centerY = channelY + channelHeight / 2; - const halfHeight = channelHeight / 2 - 2; - - // Reproject source sample bins at the current zoom, even while a new - // resolution is pending. Cached peaks never retain obsolete pixel widths. - for (let i = firstVisiblePeak; i < lastVisiblePeak; i++) { - const channelData = recordingPeaks[i].channels[ch]; - if (!channelData) continue; - - const px = waveformX + i * pixelsPerPeak; - const py = centerY - channelData.max * halfHeight; - points.push(px, py); - } - - // Draw bottom half (min values, reversed) - for (let i = lastVisiblePeak - 1; i >= firstVisiblePeak; i--) { - const channelData = recordingPeaks[i].channels[ch]; - if (!channelData) continue; - - const px = waveformX + i * pixelsPerPeak; - const py = centerY - channelData.min * halfHeight; - points.push(px, py); - } - - if (points.length > 0) { - waveforms.push( - , - ); - } - } - - return waveforms; - }; - - return ( - - {/* Background */} - - {/* Live waveform or MIDI preview visualization */} - {isMIDIRecordingTrack - ? renderCompactMIDIThumbnail( - midiPreview?.events || [], - Math.max(0.001, recordingRenderTime - clip.startTime), - x, - width, - clipY, - previewHeight, - trackColor, - !!track?.muted, - midiPreview?.activeNotes, - ) - : renderRecordingWaveform()} - {/* Recording indicator text */} - - {/* Recording indicator dot (pulsing effect via opacity) */} - - - ); - }; - // Render MIDI clip const renderMIDIClip = ( clip: MIDIClip, @@ -6748,10 +6980,12 @@ export function Timeline({ const isNarrowMidi = width < 60; let visualTrackY = trackY; - // Visual offset for multi-clip drag - if (!isCopyPreview && !dragState.copyOnDrag && dragState.type === "move" && dragState.multiClipInfo && dragState.multiClipInfo.length > 1 && dragState.targetTrackIndex != null) { - const info = dragState.multiClipInfo.find(m => m.clipId === clip.id); - if (info) { + // The anchor follows the destination for single and multi-clip gestures. + if (!isCopyPreview && !dragState.copyOnDrag && dragState.type === "move" && dragState.targetTrackIndex != null) { + const info = dragState.multiClipInfo?.find(m => m.clipId === clip.id); + if (dragState.clipId === clip.id) { + visualTrackY = trackYs[dragState.targetTrackIndex] ?? visualTrackY; + } else if (info) { const trackDelta = dragState.targetTrackIndex - (dragState.trackIndex ?? 0); const visualTrackIdx = info.trackIndex + trackDelta; if (visualTrackIdx !== _trackIndex) { @@ -6759,12 +6993,17 @@ export function Timeline({ } } } + visualTrackY = insertionPreviewY(clip.id, track?.id ?? "") ?? visualTrackY; const clipY = visualTrackY + rowMetrics.clipInsetY; // Skip if clip is outside visible area if (x + width < 0 || x > dimensions.width) return null; - const visibleEvents = getVisibleMIDIEventsForClip(clip); + let visibleEvents = midiVisibleEventsRef.current.get(clip); + if (!visibleEvents) { + visibleEvents = getVisibleMIDIEventsForClip(clip); + midiVisibleEventsRef.current.set(clip, visibleEvents); + } const sourceLoopLength = getMIDIClipSourceLoopLength(clip); const showLoopNotches = sourceLoopLength > 0 && clip.duration > sourceLoopLength + 0.000001; const loopPhase = showLoopNotches @@ -6818,6 +7057,7 @@ export function Timeline({ }; const handleMIDIClipMouseDown = (e: KonvaEvent) => { + if (finalizingTimelineClipGestureRef.current) return; if ((e.evt?.button ?? 0) !== 0) return; if (toolModeRef.current === "split") { @@ -6862,7 +7102,7 @@ export function Timeline({ && shouldStartTimelineCopyDrag(true, dragType, clipEditLocked); const currentSelectedIds = useDAWStore.getState().selectedClipIds; const isAlreadyInMultiSelection = currentSelectedIds.length > 1 && currentSelectedIds.includes(clip.id); - if (!copyOnDrag && !isAlreadyInMultiSelection) { + if (!isTimelineClipCopyAction(resolveLiveMouseModifierAction(e.evt || {}, "clip_drag")) && !isAlreadyInMultiSelection) { selectClip(clip.id); } @@ -7024,7 +7264,7 @@ export function Timeline({ onDragStart={handleMIDIClipDragStart} onDragMove={handleMIDIClipDragMove} onDragEnd={handleMIDIClipDragEnd} - dragBoundFunc={() => ({ x: 0, y: 0 })} + dragBoundFunc={() => ({ x: 0, y: -scrollY })} > {/* Clip background */} @@ -7866,15 +8108,17 @@ export function Timeline({ const renderGhostTrack = () => { if (!showGhostTrack) return null; - const ghostY = contentHeight; + const ghostY = dragState.insertTrackIndex === undefined ? contentHeight + : trackYs[dragState.insertTrackIndex] ?? contentHeight; return ( + 1 ? `+ Create ${copyPreviewPlan?.newTracks.length || insertedMoveSourceIds.length} Tracks` : "+ Create New Track"} fill="#ffffff" opacity={0.5} listening={false} @@ -7896,13 +8140,13 @@ export function Timeline({ const maxClipEnd = getTimelineVisibleContentEnd( tracks, recordingClips.length > 0 - ? useDAWStore.getState().transport.currentTime + ? recordingExtentTime : undefined, ); // Timeline length = last clip end + the shared five-minute editing tail. return (maxClipEnd + TIMELINE_SCROLL_PADDING_SECONDS) * pixelsPerSecond; - }, [tracks, recordingClips, pixelsPerSecond]); + }, [tracks, recordingClips, pixelsPerSecond, recordingExtentTime]); const totalTimelineWidth = calculateTotalWidth(); const scrollbarOverview = useMemo(() => { @@ -7946,31 +8190,10 @@ export function Timeline({ }; const exportMIDIClip = () => { - if (!clip || !isMidi) return; - const midiClip = clip as MIDIClip; - void (async () => { - const filePath = await nativeBridge.showSaveDialog( - `${midiClip.name || "MIDI Clip"}.mid`, - "Export MIDI Clip", - "*.mid;*.midi", - ); - if (!filePath) return; - const exportClip = { ...midiClip, startTime: 0 }; - const exported = serializeMIDIClipsForBackend([exportClip], track?.midiEffects || [])[0]; - const success = await nativeBridge.exportProjectMIDI(filePath, [{ - name: track?.name || "MIDI Track", - clips: [{ - startTime: 0, - duration: exported?.duration ?? midiClip.duration, - events: exported?.events ?? getVisibleMIDIEventsForClip(midiClip), - }], - }]); - useDAWStore.getState().showToast( - success ? "MIDI clip exported" : "Failed to export MIDI clip", - success ? "success" : "error", - ); - })(); + if (clip && isMidi && track) void exportMIDISelection(state, [{ track, clip: clip as MIDIClip }], "single"); }; + const joinSelection = midiJoinSelection(state); + const exportSelection = selectedMIDIExportEntries(state); const getMIDIContentEnd = () => { if (!clip || !isMidi) return 0.01; const midiClip = clip as MIDIClip; @@ -7985,6 +8208,42 @@ export function Timeline({ }; return [ + { + label: "Detect Tempo...", + onClick: () => useDAWStore.getState().openTempoDetection(menu.trackId, menu.clipId), + }, + ...(isMidi ? [{ + label: `Join Selected MIDI Clips${joinSelection.count > 1 ? ` (${joinSelection.count})` : ""}`, + shortcut: shortcut("clip.joinSelectedMidi", "Ctrl+J"), + disabled: Boolean(joinSelection.disabledReason), + disabledReason: joinSelection.disabledReason, + onClick: () => useDAWStore.getState().joinSelectedMIDIClips(), + }, { + label: `Export Selected MIDI Clips${exportSelection.length > 1 ? ` (${exportSelection.length})` : ""}...`, + shortcut: shortcut("clip.exportSelectedMidi", ""), + disabled: exportSelection.length === 0, + onClick: () => { void exportMIDISelection(state, exportSelection); }, + }] : [{ + label: "Crossfade Selected Audio Clips", + shortcut: shortcut("clip.crossfadeSelected", "Shift+X"), + disabled: selectedCrossfadePairs(state).length === 0, + onClick: () => useDAWStore.getState().crossfadeSelectedClips(), + }, { + label: "Edit Selected Crossfade...", + disabled: !canEditSelectedCrossfade(state), + onClick: () => useDAWStore.getState().editSelectedCrossfade(), + }, { + label: audioConversionJobs[menu.clipId] + ? `Cancel ${audioConversionJobs[menu.clipId].targetRate / 1000} kHz Audio Copy` + : `Create ${state.projectSampleRate / 1000} kHz Project-Rate Copy`, + disabled: audioConversionJobs[menu.clipId] + ? audioConversionJobs[menu.clipId].stage !== "converting" + : Boolean(clip && ((clip as AudioClip).sampleRate === state.projectSampleRate || isClipEditLocked(state, clip))), + onClick: () => audioConversionJobs[menu.clipId] + ? useDAWStore.getState().cancelProjectRateCopy(menu.clipId) + : void useDAWStore.getState().createProjectRateCopy(menu.trackId, menu.clipId), + }]), + { divider: true, label: "" }, ...(isMidi ? [{ label: "Open in Piano Roll", @@ -8207,7 +8466,7 @@ export function Timeline({ ], }, { - label: "Export MIDI Clip...", + label: "Export This MIDI Clip...", onClick: exportMIDIClip, }, { divider: true, label: "" }, @@ -8281,6 +8540,18 @@ export function Timeline({ onFocusCapture={() => activateShortcutContext({ kind: "timeline" })} data-shortcut-context="timeline" > + {(timeSelection || dragState.dragActivated || slipFeedbackActive) && ( +
+ {slipFeedbackActive ? "Slip contents" + : dragState.dragActivated ? (dragState.copyOnDrag ? "Copy clips" : dragState.type === "move" ? "Move clips" : "Resize clip") + : timeSelection ? `Time selection: ${timeSelection.start.toFixed(2)}?${timeSelection.end.toFixed(2)} s (${Math.abs(timeSelection.end - timeSelection.start).toFixed(2)} s)` : ""} + {timeSelection && !timeSelectionDrag?.active && !dragState.dragActivated && !slipFeedbackActive && <> + + + + } +
+ )} {showRuler && (
)} + {/* Native scrolling retains the full track extent while canvases stay + viewport-sized. Layer offsets translate world track coordinates. */} +
+
{/* Main Timeline Stage */} {/* Static Layer: grid, track backgrounds, overlays — only redraws on zoom/scroll. listening={false} skips Konva hit-testing for every child, reducing event overhead. */} - + {/* Background fill */} {/* Dynamic Layer: clips, playhead, selection rects, automation — changes during playback/editing */} - + {/* Invisible full-stage rect for click/drag event detection */} rc.trackId === track.id) && - renderRecordingClip( - recordingClips.find((rc) => rc.trackId === track.id)!, - trackY, - track.color, - )} {mouseBehaviorProfileId === "audacity" && track.type === "audio" && (() => { const scaleView = waveformScaleViews[track.id] @@ -8959,8 +9231,9 @@ export function Timeline({ {/* Marquee selection rectangle */} {marqueeRect && ( - + + + {externalMediaPreview && (() => { const target = externalMediaPreview.target; const targetTrack = target.kind === "existingTrack" ? tracks[target.trackIndex] : undefined; @@ -9317,11 +9598,13 @@ export function Timeline({ type="main" pixelsPerSecond={pixelsPerSecond} scrollX={scrollX} - stageHeight={stageHeight} + stageHeight={Math.max(1, availableHeight)} viewportWidth={dimensions.width} /> +
+
{/* Custom Horizontal Scrollbar */} { - const beatsPerBar = timeSignature.numerator; - const secondsPerBeat = 60 / tempo; - const pixelsPerBeat = secondsPerBeat * pixelsPerSecond; - const pixelsPerBar = pixelsPerBeat * beatsPerBar; - - if (pixelsPerBeat >= 140) { - return { - mode: "division" as const, - divisionsPerBeat: pixelsPerBeat >= 260 ? 8 : 4, - labelEveryBars: pixelsPerBar < 90 ? 2 : 1, - }; - } - - if (pixelsPerBeat >= 32) { - return { - mode: "beat" as const, - divisionsPerBeat: 1, - labelEveryBars: pixelsPerBar < 60 ? 2 : 1, - }; - } - - return { - mode: "bar" as const, - divisionsPerBeat: 1, - labelEveryBars: - pixelsPerBar < 30 ? 8 : pixelsPerBar < 45 ? 4 : pixelsPerBar < 70 ? 2 : 1, - }; - }, [pixelsPerSecond, tempo, timeSignature.numerator]); + const rulerDensity = useMemo(() => getTimelineRulerDensity( + pixelsPerSecond, tempo, timeSignature, scrollX, width, + ), [pixelsPerSecond, tempo, timeSignature, scrollX, width]); const rulerMarks = useMemo(() => { const marks: React.ReactNode[] = []; const beatsPerBar = timeSignature.numerator; - const secondsPerBeat = 60 / tempo; - const secondsPerBar = secondsPerBeat * beatsPerBar; + const { secondsPerBeat, secondsPerBar, barStep } = rulerDensity; const startTime = Math.max(0, scrollX / pixelsPerSecond - secondsPerBar); const endTime = (scrollX + width) / pixelsPerSecond + secondsPerBar; - const startBar = Math.floor(startTime / secondsPerBar); + const startBar = Math.floor(startTime / secondsPerBar / barStep) * barStep; const endBar = Math.ceil(endTime / secondsPerBar); - for (let bar = startBar; bar <= endBar; bar += 1) { + for (let bar = startBar; bar <= endBar; bar += barStep) { const barTime = bar * secondsPerBar; const barX = barTime * pixelsPerSecond - scrollX; // A bar starting offscreen can still contain visible beats. @@ -552,7 +526,7 @@ export function TimelineRuler() { state.transport.isPlaying); const isPaused = useDAWStore((state) => state.transport.isPaused); - const isRecording = useDAWStore((state) => state.transport.isRecording); + const { isRecording, isPreparingToRecord } = useDAWStore(useShallow(state => ({ + isRecording: state.transport.isRecording, isPreparingToRecord: state.isPreparingToRecord, + }))); const tempo = useDAWStore((state) => state.transport.tempo); const loopEnabled = useDAWStore((state) => state.transport.loopEnabled); const recordMode = useDAWStore((state) => state.recordMode); @@ -217,6 +219,7 @@ export function TransportBar() { const hasArmedTracks = tracks.some((track) => track.armed); const getStatusText = () => { + if (isPreparingToRecord) return "Preparing recording"; if (isRecording) return "Recording"; if (isPlaying) return "Playing"; if (isPaused) return "Paused"; @@ -234,6 +237,7 @@ export function TransportBar() { "text-neutral-500": !isRecording, })} aria-live="polite" + role="status" aria-label={`Transport status: ${getStatusText()}`} > {isRecording && ( @@ -270,10 +274,10 @@ export function TransportBar() { variant="danger" size="icon-lg" active={isRecording} - disabled={!hasArmedTracks && !isRecording} + disabled={!hasArmedTracks && !isRecording && !isPreparingToRecord} onClick={handleRecord} - title={isRecording || hasArmedTracks ? `Record (${shortcuts.record})` : "Arm a track to record"} - aria-label={isRecording || hasArmedTracks ? "Record" : "Arm a track to record"} + title={isPreparingToRecord ? "Cancel recording preparation" : isRecording || hasArmedTracks ? `Record (${shortcuts.record})` : "Arm a track to record"} + aria-label={isPreparingToRecord ? "Cancel recording preparation" : isRecording || hasArmedTracks ? "Record" : "Arm a track to record"} > @@ -281,7 +285,7 @@ export function TransportBar() { variant="success" size="icon-lg" active={isPlaying && !isRecording} - disabled={isPlaying && !isPaused} + disabled={isPreparingToRecord || (isPlaying && !isPaused)} onClick={handlePlay} title={`Play (${shortcuts.play})`} aria-label="Play" @@ -291,7 +295,7 @@ export function TransportBar() { From 392d66b430d9a280b7ac468f90b6ef81e970f398 Mon Sep 17 00:00:00 2001 From: Sourav Das Date: Wed, 4 Nov 2026 13:24:33 +0530 Subject: [PATCH 08/33] Keep ruler fixture module loading owned by the document --- docs/workflow-review-qualification.md | 13 +++++++++++++ frontend/e2e/support/rulerChecks.ts | 27 ++++++++++++++++++--------- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/docs/workflow-review-qualification.md b/docs/workflow-review-qualification.md index cd558665..f87ea3f2 100644 --- a/docs/workflow-review-qualification.md +++ b/docs/workflow-review-qualification.md @@ -128,3 +128,16 @@ evaluation context during development-server startup; the complete subsequent 25-case run passed. Remote Windows/Linux/macOS qualification is tracked in the PR checks and must not be inferred from these local results. These fixes do not change the strict eight-sample hardware timing and listening limits above. + +The replacement run on `5fc661b0` passed all four native jobs, all AI/Python jobs +and the macOS browser job. Windows and Linux each passed 285 browser cases but +failed the same ruler setup: Chromium collected the pending dynamic-import +Promise inside `page.evaluate`. This is the same local failure noted above; +the earlier passing rerun was insufficient to establish setup reliability. +The ruler fixture now waits for the actual transport UI, resolves App's store +import with Playwright's request client and loads it in a document-owned module +before synchronous seeding. All nine ruler behavior assertions are retained, +without adding retries, skips or longer timeouts. Website PR #32's updated CI +also passed; this fixture correction changes no app behavior or shortcut. +Local e2e TypeScript and 12 consecutive complete ruler runs passed +(`output/ci-nov04/ruler-followup/repeated-ruler.log`). diff --git a/frontend/e2e/support/rulerChecks.ts b/frontend/e2e/support/rulerChecks.ts index 7524dac1..9ac02bed 100644 --- a/frontend/e2e/support/rulerChecks.ts +++ b/frontend/e2e/support/rulerChecks.ts @@ -1,5 +1,5 @@ import type { Page } from "@playwright/test"; -import type { DAWState, useDAWStore } from "../../src/store/useDAWStore"; +import type { createDefaultTrack, DAWState, useDAWStore } from "../../src/store/useDAWStore"; import type { BrowserRegressionReport } from "./interactionFixtureTypes"; type RulerSeedOverrides = Partial>; @@ -15,7 +15,7 @@ interface RulerView { } declare global { interface Window { - __rulerGestureQA: { store: typeof useDAWStore }; + __rulerGestureQA: { store: typeof useDAWStore; createDefaultTrack: typeof createDefaultTrack }; } } @@ -46,14 +46,23 @@ export async function runRulerChecks(page: Page): Promise { - // Follow App's transformed Vite import so HMR cannot create a second store. - const source = await (await fetch('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/src/App.tsx')).text(); - const match = source.match(/from "([^"]*useDAWStore\.ts[^"]*)"/); - if (!match) throw new Error('Cannot locate the real App store import'); - const { useDAWStore: store, createDefaultTrack } = await import(/* @vite-ignore */ match[1]) as typeof import("../../src/store/useDAWStore"); + await page.getByRole('contentinfo', { name: 'Transport controls' }).waitFor(); + // Follow App's transformed import to retain the real store instance, but + // let the document own module loading. Chromium can collect a CDP + // evaluation's pending dynamic-import Promise before Vite finishes loading. + const response = await page.request.get('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/src/App.tsx'); + if (!response.ok()) throw new Error('App source was not available'); + const source = await response.text(); + const storeUrl = source.match(/from "([^"]*useDAWStore\.ts[^"]*)"/)?.[1]; + if (!storeUrl) throw new Error('Cannot locate the real App store import'); + await page.addScriptTag({ type: 'module', content: ` + import { useDAWStore as store, createDefaultTrack } from ${JSON.stringify(storeUrl)}; + window.__rulerGestureQA = { store, createDefaultTrack }; + ` }); + await page.waitForFunction(() => Boolean(window.__rulerGestureQA)); + await page.evaluate(() => { + const { store, createDefaultTrack } = window.__rulerGestureQA; const track = createDefaultTrack('ruler-browser-qa', 'Ruler browser QA', '#4379ee', 'audio', []); - window.__rulerGestureQA = { store }; store.setState({ tracks: [track], selectedTrackId: track.id, selectedTrackIds: [track.id], selectedClipId: null, selectedClipIds: [], recordingClips: [], recordingMIDIPreviews: {}, From f45637beed602117a3d6fca0f4e8ab8d46ef03b2 Mon Sep 17 00:00:00 2001 From: Sourav Das Date: Wed, 4 Nov 2026 13:54:56 +0530 Subject: [PATCH 09/33] Separate offline output parity from runner scheduling deadlines --- Source/AudioEngine.cpp | 5 ++- Source/AudioEngine.h | 3 ++ Source/AudioEngineParallelRegression.inc | 41 ++++++++++++++++++------ docs/workflow-review-qualification.md | 19 +++++++++++ 4 files changed, 58 insertions(+), 10 deletions(-) diff --git a/Source/AudioEngine.cpp b/Source/AudioEngine.cpp index fd724321..2ddbcde7 100644 --- a/Source/AudioEngine.cpp +++ b/Source/AudioEngine.cpp @@ -7282,7 +7282,10 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha // This is a collection target, not a guarantee for the remaining // serial/master work. The full callback deadline meter includes // those stages and reports any overrun independently. - const double remainingMs = juce::jmax(0.0, callbackBlockDurationMs - 0.012 - elapsedMs); + const double collectionBudgetMs = deviceStartupMode == DeviceStartup::offline + && regressionTrackCollectionBudgetMs > 0.0 + ? regressionTrackCollectionBudgetMs : callbackBlockDurationMs; + const double remainingMs = juce::jmax(0.0, collectionBudgetMs - 0.012 - elapsedMs); const auto cutoff = now + static_cast(remainingMs * 0.001 * static_cast(juce::Time::getHighResolutionTicksPerSecond())); const auto hardCutoff = cutoff diff --git a/Source/AudioEngine.h b/Source/AudioEngine.h index 36e204db..4652ef2f 100644 --- a/Source/AudioEngine.h +++ b/Source/AudioEngine.h @@ -1076,6 +1076,9 @@ class AudioEngine : public juce::AudioIODeviceCallback, std::atomic realtimeSnapshotAudioReaders { 0 }; RealtimeTrackExecutor realtimeTrackExecutor; int requestedRealtimeWorkers = -1; + // Only the device-free regression scopes this while driving callbacks + // synchronously. Never configurable by the app or a hardware probe. + double regressionTrackCollectionBudgetMs = 0.0; std::atomic probeTrackRecoveryGraceMicroseconds { 0 }; const RealtimeTrackSnapshot* pendingParallelSnapshot = nullptr; std::atomic parallelTrackBlocks { 0 }, parallelTrackRejectedJobs { 0 }; diff --git a/Source/AudioEngineParallelRegression.inc b/Source/AudioEngineParallelRegression.inc index b53348f7..11005b5f 100644 --- a/Source/AudioEngineParallelRegression.inc +++ b/Source/AudioEngineParallelRegression.inc @@ -185,8 +185,14 @@ juce::var AudioEngine::runParallelTrackRegression() input.setSample(channel, sample, static_cast(0.1 * std::sin( 0.017 * (sample + blockIndex * capacity) + 0.29 * channel))); }; - const auto callback = [&](AudioEngine& engine, juce::AudioBuffer& output, int samples) + constexpr double parityCollectionBudgetMs = 1000.0; + const auto callback = [&](AudioEngine& engine, juce::AudioBuffer& output, int samples, + double collectionBudgetMs = 0.0) { + // Exact-output cases test completed work, independently of shared CI + // scheduling. Keep real sample rates/block lengths and nominal callback + // telemetry. Bounds and deliberately late-worker cases use zero here. + const juce::ScopedValueSetter budget(engine.regressionTrackCollectionBudgetMs, collectionBudgetMs); for (int channel = 0; channel < output.getNumChannels(); ++channel) juce::FloatVectorOperations::fill(output.getWritePointer(channel), poison, capacity); engine.audioDeviceIOCallbackWithContext(input.getArrayOfReadPointers(), input.getNumChannels(), @@ -209,6 +215,8 @@ juce::var AudioEngine::runParallelTrackRegression() auto serial = createEngine(trackCount, 0); auto parallel = createEngine(trackCount, trackCount - 1); bool exact = true, stateChanges = true, finite = true, suffixPreserved = true; + int firstMismatchBlock = -1; + juce::var firstMismatchStats; float peak = 0.0f; for (int block = 0; block < 40; ++block) { @@ -250,17 +258,32 @@ juce::var AudioEngine::runParallelTrackRegression() if (block == 36) stateChanges = engine->removeTrack(trackId(trackCount - 1)) && stateChanges; } fillInput(block); - callback(*serial, serialOutput, capacity); - callback(*parallel, parallelOutput, capacity); - exact = exactOutput(capacity) && exact; + callback(*serial, serialOutput, capacity, parityCollectionBudgetMs); + callback(*parallel, parallelOutput, capacity, parityCollectionBudgetMs); + const bool blockExact = exactOutput(capacity); + if (!blockExact && firstMismatchBlock < 0) + { + firstMismatchBlock = block; + firstMismatchStats = parallel->getRealtimeTrackExecutionDiagnostics(); + } + exact = blockExact && exact; peak = juce::jmax(peak, parallelOutput.getMagnitude(0, capacity)); } const auto parityStats = parallel->realtimeTrackExecutor.snapshotStats(); + auto parityDiagnostics = parallel->getRealtimeTrackExecutionDiagnostics(); + parityDiagnostics.getDynamicObject()->setProperty("exactOutput", exact); + parityDiagnostics.getDynamicObject()->setProperty("stateChanges", stateChanges); + parityDiagnostics.getDynamicObject()->setProperty("peak", peak); + parityDiagnostics.getDynamicObject()->setProperty("firstMismatchBlock", firstMismatchBlock); + parityDiagnostics.getDynamicObject()->setProperty("firstMismatchStats", firstMismatchStats); + parityDiagnostics.getDynamicObject()->setProperty("collectionBudgetMs", parityCollectionBudgetMs); + parityDiagnostics.getDynamicObject()->setProperty("timingClassification", "not_asserted; offline completed-work parity only"); check("serial_parallel_exact_" + juce::String(trackCount), exact && stateChanges && peak > 0.000001f && parityStats.batches == 40 && parityStats.completedBatches == 40 && parityStats.workerJobs > 0 && parityStats.cancelledJobs == 0 - && parallel->parallelTrackRejectedJobs.load() == 0, - "Forty 512-frame blocks: mono/stereo input, volume/pan, mute/solo, output route, master send, live Drive, bypass, reorder and removal preserve exact serial output."); + && parallel->parallelTrackRejectedJobs.load() == 0 + && serial->regressionTrackCollectionBudgetMs == 0.0 && parallel->regressionTrackCollectionBudgetMs == 0.0, + "Forty 512-frame blocks: mono/stereo input, volume/pan, mute/solo, output route, master send, live Drive, bypass, reorder and removal preserve exact serial output.", parityDiagnostics); // Small callbacks exercise allocation capacity and buffer bounds. They // deliberately retain their real deadline; do not turn a scheduler @@ -319,7 +342,7 @@ juce::var AudioEngine::runParallelTrackRegression() parallel->audioDeviceAboutToStart(&device); const auto beforeRestart = parallel->realtimeTrackExecutor.snapshotStats().completedBatches; fillInput(101); - callback(*parallel, parallelOutput, capacity); + callback(*parallel, parallelOutput, capacity, parityCollectionBudgetMs); check("device_restart_" + juce::String(trackCount), stopped && parallel->realtimeTrackExecutor.getWorkerCount() == trackCount - 1 && parallel->realtimeTrackExecutor.snapshotStats().completedBatches == beforeRestart + 1 @@ -473,8 +496,8 @@ juce::var AudioEngine::runParallelTrackRegression() input.setSample(3, sample, static_cast(sample % 7 - 3) * 0.031f); } const auto batchesBefore = engines[1]->realtimeTrackExecutor.snapshotStats().batches; - callback(*engines[0], serialOutput, capacity); - callback(*engines[1], parallelOutput, capacity); + callback(*engines[0], serialOutput, capacity, parityCollectionBudgetMs); + callback(*engines[1], parallelOutput, capacity, parityCollectionBudgetMs); exact = exactOutput(capacity) && exact; subset = engines[1]->realtimeTrackExecutor.snapshotStats().batches == batchesBefore + 1 && subset; auto* scenarioResult = new juce::DynamicObject(); diff --git a/docs/workflow-review-qualification.md b/docs/workflow-review-qualification.md index f87ea3f2..30e6cc35 100644 --- a/docs/workflow-review-qualification.md +++ b/docs/workflow-review-qualification.md @@ -141,3 +141,22 @@ without adding retries, skips or longer timeouts. Website PR #32's updated CI also passed; this fixture correction changes no app behavior or shortcut. Local e2e TypeScript and 12 consecutive complete ruler runs passed (`output/ci-nov04/ruler-followup/repeated-ruler.log`). + +All three browser jobs passed on `392d66b4`, including the previously failing +Windows/Linux jobs. The local fresh-Vite-cache sequence through the first 28 CI +cases also passed. That remote run exposed a separate macOS ARM failure in +`serial_parallel_exact_12`; the exact tiny-buffer gate and worker ownership tests +passed. The full-host parity fixture required 11 helpers on a three-core runner +to complete inside the live 512-frame deadline, coupling output correctness to +shared-runner scheduling. Its aggregate report lacked per-parity-block evidence. + +The device-free exact-output and restart cases now scope a private 1,000 ms +collection budget, while retaining the actual sample rate/block length, exact +comparisons, worker dispatch/completion requirements, nominal callback timing +counters, and normal deadlines for small-buffer bounds and injected late-worker +tests. The budget is zero by default, guarded by offline startup, restored after +each fixture callback and has no app/probe setter. Parity reports include the +budget, first mismatch and executor counters; they assert no timing capacity. +Local Release qualification passed four simultaneous realtime-safety runs on +four logical CPUs and all 421 native render/export checks. This is a correctness +fixture correction, not evidence of improved eight-sample hardware timing. From 3380973a4b3d4f975a97cfff116ed60dc746fa1d Mon Sep 17 00:00:00 2001 From: Sourav Das Date: Wed, 4 Nov 2026 20:19:26 +0530 Subject: [PATCH 10/33] Package opt-in offline support and compile callback profiling out of normal builds --- .github/workflows/certification-access.yml | 54 +++ .github/workflows/release.yml | 54 ++- .github/workflows/support-qualification.yml | 118 +++++ .github/workflows/verify.yml | 17 + .gitignore | 1 + CMakeLists.txt | 14 +- Source/AudioEngine.cpp | 121 ++++- Source/AudioEngine.h | 30 +- Source/Main.cpp | 4 +- Source/RealtimeDiagnosticsConfig.h | 11 + WORKFLOWS.md | 11 + build.py | 10 + docs/USER_MANUAL.md | 12 + docs/certification-0.1.09.md | 154 ++++++ docs/offline-support.md | 283 +++++++++++ docs/release-runbook.md | 38 +- .../src/__tests__/audioDeadlineStatus.test.ts | 14 + frontend/src/components/NAMRackPanel.tsx | 4 +- frontend/src/services/NativeBridge.ts | 2 + frontend/src/utils/audioDeadlineStatus.ts | 6 + packaging/linux/OpenStudioSupport.desktop | 9 + packaging/macos/SupportLauncher.m | 37 ++ packaging/msix/AppxManifest.xml.in | 6 + packaging/signpath/windows-payload.xml | 3 + packaging/support/licenses/Python-LICENSE.txt | 277 +++++++++++ packaging/support/licenses/QuickJS-LICENSE | 24 + packaging/support/licenses/Tcl-license.terms | 40 ++ packaging/windows/OpenStudio.iss | 5 + tests/test_desktop_qualification.py | 3 + tests/test_linux_native_packaging.py | 15 +- tests/test_release_build_policy.py | 26 + tests/test_release_notes.py | 2 +- tests/test_store_release_workflow.py | 3 +- tests/test_windows_installer.py | 35 ++ tools/assemble-macos-support.py | 62 +++ tools/build-support-midi.mjs | 40 ++ tools/build-support-tool.py | 189 ++++++++ tools/check-certification-access.py | 64 +++ tools/evaluate-recording-qualification.py | 6 +- tools/openstudio-support.py | 6 + tools/openstudio_support/__init__.py | 1 + tools/openstudio_support/audio.py | 222 +++++++++ tools/openstudio_support/cli.py | 93 ++++ tools/openstudio_support/collection.py | 128 +++++ tools/openstudio_support/common.py | 178 +++++++ tools/openstudio_support/gui.py | 193 ++++++++ tools/openstudio_support/midi.py | 273 +++++++++++ tools/openstudio_support/packaged.py | 36 ++ tools/openstudio_support/selftest.py | 44 ++ tools/package-linux-native.py | 29 +- tools/package-linux-release.sh | 10 + tools/package-macos-release.sh | 13 + tools/package-support-appimage.py | 52 ++ tools/package-windows-release.ps1 | 5 + tools/package-windows-store.ps1 | 10 +- tools/prepare-support-midi.mjs | 39 ++ tools/release_build_policy.py | 22 + tools/sign-macos-support.py | 30 ++ tools/support-build-requirements.txt | 6 + tools/support-midi-core.mjs | 39 ++ tools/support-requirements.txt | 4 + tools/support_package.py | 44 ++ tools/test-macos-support-package.py | 44 ++ tools/test-packaged-support.py | 74 +++ tools/test-support-linux.py | 77 +++ tools/test-support-msix.ps1 | 100 ++++ tools/test-windows-signing.ps1 | 4 +- tools/test-windows-store-package.ps1 | 6 +- tools/test_openstudio_support.py | 446 ++++++++++++++++++ tools/validate-linux-abi.py | 10 +- tools/validate-support-tool.py | 14 + tools/windows-store-runtime.ps1 | 2 +- 72 files changed, 4025 insertions(+), 33 deletions(-) create mode 100644 .github/workflows/certification-access.yml create mode 100644 .github/workflows/support-qualification.yml create mode 100644 Source/RealtimeDiagnosticsConfig.h create mode 100644 docs/certification-0.1.09.md create mode 100644 docs/offline-support.md create mode 100644 packaging/linux/OpenStudioSupport.desktop create mode 100644 packaging/macos/SupportLauncher.m create mode 100644 packaging/support/licenses/Python-LICENSE.txt create mode 100644 packaging/support/licenses/QuickJS-LICENSE create mode 100644 packaging/support/licenses/Tcl-license.terms create mode 100644 tests/test_release_build_policy.py create mode 100644 tools/assemble-macos-support.py create mode 100644 tools/build-support-midi.mjs create mode 100644 tools/build-support-tool.py create mode 100644 tools/check-certification-access.py create mode 100644 tools/openstudio-support.py create mode 100644 tools/openstudio_support/__init__.py create mode 100644 tools/openstudio_support/audio.py create mode 100644 tools/openstudio_support/cli.py create mode 100644 tools/openstudio_support/collection.py create mode 100644 tools/openstudio_support/common.py create mode 100644 tools/openstudio_support/gui.py create mode 100644 tools/openstudio_support/midi.py create mode 100644 tools/openstudio_support/packaged.py create mode 100644 tools/openstudio_support/selftest.py create mode 100644 tools/package-support-appimage.py create mode 100644 tools/prepare-support-midi.mjs create mode 100644 tools/release_build_policy.py create mode 100644 tools/sign-macos-support.py create mode 100644 tools/support-build-requirements.txt create mode 100644 tools/support-midi-core.mjs create mode 100644 tools/support-requirements.txt create mode 100644 tools/support_package.py create mode 100644 tools/test-macos-support-package.py create mode 100644 tools/test-packaged-support.py create mode 100644 tools/test-support-linux.py create mode 100644 tools/test-support-msix.ps1 create mode 100644 tools/test_openstudio_support.py create mode 100644 tools/validate-support-tool.py diff --git a/.github/workflows/certification-access.yml b/.github/workflows/certification-access.yml new file mode 100644 index 00000000..783c77c0 --- /dev/null +++ b/.github/workflows/certification-access.yml @@ -0,0 +1,54 @@ +name: Certification access inspection (no publication) +on: + push: + branches: ['codex/certification-0.1.09-**'] + paths: + - '.github/workflows/certification-access.yml' + - 'tools/check-certification-access.py' + workflow_dispatch: +permissions: + contents: read +jobs: + apple: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 + with: + persist-credentials: false + - uses: dopplerhq/cli-action@014df23b1329b615816a38eb5f473bb9000700b1 + - name: Inspect allowlisted credential presence without exposing values + env: + DOPPLER_TOKEN: ${{ secrets.DOPPLER_TOKEN }} + MACOS_CERTIFICATE_BASE64: ${{ secrets.MACOS_CERTIFICATE_BASE64 }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_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 }} + run: python3 tools/check-certification-access.py apple --report output/apple-access.json + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: apple-certification-access + path: output/apple-access.json + store: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + environment: microsoft-store + concurrency: + group: openstudio-microsoft-store-submission + cancel-in-progress: false + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 + with: + persist-credentials: false + - name: Read Store identity and submission state without mutations + env: + MS_STORE_TENANT_ID: ${{ secrets.MS_STORE_TENANT_ID }} + MS_STORE_CLIENT_ID: ${{ secrets.MS_STORE_CLIENT_ID }} + MS_STORE_CLIENT_SECRET: ${{ secrets.MS_STORE_CLIENT_SECRET }} + run: python3 tools/check-certification-access.py store --report output/store-access.json + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: store-certification-access + path: output/store-access.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8aa56083..02645f0c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,6 +22,7 @@ jobs: runs-on: ubuntu-24.04 outputs: notes_file: ${{ steps.notes.outputs.notes_file }} + app_version: ${{ steps.normalized.outputs.version }} env: VERSION: ${{ github.event.inputs.version || github.ref_name }} NOTES_FILE: ${{ github.event.inputs.release_notes_file }} @@ -35,10 +36,19 @@ jobs: exit 1 fi - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - name: Normalize support package version + id: normalized + run: echo "version=${VERSION#v}" >> "$GITHUB_OUTPUT" - name: Validate exact-version release notes before building id: notes run: python3 tools/validate-release-notes.py --version "$VERSION" --notes-file "$NOTES_FILE" --github-output "$GITHUB_OUTPUT" + support-packages: + needs: validate-release-notes + uses: ./.github/workflows/support-qualification.yml + with: + version: ${{ needs.validate-release-notes.outputs.app_version }} + build-windows: needs: validate-release-notes runs-on: windows-latest @@ -193,6 +203,20 @@ jobs: shell: pwsh run: cmake --build $env:BUILD_DIR --config Release --target OpenStudio + - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + with: + python-version: '3.13' + + - name: Build and qualify standalone support (Windows) + shell: pwsh + run: | + python tools/build-support-tool.py --version $env:VERSION --stage "$env:BUILD_DIR/OpenStudio_artefacts/Release" + if ($LASTEXITCODE -ne 0) { throw 'Support build failed.' } + ./.venv-support/Scripts/python.exe -m unittest tools.test_openstudio_support -q + if ($LASTEXITCODE -ne 0) { throw 'Support regression failed.' } + python tools/test-packaged-support.py --bundle "$env:BUILD_DIR/OpenStudio_artefacts/Release" + if ($LASTEXITCODE -ne 0) { throw 'Support qualification failed.' } + - name: Verify signed updater (Windows Release) shell: pwsh run: | @@ -477,7 +501,7 @@ jobs: if-no-files-found: error build-macos: - needs: validate-release-notes + needs: [validate-release-notes, support-packages] runs-on: macos-14 env: VERSION: ${{ github.event.inputs.version || github.ref_name }} @@ -615,6 +639,18 @@ jobs: shell: bash run: cmake --build "$BUILD_DIR" --config Release --target OpenStudio + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: support-macos-universal + path: output/support-universal + - name: Stage universal offline support + run: | + tar -xzf output/support-universal/support-macos-universal.tgz -C output/support-universal + APP="$BUILD_DIR/OpenStudio_artefacts/Release/OpenStudio.app" + ditto output/support-universal/OpenStudioSupport.app "$APP/Contents/Helpers/OpenStudioSupport.app" + ditto 'output/support-universal/OpenStudio Support.app' "$BUILD_DIR/OpenStudio_artefacts/Release/OpenStudio Support.app" + python3 tools/validate-support-tool.py --bundle "$APP" --version "$VERSION" + - name: Verify signed updater (macOS Release) shell: bash run: | @@ -725,7 +761,7 @@ jobs: if-no-files-found: error build-linux: - needs: validate-release-notes + needs: [validate-release-notes, support-packages] runs-on: ubuntu-22.04 permissions: contents: read @@ -826,6 +862,16 @@ jobs: - name: Build OpenStudio run: cmake --build "$BUILD_DIR" --config Release --target OpenStudio + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: support-linux-x64 + path: output/support-native + - name: Stage offline support + run: | + tar -xzf output/support-native/support-linux-x64.tgz -C output/support-native + cp -a output/support-native/OpenStudioSupport/. "$BUILD_DIR/OpenStudio_artefacts/Release/" + python3 tools/validate-support-tool.py --bundle "$BUILD_DIR/OpenStudio_artefacts/Release" --version "$VERSION" + - name: Verify signed updater (Linux Release) shell: bash run: | @@ -903,6 +949,8 @@ jobs: name: linux-release path: | dist/linux/OpenStudio-*-x86_64.AppImage + dist/linux/OpenStudioSupport-*-x86_64.AppImage + dist/linux/OpenStudio-*-with-support.tar.gz dist/linux/OpenStudio-*-ubuntu-22.04-amd64.deb if-no-files-found: error @@ -1153,6 +1201,8 @@ jobs: dist/windows/OpenStudio-FFmpeg-8.0.1-complete-corresponding-source.zip dist/macos/OpenStudio-macOS.dmg dist/linux/OpenStudio-*-x86_64.AppImage + dist/linux/OpenStudioSupport-*-x86_64.AppImage + dist/linux/OpenStudio-*-with-support.tar.gz dist/linux/OpenStudio-*-ubuntu-22.04-amd64.deb dist/release-publish-assets/OpenStudio-checksums.txt dist/release-publish-assets/OpenStudio-release-latest.json diff --git a/.github/workflows/support-qualification.yml b/.github/workflows/support-qualification.yml new file mode 100644 index 00000000..6117c294 --- /dev/null +++ b/.github/workflows/support-qualification.yml @@ -0,0 +1,118 @@ +name: Offline support qualification +on: + workflow_dispatch: + workflow_call: + inputs: + version: + type: string + default: '0.0.0' + push: + branches: ['codex/support-packaging-qa-**'] +permissions: + contents: read +jobs: + native: + strategy: + fail-fast: false + matrix: + include: + - {os: windows-latest, platform: windows, arch: x64} + - {os: ubuntu-22.04, platform: linux, arch: x64} + - {os: macos-14, platform: macos, arch: arm64} + - {os: macos-15-intel, platform: macos, arch: x86_64} + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + env: + SUPPORT_VERSION: ${{ inputs.version || '0.0.0' }} + MACOSX_DEPLOYMENT_TARGET: '11.0' + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 + with: + node-version-file: frontend/.nvmrc + - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 + with: + python-version: '3.13' + - name: Linux desktop build dependencies + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y python3-tk xvfb xauth desktop-file-utils dpkg-dev rpm + - run: npm --prefix frontend ci + - name: Build native support + shell: bash + run: python tools/build-support-tool.py --version "$SUPPORT_VERSION" + - name: Windows installed support tests + if: runner.os == 'Windows' + shell: pwsh + run: | + ./.venv-support/Scripts/python.exe -m unittest tools.test_openstudio_support -q + if ($LASTEXITCODE -ne 0) { throw 'Support tests failed' } + $env:OPENSTUDIO_SUPPORT_TEST_BUNDLE = "$PWD/output/support-package/OpenStudioSupport" + python tests/test_windows_installer.py --require-tools + if ($LASTEXITCODE -ne 0) { throw 'Installer regression failed' } + python tools/test-packaged-support.py --bundle $env:OPENSTUDIO_SUPPORT_TEST_BUNDLE + if ($LASTEXITCODE -ne 0) { throw 'Frozen support failed' } + ./tools/test-support-msix.ps1 -BundlePath $env:OPENSTUDIO_SUPPORT_TEST_BUNDLE -OutputDir output/support-msix-qa + - name: macOS support tests + if: runner.os == 'macOS' + timeout-minutes: 6 + run: | + .venv-support/bin/python -c "import faulthandler, unittest; faulthandler.dump_traceback_later(180, exit=True); unittest.main(module=None, argv=['unittest', 'tools.test_openstudio_support', '-v'])" + python tools/test-packaged-support.py --bundle output/support-package/OpenStudioSupport.app + codesign --verify --deep --strict output/support-package/OpenStudioSupport.app + - name: Linux support tests and native packaging + if: runner.os == 'Linux' + run: | + xvfb-run -a .venv-support/bin/python -m unittest tools.test_openstudio_support -q + python -m unittest tests.test_linux_native_packaging tests.test_linux_abi tests.test_release_notes -q + xvfb-run -a python tools/test-packaged-support.py --bundle output/support-package/OpenStudioSupport + xvfb-run -a python tools/test-support-linux.py --bundle output/support-package/OpenStudioSupport --version "$SUPPORT_VERSION" + python tools/validate-linux-abi.py --root output/support-package/OpenStudioSupport --report output/support-distribution/abi.json --max-glibc 2.35 --require-os ubuntu:22.04 + - name: Archive native payload preserving executable modes and links + shell: bash + run: tar -czf support-${{ matrix.platform }}-${{ matrix.arch }}.tgz -C output/support-package . + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: support-${{ matrix.platform }}-${{ matrix.arch }} + path: | + support-*.tgz + output/support-distribution/*.AppImage + output/support-distribution/abi.json + if-no-files-found: error + universal-macos: + needs: native + if: ${{ !cancelled() }} + runs-on: macos-14 + timeout-minutes: 15 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + pattern: support-macos-* + path: output/native + - name: Assemble both native runtimes and Finder launcher + run: | + mkdir -p output/arm64 output/x86_64 + tar -xzf output/native/support-macos-arm64/support-macos-arm64.tgz -C output/arm64 + tar -xzf output/native/support-macos-x86_64/support-macos-x86_64.tgz -C output/x86_64 + python3 tools/assemble-macos-support.py --arm64 output/arm64/OpenStudioSupport.app --x86-64 output/x86_64/OpenStudioSupport.app --version '${{ inputs.version || '0.0.0' }}' --output output/universal + python3 tools/sign-macos-support.py output/universal/OpenStudioSupport.app --identity - --no-timestamp + python3 tools/test-macos-support-package.py --bundle output/universal --version '${{ inputs.version || '0.0.0' }}' + tar -czf support-macos-universal.tgz -C output/universal . + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: support-macos-universal + path: support-macos-universal.tgz + universal-macos-intel: + needs: universal-macos + runs-on: macos-15-intel + timeout-minutes: 15 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: support-macos-universal + path: output/universal + - name: Qualify universal launcher and mounted DMG on Intel + run: | + tar -xzf output/universal/support-macos-universal.tgz -C output/universal + python3 tools/test-macos-support-package.py --bundle output/universal --version '${{ inputs.version || '0.0.0' }}' diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 31fb09c2..2f34163f 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -34,6 +34,7 @@ jobs: run: >- python3 -m unittest tests.test_release_notes + tests.test_release_build_policy tests.test_build_process_lifecycle tests.test_juce_lv2_identifier_patch tests.test_juce_mac_browser_navigation_patch @@ -253,6 +254,22 @@ jobs: npm run build if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + with: + python-version: '3.13' + + - name: Qualify standalone offline support + shell: pwsh + run: | + python tools/build-support-tool.py --version 0.0.0 + if ($LASTEXITCODE -ne 0) { throw 'Support build failed.' } + ./.venv-support/Scripts/python.exe -m unittest tools.test_openstudio_support -q + if ($LASTEXITCODE -ne 0) { throw 'Support regression failed.' } + python tools/validate-support-tool.py --bundle output/support-package/OpenStudioSupport --version 0.0.0 + if ($LASTEXITCODE -ne 0) { throw 'Support inventory failed.' } + python tools/test-packaged-support.py --bundle output/support-package/OpenStudioSupport + if ($LASTEXITCODE -ne 0) { throw 'Packaged support qualification failed.' } + - name: Install ASIO SDK shell: pwsh run: ./tools/setup-asio-sdk.ps1 -Destination $env:ASIO_SDK_DIR diff --git a/.gitignore b/.gitignore index 27cb6179..9f881710 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ node_modules # Python __pycache__/ +.venv-support/ *.pyc get-pip.py .env diff --git a/CMakeLists.txt b/CMakeLists.txt index 36302339..7a0d3b6c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -63,6 +63,7 @@ endif() option(OPENSTUDIO_ENABLE_EXTERNAL_PYTHON_AI_FALLBACK "Allow dev/unbundled builds to bootstrap AI tools from a system Python interpreter" ON) option(OPENSTUDIO_REQUIRE_ARA "Fail configuration when the ARA SDK is unavailable" ON) option(OPENSTUDIO_REQUIRE_ASIO "Fail configuration when the ASIO SDK is unavailable for a Windows build" OFF) +option(OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS "Instrument audio callbacks for explicit diagnostic builds (never release packages)" OFF) # Some third-party dependencies still expect CMAKE_BUILD_TYPE to exist even # under multi-config generators like Visual Studio. Seed a harmless default @@ -616,7 +617,8 @@ target_sources(OpenStudio PRIVATE Source/ARAHostController.cpp ) -target_compile_definitions(OpenStudio PRIVATE OPENSTUDIO_BUILD_CONFIGURATION="$") +target_compile_definitions(OpenStudio PRIVATE OPENSTUDIO_BUILD_CONFIGURATION="$" + OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS=$) if(WIN32) # Tiny hidden helper: no JUCE, WebView, audio device, plug-in or Python startup. add_executable(OpenStudioCrashReporter Source/CrashReporterMain.cpp Source/CrashReportWriter.cpp) @@ -1056,6 +1058,16 @@ endfunction() set(OPENSTUDIO_VERSION_MANIFEST "${CMAKE_CURRENT_BINARY_DIR}/OpenStudio.version") file(WRITE "${OPENSTUDIO_VERSION_MANIFEST}" "${OPENSTUDIO_APP_VERSION}\n") +if(OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS) + set(OPENSTUDIO_CALLBACK_DIAGNOSTICS_JSON true) +else() + set(OPENSTUDIO_CALLBACK_DIAGNOSTICS_JSON false) +endif() +set(OPENSTUDIO_BUILD_POLICY "${CMAKE_CURRENT_BINARY_DIR}/OpenStudio.build-policy.json") +file(WRITE "${OPENSTUDIO_BUILD_POLICY}" + "{\"version\":\"${OPENSTUDIO_APP_VERSION}\",\"callbackDiagnostics\":${OPENSTUDIO_CALLBACK_DIAGNOSTICS_JSON}}\n") +openstudio_copy_runtime_file("${OPENSTUDIO_BUILD_POLICY}" "OpenStudio.build-policy.json" + "Copying callback instrumentation build policy") openstudio_copy_runtime_file( "${OPENSTUDIO_VERSION_MANIFEST}" "OpenStudio.version" diff --git a/Source/AudioEngine.cpp b/Source/AudioEngine.cpp index 2ddbcde7..f24fd031 100644 --- a/Source/AudioEngine.cpp +++ b/Source/AudioEngine.cpp @@ -74,6 +74,27 @@ namespace { +void publishCallbackDiagnosticsAvailability(juce::DynamicObject& object) +{ + object.setProperty("audioCallbackTimingAvailable", OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS != 0); +#if !OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS + object.setProperty("audioCallbackTimingStatus", "not_collected_in_this_build"); + for (const auto* name : { + "lastAudioCallbackProcessMs", "maxAudioCallbackProcessMs", + "audioCallbackDeadlineMissCount", "lifetimeAudioCallbackDeadlineMissCount", + "audioCallbackArrivalGapCount", "lastAudioCallbackArrivalGapCounter", + "lastAudioCallbackArrivalGapMs", "maxAudioCallbackArrivalGapMs", + "audioCallbackDeadlineMissBurstCount", "lastAudioCallbackDeadlineMissCounter", + "lastAudioCallbackDeadlineMissProcessMs", "lastAudioCallbackDeadlineMissWhileRecording", + "audioCallbackScopedNoDenormalsCount", "lastDeviceOutputDiscontinuityDeadlineMissCount", + "postTrackPlaybackPeak", "postMonitoringInputPeak", "postMasterFxPeak", + "postMonitoringFxPeak", "finalOutputPeak", + "signalChainDiagnosticPeakSampleIntervalCallbacks", "signalChainDiagnosticPeaksPreserveLastSample" }) + object.removeProperty(name); +#endif +} + +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS struct CallbackTraceThreadSample { std::uint64_t threadId = 0, cycles = 0; @@ -104,6 +125,7 @@ CallbackTraceThreadSample captureCallbackTraceThread(bool includeCycles) noexcep return sample; } +#endif juce::String builtInInstanceIdentity(const std::shared_ptr& processor); void registerBuiltInInstanceIdentity(const std::shared_ptr&, const juce::String&); // Called by control/state workers with the route publication lease held. @@ -126,6 +148,7 @@ bool restorePreparedProcessorState(juce::AudioProcessor& processor, const juce:: return !isolated || isolated->isHealthy(); } +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS constexpr std::uint64_t packEpochValue( std::uint32_t epoch, std::uint32_t value) noexcept @@ -174,6 +197,7 @@ void updateEpochMaximum( maximum.store(packEpochValue(epoch, value), std::memory_order_relaxed); } +#endif void addMonoHardwareInputToStereoProcessingPath( juce::AudioBuffer& processingBuffer, const float* monoInput, @@ -4376,6 +4400,7 @@ void AudioEngine::requestAudioInputAccess(std::function completion) void AudioEngine::resetAudioCallbackWindowTelemetry() noexcept { +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS for (auto& cell : audioCallbackDurationHistogram) cell.store(0, std::memory_order_relaxed); for (auto& cell : audioCallbackWindowMissCounts) @@ -4391,8 +4416,10 @@ void AudioEngine::resetAudioCallbackWindowTelemetry() noexcept } firstAudioCallbackTelemetryWallTimeMs.store( 0.0, std::memory_order_relaxed); +#endif } +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS void AudioEngine::recordAudioCallbackTiming( double callbackProcessMs, double callbackEndWallTimeMs, @@ -4539,6 +4566,7 @@ void AudioEngine::recordAudioCallbackStageTiming( durationNanoseconds + 0.5)); } } +#endif #if JUCE_WINDOWS void AudioEngine::registerWindowsAudioCallbackMMCSS() noexcept @@ -6446,17 +6474,22 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha int numSamples, const juce::AudioIODeviceCallbackContext& context) { +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS const auto traceOptions = realtimeCallbackTrace.getOptions(); const bool tracingCallback = (traceOptions & RealtimeCallbackTrace::enabledFlag) != 0; const auto callbackEntryTicks = tracingCallback ? juce::Time::getHighResolutionTicks() : 0; +#endif const double callbackStartWallTimeMs = juce::Time::getMillisecondCounterHiRes(); #if JUCE_WINDOWS registerWindowsAudioCallbackMMCSS(); #endif juce::ScopedNoDenormals noDenormals; juce::ignoreUnused(context); +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS audioCallbackScopedNoDenormalsCount.fetch_add (1, std::memory_order_relaxed); +#endif const uint64 callbackCounter = audioCallbackCounter.fetch_add(1, std::memory_order_acq_rel) + 1; +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS const bool shouldSampleAudioCallbackStages = tracingCallback || (callbackCounter % audioCallbackStageSampleInterval) == 0; @@ -6465,6 +6498,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha std::int64_t sampledTrackPlaybackTicks = 0; std::int64_t sampledMasterMonitoringTicks = 0; std::int64_t sampledMeteringSyncTicks = 0; +#endif lastAudioCallbackCounter.store(callbackCounter, std::memory_order_relaxed); // One transport snapshot owns the entire block. A prepared start published // midway through a stopped callback must not advance that unrecorded block. @@ -6486,6 +6520,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha / currentSampleRate) * 1000.0 : 0.0; +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS std::optional pairedTrace; if (tracingCallback) { @@ -6536,11 +6571,13 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha if (stages != nullptr) event.stageMs = *stages; realtimeCallbackTrace.tryPush(event); }; +#endif if (callbackBlockDurationMs > 0.0) lastAudioBlockDurationMs.store( callbackBlockDurationMs, std::memory_order_release); +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS // Processing-duration telemetry cannot detect a callback that the driver // delivered late. Use the already-read callback timestamp to separately // retain arrival-gap evidence without another clock query. @@ -6581,6 +6618,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha } } +#endif // Clear outputs first for (int i = 0; i < numOutputChannels; ++i) juce::FloatVectorOperations::clear (outputChannelData[i], numSamples); @@ -6611,6 +6649,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha oversizedAudioCallbackCount.fetch_add( 1, std::memory_order_relaxed); } +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS const double callbackEndWallTimeMs = finishCallbackTraceClock(); recordAudioCallbackTiming( @@ -6620,6 +6659,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha callbackCounter, callbackStartedWhileRecording); publishCallbackTrace(callbackEndWallTimeMs, nullptr); +#endif return; } @@ -6636,6 +6676,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha numSamples, tunerRouteGeneration(route)); } +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS const double callbackEndWallTimeMs = finishCallbackTraceClock(); const double callbackDurationMs = @@ -6647,6 +6688,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha callbackCounter, callbackStartedWhileRecording); publishCallbackTrace(callbackEndWallTimeMs, nullptr); +#endif return; } @@ -6709,14 +6751,18 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha jassert(rtTracks != nullptr); if (rtTracks == nullptr) { +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS if (pairedTrace) publishCallbackTrace(finishCallbackTraceClock(), nullptr); +#endif return; } const auto blockCaptureEpoch = pluginAutomationClock->epoch.load(std::memory_order_acquire); const auto blockStartSamplePosition = currentSamplePosition.load( std::memory_order_acquire); +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS if (pairedTrace) pairedTrace->startSamplePosition = blockStartSamplePosition; +#endif pluginAutomationClock->update(static_cast(blockStartSamplePosition) / juce::jmax(1.0, currentSampleRate), currentSampleRate, callbackPlaying); const ScopedPluginAutomationProcessing blockCaptureContext(static_cast(blockStartSamplePosition) / juce::jmax(1.0, currentSampleRate), currentSampleRate, blockCaptureEpoch, callbackPlaying); // Every routed buffer represents exactly one callback. Clearing at the @@ -6741,9 +6787,13 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha } const bool shouldLogCallbackSummary = kAudioPathDebugLogs && (((callbackCounter % 50) == 1) || ((callbackPlaying || callbackRecording) && firstCallbackAfterTransportStart)); +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS const bool shouldSampleSignalChainPeaks = shouldSampleAudioCallbackStages || kAudioPathDebugLogs; +#else + constexpr bool shouldSampleSignalChainPeaks = kAudioPathDebugLogs; +#endif float postTrackPlaybackPeak = 0.0f; float postMonitoringInputPeak = 0.0f; // preTrackPeak is only ever used in logging — compute it only when logging is active. @@ -6774,10 +6824,12 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha // position for the samples being output right now. The increment is at the // very end of the callback (search "Advance transport position"). +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS const auto sampledPlaybackSetupStartTicks = shouldSampleAudioCallbackStages ? juce::Time::getHighResolutionTicks() : 0; +#endif // The common no-marker path is lock-free. Reuse this block's resolved // tempo for every downstream timing consumer. @@ -6802,12 +6854,14 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha // record latch must not repeatedly restart the click at one position. callbackPlaying); } +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS if (shouldSampleAudioCallbackStages) { sampledPlaybackTicks += juce::Time::getHighResolutionTicks() - sampledPlaybackSetupStartTicks; } +#endif const bool useHybrid64Summing = processingPrecisionMode == ProcessingPrecisionMode::Hybrid64 && reusableMasterBufferDouble.getNumChannels() >= numOutputChannels @@ -6828,10 +6882,12 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha // Use cached solo state (updated when solo changes, avoids scanning every callback) bool anySoloed = cachedAnySoloed.load(); +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS const auto sampledTrackStartTicks = shouldSampleAudioCallbackStages ? juce::Time::getHighResolutionTicks() : 0; +#endif // Both execution modes share input preparation, raw recording and final // reduction. Only the independent per-track DSP runs on audio workers. @@ -6952,10 +7008,12 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha - trackOffsetSec; // Fill buffer with playback audio from clips +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS const auto sampledClipPlaybackStartTicks = shouldSampleAudioCallbackStages ? juce::Time::getHighResolutionTicks() : 0; +#endif playbackEngine.fillTrackBuffer( trackId, trackBuffer, @@ -6965,6 +7023,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha track->getTrackChannelCount() == 1, trackEntry.playbackContinuityKey ); +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS if (shouldSampleAudioCallbackStages) { const auto elapsedTicks = @@ -6973,6 +7032,8 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha sampledPlaybackTicks += elapsedTicks; sampledTrackPlaybackTicks += elapsedTicks; } +#endif + if (shouldSampleSignalChainPeaks) { postTrackPlaybackPeak = juce::jmax( @@ -7334,6 +7395,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha } } +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS if (shouldSampleAudioCallbackStages) { sampledTrackTicks = @@ -7343,12 +7405,15 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha - sampledTrackStartTicks - sampledTrackPlaybackTicks); } +#endif // ========== FX Chains (extracted helpers) ========== +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS const auto sampledPitchScrubStartTicks = shouldSampleAudioCallbackStages ? juce::Time::getHighResolutionTicks() : 0; +#endif if (playbackEngine.mayRenderPitchScrubPreview()) { const bool pitchScrubBufferReady = @@ -7393,12 +7458,14 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha } } } +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS if (shouldSampleAudioCallbackStages) { sampledPlaybackTicks += juce::Time::getHighResolutionTicks() - sampledPitchScrubStartTicks; } +#endif // Isolated instrument audition joins only the live monitor/master path. // Track recording queues and offline rendering never see these notes. @@ -7415,10 +7482,12 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha else outputChannelData[ch][i] += reusablePitchScrubBuffer.getSample(ch, i); } +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS const auto sampledMasterMonitoringStartTicks = shouldSampleAudioCallbackStages ? juce::Time::getHighResolutionTicks() : 0; +#endif if (shouldSampleSignalChainPeaks) { lastPostTrackPlaybackPeak.store( @@ -7497,12 +7566,15 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha finalOutputPeak, std::memory_order_relaxed); } +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS if (shouldSampleAudioCallbackStages) { sampledMasterMonitoringTicks = juce::Time::getHighResolutionTicks() - sampledMasterMonitoringStartTicks; } +#endif + // Silence-detection log: only fires when kAudioPathDebugLogs is on. // Previously also fired unconditionally in release builds; removed because // it calls FileLogger (disk write + mutex) from the audio thread. @@ -7521,10 +7593,12 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha + " finalOutputPeak=" + juce::String(finalOutputPeak, 4)); } +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS const auto sampledMeteringSyncStartTicks = shouldSampleAudioCallbackStages ? juce::Time::getHighResolutionTicks() : 0; +#endif // ========== Metering & Analysis (extracted helpers) ========== updateMasterMetering (outputChannelData, numOutputChannels, numSamples); @@ -7546,12 +7620,14 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha triggerEngine.processBlock( numSamples, currentSampleRate, callbackTempo, currentBeat); } +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS if (shouldSampleAudioCallbackStages) { sampledMeteringSyncTicks = juce::Time::getHighResolutionTicks() - sampledMeteringSyncStartTicks; } +#endif // ========== Advance transport position ========== // IMPORTANT: This is intentionally AFTER all processing (metronome, playback, @@ -7603,6 +7679,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha } } +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS const double callbackEndWallTimeMs = finishCallbackTraceClock(); const double callbackProcessMs = @@ -7661,6 +7738,8 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha std::memory_order_relaxed), std::memory_order_relaxed); } +#endif + } juce::String AudioEngine::addTrack(const juce::String& explicitId, const juce::String& initialType) @@ -50751,10 +50830,15 @@ juce::var AudioEngine::runNAMRackRegression() && getNumber(secondSnapshot, "sampleRate", 0.0) > 0.0; const bool blockSizeSane = isFiniteProperty(secondSnapshot, "blockSize") && getNumber(secondSnapshot, "blockSize", 0.0) > 0.0; - const bool callbackMsSane = isFiniteProperty(secondSnapshot, "lastAudioCallbackProcessMs") - && getNumber(secondSnapshot, "lastAudioCallbackProcessMs", -1.0) >= 0.0; - const bool deadlineMissesSane = isFiniteProperty(secondSnapshot, "audioCallbackDeadlineMissCount") - && getNumber(secondSnapshot, "audioCallbackDeadlineMissCount", -1.0) >= 0.0; + const bool timingAvailable = static_cast(secondSnapshot.getProperty("audioCallbackTimingAvailable", false)); + const bool callbackMsSane = timingAvailable + ? isFiniteProperty(secondSnapshot, "lastAudioCallbackProcessMs") + && getNumber(secondSnapshot, "lastAudioCallbackProcessMs", -1.0) >= 0.0 + : secondSnapshot.getProperty("lastAudioCallbackProcessMs", {}).isVoid(); + const bool deadlineMissesSane = timingAvailable + ? isFiniteProperty(secondSnapshot, "audioCallbackDeadlineMissCount") + && getNumber(secondSnapshot, "audioCallbackDeadlineMissCount", -1.0) >= 0.0 + : secondSnapshot.getProperty("audioCallbackDeadlineMissCount", {}).isVoid(); const bool tunerFieldsSane = isFiniteProperty(secondSnapshot, "tunerFrequencyHz") && isFiniteProperty(secondSnapshot, "tunerCents") && isFiniteProperty(secondSnapshot, "tunerConfidence") @@ -72799,6 +72883,7 @@ juce::var AudioEngine::getRealtimeAudioTelemetry() const root->setProperty( "playbackTracks", juce::Array()); + publishCallbackDiagnosticsAvailability(*root); return juce::var(root); } @@ -72861,6 +72946,7 @@ juce::var AudioEngine::getAudioDebugSnapshot() const root->setProperty("lastCallbackCounter", static_cast(lastAudioCallbackCounter.load(std::memory_order_relaxed))); root->setProperty("lastAudioCallbackProcessMs", lastAudioCallbackProcessMs.load(std::memory_order_relaxed)); +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS const double callbackTelemetryNowMs = juce::Time::getMillisecondCounterHiRes(); const auto currentWindowEpoch = static_cast( @@ -73277,6 +73363,7 @@ juce::var AudioEngine::getAudioDebugSnapshot() const root->setProperty( "audioCallbackStageTimings", stageTimings); +#endif root->setProperty("audioCallbackDeadlineMissCount", static_cast(audioCallbackDeadlineMissCount.load(std::memory_order_relaxed))); root->setProperty( "lifetimeAudioCallbackDeadlineMissCount", @@ -73336,9 +73423,12 @@ juce::var AudioEngine::getAudioDebugSnapshot() const root->setProperty("lastAudioCallbackDeadlineMissCounter", static_cast(lastAudioCallbackDeadlineMissCounter.load(std::memory_order_relaxed))); // Backward-compatible name used by the current warning UI. It is now the // actual rolling-window count, not an indefinitely growing miss cluster. +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS root->setProperty( "audioCallbackDeadlineMissBurstCount", static_cast(windowDeadlineMissCount)); +#endif + root->setProperty("lastAudioCallbackDeadlineMissProcessMs", lastAudioCallbackDeadlineMissProcessMs.load(std::memory_order_relaxed)); root->setProperty("lastAudioCallbackDeadlineMissWhileRecording", lastAudioCallbackDeadlineMissWhileRecording.load(std::memory_order_relaxed)); root->setProperty( @@ -73574,6 +73664,7 @@ juce::var AudioEngine::getAudioDebugSnapshot() const root->setProperty("pitchRouteScrubPreviewActive", pitchRouting.scrubPreviewActive); root->setProperty("pitchRouteCorrectedSourceActive", pitchRouting.correctedSourceActive); root->setProperty("playbackTracks", playbackTracks); + publishCallbackDiagnosticsAvailability(*root); return juce::var(root); } @@ -76125,6 +76216,28 @@ juce::var AudioEngine::runRenderExportRegression(const juce::File& outputDirecto runRealtimeCallbackTraceRegression([&](const char* id, bool pass) { addCheck(id, pass, "Opt-in paired callback tracing preserves bounded SPSC ownership, sample identity and drop accounting."); }); + { + auto probe = std::make_unique(DeviceStartup::offline); + probe->setRealtimeCallbackTraceEnabled(true); + for (int index = 0; index < 16; ++index) + probe->audioDeviceIOCallbackWithContext(nullptr, 0, nullptr, 0, 8, {}); + const auto snapshot = probe->getAudioDebugSnapshot(); + const auto compact = probe->getRealtimeAudioTelemetry(); + const auto stats = probe->getRealtimeCallbackTraceStats(); + const bool expected = OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS != 0; + const bool availability = static_cast(snapshot["audioCallbackTimingAvailable"]) == expected + && static_cast(compact["audioCallbackTimingAvailable"]) == expected; + const bool timing = expected + ? static_cast(snapshot["audioCallbackWindowCallbackCount"]) == 16 + && stats.enabled && stats.published > 0 + : snapshot["lastAudioCallbackProcessMs"].isVoid() + && snapshot["audioCallbackDeadlineMissCount"].isVoid() + && snapshot["audioCallbackStageTimings"].isVoid() + && compact["audioCallbackDeadlineMissCount"].isVoid() + && !stats.enabled && stats.published == 0 && stats.pending == 0; + addCheck("callback_diagnostics_build_contract", availability && timing, + "Production omits measurements and cannot enable tracing; diagnostic builds collect explicit fixture callbacks. No hardware timing claim."); + } runEditingWorkflowRegression([&](const char* id, bool pass) { addCheck(id, pass, "Fader silence and bounded, cancellable tempo work; no hardware timing or listening claim."); }); diff --git a/Source/AudioEngine.h b/Source/AudioEngine.h index 4652ef2f..02600667 100644 --- a/Source/AudioEngine.h +++ b/Source/AudioEngine.h @@ -2,6 +2,7 @@ #pragma once #include "RealtimeTrackExecutor.h" #include "RealtimeCallbackTrace.h" +#include "RealtimeDiagnosticsConfig.h" #include #include "TrackProcessor.h" @@ -406,11 +407,30 @@ class AudioEngine : public juce::AudioIODeviceCallback, // Explicit CLI diagnostics only. Reporter is the sole queue consumer; // ordinary UI/debug snapshots never drain or enable paired tracing. void setRealtimeCallbackTraceEnabled(bool enabled, bool threadCycles = false) noexcept - { realtimeCallbackTrace.setEnabled(enabled, threadCycles); } + { +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS + realtimeCallbackTrace.setEnabled(enabled, threadCycles); +#else + juce::ignoreUnused(enabled, threadCycles); +#endif + } std::size_t drainRealtimeCallbackTrace(RealtimeCallbackTraceEvent* destination, std::size_t maximum) noexcept - { return realtimeCallbackTrace.drain(destination, maximum); } + { +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS + return realtimeCallbackTrace.drain(destination, maximum); +#else + juce::ignoreUnused(destination, maximum); + return 0; +#endif + } RealtimeCallbackTraceStats getRealtimeCallbackTraceStats() const noexcept - { return realtimeCallbackTrace.getStats(); } + { +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS + return realtimeCallbackTrace.getStats(); +#else + return {}; +#endif + } // Plugin State Serialization (F2 - Project Save/Load) juce::String getPluginState(const juce::String& trackId, int fxIndex, bool isInputFX); @@ -1031,6 +1051,7 @@ class AudioEngine : public juce::AudioIODeviceCallback, bool useDefaultDevices); juce::File getDeviceSettingsFile() const; void resetAudioCallbackWindowTelemetry() noexcept; +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS void recordAudioCallbackTiming(double callbackProcessMs, double callbackEndWallTimeMs, double expectedBlockMs, @@ -1039,6 +1060,7 @@ class AudioEngine : public juce::AudioIODeviceCallback, void recordAudioCallbackStageTiming( const std::array& stageProcessMs, double callbackEndWallTimeMs) noexcept; +#endif #if JUCE_WINDOWS void registerWindowsAudioCallbackMMCSS() noexcept; #endif @@ -1169,6 +1191,7 @@ class AudioEngine : public juce::AudioIODeviceCallback, // count/value in the low 32 bits. The callback is the sole writer; message // thread diagnostics can therefore read an exact, lock-free rolling set of // one-second buckets without clearing memory on the realtime thread. +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS std::array, audioCallbackHistogramCellCount> audioCallbackDurationHistogram {}; std::array, audioCallbackWindowSeconds> @@ -1178,6 +1201,7 @@ class AudioEngine : public juce::AudioIODeviceCallback, std::array audioCallbackStageWindows {}; RealtimeCallbackTrace realtimeCallbackTrace; +#endif std::atomic firstAudioCallbackTelemetryWallTimeMs { 0.0 }; std::atomic windowsAudioCallbackMMCSSRequested { false }; std::atomic windowsAudioCallbackMMCSSActive { false }; diff --git a/Source/Main.cpp b/Source/Main.cpp index 9bce0e85..a0ff9052 100644 --- a/Source/Main.cpp +++ b/Source/Main.cpp @@ -94,7 +94,9 @@ class RealtimeQualificationReporter final : private juce::Timer : engine(engineToRead), report(std::move(reportToWrite)) { report.getParentDirectory().createDirectory(); - if (callbackTraceToWrite != juce::File()) + if (callbackTraceToWrite != juce::File() && !OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS) + juce::Logger::writeToLog("Callback trace unavailable: use an explicit callback-diagnostics build."); + if (callbackTraceToWrite != juce::File() && OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS) { callbackTraceFile = std::move(callbackTraceToWrite); if (callbackTraceFile == report diff --git a/Source/RealtimeDiagnosticsConfig.h b/Source/RealtimeDiagnosticsConfig.h new file mode 100644 index 00000000..4f5a387a --- /dev/null +++ b/Source/RealtimeDiagnosticsConfig.h @@ -0,0 +1,11 @@ +#pragma once + +// Production has no callback timing histograms, stage sampling or trace queue. +// A separate diagnostic build must opt in; opening Support cannot enable this. +#ifndef OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS + #define OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS 0 +#endif + +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS != 0 && OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS != 1 + #error OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS must be 0 or 1 +#endif diff --git a/WORKFLOWS.md b/WORKFLOWS.md index 622aa5d8..588e8295 100644 --- a/WORKFLOWS.md +++ b/WORKFLOWS.md @@ -45,6 +45,17 @@ A Debug build is evidence for that configuration only. Release and installed WebView startup are qualified separately; see the [release smoke checklist](docs/release-smoke-checklist.md). +## Offline support + +Offline support runs in a separate, manually opened process after the DAW is closed. +Release packaging includes **OpenStudio Support** with its own runtime and separate +Windows, macOS and Linux launchers; users need no Python or terminal. Read [offline support](docs/offline-support.md) +for build commands, qualification, privacy review and platform limits. Source launch +remains `python tools/openstudio-support.py`. Test with +`python -m unittest tools.test_openstudio_support -v` and qualify the actual executable +with `python tools/test-packaged-support.py --bundle `. Tool-only +changes do not change native app/frontend assets or require a DAW rebuild. + ## Frontend checks ```sh diff --git a/build.py b/build.py index 491f23b2..9ec07295 100644 --- a/build.py +++ b/build.py @@ -1,3 +1,5 @@ +from pathlib import Path +import tempfile import os import subprocess import sys @@ -210,6 +212,8 @@ def build_backend(mode="debug", app_version=None): # Configure CMake. On single-config generators (Linux/macOS Make/Ninja) # CMAKE_BUILD_TYPE must be set at configure time, not just at build time. cmd = ["cmake", "-B", build_dir] + # Ordinary launches and production packaging never opt into callback profiling. + cmd.append("-DOPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS=OFF") # Production installs must fetch the managed runtime, never depend on system Python. cmd.append("-DOPENSTUDIO_ENABLE_EXTERNAL_PYTHON_AI_FALLBACK=" + ("ON" if mode == "debug" else "OFF")) @@ -311,6 +315,12 @@ def main(): run_command(validation, shell=False) build_frontend("prod") build_backend("release", app_version=version) + if platform.system() in ("Windows", "Linux", "Darwin"): + with tempfile.TemporaryDirectory(prefix="openstudio-support-") as support_output: + run_command([sys.executable, "tools/build-support-tool.py", "--version", version, + "--output", support_output, + "--stage", (str(Path(get_cpp_exe_path("Release")).parents[2]) if platform.system() == "Darwin" + else os.path.dirname(get_cpp_exe_path("Release")))], shell=False) print("\nProduction Build Complete.") print(f"Run: {get_cpp_exe_path('Release')}") diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md index 74b8e7f4..423a238b 100644 --- a/docs/USER_MANUAL.md +++ b/docs/USER_MANUAL.md @@ -3105,6 +3105,18 @@ microphone permission for every application. ### 17.11 Render Produces Silence +**Unreleased support companion:** after closing OpenStudio, open +**OpenStudio Support** from the Windows Start menu, macOS Finder (install both apps +from the DMG), or Linux application menu/companion AppImage. Collect existing evidence or inspect +an audio/MIDI export, and use **Open output folder** to review the report. Choose +**Create reviewed ZIP** to prepare an attachment for support. No Python or terminal +is needed in packaged builds. Nothing is uploaded automatically. +Close the support window before reopening the DAW. This adds no work to normal +recording, playback or exports; the explicitly opened tool uses CPU, RAM and disk. +Dumps are optional and unredacted. See [Offline support and export inspection](offline-support.md) +for platform launch instructions, source-tool setup, comparisons and limits. A valid file or +matching duration alone does not prove that every track was rendered. + **Symptoms**: Rendered file is silent or contains only silence. **Solutions**: diff --git a/docs/certification-0.1.09.md b/docs/certification-0.1.09.md new file mode 100644 index 00000000..234cb655 --- /dev/null +++ b/docs/certification-0.1.09.md @@ -0,0 +1,154 @@ +# OpenStudio 0.1.09 candidate qualification + +Unpublished candidate selected on 4 November 2026. No release tag, Store +submission, publication or website/update-feed change has been created for it. +The user requested certification checks and a release PR, with publication held. + +## Current evidence + +- **Pass — support packaging:** the [six-job qualification](https://github.com/sdevil7th/OpenStudio/actions/runs/41841385666) + covers Windows installer/MSIX fixtures, native macOS and universal Finder/DMG + launches on both architectures, and Linux DEB/RPM/AppImage payload checks. + This used support version 0.0.0 and a dummy DAW where needed; it does not certify + a full 0.1.09 application package. +- **Pass — existing application CI:** [PR #36](https://github.com/sdevil7th/OpenStudio/pull/36) + records all 11 Verify jobs passing at `f45637be`. Its current draft status is + intentional. As inspected on 4 November, GitHub lists no submitted PR reviews. +- **Fail — existing hardware timing gate:** the recorded Audient ASIO 48 kHz / + 8-sample test added 19 engine deadline misses and 21 host overrun counts during + a 59.979-second steady recording interval. These are not proven physical driver + loss or an audition result. See [the exact-artifact report](workflow-review-qualification.md). +- **Blocked — macOS notarization:** the [read-only access check](https://github.com/sdevil7th/OpenStudio/actions/runs/41845529536) + found none of the six required Apple signing/notarization values in GitHub or + the configured Doppler project. The user confirmed credentials are unavailable. +- **Blocked — Store account inspection:** the `microsoft-store` environment + permits only `v*` tags. It rejected the certification branch before any Store + request. The policy has not been changed. The local installed Store app is + 0.1.8.0; it is not the proposed candidate and must not be replaced for a test. +- **Not asserted — WACK and Microsoft certification:** the Windows App + Certification Kit is installed locally, but this session is not elevated and + no real 0.1.09 MSIX has been qualified. Old packages and dummy executables cannot + establish candidate certification. + +Merging a PR does not currently run Store submission. The release workflow uses +release tags and contains public-release steps. Do not create a `v0.1.09` tag or +run that workflow merely to expose Store actions. A release PR can show ordinary +build/package checks; account-backed submission needs an explicitly permitted, +nonpublishing path and a verified manual publishing hold. + +## Physical test matrix + +Use copies of projects, and record the candidate file hash, app version, OS, +CPU/core count, RAM, storage type/free space, interface/driver/firmware, sample +rate, buffer size, plugin versions, monitoring route and power mode for every +run. Keep the Support tool closed during every recording/performance run. + +| Setup | Minimum coverage | +| --- | --- | +| Windows | Windows 11 on the main recording PC with the actual ASIO interface; the oldest Windows version still advertised on a separate supported installation; WASAPI shared/exclusive and built-in audio where offered. | +| macOS | One Apple Silicon and one Intel Mac, using Core Audio and a physical interface. Include the oldest advertised base-app macOS separately from the newer optional AI-runtime requirement. | +| Linux | A supported distribution with its native installer, the intended PipeWire/JACK/ALSA route and a physical interface. Exercise AppImage separately on a prepared host. RPM installation on Fedora remains separate from archive extraction on Ubuntu. | +| Resource limits | Include the lowest CPU/RAM configuration you intend to support. Record actual usable logical cores; do not infer a low-core result from a fast workstation's average CPU. | + +## Recording and latency + +1. With Support closed, start with a clean project and a known input. Test each + advertised sample rate and available device buffer. For the target Audient + route, explicitly include 48 kHz / 8 samples; then test 16, 32, 64 and 128 + where the driver offers them. A larger-buffer pass does not clear the + 8-sample failure. Do not claim an unsupported device buffer. +2. At each target setting, record at least 30 minutes with the expected maximum + armed/monitored tracks and actual FX. Include the known demanding case of + four monitored NAM tracks with the intended oversampling. Then run a two-hour + session at the normal supported working buffer. These durations are test + coverage choices, not a guarantee against later failures. +3. During capture, scroll/zoom, resize/open/close the mixer and supported popouts, + adjust parameters, save, and perform normal punch/loop operations. Check the + start, stop and transition regions as well as the middle of each take. +4. Use a physical cable loopback at safe levels to record known pulses and a + continuous reference signal. Measure round-trip latency and take placement + after the run; verify there are no missing/repeated segments or unexplained + jumps. Buffer duration alone is not measured round-trip latency. Audition + the exact take and monitoring path; file length alone does not prove clean + audio. An independent external recorder can capture the monitoring output. +5. Repeat the same project with the heavy game and usual recording/streaming + applications running. Keep game settings, frame cap, DAW route and power mode + fixed. Separately test CPU contention, RAM pressure and disk activity, changing + one factor at a time. Stop deliberate stress if the machine becomes unstable; + use expendable project copies. + +**Pass:** the declared workload/buffer completes without audible dropouts, +unexpected silence, missing/duplicated recorded frames, recording write failure +or a reproducible latency regression. Where existing counters are available, +record before/after values and their scope; the strict target requires zero +new deadline misses. Record unsupported driver counters as **not_asserted**. +Subjective sound quality requires listening acceptance of the exact artifacts. + +For a support-overhead comparison, use the same DAW executable and project with +the companion files present versus absent in disposable copies. Keep Support +closed and verify once before the run that its process is absent. Do not run a +new logger, profiler or recurring process sampler during the baseline. This +comparison isolates the companion; comparing 0.1.08 and 0.1.09 also includes all +the application changes and cannot isolate support overhead. + +## Audio, MIDI and export completeness + +| Test | Evidence to retain | +| --- | --- | +| Audio mix and stems | Use a small known project with a distinct signal on each track, including the last clip, trims, fades, gain/automation, mute/solo, master/track/input FX and routing. Check whole-project, time-selection, selected-source and queued exports in every advertised format/rate/channel/depth setting. Verify expected sources, boundaries and tails by independent reference and listening. | +| Cold plugin render | Restart, reopen the same saved project and compare first and subsequent renders. Include the plugins with known state/first-export differences. Do not generalize a deterministic WAV match to every plugin. | +| MIDI interchange | Record a physical keyboard/controller; include pedal, pitch bend, overlapping same-key notes and tempo/meter changes. Export the full project, a single clip and selected clips with trims/loops/gaps. Import into another DAW and check notes, channels, order, timing and end-of-file note releases. | +| Failure handling | On disposable files, test a missing source/plugin, cancellation and an unwritable destination. Errors must be visible; partial output must not be presented as complete or overwrite a good existing export. | +| Offline support | Close the DAW, then collect logs and inspect those exports. Review the report and ZIP for private paths, project/media exclusion and opt-in dumps. The tools must not silently claim every track was audible or that a later project is the original export snapshot. | + +## Installed application and lifecycle + +Use the exact final installer/MSIX/DMG/native package on a clean standard-user +account without developer Python/Node. Check first launch, microphone permission +allow/deny/retry, device reconnect, supported sample-rate/buffer changes, physical +MIDI reconnect, external FX discovery/loading/editors, and every popout's +open/close/reopen/quit behavior. Test different display scales and monitors. +Verify Support remains absent until explicitly opened and that its launch needs +no terminal. Test upgrade/uninstall on disposable machines while preserving +external projects. Actual Store update delivery needs two Store-delivered +versions; local registration does not prove that path. + +Do not work around Gatekeeper quarantine and count that as a notarization pass. +Without Apple credentials, record normal first-download acceptance as +**not_asserted** and retain any actual rejection. + +## What diagnostics can and cannot establish + +The standalone support tool adds no callback hooks or background collector. +This is distinct from diagnostics already present in the application branch: +`AudioEngine::audioDeviceIOCallbackWithContext` reads the clock and updates +timing counters even when detailed tracing is disabled, and samples stage +timings every 16 callbacks. `RealtimeCallbackTrace` also allocates its fixed +queue when the engine is constructed. These existing diagnostics are not +zero-cost; their contribution to the observed deadline misses is unmeasured. + +Before making a literal zero-overhead claim for all application diagnostics, +audit these operations and their consumers. Compile diagnostic-only work and +storage out of the production configuration if that is the required policy, +while retaining timing needed by actual engine scheduling. A runtime off +switch alone does not eliminate the checks. This is a review finding and a +proposed follow-up, not an implemented or measured optimization. + +The next timing investigation should compare the same Release configuration, +interface and 48 kHz / 8-sample setting with a minimal project versus the +retained project, then idle UI versus the recorded gestures. Keep a known +physical input/loopback reference and repeat each condition. Any extra profiler +belongs in a separate explicit diagnostic run; its results cannot qualify an +unmeasured production build. Larger-buffer controls can help attribution but +do not replace the failed eight-sample gate. + +A later support bundle can preserve existing failures and examine output files. +It cannot reconstruct the exact CPU scheduling, RAM pressure, driver delay or +game activity at an unobserved glitch. Controlled repeats narrow causes; they +do not prove the historical cause. More precise attribution needs a separate, +explicit diagnostic reproduction with measurement overhead, or evidence already +provided by the OS/device. No such monitor is added to normal DAW use. + +Official references: [Microsoft's WACK prerequisites and commands](https://learn.microsoft.com/en-us/windows/uwp/debug-test-perf/windows-app-certification-kit), +[manual Store publishing holds](https://learn.microsoft.com/en-us/windows/apps/publish/publish-your-app/msix/manage-submission-options), +and [Apple's notarization requirements](https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution). diff --git a/docs/offline-support.md b/docs/offline-support.md new file mode 100644 index 00000000..1026a6e4 --- /dev/null +++ b/docs/offline-support.md @@ -0,0 +1,283 @@ +# Offline support and export inspection + +Unreleased development work added after app commit `f45637be`. The support companion +is packaged separately for Windows, macOS and Linux. It adds no collectors, timers, +threads, buffers, callbacks, uploads, or logging to the running DAW. Existing app +diagnostics remain unchanged; this is not a claim that those diagnostics cost zero. +No release containing this work has been published here. + +**Close OpenStudio before running support tools. Keep it closed until the operation +finishes, then close the support window before reopening the DAW.** The tools check +for OpenStudio and its workers once at entry and refuse to proceed if found, or if +process enumeration fails. They cannot prevent another process from launching the +DAW later, and cannot recognize a renamed executable. They do use CPU, memory and +disk while explicitly running. They never silently start a diagnostic render. + +## Open the installed companion + +Close OpenStudio first, then use the launcher for your platform: + +| Platform | Launcher | +| --- | --- | +| Windows installer / MSIX | **OpenStudio Support** in the Start menu. A portable folder also has `OpenStudioSupport.exe`. | +| macOS | Drag both **OpenStudio.app** and **OpenStudio Support.app** from the DMG into the same Applications folder. Open **OpenStudio Support** in Finder. The small launcher uses the matching support runtime inside OpenStudio.app, so app updates replace that runtime too. Intel and Apple silicon runtimes are both included. | +| Linux DEB / RPM | **OpenStudio Support** in the desktop application menu. No login item or service is installed. | +| Linux AppImage | Extract the `with-support.tar.gz` download in a file manager. Open the separate **OpenStudioSupport-…AppImage** after closing the DAW AppImage. Both launchers are independent. | + +1. Open the companion explicitly. +2. Leave **Collect existing evidence** selected, describe what happened, and click + **Run selected operation**. You can instead inspect an existing audio/MIDI export. +3. Click **Open output folder** and review its files. Dumps are excluded unless + explicitly selected; they contain unredacted process memory. +4. Choose **Create reviewed ZIP**, tick the review confirmation, and run it. + Click **Open output folder** and attach the ZIP to your support request yourself. +5. Close the support window before reopening OpenStudio. + +Users install no Python, Node.js, libraries, or terminal tools. The companion +bundles its interpreter, Tk window, audio decoder and a bounded JavaScript engine +containing the app's MIDI serializer. Nothing is registered to run at login, +DAW startup, recording, export, crash or shutdown. Installation space increases; +normal DAW execution loads none of this payload. Existing native crash reporting +is unaffected. Explicitly launching this companion does consume resources. + +## Developer source launch + +From the source checkout, using Python 3.11 or newer: + +```sh +python tools/openstudio-support.py +``` + +This opens a separate window with four operations: **Collect existing evidence**, +**Inspect audio export**, **Inspect MIDI export**, and **Create reviewed ZIP**. +Tk is required for the window. The command-line operations below do not need Tk. +Choose a **new** output directory for each report. Existing reports, projects, +media and ZIPs are never overwritten. + +Collection and MIDI structure inspection use the Python standard library. Audio +inspection needs NumPy and SoundFile; install them in a separate environment, +not the application's AI/audio runtime: + +```sh +python -m venv .venv-support +.venv-support/Scripts/python -m pip install -r tools/support-requirements.txt +.venv-support/Scripts/python tools/openstudio-support.py +``` + +On macOS/Linux use `.venv-support/bin/python` instead. MIDI comparison with a saved +project also needs Node.js and the checkout's existing frontend dependencies +(`npm --prefix frontend ci` if not installed). It loads the actual clip serializer +in a short-lived Node process: no browser, listening server, plugin, or audio device +is opened. Serializer source hashes are included so later code changes are visible. + +## Developer command-line collection + +```sh +python tools/openstudio-support.py collect --output output/support-review +``` + +Automatic discovery checks the platform's existing OpenStudio diagnostic locations. +On Windows this includes the actual Documents known folder, including relocation, +and `%APPDATA%/OpenStudio/logs`. Use `--log-dir "path"` (repeatable) to replace the +default locations, for example when collecting a startup log beside an executable. +Only exact known startup/debug/crash/hang filenames are read, without recursion. +Use `--route-report "mix_render_route_report.json"` to select existing render reports +and `--description "What happened and what I expected"` to supply context. + +The output contains `summary.txt`, `report.json`, explicitly selected evidence and +`manifest.json` with checksums. Current OS/CPU-count information is labelled +**at collection time**, not evidence of conditions during an earlier incident. +Logs may span sessions and builds; their contents are not automatically assigned +to a particular incident. Missing evidence is `not_asserted`, not a successful check. + +Privacy and bounds: + +- Original audio, MIDI and project files are not bundled. No recursive directory + collection, environment dump, process list, serial number or hostname is saved. +- Text filters replace known paths, names in structured fields, URLs, email + addresses and common credential fields. **Redaction is best effort.** Free-form + text can still contain private information. Read every file before sharing. +- Only the latest 512 KiB of each log is retained, with truncation disclosed. +- Dumps are excluded unless `--include-dumps` is explicitly selected. Included + dumps are **unredacted memory** and can contain credentials or audio fragments. + Each dump is limited to 32 MiB; the collection is limited to 128 MiB. Oversized + or unreadable evidence is reported as unavailable, not silently treated as present. +- A ZIP is created only on request after review. It contains only manifest-listed, + unchanged files. Neighbouring projects/media are never picked up. + +After reviewing the folder: + +```sh +python tools/openstudio-support.py bundle output/support-review --reviewed --output output/support.zip +``` + +Nothing is uploaded. Share the ZIP through your chosen support channel. If evidence +was changed after collection, bundling refuses it; collect a new report to establish +new checksums and review it again. Remove information at its source copy rather than +editing the original application logs. + +## Inspect an audio export + +```sh +python tools/openstudio-support.py audio "mix.wav" --route-report "mix_render_route_report.json" --output output/audio-review +``` + +The inspector streams the decoded audio in bounded blocks, checks RIFF/AIFF chunk +lengths independently where supported, checks frames and non-finite samples, and +reports duration, channels and rate. Other container validation relies on the +installed decoder and cannot prove that every truncated compressed stream is caught. +Raw PCM and unsupported formats are not guessed. Full-scale sample counts and +exact-silence lengths are **diagnostic_only**; they are not proof of audible distortion +or missing music. + +The optional existing route report supplies requested settings, tracks, clips and +planned sample count. Lossless duration comparison allows one output frame for +rounding. Lossy codec-padding duration is **not_asserted**. Source-file existence is +checked only at collection time. Existing route reports have no output hash or +completion record, so their association with the chosen output is **not_asserted**. +A matching report or valid file cannot prove every track contributed audible sound. + +For saved-project metadata comparison, add `--project "song.osproj"`. Advanced +source comparison is an explicit extra pass: + +```sh +python tools/openstudio-support.py audio "mix.wav" --project "song.osproj" --route-report "mix_render_route_report.json" --reference --start 0 --pan-law linear --output output/source-review +``` + +This reuses the developer's independent neutral-mix reference. Choose the actual +export start and pan law. It supports a limited lossless, neutral audio master mix; +plugins, instruments, automation, normalization, +crossfades and other unsupported processing prevent a completeness claim. Do not +omit the route report: it is required to check scope, start and normalization. Unsupported +processing is **not_asserted**. A sample comparison concerns the supplied snapshot +and interpretation, not the original export-time state or subjective quality. + +## Inspect a MIDI export + +```sh +python tools/openstudio-support.py midi "song.mid" --output output/midi-structure +python tools/openstudio-support.py midi "song.mid" --project "song.osproj" --output output/midi-comparison +``` + +Supported files are synchronous Standard MIDI Files type 0/1 with PPQN timing. +Type 2 and SMPTE timing are **not_asserted**. Inputs are limited to 32 MiB and +250,000 parsed events. Structure inspection checks chunk boundaries, running status, +event data and end-of-track markers. It reports channel-event counts, tempo/meter, +other event types and diagnostic note pairing. Missing explicit note-offs do not +automatically prove a stuck note: sustain and all-notes-off can change interpretation. + +With a project, current frontend preparation accounts for clip trimming, loops, +muted notes/clips and MIDI effects. Comparison checks ordered channel events, +channels, values, tempo, meter and musical track count; timing allows one SMF tick. +It is scoped to the supplied saved bytes and current serializer, not an unsaved +or older export-time state. Unknown source representations prevent a full claim. +Metadata/conductor-only tracks are separate from tracks containing channel events. +The tool does not certify playback sound in another DAW or instrument. + +For **Export Selected MIDI Clips** or **Export This MIDI Clip**, supply each exact +saved clip ID using `--clip-id ID`. The scope and tempo map are rebased together: + +```sh +python tools/openstudio-support.py midi "clip.mid" --project "song.osproj" --clip-id clip-id --output output/clip-review +``` + +In the support window, enter IDs separated by commas. A mismatched or missing ID is +reported as unverified. Legacy note-array projects must first be migrated using the +app's normal project loading/saving workflow; the inspector never rewrites them. + +## Interpretation and developer checks + +Reports use **pass**, **fail**, **diagnostic_only**, and **not_asserted** for individual +checks. The overall report remains `diagnostic_only` unless a checked invariant +fails. Original content completeness and subjective audio quality remain +`not_asserted`. CLI exit 0 means the operation completed without a failed check, +not that all content was verified; 2 means a check failed; 1 means a prerequisite or +tool error prevented completion. Read the report, not just the exit code. + +```sh +python -m unittest tools.test_openstudio_support -v +``` + +Tests use synthetic files and temporary folders, never audio devices or user media. +Production crash analysis also requires symbols/source maps matching the original +build. Optional diagnostic re-exports and new runtime telemetry are outside this +implementation; neither is enabled by installing or running these tools. + +## Packaging and qualification + +After installing frontend dependencies, run: + +```sh +python tools/build-support-tool.py --version --output --stage +``` +Use a Python 3.13 build host with Tk. The builder installs its isolated +`.venv-support` dependencies and bundles the current pure MIDI serializer. Node.js +and frontend dependencies are build/source-runner requirements only. The NativeBridge +is replaced by an unavailable stub in this offline bundle; it cannot contact the DAW. +On Windows/Linux, stage into `build/OpenStudio_artefacts/Release`. On macOS, stage +into `build/OpenStudio_artefacts/Release/OpenStudio.app`; a local build contains +the build host's architecture only. A universal distribution requires both native +builds and the separate Finder launcher, assembled by release CI. To assemble +downloaded native artifacts locally on macOS: + +```sh +python3 tools/assemble-macos-support.py --arm64 arm64/OpenStudioSupport.app --x86-64 x86_64/OpenStudioSupport.app --version --output universal +ditto universal/OpenStudioSupport.app build/OpenStudio_artefacts/Release/OpenStudio.app/Contents/Helpers/OpenStudioSupport.app +ditto 'universal/OpenStudio Support.app' 'build/OpenStudio_artefacts/Release/OpenStudio Support.app' +``` + +`python build.py prod --version ` includes this build after its required +release-notes gate. Release CI builds and qualifies the native companions before signing. +Installer and Store packaging fail if its version/inventory is missing or mismatched. +The support executable is in the first-party signing allowlist; synchronize the +hosted SignPath `windows-payload` configuration with the repository before release. +The existing release-notes, signing and Store qualification gates still apply. + +Developer checks (no DAW/audio device opened): + +- `python -m unittest tools.test_openstudio_support -v` +- `python tools/validate-support-tool.py --bundle --version ` +- `python tools/test-packaged-support.py --bundle ` + +The packaged check launches the actual executable with a restricted PATH and no +external Python/Node invocation, constructs its window hidden, decodes synthetic audio, compares synthetic MIDI +with a saved project and exercises collection, redaction, review and ZIP creation. +That check alone does not qualify installation, OS trust or subjective audio quality. +No release or upload is performed by these checks. + +The reusable `support-qualification.yml` workflow builds on Windows, Ubuntu 22.04, +Apple silicon and Intel macOS. macOS assembly retains two native runtimes behind +one universal launcher without requiring Rosetta. Release packaging signs +nested code from the inside out and includes the Finder launcher in the DMG. +Linux installers copy the private runtime and a separate desktop entry; RPM must +preserve frozen runtime bytes while retaining dependency scanning. The AppImage +bundle includes separate DAW and support AppImages without modifying the DAW launcher. + +Tests use disposable identities and a dummy DAW where necessary. Windows tests run +production installer validation and the production support shortcut with the real +support payload; MSIX tests pack/unpack, register, activate via the Start menu app ID, +check package identity and unregister. Linux tests execute support after DEB/RPM +round trips and inside its companion AppImage. macOS tests mount a read-only DMG +and activate the Finder launcher. These are support-distribution tests; they do not +claim Store certification, notarization, clean-machine full-DAW readiness, hardware +recording performance or subjective audio quality. Source/audio execution paths in +the DAW are unchanged. Existing release-note and signing gates remain mandatory. + +### Recorded development qualification — 2026-11-04 + +[CI run 41841385666](https://github.com/sdevil7th/OpenStudio/actions/runs/41841385666) +passed all six jobs at commit `cf25a5e3665f1bd444214d871b2903a563863ba8` using +QA version `0.0.0`. Its artifacts contain the Windows payload, Linux payload and +companion AppImage, and the universal macOS helper plus Finder launcher. + +| Platform | Verified scope | +| --- | --- | +| Windows | Seven installer fixture scenarios, real support installation/shortcut/uninstall, MSIX pack/unpack, registration, Start menu activation, package identity and cleanup. | +| macOS | Native Apple Silicon and Intel builds; universal architecture selection, strict code-signature verification, read-only DMG and Finder activation on both processor types. QA signatures are ad hoc. | +| Linux | Ubuntu 22.04 runtime, DEB/RPM payload round trips, desktop entry, companion AppImage and GLIBC 2.35 ceiling. RPM installation on Fedora is not asserted. | + +Each native build passed 39 source checks and frozen executable tests covering GUI +construction, collection, privacy filtering, reviewed ZIP creation, audio decoding, +MIDI comparison and existing-output protection. No external Python/Node is available +on the frozen test's PATH. This qualification creates no published application +release and does not establish the separate release/hardware claims listed above. diff --git a/docs/release-runbook.md b/docs/release-runbook.md index 3ce4186e..9020c9a6 100644 --- a/docs/release-runbook.md +++ b/docs/release-runbook.md @@ -222,7 +222,7 @@ If you want one command for the full guarded Windows path, use: `./tools/validate-release-metadata.ps1 -MetadataDir dist/release-metadata -Channel stable -WindowsAssetPath dist/windows/OpenStudio-Setup-x64.exe -WindowsBaseAiRuntimeAssetPath dist/ai-runtime/OpenStudio-AI-Runtime-windows-base-x64.zip -WindowsCudaInstallPlanPath tools/ai-runtime-install-plan-windows-cuda.json -WindowsDirectmlInstallPlanPath tools/ai-runtime-install-plan-windows-directml.json` 11. Stage the uniquely named GitHub Release metadata assets: `./tools/prepare-release-publish-assets.ps1 -MetadataDir dist/release-metadata -OutputDir dist/release-publish-assets` -12. If certificate signing is enabled, the packaging helper signs and verifies `OpenStudio.exe`, `OpenStudioCrashReporter.exe` and `OpenStudio-Setup-x64.exe`. The SignPath workflow verifies product metadata, trusted signatures and timestamps before packaging/publishing. +12. If certificate signing is enabled, the packaging helper signs and verifies `OpenStudio.exe`, `OpenStudioCrashReporter.exe`, `OpenStudioSupport.exe` and `OpenStudio-Setup-x64.exe`. The SignPath workflow verifies product metadata, trusted signatures and timestamps before packaging/publishing. ## Local macOS release flow @@ -773,7 +773,7 @@ repository owner activates SignPath. The GitHub-hosted Windows release job builds and tests OpenStudio, then: -1. Stages only `OpenStudio.exe` and `OpenStudioCrashReporter.exe` as a GitHub +1. Stages only `OpenStudio.exe`, `OpenStudioCrashReporter.exe` and `OpenStudioSupport.exe` as a GitHub artifact. Third-party DLLs and prerequisite installers are not re-signed. 2. Submits that artifact to SignPath using the `windows-payload` configuration. It waits up to one hour for approval/completion and downloads the signed files. @@ -887,3 +887,37 @@ still encounter operating-system trust warnings during installation. These changes take effect once a release containing them is distributed. Users on older builds need to install that release first; if their old update feed is unavailable, they need to download it manually once. + +### Offline support companion (unreleased cross-platform integration) + +Release CI qualifies native support builds before packaging/signing. `build.py prod` +also builds a native companion; direct CMake builds must additionally follow +[the support packaging instructions](offline-support.md#packaging-and-qualification). +The release-notes gate applies before preparing an actual release. + +- Windows: include `OpenStudioSupport.exe` and the entire `support-runtime` folder. + Installer and MSIX expose a separate **OpenStudio Support** Start menu entry. + Synchronize the hosted SignPath `windows-payload` definition with the repository + XML before signing a release; missing/untrusted helper signatures fail closed. +- macOS: release CI assembles Apple Silicon and Intel runtimes inside the helper's + universal `.app`, stored in `OpenStudio.app/Contents/Helpers`. The DMG also contains + **OpenStudio Support.app**, a small Finder launcher. Install both top-level apps + together. Sign nested helper code inside out before the main app, then notarize + through the existing release path. A local native-only support build does not + satisfy the universal DMG gate; assemble both native artifacts first. +- Linux x86-64: DEB/RPM install a separate desktop entry and private runtime. + AppImage distributions offer a `with-support.tar.gz` containing two independently + launched AppImages. Existing main-AppImage URLs remain compatible. RPM must preserve + frozen support bytes; the support ABI gate retains the Ubuntu 22.04 baseline. + +Nothing registers support for autostart or adds a hook to the DAW. The support +window refuses work when it detects OpenStudio; users must keep the DAW closed +until support exits. No monitor or resident service enforces that afterwards. + +`support-qualification.yml` exercises installer install/uninstall and shortcuts, +MSIX registration/activation/package identity/cleanup, DEB/RPM payload round trips, +the companion AppImage, and mounted-DMG/Finder activation. These use real support +payloads with a dummy DAW and disposable package identities. They do not replace +final signed-artifact testing, Store certification, Gatekeeper/notarization, +oldest-supported-OS testing or full-DAW hardware qualification. Retain those release +gates and verify that support remains absent until explicitly opened. diff --git a/frontend/src/__tests__/audioDeadlineStatus.test.ts b/frontend/src/__tests__/audioDeadlineStatus.test.ts index ceb590cc..8b0ef11a 100644 --- a/frontend/src/__tests__/audioDeadlineStatus.test.ts +++ b/frontend/src/__tests__/audioDeadlineStatus.test.ts @@ -9,6 +9,20 @@ const base = { }; describe("audio deadline warning state", () => { + it("does not treat unavailable timing or stale counters as measured health", () => { + const status = resolveAudioDeadlineStatus({ + ...base, + audioCallbackTimingAvailable: false, + audioCallbackDeadlineMissCount: 12, + lastAudioCallbackDeadlineMissCounter: 999, + lastAudioCallbackDeadlineMissWhileRecording: true, + }); + expect(status.available).toBe(false); + expect(status.shouldWarn).toBe(false); + expect(resolveAudioDeadlineStatus(undefined).available).toBe(false); + expect(resolveAudioDeadlineStatus({ ...base, audioCallbackDeadlineMissCount: 0 }).available).toBe(true); + }); + it("stays quiet without deadline misses", () => { expect(resolveAudioDeadlineStatus(base)).toMatchObject({ recent: false, diff --git a/frontend/src/components/NAMRackPanel.tsx b/frontend/src/components/NAMRackPanel.tsx index 2c5e0921..832bd1aa 100644 --- a/frontend/src/components/NAMRackPanel.tsx +++ b/frontend/src/components/NAMRackPanel.tsx @@ -2970,7 +2970,9 @@ export function NAMRackPanel({ label: namRealtimeBlocked ? "Blocked" : trackExecutionWarning ? "Overload" : dspBudgetLabel, alert: dspBudgetAlert, meterPct: namRealtimeBlocked ? undefined : dspBudgetMeterPct, - title: audioBlockBudgetMs > 0 + title: audioDebugSnapshot?.audioCallbackTimingAvailable === false + ? "Callback timing is not collected in this build" + : audioBlockBudgetMs > 0 ? `Callback ${audioCallbackMs.toFixed(2)} ms / ${audioBlockBudgetMs.toFixed(2)} ms block budget; device-session max ${audioCallbackMaxMs.toFixed(2)} ms (${dspMaxBudgetLabel}); over-budget callbacks ${audioDeadlineMisses}${trackExecutionStatus.hasRecordedFailures ? `; lifetime track deadline misses ${trackExecutionStatus.deadlineMisses}; rejected jobs ${trackExecutionStatus.rejectedTrackJobs}; cancelled jobs ${trackExecutionStatus.cancelledJobs}; rejected batches ${trackExecutionStatus.rejectedBatches}; quarantined blocks ${trackExecutionStatus.quarantinedBlocks}` : ""}` : "DSP callback budget is waiting for native audio debug data", }, diff --git a/frontend/src/services/NativeBridge.ts b/frontend/src/services/NativeBridge.ts index e89c063e..38665b6f 100644 --- a/frontend/src/services/NativeBridge.ts +++ b/frontend/src/services/NativeBridge.ts @@ -2113,6 +2113,8 @@ export interface AudioDebugSnapshot { maxMs: number; histogramOverflowCount: number; }>; + audioCallbackTimingAvailable?: boolean; + audioCallbackTimingStatus?: string; audioCallbackDeadlineMissCount?: number; lifetimeAudioCallbackDeadlineMissCount?: number; oversizedAudioCallbackCount?: number; diff --git a/frontend/src/utils/audioDeadlineStatus.ts b/frontend/src/utils/audioDeadlineStatus.ts index fab30298..573a97e6 100644 --- a/frontend/src/utils/audioDeadlineStatus.ts +++ b/frontend/src/utils/audioDeadlineStatus.ts @@ -1,6 +1,7 @@ export const AUDIO_DEADLINE_RECENT_WINDOW_SECONDS = 10; export type AudioDeadlineTelemetry = { + audioCallbackTimingAvailable?: boolean; blockSize?: number; sampleRate?: number; lastCallbackCounter?: number; @@ -12,6 +13,7 @@ export type AudioDeadlineTelemetry = { }; export type AudioDeadlineStatus = { + available: boolean; deviceSessionMissCount: number; burstMissCount: number; lastMissProcessMs: number; @@ -38,6 +40,9 @@ export function resolveAudioDeadlineStatus( telemetry: AudioDeadlineTelemetry | null | undefined, recentWindowSeconds = AUDIO_DEADLINE_RECENT_WINDOW_SECONDS, ): AudioDeadlineStatus { + const available = telemetry?.audioCallbackTimingAvailable !== false + && typeof telemetry?.audioCallbackDeadlineMissCount === "number"; + if (!available) telemetry = undefined; const deviceSessionMissCount = Math.trunc(finiteNonNegative(telemetry?.audioCallbackDeadlineMissCount)); const burstMissCount = Math.trunc(finiteNonNegative(telemetry?.audioCallbackDeadlineMissBurstCount)); const lastMissProcessMs = finiteNonNegative(telemetry?.lastAudioCallbackDeadlineMissProcessMs); @@ -64,6 +69,7 @@ export function resolveAudioDeadlineStatus( ); return { + available, deviceSessionMissCount, burstMissCount, lastMissProcessMs, diff --git a/packaging/linux/OpenStudioSupport.desktop b/packaging/linux/OpenStudioSupport.desktop new file mode 100644 index 00000000..924ff331 --- /dev/null +++ b/packaging/linux/OpenStudioSupport.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Name=OpenStudio Support +Comment=Collect offline diagnostics after closing OpenStudio +Exec=OpenStudioSupport +Icon=OpenStudio +Type=Application +Categories=Utility; +Terminal=false +Keywords=diagnostics;support;logs; diff --git a/packaging/macos/SupportLauncher.m b/packaging/macos/SupportLauncher.m new file mode 100644 index 00000000..0bdbfa56 --- /dev/null +++ b/packaging/macos/SupportLauncher.m @@ -0,0 +1,37 @@ +// Separate, user-launched executable. Never linked into or invoked by the DAW. +#import +#include +#include +#include +#include + +int main(int argc, char **argv) +{ + @autoreleasepool { + NSString *bundle = NSBundle.mainBundle.bundlePath; +#ifdef OPENSTUDIO_SUPPORT_SHORTCUT + NSString *target = [[bundle stringByDeletingLastPathComponent] + stringByAppendingPathComponent:@"OpenStudio.app/Contents/Helpers/OpenStudioSupport.app/Contents/MacOS/OpenStudioSupport"]; +#else +#if defined(__arm64__) + NSString *architecture = @"arm64"; +#else + NSString *architecture = @"x86_64"; +#endif + NSString *target = [bundle stringByAppendingPathComponent: + [NSString stringWithFormat:@"Contents/Helpers/%@/OpenStudioSupport.app/Contents/MacOS/OpenStudioSupport", architecture]]; +#endif + char **arguments = calloc((size_t)argc + 1, sizeof(char *)); + if (arguments != NULL) { + arguments[0] = (char *)target.fileSystemRepresentation; + for (int index = 1; index < argc; ++index) arguments[index] = argv[index]; + execv(arguments[0], arguments); + free(arguments); + } + NSAlert *alert = [[NSAlert alloc] init]; + alert.messageText = @"OpenStudio Support could not open"; + alert.informativeText = @"Keep OpenStudio.app and OpenStudio Support.app together in Applications. Reinstall both from the same download if the support files are missing."; + [alert runModal]; + return 1; + } +} diff --git a/packaging/msix/AppxManifest.xml.in b/packaging/msix/AppxManifest.xml.in index 2968e8df..f60db9e7 100644 --- a/packaging/msix/AppxManifest.xml.in +++ b/packaging/msix/AppxManifest.xml.in @@ -31,6 +31,12 @@ + + + diff --git a/packaging/signpath/windows-payload.xml b/packaging/signpath/windows-payload.xml index d6a15f7d..77e5c2c1 100644 --- a/packaging/signpath/windows-payload.xml +++ b/packaging/signpath/windows-payload.xml @@ -4,6 +4,9 @@ + + + diff --git a/packaging/support/licenses/Python-LICENSE.txt b/packaging/support/licenses/Python-LICENSE.txt new file mode 100644 index 00000000..14603b95 --- /dev/null +++ b/packaging/support/licenses/Python-LICENSE.txt @@ -0,0 +1,277 @@ +A. HISTORY OF THE SOFTWARE +========================== + +Python was created in the early 1990s by Guido van Rossum at Stichting +Mathematisch Centrum (CWI, see https://www.cwi.nl) in the Netherlands +as a successor of a language called ABC. Guido remains Python's +principal author, although it includes many contributions from others. + +In 1995, Guido continued his work on Python at the Corporation for +National Research Initiatives (CNRI, see https://www.cnri.reston.va.us) +in Reston, Virginia where he released several versions of the +software. + +In May 2000, Guido and the Python core development team moved to +BeOpen.com to form the BeOpen PythonLabs team. In October of the same +year, the PythonLabs team moved to Digital Creations, which became +Zope Corporation. In 2001, the Python Software Foundation (PSF, see +https://www.python.org/psf/) was formed, a non-profit organization +created specifically to own Python-related Intellectual Property. +Zope Corporation was a sponsoring member of the PSF. + +All Python releases are Open Source (see https://opensource.org for +the Open Source Definition). Historically, most, but not all, Python +releases have also been GPL-compatible; the table below summarizes +the various releases. + + Release Derived Year Owner GPL- + from compatible? (1) + + 0.9.0 thru 1.2 1991-1995 CWI yes + 1.3 thru 1.5.2 1.2 1995-1999 CNRI yes + 1.6 1.5.2 2000 CNRI no + 2.0 1.6 2000 BeOpen.com no + 1.6.1 1.6 2001 CNRI yes (2) + 2.1 2.0+1.6.1 2001 PSF no + 2.0.1 2.0+1.6.1 2001 PSF yes + 2.1.1 2.1+2.0.1 2001 PSF yes + 2.1.2 2.1.1 2002 PSF yes + 2.1.3 2.1.2 2002 PSF yes + 2.2 and above 2.1.1 2001-now PSF yes + +Footnotes: + +(1) GPL-compatible doesn't mean that we're distributing Python under + the GPL. All Python licenses, unlike the GPL, let you distribute + a modified version without making your changes open source. The + GPL-compatible licenses make it possible to combine Python with + other software that is released under the GPL; the others don't. + +(2) According to Richard Stallman, 1.6.1 is not GPL-compatible, + because its license has a choice of law clause. According to + CNRI, however, Stallman's lawyer has told CNRI's lawyer that 1.6.1 + is "not incompatible" with the GPL. + +Thanks to the many outside volunteers who have worked under Guido's +direction to make these releases possible. + + +B. TERMS AND CONDITIONS FOR ACCESSING OR OTHERWISE USING PYTHON +=============================================================== + +Python software and documentation are licensed under the +Python Software Foundation License Version 2. + +Starting with Python 3.8.6, examples, recipes, and other code in +the documentation are dual licensed under the PSF License Version 2 +and the Zero-Clause BSD license. + +Some software incorporated into Python is under different licenses. +The licenses are listed with code falling under that license. + + +PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 +-------------------------------------------- + +1. This LICENSE AGREEMENT is between the Python Software Foundation +("PSF"), and the Individual or Organization ("Licensee") accessing and +otherwise using this software ("Python") in source or binary form and +its associated documentation. + +2. Subject to the terms and conditions of this License Agreement, PSF hereby +grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, +analyze, test, perform and/or display publicly, prepare derivative works, +distribute, and otherwise use Python alone or in any derivative version, +provided, however, that PSF's License Agreement and PSF's notice of copyright, +i.e., "Copyright (c) 2001-2024 Python Software Foundation; All Rights Reserved" +are retained in Python alone or in any derivative version prepared by Licensee. + +3. In the event Licensee prepares a derivative work that is based on +or incorporates Python or any part thereof, and wants to make +the derivative work available to others as provided herein, then +Licensee hereby agrees to include in any such work a brief summary of +the changes made to Python. + +4. PSF is making Python available to Licensee on an "AS IS" +basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON +FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS +A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, +OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +6. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +7. Nothing in this License Agreement shall be deemed to create any +relationship of agency, partnership, or joint venture between PSF and +Licensee. This License Agreement does not grant permission to use PSF +trademarks or trade name in a trademark sense to endorse or promote +products or services of Licensee, or any third party. + +8. By copying, installing or otherwise using Python, Licensee +agrees to be bound by the terms and conditions of this License +Agreement. + + +BEOPEN.COM LICENSE AGREEMENT FOR PYTHON 2.0 +------------------------------------------- + +BEOPEN PYTHON OPEN SOURCE LICENSE AGREEMENT VERSION 1 + +1. This LICENSE AGREEMENT is between BeOpen.com ("BeOpen"), having an +office at 160 Saratoga Avenue, Santa Clara, CA 95051, and the +Individual or Organization ("Licensee") accessing and otherwise using +this software in source or binary form and its associated +documentation ("the Software"). + +2. Subject to the terms and conditions of this BeOpen Python License +Agreement, BeOpen hereby grants Licensee a non-exclusive, +royalty-free, world-wide license to reproduce, analyze, test, perform +and/or display publicly, prepare derivative works, distribute, and +otherwise use the Software alone or in any derivative version, +provided, however, that the BeOpen Python License is retained in the +Software, alone or in any derivative version prepared by Licensee. + +3. BeOpen is making the Software available to Licensee on an "AS IS" +basis. BEOPEN MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, BEOPEN MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +4. BEOPEN SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF THE +SOFTWARE FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS +AS A RESULT OF USING, MODIFYING OR DISTRIBUTING THE SOFTWARE, OR ANY +DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +5. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +6. This License Agreement shall be governed by and interpreted in all +respects by the law of the State of California, excluding conflict of +law provisions. Nothing in this License Agreement shall be deemed to +create any relationship of agency, partnership, or joint venture +between BeOpen and Licensee. This License Agreement does not grant +permission to use BeOpen trademarks or trade names in a trademark +sense to endorse or promote products or services of Licensee, or any +third party. As an exception, the "BeOpen Python" logos available at +http://www.pythonlabs.com/logos.html may be used according to the +permissions granted on that web page. + +7. By copying, installing or otherwise using the software, Licensee +agrees to be bound by the terms and conditions of this License +Agreement. + + +CNRI LICENSE AGREEMENT FOR PYTHON 1.6.1 +--------------------------------------- + +1. This LICENSE AGREEMENT is between the Corporation for National +Research Initiatives, having an office at 1895 Preston White Drive, +Reston, VA 20191 ("CNRI"), and the Individual or Organization +("Licensee") accessing and otherwise using Python 1.6.1 software in +source or binary form and its associated documentation. + +2. Subject to the terms and conditions of this License Agreement, CNRI +hereby grants Licensee a nonexclusive, royalty-free, world-wide +license to reproduce, analyze, test, perform and/or display publicly, +prepare derivative works, distribute, and otherwise use Python 1.6.1 +alone or in any derivative version, provided, however, that CNRI's +License Agreement and CNRI's notice of copyright, i.e., "Copyright (c) +1995-2001 Corporation for National Research Initiatives; All Rights +Reserved" are retained in Python 1.6.1 alone or in any derivative +version prepared by Licensee. Alternately, in lieu of CNRI's License +Agreement, Licensee may substitute the following text (omitting the +quotes): "Python 1.6.1 is made available subject to the terms and +conditions in CNRI's License Agreement. This Agreement together with +Python 1.6.1 may be located on the internet using the following +unique, persistent identifier (known as a handle): 1895.22/1013. This +Agreement may also be obtained from a proxy server on the internet +using the following URL: http://hdl.handle.net/1895.22/1013". + +3. In the event Licensee prepares a derivative work that is based on +or incorporates Python 1.6.1 or any part thereof, and wants to make +the derivative work available to others as provided herein, then +Licensee hereby agrees to include in any such work a brief summary of +the changes made to Python 1.6.1. + +4. CNRI is making Python 1.6.1 available to Licensee on an "AS IS" +basis. CNRI MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, CNRI MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON 1.6.1 WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +5. CNRI SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON +1.6.1 FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS +A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON 1.6.1, +OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +6. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +7. This License Agreement shall be governed by the federal +intellectual property law of the United States, including without +limitation the federal copyright law, and, to the extent such +U.S. federal law does not apply, by the law of the Commonwealth of +Virginia, excluding Virginia's conflict of law provisions. +Notwithstanding the foregoing, with regard to derivative works based +on Python 1.6.1 that incorporate non-separable material that was +previously distributed under the GNU General Public License (GPL), the +law of the Commonwealth of Virginia shall govern this License +Agreement only as to issues arising under or with respect to +Paragraphs 4, 5, and 7 of this License Agreement. Nothing in this +License Agreement shall be deemed to create any relationship of +agency, partnership, or joint venture between CNRI and Licensee. This +License Agreement does not grant permission to use CNRI trademarks or +trade name in a trademark sense to endorse or promote products or +services of Licensee, or any third party. + +8. By clicking on the "ACCEPT" button where indicated, or by copying, +installing or otherwise using Python 1.6.1, Licensee agrees to be +bound by the terms and conditions of this License Agreement. + + ACCEPT + + +CWI LICENSE AGREEMENT FOR PYTHON 0.9.0 THROUGH 1.2 +-------------------------------------------------- + +Copyright (c) 1991 - 1995, Stichting Mathematisch Centrum Amsterdam, +The Netherlands. All rights reserved. + +Permission to use, copy, modify, and distribute this software and its +documentation for any purpose and without fee is hereby granted, +provided that the above copyright notice appear in all copies and that +both that copyright notice and this permission notice appear in +supporting documentation, and that the name of Stichting Mathematisch +Centrum or CWI not be used in advertising or publicity pertaining to +distribution of the software without specific, written prior +permission. + +STICHTING MATHEMATISCH CENTRUM DISCLAIMS ALL WARRANTIES WITH REGARD TO +THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS, IN NO EVENT SHALL STICHTING MATHEMATISCH CENTRUM BE LIABLE +FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT +OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +ZERO-CLAUSE BSD LICENSE FOR CODE IN THE PYTHON DOCUMENTATION +---------------------------------------------------------------------- + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. diff --git a/packaging/support/licenses/QuickJS-LICENSE b/packaging/support/licenses/QuickJS-LICENSE new file mode 100644 index 00000000..fae65732 --- /dev/null +++ b/packaging/support/licenses/QuickJS-LICENSE @@ -0,0 +1,24 @@ +The MIT License (MIT) + +Copyright (c) 2017-2026 Fabrice Bellard +Copyright (c) 2017-2024 Charlie Gordon +Copyright (c) 2023-2026 Ben Noordhuis +Copyright (c) 2023-2026 Saúl Ibarra Corretgé + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/packaging/support/licenses/Tcl-license.terms b/packaging/support/licenses/Tcl-license.terms new file mode 100644 index 00000000..d8049cd9 --- /dev/null +++ b/packaging/support/licenses/Tcl-license.terms @@ -0,0 +1,40 @@ +This software is copyrighted by the Regents of the University of +California, Sun Microsystems, Inc., Scriptics Corporation, ActiveState +Corporation and other parties. The following terms apply to all files +associated with the software unless explicitly disclaimed in +individual files. + +The authors hereby grant permission to use, copy, modify, distribute, +and license this software and its documentation for any purpose, provided +that existing copyright notices are retained in all copies and that this +notice is included verbatim in any distributions. No written agreement, +license, or royalty fee is required for any of the authorized uses. +Modifications to this software may be copyrighted by their authors +and need not follow the licensing terms described here, provided that +the new terms are clearly indicated on the first page of each file where +they apply. + +IN NO EVENT SHALL THE AUTHORS OR DISTRIBUTORS BE LIABLE TO ANY PARTY +FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE, ITS DOCUMENTATION, OR ANY +DERIVATIVES THEREOF, EVEN IF THE AUTHORS HAVE BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + +THE AUTHORS AND DISTRIBUTORS SPECIFICALLY DISCLAIM ANY WARRANTIES, +INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. THIS SOFTWARE +IS PROVIDED ON AN "AS IS" BASIS, AND THE AUTHORS AND DISTRIBUTORS HAVE +NO OBLIGATION TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR +MODIFICATIONS. + +GOVERNMENT USE: If you are acquiring this software on behalf of the +U.S. government, the Government shall have only "Restricted Rights" +in the software and related documentation as defined in the Federal +Acquisition Regulations (FARs) in Clause 52.227.19 (c) (2). If you +are acquiring the software on behalf of the Department of Defense, the +software shall be classified as "Commercial Computer Software" and the +Government shall have only "Restricted Rights" as defined in Clause +252.227-7014 (b) (3) of DFARs. Notwithstanding the foregoing, the +authors grant the U.S. Government and others acting in its behalf +permission to use and distribute the software in accordance with the +terms specified in this license. diff --git a/packaging/windows/OpenStudio.iss b/packaging/windows/OpenStudio.iss index 47dad1fa..aeed434f 100644 --- a/packaging/windows/OpenStudio.iss +++ b/packaging/windows/OpenStudio.iss @@ -63,6 +63,7 @@ Root: HKCR; Subkey: "OpenStudio.Project\shell\open\command"; ValueType: string; [Icons] Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" +Name: "{autoprograms}\OpenStudio Support"; Filename: "{app}\OpenStudioSupport.exe"; Comment: "Collect diagnostics after closing OpenStudio" Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon [Run] @@ -93,6 +94,10 @@ begin if not FileExists(ExpandConstant('{app}\OpenStudio.exe')) then MissingItems := MissingItems + #13#10 + ' - OpenStudio.exe'; + if not FileExists(ExpandConstant('{app}\OpenStudioSupport.exe')) then + MissingItems := MissingItems + #13#10 + ' - OpenStudioSupport.exe'; + if not FileExists(ExpandConstant('{app}\support-runtime\support-build.json')) then + MissingItems := MissingItems + #13#10 + ' - support-runtime\support-build.json'; if not FileExists(ExpandConstant('{app}\webui\index.html')) then MissingItems := MissingItems + #13#10 + ' - webui\index.html'; diff --git a/tests/test_desktop_qualification.py b/tests/test_desktop_qualification.py index 06c6b2fc..d7850b31 100644 --- a/tests/test_desktop_qualification.py +++ b/tests/test_desktop_qualification.py @@ -60,6 +60,9 @@ def test_missing_or_reset_audio_telemetry_never_becomes_zero_failures(self): self.assertEqual(recording.evaluate(known | {"audioDeviceXRunCount": 2}, known, path, 1)["status"], "fail") self.assertEqual(recording.evaluate(known, known | {"audioDeviceStopCount": 1}, path, 1)["status"], "fail") self.assertEqual(recording.evaluate({}, {}, path, 1)["status"], "not_asserted") + unavailable = known | {"audioCallbackTimingAvailable": False} + self.assertEqual(recording.evaluate(unavailable, unavailable, path, 1)["status"], "not_asserted") + self.assertEqual(recording.evaluate(known, known | {"audioCallbackDeadlineMissCount": 1}, path, 1)["status"], "fail") self.assertEqual(recording.evaluate(known, known, path, 60)["status"], "fail") diff --git a/tests/test_linux_native_packaging.py b/tests/test_linux_native_packaging.py index 8d757520..ecb57ea1 100644 --- a/tests/test_linux_native_packaging.py +++ b/tests/test_linux_native_packaging.py @@ -5,10 +5,13 @@ import shutil import subprocess import tempfile +import sys +import json import unittest from unittest import mock ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'tools')) spec = importlib.util.spec_from_file_location('linux_native', ROOT / 'tools/package-linux-native.py') packaging = importlib.util.module_from_spec(spec) spec.loader.exec_module(packaging) @@ -27,6 +30,16 @@ def setUp(self): shutil.copyfile('/bin/true', self.bundle / name) (self.bundle / name).chmod(0o755) (self.bundle / 'OpenStudio.version').write_text('1.2.3\n') + (self.bundle / 'OpenStudio.build-policy.json').write_text('{"version":"1.2.3","callbackDiagnostics":false}') + shutil.copy2('/bin/true', self.bundle / 'OpenStudioSupport') + resources = self.bundle / 'support-runtime' + (resources / 'support-licenses').mkdir(parents=True) + (resources / 'support-build.json').write_text(json.dumps({'version':'1.2.3','launch':'manual_only'})) + for name in ['support-midi.js', 'support-midi-sources.json', 'support-licenses/README.txt'] + [f'fixture-{i}' for i in range(20)]: + (resources / name).write_text('fixture') + from support_package import write_inventory + write_inventory(self.bundle, '1.2.3', platform='linux') + for name in ('webui/index.html', 'effects/example.jsfx', 'scripts/example.lua', 'models/basic_pitch_nmp.onnx', 'licenses/fixture.txt'): path = self.bundle / name @@ -99,7 +112,7 @@ def test_release_entry_point_rejects_missing_notes_before_build(self): def test_windows_snapshot_desktop_entry_is_staged_with_unix_line_endings(self): source_root = self.root / 'source' - for relative in ('tools/OpenStudio.desktop', 'assets/icon-256x256.png', + for relative in ('tools/OpenStudio.desktop', 'packaging/linux/OpenStudioSupport.desktop', 'assets/icon-256x256.png', 'packaging/linux/openstudio-mime.xml', 'packaging/linux/in.org.openstudio.OpenStudio.metainfo.xml', 'LICENSE'): target = source_root / relative diff --git a/tests/test_release_build_policy.py b/tests/test_release_build_policy.py new file mode 100644 index 00000000..7027f8a2 --- /dev/null +++ b/tests/test_release_build_policy.py @@ -0,0 +1,26 @@ +import json +import tempfile +import unittest +from pathlib import Path +from tools.release_build_policy import validate + + +class ReleaseBuildPolicyTest(unittest.TestCase): + def test_release_requires_explicit_false_and_matching_version(self): + with tempfile.TemporaryDirectory() as temporary: + for suffix in ('', 'OpenStudio.app/Contents/Resources'): + root = Path(temporary) / suffix + root.mkdir(parents=True, exist_ok=True) + bundle = root if not suffix else root.parent.parent + policy = root / 'OpenStudio.build-policy.json' + with self.assertRaises(FileNotFoundError): + validate(bundle, '0.1.09') + for value in (True, None, 0, 'false'): + policy.write_text(json.dumps({'version': '0.1.09', 'callbackDiagnostics': value})) + with self.assertRaises(ValueError): + validate(bundle, '0.1.09') + policy.write_text(json.dumps({'version': '0.1.08', 'callbackDiagnostics': False})) + with self.assertRaises(ValueError): + validate(bundle, '0.1.09') + policy.write_text(json.dumps({'version': '0.1.09', 'callbackDiagnostics': False})) + validate(bundle, '0.1.09') diff --git a/tests/test_release_notes.py b/tests/test_release_notes.py index e9d48f1d..65853434 100644 --- a/tests/test_release_notes.py +++ b/tests/test_release_notes.py @@ -63,7 +63,7 @@ def setUp(self): self.path.parent.mkdir(parents=True) self.valid = (ROOT / "docs/releases/0.1.01.md").read_text(encoding="utf-8") self.source = self.root / "input" - for name in ("OpenStudio.exe", "OpenStudioCrashReporter.exe", "ffmpeg.exe", "onnxruntime.dll", + for name in ("OpenStudio.exe", "OpenStudioCrashReporter.exe", "OpenStudioSupport.exe", "ffmpeg.exe", "onnxruntime.dll", "OpenStudio.version", "webui/index.html", "LICENSE", "THIRD_PARTY_LICENSES.md"): file = self.source / name file.parent.mkdir(parents=True, exist_ok=True) diff --git a/tests/test_store_release_workflow.py b/tests/test_store_release_workflow.py index fde3a344..73f3137e 100644 --- a/tests/test_store_release_workflow.py +++ b/tests/test_store_release_workflow.py @@ -139,7 +139,8 @@ def test_enabled_store_requires_matching_tag_before_notes_or_builds(self): "vars.OPENSTUDIO_STORE_ENABLED": enabled, }), expected) for name in ("build-windows", "build-macos", "build-linux"): - self.assertEqual(self.jobs[name]["needs"], "validate-release-notes") + needs = self.jobs[name]["needs"] + self.assertIn("validate-release-notes", [needs] if isinstance(needs, str) else needs) if os.name == "nt": git = shutil.which("git") diff --git a/tests/test_windows_installer.py b/tests/test_windows_installer.py index 45be12e4..5303d05c 100644 --- a/tests/test_windows_installer.py +++ b/tests/test_windows_installer.py @@ -16,6 +16,7 @@ import tempfile import unittest import uuid +import os ROOT = Path(__file__).resolve().parents[1] @@ -150,6 +151,14 @@ def build_fixture(self, scenario): app.write_bytes(b"Not a Windows executable") else: shutil.copyfile(self.fixture_exe, app) + if scenario == 'support': + support = Path(os.environ['OPENSTUDIO_SUPPORT_TEST_BUNDLE']) + shutil.copy2(support / 'OpenStudioSupport.exe', payload) + shutil.copytree(support / 'support-runtime', payload / 'support-runtime') + else: + shutil.copyfile(self.fixture_exe, payload / "OpenStudioSupport.exe") + (payload / "support-runtime").mkdir() + (payload / "support-runtime/support-build.json").write_text('{}') if scenario != "payload-failure": (payload / "webui").mkdir() (payload / "webui/index.html").write_text("fixture frontend", encoding="utf-8") @@ -180,6 +189,13 @@ def build_fixture(self, scenario): [Code] %s ''' % (uuid.uuid4().hex, directory / "installed", directory, payload, self.code), encoding="utf-8") + if scenario == 'support': + shortcut_name = 'OpenStudio Support QA ' + uuid.uuid4().hex + production = (ROOT / 'packaging/windows/OpenStudio.iss').read_text(encoding='utf-8') + icon = next(line for line in production.splitlines() if 'Filename: "{app}\\OpenStudioSupport.exe"' in line) + icon = icon.replace('{autoprograms}\\OpenStudio Support', '{userprograms}\\' + shortcut_name) + script.write_text(script.read_text(encoding='utf-8').replace('[Code]', '[Icons]\n' + icon + '\n[Code]'), encoding='utf-8') + (directory / 'shortcut.txt').write_text(shortcut_name, encoding='utf-8') exit_code, output = run_bounded([self.compiler, "/Q", script], timeout=60) self.assertEqual(exit_code, 0, output) return directory @@ -220,6 +236,25 @@ def test_successful_install_and_unattended_uninstall(self): finally: self.uninstall(directory) + @unittest.skipUnless(os.environ.get('OPENSTUDIO_SUPPORT_TEST_BUNDLE'), 'Real support bundle not supplied') + def test_support_installs_shortcut_runs_only_on_request_and_uninstalls(self): + directory = self.build_fixture('support') + shortcut = Path(os.environ['APPDATA']) / 'Microsoft/Windows/Start Menu/Programs' / ((directory / 'shortcut.txt').read_text() + '.lnk') + try: + code, output = self.install(directory) + self.assertEqual(code, 0, output) + self.assertTrue(shortcut.is_file()) + # The application prerequisites/self-test do not start the support executable. + processes = subprocess.check_output(['tasklist', '/FO', 'CSV', '/NH'], text=True) + self.assertNotIn('"OpenStudioSupport.exe"', processes) + subprocess.run([sys.executable, str(ROOT / 'tools/test-packaged-support.py'), '--bundle', + str(directory / 'installed')], check=True, timeout=120) + finally: + self.uninstall(directory) + self.assertFalse(shortcut.exists()) + self.assertFalse((directory / 'installed/OpenStudioSupport.exe').exists()) + self.assertFalse((directory / 'installed/support-runtime').exists()) + def test_missing_payload_returns_failure_without_dialog(self): self.verify_failure("payload-failure", False) diff --git a/tools/assemble-macos-support.py b/tools/assemble-macos-support.py new file mode 100644 index 00000000..22dd16d7 --- /dev/null +++ b/tools/assemble-macos-support.py @@ -0,0 +1,62 @@ +"""Combine qualified native support runtimes with a universal, manual-only launcher.""" +import argparse +from pathlib import Path +import plistlib +import shutil +import subprocess +from support_package import validate, write_inventory + +ROOT = Path(__file__).resolve().parents[1] + + +def make_launcher(target, version, shortcut=False): + if target.exists(): + raise ValueError(f'Launcher already exists: {target}') + contents = target / 'Contents' + (contents / 'MacOS').mkdir(parents=True) + (contents / 'Resources').mkdir() + info = {'CFBundleIdentifier': 'in.org.openstudio.support-shortcut' if shortcut else 'in.org.openstudio.support', + 'CFBundleName': 'OpenStudio Support', 'CFBundleDisplayName': 'OpenStudio Support', + 'CFBundleExecutable': 'OpenStudioSupport', 'CFBundlePackageType': 'APPL', + 'CFBundleVersion': version, 'CFBundleShortVersionString': version, + 'LSMinimumSystemVersion': '11.0', 'NSHighResolutionCapable': True} + (contents / 'Info.plist').write_bytes(plistlib.dumps(info)) + subprocess.run(['xcrun', 'clang', '-Wall', '-Wextra', '-Werror', '-fobjc-arc', + '-arch', 'arm64', '-arch', 'x86_64', '-mmacosx-version-min=11.0', + *(['-DOPENSTUDIO_SUPPORT_SHORTCUT=1'] if shortcut else []), + '-framework', 'Cocoa', str(ROOT / 'packaging/macos/SupportLauncher.m'), + '-o', str(contents / 'MacOS/OpenStudioSupport')], check=True) + + +def assemble(arm64, intel, output, version): + target = output / 'OpenStudioSupport.app' + for architecture, source in (('arm64', arm64), ('x86_64', intel)): + validate(source, version) + subprocess.run(['lipo', str(source / 'Contents/MacOS/OpenStudioSupport'), '-verify_arch', architecture], check=True) + make_launcher(target, version) + for architecture, source in (('arm64', arm64), ('x86_64', intel)): + shutil.copytree(source, target / 'Contents/Helpers' / architecture / 'OpenStudioSupport.app', symlinks=True) + # Inventory/version anchors for the outer launcher. Actual analysis runs in a native child bundle. + for name in ('support-build.json', 'support-midi.js', 'support-midi-sources.json', 'support-licenses'): + source = arm64 / 'Contents/Resources' / name + destination = target / 'Contents/Resources' / name + if source.is_dir(): + shutil.copytree(source, destination) + else: + shutil.copy2(source, destination) + write_inventory(target, version) + subprocess.run(['codesign', '--force', '--sign', '-', str(target)], check=True) + shortcut = output / 'OpenStudio Support.app' + make_launcher(shortcut, version, shortcut=True) + subprocess.run(['codesign', '--force', '--sign', '-', str(shortcut)], check=True) + return target + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--arm64', type=Path, required=True) + parser.add_argument('--x86-64', type=Path, required=True) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--version', required=True) + args = parser.parse_args() + print(assemble(args.arm64.resolve(), args.x86_64.resolve(), args.output.resolve(), args.version)) diff --git a/tools/build-support-midi.mjs b/tools/build-support-midi.mjs new file mode 100644 index 00000000..6555fa71 --- /dev/null +++ b/tools/build-support-midi.mjs @@ -0,0 +1,40 @@ +// Build time only: bundle pure app serialization for the offline embedded JS engine. +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const output = path.resolve(process.argv[2]); +const { rolldown } = await import(pathToFileURL(path.join(root, 'frontend/node_modules/rolldown/dist/index.mjs')).href); +const sources = {}; +const bundle = await rolldown({ + input: 'support-entry', cwd: root, platform: 'neutral', + plugins: [{ name: 'offline-only', + resolveId(id) { + if (id === 'support-entry') return '\0support-entry'; + if (id.startsWith('./frontend/') || id.startsWith('./tools/')) return path.resolve(root, id); + }, + load(id) { + if (id === '\0support-entry') return ` + import { parseValidatedProject } from './frontend/src/utils/projectValidation.ts'; + import { serializeMIDIClipsForBackend, getMIDIClipSourceLoopLength } from './frontend/src/utils/midiClipSerialization.ts'; + import { midiExportOptions } from './frontend/src/utils/midiExportOptions.ts'; + import { prepareMIDI } from './tools/support-midi-core.mjs'; + export function prepare(project, selection) { + return JSON.stringify(prepareMIDI(project, JSON.parse(selection), { parseValidatedProject, + serializeMIDIClipsForBackend, getMIDIClipSourceLoopLength, midiExportOptions })); + }`; + if (/[\\/]services[\\/]NativeBridge\.ts$/.test(id)) + return 'export const nativeBridge = { setTrackMIDIClips() { throw new Error("Native bridge unavailable offline"); } };'; + }, + generateBundle() { + for (const id of this.getModuleIds()) if (!id.startsWith('\0') && fs.existsSync(id)) + sources[path.relative(root, id).replaceAll('\\', '/')] = crypto.createHash('sha256').update(fs.readFileSync(id)).digest('hex'); + }, + }], +}); +const result = await bundle.generate({ format: 'iife', name: 'SupportMIDI' }); +await bundle.close(); +fs.mkdirSync(output, { recursive: true }); +fs.writeFileSync(path.join(output, 'support-midi.js'), result.output[0].code); +fs.writeFileSync(path.join(output, 'support-midi-sources.json'), JSON.stringify(sources, null, 2)); diff --git a/tools/build-support-tool.py b/tools/build-support-tool.py new file mode 100644 index 00000000..3579d421 --- /dev/null +++ b/tools/build-support-tool.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""Developer/release build only. Produces a self-contained, separately launched GUI.""" +from pathlib import Path +import argparse +import importlib.metadata +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import venv +import struct +import plistlib +import ast +from support_package import write_inventory + +ROOT = Path(__file__).resolve().parents[1] + + +def linux_system_notices(toc, destination): + """Retain the build distribution's notices for system libraries frozen by PyInstaller.""" + data = ast.literal_eval(toc.read_text(encoding='utf-8')) + sources = set() + def visit(value): + if isinstance(value, (list, tuple)): + if (len(value) == 3 and value[2] in ('BINARY', 'EXTENSION') + and isinstance(value[1], str) and value[1].startswith(('/lib/', '/usr/lib/'))): + sources.add(value[1]) + else: + for child in value: + visit(child) + visit(data) + owners = {} + for source in sorted(sources): + candidates = [source, str(Path(source).resolve())] + candidates += [p.removeprefix('/usr') if p.startswith('/usr/lib/') else '/usr' + p for p in candidates[:]] + for candidate in dict.fromkeys(candidates): + result = subprocess.run(['dpkg-query', '-S', candidate], capture_output=True, text=True) + if result.returncode == 0: + package = result.stdout.splitlines()[0].rsplit(': ', 1)[0].split(':')[0] + notice = Path('/usr/share/doc') / package / 'copyright' + if not notice.is_file(): + raise ValueError(f'Missing bundled system library notice: {package}') + destination.mkdir(exist_ok=True) + shutil.copyfile(notice, destination / (package + '.copyright')) + owners[source] = package + break + else: + raise ValueError(f'Cannot identify bundled system library license: {source}') + if owners: + shutil.copytree('/usr/share/common-licenses', destination / 'common-licenses') + (destination / 'bundled-system-libraries.json').write_text(json.dumps(owners, indent=2)) + + +def run(*command): + subprocess.run([str(part) for part in command], cwd=ROOT, check=True) + + +def notices(destination): + destination.mkdir() + shutil.copytree(ROOT / 'packaging/support/licenses', destination / 'upstream') + packages = ['pyinstaller', 'numpy', 'soundfile', 'cffi', 'pycparser', 'quickjs-ng'] + versions = {} + for name in packages: + dist = importlib.metadata.distribution(name) + versions[name] = dist.version + for item in dist.files or []: + if any(word in item.name.lower() for word in ('license', 'copying', 'notice')): + source = Path(dist.locate_file(item)) + if source.is_file() and source.suffix != '.py': + target = destination / name / str(item).replace('..', '_') + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, target) + import tkinter + tcl = tkinter.Tcl() + library = Path(tcl.eval('info library')) + # Python distributions place the combined Python/Tcl notices in different slots. + for index, path in enumerate((Path(sys.base_prefix) / 'LICENSE.txt', Path(sys.base_prefix) / 'LICENSE', + library / 'license.terms', library.parent / 'tk8.6/license.terms')): + if path.is_file(): + shutil.copyfile(path, destination / f'runtime-{index}-{path.name}') + (destination / 'runtime-versions.json').write_text(json.dumps({'python': sys.version, **versions}, indent=2)) + shutil.copyfile(ROOT / 'LICENSE', destination / 'OpenStudio-LICENSE') + (destination / 'README.txt').write_text( + 'This separate offline support executable bundles Python, Tcl/Tk, NumPy, SoundFile, libsndfile, ' + 'CFFI, QuickJS-NG and the PyInstaller bootloader. See the accompanying license files.\n' + 'SoundFile distributes unmodified libsndfile as a replaceable shared library under LGPL-2.1.\n' + 'Corresponding source: https://github.com/bastibe/python-soundfile/releases/tag/0.13.1 ' + 'and https://github.com/libsndfile/libsndfile/releases/tag/1.2.2\n' + 'Build recipe and OpenStudio support source: https://github.com/sdevil7th/OpenStudio/tree/main/tools\n' + 'No service, startup task, updater, upload endpoint or DAW dependency is installed.\n', encoding='utf-8') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--version', required=True) + parser.add_argument('--output', type=Path, default=ROOT / 'output/support-package') + parser.add_argument('--stage', type=Path, help='Existing app runtime directory; on macOS pass OpenStudio.app') + parser.add_argument('--prepared-environment', action='store_true', help=argparse.SUPPRESS) + args = parser.parse_args() + if not re.fullmatch(r'\d+\.\d+\.\d+', args.version) or any(int(n) > 65535 for n in args.version.split('.')): + parser.error('Version must be three numeric components, each at most 65535') + if not args.prepared_environment: + environment = ROOT / '.venv-support' + python = environment / ('Scripts/python.exe' if os.name == 'nt' else 'bin/python') + if not python.exists(): + venv.EnvBuilder(with_pip=True).create(environment) + run(python, '-m', 'pip', 'install', '-r', ROOT / 'tools/support-build-requirements.txt') + run(python, __file__, *sys.argv[1:], '--prepared-environment') + return + output = args.output.resolve() + output.mkdir(parents=True, exist_ok=True) + # Work and PyInstaller's replaceable output are isolated from application files. + with tempfile.TemporaryDirectory(prefix='support-build-') as directory: + work = Path(directory) + run('node', ROOT / 'tools/build-support-midi.mjs', work) + notices(work / 'support-licenses') + (work / 'support-build.json').write_text(json.dumps({'version': args.version, 'launch': 'manual_only'})) + options = [sys.executable, '-m', 'PyInstaller', '--noconfirm', '--clean', '--onedir', '--windowed', + '--name', 'OpenStudioSupport', '--contents-directory', 'support-runtime', + '--distpath', work / 'dist', '--workpath', work / 'build', '--specpath', work, + '--paths', ROOT / 'tools', '--hidden-import', 'quickjs', + '--exclude-module', 'pytest', '--exclude-module', 'IPython'] + for name in ('support-midi.js', 'support-midi-sources.json', 'support-build.json'): + options += ['--add-data', f'{work / name}{os.pathsep}.'] + options += ['--add-data', f'{work / "support-licenses"}{os.pathsep}support-licenses', + '--add-data', f'{ROOT / "tools/check-project-render-reference.py"}{os.pathsep}.'] + if sys.platform == 'win32': + icon = work / 'support.ico' + png = (ROOT / 'assets/icon-256x256.png').read_bytes() + icon.write_bytes(struct.pack(' dict: @@ -19,6 +20,9 @@ def evaluate(before: dict, after: dict, audio: Path, seconds: float) -> dict: start, end = before.get(counter), after.get(counter) known = all(isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) and value >= 0 for value in (start, end)) + if counter in ("audioCallbackArrivalGapCount", "audioCallbackDeadlineMissCount"): + known = known and all(snapshot.get("audioCallbackTimingAvailable") is not False + for snapshot in (before, after)) delta = end - start if known else None checks.append({"id": counter, "status": "not_asserted" if not known else "pass" if delta == 0 else "fail", "delta": delta, "detail": "unsupported/missing" if not known else "counter reset" if delta < 0 else "measured"}) diff --git a/tools/openstudio-support.py b/tools/openstudio-support.py new file mode 100644 index 00000000..eede9532 --- /dev/null +++ b/tools/openstudio-support.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +"""Explicit, offline support collection and export inspection. Never imported by the DAW.""" +from openstudio_support.cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/openstudio_support/__init__.py b/tools/openstudio_support/__init__.py new file mode 100644 index 00000000..851a386b --- /dev/null +++ b/tools/openstudio_support/__init__.py @@ -0,0 +1 @@ +"""Offline tools only. No startup registration, application hooks, or network clients.""" diff --git a/tools/openstudio_support/audio.py b/tools/openstudio_support/audio.py new file mode 100644 index 00000000..c75b9d9c --- /dev/null +++ b/tools/openstudio_support/audio.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +import math +from pathlib import Path +import struct + +from .common import check, read_json, regular_file, report, sha256 + + +def container_check(path: Path, result: dict): + """Detect declared RIFF/AIFF lengths beyond EOF even if a decoder repairs them.""" + size = path.stat().st_size + with path.open("rb") as stream: + head = stream.read(12) + if len(head) < 12: + check(result, "container_length", "fail", "File is too short for an audio container header.") + return + if head[:4] not in {b"RIFF", b"RIFX", b"FORM"}: + check(result, "container_length", "not_asserted", "No independent container-length check for this format; decoder checks still run.") + return + order = "<" if head[:4] == b"RIFF" else ">" + declared = struct.unpack(order + "I", head[4:8])[0] + 8 + valid = 12 <= declared <= size + position = 12 + while valid and position < declared: + stream.seek(position) + chunk = stream.read(8) + if len(chunk) != 8: + valid = False + break + length = struct.unpack(order + "I", chunk[4:])[0] + position += 8 + length + (length % 2) + valid = position <= declared + check(result, "container_length", "pass" if valid and position == declared else "fail", + "Checked declared container and chunk boundaries against the file size.", + declaredBytes=declared, actualBytes=size) + + +def inspect_audio(path: Path, route_path: Path | None = None, project_path: Path | None = None, + reference=False, start=0.0, pan_law="linear"): + result = report("audio_export") + regular_file(path) + initial = path.stat() + result["input"] = {"path": str(path.absolute()), "bytes": initial.st_size, "sha256": sha256(path)} + result["limitations"] += ["Decodable audio and correct duration do not prove every intended track is audible.", + "Silence and full-scale samples are diagnostic only, not proof of dropouts or audible distortion."] + container_check(path, result) + if path.suffix.lower() in {".raw", ".pcm"}: + check(result, "decode", "not_asserted", "Raw PCM has no self-describing format; supply a container export for inspection.") + return result + try: + import numpy as np + import soundfile as sf + except ImportError: + check(result, "decode", "not_asserted", "Install tools/support-requirements.txt in a separate Python environment to inspect audio.") + return result + result["decoder"] = {"soundfile": sf.__version__, "libsndfile": sf.__libsndfile_version__} + try: + with sf.SoundFile(path) as audio: + if audio.channels > 64: + check(result, "decode", "not_asserted", "Channel count exceeds the inspector's 64-channel memory bound.") + return result + rate, channels, declared_frames = audio.samplerate, audio.channels, audio.frames + result["audio"] = {"sampleRate": rate, "channels": channels, "format": audio.format, + "subtype": audio.subtype, "declaredFrames": declared_frames} + visited = nonfinite = fullscale = trailing_zeros = longest_zeros = 0 + peak = energy = 0.0 + for block in audio.blocks(blocksize=16384, dtype="float64", always_2d=True): + visited += len(block) + finite = np.isfinite(block) + nonfinite += int(np.count_nonzero(~finite)) + values = np.where(finite, block, 0.0) + peak = max(peak, float(np.max(np.abs(values)))) + # Avoid overflow for malformed float files; retain finite report JSON. + bounded = np.clip(values, -1e100, 1e100) + energy += float(np.sum(bounded * bounded)) + fullscale += int(np.count_nonzero(np.abs(values) >= 1.0)) + zeros = np.all(block == 0, axis=1) + edges = np.flatnonzero(np.diff(np.concatenate(([False], zeros, [False])))) + if len(edges): + lengths = edges[1::2] - edges[::2] + if edges[0] == 0: + lengths[0] += trailing_zeros + longest_zeros = max(longest_zeros, int(np.max(lengths))) + trailing_zeros = int(lengths[-1]) if edges[-1] == len(block) else 0 + else: + trailing_zeros = 0 + result["audio"].update(framesRead=visited, durationSeconds=visited / rate, + nonFiniteSamples=nonfinite) + result["signalDiagnostics"] = {"classification": "diagnostic_only", "samplePeak": peak, + "rmsWithExtremeValuesClamped": math.sqrt(energy / max(1, visited * channels)), + "fullScaleSampleCount": fullscale, "longestExactSilenceSeconds": longest_zeros / rate} + check(result, "decode", "pass" if visited == declared_frames and visited > 0 else "fail", + "Read the entire decoder-reported audio stream.", framesRead=visited, declaredFrames=declared_frames) + check(result, "finite_samples", "pass" if nonfinite == 0 else "fail", + "Checked for NaN and infinite samples.", count=nonfinite) + except (RuntimeError, ValueError, OSError) as exc: + check(result, "decode", "fail", str(exc)) + if route_path: + compare_route(result, read_json(route_path), route_path) + if project_path: + compare_project(result, path, project_path, reference, start, pan_law) + final = path.stat() + check(result, "input_stable", "pass" if (initial.st_size, initial.st_mtime_ns) == (final.st_size, final.st_mtime_ns) else "fail", + "Input size and modification time remained stable during inspection.") + return result + + +def compare_project(result, audio_path, project_path, reference, start, pan_law): + project = read_json(project_path) + if (not isinstance(project, dict) or not isinstance(project.get("tracks"), list) + or any(not isinstance(track, dict) or not isinstance(track.get("clips", []), list) + or any(not isinstance(clip, dict) for clip in track.get("clips", [])) for track in project["tracks"])): + check(result, "project_snapshot", "fail", "Invalid project snapshot") + return + fingerprint = sha256(project_path) + result["projectSnapshot"] = {"path": str(project_path.absolute()), "sha256": fingerprint, + "associationWithOriginalExport": "not_asserted"} + route = result.get("routeReport") + if route: + expected = {track.get("id") for track in project["tracks"] if isinstance(track, dict)} + observed = {track.get("id") for track in route["tracks"] if isinstance(track, dict)} + check(result, "snapshot_track_ids", "pass" if expected == observed and None not in expected else "fail", + "Compared saved-project track IDs with the supplied backend snapshot; this does not prove an export-time match.", + missingInRoute=sorted(str(item) for item in expected - observed), + extraInRoute=sorted(str(item) for item in observed - expected)) + sources = [] + for track in project["tracks"]: + for clip in track.get("clips", []): + source = clip.get("filePath") + if isinstance(source, str): + file = Path(source) + sources.append({"clipId": clip.get("id"), "path": source, + "availableAtCollectionTime": file.is_file() if file.is_absolute() else None}) + result["projectSourcesAtCollectionTime"] = sources + if not reference: + check(result, "source_reference", "not_asserted", "Full source comparison was not requested. Use --reference for supported neutral audio projects.") + return + # The existing QA reference models only a subset of the engine. Tighten its + # admission here; a decoder/reference success must not hide unmodelled DSP. + unsupported = [] + if not route: + unsupported.append("render route report is required to establish scope and normalization settings") + if route and route.get("startTimeSec") != start: + unsupported.append("reference start differs from the supplied render plan") + if result["status"] == "fail": + unsupported.append("file/metadata checks already failed") + if route and route.get("source") != "master": + unsupported.append("only full master scope is supported by this inspector's reference mode") + if route and route.get("normalize"): + unsupported.append("normalization") + if result.get("audio", {}).get("format") not in {"WAV", "WAVEX", "RF64", "AIFF", "FLAC"}: + unsupported.append("reference requires lossless container audio") + for track in project["tracks"]: + if track.get("type", "audio") != "audio": + unsupported.append("non-audio track") + for key in ("channelStripEQEnabled", "channelStripEQState", "eqEnabled", "isFolder", "frozen", "midiClips", "unavailableFX", "trackFXSidechains"): + if track.get(key): + unsupported.append(key) + for clip in track.get("clips", []): + for key in ("crossfadeLayers", "crossfadeGainLayers", "sourceStart", "loopOffset", "loopEnabled", "reversed", "reverse", "pitchShift", "gainEnvelope", "pan"): + if clip.get(key): + unsupported.append(key) + if not Path(clip.get("filePath", "")).is_absolute(): + unsupported.append("relative or absent source path") + if unsupported: + check(result, "source_reference", "not_asserted", "Reference cannot represent this saved processing state.", unsupported=sorted(set(unsupported))) + return + try: + import importlib.util + script = Path(__file__).resolve().parents[1] / "check-project-render-reference.py" + spec = importlib.util.spec_from_file_location("support_audio_reference", script) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + comparison = module.reference_check(project_path, audio_path, start=start, law=pan_law) + # Raw sample values and paths remain local unless the reviewed report is shared. + result["sourceReference"] = comparison + check(result, "source_reference", comparison["status"], + "Compared output samples with the supplied snapshot's supported neutral mix. This is not a subjective quality assessment or proof of original export-time state.") + except (OSError, ValueError, KeyError, ImportError, RuntimeError) as exc: + check(result, "source_reference", "not_asserted", str(exc)) + check(result, "project_stable", "pass" if sha256(project_path) == fingerprint else "fail", "Saved project bytes remained unchanged during source comparison.") + + +def compare_route(result: dict, route: dict, route_path: Path): + if not isinstance(route, dict) or route.get("purpose") != "manual_render_route_report": + check(result, "route_report", "fail", "The supplied JSON is not an OpenStudio render route report.") + return + result["routeReport"] = {"path": str(route_path.absolute()), "sha256": sha256(route_path), + "associationWithOriginalExport": "not_asserted", + "source": route.get("source"), "writtenAt": route.get("writtenAt"), + "normalize": route.get("normalize", False), + "startTimeSec": route.get("startTimeSec"), + "tracks": route.get("tracks", []), "clips": route.get("clips", [])} + check(result, "route_provenance", "not_asserted", + "Existing route reports have no output hash or final completion record. Comparisons below are against the supplied report only.") + audio = result.get("audio", {}) + for field, route_field in (("sampleRate", "requestedOutputSampleRate"), ("channels", "channels")): + expected, actual = route.get(route_field), audio.get(field) + if isinstance(expected, (int, float)) and math.isfinite(expected) and actual is not None: + check(result, "route_" + field, "pass" if actual == expected else "fail", + "Compared output metadata with the supplied render plan.", expected=expected, actual=actual) + samples, source_rate, output_rate = route.get("outputSamples"), route.get("actualSampleRate"), route.get("requestedOutputSampleRate") + if all(isinstance(n, (int, float)) and math.isfinite(n) and n > 0 for n in (samples, source_rate, output_rate)) and "framesRead" in audio: + expected = round(samples * output_rate / source_rate) + lossy = str(route.get("format", "")).lower() in {"mp3", "ogg"} + check(result, "route_duration", "not_asserted" if lossy else "pass" if abs(audio["framesRead"] - expected) <= 1 else "fail", + "Lossy codec padding needs format-specific interpretation." if lossy else "Compared frame count including the planned tail and rate conversion (one-frame rounding tolerance).", + expectedFrames=expected, actualFrames=audio["framesRead"]) + sources = [] + for clip in route.get("clips", []): + if not isinstance(clip, dict): + continue + source = clip.get("audioFile") + if not isinstance(source, str) or not source: + continue + file = Path(source) + sources.append({"clipId": clip.get("clipId"), "path": source, + "availableAtCollectionTime": file.is_file() if file.is_absolute() else None}) + result["sourcesAtCollectionTime"] = sources + check(result, "track_completeness", "not_asserted", + "The pre-render snapshot describes intended routing, not per-track contribution to the completed mix.") diff --git a/tools/openstudio_support/cli.py b/tools/openstudio_support/cli.py new file mode 100644 index 00000000..e09b5121 --- /dev/null +++ b/tools/openstudio_support/cli.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import argparse +from pathlib import Path +import subprocess +import sys + +from .common import require_closed, save_report + + +def parser(): + command = argparse.ArgumentParser(description="Offline OpenStudio support. Close the DAW and keep it closed until finished. No uploads or live collection.") + command.add_argument("--gui", action="store_true", help="Open the standalone support window") + command.add_argument("--self-test", type=Path, help=argparse.SUPPRESS) + actions = command.add_subparsers(dest="action") + collect = actions.add_parser("collect", help="Prepare existing evidence for review") + collect.add_argument("--log-dir", type=Path, action="append", help="Additional exact log directory; not scanned recursively") + collect.add_argument("--route-report", type=Path, action="append", default=[]) + collect.add_argument("--include-dumps", action="store_true", help="Include unredacted memory dumps; may contain private data") + collect.add_argument("--description", default="", help="What happened and what you expected") + collect.add_argument("--output", type=Path, required=True, help="New review directory (must not exist)") + audio = actions.add_parser("audio", help="Inspect an existing audio export") + audio.add_argument("file", type=Path) + audio.add_argument("--route-report", type=Path) + audio.add_argument("--project", type=Path, help="Optional saved snapshot; original export-time association is not assumed") + audio.add_argument("--reference", action="store_true", help="Read source audio and compare a supported neutral master mix") + audio.add_argument("--start", type=float, default=0, help="Reference export start in project seconds") + audio.add_argument("--pan-law", choices=("linear", "constant-power"), default="linear") + audio.add_argument("--output", type=Path, required=True) + midi = actions.add_parser("midi", help="Inspect MIDI; optionally compare a saved project snapshot") + midi.add_argument("file", type=Path) + midi.add_argument("--project", type=Path) + midi.add_argument("--clip-id", action="append", default=[], help="Selected-clip export scope; repeat for multiple clips") + midi.add_argument("--output", type=Path, required=True) + archive = actions.add_parser("bundle", help="ZIP only reviewed, unchanged report files") + archive.add_argument("directory", type=Path) + archive.add_argument("--output", type=Path, required=True) + archive.add_argument("--reviewed", action="store_true", help="I reviewed every file, including any unredacted dumps") + return command + + +def run(args): + require_closed() + if args.output.exists(): + raise ValueError("Output already exists. Choose a new directory or ZIP; existing files are never overwritten.") + if args.action == "collect": + from .collection import collect, default_directories + result, artifacts, redactor = collect(args.log_dir or default_directories(), args.route_report, + args.include_dumps, args.description) + save_report(args.output, result, artifacts, redactor) + elif args.action == "audio": + from .audio import inspect_audio + import math + if args.reference and (not args.project or not args.route_report): + raise ValueError("--reference requires --project and --route-report to check scope and normalization") + if not math.isfinite(args.start) or args.start < 0: + raise ValueError("--start must be finite and non-negative") + result = inspect_audio(args.file, args.route_report, args.project, args.reference, args.start, args.pan_law) + save_report(args.output, result) + elif args.action == "midi": + from .midi import inspect_midi + if args.clip_id and not args.project: + raise ValueError("--clip-id requires --project") + result = inspect_midi(args.file, args.project, args.clip_id) + save_report(args.output, result) + elif args.action == "bundle": + from .collection import bundle + bundle(args.directory, args.output, args.reviewed) + return 0, "Created reviewed ZIP. Nothing was uploaded." + else: + raise ValueError("Choose collect, audio, midi, or bundle") + return (2 if result["status"] == "fail" else 0), (args.output / "summary.txt").read_text(encoding="utf-8") + + +def main(argv=None): + arguments = parser().parse_args(argv) + if arguments.self_test: + from .selftest import run_selftest + return run_selftest(arguments.self_test) + if arguments.gui or not arguments.action: + from .gui import launch + launch() + return 0 + try: + code, message = run(arguments) + if sys.stdout is not None: + print(message) + print("Output:", arguments.output.absolute()) + return code + except (OSError, ValueError, KeyError, TypeError, subprocess.SubprocessError) as exc: + if sys.stderr is not None: + print("Support tool could not complete:", exc, file=sys.stderr) + return 1 diff --git a/tools/openstudio_support/collection.py b/tools/openstudio_support/collection.py new file mode 100644 index 00000000..7a159fb4 --- /dev/null +++ b/tools/openstudio_support/collection.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import ctypes +import hashlib +import json +import os +from pathlib import Path +import re +import sys +import uuid +import zipfile + +from .common import Redactor, check, collection_environment, read_json, regular_file, report + +LOG_NAMES = ("OpenStudio_Startup.log", "OpenStudio_Debug.log", "debug_log.txt", + "crash_breadcrumbs.log", "crash_breadcrumbs.previous.log", "hang_breadcrumbs.log") +DUMP_NAMES = ("last_crash.dmp", "last_crash.previous.dmp", "last_hang.dmp", "last_hang.previous.dmp") +LOG_LIMIT = 512 * 1024 +DUMP_LIMIT = 32 * 1024 * 1024 +TOTAL_LIMIT = 128 * 1024 * 1024 + + +def default_directories(): + home = Path.home() + if os.name == "nt": + documents = home / "Documents" + # Documents may be relocated to OneDrive or another drive. + guid = (ctypes.c_byte * 16).from_buffer_copy(uuid.UUID("FDD39AD0-238F-46AF-ADB4-6C85480369C7").bytes_le) + value = ctypes.c_wchar_p() + if ctypes.windll.shell32.SHGetKnownFolderPath(ctypes.byref(guid), 0, None, ctypes.byref(value)) == 0: + documents = Path(value.value) + ctypes.windll.ole32.CoTaskMemFree(value) + return [documents / "OpenStudio", Path(os.environ.get("APPDATA", home / "AppData/Roaming")) / "OpenStudio/logs"] + if sys.platform == "darwin": + return [home / "Library/Logs/OpenStudio", home / "Library/OpenStudio/logs"] + return [home / "Documents/OpenStudio", Path(os.environ.get("XDG_CONFIG_HOME", home / ".config")) / "OpenStudio/logs"] + + +def collect(directories, route_reports=(), include_dumps=False, description=""): + result = report("existing_evidence") + result["environment"] = collection_environment() + result["userDescription"] = description[:8000] + result["artifacts"] = [] + result["limitations"].append("Logs can span multiple sessions/builds. No incident grouping or original build identity is inferred.") + redactor = Redactor() + artifacts = {} + seen = set() + total = 0 + candidates = [(directory / name, False) for directory in directories + for name in (*LOG_NAMES, *(DUMP_NAMES if include_dumps else ()))] + candidates += [(path, True) for path in route_reports] + for path, route in candidates: + if path.absolute() in seen: + continue + seen.add(path.absolute()) + if not path.exists(): + if route: + check(result, "route_report_missing", "fail", "The selected route report does not exist.") + continue + dump = path.suffix == ".dmp" + name = f"evidence-{len(artifacts) + 1:02d}" + (".dmp" if dump else ".json" if route else ".txt") + try: + info = regular_file(path) + truncated = False + if dump: + if info.st_size > DUMP_LIMIT: + raise ValueError("Dump exceeds 32 MiB; not copied. Share separately only after review.") + data = path.read_bytes() + elif route: + value = read_json(path) + if not isinstance(value, dict) or value.get("purpose") != "manual_render_route_report": + raise ValueError("Not an OpenStudio render route report") + data = json.dumps(redactor.clean(value), indent=2, allow_nan=False).encode("utf-8") + else: + with path.open("rb") as stream: + if info.st_size > LOG_LIMIT: + stream.seek(info.st_size - LOG_LIMIT) + stream.readline() # Do not present an incomplete first line as complete evidence. + truncated = True + data = redactor.text(stream.read(LOG_LIMIT).decode("utf-8", errors="replace")).encode("utf-8") + if total + len(data) > TOTAL_LIMIT: + raise ValueError("Support collection exceeds the 128 MiB total limit") + artifacts[name] = data + total += len(data) + result["artifacts"].append({"file": name, "source": str(path.absolute()), + "sourceBytes": info.st_size, "tailOnly": truncated, + "unredactedDump": dump}) + except (OSError, ValueError) as exc: + check(result, "evidence_not_collected", "not_asserted", str(exc), source=str(path)) + check(result, "existing_evidence", "pass" if artifacts else "not_asserted", + f"Collected {len(artifacts)} existing evidence files; original files were not modified.") + if include_dumps: + result["limitations"].append("Included dumps are unredacted and can contain private memory, audio fragments, and credentials.") + return result, artifacts, redactor + + +def bundle(directory: Path, destination: Path, reviewed: bool): + if not reviewed: + raise ValueError("Review all files first, then use --reviewed to create the ZIP. Nothing is uploaded.") + manifest = read_json(directory / "manifest.json") + if not isinstance(manifest, dict) or manifest.get("kind") != "openstudio_support_manifest": + raise ValueError("Not a support report directory") + entries = manifest.get("files", []) + if not isinstance(entries, list) or not 2 <= len(entries) <= 100: + raise ValueError("Invalid evidence list") + files = {} + total = 0 + for entry in entries: + name = entry["file"] + if (not isinstance(name, str) or not re.fullmatch(r"(?:report\.json|summary\.txt|evidence-\d{2,3}\.(?:txt|json|dmp))", name) + or name in files): + raise ValueError("Unsafe or duplicated evidence entry") + path = directory / name + info = regular_file(path) + total += info.st_size + if total > TOTAL_LIMIT or info.st_size != entry["bytes"]: + raise ValueError("Evidence size changed or exceeds the bundle limit") + data = path.read_bytes() + if hashlib.sha256(data).hexdigest() != entry["sha256"]: + raise ValueError("Evidence changed after collection; regenerate and review the report") + files[name] = data + # Explicit entries only. Never recursively include neighbouring media/projects. + files["manifest.json"] = json.dumps(manifest, indent=2).encode("utf-8") + destination.parent.mkdir(parents=True, exist_ok=True) + with destination.open("xb") as output: + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for name, data in files.items(): + archive.writestr(name, data) diff --git a/tools/openstudio_support/common.py b/tools/openstudio_support/common.py new file mode 100644 index 00000000..44c81102 --- /dev/null +++ b/tools/openstudio_support/common.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +import csv +import hashlib +import io +import json +import os +from pathlib import Path +import platform +import re +import stat +import subprocess +import sys +from datetime import datetime, timezone + +JSON_LIMIT = 32 * 1024 * 1024 + + +def utc_now(): + return datetime.now(timezone.utc).isoformat() + + +def _linux_executable(pid): + return Path(f'/proc/{pid}/exe').resolve(strict=True) + + +def _is_own_appimage_runtime(pid): + # AppImage keeps an extraction/FUSE runtime alive beside the actual support + # process. Identify that exact image by executable path, never by process name. + # Other support instances' Python workers are still rejected by require_closed. + if sys.platform != 'linux' or not getattr(sys, 'frozen', False): + return False + try: + image = Path(os.environ['APPIMAGE']).resolve(strict=True) + appdir = Path(os.environ['APPDIR']).resolve(strict=True) + return (image.name.startswith('OpenStudioSupport-') and image.suffix == '.AppImage' + and Path(sys.executable).resolve(strict=True) == (appdir / 'usr/bin/OpenStudioSupport').resolve(strict=True) + and _linux_executable(pid) == image) + except (KeyError, OSError, RuntimeError): + return False + + +def require_closed(): + """One-shot prerequisite, not a resident monitor. Fail closed if enumeration fails. + + This cannot prevent a user launching the DAW later. Keep it closed until the + tool exits; there is deliberately no new coordination hook in the application. + """ + flags = {"creationflags": subprocess.CREATE_NO_WINDOW} if os.name == "nt" else {} + if os.name == "nt": + result = subprocess.run(["tasklist", "/FO", "CSV", "/NH"], capture_output=True, + text=True, errors="replace", timeout=15, check=True, **flags) + names = [row[0] for row in csv.reader(io.StringIO(result.stdout)) + if row and (len(row) < 2 or row[1] != str(os.getpid()))] + else: + result = subprocess.run(["ps", "-A", "-o", "pid=,comm="], capture_output=True, + text=True, timeout=15, check=True) + names = [Path(parts[1]).name for line in result.stdout.splitlines() + if len(parts := line.strip().split(None, 1)) == 2 and parts[0] != str(os.getpid()) + and not (Path(parts[1]).name.lower().startswith('openstudio') + and _is_own_appimage_runtime(parts[0]))] + if not names: + raise ValueError("Could not check running applications; no diagnostic work was started.") + if any(name.lower().removesuffix(".exe").startswith("openstudio") for name in names): + raise ValueError("Close OpenStudio and its workers before running support tools. Keep it closed until finished.") + + +def regular_file(path: Path): + info = path.lstat() + if path.is_symlink() or not stat.S_ISREG(info.st_mode): + raise ValueError("Only regular files are accepted (no symbolic links or special files).") + return info + + +def read_json(path: Path): + if regular_file(path).st_size > JSON_LIMIT: + raise ValueError("JSON input exceeds the 32 MiB inspection limit.") + def invalid_constant(_value): + raise ValueError("Non-finite JSON number") + with path.open("r", encoding="utf-8-sig") as stream: + return json.load(stream, parse_constant=invalid_constant) + + +def sha256(path: Path): + regular_file(path) + with path.open("rb") as stream: + return hashlib.file_digest(stream, "sha256").hexdigest() + + +def report(kind: str): + return {"schemaVersion": 1, "tool": "OpenStudio offline support", "kind": kind, + "collectedAtUtc": utc_now(), "status": "diagnostic_only", "checks": [], + "contentCompleteness": "not_asserted", "subjectiveAudioQuality": "not_asserted", + "limitations": ["No new live-session telemetry was collected.", + "A later snapshot does not establish the state at the original incident."]} + + +def check(result: dict, name: str, status: str, detail: str, **evidence): + result["checks"].append({"id": name, "status": status, "detail": detail, **evidence}) + if status == "fail": + result["status"] = "fail" + + +class Redactor: + """Best-effort privacy filter. Preview is mandatory; dumps are never redacted.""" + def __init__(self): + self.aliases = {} + + def alias(self, value, kind="path"): + key = (kind, str(value).replace("\\", "/").casefold()) + if key not in self.aliases: + self.aliases[key] = f"<{kind}-{len(self.aliases) + 1}>" + return self.aliases[key] + + def text(self, value): + value = re.sub(r"(?i)\b(?:https?|file)://[^\s<>\"']+", "", value) + value = re.sub(r"(?i)\bBearer\s+[^\s,;\"']+", "Bearer ", value) + value = re.sub(r"(?im)([\"']?(?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|password|secret|authorization|cookie)[\"']?\s*[:=]\s*)([^\r\n]+)", r"\1", value) + value = re.sub(r"\b[^\s@]+@[^\s@]+\.[^\s@]+", "", value) + value = re.sub(r"(?i)(?:[A-Z]:[\\/]|\\\\)[^\r\n\"'<>|]*", lambda m: self.alias(m[0].rstrip()), value) + value = re.sub(r"(?|]*", lambda m: self.alias(m[0].rstrip()), value) + value = re.sub(r"[A-Za-z0-9+/=_-]{128,}", "", value) + return value + + def clean(self, value, key=""): + if re.search(r"(?i)(password|secret|token|api.?key|cookie|authorization|pluginState|fxState|projectNotes)", key): + return "" + if isinstance(value, dict): + return {str(k): self.clean(v, str(k)) for k, v in value.items()} + if isinstance(value, list): + return [self.clean(item, key) for item in value] + if isinstance(value, str): + if key.lower() in {"name", "trackname", "projectname", "clipname"}: + return self.alias(value, "name") + return self.text(value) + return value + + +def summary(result): + lines = ["OpenStudio offline support", f"Report: {result['kind']}", + f"Result: {result['status']}", f"Collected: {result['collectedAtUtc']}", + f"Original content completeness: {result['contentCompleteness']}", + "Subjective audio quality: not_asserted", ""] + for item in result["checks"]: + lines.append(f"[{item['status']}] {item['id']}: {item['detail']}") + lines += ["", "Limitations:", *["- " + item for item in result["limitations"]], "", + "Review report.json and every evidence file before sharing.", + "Redaction is best effort. Dumps contain unredacted process memory.", + "Nothing is uploaded. Original media and projects are not copied."] + return "\n".join(lines) + "\n" + + +def save_report(directory: Path, result: dict, artifacts=None, redactor=None): + """Exclusive output directory: never overwrite a project, export, or old report.""" + directory.mkdir(parents=True, exist_ok=False) + redactor = redactor or Redactor() + cleaned = redactor.clean(result) + files = {"report.json": json.dumps(cleaned, indent=2, allow_nan=False).encode("utf-8"), + "summary.txt": summary(cleaned).encode("utf-8"), **(artifacts or {})} + manifest = {"schemaVersion": 1, "kind": "openstudio_support_manifest", "files": []} + for name, data in files.items(): + if Path(name).name != name or name in {".", "..", "manifest.json"}: + raise ValueError("Invalid evidence filename") + with (directory / name).open("xb") as stream: + stream.write(data) + manifest["files"].append({"file": name, "bytes": len(data), + "sha256": hashlib.sha256(data).hexdigest(), + "unredactedDump": name.endswith(".dmp")}) + (directory / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") + return cleaned + + +def collection_environment(): + # Deliberately excludes host/user names, process lists, environment variables, + # hardware serial numbers, and misleading historical CPU/RAM attribution. + return {"when": "at_collection_time_only", "os": platform.system(), + "osRelease": platform.release(), "architecture": platform.machine(), + "logicalCPUs": os.cpu_count(), "python": sys.version.split()[0]} diff --git a/tools/openstudio_support/gui.py b/tools/openstudio_support/gui.py new file mode 100644 index 00000000..924b9b84 --- /dev/null +++ b/tools/openstudio_support/gui.py @@ -0,0 +1,193 @@ +"""Optional standalone Tk interface. Never linked to or started by OpenStudio.""" +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime +from pathlib import Path + + +def create_window(root): + import tkinter as tk + from tkinter import filedialog, messagebox, ttk + from .cli import parser, run + + root.title("OpenStudio — Offline support") + root.minsize(720, 620) + root.geometry("820x720") + frame = ttk.Frame(root, padding=18) + frame.pack(fill="both", expand=True) + frame.columnconfigure(1, weight=1) + ttk.Label(frame, text="Offline support", font=("Segoe UI", 17, "bold")).grid(row=0, column=0, columnspan=3, sticky="w") + ttk.Label(frame, text="Close OpenStudio first. Close this support window before reopening the DAW.\nNo uploads, live telemetry, or changes to your media and projects.", wraplength=680).grid(row=1, column=0, columnspan=3, sticky="w", pady=(8, 14)) + choices = {"Collect existing evidence": "collect", "Inspect audio export": "audio", + "Inspect MIDI export": "midi", "Create reviewed ZIP": "bundle"} + operation = tk.StringVar(value=next(iter(choices))) + ttk.Label(frame, text="Operation").grid(row=2, column=0, sticky="w") + combo = ttk.Combobox(frame, textvariable=operation, values=list(choices), state="readonly") + combo.grid(row=2, column=1, columnspan=2, sticky="ew", pady=5) + variables = {key: tk.StringVar() for key in ("input", "context", "output", "description", "clips")} + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + variables["output"].set(str(Path.home() / "OpenStudio-support" / stamp)) + labels, fields, operation_controls = {}, {}, {} + controls = [combo] + for index, (key, label) in enumerate((("input", "Log folder (optional)"), ("context", "Render report (optional)"), + ("output", "New review folder"), ("description", "What happened?"), + ("clips", "MIDI clip IDs (optional)")), start=3): + labels[key] = ttk.Label(frame, text=label) + labels[key].grid(row=index, column=0, sticky="w", padx=(0, 12)) + fields[key] = [labels[key]] + entry = ttk.Entry(frame, textvariable=variables[key]) + entry.grid(row=index, column=1, columnspan=1 if key in {"input", "context", "output"} else 2, sticky="ew", pady=5) + controls.append(entry) + fields[key].append(entry) + if key in {"input", "context", "output"}: + def browse(which=key): + action = choices[operation.get()] + if which == "output": + if action == "bundle": + value = filedialog.asksaveasfilename(title="New support ZIP", defaultextension=".zip") + else: + parent = filedialog.askdirectory(title="Parent folder for a NEW review folder") + value = str(Path(parent) / datetime.now().strftime("support-%Y%m%d-%H%M%S")) if parent else "" + elif which == "input" and action in {"collect", "bundle"}: + value = filedialog.askdirectory(title="Evidence folder") + else: + value = filedialog.askopenfilename(title="Choose existing file") + if value: + variables[which].set(value) + button = ttk.Button(frame, text="Browse…", command=browse) + button.grid(row=index, column=2, padx=(8, 0)) + controls.append(button) + fields[key].append(button) + dumps, reviewed = tk.BooleanVar(), tk.BooleanVar() + for row, variable, label in ((8, dumps, "Include unredacted crash dumps (can contain private memory and credentials)"), + (9, reviewed, "I reviewed every file in the report folder before creating this ZIP")): + control = ttk.Checkbutton(frame, text=label, variable=variable) + control.grid(row=row, column=0, columnspan=3, sticky="w", pady=5) + controls.append(control) + operation_controls[row] = control + ttk.Label(frame, text="Redaction is best effort. Review the report folder before creating a ZIP.\nAttach the ZIP to your support request yourself; nothing is sent automatically.", wraplength=680).grid(row=10, column=0, columnspan=3, sticky="w", pady=8) + display = tk.Text(frame, height=12, wrap="word", state="disabled") + display.grid(row=12, column=0, columnspan=3, sticky="nsew", pady=(12, 0)) + frame.rowconfigure(12, weight=1) + executor = ThreadPoolExecutor(max_workers=1) + state = {"future": None, "lastReview": None, "lastOutput": None} + + def show(text): + display.configure(state="normal") + display.delete("1.0", "end") + display.insert("end", text) + display.configure(state="disabled") + + def selected(_event=None): + action = choices[operation.get()] + labels["input"].configure(text="Log folder (optional)" if action == "collect" else "Review folder" if action == "bundle" else "Exported file") + labels["context"].configure(text="Saved project (optional)" if action == "midi" else "Render report (optional)") + labels["output"].configure(text="New ZIP file" if action == "bundle" else "New review folder") + if action == "bundle" and state["lastReview"]: + variables["input"].set(state["lastReview"]) + variables["output"].set(state["lastReview"] + ".zip") + elif action != "bundle": + variables["output"].set(str(Path.home() / 'OpenStudio-support' / datetime.now().strftime('%Y%m%d-%H%M%S-%f'))) + dumps.set(False) + reviewed.set(False) + for key, widgets in fields.items(): + visible = (key not in {'description', 'clips', 'context'} or + key == 'description' and action == 'collect' or + key == 'clips' and action == 'midi' or + key == 'context' and action != 'bundle') + for widget in widgets: + widget.grid() if visible else widget.grid_remove() + for row, visible in ((8, action == 'collect'), (9, action == 'bundle')): + operation_controls[row].grid() if visible else operation_controls[row].grid_remove() + + def poll(): + future = state["future"] + if not future.done(): + root.after(100, poll) + return + try: + _code, message = future.result() + show(message + "\nOutput: " + variables["output"].get()) + state["lastOutput"] = variables["output"].get() + if choices[operation.get()] != "bundle": + state["lastReview"] = variables["output"].get() + except Exception as exc: + show("Could not complete: " + str(exc)) + state["future"] = None + for control in controls: + control.configure(state="readonly" if control is combo else "normal") + + def start(): + action = choices[operation.get()] + input_value, context = variables["input"].get().strip(), variables["context"].get().strip() + output = variables["output"].get().strip() + if not output or (action != "collect" and not input_value): + messagebox.showerror("Missing path", "Choose the input and a new output location.") + return + args = [action] + if action != "collect": + args.append(input_value) + elif input_value: + args += ["--log-dir", input_value] + if context and action in {"audio", "collect", "midi"}: + args += ["--project" if action == "midi" else "--route-report", context] + if action == "collect": + args += ["--description", variables["description"].get()] + if dumps.get(): + args.append("--include-dumps") + if action == "midi": + for clip in variables["clips"].get().split(","): + if clip.strip(): + args += ["--clip-id", clip.strip()] + if action == "bundle" and reviewed.get(): + args.append("--reviewed") + args += ["--output", output] + parsed = parser().parse_args(args) + for control in controls: + control.configure(state="disabled") + show("Working offline. Keep OpenStudio closed until this operation finishes.") + state["future"] = executor.submit(run, parsed) + root.after(100, poll) + + button = ttk.Button(frame, text="Run selected operation", command=start) + button.grid(row=11, column=0, columnspan=3, sticky="w") + controls.append(button) + def open_output(): + import os + import subprocess + import sys + if not state['lastOutput']: + messagebox.showinfo('No report yet', 'Run an operation first, then open its output folder here.') + return + path = Path(state['lastOutput']).absolute() + if not path.is_dir(): + path = path.parent + try: + if sys.platform == 'win32': + os.startfile(path) + else: + subprocess.Popen(['open' if sys.platform == 'darwin' else 'xdg-open', str(path)]) + except OSError as exc: + messagebox.showerror('Could not open folder', str(exc)) + folder_button = ttk.Button(frame, text='Open output folder', command=open_output) + folder_button.grid(row=11, column=1, columnspan=2, sticky='e') + controls.append(folder_button) + combo.bind("<>", selected) + selected() + + def close(): + if state["future"] is not None: + messagebox.showinfo("Operation running", "Wait for inspection to finish before closing or reopening OpenStudio.") + return + executor.shutdown(wait=True) + root.destroy() + root.protocol("WM_DELETE_WINDOW", close) + return close + + +def launch(): + import tkinter as tk + root = tk.Tk() + create_window(root) + root.mainloop() diff --git a/tools/openstudio_support/midi.py b/tools/openstudio_support/midi.py new file mode 100644 index 00000000..ea6523ee --- /dev/null +++ b/tools/openstudio_support/midi.py @@ -0,0 +1,273 @@ +from __future__ import annotations + +from collections import Counter +import json +import math +import os +from pathlib import Path +import struct +import subprocess + +from .common import check, regular_file, report, sha256 + +MAX_EVENTS = 250000 +KINDS = {0x80: "noteOff", 0x90: "noteOn", 0xA0: "polyPressure", 0xB0: "cc", + 0xC0: "programChange", 0xD0: "channelPressure", 0xE0: "pitchBend"} + + +class Reader: + def __init__(self, data): + self.data, self.position = data, 0 + + def take(self, count): + if count < 0 or self.position + count > len(self.data): + raise ValueError("Truncated MIDI chunk/event") + value = self.data[self.position:self.position + count] + self.position += count + return value + + def vlq(self): + value = 0 + for _ in range(4): + byte = self.take(1)[0] + value = (value << 7) | (byte & 127) + if byte < 128: + return value + raise ValueError("Invalid MIDI variable-length quantity") + + +def parse_midi(data: bytes): + reader = Reader(data) + if reader.take(4) != b"MThd": + raise ValueError("Missing standard MIDI header") + header = reader.take(struct.unpack(">I", reader.take(4))[0]) + if len(header) < 6: + raise ValueError("Short MIDI header") + format_id, track_count, division = struct.unpack(">HHH", header[:6]) + if format_id not in (0, 1) or division & 0x8000: + raise NotImplementedError("This inspector supports synchronous SMF type 0/1 with quarter-note timing; type 2/SMPTE timing is not asserted.") + if division == 0 or track_count == 0 or track_count > 1024 or (format_id == 0 and track_count != 1): + raise ValueError("Invalid MIDI track count or time division") + tracks, count = [], 0 + for _ in range(track_count): + if reader.take(4) != b"MTrk": + raise ValueError("Missing MIDI track chunk") + track = Reader(reader.take(struct.unpack(">I", reader.take(4))[0])) + events, tempos, meters = [], [], [] + running, tick, ended, name = None, 0, False, "" + extra = Counter() + while track.position < len(track.data): + tick += track.vlq() + first = track.take(1)[0] + if first < 128: + if running is None: + raise ValueError("MIDI running status without a preceding channel status") + status, prefix = running, bytes([first]) + else: + status, prefix = first, b"" + count += 1 + if count > MAX_EVENTS: + raise ValueError("MIDI exceeds the 250,000-event inspection limit") + if 0x80 <= status <= 0xEF: + running = status + high = status & 0xF0 + values = prefix + track.take((1 if high in (0xC0, 0xD0) else 2) - len(prefix)) + if any(value > 127 for value in values): + raise ValueError("MIDI channel data byte has its status bit set") + kind, channel = KINDS[high], (status & 15) + 1 + if high == 0x90 and values[1] == 0: + kind = "noteOff" + address = values[0] if high in (0x80, 0x90, 0xA0, 0xB0) else 0 + value = values[0] | (values[1] << 7) if high == 0xE0 else values[-1] + events.append({"tick": tick, "kind": kind, "channel": channel, "address": address, "value": value}) + elif status == 0xFF: + running = None + meta = track.take(1)[0] + payload = track.take(track.vlq()) + if meta == 0x2F: + if payload or track.position != len(track.data): + raise ValueError("Invalid end-of-track event or data after it") + ended = True + break + if meta == 0x51: + if len(payload) != 3 or int.from_bytes(payload, "big") == 0: + raise ValueError("Invalid tempo event") + tempos.append((tick, int.from_bytes(payload, "big"))) + elif meta == 0x58: + if len(payload) != 4 or payload[0] == 0 or payload[1] > 6: + raise ValueError("Invalid time signature event") + meters.append((tick, tuple(payload))) + elif meta == 0x03: + name = payload.decode("utf-8", errors="replace") + else: + extra[f"meta_{meta:02x}"] += 1 + elif status in (0xF0, 0xF7): + running = None + track.take(track.vlq()) + extra["sysex"] += 1 + else: + raise ValueError("Unsupported status in standard MIDI file") + if not ended: + raise ValueError("MIDI track has no end-of-track marker") + tracks.append({"name": name, "events": events, "tempos": tempos, "meters": meters, + "endTick": tick, "otherEvents": dict(extra)}) + if reader.position != len(data): + raise ValueError("Extra data after declared MIDI tracks") + return {"format": format_id, "ticksPerQuarterNote": division, "tracks": tracks} + + +def prepare_project(path: Path, clip_ids=()): + import sys + if getattr(sys, 'frozen', False): + from .packaged import prepare_project as prepare_packaged + return prepare_packaged(path, clip_ids) + regular_file(path) + script = Path(__file__).resolve().parents[1] / "prepare-support-midi.mjs" + flags = {"creationflags": subprocess.CREATE_NO_WINDOW} if os.name == "nt" else {} + result = subprocess.run(["node", str(script), str(path.resolve()), json.dumps(list(clip_ids))], + capture_output=True, text=True, encoding="utf-8", errors="replace", + timeout=60, **flags) + if result.returncode: + raise ValueError("Could not prepare this project with the checkout's MIDI serializer. " + result.stderr[-2000:]) + if len(result.stdout) > 32 * 1024 * 1024: + raise ValueError("Prepared MIDI exceeds 32 MiB limit") + return json.loads(result.stdout) + + +def expected_tracks(payload, ppq): + timing = payload["timing"] + markers = [(0.0, timing["tempo"])] + [(item["time"], item["bpm"]) for item in timing["tempoMarkers"]] + markers.sort(key=lambda marker: marker[0]) + if any(not math.isfinite(float(t)) or not math.isfinite(float(bpm)) or t < 0 or bpm <= 0 for t, bpm in markers): + raise ValueError("Invalid expected tempo map") + markers = [(time, math.floor(60000000 / bpm + .5)) for time, bpm in markers] + + def ticks(seconds): + total, previous, tempo = 0.0, 0.0, markers[0][1] + for time, next_tempo in markers: + if time > seconds: + break + total += (time - previous) * 1000000 / tempo * ppq + previous, tempo = time, next_tempo + return max(0, total + (seconds - previous) * 1000000 / tempo * ppq) + + def integer(event, field, default, low=0, high=127): + value = event.get(field, default) + if value is None: + value = default + if field in {"velocity", "releaseVelocity"} and 0 < value <= 1: + value *= 127 + return min(high, max(low, math.floor(value + .5))) + + expected = [] + unsupported = list(payload.get("unsupported", [])) + for track in payload["tracks"]: + events = [] + for clip in track["clips"]: + for item in clip["events"]: + kind = item["type"] + if kind not in KINDS.values(): + unsupported.append("Unsupported source event: " + kind) + continue + address = integer(item, "note", 60) if kind in {"noteOn", "noteOff", "polyPressure"} else integer(item, "controller", 0) if kind == "cc" else 0 + if kind == "noteOn": + value = integer(item, "velocity", 100, 1) + elif kind == "noteOff": + value = integer(item, "releaseVelocity" if "releaseVelocity" in item else "velocity", 0) + else: + value = integer(item, "value", 8192 if kind == "pitchBend" else 0, 0, 16383 if kind == "pitchBend" else 127) + events.append({"tick": ticks(clip["startTime"] + item["timestamp"]), "kind": kind, + "channel": integer(item, "channel", 1, 1, 16), "address": address, "value": value}) + events.sort(key=lambda event: event["tick"]) + if events: + expected.append({"name": track["name"], "events": events}) + signature = timing["timeSignature"] + denominator = signature["denominator"] + if denominator < 1 or denominator > 64 or denominator & (denominator - 1): + raise ValueError("Unsupported expected time signature") + return expected, [(ticks(t), tempo) for t, tempo in markers], (signature["numerator"], int(math.log2(denominator)), 24, 8), unsupported + + +def compare_expected(parsed, payload, result): + check_start = len(result["checks"]) + expected, tempos, meter, unsupported = expected_tracks(payload, parsed["ticksPerQuarterNote"]) + actual = [track for track in parsed["tracks"] if track["events"]] + result["comparison"] = {"scope": payload["scope"], "sourceProjectSha256": payload["sourceProjectSha256"], + "rangeStart": payload["rangeStart"], "unsupported": unsupported, + "preparationSources": payload.get("preparationSources", {}), + "claim": "Matches the supplied saved snapshot and bundled/source serializer only; original export-time state is not established."} + check(result, "source_representation", "not_asserted" if unsupported else "pass", + "Unsupported source properties prevent a complete comparison." if unsupported else "All prepared channel event types are supported.") + check(result, "musical_track_count", "pass" if len(expected) == len(actual) else "fail", + "Compared tracks containing channel events; conductor-only tracks are counted separately.", expected=len(expected), actual=len(actual)) + failures = [] + total_mismatches = 0 + for track_index, (wanted, found) in enumerate(zip(expected, actual)): + # Preserve order, including controllers at the same tick; counts alone are insufficient. + mismatch = abs(len(wanted["events"]) - len(found["events"])) + for index, (a, b) in enumerate(zip(wanted["events"], found["events"])): + if any(a[key] != b[key] for key in ("kind", "channel", "address", "value")) or abs(a["tick"] - b["tick"]) > 1.000001: + mismatch += 1 + if len(failures) < 20: + failures.append({"track": track_index + 1, "event": index, "expected": a, "actual": b}) + total_mismatches += mismatch + check(result, f"track_{track_index + 1}_events", "fail" if mismatch else "pass", + "Compared ordered channel events, values, and ticks (one-tick tolerance).", mismatches=mismatch, + expectedEvents=len(wanted["events"]), actualEvents=len(found["events"])) + check(result, f"track_{track_index + 1}_name", "pass" if wanted["name"] == found["name"] else "fail", "Compared track name with the selected project snapshot.") + actual_tempos = sorted(set(item for track in parsed["tracks"] for item in track["tempos"])) + tempo_ok = len(actual_tempos) == len(tempos) and all(abs(a[0] - b[0]) <= 1.000001 and a[1] == b[1] for a, b in zip(tempos, actual_tempos)) + check(result, "tempo_map", "pass" if tempo_ok else "fail", "Compared encoded tempos and their tick positions.") + actual_meters = sorted(set(item for track in parsed["tracks"] for item in track["meters"])) + check(result, "meter", "pass" if actual_meters == [(0, meter)] else "fail", "Compared time signature metadata, including click and notation fields.") + result["comparison"].update(mismatches=total_mismatches, examples=failures) + statuses = {item["status"] for item in result["checks"][check_start:]} + result["comparison"]["status"] = "fail" if "fail" in statuses else "not_asserted" if "not_asserted" in statuses else "pass" + + +def inspect_midi(path: Path, project_path: Path | None = None, clip_ids=()): + result = report("midi_export") + if regular_file(path).st_size > 32 * 1024 * 1024: + raise ValueError("MIDI input exceeds 32 MiB limit") + data = path.read_bytes() + import hashlib + result["input"] = {"path": str(path.absolute()), "bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()} + result["limitations"] += ["A MIDI file contains musical instructions, not the instrument's audio or plugin state.", + "Unmatched note messages are diagnostic only: sustain, all-notes-off, or external state can affect interpretation."] + try: + parsed = parse_midi(data) + except NotImplementedError as exc: + check(result, "midi_structure", "not_asserted", str(exc)) + return result + except ValueError as exc: + check(result, "midi_structure", "fail", str(exc)) + return result + check(result, "midi_structure", "pass", "Parsed all declared chunks, channel/meta events, and end-of-track markers.") + result["midi"] = {"format": parsed["format"], "ticksPerQuarterNote": parsed["ticksPerQuarterNote"], "tracks": []} + for track in parsed["tracks"]: + active = Counter() + unmatched = 0 + for event in track["events"]: + key = (event["channel"], event["address"]) + if event["kind"] == "noteOn": + active[key] += 1 + elif event["kind"] == "noteOff": + if active[key]: + active[key] -= 1 + else: + unmatched += 1 + result["midi"]["tracks"].append({"name": track["name"], "eventCounts": dict(Counter(e["kind"] for e in track["events"])), + "endTick": track["endTick"], "tempos": track["tempos"], "timeSignatures": track["meters"], + "otherEvents": track["otherEvents"], "notePairing": {"classification": "diagnostic_only", + "unmatchedNoteOffs": unmatched, "notesWithoutExplicitOff": sum(active.values())}}) + if project_path: + try: + payload = prepare_project(project_path, clip_ids) + if payload["sourceProjectSha256"] != sha256(project_path): + raise ValueError("Project changed during comparison") + compare_expected(parsed, payload, result) + except (OSError, ValueError, KeyError, TypeError, subprocess.SubprocessError) as exc: + check(result, "project_comparison", "not_asserted", str(exc)) + else: + check(result, "project_comparison", "not_asserted", "No saved project supplied; structural validity does not establish event completeness.") + return result diff --git a/tools/openstudio_support/packaged.py b/tools/openstudio_support/packaged.py new file mode 100644 index 00000000..47bbfe92 --- /dev/null +++ b/tools/openstudio_support/packaged.py @@ -0,0 +1,36 @@ +"""Resources exist only inside the explicitly launched, standalone executable.""" +from pathlib import Path +import hashlib +import json +import sys + + +def resource_root(): + return Path(getattr(sys, '_MEIPASS', Path(__file__).resolve().parents[1])) + + +def prepare_project(path, clip_ids=(), resources=None): + import quickjs + from .common import JSON_LIMIT, regular_file, read_json + if regular_file(path).st_size > JSON_LIMIT: + raise ValueError('Project exceeds 32 MiB limit') + data = path.read_bytes() + root = resources or resource_root() + # No filesystem/network callbacks are exposed to JS. Bound untrusted project work. + context = quickjs.Context() + context.set_memory_limit(256 * 1024 * 1024) + context.set_time_limit(30) + try: + context.eval((root / 'support-midi.js').read_text(encoding='utf-8')) + prepare = context.eval('SupportMIDI.prepare') + encoded = prepare(data.decode('utf-8-sig'), json.dumps(list(clip_ids))) + if len(encoded) > JSON_LIMIT: + raise ValueError('Prepared MIDI exceeds 32 MiB limit') + result = json.loads(encoded) + except quickjs.JSException as exc: + raise ValueError('Could not compare this saved project: ' + str(exc)) from exc + result['sourceProjectSha256'] = hashlib.sha256(data).hexdigest() + # PyInstaller cross-links its own macOS Resources/Frameworks directories. + # Resolve only this trusted bundled resource; user inputs retain no-symlink checks. + result['preparationSources'] = read_json((root / 'support-midi-sources.json').resolve(strict=True)) + return result diff --git a/tools/openstudio_support/selftest.py b/tools/openstudio_support/selftest.py new file mode 100644 index 00000000..161f503b --- /dev/null +++ b/tools/openstudio_support/selftest.py @@ -0,0 +1,44 @@ +"""Explicit package qualification only. Never invoked on ordinary GUI startup.""" +import json +from pathlib import Path +import tempfile + + +def run_selftest(output): + from .common import require_closed + try: + require_closed() + import tkinter as tk + import numpy as np + import soundfile as sf + from .gui import create_window + from .packaged import prepare_project + root = tk.Tk() + root.withdraw() + close = create_window(root) + root.update_idletasks() + close() + with tempfile.TemporaryDirectory(prefix='support-self-test-') as directory: + path = Path(directory) + sf.write(path / 'silence.wav', np.zeros((16, 2)), 48000, subtype='PCM_16') + assert sf.info(path / 'silence.wav').frames == 16 + project = path / 'test.osproj' + project.write_text(json.dumps({'version': '1.2.0', 'tempo': 120, 'tracks': []})) + assert prepare_project(project)['tracks'] == [] + result = {'status': 'pass', 'gui': 'constructed_hidden', 'audioDecoder': 'pass', + 'embeddedMIDISerializer': 'pass', 'closedAppGuard': 'pass'} + import sys + if sys.platform == 'win32': + import ctypes + length = ctypes.c_uint32(0) + api = ctypes.windll.kernel32.GetCurrentPackageFullName + if api(ctypes.byref(length), None) == 122: + name = ctypes.create_unicode_buffer(length.value) + if api(ctypes.byref(length), name) == 0: + result['packageIdentity'] = name.value + except Exception as exc: + import traceback + result = {'status': 'fail', 'error': str(exc), 'traceback': traceback.format_exc()} + with output.open('x', encoding='utf-8') as stream: + json.dump(result, stream, indent=2) + return 0 if result['status'] == 'pass' else 1 diff --git a/tools/package-linux-native.py b/tools/package-linux-native.py index 0d8a3b6b..670847e4 100644 --- a/tools/package-linux-native.py +++ b/tools/package-linux-native.py @@ -9,6 +9,8 @@ import importlib.util import os from pathlib import Path +from support_package import validate as validate_support +from release_build_policy import validate as validate_release_build_policy import re import shutil import subprocess @@ -63,10 +65,12 @@ def validate_bundle(bundle, version): for path in bundle.rglob('*'): if path.is_symlink() and not path.resolve().is_relative_to(bundle.resolve()): raise ValueError(f'Runtime symlink escapes the bundle: {path}') - if path.is_file() and '.so' in path.name and not path.name.startswith('libonnxruntime.so'): + if path.is_file() and '.so' in path.name and not path.name.startswith('libonnxruntime.so') and not path.is_relative_to(bundle / 'support-runtime'): raise ValueError(f'Unexpected bundled shared library: {path}') if (bundle / 'python').exists() or (bundle / 'ffmpeg').exists(): raise ValueError('Native Linux packages use system FFmpeg and optional AI runtimes') + validate_support(bundle, version, platform='linux') + validate_release_build_policy(bundle, version) def stage_bundle(bundle, stage, package_format): @@ -85,8 +89,10 @@ def stage_bundle(bundle, stage, package_format): bin_dir = stage / 'usr/bin' bin_dir.mkdir(parents=True) (bin_dir / 'OpenStudio').symlink_to('../lib/openstudio/OpenStudio') + (bin_dir / 'OpenStudioSupport').symlink_to('../lib/openstudio/OpenStudioSupport') for source, relative in ( (ROOT / 'tools/OpenStudio.desktop', 'usr/share/applications/OpenStudio.desktop'), + (ROOT / 'packaging/linux/OpenStudioSupport.desktop', 'usr/share/applications/OpenStudioSupport.desktop'), (ROOT / 'assets/icon-256x256.png', 'usr/share/icons/hicolor/256x256/apps/OpenStudio.png'), (ROOT / 'packaging/linux/openstudio-mime.xml', 'usr/share/mime/packages/openstudio.xml'), (ROOT / 'packaging/linux/in.org.openstudio.OpenStudio.metainfo.xml', 'usr/share/metainfo/in.org.openstudio.OpenStudio.metainfo.xml'), @@ -134,7 +140,7 @@ def deb_dependencies(stage, work): 'libx11-6', 'libxext6', 'libxrandr2', 'libxinerama1', 'libxcursor1', 'libxcomposite1', 'libxrender1', 'libfreetype6', 'libfontconfig1', 'libcurl4t64 | libcurl4', 'ffmpeg', 'libsecret-tools', - 'shared-mime-info', 'desktop-file-utils', + 'shared-mime-info', 'desktop-file-utils', 'libxft2', 'libxss1', )) @@ -188,7 +194,7 @@ def build_rpm(stage, work, output, version, distro_version, revision=1): License: AGPL-3.0-only URL: https://openstudio.org.in BuildArch: x86_64 -Requires: webkit2gtk4.1 >= 2.40, gtk3, alsa-lib +Requires: webkit2gtk4.1 >= 2.40, gtk3, alsa-lib, libXft, libXScrnSaver Requires: libjack.so.0()(64bit), libX11, libXext, libXrandr, libXinerama, libXcursor, libXcomposite, libXrender Requires: fontconfig, freetype, libcurl.so.4()(64bit), ffmpeg-free, /usr/bin/secret-tool, shared-mime-info, desktop-file-utils @@ -201,15 +207,30 @@ def build_rpm(stage, work, output, version, distro_version, revision=1): %files /usr/bin/OpenStudio +/usr/bin/OpenStudioSupport /usr/lib/openstudio /usr/share/applications/OpenStudio.desktop +/usr/share/applications/OpenStudioSupport.desktop /usr/share/icons/hicolor/256x256/apps/OpenStudio.png /usr/share/mime/packages/openstudio.xml /usr/share/metainfo/in.org.openstudio.OpenStudio.metainfo.xml %license /usr/share/doc/openstudio/copyright ''') + # The frozen runtime is already built: RPM's strip pass can corrupt vendored + # extension libraries. Preserve this private payload; keep dependency scanning + # and the normal stripping of the DAW and its other libraries enabled. + strip_wrapper = work / 'strip-preserving-support.sh' + strip_wrapper.write_text('''#!/bin/sh +for arg in "$@"; do + case "$arg" in + */usr/lib/openstudio/OpenStudioSupport|*/usr/lib/openstudio/support-runtime/*) exit 0 ;; + esac +done +exec /usr/bin/strip "$@" +''') + strip_wrapper.chmod(0o755) run(['rpmbuild', '-bb', '--define', f'_topdir {work / "rpm"}', - '--define', '_build_id_links none', spec]) + '--define', f'__strip {strip_wrapper}', '--define', '_build_id_links none', spec]) packages = list((work / 'rpm/RPMS/x86_64').glob('openstudio-*.rpm')) if len(packages) != 1: raise ValueError('Expected exactly one RPM output') diff --git a/tools/package-linux-release.sh b/tools/package-linux-release.sh index b543f81b..0641ad72 100644 --- a/tools/package-linux-release.sh +++ b/tools/package-linux-release.sh @@ -25,6 +25,7 @@ python3 - "$ROOT_DIR/$BUILD_DIR" "$SCRIPT_DIR/package-linux-native.py" <<'PYCODE import importlib.util import sys from pathlib import Path +sys.path.insert(0, str(Path(sys.argv[2]).parent)) spec = importlib.util.spec_from_file_location("native_packaging", sys.argv[2]) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) @@ -66,10 +67,14 @@ chmod +x "$APPDIR/usr/bin/OpenStudio" # Copy all runtime assets that sit beside the binary (models/, scripts/, ffmpeg, webui/, etc.) RELEASE_ASSET_DIR="$(dirname "$BINARY")" +python3 "$TOOLS_DIR/validate-support-tool.py" --bundle "$RELEASE_ASSET_DIR" --version "$VERSION" +python3 "$TOOLS_DIR/release_build_policy.py" --bundle "$RELEASE_ASSET_DIR" --version "$VERSION" for item in "$RELEASE_ASSET_DIR"/*/; do + [ "$(basename "$item")" != "support-runtime" ] || continue [ -d "$item" ] && cp -r "$item" "$APPDIR/usr/bin/" done for item in "$RELEASE_ASSET_DIR"/*; do + [ "$(basename "$item")" != "OpenStudioSupport" ] || continue [ -f "$item" ] && [ "$(basename "$item")" != "OpenStudio" ] && cp "$item" "$APPDIR/usr/bin/" done @@ -153,3 +158,8 @@ else echo "WARNING: Could not locate built AppImage. Check linuxdeploy output above." exit 1 fi + +# A separate AppImage keeps support unreachable from ordinary DAW launch/recording. +python3 "$TOOLS_DIR/package-support-appimage.py" --bundle "$RELEASE_ASSET_DIR" --output "$OUT_DIR" --version "$VERSION" +tar -czf "$OUT_DIR/OpenStudio-${VERSION}-linux-x86_64-with-support.tar.gz" -C "$OUT_DIR" \ + "OpenStudio-${VERSION}-linux-x86_64.AppImage" "OpenStudioSupport-${VERSION}-linux-x86_64.AppImage" diff --git a/tools/package-macos-release.sh b/tools/package-macos-release.sh index 69fac1b9..dc4a378f 100644 --- a/tools/package-macos-release.sh +++ b/tools/package-macos-release.sh @@ -66,12 +66,25 @@ if [[ ! -x "$STAGED_APP/Contents/Helpers/OpenStudioUpdateInstaller" ]]; then echo "The app is missing its executable update helper. Rebuild before packaging." >&2 exit 1 fi +python3 "$SCRIPT_DIR/validate-support-tool.py" --bundle "$STAGED_APP" --version "$VERSION" +python3 "$SCRIPT_DIR/release_build_policy.py" --bundle "$STAGED_APP" --version "$VERSION" +SUPPORT_SHORTCUT="$(dirname "$APP_PATH")/OpenStudio Support.app" +if [[ ! -d "$SUPPORT_SHORTCUT" ]]; then + echo "Missing separate Finder support launcher; assemble/stage the support package first." >&2 + exit 1 +fi +ditto "$SUPPORT_SHORTCUT" "$STAGING_DIR/OpenStudio Support.app" +lipo "$STAGED_APP/Contents/Helpers/OpenStudioSupport.app/Contents/MacOS/OpenStudioSupport" -verify_arch arm64 x86_64 ln -s /Applications "$STAGING_DIR/Applications" if [[ -n "${MACOS_CODESIGN_IDENTITY:-}" ]]; then # Sign the outer app explicitly. Do not use --deep while signing: any future # nested code must be signed deliberately in its own designated-code slot, # and the strict deep verification below will fail closed if it is missed. + SUPPORT_SIGN_OPTIONS=() + [[ "$SIGNING_KIND" != "self-signed" ]] || SUPPORT_SIGN_OPTIONS+=(--no-timestamp) + python3 "$SCRIPT_DIR/sign-macos-support.py" "$STAGED_APP/Contents/Helpers/OpenStudioSupport.app" --identity "$MACOS_CODESIGN_IDENTITY" "${SUPPORT_SIGN_OPTIONS[@]}" + python3 "$SCRIPT_DIR/sign-macos-support.py" "$STAGING_DIR/OpenStudio Support.app" --identity "$MACOS_CODESIGN_IDENTITY" "${SUPPORT_SIGN_OPTIONS[@]}" codesign --force "$TIMESTAMP_OPTION" --options runtime --sign "$MACOS_CODESIGN_IDENTITY" "$STAGED_APP/Contents/Helpers/OpenStudioUpdateInstaller" if [[ -f "$ENTITLEMENTS_PATH" ]]; then codesign --force "$TIMESTAMP_OPTION" --options runtime --entitlements "$ENTITLEMENTS_PATH" --sign "$MACOS_CODESIGN_IDENTITY" "$STAGED_APP" diff --git a/tools/package-support-appimage.py b/tools/package-support-appimage.py new file mode 100644 index 00000000..b959c9d9 --- /dev/null +++ b/tools/package-support-appimage.py @@ -0,0 +1,52 @@ +"""Build the companion AppImage. It never wraps or intercepts the DAW launcher.""" +import argparse +import hashlib +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import urllib.request +from support_package import validate + +ROOT = Path(__file__).resolve().parents[1] +DEPLOY_VERSION = '1-alpha-20251107-1' +DEPLOY_HASH = 'c20cd71e3a4e3b80c3483cef793cda3f4e990aca14014d23c544ca3ce1270b4d' + + +def package(bundle, output, version): + validate(bundle, version, 'linux') + output.mkdir(parents=True, exist_ok=True) + target = output / f'OpenStudioSupport-{version}-linux-x86_64.AppImage' + if target.exists(): + raise ValueError('Support AppImage output already exists') + with tempfile.TemporaryDirectory(prefix='support-appimage-') as directory: + work = Path(directory) + deploy = work / 'linuxdeploy.AppImage' + urllib.request.urlretrieve(f'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/linuxdeploy/linuxdeploy/releases/download/{DEPLOY_VERSION}/linuxdeploy-x86_64.AppImage', deploy) + if hashlib.sha256(deploy.read_bytes()).hexdigest() != DEPLOY_HASH: + raise ValueError('linuxdeploy checksum mismatch') + deploy.chmod(0o755) + app = work / 'OpenStudioSupport.AppDir' + binary_dir = app / 'usr/bin' + binary_dir.mkdir(parents=True) + shutil.copy2(bundle / 'OpenStudioSupport', binary_dir) + shutil.copytree(bundle / 'support-runtime', binary_dir / 'support-runtime', symlinks=True) + desktop = app / 'OpenStudioSupport.desktop' + desktop.write_text((ROOT / 'packaging/linux/OpenStudioSupport.desktop').read_text(), newline='\n') + shutil.copyfile(ROOT / 'assets/icon-256x256.png', app / 'OpenStudio.png') + env = dict(os.environ, APPIMAGE_EXTRACT_AND_RUN='1', OUTPUT='support.AppImage') + subprocess.run([str(deploy), '--appdir', str(app), '--executable', str(binary_dir / 'OpenStudioSupport'), + '--desktop-file', str(desktop), '--icon-file', str(app / 'OpenStudio.png'), + '--exclude-library', '*', '--output', 'appimage'], cwd=work, env=env, check=True) + shutil.copy2(work / 'support.AppImage', target) + return target + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--bundle', type=Path, required=True) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--version', required=True) + args = parser.parse_args() + print(package(args.bundle.resolve(), args.output.resolve(), args.version)) diff --git a/tools/package-windows-release.ps1 b/tools/package-windows-release.ps1 index ad8fc83b..ba6bf522 100644 --- a/tools/package-windows-release.ps1 +++ b/tools/package-windows-release.ps1 @@ -175,6 +175,11 @@ if ($RequireSignedPayload) { Assert-OpenStudioSigningFiles -Directory $resolvedSourceDir -Version $Version -RequireSignature } +& python (Join-Path $PSScriptRoot 'validate-support-tool.py') --bundle $resolvedSourceDir --version $Version +if ($LASTEXITCODE -ne 0) { throw 'Offline support package failed validation.' } +& python (Join-Path $PSScriptRoot 'release_build_policy.py') --bundle $resolvedSourceDir --version $Version +if ($LASTEXITCODE -ne 0) { throw 'Production callback instrumentation policy failed validation.' } + if ((-not (Test-Path $webView2Bootstrapper)) -or (-not (Test-Path $vcRedistInstaller))) { Write-Host "Windows prerequisite installers were not found. Fetching them now..." & (Join-Path $repoRoot "tools/setup-windows-prereqs.ps1") diff --git a/tools/package-windows-store.ps1 b/tools/package-windows-store.ps1 index b5709db1..99608b97 100644 --- a/tools/package-windows-store.ps1 +++ b/tools/package-windows-store.ps1 @@ -34,7 +34,7 @@ foreach ($part in @($packageVersion.Major, $packageVersion.Minor, $packageVersio } $source = Resolve-RepoPath $SourceDir $output = Resolve-RepoPath $OutputDir -foreach ($file in @('OpenStudio.exe', 'OpenStudioCrashReporter.exe', 'ffmpeg.exe', 'onnxruntime.dll', +foreach ($file in @('OpenStudio.exe', 'OpenStudioCrashReporter.exe', 'OpenStudioSupport.exe', 'ffmpeg.exe', 'onnxruntime.dll', 'OpenStudio.version', 'webui/index.html', 'LICENSE', 'THIRD_PARTY_LICENSES.md')) { if (-not (Test-Path -LiteralPath (Join-Path $source $file) -PathType Leaf)) { throw "Missing package input: $file" } } @@ -50,12 +50,16 @@ if ([string]::IsNullOrWhiteSpace($NotesFile)) { } & python (Join-Path $PSScriptRoot 'validate-release-notes.py') --version $applicationVersion --notes-file $NotesFile if ($LASTEXITCODE -ne 0) { throw 'Release notes failed validation; packaging stopped.' } -foreach ($name in @('OpenStudio.exe', 'OpenStudioCrashReporter.exe')) { +foreach ($name in @('OpenStudio.exe', 'OpenStudioCrashReporter.exe', 'OpenStudioSupport.exe')) { $info = [Diagnostics.FileVersionInfo]::GetVersionInfo((Join-Path $source $name)) if ($info.IsDebug -or $info.ProductName.TrimEnd() -cne 'OpenStudio' -or [version]$info.ProductVersion.TrimEnd() -ne $appVersion) { throw "Expected matching OpenStudio Release metadata: $name" } } +& python (Join-Path $PSScriptRoot 'validate-support-tool.py') --bundle $source --version $applicationVersion +if ($LASTEXITCODE -ne 0) { throw 'Offline support package failed validation.' } +& python (Join-Path $PSScriptRoot 'release_build_policy.py') --bundle $source --version $applicationVersion +if ($LASTEXITCODE -ne 0) { throw 'Production callback instrumentation policy failed validation.' } if (!$SdkBinDir) { $sdkRoot = "${env:ProgramFiles(x86)}/Windows Kits/10/bin" $SdkBinDir = Get-ChildItem -LiteralPath $sdkRoot -Directory | @@ -101,7 +105,7 @@ if ([Diagnostics.FileVersionInfo]::GetVersionInfo($runtimeExe[0].FullName).Produ Copy-Item -LiteralPath $runtimeExe[0].DirectoryName -Destination (Join-Path $stage 'WebView2Runtime') -Recurse # A deliberate allowlist excludes PDBs, prerequisite installers, debug and AI # download caches. Optional AI runtimes are installed into writable user storage. -foreach ($file in @('OpenStudio.exe', 'OpenStudioCrashReporter.exe', 'ffmpeg.exe', 'OpenStudio.version', 'LICENSE', 'THIRD_PARTY_LICENSES.md')) { +foreach ($file in @('OpenStudio.exe', 'OpenStudioCrashReporter.exe', 'OpenStudioSupport.exe', 'ffmpeg.exe', 'OpenStudio.version', 'LICENSE', 'THIRD_PARTY_LICENSES.md')) { Copy-Item -LiteralPath (Join-Path $source $file) -Destination $stage } foreach ($file in Get-ChildItem -LiteralPath $source -Filter '*.dll' -File) { diff --git a/tools/prepare-support-midi.mjs b/tools/prepare-support-midi.mjs new file mode 100644 index 00000000..6a28fc50 --- /dev/null +++ b/tools/prepare-support-midi.mjs @@ -0,0 +1,39 @@ +// Offline subprocess only. Loads the actual frontend serializer without opening +// a browser, listening on a port, loading plugins, or writing the project. +import fs from "node:fs"; +import path from "node:path"; +import crypto from "node:crypto"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const [projectPath, selectionJSON = "[]"] = process.argv.slice(2); +if (!projectPath) throw new Error("A saved .osproj snapshot is required"); +if (fs.statSync(projectPath).size > 32 * 1024 * 1024) throw new Error("Project exceeds 32 MiB limit"); +const bytes = fs.readFileSync(projectPath); +const hash = value => crypto.createHash("sha256").update(value).digest("hex"); +const sourceHash = hash(bytes); +const selected = JSON.parse(selectionJSON); +if (!Array.isArray(selected) || !selected.every(id => typeof id === "string")) throw new Error("Invalid clip selection"); +// Imported application modules can log their development environment. Keep stdout +// a single machine-readable payload; do not collect those messages as evidence. +console.log = () => {}; +const { createServer } = await import(pathToFileURL(path.join(root, "frontend/node_modules/vite/dist/node/index.js")).href); +const server = await createServer({ configFile: false, root: path.join(root, "frontend"), + logLevel: "silent", server: { middlewareMode: true, hmr: false, watch: null } }); +try { + const { parseValidatedProject } = await server.ssrLoadModule("/src/utils/projectValidation.ts"); + const { serializeMIDIClipsForBackend, getMIDIClipSourceLoopLength } = await server.ssrLoadModule("/src/utils/midiClipSerialization.ts"); + const { midiExportOptions } = await server.ssrLoadModule("/src/utils/midiExportOptions.ts"); + const { prepareMIDI } = await import("./support-midi-core.mjs"); + const prepared = prepareMIDI(bytes.toString("utf8"), selected, { parseValidatedProject, + serializeMIDIClipsForBackend, getMIDIClipSourceLoopLength, midiExportOptions }); + if (hash(fs.readFileSync(projectPath)) !== sourceHash) throw new Error("Project changed while preparing comparison"); + const payload = { ...prepared, sourceProjectSha256: sourceHash, + preparationSources: Object.fromEntries(["midiClipSerialization.ts", "midiNotes.ts", "midiExportOptions.ts", "projectValidation.ts"] + .map(file => [file, hash(fs.readFileSync(path.join(root, "frontend/src/utils", file)))])) }; + const encoded = JSON.stringify(payload); + if (encoded.length > 32 * 1024 * 1024) throw new Error("Prepared MIDI exceeds 32 MiB limit"); + process.stdout.write(encoded); +} finally { + await server.close(); +} diff --git a/tools/release_build_policy.py b/tools/release_build_policy.py new file mode 100644 index 00000000..9de5f8c8 --- /dev/null +++ b/tools/release_build_policy.py @@ -0,0 +1,22 @@ +"""Reject missing, mismatched or instrumented application release payloads.""" +import argparse +import json +from pathlib import Path + + +def validate(bundle: Path, version: str) -> None: + root = Path(bundle) + if root.suffix == '.app': + root = root / 'Contents/Resources' + policy = json.loads((root / 'OpenStudio.build-policy.json').read_text(encoding='utf-8')) + if policy.get('version') != version or policy.get('callbackDiagnostics') is not False: + raise ValueError('Release packaging requires matching application version and callback diagnostics compiled out') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--bundle', type=Path, required=True) + parser.add_argument('--version', required=True) + args = parser.parse_args() + validate(args.bundle, args.version) + print('Production callback instrumentation policy verified') diff --git a/tools/sign-macos-support.py b/tools/sign-macos-support.py new file mode 100644 index 00000000..64a61c23 --- /dev/null +++ b/tools/sign-macos-support.py @@ -0,0 +1,30 @@ +"""Sign nested support code explicitly from the inside out, never with --deep.""" +import argparse +from pathlib import Path +import subprocess + +MACHO = {b'\xcf\xfa\xed\xfe', b'\xfe\xed\xfa\xcf', b'\xce\xfa\xed\xfe', + b'\xca\xfe\xba\xbe', b'\xbe\xba\xfe\xca', b'\xca\xfe\xba\xbf'} + + +def sign(bundle, identity, timestamp): + files = [] + for path in bundle.rglob('*'): + if path.is_file() and not path.is_symlink(): + with path.open('rb') as stream: + if stream.read(4) in MACHO: + files.append(path) + bundles = [p for p in bundle.rglob('*') if p.suffix in ('.app', '.framework') and p.is_dir() and not p.is_symlink()] + for path in files + sorted(bundles, key=lambda p: len(p.parts), reverse=True) + [bundle]: + subprocess.run(['codesign', '--force', '--timestamp' if timestamp else '--timestamp=none', + '--options', 'runtime', '--sign', identity, str(path)], check=True) + subprocess.run(['codesign', '--verify', '--deep', '--strict', str(bundle)], check=True) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('bundle', type=Path) + parser.add_argument('--identity', required=True) + parser.add_argument('--no-timestamp', action='store_true') + args = parser.parse_args() + sign(args.bundle.resolve(), args.identity, not args.no_timestamp) diff --git a/tools/support-build-requirements.txt b/tools/support-build-requirements.txt new file mode 100644 index 00000000..e94e7dac --- /dev/null +++ b/tools/support-build-requirements.txt @@ -0,0 +1,6 @@ +# Build a separate executable; none of these are DAW runtime dependencies. +pyinstaller==6.16.0 +pyinstaller-hooks-contrib==2026.9 +numpy==2.2.6 +soundfile==0.13.1 +quickjs-ng==0.17.0.2 diff --git a/tools/support-midi-core.mjs b/tools/support-midi-core.mjs new file mode 100644 index 00000000..6c8db0ee --- /dev/null +++ b/tools/support-midi-core.mjs @@ -0,0 +1,39 @@ +// Pure offline preparation shared by the source runner and packaged support tool. +// Serializer implementations are supplied from the app's actual source at build time. +export function prepareMIDI(projectText, selected, { parseValidatedProject, + serializeMIDIClipsForBackend, getMIDIClipSourceLoopLength, midiExportOptions }) { + if (!Array.isArray(selected) || !selected.every(id => typeof id === "string")) throw new Error("Invalid clip selection"); + const project = parseValidatedProject(projectText.replace(/^\uFEFF/, "")); + const tracks = project.tracks.filter(track => (selected.length || track.type === "midi" || track.type === "instrument") && track.midiClips?.length); + const clips = tracks.flatMap(track => track.midiClips).filter(clip => !selected.length || selected.includes(clip.id)); + if (selected.some(id => !clips.some(clip => clip.id === id))) throw new Error("Selected MIDI clip is absent from this snapshot"); + const start = selected.length && clips.length ? Math.min(...clips.map(clip => clip.startTime)) : 0; + const end = selected.length && clips.length ? Math.max(...clips.map(clip => clip.startTime + clip.duration)) : Infinity; + const unsupported = []; + let bound = 0; + for (const track of tracks) for (const effect of track.midiEffects ?? []) + if (effect.enabled !== false && !["pitch", "velocity", "time", "arpeggiator"].includes(effect.type)) + unsupported.push(`Unsupported MIDI effect: ${effect.type}`); + for (const track of tracks) for (const clip of track.midiClips) { + if (!clips.includes(clip)) continue; + if (clip.notes?.length) throw new Error("Legacy note-array projects need migration in OpenStudio before comparison"); + const loops = clip.loopEnabled === false ? 1 : Math.ceil(clip.duration / Math.max(.01, getMIDIClipSourceLoopLength(clip))) + 1; + bound += loops * ((clip.events?.length ?? 0) + (clip.ccEvents?.length ?? 0) + 1); + for (const effect of track.midiEffects ?? []) if (effect.enabled !== false && effect.type === "arpeggiator") + bound += Math.ceil(clip.duration / Math.max(.01, effect.rateSeconds ?? .125)) * 256; + if (bound > 250000) throw new Error("Expanded MIDI exceeds the offline comparison work limit"); + for (const event of clip.events ?? []) { + if (!["noteOn", "noteOff", "cc", "pitchBend", "programChange", "channelPressure", "polyPressure"].includes(event.type)) + unsupported.push(`Unsupported source event: ${event.type}`); + if (event.centOffset) unsupported.push("Per-note centOffset is not represented by the MIDI exporter"); + } + } + const expectedTracks = tracks.map(track => ({ name: track.name, id: track.id, + clips: serializeMIDIClipsForBackend(track.midiClips.filter(clip => clips.includes(clip)) + .map(clip => ({ ...clip, startTime: clip.startTime - start })), track.midiEffects ?? []) })) + .filter(track => track.clips.some(clip => clip.events.length)); + return { schemaVersion: 1, scope: selected.length ? "selected_clips" : "project", + selectedClipIds: selected, rangeStart: start, unsupported: [...new Set(unsupported)], tracks: expectedTracks, + timing: midiExportOptions({ transport: { tempo: project.tempo }, tempoMarkers: project.tempoMarkers ?? [], + timeSignature: project.timeSignature ?? { numerator: 4, denominator: 4 } }, start, end) }; +} diff --git a/tools/support-requirements.txt b/tools/support-requirements.txt new file mode 100644 index 00000000..ca4268c5 --- /dev/null +++ b/tools/support-requirements.txt @@ -0,0 +1,4 @@ +# Optional: collection and MIDI structure inspection use only Python 3.11+ stdlib. +# Install in a separate environment; never in the app's audio/AI runtime. +numpy>=1.26,<3 +soundfile==0.13.1 diff --git a/tools/support_package.py b/tools/support_package.py new file mode 100644 index 00000000..d2fd2cd2 --- /dev/null +++ b/tools/support_package.py @@ -0,0 +1,44 @@ +"""Build-time layout and inventory for the independently launched support app.""" +import json +from pathlib import Path, PurePosixPath +import sys + + +def layout(bundle, platform=None): + root = Path(bundle).resolve(strict=True) + if root.name == 'OpenStudio.app': + root = root / 'Contents/Helpers/OpenStudioSupport.app' + if root.suffix == '.app': + return root, root / 'Contents/MacOS/OpenStudioSupport', root / 'Contents/Resources' + exe = 'OpenStudioSupport.exe' if (platform or sys.platform) == 'win32' else 'OpenStudioSupport' + return root, root / exe, root / 'support-runtime' + + +def write_inventory(bundle, version, platform=None): + root, _exe, resources = layout(bundle, platform) + files = sorted(path.relative_to(root).as_posix() for path in root.rglob('*') + if path.is_file() and '_CodeSignature' not in path.parts + and path.name != 'support-package.json') + (resources / 'support-package.json').write_text(json.dumps({'version': version, 'files': files}, indent=2), encoding='utf-8') + + +def validate(bundle, version, platform=None): + root, exe, resources = layout(bundle, platform) + manifest = json.loads((resources / 'support-package.json').read_text(encoding='utf-8')) + build = json.loads((resources / 'support-build.json').read_text(encoding='utf-8')) + if build != {'version': version, 'launch': 'manual_only'} or manifest['version'] != version: + raise ValueError('Support version or launch policy does not match this app package') + required = {exe.relative_to(root).as_posix()} + required.update((resources / name).relative_to(root).as_posix() for name in + ('support-midi.js', 'support-midi-sources.json', 'support-build.json', 'support-licenses/README.txt')) + files = manifest.get('files', []) + if not required.issubset(files) or len(files) < 20: + raise ValueError('Incomplete support runtime inventory') + for name in files: + relative = PurePosixPath(name) + if relative.is_absolute() or '..' in relative.parts or '\\' in name or ':' in name: + raise ValueError('Unsafe support inventory path') + path = root / name + if not path.resolve().is_relative_to(root) or not path.is_file(): + raise ValueError('Missing support runtime file: ' + name) + return len(files) diff --git a/tools/test-macos-support-package.py b/tools/test-macos-support-package.py new file mode 100644 index 00000000..431431a3 --- /dev/null +++ b/tools/test-macos-support-package.py @@ -0,0 +1,44 @@ +"""Qualify universal support and its Finder launcher in a mounted, disposable DMG.""" +import argparse +import json +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import time + +ROOT = Path(__file__).resolve().parents[1] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--bundle', type=Path, required=True) + parser.add_argument('--version', required=True) + args = parser.parse_args() + with tempfile.TemporaryDirectory(prefix='support-dmg-qa-') as directory: + work = Path(directory) + stage = work / 'stage'; stage.mkdir() + helper = stage / 'OpenStudio.app/Contents/Helpers/OpenStudioSupport.app' + shutil.copytree(args.bundle / 'OpenStudioSupport.app', helper, symlinks=True) + shortcut = stage / 'OpenStudio Support.app' + shutil.copytree(args.bundle / shortcut.name, shortcut, symlinks=True) + subprocess.run(['lipo', str(helper / 'Contents/MacOS/OpenStudioSupport'), '-verify_arch', 'arm64', 'x86_64'], check=True) + subprocess.run(['codesign', '--verify', '--deep', '--strict', str(helper)], check=True) + image = work / 'support-qualification.dmg' + subprocess.run(['hdiutil', 'create', '-srcfolder', str(stage), '-format', 'UDZO', str(image)], check=True) + mount = work / 'mounted' + subprocess.run(['hdiutil', 'attach', str(image), '-nobrowse', '-readonly', '-mountpoint', str(mount)], check=True) + try: + subprocess.run([sys.executable, str(ROOT / 'tools/test-packaged-support.py'), '--bundle', + str(mount / 'OpenStudio.app/Contents/Helpers/OpenStudioSupport.app')], check=True) + report = work / 'finder-launch.json' + subprocess.run(['open', '-n', '-W', str(mount / 'OpenStudio Support.app'), '--args', '--self-test', str(report)], timeout=90, check=True) + assert json.loads(report.read_text())['status'] == 'pass' + finally: + subprocess.run(['hdiutil', 'detach', str(mount)], check=True) + print('PASS universal support, mounted DMG, Finder launcher, native diagnostics; main DAW not launched.') + + +if __name__ == '__main__': + main() diff --git a/tools/test-packaged-support.py b/tools/test-packaged-support.py new file mode 100644 index 00000000..7e46da6b --- /dev/null +++ b/tools/test-packaged-support.py @@ -0,0 +1,74 @@ +"""Exercise the actual packaged GUI executable with no Python/Node on its PATH.""" +import argparse +import json +import os +from pathlib import Path +import subprocess +import shutil +import sys +import tempfile +import wave +import zipfile + +sys.path.insert(0, str(Path(__file__).parent)) +from test_openstudio_support import midi_bytes, project + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--bundle', type=Path, required=True) + args = parser.parse_args() + from support_package import layout + _root, executable, _resources = layout(args.bundle) + path = os.environ['SystemRoot'] + '/System32;' + os.environ['SystemRoot'] if os.name == 'nt' else '/usr/bin:/bin' + environment = dict(os.environ, PATH=path) + flags = {'creationflags': subprocess.CREATE_NO_WINDOW} if os.name == 'nt' else {} + for key in ('PYTHONHOME', 'PYTHONPATH', 'VIRTUAL_ENV', 'NODE_PATH'): + environment.pop(key, None) + def invoke(*arguments, expected=0): + result = subprocess.run([str(executable), *map(str, arguments)], env=environment, + timeout=60, capture_output=True, text=True, **flags) + if result.returncode != expected: + detail = result.stdout + result.stderr + if arguments[0] == '--self-test' and Path(arguments[1]).is_file(): + detail += Path(arguments[1]).read_text() + raise AssertionError(f'Packaged operation {arguments[0]} exited {result.returncode}; expected {expected}: {detail}') + with tempfile.TemporaryDirectory(prefix='support-package-test-') as directory: + root = Path(directory) + if os.name != 'nt': + # Keep only the OS process-list utility; /usr/bin may contain Python + # on CI images and must not accidentally satisfy a runtime dependency. + utilities = root / 'utilities'; utilities.mkdir() + (utilities / 'ps').symlink_to(shutil.which('ps')) + environment['PATH'] = str(utilities) + invoke('--self-test', root / 'selftest.json') + assert json.loads((root / 'selftest.json').read_text())['status'] == 'pass' + logs = root / 'logs'; logs.mkdir() + (logs / 'debug_log.txt').write_text('Export failed: C:/Users/Alice/Private.wav\n') + (logs / 'take.wav').write_bytes(b'never collect private audio') + invoke('collect', '--log-dir', logs, '--output', root / 'review') + invoke('bundle', root / 'review', '--output', root / 'unreviewed.zip', expected=1) + assert not (root / 'unreviewed.zip').exists() + invoke('bundle', root / 'review', '--reviewed', '--output', root / 'reviewed.zip') + with zipfile.ZipFile(root / 'reviewed.zip') as archive: + assert 'evidence-01.txt' in archive.namelist() + evidence = archive.read('evidence-01.txt') + assert b'Alice' not in evidence and b'Private' not in evidence + assert 'take.wav' not in archive.namelist() + with wave.open(str(root / 'audio.wav'), 'wb') as stream: + stream.setparams((2, 2, 48000, 480, 'NONE', 'not compressed')) + stream.writeframes(b'\0' * 480 * 4) + invoke('audio', root / 'audio.wav', '--output', root / 'audio-report') + audio = json.loads((root / 'audio-report/report.json').read_text()) + assert next(c for c in audio['checks'] if c['id'] == 'decode')['status'] == 'pass' + (root / 'export.mid').write_bytes(midi_bytes()) + (root / 'project.osproj').write_text(json.dumps(project())) + invoke('midi', root / 'export.mid', '--project', root / 'project.osproj', '--output', root / 'midi-report') + midi = json.loads((root / 'midi-report/report.json').read_text()) + assert midi['comparison']['status'] == 'pass', midi + invoke('collect', '--output', root / 'review', expected=1) + print('PASS: packaged GUI construction, collection, privacy, reviewed ZIP, audio, MIDI project comparison, no overwrite; no external Python/Node.') + + +if __name__ == '__main__': + main() diff --git a/tools/test-support-linux.py b/tools/test-support-linux.py new file mode 100644 index 00000000..609472c1 --- /dev/null +++ b/tools/test-support-linux.py @@ -0,0 +1,77 @@ +"""Native installer/archive qualification with the real support payload and a dummy DAW.""" +import argparse +import importlib.util +import json +import hashlib +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile + +ROOT = Path(__file__).resolve().parents[1] + + +def load(name, file): + spec = importlib.util.spec_from_file_location(name, ROOT / 'tools' / file) + module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module) + return module + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--bundle', type=Path, required=True) + parser.add_argument('--version', required=True) + args = parser.parse_args() + native = load('native_package', 'package-linux-native.py') + appimage = load('support_appimage', 'package-support-appimage.py') + with tempfile.TemporaryDirectory(prefix='support-linux-qa-') as directory: + root = Path(directory) + bundle = root / 'bundle' + shutil.copytree(args.bundle, bundle, symlinks=True) + for name in ('OpenStudio', 'OpenStudioUpdateInstaller'): + shutil.copy2('/bin/true', bundle / name) + (bundle / 'OpenStudio.version').write_text(args.version) + (bundle / 'OpenStudio.build-policy.json').write_text(json.dumps({'version': args.version, 'callbackDiagnostics': False})) + for name in ('webui/index.html', 'effects/fixture', 'scripts/fixture', 'licenses/fixture', 'models/basic_pitch_nmp.onnx'): + path = bundle / name; path.parent.mkdir(exist_ok=True); path.write_text('QA fixture, not the DAW') + native.validate_bundle(bundle, args.version) + stage = root / 'package' + native.stage_bundle(bundle, stage, 'deb') + desktop = stage / 'usr/share/applications/OpenStudioSupport.desktop' + subprocess.run(['desktop-file-validate', str(desktop)], check=True) + assert 'Exec=OpenStudioSupport\n' in desktop.read_text() + assert not list(stage.rglob('*autostart*')) + deb = native.build_deb(stage, root, root, args.version, 'ubuntu', '22.04') + extracted = root / 'deb-extracted' + subprocess.run(['dpkg-deb', '-x', str(deb), str(extracted)], check=True) + subprocess.run([sys.executable, str(ROOT / 'tools/test-packaged-support.py'), '--bundle', + str(extracted / 'usr/lib/openstudio')], check=True) + rpm = native.build_rpm(stage, root, root, args.version, '41') + rpm_root = root / 'rpm-extracted'; rpm_root.mkdir() + archive = subprocess.run(['rpm2cpio', str(rpm)], stdout=subprocess.PIPE, check=True) + subprocess.run(['cpio', '-idm', '--quiet'], input=archive.stdout, cwd=rpm_root, check=True) + changed = [] + for original in (bundle / 'support-runtime').rglob('*'): + if original.is_file(): + copy = rpm_root / 'usr/lib/openstudio' / original.relative_to(bundle) + if not copy.is_file() or hashlib.sha256(copy.read_bytes()).digest() != hashlib.sha256(original.read_bytes()).digest(): + changed.append(str(original.relative_to(bundle))) + if changed: + raise AssertionError('RPM changed frozen runtime files: ' + ', '.join(changed[:20])) + subprocess.run([sys.executable, str(ROOT / 'tools/test-packaged-support.py'), '--bundle', + str(rpm_root / 'usr/lib/openstudio')], check=True) + image = appimage.package(bundle, ROOT / 'output/support-distribution', args.version) + report = root / 'appimage-selftest.json' + tested = subprocess.run([str(image), '--self-test', str(report)], env=dict(os.environ, APPIMAGE_EXTRACT_AND_RUN='1'), + capture_output=True, text=True) + if tested.returncode or not report.is_file(): + raise AssertionError(f'AppImage failed: {tested.stdout}\n{tested.stderr}\n' + + (report.read_text() if report.is_file() else 'No self-test report')) + assert json.loads(report.read_text())['status'] == 'pass' + print('PASS real support payload in DEB, RPM, desktop entry and companion AppImage; DAW is a test stub.') + + +if __name__ == '__main__': + main() diff --git a/tools/test-support-msix.ps1 b/tools/test-support-msix.ps1 new file mode 100644 index 00000000..c2b8c30b --- /dev/null +++ b/tools/test-support-msix.ps1 @@ -0,0 +1,100 @@ +param( + [Parameter(Mandatory=$true)][string]$BundlePath, + [string]$OutputDir = 'output/support-msix-qa' +) +# Disposable identity, no Store upload, certificate installation or policy changes. +$ErrorActionPreference = 'Stop' +$repoRoot = Split-Path -Parent $PSScriptRoot +$source = (Resolve-Path -LiteralPath $BundlePath).Path +$work = Join-Path ([IO.Path]::GetFullPath($OutputDir)) ([guid]::NewGuid().ToString('N')) +$stage = Join-Path $work 'package' +New-Item -ItemType Directory -Path $stage -Force | Out-Null +Copy-Item -LiteralPath (Join-Path $source 'OpenStudioSupport.exe') -Destination $stage +Copy-Item -LiteralPath (Join-Path $source 'support-runtime') -Destination $stage -Recurse +$fixture = Join-Path $work 'Fixture.cs' +Set-Content -LiteralPath $fixture -Value 'class Fixture { static void Main() { System.Threading.Thread.Sleep(500); } }' +& "$env:WINDIR/Microsoft.NET/Framework64/v4.0.30319/csc.exe" /nologo /target:winexe "/out:$stage/OpenStudio.exe" $fixture +if ($LASTEXITCODE -ne 0) { throw 'Failed to build isolated DAW placeholder' } +[xml]$manifest = (Get-Content (Join-Path $repoRoot 'packaging/msix/AppxManifest.xml.in') -Raw).Replace('@VERSION@', '0.0.0.0') +$identity = 'OpenStudioQA' + [guid]::NewGuid().ToString('N') +$manifest.Package.Identity.Name = $identity +# The main DAW is a placeholder; do not register a competing .osproj association. +foreach ($app in $manifest.Package.Applications.Application) { + foreach ($extension in @($app.ChildNodes | Where-Object LocalName -eq 'Extensions')) { [void]$app.RemoveChild($extension) } +} +$developerMode = (Get-ItemProperty 'HKLM:/SOFTWARE/Microsoft/Windows/CurrentVersion/AppModelUnlock' -ErrorAction SilentlyContinue).AllowDevelopmentWithoutDevLicense -eq 1 +$admin = [Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +if (!$developerMode -and !$admin) { throw 'MSIX qualification needs existing Developer Mode or an administrator test runner; no policy was changed.' } +if (!$developerMode) { $manifest.Package.Identity.Publisher = 'CN=OpenStudioSupportQA, OID.2.25.311729368913984317654407730594956997722=1' } +$manifest.Save((Join-Path $stage 'AppxManifest.xml')) +$assets = Join-Path $stage 'Assets' +New-Item -ItemType Directory -Path $assets | Out-Null +Add-Type -AssemblyName System.Drawing +$icon = [Drawing.Image]::FromFile((Join-Path $repoRoot 'assets/icon-1024x1024.png')) +try { + foreach ($entry in @(@('StoreLogo',50), @('Square44x44Logo',44), @('Square150x150Logo',150))) { + $bitmap = [Drawing.Bitmap]::new([int]$entry[1], [int]$entry[1]); $graphics = [Drawing.Graphics]::FromImage($bitmap) + try { $graphics.DrawImage($icon, 0, 0, [int]$entry[1], [int]$entry[1]); $bitmap.Save((Join-Path $assets ($entry[0]+'.png')), [Drawing.Imaging.ImageFormat]::Png) } + finally { $graphics.Dispose(); $bitmap.Dispose() } + } +} finally { $icon.Dispose() } +$sdk = Get-ChildItem "${env:ProgramFiles(x86)}/Windows Kits/10/bin" -Directory | + Where-Object Name -Match '^10\.0\.\d+\.0$' | Sort-Object { [version]$_.Name } -Descending | + ForEach-Object { Join-Path $_.FullName 'x64' } | Where-Object { Test-Path (Join-Path $_ 'makeappx.exe') } | Select-Object -First 1 +if (!$sdk) { throw 'Windows SDK MakeAppx is required' } +$archive = Join-Path $work 'SupportQualification.msix' +& "$sdk/makeappx.exe" pack /d $stage /p $archive /h SHA256 /o | Tee-Object -FilePath (Join-Path $work 'makeappx.log') -Append | Out-Null +if ($LASTEXITCODE -ne 0) { throw 'MSIX validation/packing failed' } +$expanded = Join-Path $work 'roundtrip' +& "$sdk/makeappx.exe" unpack /p $archive /d $expanded /o | Tee-Object -FilePath (Join-Path $work 'makeappx.log') -Append | Out-Null +if ($LASTEXITCODE -ne 0) { throw 'MSIX unpack failed' } +foreach ($file in Get-ChildItem $stage -Recurse -File) { + $copy = Join-Path $expanded $file.FullName.Substring($stage.Length+1) + if ((Get-FileHash $copy).Hash -ne (Get-FileHash $file.FullName).Hash) { throw 'MSIX payload changed during round trip' } +} +Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +public static class SupportPackageActivation { + [ComImport, Guid("45BA127D-10A8-46EA-8AB7-56EA9078943C")] private class Manager {} + [ComImport, Guid("2e941141-7f97-4756-ba1d-9decde894a3d"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IManager { + [PreserveSig] int ActivateApplication([MarshalAs(UnmanagedType.LPWStr)] string id, [MarshalAs(UnmanagedType.LPWStr)] string args, uint options, out uint pid); + [PreserveSig] int ActivateForFile(IntPtr a, IntPtr b, IntPtr c, out uint pid); + [PreserveSig] int ActivateForProtocol(IntPtr a, IntPtr b, out uint pid); + } + public static uint Launch(string id, string args) { + var manager = (IManager)new Manager(); uint pid; + try { Marshal.ThrowExceptionForHR(manager.ActivateApplication(id,args,0,out pid)); return pid; } + finally { Marshal.ReleaseComObject(manager); } + } +} +'@ +$registered = $null +try { + if ($developerMode) { Add-AppxPackage -Register (Join-Path $expanded 'AppxManifest.xml') } + else { Add-AppxPackage -Path $archive -AllowUnsigned } + $registered = Get-AppxPackage -Name $identity + if (!$registered) { throw 'Test package was not registered' } + $apps = @(Get-StartApps | Where-Object AppID -Like ($registered.PackageFamilyName+'!*')) + if (!($apps.AppID -contains ($registered.PackageFamilyName+'!OpenStudioSupport'))) { throw 'Support Start menu entry missing' } + $before = @(Get-Process OpenStudioSupport -ErrorAction SilentlyContinue).Id + $mainId = [SupportPackageActivation]::Launch($registered.PackageFamilyName+'!OpenStudio', '') + $main = Get-Process -Id $mainId -ErrorAction SilentlyContinue + if ($main -and !$main.WaitForExit(30000)) { Stop-Process -Id $mainId; throw 'DAW placeholder timed out' } + $after = @(Get-Process OpenStudioSupport -ErrorAction SilentlyContinue).Id + if (@($after | Where-Object { $_ -notin $before }).Count) { throw 'Support started without explicit activation' } + $report = Join-Path $work 'installed-support.json' + $supportId = [SupportPackageActivation]::Launch($registered.PackageFamilyName+'!OpenStudioSupport', ('--self-test "'+$report+'"')) + $support = Get-Process -Id $supportId -ErrorAction SilentlyContinue + if ($support -and !$support.WaitForExit(90000)) { Stop-Process -Id $supportId; throw 'Packaged support timed out' } + $result = Get-Content $report -Raw | ConvertFrom-Json + if ($result.status -ne 'pass' -or $result.packageIdentity -notlike "$identity`_*") { throw "Packaged support identity/self-test failed: $report" } + @{ status='pass'; msixRoundTrip='pass'; startMenuActivation='pass'; idleOnInstallAndDAWLaunch='pass'; storeCertification='not_asserted'; scope='Production support payload and manifest; isolated identity and dummy DAW' } | + ConvertTo-Json | Set-Content (Join-Path $work 'qualification.json') +} finally { + $registered = Get-AppxPackage -Name $identity + if ($registered) { Remove-AppxPackage -Package $registered.PackageFullName } +} +if (Get-AppxPackage -Name $identity) { throw 'Test package cleanup failed' } +Write-Output "PASS MSIX pack/unpack, registration, Start menu activation, package identity, no implicit support launch, unregister: $work" diff --git a/tools/test-windows-signing.ps1 b/tools/test-windows-signing.ps1 index 24dc8de6..0b031599 100644 --- a/tools/test-windows-signing.ps1 +++ b/tools/test-windows-signing.ps1 @@ -43,8 +43,8 @@ try { Set-Content -LiteralPath (Join-Path $source "thirdparty.dll") -Value "Do not sign or copy this." Copy-OpenStudioSigningFiles -SourceDirectory $source -DestinationDirectory $stage ` -Version "0.1.01" -RequireEmptyDestination - if (@(Get-ChildItem -LiteralPath $stage).Count -ne 2) { - throw "Only the two first-party payload files should be staged." + if (@(Get-ChildItem -LiteralPath $stage).Count -ne 3) { + throw "Only the three first-party payload files should be staged." } Assert-Fails { Copy-OpenStudioSigningFiles -SourceDirectory $source -DestinationDirectory $stage ` -Version "0.1.01" -RequireEmptyDestination } "*must be empty*" diff --git a/tools/test-windows-store-package.ps1 b/tools/test-windows-store-package.ps1 index e86835df..f1ec5ee2 100644 --- a/tools/test-windows-store-package.ps1 +++ b/tools/test-windows-store-package.ps1 @@ -24,7 +24,7 @@ Expect-Rejection 'incomplete input' @{ Version = '0.1.1.0'; SourceDir = $testRoo if (!$SourceDir) { $SourceDir = Join-Path $testRoot 'source' New-Item -ItemType Directory -Path (Join-Path $SourceDir 'webui') -Force | Out-Null - foreach ($name in @('OpenStudio.exe', 'OpenStudioCrashReporter.exe', 'ffmpeg.exe', 'onnxruntime.dll', + foreach ($name in @('OpenStudio.exe', 'OpenStudioCrashReporter.exe', 'OpenStudioSupport.exe', 'ffmpeg.exe', 'onnxruntime.dll', 'webui/index.html', 'LICENSE', 'THIRD_PARTY_LICENSES.md')) { Set-Content -LiteralPath (Join-Path $SourceDir $name) -Value 'Test fixture; not an executable.' } @@ -34,7 +34,7 @@ Expect-Rejection 'stale binary version' @{ Version = '65535.65535.65535.0'; Sour Write-Output "$count Store package rejection tests passed." . (Join-Path $PSScriptRoot 'windows-store-runtime.ps1') -$required = @('webui', 'effects', 'licenses', 'models', 'scripts') +$required = @('webui', 'effects', 'licenses', 'models', 'scripts', 'support-runtime') function New-RuntimeFixture([string]$Name, [string]$Missing = '') { $root = Join-Path $testRoot $Name $inputDir = Join-Path $root 'source' @@ -82,4 +82,4 @@ foreach ($directory in $required) { if (@(Get-ChildItem -LiteralPath $fixture.StageDir).Count -ne 0) { throw 'Invalid runtime fixture was partially staged.' } Write-Output "PASS missing required $directory is rejected before copying" } -Write-Output '7 Store runtime directory tests passed.' +Write-Output '8 Store runtime directory tests passed.' diff --git a/tools/test_openstudio_support.py b/tools/test_openstudio_support.py new file mode 100644 index 00000000..f06e323c --- /dev/null +++ b/tools/test_openstudio_support.py @@ -0,0 +1,446 @@ +"""Offline support tests use synthetic media/projects only; never open an audio device.""" +import hashlib +import json +from pathlib import Path +import struct +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch +import zipfile + +sys.path.insert(0, str(Path(__file__).parent)) +from openstudio_support.common import Redactor, require_closed, save_report +from openstudio_support.collection import collect, bundle, LOG_LIMIT +from openstudio_support.audio import inspect_audio +from openstudio_support.midi import inspect_midi, parse_midi, compare_expected, prepare_project +from openstudio_support.cli import parser, run +from openstudio_support.common import report + + +def vlq(value): + result = [value & 127] + value >>= 7 + while value: + result.insert(0, (value & 127) | 128) + value >>= 7 + return bytes(result) + + +def midi_bytes(events=None, *, ppq=9600, name=b"Piano", end=True): + events = events if events is not None else [(4800, b"\x90\x3c\x64"), (9600, b"\x80\x3c\x00")] + body = b"\x00\xff\x51\x03\x07\xa1\x20\x00\xff\x58\x04\x04\x02\x18\x08" + body += b"\x00\xff\x03" + vlq(len(name)) + name + for delta, event in events: + body += vlq(delta) + event + if end: + body += b"\x00\xff\x2f\x00" + return b"MThd\x00\x00\x00\x06" + struct.pack(">HHH", 1, 1, ppq) + b"MTrk" + struct.pack(">I", len(body)) + body + + +def project(): + return {"version": "1.2.0", "tempo": 120, "timeSignature": {"numerator": 4, "denominator": 4}, + "tempoMarkers": [], "tracks": [{"id": "track-a", "name": "Piano", "type": "midi", "clips": [], + "midiClips": [{"id": "clip-a", "startTime": 0, "duration": 1, "loopEnabled": False, + "events": [{"type": "noteOn", "timestamp": .25, "note": 60, "velocity": 100, "channel": 1}, + {"type": "noteOff", "timestamp": .75, "note": 60, "velocity": 0, "channel": 1}]}]}]} + + +def payload(): + data = project() + return {"scope": "project", "sourceProjectSha256": "fixture", "rangeStart": 0, "unsupported": [], + "timing": {"tempo": 120, "tempoMarkers": [], "timeSignature": data["timeSignature"]}, + "tracks": [{"name": "Piano", "clips": data["tracks"][0]["midiClips"]}]} + + +def status(result, name): + return next(item["status"] for item in result["checks"] if item["id"] == name) + + +class TemporaryTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + + def tearDown(self): + self.temp.cleanup() + + +class CollectionTests(TemporaryTest): + def test_collection_allowlist_redaction_and_no_original_media(self): + (self.root / "debug_log.txt").write_text('load failed "C:\\Users\\Alice\\Secret Song.wav"\napi_key=abcd\nBearer xyz\nhttps://example.test/?token=secret\n', encoding="utf-8") + (self.root / "take.wav").write_bytes(b"private audio") + (self.root / "song.osproj").write_text("private project") + (self.root / "last_crash.dmp").write_bytes(b"private memory") + before = (self.root / "debug_log.txt").read_bytes() + result, files, redactor = collect([self.root], description="Cannot export") + self.assertEqual(len(files), 1) + text = next(iter(files.values())).decode() + for private in ("Alice", "Secret Song", "abcd", "xyz", "example.test"): + self.assertNotIn(private, text) + self.assertEqual((self.root / "debug_log.txt").read_bytes(), before) + save_report(self.root / "review", result, files, redactor) + with self.assertRaises(ValueError): + bundle(self.root / "review", self.root / "bundle.zip", False) + bundle(self.root / "review", self.root / "bundle.zip", True) + with zipfile.ZipFile(self.root / "bundle.zip") as archive: + self.assertEqual(set(archive.namelist()), {"summary.txt", "report.json", "manifest.json", "evidence-01.txt"}) + self.assertNotIn(b"private", b"".join(archive.read(name) for name in archive.namelist())) + + def test_dump_is_explicit_and_identified_as_unredacted(self): + (self.root / "last_crash.dmp").write_bytes(b"raw memory") + result, files, _ = collect([self.root], include_dumps=True) + self.assertEqual(files["evidence-01.dmp"], b"raw memory") + self.assertTrue(result["artifacts"][0]["unredactedDump"]) + + def test_long_logs_are_bounded_tail_with_disclosure(self): + (self.root / "debug_log.txt").write_bytes(b"old\n" * LOG_LIMIT + b"new failure\n") + result, files, _ = collect([self.root]) + self.assertTrue(result["artifacts"][0]["tailOnly"]) + self.assertLessEqual(len(files["evidence-01.txt"]), LOG_LIMIT) + self.assertTrue(files["evidence-01.txt"].endswith(b"new failure\n")) + + def test_route_report_does_not_follow_sources(self): + route = self.root / "route.json" + route.write_text(json.dumps({"purpose": "manual_render_route_report", "clips": [{"audioFile": "C:/private/take.wav"}], "pluginState": "SECRET"})) + result, files, _ = collect([], [route]) + self.assertEqual(len(files), 1) + self.assertNotIn(b"SECRET", files["evidence-01.json"]) + + def test_modified_evidence_is_not_bundled(self): + save_report(self.root / "review", report("test")) + (self.root / "review/summary.txt").write_text("modified") + with self.assertRaises(ValueError): + bundle(self.root / "review", self.root / "bundle.zip", True) + self.assertFalse((self.root / "bundle.zip").exists()) + + def test_path_traversal_manifest_rejected(self): + save_report(self.root / "review", report("test")) + path = self.root / "review/manifest.json" + value = json.loads(path.read_text()) + value["files"][0]["file"] = "../secret.json" + path.write_text(json.dumps(value)) + with self.assertRaises(ValueError): + bundle(path.parent, self.root / "bundle.zip", True) + + def test_existing_outputs_preserved(self): + save_report(self.root / "review", report("test")) + original = (self.root / "review/report.json").read_bytes() + with self.assertRaises(FileExistsError): + save_report(self.root / "review", report("other")) + self.assertEqual((self.root / "review/report.json").read_bytes(), original) + + def test_absent_evidence_is_not_a_pass(self): + result, files, _ = collect([self.root]) + self.assertEqual(status(result, "existing_evidence"), "not_asserted") + self.assertEqual(files, {}) + + def test_redaction_consistent_for_paths_and_names(self): + redactor = Redactor() + self.assertEqual(redactor.clean("C:/private/take.wav"), redactor.clean("C:\\private\\take.wav")) + value = redactor.clean({"name": "Private title", "token": "secret", "error": "/home/alice/song.wav"}) + self.assertNotIn("Private", json.dumps(value)) + self.assertNotIn("alice", json.dumps(value)) + self.assertEqual(value["token"], "") + + +class GuardTests(TemporaryTest): + def test_appimage_exception_requires_exact_image_and_contained_executable(self): + from openstudio_support.common import _is_own_appimage_runtime + image = self.root / 'OpenStudioSupport-0.0.0-linux-x86_64.AppImage' + image.touch() + executable = self.root / 'appdir/usr/bin/OpenStudioSupport' + executable.parent.mkdir(parents=True) + executable.touch() + import os + with patch('sys.platform', 'linux'), patch('sys.frozen', True, create=True), \ + patch('sys.executable', str(executable)), \ + patch.dict(os.environ, APPIMAGE=str(image), APPDIR=str(self.root / 'appdir')), \ + patch('openstudio_support.common._linux_executable', return_value=image.resolve()) as process: + self.assertTrue(_is_own_appimage_runtime('123')) + process.return_value = executable.resolve() + self.assertFalse(_is_own_appimage_runtime('124')) # another support worker + process.return_value = self.root / 'OpenStudio' + self.assertFalse(_is_own_appimage_runtime('125')) + process.return_value = image.resolve() + with patch('sys.executable', str(image)): + self.assertFalse(_is_own_appimage_runtime('123')) + process.side_effect = PermissionError() + self.assertFalse(_is_own_appimage_runtime('123')) + + def test_guard_rejects_running_app_and_workers(self): + for name in ("OpenStudio.exe", "OpenStudioCrashReporter.exe", "openstudio"): + output = f'"{name}","123","Console","1","20 K"\n' if sys.platform == "win32" else '123 ' + name + "\n" + with patch("openstudio_support.common.subprocess.run", return_value=subprocess.CompletedProcess([], 0, output, "")): + with self.assertRaises(ValueError): + require_closed() + + def test_guard_excludes_only_its_own_pid(self): + import os + own = f'"OpenStudioSupport.exe","{os.getpid()}","Console","1","20 K"\n' + other = '"explorer.exe","999","Console","1","20 K"\n' + if sys.platform != 'win32': + own = f'{os.getpid()} OpenStudioSupport\n' + other = '999 desktop\n' + with patch('openstudio_support.common.subprocess.run', return_value=subprocess.CompletedProcess([], 0, own + other, '')): + require_closed() + another = '"OpenStudioSupport.exe","123","Console","1","20 K"\n' if sys.platform == 'win32' else '123 OpenStudioSupport\n' + with patch('openstudio_support.common.subprocess.run', return_value=subprocess.CompletedProcess([], 0, own + other + another, '')): + with self.assertRaises(ValueError): + require_closed() + + def test_guard_fails_closed_on_enumeration_error(self): + with patch("openstudio_support.common.subprocess.run", side_effect=subprocess.CalledProcessError(1, "ps")): + with self.assertRaises(subprocess.SubprocessError): + require_closed() + + def test_refusal_precedes_any_file_writes(self): + args = parser().parse_args(["collect", "--output", str(self.root / "out")]) + with patch("openstudio_support.cli.require_closed", side_effect=ValueError("app running")): + with self.assertRaises(ValueError): + run(args) + self.assertFalse(args.output.exists()) + + def test_cli_collect_and_bundle(self): + (self.root / "debug_log.txt").write_text("render failed\n") + with patch("openstudio_support.cli.require_closed"): + code, message = run(parser().parse_args(["collect", "--log-dir", str(self.root), "--output", str(self.root / "review")])) + self.assertEqual(code, 0) + self.assertIn("not_asserted", message) + code, _ = run(parser().parse_args(["bundle", str(self.root / "review"), "--reviewed", "--output", str(self.root / "support.zip")])) + self.assertEqual(code, 0) + + +class AudioTests(TemporaryTest): + def audio(self, frames=4800, subtype="PCM_16", values=None): + import numpy as np + import soundfile as sf + path = self.root / "render.wav" + sf.write(path, np.zeros((frames, 2)) if values is None else values, 48000, subtype=subtype) + return path + + def test_silent_audio_valid_not_dropout_proof(self): + path = self.audio() + before = path.read_bytes() + result = inspect_audio(path) + self.assertEqual(status(result, "decode"), "pass") + self.assertEqual(result["status"], "diagnostic_only") + self.assertEqual(result["contentCompleteness"], "not_asserted") + self.assertAlmostEqual(result["signalDiagnostics"]["longestExactSilenceSeconds"], .1) + self.assertEqual(path.read_bytes(), before) + + def test_truncated_wave_is_not_silently_repaired_to_pass(self): + path = self.audio() + path.write_bytes(path.read_bytes()[:-40]) + result = inspect_audio(path) + self.assertEqual(status(result, "container_length"), "fail") + self.assertEqual(result["status"], "fail") + + def test_nonfinite_float_samples_fail(self): + import numpy as np + data = np.zeros((100, 2)); data[2, 0] = float("nan"); data[3, 1] = float("inf") + result = inspect_audio(self.audio(subtype="FLOAT", values=data)) + self.assertEqual(status(result, "finite_samples"), "fail") + json.dumps(result, allow_nan=False) + + def test_route_mismatch_and_unproven_association(self): + route = self.root / "route.json" + route.write_text(json.dumps({"purpose": "manual_render_route_report", "requestedOutputSampleRate": 48000, + "actualSampleRate": 48000, "outputSamples": 9600, "channels": 1, "format": "wav", "clips": [], "tracks": []})) + result = inspect_audio(self.audio(), route) + self.assertEqual(status(result, "route_duration"), "fail") + self.assertEqual(status(result, "route_channels"), "fail") + self.assertEqual(status(result, "route_provenance"), "not_asserted") + + def test_lossy_duration_is_not_exactly_asserted(self): + route = self.root / "route.json" + route.write_text(json.dumps({"purpose": "manual_render_route_report", "requestedOutputSampleRate": 48000, + "actualSampleRate": 48000, "outputSamples": 4800, "channels": 2, "format": "mp3"})) + result = inspect_audio(self.audio(), route) + self.assertEqual(status(result, "route_duration"), "not_asserted") + + def test_silence_spans_streaming_block_boundary(self): + result = inspect_audio(self.audio(frames=40000)) + self.assertAlmostEqual(result["signalDiagnostics"]["longestExactSilenceSeconds"], 40000 / 48000) + + def neutral_reference(self): + import numpy as np + import soundfile as sf + values = np.column_stack((np.linspace(-.2, .2, 4800), np.linspace(.2, -.2, 4800))) + source = self.root / "source.wav"; sf.write(source, values, 48000, subtype="FLOAT") + output = self.root / "mix.wav"; sf.write(output, values, 48000, subtype="FLOAT") + project_file = self.root / "song.osproj" + project_file.write_text(json.dumps({"tracks": [{"id": "track", "type": "audio", "clips": [ + {"id": "clip", "filePath": str(source), "startTime": 0, "duration": .1}]}]})) + route = self.root / "route.json" + route.write_text(json.dumps({"purpose": "manual_render_route_report", "source": "master", "startTimeSec": 0, + "normalize": False, "channels": 2, "requestedOutputSampleRate": 48000, "actualSampleRate": 48000, + "outputSamples": 4800, "format": "wav", "tracks": [{"id": "track"}]})) + return output, project_file, route + + def test_independent_neutral_source_reference(self): + output, source, route = self.neutral_reference() + result = inspect_audio(output, route, source, reference=True) + self.assertEqual(status(result, "source_reference"), "pass", result) + self.assertEqual(status(result, "snapshot_track_ids"), "pass") + self.assertEqual(result["contentCompleteness"], "not_asserted") + + def test_reference_rejects_unknown_processing_and_normalization(self): + output, source, route = self.neutral_reference() + data = json.loads(source.read_text()); data["tracks"][0]["channelStripEQEnabled"] = True + source.write_text(json.dumps(data)) + result = inspect_audio(output, route, source, reference=True) + self.assertEqual(status(result, "source_reference"), "not_asserted") + data["tracks"][0]["channelStripEQEnabled"] = False; source.write_text(json.dumps(data)) + data = json.loads(route.read_text()); data["normalize"] = True; route.write_text(json.dumps(data)) + self.assertEqual(status(inspect_audio(output, route, source, reference=True), "source_reference"), "not_asserted") + + def test_reference_requires_plan_and_correct_start(self): + output, source, route = self.neutral_reference() + self.assertEqual(status(inspect_audio(output, None, source, reference=True), "source_reference"), "not_asserted") + self.assertEqual(status(inspect_audio(output, route, source, reference=True, start=1), "source_reference"), "not_asserted") + + +class MIDITests(TemporaryTest): + def test_structure_running_status_and_zero_velocity_off(self): + parsed = parse_midi(midi_bytes([(0, b"\x90\x3c\x64"), (9600, b"\x3c\x00")])) + self.assertEqual([e["kind"] for e in parsed["tracks"][0]["events"]], ["noteOn", "noteOff"]) + + def test_bad_files_do_not_pass(self): + for data in (b"", midi_bytes()[:-1], midi_bytes(end=False), midi_bytes() + b"extra", + midi_bytes([(0, b"\x3c\x64")]), midi_bytes([(0, b"\x90\x80\x64")])): + with self.subTest(data=data[:8]), self.assertRaises(ValueError): + parse_midi(data) + + def test_smpte_is_explicitly_unsupported(self): + with self.assertRaises(NotImplementedError): + parse_midi(midi_bytes(ppq=0xE728)) + + def test_comparison_detects_missing_and_changed_notes(self): + for events in ([(4800, b"\x90\x3d\x64"), (9600, b"\x80\x3d\x00")], [(4800, b"\x90\x3c\x64")]): + result = report("midi_export") + compare_expected(parse_midi(midi_bytes(events)), payload(), result) + self.assertEqual(status(result, "track_1_events"), "fail") + + def test_correct_snapshot_comparison_is_scoped(self): + result = report("midi_export") + compare_expected(parse_midi(midi_bytes()), payload(), result) + self.assertTrue(all(c["status"] == "pass" for c in result["checks"])) + self.assertEqual(result["contentCompleteness"], "not_asserted") + + def test_timing_and_event_order_are_checked(self): + result = report("midi_export") + compare_expected(parse_midi(midi_bytes([(4804, b"\x90\x3c\x64"), (9600, b"\x80\x3c\x00")])), payload(), result) + self.assertEqual(status(result, "track_1_events"), "fail") + + def test_no_project_only_asserts_structure(self): + path = self.root / "export.mid"; path.write_bytes(midi_bytes()) + result = inspect_midi(path) + self.assertEqual(status(result, "midi_structure"), "pass") + self.assertEqual(status(result, "project_comparison"), "not_asserted") + + def test_real_frontend_serializer_and_full_comparison(self): + source = self.root / "project.osproj"; source.write_text(json.dumps(project())) + midi = self.root / "export.mid"; midi.write_bytes(midi_bytes()) + before = source.read_bytes() + prepared = prepare_project(source) + self.assertEqual(prepared["sourceProjectSha256"], hashlib.sha256(before).hexdigest()) + result = inspect_midi(midi, source) + self.assertEqual(status(result, "track_1_events"), "pass", result) + self.assertEqual(source.read_bytes(), before) + + def test_real_selected_clip_serializer_rebases_tempo(self): + data = project(); data["tracks"][0]["midiClips"][0]["startTime"] = 4 + data["tempoMarkers"] = [{"id": "tempo-a", "time": 2, "tempo": 60}] + source = self.root / "project.osproj"; source.write_text(json.dumps(data)) + prepared = prepare_project(source, ["clip-a"]) + self.assertEqual(prepared["rangeStart"], 4) + self.assertEqual(prepared["timing"]["tempo"], 60) + self.assertEqual(prepared["tracks"][0]["clips"][0]["startTime"], 0) + + def test_missing_selected_clip_is_rejected(self): + source = self.root / "project.osproj"; source.write_text(json.dumps(project())) + with self.assertRaises(ValueError): + prepare_project(source, ["missing"]) + + def test_tempo_change_tick_integration(self): + expected = payload() + expected["timing"]["tempoMarkers"] = [{"time": 1, "bpm": 60}] + expected["tracks"][0]["clips"][0]["events"][0]["timestamp"] = 2 + expected["tracks"][0]["clips"][0]["events"][1]["timestamp"] = 2.5 + events = [(19200, b"\xff\x51\x03\x0f\x42\x40"), (9600, b"\x90\x3c\x64"), (4800, b"\x80\x3c\x00")] + result = report("midi_export") + compare_expected(parse_midi(midi_bytes(events)), expected, result) + self.assertEqual(result["comparison"]["status"], "pass", result) + + def test_same_tick_controller_order_is_not_lost_in_counts(self): + expected = payload() + expected["tracks"][0]["clips"][0]["events"] = [ + {"type": "cc", "timestamp": 0, "controller": 0, "value": 1, "channel": 2}, + {"type": "programChange", "timestamp": 0, "value": 5, "channel": 2}] + result = report("midi_export") + compare_expected(parse_midi(midi_bytes([(0, b"\xc1\x05"), (0, b"\xb1\x00\x01")])), expected, result) + self.assertEqual(status(result, "track_1_events"), "fail") + + +class WindowTests(unittest.TestCase): + def test_standalone_window_builds_without_starting_work(self): + import tkinter as tk + from openstudio_support.gui import create_window + root = tk.Tk(); root.withdraw() + close = create_window(root) + try: + root.update_idletasks() + self.assertIn("Offline support", root.title()) + self.assertLessEqual(root.winfo_reqwidth(), 820) + self.assertLessEqual(root.winfo_reqheight(), 720) + finally: + close() + + +class PackagedSerializerTests(TemporaryTest): + def test_embedded_serializer_matches_source_for_loops_and_selection(self): + import importlib.util + if importlib.util.find_spec('quickjs') is None: + self.skipTest('Run in .venv-support for embedded serializer parity') + from openstudio_support.packaged import prepare_project as prepare_embedded + subprocess.run(['node', str(Path(__file__).parent / 'build-support-midi.mjs'), str(self.root)], check=True) + data = project() + clip = data['tracks'][0]['midiClips'][0] + clip.update(startTime=4, duration=6, loopEnabled=True, sourceDuration=1) + data['tempoMarkers'] = [{'id': 'tempo-a', 'time': 2, 'tempo': 60}] + clip['ccEvents'] = [{'time': .1, 'cc': 1, 'value': 90, 'channel': 1}] + source = self.root / 'project.osproj' + source.write_text(json.dumps(data), encoding='utf-8') + for selection in ([], ['clip-a']): + expected = prepare_project(source, selection) + actual = prepare_embedded(source, selection, resources=self.root) + expected.pop('preparationSources'); actual.pop('preparationSources') + self.assertEqual(actual, expected) + + +class PackageInventoryTests(TemporaryTest): + def test_missing_runtime_and_wrong_version_are_rejected(self): + import importlib.util + spec = importlib.util.spec_from_file_location('support_validator', Path(__file__).parent / 'validate-support-tool.py') + module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module) + files = ['OpenStudioSupport.exe', 'support-runtime/support-midi.js', + 'support-runtime/support-midi-sources.json', 'support-runtime/support-build.json', + 'support-runtime/support-licenses/README.txt'] + [f'support-runtime/library-{i}.dll' for i in range(20)] + for name in files: + path = self.root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('fixture') + (self.root / 'support-runtime/support-build.json').write_text(json.dumps({'version': '0.0.0', 'launch': 'manual_only'})) + (self.root / 'support-runtime/support-package.json').write_text(json.dumps({'version': '0.0.0', 'files': files})) + self.assertEqual(module.validate(self.root, '0.0.0', platform='win32'), len(files)) + with self.assertRaises(ValueError): + module.validate(self.root, '0.0.1', platform='win32') + (self.root / 'support-runtime/library-1.dll').unlink() + with self.assertRaises(ValueError): + module.validate(self.root, '0.0.0', platform='win32') + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/validate-linux-abi.py b/tools/validate-linux-abi.py index 116d4dca..5905c7e6 100644 --- a/tools/validate-linux-abi.py +++ b/tools/validate-linux-abi.py @@ -16,11 +16,14 @@ import subprocess -def run(*command): +def run(*command, private_runtime=None): env = os.environ.copy() for name in ('LD_LIBRARY_PATH', 'LD_PRELOAD', 'LD_AUDIT'): env.pop(name, None) env['LC_ALL'] = 'C' + if private_runtime is not None: + # Match the frozen helper's own loader. Never expose these libraries to the DAW. + env['LD_LIBRARY_PATH'] = str(private_runtime) return subprocess.run(command, env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=60) @@ -63,7 +66,10 @@ def audit(root, max_glibc=None, require_os=None): errors.append(f'{name} exceeds declared GLIBC_{max_glibc} baseline') loader = '' if '(NEEDED)' in dynamic.stdout: - resolved = run('ldd', '-v', str(path)) + private_runtime = None + if 'support-runtime' in path.parts: + private_runtime = Path(*path.parts[:path.parts.index('support-runtime') + 1]) + resolved = run('ldd', '-v', str(path), private_runtime=private_runtime) loader = resolved.stdout if resolved.returncode or re.search(r'not found|version lookup error', loader): errors.append('Loader rejected a dependency or required symbol version') diff --git a/tools/validate-support-tool.py b/tools/validate-support-tool.py new file mode 100644 index 00000000..08becd79 --- /dev/null +++ b/tools/validate-support-tool.py @@ -0,0 +1,14 @@ +"""Release gate for the separate offline support payload; does not launch it.""" +import argparse +from pathlib import Path + + +from support_package import validate + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--bundle', type=Path, required=True) + parser.add_argument('--version', required=True) + args = parser.parse_args() + print(f'Validated {validate(args.bundle, args.version)} offline support files (tool was not launched).') diff --git a/tools/windows-store-runtime.ps1 b/tools/windows-store-runtime.ps1 index 23a2b80b..a5965943 100644 --- a/tools/windows-store-runtime.ps1 +++ b/tools/windows-store-runtime.ps1 @@ -4,7 +4,7 @@ function Copy-StoreRuntimeDirectories { [Parameter(Mandatory = $true)][string]$StageDir ) - $required = @('webui', 'effects', 'licenses', 'models', 'scripts') + $required = @('webui', 'effects', 'licenses', 'models', 'scripts', 'support-runtime') foreach ($directory in $required) { if (!(Test-Path -LiteralPath (Join-Path $SourceDir $directory) -PathType Container)) { throw "Missing runtime directory: $directory" From 3975358871ab1e5b01a7c67e39461fefaadc618d Mon Sep 17 00:00:00 2001 From: Sourav Das Date: Wed, 4 Nov 2026 20:27:56 +0530 Subject: [PATCH 11/33] Qualify complete 0.1.09 candidate packages without publishing --- .github/workflows/verify.yml | 154 +++++++++++++++++++++++++--- docs/releases/0.1.09.md | 74 +++++++++++++ tools/package-windows-store.ps1 | 2 +- tools/run-windows-certification.ps1 | 35 +++++++ 4 files changed, 248 insertions(+), 17 deletions(-) create mode 100644 docs/releases/0.1.09.md create mode 100644 tools/run-windows-certification.ps1 diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 28eaa5be..19fcb621 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -6,11 +6,21 @@ on: - main pull_request: workflow_dispatch: + inputs: + version: + description: Unpublished candidate version to qualify + default: "0.1.09" + type: string permissions: contents: read jobs: + support-packages: + uses: ./.github/workflows/support-qualification.yml + with: + version: ${{ inputs.version || '0.1.09' }} + verify-python-tools: name: Python tooling runs-on: ubuntu-24.04 @@ -211,7 +221,7 @@ jobs: verify-windows: runs-on: windows-latest env: - VERSION: 0.0.0 + VERSION: ${{ inputs.version || '0.1.09' }} BUILD_DIR: build-verify-windows ASIO_SDK_DIR: thirdparty/asio ONNXRUNTIME_VERSION: 1.24.4 @@ -219,6 +229,9 @@ jobs: steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - name: Validate candidate release notes + run: python tools/validate-release-notes.py --version "${{ env.VERSION }}" + - name: Install Inno Setup for installer regression tests shell: pwsh run: choco install innosetup -y --no-progress @@ -258,18 +271,6 @@ jobs: with: python-version: '3.13' - - name: Qualify standalone offline support - shell: pwsh - run: | - python tools/build-support-tool.py --version 0.0.0 - if ($LASTEXITCODE -ne 0) { throw 'Support build failed.' } - ./.venv-support/Scripts/python.exe -m unittest tools.test_openstudio_support -q - if ($LASTEXITCODE -ne 0) { throw 'Support regression failed.' } - python tools/validate-support-tool.py --bundle output/support-package/OpenStudioSupport --version 0.0.0 - if ($LASTEXITCODE -ne 0) { throw 'Support inventory failed.' } - python tools/test-packaged-support.py --bundle output/support-package/OpenStudioSupport - if ($LASTEXITCODE -ne 0) { throw 'Packaged support qualification failed.' } - - name: Install ASIO SDK shell: pwsh run: ./tools/setup-asio-sdk.ps1 -Destination $env:ASIO_SDK_DIR @@ -333,6 +334,18 @@ jobs: shell: pwsh run: cmake --build $env:BUILD_DIR --config Release --target OpenStudio + - name: Qualify standalone offline support + shell: pwsh + run: | + python tools/build-support-tool.py --version "$env:VERSION" --stage "$env:BUILD_DIR/OpenStudio_artefacts/Release" + if ($LASTEXITCODE -ne 0) { throw 'Support build failed.' } + ./.venv-support/Scripts/python.exe -m unittest tools.test_openstudio_support -q + if ($LASTEXITCODE -ne 0) { throw 'Support regression failed.' } + python tools/validate-support-tool.py --bundle output/support-package/OpenStudioSupport --version "$env:VERSION" + if ($LASTEXITCODE -ne 0) { throw 'Support inventory failed.' } + python tools/test-packaged-support.py --bundle output/support-package/OpenStudioSupport + if ($LASTEXITCODE -ne 0) { throw 'Packaged support qualification failed.' } + - name: Install Chromium for native-schema editor qualification shell: pwsh working-directory: frontend @@ -574,19 +587,56 @@ jobs: -TimeoutSeconds 360 ` -SkipBuild + - name: Package complete Windows candidate without publishing + shell: pwsh + run: | + ./tools/package-windows-release.ps1 -Version $env:VERSION -SourceDir "$env:BUILD_DIR/OpenStudio_artefacts/Release" -OutputDir dist/windows + ./tools/package-windows-store.ps1 -Version "$env:VERSION.0" -SourceDir "$env:BUILD_DIR/OpenStudio_artefacts/Release" -OutputDir dist/store + + - name: Qualify installed Windows candidate + shell: pwsh + run: | + $root = Join-Path $env:RUNNER_TEMP 'OpenStudio-installed-candidate' + $installer = (Resolve-Path 'dist/windows/OpenStudio-Setup-x64.exe').Path + $install = Start-Process -FilePath $installer -PassThru -Wait -WindowStyle Hidden -ArgumentList @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/SP-', ('/DIR="{0}"' -f $root)) + if ($install.ExitCode -ne 0) { throw "Installer failed: $($install.ExitCode)" } + try { + ./tools/run-window-lifecycle-smoke.ps1 -AppPath "$root/OpenStudio.exe" -ReportPath "$env:RUNNER_TEMP/candidate-installed.json" -TimeoutSeconds 300 + python tools/test-packaged-support.py --bundle $root + if ($LASTEXITCODE -ne 0) { throw 'Installed support self-test failed.' } + } finally { + $uninstall = Start-Process -FilePath "$root/unins000.exe" -PassThru -Wait -WindowStyle Hidden -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART' + if ($uninstall.ExitCode -ne 0) { throw 'Candidate uninstall failed.' } + } + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: candidate-windows-x64 + path: | + dist/windows/OpenStudio-Setup-x64.exe + dist/store/*.msix + dist/store/package-report.json + ${{ runner.temp }}/candidate-installed* + if-no-files-found: error + verify-macos: - name: macOS native windows (${{ matrix.os }}) + needs: support-packages + name: macOS native windows (${{ matrix.os }}; callback diagnostics ${{ matrix.os == 'macos-15-intel' && 'ON' || 'OFF' }}) runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [macos-14, macos-15-intel] env: - VERSION: 0.0.0 + VERSION: ${{ inputs.version || '0.1.09' }} BUILD_DIR: build-verify-macos + CALLBACK_DIAGNOSTICS: ${{ matrix.os == 'macos-15-intel' && 'ON' || 'OFF' }} steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - name: Validate candidate release notes + run: python tools/validate-release-notes.py --version "${{ env.VERSION }}" + - name: Test POSIX desktop ownership and lifecycle identity shell: bash run: python3 -B -m unittest tests.test_desktop_qualification -v @@ -617,6 +667,7 @@ jobs: run: | cmake -S . -B "$BUILD_DIR" \ -DCMAKE_BUILD_TYPE=Release \ + -DOPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS="$CALLBACK_DIAGNOSTICS" \ -DOPENSTUDIO_APP_VERSION="$VERSION" \ -DOPENSTUDIO_ENABLE_EXTERNAL_PYTHON_AI_FALLBACK=OFF \ -DFETCHCONTENT_UPDATES_DISCONNECTED=ON @@ -723,11 +774,38 @@ jobs: ${{ runner.temp }}/OpenStudio_WindowLifecycleHarness.startup.log if-no-files-found: warn + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + if: env.CALLBACK_DIAGNOSTICS == 'OFF' + with: + name: support-macos-universal + path: output/support-universal + + - name: Package complete macOS candidate without notarization or publication + if: env.CALLBACK_DIAGNOSTICS == 'OFF' + shell: bash + run: | + tar -xzf output/support-universal/support-macos-universal.tgz -C output/support-universal + ditto output/support-universal/OpenStudioSupport.app "$APP_BUNDLE/Contents/Helpers/OpenStudioSupport.app" + ditto 'output/support-universal/OpenStudio Support.app' "$BUILD_DIR/OpenStudio_artefacts/Release/OpenStudio Support.app" + bash tools/package-macos-release.sh "$APP_BUNDLE" "$VERSION" dist/macos + + - name: Check callback diagnostic build contract + shell: pwsh + run: ./tools/run-render-export-headless-regression.ps1 -AppPath "$env:APP_BUNDLE/Contents/MacOS/OpenStudio" -Configuration Release -SkipBuild -OutputRoot "$env:RUNNER_TEMP/candidate-render" -TimeoutSeconds 300 + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + if: env.CALLBACK_DIAGNOSTICS == 'OFF' + with: + name: candidate-macos-universal + path: dist/macos/* + if-no-files-found: error + verify-linux: + needs: support-packages runs-on: ubuntu-22.04 env: # AppImage verification exercises the real release-notes gate. - VERSION: 0.1.04 + VERSION: ${{ inputs.version || '0.1.09' }} BUILD_DIR: build-verify-linux ONNXRUNTIME_VERSION: 1.24.4 ONNXRUNTIME_LINUX_X64_SHA256: 3a211fbea252c1e66290658f1b735b772056149f28321e71c308942cdb54b747 @@ -846,6 +924,17 @@ jobs: - name: Test native package construction run: python3 -m unittest discover -s tests -p 'test_linux_*.py' -v + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: support-linux-x64 + path: output/support-native + + - name: Stage complete candidate support payload + run: | + tar -xzf output/support-native/support-linux-x64.tgz -C output/support-native + cp -a output/support-native/OpenStudioSupport/. "$BUILD_DIR/OpenStudio_artefacts/Release/" + python3 tools/validate-support-tool.py --bundle "$BUILD_DIR/OpenStudio_artefacts/Release" --version "$VERSION" + - name: Build and install Ubuntu package shell: bash run: | @@ -903,3 +992,36 @@ jobs: output/linux-native-safe output/linux-appimage if-no-files-found: warn + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: candidate-linux-x64 + path: | + dist/linux/*.AppImage + dist/linux/*with-support.tar.gz + dist/linux/*.deb + if-no-files-found: error + + certify-windows-package: + name: Windows App Certification Kit (unpublished candidate) + needs: verify-windows + runs-on: windows-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: candidate-windows-x64 + path: candidate + - name: Run WACK without uploading or submitting to the Store + shell: pwsh + run: | + $packages = @(Get-ChildItem -LiteralPath candidate -Filter '*.msix' -Recurse -File) + if ($packages.Count -ne 1) { throw 'Expected exactly one candidate MSIX.' } + ./tools/run-windows-certification.ps1 -PackagePath $packages[0].FullName -OutputDirectory "$env:RUNNER_TEMP/candidate-wack" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + if: always() + with: + name: candidate-windows-certification + path: ${{ runner.temp }}/candidate-wack/ + if-no-files-found: error diff --git a/docs/releases/0.1.09.md b/docs/releases/0.1.09.md new file mode 100644 index 00000000..b42291cb --- /dev/null +++ b/docs/releases/0.1.09.md @@ -0,0 +1,74 @@ +# OpenStudio 0.1.09 + +This is an unpublished candidate. The +[comparison since the published 0.1.08 app](https://github.com/sdevil7th/OpenStudio/compare/v0.1.08...test-run-1-bugfixes) +and [application PR #36](https://github.com/sdevil7th/OpenStudio/pull/36) +describe the source under review. The candidate includes the shipped 0.1.08 +plugin Undo and release-wrapper fixes; it does not replace them with the older +development versions. No 0.1.09 release or Store update has been published. + +## Highlights + +- Add a separately launched OpenStudio Support application for Windows, macOS + and Linux. Users can collect existing diagnostic files, inspect audio/MIDI + exports and create a support archive without installing Python or using a + terminal. Collection and analysis require OpenStudio to be closed. Nothing + is uploaded automatically; users review and share the resulting archive. +- Add audio and MIDI tempo analysis with candidate audition and Undo, editable + linked crossfades, exact mixer level entry and a resizable single-row mixer. +- Add optional project-rate copies of imported audio while retaining original + source files, and show conversion progress and saved MIDI-device availability. + +## Fixes + +- Reduce repeated work for tiny audio blocks and prepare recording storage + before capture starts. The recording writer publishes prepared FIFO data + without waking its disk thread from each audio callback. These changes do + not establish a universal glitch-free minimum buffer size. +- Limit timeline waveform and canvas work to the visible region, keep recording + previews updating during navigation, and reduce ruler label crowding without + changing the snap grid. Mixer resizing releases keyboard focus correctly. +- Preserve joined MIDI note identity, mute state and note releases; export + selected MIDI clips consistently from the context menu and action catalog. + Single-clip export is labelled separately. Correct selection near clip edges. +- Preserve source geometry and fades through project restoration and rendering, + validate final project-rate conversions, and avoid conversion staging names + that exceed Windows path limits. The fader floor produces digital silence. +- Compile callback timing histograms, stage sampling and paired trace storage + out of ordinary builds. Missing measurements are reported as unavailable, + including in qualification reports, rather than as zero failures. MIDI event + placement, processing deadlines, meters and existing fault handling remain. + Release packaging rejects an instrumented or mismatched build policy. + +## Known Issues + +- The retained Audient ASIO 48 kHz / 8-sample recording test recorded 19 engine + deadline misses and 21 host overrun increments in about 60 seconds. That + earlier result remains a failed timing gate; removing measurements is not a + passing retest. Physical recording continuity, monitoring quality and the + supported workload/buffer combinations still require exact-artifact hardware + qualification. Heavy games and other competing applications can affect them. +- Offline support cannot reconstruct unmeasured CPU scheduling or RAM pressure + at a past glitch, prove that every intended instrument was audible, or replace + a physical signal reference. Redaction is best effort; inspect archives before + sharing. Crash dumps are optional and can contain private information. +- Cross-platform support qualification passed with development fixtures. This + does not certify the complete 0.1.09 packages. Apple signing/notarization + credentials are unavailable; Microsoft certification and final installed-app + qualification remain outstanding. See the + [candidate qualification record](../certification-0.1.09.md). +- Automated render and MIDI checks establish objective behavior for their test + inputs. They do not establish subjective sound quality or compatibility with + every external plugin, interface or driver. + +## Upgrade Notes + +- Back up projects before opening them in this candidate. Existing project + files remain supported; save a separate copy when testing new editing flows. + The 0.1.08 plugin Undo corrections remain included. +- Open Support explicitly from the installed shortcut or launcher after closing + the DAW. No support process, scheduled collector or service needs to run during + recording. Close Support before reopening OpenStudio. +- Keep existing audio interfaces and driver settings for comparison. Use the + buffer/workload combination qualified on your hardware; a successful export + or a larger-buffer test does not qualify an eight-sample recording session. diff --git a/tools/package-windows-store.ps1 b/tools/package-windows-store.ps1 index 99608b97..22a5d00e 100644 --- a/tools/package-windows-store.ps1 +++ b/tools/package-windows-store.ps1 @@ -105,7 +105,7 @@ if ([Diagnostics.FileVersionInfo]::GetVersionInfo($runtimeExe[0].FullName).Produ Copy-Item -LiteralPath $runtimeExe[0].DirectoryName -Destination (Join-Path $stage 'WebView2Runtime') -Recurse # A deliberate allowlist excludes PDBs, prerequisite installers, debug and AI # download caches. Optional AI runtimes are installed into writable user storage. -foreach ($file in @('OpenStudio.exe', 'OpenStudioCrashReporter.exe', 'OpenStudioSupport.exe', 'ffmpeg.exe', 'OpenStudio.version', 'LICENSE', 'THIRD_PARTY_LICENSES.md')) { +foreach ($file in @('OpenStudio.exe', 'OpenStudioCrashReporter.exe', 'OpenStudioSupport.exe', 'ffmpeg.exe', 'OpenStudio.version', 'OpenStudio.build-policy.json', 'LICENSE', 'THIRD_PARTY_LICENSES.md')) { Copy-Item -LiteralPath (Join-Path $source $file) -Destination $stage } foreach ($file in Get-ChildItem -LiteralPath $source -Filter '*.dll' -File) { diff --git a/tools/run-windows-certification.ps1 b/tools/run-windows-certification.ps1 new file mode 100644 index 00000000..5ec5ec9c --- /dev/null +++ b/tools/run-windows-certification.ps1 @@ -0,0 +1,35 @@ +param( + [Parameter(Mandatory=$true)][string]$PackagePath, + [Parameter(Mandatory=$true)][string]$OutputDirectory +) +$ErrorActionPreference = 'Stop' +$package = (Resolve-Path -LiteralPath $PackagePath).Path +$output = [IO.Path]::GetFullPath($OutputDirectory) +if (Test-Path -LiteralPath $output) { throw 'Certification output must be a new directory to exclude stale reports.' } +New-Item -ItemType Directory -Path $output | Out-Null +$report = Join-Path $output 'wack.xml' +$summary = @{ status='blocked'; packageSha256=(Get-FileHash -LiteralPath $package -Algorithm SHA256).Hash; published=$false } +try { + $admin = [Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + if (!$admin) { throw 'WACK requires an administrator test session; no elevation or policy change was attempted.' } + if ((Get-Process -Id $PID).SessionId -eq 0) { throw 'WACK requires an active user session, not Session 0.' } + if (Get-AppxPackage -Name SouravDas.OpenStudio) { throw 'Use a disposable test account without an existing OpenStudio Store installation.' } + $kit = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits/10/App Certification Kit/appcert.exe' + if (!(Test-Path -LiteralPath $kit)) { throw 'Windows App Certification Kit is not installed on this runner.' } + $reset = Start-Process -FilePath $kit -ArgumentList 'reset' -PassThru -Wait -WindowStyle Hidden + if ($reset.ExitCode -ne 0) { throw "WACK reset failed: $($reset.ExitCode)" } + $summary.status = 'fail' + $test = Start-Process -FilePath $kit -ArgumentList @('test', '-appxpackagepath', ('"{0}"' -f $package), '-reportoutputpath', ('"{0}"' -f $report)) -PassThru -Wait -WindowStyle Hidden + $summary.kitExitCode = $test.ExitCode + if (!(Test-Path -LiteralPath $report)) { throw 'WACK did not produce an XML report.' } + [xml]$xml = Get-Content -LiteralPath $report -Raw + $overall = $xml.DocumentElement.GetAttribute('OVERALL_RESULT') + $summary.overallResult = $overall + if ($test.ExitCode -ne 0 -or $overall -ine 'PASS') { throw "WACK did not pass: result '$overall', exit $($test.ExitCode). Review the retained XML." } + $summary.status = 'pass' +} catch { + $summary.reason = $_.Exception.Message + throw +} finally { + $summary | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $output 'summary.json') -Encoding utf8 +} From d1a2ee60665a1030e4cc92c06abdd771f6f9bd94 Mon Sep 17 00:00:00 2001 From: Sourav Das Date: Wed, 4 Nov 2026 20:54:35 +0530 Subject: [PATCH 12/33] Normalize Store candidate version and retain measured qualification limits --- .github/workflows/verify.yml | 6 ++- WORKFLOWS.md | 9 ++++ docs/USER_MANUAL.md | 5 ++ docs/certification-0.1.09.md | 83 +++++++++++++++++++++++++++------ docs/offline-support.md | 12 +++-- docs/releases/0.1.09.md | 15 +++--- tools/package-windows-store.ps1 | 2 + 7 files changed, 105 insertions(+), 27 deletions(-) diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 19fcb621..b9071a2d 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -230,7 +230,8 @@ jobs: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - name: Validate candidate release notes - run: python tools/validate-release-notes.py --version "${{ env.VERSION }}" + shell: pwsh + run: python tools/validate-release-notes.py --version "$env:VERSION" - name: Install Inno Setup for installer regression tests shell: pwsh @@ -635,7 +636,8 @@ jobs: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - name: Validate candidate release notes - run: python tools/validate-release-notes.py --version "${{ env.VERSION }}" + shell: pwsh + run: python tools/validate-release-notes.py --version "$env:VERSION" - name: Test POSIX desktop ownership and lifecycle identity shell: bash diff --git a/WORKFLOWS.md b/WORKFLOWS.md index 588e8295..0e223ee5 100644 --- a/WORKFLOWS.md +++ b/WORKFLOWS.md @@ -56,6 +56,15 @@ remains `python tools/openstudio-support.py`. Test with with `python tools/test-packaged-support.py --bundle `. Tool-only changes do not change native app/frontend assets or require a DAW rebuild. +Ordinary builds use `OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS=OFF`. For an explicitly +requested profiling reproduction only, configure a separate build directory with +`-DOPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS=ON`. This enables measurements that have +runtime cost; it is not the shipping performance baseline. Packaging validates +`OpenStudio.build-policy.json` and rejects missing, mismatched or instrumented +builds. Do not edit that generated policy to bypass the gate. An unavailable +callback measurement must remain `not_asserted` in qualification reports. + + ## Frontend checks ```sh diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md index 423a238b..1945ca07 100644 --- a/docs/USER_MANUAL.md +++ b/docs/USER_MANUAL.md @@ -3117,6 +3117,11 @@ Dumps are optional and unredacted. See [Offline support and export inspection](o for platform launch instructions, source-tool setup, comparisons and limits. A valid file or matching duration alone does not prove that every track was rendered. +Ordinary 0.1.09 candidate builds do not collect callback timing profiles. Timing +readouts may therefore be unavailable rather than zero. Existing device, plugin +and recording failure information can still help, but a later support bundle +cannot reconstruct unmeasured CPU scheduling or RAM pressure at a past glitch. + **Symptoms**: Rendered file is silent or contains only silence. **Solutions**: diff --git a/docs/certification-0.1.09.md b/docs/certification-0.1.09.md index 234cb655..546f911d 100644 --- a/docs/certification-0.1.09.md +++ b/docs/certification-0.1.09.md @@ -6,6 +6,19 @@ The user requested certification checks and a release PR, with publication held. ## Current evidence +- **Pass — complete Windows candidate, locally:** Release and Debug were rebuilt + with callback profiling compiled out and no C++ warnings. The current frontend + build and all 3,136 tests passed; the native render/export matrix passed all 422 + checks. The frozen 0.1.09 Support executable passed collection, privacy, audio, + MIDI, GUI construction and reviewed-archive checks without external Python/Node. + The actual MSIX packed/unpacked successfully and every payload hash matched. + Its Store version is `0.1.9.0` (MSIX requires canonical numeric components); + the application and notes retain `0.1.09`. This unsigned package was not + registered over the user's installed Store app or submitted to Microsoft. +- **In progress — full candidate CI:** PR #36 now builds the actual Windows + installer/MSIX, macOS universal DMG and Linux packages, checks installed-app + behavior and runs WACK separately. See the latest PR checks for their final + state; fixture success below is not a substitute for those results. - **Pass — support packaging:** the [six-job qualification](https://github.com/sdevil7th/OpenStudio/actions/runs/41841385666) covers Windows installer/MSIX fixtures, native macOS and universal Finder/DMG launches on both architectures, and Linux DEB/RPM/AppImage payload checks. @@ -18,6 +31,8 @@ The user requested certification checks and a release PR, with publication held. 8-sample test added 19 engine deadline misses and 21 host overrun counts during a 59.979-second steady recording interval. These are not proven physical driver loss or an audition result. See [the exact-artifact report](workflow-review-qualification.md). +- **Fail — fresh candidate 8-sample capacity checks:** see the measured cases + below. Compiling out the ordinary profiler did not establish the strict target. - **Blocked — macOS notarization:** the [read-only access check](https://github.com/sdevil7th/OpenStudio/actions/runs/41845529536) found none of the six required Apple signing/notarization values in GitHub or the configured Doppler project. The user confirmed credentials are unavailable. @@ -27,8 +42,9 @@ The user requested certification checks and a release PR, with publication held. 0.1.8.0; it is not the proposed candidate and must not be replaced for a test. - **Not asserted — WACK and Microsoft certification:** the Windows App Certification Kit is installed locally, but this session is not elevated and - no real 0.1.09 MSIX has been qualified. Old packages and dummy executables cannot - establish candidate certification. + WACK has not run locally. Actual MSIX payload validation passed; it does not + establish certification. The separate CI job retains WACK's XML and package + hash and fails closed if its prerequisites or checks fail. Merging a PR does not currently run Store submission. The release workflow uses release tags and contains public-release steps. Do not create a `v0.1.09` tag or @@ -36,6 +52,47 @@ run that workflow merely to expose Store actions. A release PR can show ordinary build/package checks; account-backed submission needs an explicitly permitted, nonpublishing path and a verified manual publishing hold. +## Fresh Windows hardware evidence — 4 November 2026 + +Release executable SHA-256: +`84e529369dd7e2655f420f5130558ece85359dd92c470865889b692dcfa5719b`. +The retained local executable hash must be checked against this record before +reusing an artifact. The candidate has `callbackDiagnostics: false` in its build +policy. Support and build jobs were closed during these runs. + +The explicitly launched `--nam-live-callback-probe` used the physical Audient +ASIO driver at 48 kHz, one or four NAM tracks with 4x oversampling, three seconds +of warmup and 30 seconds of measurement per case. It supplied synthetic input, +ignored physical microphone input and silenced physical outputs. This probe adds +its own measurement work only while explicitly running; it is not an audition, +a game-contention test or a repeat of the earlier 31-track UI workload. + +| NAM tracks | Buffer | Mean / p99 / max callback, microseconds | Late callbacks | Capacity | +| --- | --- | --- | --- | --- | +| 1 | 8 samples | 58.0 / 113.3 / 422.5 | 227 / 180,000 | **fail** | +| 4 | 8 samples | 77.8 / 155.6 / 441.5 | 451 / 180,000 | **fail** | +| 1 | 64 samples | 194.5 / 302.1 / 455.0 | 0 / 22,500 | **pass**, this short case only | +| 4 | 64 samples | 282.3 / 500.0 / 932.8 | 0 / 22,500 | **pass**, this short case only | + +The four-track / 8-sample case also recorded 3,425 worker deadline misses and +6,183 quarantined blocks across warmup plus measurement. Other cases recorded +zero worker deadline misses. Those worker figures have a different interval from +the table's measured callbacks and must not be added together. Raw recording +integrity passed in all four cases: exact frame counts, format and synthetic +input comparison within PCM tolerance. Successful raw capture does not establish +continuous monitoring through the overloaded processing chain. + +Driver xrun counters were unsupported (`-1`), so physical driver loss is +**not_asserted**. Subjective quality, physical monitoring/loopback latency, game +contention and long-session continuity are **not_asserted**. These data are +**diagnostic_only** for cause attribution. There was no matched before/after +test, so no performance improvement or regression is inferred from the numbers. +Larger-buffer passes do not clear the failed 8-sample gate. + +Local evidence is retained under `output/candidate-0.1.09/hardware/`, with +separate `1-tracks/result.json` and `4-tracks/result.json` reports. Those contain +local device/preset details and are not automatically uploaded or committed. + ## Physical test matrix Use copies of projects, and record the candidate file hash, app version, OS, @@ -120,19 +177,15 @@ Without Apple credentials, record normal first-download acceptance as ## What diagnostics can and cannot establish The standalone support tool adds no callback hooks or background collector. -This is distinct from diagnostics already present in the application branch: -`AudioEngine::audioDeviceIOCallbackWithContext` reads the clock and updates -timing counters even when detailed tracing is disabled, and samples stage -timings every 16 callbacks. `RealtimeCallbackTrace` also allocates its fixed -queue when the engine is constructed. These existing diagnostics are not -zero-cost; their contribution to the observed deadline misses is unmeasured. - -Before making a literal zero-overhead claim for all application diagnostics, -audit these operations and their consumers. Compile diagnostic-only work and -storage out of the production configuration if that is the required policy, -while retaining timing needed by actual engine scheduling. A runtime off -switch alone does not eliminate the checks. This is a review finding and a -proposed follow-up, not an implemented or measured optimization. +Ordinary builds now also compile out callback timing histograms, stage sampling, +arrival-gap profiling and paired trace storage. `OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS` +defaults to OFF; release packaging rejects an enabled or missing build policy. +Timing fields are absent/unavailable, never a false zero-miss pass. The callback +start clock remains because MIDI event placement and worker deadlines need it; +existing meters, transport state and recording/plugin fault handling remain. +Those engine operations are not zero-cost. The promise is no additional runtime +work from the closed support companion, not a claim that the DAW has no existing +status handling or that all workloads meet every hardware deadline. The next timing investigation should compare the same Release configuration, interface and 48 kHz / 8-sample setting with a minimal project versus the diff --git a/docs/offline-support.md b/docs/offline-support.md index 1026a6e4..f8a8654b 100644 --- a/docs/offline-support.md +++ b/docs/offline-support.md @@ -2,8 +2,11 @@ Unreleased development work added after app commit `f45637be`. The support companion is packaged separately for Windows, macOS and Linux. It adds no collectors, timers, -threads, buffers, callbacks, uploads, or logging to the running DAW. Existing app -diagnostics remain unchanged; this is not a claim that those diagnostics cost zero. +threads, buffers, callbacks, uploads, or logging to the running DAW. Ordinary +0.1.09 candidate builds also compile out callback timing profiling and trace storage. +Essential engine scheduling, meters and existing fault handling remain; this is +not a claim that those engine operations cost zero. Missing timing is reported as +unavailable, never as zero failures. See [candidate qualification](certification-0.1.09.md). No release containing this work has been published here. **Close OpenStudio before running support tools. Keep it closed until the operation @@ -260,8 +263,9 @@ check package identity and unregister. Linux tests execute support after DEB/RPM round trips and inside its companion AppImage. macOS tests mount a read-only DMG and activate the Finder launcher. These are support-distribution tests; they do not claim Store certification, notarization, clean-machine full-DAW readiness, hardware -recording performance or subjective audio quality. Source/audio execution paths in -the DAW are unchanged. Existing release-note and signing gates remain mandatory. +recording performance or subjective audio quality. These tests isolate support +distribution; the complete candidate also changes the DAW and requires its separate +qualification. Existing release-note and signing gates remain mandatory. ### Recorded development qualification — 2026-11-04 diff --git a/docs/releases/0.1.09.md b/docs/releases/0.1.09.md index b42291cb..831e061f 100644 --- a/docs/releases/0.1.09.md +++ b/docs/releases/0.1.09.md @@ -42,12 +42,15 @@ development versions. No 0.1.09 release or Store update has been published. ## Known Issues -- The retained Audient ASIO 48 kHz / 8-sample recording test recorded 19 engine - deadline misses and 21 host overrun increments in about 60 seconds. That - earlier result remains a failed timing gate; removing measurements is not a - passing retest. Physical recording continuity, monitoring quality and the - supported workload/buffer combinations still require exact-artifact hardware - qualification. Heavy games and other competing applications can affect them. +- The strict Audient ASIO 48 kHz / 8-sample target remains unqualified. Fresh + candidate probes recorded 227 late callbacks with one NAM track and 451 with + four tracks in separate 30-second measurements; the four-track case also + missed worker deadlines. Raw synthetic-input recording integrity passed, but + that does not establish continuous monitoring. Short 64-sample controls had + no measured misses; they do not clear the 8-sample failure or qualify long + sessions. These are different workloads from the earlier 31-track test, not + a before/after comparison. Physical recording, audition and game contention + still require exact-artifact hardware qualification. - Offline support cannot reconstruct unmeasured CPU scheduling or RAM pressure at a past glitch, prove that every intended instrument was audible, or replace a physical signal reference. Redaction is best effort; inspect archives before diff --git a/tools/package-windows-store.ps1 b/tools/package-windows-store.ps1 index 22a5d00e..81380b77 100644 --- a/tools/package-windows-store.ps1 +++ b/tools/package-windows-store.ps1 @@ -32,6 +32,8 @@ $packageVersion = [version]$Version foreach ($part in @($packageVersion.Major, $packageVersion.Minor, $packageVersion.Build, $packageVersion.Revision)) { if ($part -gt 65535) { throw 'Package version components must be at most 65535.' } } +# MSIX rejects leading zeros (the app's 0.1.09 spelling remains in its notes). +$Version = $packageVersion.ToString() $source = Resolve-RepoPath $SourceDir $output = Resolve-RepoPath $OutputDir foreach ($file in @('OpenStudio.exe', 'OpenStudioCrashReporter.exe', 'OpenStudioSupport.exe', 'ffmpeg.exe', 'onnxruntime.dll', From 9633ee798a41ac55bd9c6030b847c1f8dce37806 Mon Sep 17 00:00:00 2001 From: Sourav Das Date: Wed, 4 Nov 2026 20:58:15 +0530 Subject: [PATCH 13/33] Qualify finished macOS candidate DMG on both architectures --- .github/workflows/verify.yml | 35 ++++++++++++++++ tools/package-macos-release.sh | 17 ++++++-- tools/test-macos-candidate.py | 73 ++++++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 tools/test-macos-candidate.py diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index b9071a2d..647b6040 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -785,12 +785,18 @@ jobs: - name: Package complete macOS candidate without notarization or publication if: env.CALLBACK_DIAGNOSTICS == 'OFF' shell: bash + env: + MACOS_CODESIGN_KIND: ad-hoc run: | tar -xzf output/support-universal/support-macos-universal.tgz -C output/support-universal ditto output/support-universal/OpenStudioSupport.app "$APP_BUNDLE/Contents/Helpers/OpenStudioSupport.app" ditto 'output/support-universal/OpenStudio Support.app' "$BUILD_DIR/OpenStudio_artefacts/Release/OpenStudio Support.app" bash tools/package-macos-release.sh "$APP_BUNDLE" "$VERSION" dist/macos + - name: Qualify installed apps from the finished candidate DMG + if: env.CALLBACK_DIAGNOSTICS == 'OFF' + run: python3 tools/test-macos-candidate.py --dmg dist/macos/OpenStudio-macOS.dmg --version "$VERSION" --output "$RUNNER_TEMP/candidate-macos-installed" + - name: Check callback diagnostic build contract shell: pwsh run: ./tools/run-render-export-headless-regression.ps1 -AppPath "$env:APP_BUNDLE/Contents/MacOS/OpenStudio" -Configuration Release -SkipBuild -OutputRoot "$env:RUNNER_TEMP/candidate-render" -TimeoutSeconds 300 @@ -802,6 +808,35 @@ jobs: path: dist/macos/* if-no-files-found: error + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + if: always() && env.CALLBACK_DIAGNOSTICS == 'OFF' + with: + name: candidate-macos-installed-arm64 + path: ${{ runner.temp }}/candidate-macos-installed/ + if-no-files-found: warn + + qualify-macos-candidate-intel: + name: Installed universal DMG (Intel) + needs: verify-macos + runs-on: macos-15-intel + timeout-minutes: 15 + env: + VERSION: ${{ inputs.version || '0.1.09' }} + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: candidate-macos-universal + path: candidate + - name: Qualify the same finished DMG on Intel + run: python3 tools/test-macos-candidate.py --dmg candidate/OpenStudio-macOS.dmg --version "$VERSION" --output "$RUNNER_TEMP/candidate-macos-installed" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + if: always() + with: + name: candidate-macos-installed-intel + path: ${{ runner.temp }}/candidate-macos-installed/ + if-no-files-found: error + verify-linux: needs: support-packages runs-on: ubuntu-22.04 diff --git a/tools/package-macos-release.sh b/tools/package-macos-release.sh index dc4a378f..6aa838d1 100644 --- a/tools/package-macos-release.sh +++ b/tools/package-macos-release.sh @@ -34,7 +34,15 @@ SIGNING_KIND="${MACOS_CODESIGN_KIND:-developer-id}" case "$SIGNING_KIND" in developer-id) TIMESTAMP_OPTION="--timestamp" ;; self-signed) TIMESTAMP_OPTION="--timestamp=none" ;; - *) echo "MACOS_CODESIGN_KIND must be developer-id or self-signed." >&2; exit 1 ;; + ad-hoc) + TIMESTAMP_OPTION="--timestamp=none" + if [[ -n "${MACOS_CODESIGN_IDENTITY:-}" && "$MACOS_CODESIGN_IDENTITY" != "-" ]]; then + echo "Ad-hoc candidate signing cannot use a certificate identity." >&2 + exit 1 + fi + MACOS_CODESIGN_IDENTITY="-" + ;; + *) echo "MACOS_CODESIGN_KIND must be developer-id, self-signed or ad-hoc." >&2; exit 1 ;; esac if [[ "$SIGNING_KIND" == "self-signed" && -z "${MACOS_CODESIGN_IDENTITY:-}" ]]; then echo "Self-signed mode requires a persistent MACOS_CODESIGN_IDENTITY." >&2 @@ -57,7 +65,7 @@ if [[ "$NOTARY_CREDENTIAL_COUNT" -eq 3 && -z "${MACOS_CODESIGN_IDENTITY:-}" ]]; fi if [[ "$NOTARY_CREDENTIAL_COUNT" -eq 3 && "$SIGNING_KIND" != "developer-id" ]]; then - echo "Self-signed builds cannot be notarized. Use Developer ID for notarization." >&2 + echo "Only Developer ID builds can be notarized." >&2 exit 1 fi @@ -82,7 +90,7 @@ if [[ -n "${MACOS_CODESIGN_IDENTITY:-}" ]]; then # nested code must be signed deliberately in its own designated-code slot, # and the strict deep verification below will fail closed if it is missed. SUPPORT_SIGN_OPTIONS=() - [[ "$SIGNING_KIND" != "self-signed" ]] || SUPPORT_SIGN_OPTIONS+=(--no-timestamp) + [[ "$SIGNING_KIND" == "developer-id" ]] || SUPPORT_SIGN_OPTIONS+=(--no-timestamp) python3 "$SCRIPT_DIR/sign-macos-support.py" "$STAGED_APP/Contents/Helpers/OpenStudioSupport.app" --identity "$MACOS_CODESIGN_IDENTITY" "${SUPPORT_SIGN_OPTIONS[@]}" python3 "$SCRIPT_DIR/sign-macos-support.py" "$STAGING_DIR/OpenStudio Support.app" --identity "$MACOS_CODESIGN_IDENTITY" "${SUPPORT_SIGN_OPTIONS[@]}" codesign --force "$TIMESTAMP_OPTION" --options runtime --sign "$MACOS_CODESIGN_IDENTITY" "$STAGED_APP/Contents/Helpers/OpenStudioUpdateInstaller" @@ -130,4 +138,7 @@ fi if [[ "$SIGNING_KIND" == "self-signed" ]]; then echo "Self-signing is an identity-continuity experiment, not Gatekeeper acceptance. Reuse the same release key; never ask users to trust a root certificate." >&2 fi +if [[ "$SIGNING_KIND" == "ad-hoc" ]]; then + echo "Ad-hoc candidate signing verifies package integrity only, not trusted distribution or Gatekeeper acceptance." >&2 +fi echo "Created macOS DMG at $DMG_PATH" diff --git a/tools/test-macos-candidate.py b/tools/test-macos-candidate.py new file mode 100644 index 00000000..b8adaa1e --- /dev/null +++ b/tools/test-macos-candidate.py @@ -0,0 +1,73 @@ +"""Install both apps from an actual candidate DMG into a disposable test directory. + +This does not remove quarantine, notarize, publish, or assert Gatekeeper acceptance. +""" +import argparse +import hashlib +import json +from pathlib import Path +import subprocess +import sys +import tempfile + +ROOT = Path(__file__).resolve().parents[1] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--dmg', type=Path, required=True) + parser.add_argument('--version', required=True) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + args.output.mkdir(parents=True, exist_ok=False) + summary = {'status': 'fail', 'version': args.version, 'published': False, + 'notarization': 'not_asserted', 'gatekeeper': 'not_asserted'} + digest = hashlib.sha256() + with args.dmg.open('rb') as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b''): + digest.update(chunk) + summary['dmgSha256'] = digest.hexdigest() + try: + with tempfile.TemporaryDirectory(prefix='openstudio-candidate-') as directory: + work = Path(directory) + mount = work / 'mounted' + subprocess.run(['hdiutil', 'attach', str(args.dmg.resolve()), '-nobrowse', + '-readonly', '-mountpoint', str(mount)], check=True, timeout=90) + try: + install = work / 'installed'; install.mkdir() + for name in ('OpenStudio.app', 'OpenStudio Support.app'): + subprocess.run(['ditto', str(mount / name), str(install / name)], check=True) + finally: + subprocess.run(['hdiutil', 'detach', str(mount)], check=True, timeout=60) + app = install / 'OpenStudio.app' + helper = app / 'Contents/Helpers/OpenStudioSupport.app' + shortcut = install / 'OpenStudio Support.app' + for bundle in (app, shortcut): + subprocess.run(['codesign', '--verify', '--deep', '--strict', str(bundle)], check=True) + subprocess.run(['lipo', str(app / 'Contents/MacOS/OpenStudio'), + '-verify_arch', 'arm64', 'x86_64'], check=True) + for tool in ('release_build_policy.py', 'validate-support-tool.py'): + subprocess.run([sys.executable, str(ROOT / 'tools' / tool), '--bundle', + str(app), '--version', args.version], check=True) + lifecycle = args.output.resolve() / 'window-lifecycle.json' + subprocess.run(['pwsh', '-NoProfile', '-File', str(ROOT / 'tools/run-window-lifecycle-smoke.ps1'), + '-AppPath', str(app / 'Contents/MacOS/OpenStudio'), '-ReportPath', + str(lifecycle), '-TimeoutSeconds', '180'], check=True, timeout=240) + subprocess.run([sys.executable, str(ROOT / 'tools/test-packaged-support.py'), + '--bundle', str(helper)], check=True, timeout=300) + report = args.output.resolve() / 'finder-support.json' + subprocess.run(['open', '-n', '-W', str(shortcut), '--args', '--self-test', + str(report)], check=True, timeout=90) + if json.loads(report.read_text())['status'] != 'pass': + raise RuntimeError('Installed Finder support launch failed') + summary.update(status='pass', installedLifecycle='pass', frozenSupport='pass', + finderSupport='pass', signatureIntegrity='pass') + except Exception as error: + summary['reason'] = str(error) + raise + finally: + (args.output / 'summary.json').write_text(json.dumps(summary, indent=2) + '\n') + + +if __name__ == '__main__': + main() From b22bfa176ded45d1c7c5469250117d361b333ad4 Mon Sep 17 00:00:00 2001 From: Sourav Das Date: Wed, 4 Nov 2026 20:59:48 +0530 Subject: [PATCH 14/33] Move macOS qualification off retired hosted runners --- .github/workflows/ai-runtime-release.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/support-qualification.yml | 4 ++-- .github/workflows/verify.yml | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ai-runtime-release.yml b/.github/workflows/ai-runtime-release.yml index f30b67d5..65e8a839 100644 --- a/.github/workflows/ai-runtime-release.yml +++ b/.github/workflows/ai-runtime-release.yml @@ -87,7 +87,7 @@ jobs: build-macos-runtime-arm64: needs: validate-ai-runtime-release - runs-on: macos-14 + runs-on: macos-15 env: VERSION: ${{ needs.validate-ai-runtime-release.outputs.version }} AI_RUNTIME_STANDALONE_RELEASE_TAG: ${{ vars.OPENSTUDIO_AI_RUNTIME_STANDALONE_RELEASE_TAG != '' && vars.OPENSTUDIO_AI_RUNTIME_STANDALONE_RELEASE_TAG || '20260325' }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c7a98eac..05451527 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -502,7 +502,7 @@ jobs: build-macos: needs: [validate-release-notes, support-packages] - runs-on: macos-14 + runs-on: macos-15 env: VERSION: ${{ github.event.inputs.version || github.ref_name }} RELEASE_NOTES_FILE: ${{ needs.validate-release-notes.outputs.notes_file }} diff --git a/.github/workflows/support-qualification.yml b/.github/workflows/support-qualification.yml index 6117c294..36924000 100644 --- a/.github/workflows/support-qualification.yml +++ b/.github/workflows/support-qualification.yml @@ -18,7 +18,7 @@ jobs: include: - {os: windows-latest, platform: windows, arch: x64} - {os: ubuntu-22.04, platform: linux, arch: x64} - - {os: macos-14, platform: macos, arch: arm64} + - {os: macos-15, platform: macos, arch: arm64} - {os: macos-15-intel, platform: macos, arch: x86_64} runs-on: ${{ matrix.os }} timeout-minutes: 20 @@ -81,7 +81,7 @@ jobs: universal-macos: needs: native if: ${{ !cancelled() }} - runs-on: macos-14 + runs-on: macos-15 timeout-minutes: 15 steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 647b6040..5145d9db 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -77,7 +77,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-24.04, windows-latest, macos-14] + os: [ubuntu-24.04, windows-latest, macos-15] steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 @@ -627,7 +627,7 @@ jobs: strategy: fail-fast: false matrix: - os: [macos-14, macos-15-intel] + os: [macos-15, macos-15-intel] env: VERSION: ${{ inputs.version || '0.1.09' }} BUILD_DIR: build-verify-macos From 0c32fa48852b86e66c1145e73e3dca55ba2f3a33 Mon Sep 17 00:00:00 2001 From: Sourav Das Date: Wed, 4 Nov 2026 21:15:25 +0530 Subject: [PATCH 15/33] Keep Linux candidate qualification independent of Mac runner availability --- .github/workflows/verify.yml | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 5145d9db..4a6cf793 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -838,7 +838,6 @@ jobs: if-no-files-found: error verify-linux: - needs: support-packages runs-on: ubuntu-22.04 env: # AppImage verification exercises the real release-notes gate. @@ -849,6 +848,10 @@ jobs: steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 + with: + python-version: '3.13' + - name: Validate AppImage release notes run: python3 tools/validate-release-notes.py --version "$VERSION" @@ -863,7 +866,7 @@ jobs: | sudo tee /etc/apt/apt-mirrors.txt >/dev/null fi bash ./tools/setup-linux-prereqs.sh - sudo apt-get install -y xvfb xauth openbox + sudo apt-get install -y xvfb xauth openbox python3-tk - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: @@ -961,16 +964,11 @@ jobs: - name: Test native package construction run: python3 -m unittest discover -s tests -p 'test_linux_*.py' -v - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 - with: - name: support-linux-x64 - path: output/support-native - - - name: Stage complete candidate support payload + - name: Build and qualify candidate support without waiting for other platforms run: | - tar -xzf output/support-native/support-linux-x64.tgz -C output/support-native - cp -a output/support-native/OpenStudioSupport/. "$BUILD_DIR/OpenStudio_artefacts/Release/" + python3 tools/build-support-tool.py --version "$VERSION" --stage "$BUILD_DIR/OpenStudio_artefacts/Release" python3 tools/validate-support-tool.py --bundle "$BUILD_DIR/OpenStudio_artefacts/Release" --version "$VERSION" + xvfb-run -a python3 tools/test-packaged-support.py --bundle "$BUILD_DIR/OpenStudio_artefacts/Release" - name: Build and install Ubuntu package shell: bash From 389c244a1ed119be2379f45135b798ba583b5404 Mon Sep 17 00:00:00 2001 From: Sourav Das Date: Wed, 4 Nov 2026 21:53:34 +0530 Subject: [PATCH 16/33] Stabilize candidate UI fixtures and retain installed package evidence --- .github/workflows/verify.yml | 18 +++++++++--- docs/certification-0.1.09.md | 26 +++++++++++++++--- docs/releases/0.1.09.md | 10 ++++--- frontend/e2e/nam-fuzz-workflow.spec.ts | 9 +++++- frontend/e2e/recording-zoom.spec.ts | 38 ++++++++++++++++++-------- 5 files changed, 76 insertions(+), 25 deletions(-) diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 4a6cf793..89312f2b 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -131,12 +131,22 @@ jobs: if-no-files-found: error verify-input-profiles: - name: Input profiles (${{ matrix.os }}) + name: Input profiles (${{ matrix.os }}, ${{ matrix.shard }}) runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - os: [windows-latest, macos-15, ubuntu-24.04] + # Fresh browser contexts load hundreds of Vite modules. Spread Windows + # cases across hosts to reduce loopback socket pressure after an + # ERR_NO_BUFFER_SPACE failure in the full suite. Keep every test and + # assertion; do not retry a failed navigation or extend UI deadlines. + include: + - { os: windows-latest, shard: '1/4', artifact: windows-1 } + - { os: windows-latest, shard: '2/4', artifact: windows-2 } + - { os: windows-latest, shard: '3/4', artifact: windows-3 } + - { os: windows-latest, shard: '4/4', artifact: windows-4 } + - { os: macos-15, shard: '1/1', artifact: macos } + - { os: ubuntu-24.04, shard: '1/1', artifact: linux } defaults: run: working-directory: frontend @@ -194,7 +204,7 @@ jobs: NODE - name: Run shortcut and input-profile browser tests - run: npm run test:e2e + run: npm run test:e2e -- --shard=${{ matrix.shard }} - name: Remove Playwright WebKit keyboard-navigation preference if: always() && runner.os == 'macOS' @@ -212,7 +222,7 @@ jobs: if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: control-ui-${{ matrix.os }} + name: control-ui-${{ matrix.artifact }} path: | output/playwright/test-results output/linux-control-ui diff --git a/docs/certification-0.1.09.md b/docs/certification-0.1.09.md index 546f911d..8b02a7d6 100644 --- a/docs/certification-0.1.09.md +++ b/docs/certification-0.1.09.md @@ -15,10 +15,28 @@ The user requested certification checks and a release PR, with publication held. Its Store version is `0.1.9.0` (MSIX requires canonical numeric components); the application and notes retain `0.1.09`. This unsigned package was not registered over the user's installed Store app or submitted to Microsoft. -- **In progress — full candidate CI:** PR #36 now builds the actual Windows - installer/MSIX, macOS universal DMG and Linux packages, checks installed-app - behavior and runs WACK separately. See the latest PR checks for their final - state; fixture success below is not a substitute for those results. +- **Pass — full Windows installer and MSIX:** the [candidate package job](https://github.com/sdevil7th/OpenStudio/actions/runs/41858351056/job/126673030017) + built the production app, created and validated its MSIX, installed the actual + setup executable, passed installed window-lifecycle and frozen Support checks, + and uninstalled it. The separate WACK job is still awaiting a runner; package + validation does not establish Microsoft certification. +- **Pass — full Linux candidate:** the [Linux job](https://github.com/sdevil7th/OpenStudio/actions/runs/41861058092/job/126682629756) + built the production app and Support, installed the actual DEB, checked normal + and safe desktop launches, render/features, and the AppImage payload and + launchers. The candidate packages are retained as unpublished CI artifacts. +- **In progress — final Windows browser checks:** Linux's full browser suite + passed. Windows exposed two test-fixture problems: driver round trips made a + purported sustained gesture exceed its 200 ms idle boundary, and a second + full-screen 4K screenshot consumed the interaction deadline. Corrected focused + cases pass locally without relaxing the behavior assertions. A separate run + failed to load Vite's React refresh module with `ERR_NO_BUFFER_SPACE`; the + Windows suite is now split across four hosts, retaining all tests and zero + retries. Full rerun results are still required. +- **Blocked — full macOS candidate execution:** GitHub repeatedly failed to + allocate either Mac architecture before any steps ran. The workflow now uses + supported macOS 15 images and includes installed universal-DMG checks on both + architectures, but these have not completed for this candidate. No alternative + runner is configured. Earlier fixture passes do not clear this gate. - **Pass — support packaging:** the [six-job qualification](https://github.com/sdevil7th/OpenStudio/actions/runs/41841385666) covers Windows installer/MSIX fixtures, native macOS and universal Finder/DMG launches on both architectures, and Linux DEB/RPM/AppImage payload checks. diff --git a/docs/releases/0.1.09.md b/docs/releases/0.1.09.md index 831e061f..a3af8bd5 100644 --- a/docs/releases/0.1.09.md +++ b/docs/releases/0.1.09.md @@ -55,10 +55,12 @@ development versions. No 0.1.09 release or Store update has been published. at a past glitch, prove that every intended instrument was audible, or replace a physical signal reference. Redaction is best effort; inspect archives before sharing. Crash dumps are optional and can contain private information. -- Cross-platform support qualification passed with development fixtures. This - does not certify the complete 0.1.09 packages. Apple signing/notarization - credentials are unavailable; Microsoft certification and final installed-app - qualification remain outstanding. See the +- Full Windows installer/MSIX packaging and installed-app checks passed; Linux + DEB installation and AppImage qualification passed. These are unpublished test + artifacts. Final Windows browser checks are being rerun. GitHub could not + allocate Mac test runners, and Apple signing/notarization credentials are + unavailable. Microsoft certification remains outstanding. Earlier Mac support + fixture passes do not certify the full candidate. See the [candidate qualification record](../certification-0.1.09.md). - Automated render and MIDI checks establish objective behavior for their test inputs. They do not establish subjective sound quality or compatibility with diff --git a/frontend/e2e/nam-fuzz-workflow.spec.ts b/frontend/e2e/nam-fuzz-workflow.spec.ts index 5403aaae..98c6b11e 100644 --- a/frontend/e2e/nam-fuzz-workflow.spec.ts +++ b/frontend/e2e/nam-fuzz-workflow.spec.ts @@ -115,7 +115,14 @@ for (const viewport of [{ width: 640, height: 480 }, { width: 900, height: 600 } await knob.focus(); await expect(knob).toBeFocused(); await expect(knob).toHaveCSS("outline-style", "solid"); - await page.screenshot({ path: testInfo.outputPath(`fuzz-keyboard-focus-${viewport.width}.png`) }); + // Keep the full-layout capture above, but capture focus locally. Encoding a + // second complete 4K WebKit image consumed the interaction test's deadline. + const focused = await knob.boundingBox(); + if (!focused) throw new Error("Focused Fuzz control has no bounds"); + const focusX = Math.max(0, focused.x - 6), focusY = Math.max(0, focused.y - 6); + await page.screenshot({ path: testInfo.outputPath(`fuzz-keyboard-focus-${viewport.width}.png`), + clip: { x: focusX, y: focusY, width: Math.min(viewport.width - focusX, focused.width + 12), + height: Math.min(viewport.height - focusY, focused.height + 12) } }); await knob.press("Enter"); const number = page.getByRole("spinbutton", { name: "Fuzz Clean blend percentage", exact: true }); await expect(number).toBeInViewport(); diff --git a/frontend/e2e/recording-zoom.spec.ts b/frontend/e2e/recording-zoom.spec.ts index e597dc94..c7d8a09e 100644 --- a/frontend/e2e/recording-zoom.spec.ts +++ b/frontend/e2e/recording-zoom.spec.ts @@ -243,20 +243,34 @@ test("live waveforms keep updating during sustained scrolling and modifier zoom" const before = await page.evaluate(() => ({ count: window.__recordingZoomIntegration.requests.length, time: performance.now() })); if (modifier) await page.keyboard.down(modifier); try { - for (let n = 0; n < 32; n++) { - await page.mouse.wheel(0, n % 2 ? 20 : -20); - await page.evaluate(() => { - const store = window.__recordingZoomIntegration.store; - store.getState().setCurrentTime(store.getState().transport.currentTime + 0.05); - }); - await page.waitForTimeout(45); - } - // Inspect before releasing the gesture or its 200 ms idle debounce. - const during = await page.evaluate(start => { + // Drive this sustained-input case inside the document: individual Windows + // driver round trips can exceed the 200 ms idle debounce, turning the + // old loop into separate gestures. Trusted wheel mapping is covered by + // the other integration cases; plain scrolling still uses native scrollTop. + const during = await page.evaluate(async ({ start, modifier }) => { + const target = document.elementFromPoint(850, 240); + const workspace = target?.closest(".workspace"); + if (!target || !workspace) throw new Error("Recording viewport is missing"); const f = window.__recordingZoomIntegration; + let previous = performance.now(); + let maxWheelGap = 0; + for (let n = 0; n < 32; n++) { + const now = performance.now(); + if (n > 0) maxWheelGap = Math.max(maxWheelGap, now - previous); + previous = now; + const deltaY = n % 2 ? 20 : -20; + target.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, + clientX: 850, clientY: 240, deltaY, ctrlKey: modifier === "Control", + metaKey: modifier === "Meta", shiftKey: modifier === "Shift", altKey: modifier === "Alt" })); + if (modifier === null) workspace.scrollTop += deltaY; + f.store.getState().setCurrentTime(f.store.getState().transport.currentTime + 0.05); + await new Promise(resolve => window.setTimeout(resolve, 45)); + } + // Inspect before releasing the gesture or its idle debounce. return { requests: f.requests.slice(start.count), elapsed: performance.now()-start.time, - maxPending: f.maxPending, recording: f.store.getState().transport.isRecording }; - }, before); + maxPending: f.maxPending, recording: f.store.getState().transport.isRecording, maxWheelGap }; + }, { start: before, modifier }); + expect(during.maxWheelGap, "fixture must actually deliver a sustained gesture").toBeLessThan(200); for (const trackId of ["record-zoom-a", "record-zoom-b"]) expect(during.requests.filter(request => request.trackId === trackId && request.completed).length, `${modifier ?? "plain"} live data for ${trackId}`).toBeGreaterThan(1); From d82029533f4d278448514064436b2260ed1c9d16 Mon Sep 17 00:00:00 2001 From: Sourav Das Date: Wed, 4 Nov 2026 22:05:38 +0530 Subject: [PATCH 17/33] Fix Support DPI awareness and report certification findings explicitly --- docs/certification-0.1.09.md | 27 ++++++--- docs/releases/0.1.09.md | 7 ++- packaging/support/windows.manifest | 15 +++++ tools/build-support-tool.py | 2 +- tools/openstudio_support/display.py | 89 ++++++++++++++++++++++++++++ tools/openstudio_support/gui.py | 13 ++-- tools/openstudio_support/selftest.py | 20 ++++++- tools/run-windows-certification.ps1 | 12 ++++ 8 files changed, 169 insertions(+), 16 deletions(-) create mode 100644 packaging/support/windows.manifest create mode 100644 tools/openstudio_support/display.py diff --git a/docs/certification-0.1.09.md b/docs/certification-0.1.09.md index 8b02a7d6..dc6d3c51 100644 --- a/docs/certification-0.1.09.md +++ b/docs/certification-0.1.09.md @@ -18,8 +18,8 @@ The user requested certification checks and a release PR, with publication held. - **Pass — full Windows installer and MSIX:** the [candidate package job](https://github.com/sdevil7th/OpenStudio/actions/runs/41858351056/job/126673030017) built the production app, created and validated its MSIX, installed the actual setup executable, passed installed window-lifecycle and frozen Support checks, - and uninstalled it. The separate WACK job is still awaiting a runner; package - validation does not establish Microsoft certification. + and uninstalled it. These packages predate the Support display-scaling fix + below; a rebuilt package and another WACK run are required. - **Pass — full Linux candidate:** the [Linux job](https://github.com/sdevil7th/OpenStudio/actions/runs/41861058092/job/126682629756) built the production app and Support, installed the actual DEB, checked normal and safe desktop launches, render/features, and the AppImage payload and @@ -58,11 +58,24 @@ The user requested certification checks and a release PR, with publication held. permits only `v*` tags. It rejected the certification branch before any Store request. The policy has not been changed. The local installed Store app is 0.1.8.0; it is not the proposed candidate and must not be replaced for a test. -- **Not asserted — WACK and Microsoft certification:** the Windows App - Certification Kit is installed locally, but this session is not elevated and - WACK has not run locally. Actual MSIX payload validation passed; it does not - establish certification. The separate CI job retains WACK's XML and package - hash and fails closed if its prerequisites or checks fail. +- **Not passed — WACK:** the [actual report](https://github.com/sdevil7th/OpenStudio/actions/runs/41858351056/artifacts/13341803458) + returned `WARNING`, exit 0, on Windows Server 2025 (kit 10.0.26100.9457), with + a complete rather than partial run. The required DPI-awareness test warned + about `OpenStudioSupport.exe`. Support now has a PerMonitorV2 manifest, + event-driven font scaling and scrollable content; the rebuilt frozen helper + passes its PMv2-context and 100/150/200% font-scaling checks. Physical mixed-DPI + monitor transitions and the rebuilt MSIX's WACK result remain unasserted. + The tested package SHA-256 was + `039cae8db65124a9212e1ab6b1b786e1f71d4ea78789d0f77f75072ce3566c80`. +- **Informational findings retained:** that WACK XML marks application count + (the independent Support entry) and blocked-executable references as optional + failures. Microsoft's [Desktop Bridge test guidance](https://learn.microsoft.com/en-us/windows/uwp/debug-test-perf/windows-desktop-bridge-app-tests) + distinguishes these from required tests. Process-launch references span the + app, Support, Python/Tcl and Microsoft WebView2; this static scan does not prove + any process was started during recording. The independent launcher remains; + the strict aggregate WACK gate has not been weakened. S-mode compatibility and + Microsoft's final certification remain **not_asserted**. The report retains + every finding for review rather than relabelling `WARNING` as `PASS`. Merging a PR does not currently run Store submission. The release workflow uses release tags and contains public-release steps. Do not create a `v0.1.09` tag or diff --git a/docs/releases/0.1.09.md b/docs/releases/0.1.09.md index a3af8bd5..ce5c4e5e 100644 --- a/docs/releases/0.1.09.md +++ b/docs/releases/0.1.09.md @@ -39,6 +39,9 @@ development versions. No 0.1.09 release or Store update has been published. including in qualification reports, rather than as zero failures. MIDI event placement, processing deadlines, meters and existing fault handling remain. Release packaging rejects an instrumented or mismatched build policy. +- Make the Windows Support window scale its text when moving between displays + and keep its controls reachable with scrollbars on small or highly scaled + screens. These changes run only inside the explicitly opened companion. ## Known Issues @@ -59,7 +62,9 @@ development versions. No 0.1.09 release or Store update has been published. DEB installation and AppImage qualification passed. These are unpublished test artifacts. Final Windows browser checks are being rerun. GitHub could not allocate Mac test runners, and Apple signing/notarization credentials are - unavailable. Microsoft certification remains outstanding. Earlier Mac support + unavailable. WACK reported a required Support DPI warning and two + informational findings; the DPI fix needs a rebuilt-package certification run. + Microsoft certification remains outstanding. Earlier Mac support fixture passes do not certify the full candidate. See the [candidate qualification record](../certification-0.1.09.md). - Automated render and MIDI checks establish objective behavior for their test diff --git a/packaging/support/windows.manifest b/packaging/support/windows.manifest new file mode 100644 index 00000000..ce4b970b --- /dev/null +++ b/packaging/support/windows.manifest @@ -0,0 +1,15 @@ + + + + + + + + + + + true/pm + PerMonitorV2,PerMonitor + + + diff --git a/tools/build-support-tool.py b/tools/build-support-tool.py index 3579d421..9042f170 100644 --- a/tools/build-support-tool.py +++ b/tools/build-support-tool.py @@ -131,7 +131,7 @@ def main(): icon = work / 'support.ico' png = (ROOT / 'assets/icon-256x256.png').read_bytes() icon.write_bytes(struct.pack(' 0 else -size * dpi / 96 + item.configure(size=-max(1, round(pixels))) + if initial: + scale = dpi / 96 + width = min(round(820 * scale), round(root.winfo_screenwidth() * .9)) + height = min(round(720 * scale), round(root.winfo_screenheight() * .85)) + root.minsize(min(round(480 * scale), width), min(round(360 * scale), height)) + root.geometry(f'{width}x{height}') + + def moved(event): + if event.widget is root: + apply(api(root.winfo_id())) + + root.bind('', moved, add='+') + apply(api(root.winfo_id())) + return apply + + +def scrollable_content(root): + """Keep every operation reachable on a small or highly scaled display.""" + import tkinter as tk + from tkinter import ttk + host = ttk.Frame(root) + host.pack(fill='both', expand=True) + host.rowconfigure(0, weight=1) + host.columnconfigure(0, weight=1) + canvas = tk.Canvas(host, highlightthickness=0) + canvas.grid(row=0, column=0, sticky='nsew') + vertical = ttk.Scrollbar(host, orient='vertical', command=canvas.yview) + vertical.grid(row=0, column=1, sticky='ns') + horizontal = ttk.Scrollbar(host, orient='horizontal', command=canvas.xview) + horizontal.grid(row=1, column=0, sticky='ew') + canvas.configure(yscrollcommand=vertical.set, xscrollcommand=horizontal.set) + frame = ttk.Frame(canvas, padding=18) + window = canvas.create_window(0, 0, anchor='nw', window=frame) + + def layout(_event=None): + canvas.itemconfigure(window, width=max(canvas.winfo_width(), frame.winfo_reqwidth()), + height=max(canvas.winfo_height(), frame.winfo_reqheight())) + canvas.configure(scrollregion=canvas.bbox(window)) + + frame.bind('', layout) + canvas.bind('', layout) + + def reveal_focus(event): + widget = event.widget + if not str(widget).startswith(str(frame) + '.'): + return + # Tab navigation must reveal off-screen controls as well as focus them. + left = widget.winfo_rootx() - frame.winfo_rootx() + top = widget.winfo_rooty() - frame.winfo_rooty() + for axis, start, size, extent, visible in ( + ('x', left, widget.winfo_width(), frame.winfo_width(), canvas.winfo_width()), + ('y', top, widget.winfo_height(), frame.winfo_height(), canvas.winfo_height()), + ): + position = getattr(canvas, axis + 'view')()[0] * extent + if start < position: + getattr(canvas, axis + 'view_moveto')(max(0, start - 8) / max(1, extent)) + elif start + size > position + visible: + getattr(canvas, axis + 'view_moveto')((start + size + 8 - visible) / max(1, extent)) + + root.bind('', reveal_focus, add='+') + return frame diff --git a/tools/openstudio_support/gui.py b/tools/openstudio_support/gui.py index 924b9b84..979337a5 100644 --- a/tools/openstudio_support/gui.py +++ b/tools/openstudio_support/gui.py @@ -8,16 +8,18 @@ def create_window(root): import tkinter as tk - from tkinter import filedialog, messagebox, ttk + from tkinter import filedialog, font, messagebox, ttk from .cli import parser, run + from .display import install_dpi_scaling, scrollable_content root.title("OpenStudio — Offline support") - root.minsize(720, 620) + root.minsize(480, 360) root.geometry("820x720") - frame = ttk.Frame(root, padding=18) - frame.pack(fill="both", expand=True) + frame = scrollable_content(root) frame.columnconfigure(1, weight=1) - ttk.Label(frame, text="Offline support", font=("Segoe UI", 17, "bold")).grid(row=0, column=0, columnspan=3, sticky="w") + heading_font = font.Font(root, name='SupportHeading', family='Segoe UI', size=17, weight='bold') + root._support_heading_font = heading_font + ttk.Label(frame, text="Offline support", font=heading_font).grid(row=0, column=0, columnspan=3, sticky="w") ttk.Label(frame, text="Close OpenStudio first. Close this support window before reopening the DAW.\nNo uploads, live telemetry, or changes to your media and projects.", wraplength=680).grid(row=1, column=0, columnspan=3, sticky="w", pady=(8, 14)) choices = {"Collect existing evidence": "collect", "Inspect audio export": "audio", "Inspect MIDI export": "midi", "Create reviewed ZIP": "bundle"} @@ -183,6 +185,7 @@ def close(): executor.shutdown(wait=True) root.destroy() root.protocol("WM_DELETE_WINDOW", close) + root._support_apply_dpi = install_dpi_scaling(root) return close diff --git a/tools/openstudio_support/selftest.py b/tools/openstudio_support/selftest.py index 161f503b..7fb361b1 100644 --- a/tools/openstudio_support/selftest.py +++ b/tools/openstudio_support/selftest.py @@ -17,6 +17,23 @@ def run_selftest(output): root.withdraw() close = create_window(root) root.update_idletasks() + display_check = 'not_applicable' + import sys + if sys.platform == 'win32': + import ctypes + user32 = ctypes.windll.user32 + user32.GetThreadDpiAwarenessContext.restype = ctypes.c_void_p + user32.AreDpiAwarenessContextsEqual.argtypes = [ctypes.c_void_p, ctypes.c_void_p] + if getattr(sys, 'frozen', False): + assert user32.AreDpiAwarenessContextsEqual(user32.GetThreadDpiAwarenessContext(), -4), 'PMv2 manifest not active' + from tkinter import font + sizes = [] + for dpi in (96, 144, 192, 96): + root._support_apply_dpi(dpi) + root.update_idletasks() + sizes.append(font.nametofont('TkDefaultFont', root=root).metrics('linespace')) + assert sizes[0] < sizes[1] < sizes[2] and sizes[0] == sizes[3], sizes + display_check = 'PMv2_context_and_font_scaling' if getattr(sys, 'frozen', False) else 'font_scaling' close() with tempfile.TemporaryDirectory(prefix='support-self-test-') as directory: path = Path(directory) @@ -26,8 +43,7 @@ def run_selftest(output): project.write_text(json.dumps({'version': '1.2.0', 'tempo': 120, 'tracks': []})) assert prepare_project(project)['tracks'] == [] result = {'status': 'pass', 'gui': 'constructed_hidden', 'audioDecoder': 'pass', - 'embeddedMIDISerializer': 'pass', 'closedAppGuard': 'pass'} - import sys + 'embeddedMIDISerializer': 'pass', 'closedAppGuard': 'pass', 'displayScaling': display_check} if sys.platform == 'win32': import ctypes length = ctypes.c_uint32(0) diff --git a/tools/run-windows-certification.ps1 b/tools/run-windows-certification.ps1 index 5ec5ec9c..b4773a9a 100644 --- a/tools/run-windows-certification.ps1 +++ b/tools/run-windows-certification.ps1 @@ -25,6 +25,18 @@ try { [xml]$xml = Get-Content -LiteralPath $report -Raw $overall = $xml.DocumentElement.GetAttribute('OVERALL_RESULT') $summary.overallResult = $overall + $tests = @($xml.SelectNodes('/REPORT/REQUIREMENTS/REQUIREMENT/TEST')) + $required = @($tests | Where-Object { $_.GetAttribute('OPTIONAL') -ine 'TRUE' }) + $summary.requiredTestCount = $required.Count + $summary.requiredIssues = @($required | Where-Object { $_.SelectSingleNode('RESULT').InnerText -ine 'PASS' } | ForEach-Object { + @{ name=$_.GetAttribute('NAME'); result=$_.SelectSingleNode('RESULT').InnerText; messages=@($_.SelectNodes('MESSAGES/MESSAGE') | ForEach-Object { $_.GetAttribute('TEXT') }) } + }) + $summary.optionalIssues = @($tests | Where-Object { $_.GetAttribute('OPTIONAL') -ieq 'TRUE' -and $_.SelectSingleNode('RESULT').InnerText -ine 'PASS' } | ForEach-Object { + @{ name=$_.GetAttribute('NAME'); result=$_.SelectSingleNode('RESULT').InnerText; messageCount=@($_.SelectNodes('MESSAGES/MESSAGE')).Count } + }) + $summary.requiredTestsStatus = if ($required.Count -gt 0 -and $summary.requiredIssues.Count -eq 0 -and $test.ExitCode -eq 0 -and $xml.DocumentElement.GetAttribute('PARTIAL_RUN') -ieq 'FALSE') { 'pass' } else { 'not_passed' } + # Keep the aggregate gate strict. Informational findings are preserved for + # review, not silently converted into a Store certification pass. if ($test.ExitCode -ne 0 -or $overall -ine 'PASS') { throw "WACK did not pass: result '$overall', exit $($test.ExitCode). Review the retained XML." } $summary.status = 'pass' } catch { From 8f75022ac263d345bbd585e44b943c8ed9e9cd4b Mon Sep 17 00:00:00 2001 From: Sourav Das Date: Wed, 4 Nov 2026 22:41:43 +0530 Subject: [PATCH 18/33] Keep transport fixture imports alive and reject incomplete certification reports --- .github/workflows/verify.yml | 7 ++++-- docs/certification-0.1.09.md | 29 ++++++++++++++++------ frontend/e2e/support/singleKeyTransport.ts | 29 ++++++++++++++++++---- tools/run-windows-certification.ps1 | 4 ++- 4 files changed, 54 insertions(+), 15 deletions(-) diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 89312f2b..971faf20 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -146,7 +146,10 @@ jobs: - { os: windows-latest, shard: '3/4', artifact: windows-3 } - { os: windows-latest, shard: '4/4', artifact: windows-4 } - { os: macos-15, shard: '1/1', artifact: macos } - - { os: ubuntu-24.04, shard: '1/1', artifact: linux } + - { os: ubuntu-24.04, shard: '1/4', artifact: linux-1 } + - { os: ubuntu-24.04, shard: '2/4', artifact: linux-2 } + - { os: ubuntu-24.04, shard: '3/4', artifact: linux-3 } + - { os: ubuntu-24.04, shard: '4/4', artifact: linux-4 } defaults: run: working-directory: frontend @@ -211,7 +214,7 @@ jobs: run: defaults delete org.webkit.Playwright AppleKeyboardUIMode 2>/dev/null || true - name: Check controls in the system WebKitGTK - if: runner.os == 'Linux' + if: runner.os == 'Linux' && matrix.shard == '1/4' run: | sudo apt-get update sudo apt-get install -y python3-gi gir1.2-webkit2-4.1 libxtst6 xvfb xauth diff --git a/docs/certification-0.1.09.md b/docs/certification-0.1.09.md index dc6d3c51..398139f9 100644 --- a/docs/certification-0.1.09.md +++ b/docs/certification-0.1.09.md @@ -1,7 +1,7 @@ # OpenStudio 0.1.09 candidate qualification Unpublished candidate selected on 4 November 2026. No release tag, Store -submission, publication or website/update-feed change has been created for it. +submission, publication or public website/update-feed change has been created for it. The user requested certification checks and a release PR, with publication held. ## Current evidence @@ -20,18 +20,33 @@ The user requested certification checks and a release PR, with publication held. setup executable, passed installed window-lifecycle and frozen Support checks, and uninstalled it. These packages predate the Support display-scaling fix below; a rebuilt package and another WACK run are required. -- **Pass — full Linux candidate:** the [Linux job](https://github.com/sdevil7th/OpenStudio/actions/runs/41861058092/job/126682629756) +- **Pass — full Linux candidate:** the [final Linux job](https://github.com/sdevil7th/OpenStudio/actions/runs/41868876801/job/126710168141) built the production app and Support, installed the actual DEB, checked normal and safe desktop launches, render/features, and the AppImage payload and - launchers. The candidate packages are retained as unpublished CI artifacts. -- **In progress — final Windows browser checks:** Linux's full browser suite - passed. Windows exposed two test-fixture problems: driver round trips made a + launchers. The [candidate packages](https://github.com/sdevil7th/OpenStudio/actions/runs/41868876801/artifacts/13343059637) + include the final Support display changes at `d8202953`. AppImage launcher + tests used extract-and-run mode; physical desktop/FUSE and Fedora RPM + installation remain in the manual matrix. +- **Pass — final Windows browser checks:** all four groups at `d8202953` passed, + with 295 passing cases and 33 native-schema/explicit-input skips, zero failures and zero + retries. The [run](https://github.com/sdevil7th/OpenStudio/actions/runs/41868876801) + retains the evidence. Earlier Windows runs exposed two test-fixture problems: + driver round trips made a purported sustained gesture exceed its 200 ms idle boundary, and a second full-screen 4K screenshot consumed the interaction deadline. Corrected focused - cases pass locally without relaxing the behavior assertions. A separate run + cases pass without relaxing the behavior assertions. A separate run failed to load Vite's React refresh module with `ERR_NO_BUFFER_SPACE`; the Windows suite is now split across four hosts, retaining all tests and zero - retries. Full rerun results are still required. + retries. Linux's earlier full browser suite passed. The final rerun found a + test-loader failure before the transport-key assertions: Chromium discarded + the evaluation's pending module-import promise. That fixture now uses the + existing document-owned loading pattern from the ruler/track-copy tests, + without changing keyboard assertions. Its focused local check passes; the + Linux rerun remains required. Linux now also uses four test groups for faster + feedback, with the system WebKitGTK check retained once per run. + Native-schema editor tests run separately after the native app build. Tests + requiring a private project or explicit native tempo-preview input do not run + automatically in this general browser suite. - **Blocked — full macOS candidate execution:** GitHub repeatedly failed to allocate either Mac architecture before any steps ran. The workflow now uses supported macOS 15 images and includes installed universal-DMG checks on both diff --git a/frontend/e2e/support/singleKeyTransport.ts b/frontend/e2e/support/singleKeyTransport.ts index 4aa602fc..38805c40 100644 --- a/frontend/e2e/support/singleKeyTransport.ts +++ b/frontend/e2e/support/singleKeyTransport.ts @@ -1,6 +1,15 @@ import type { Page } from "@playwright/test"; import type { BrowserRegressionReport, TransportKeyQa } from "./interactionFixtureTypes"; +declare global { + interface Window { + __transportKeyModules: { + store: typeof import("../../src/store/useDAWStore").useDAWStore; + createDefaultTrack: typeof import("../../src/store/useDAWStore").createDefaultTrack; + }; + } +} + export async function runSingleKeyTransport(page: Page): Promise { function assert(ok: unknown, message: string): asserts ok { if (!ok) throw new Error(message); } await page.addInitScript(() => { @@ -9,11 +18,21 @@ export async function runSingleKeyTransport(page: Page): Promise { - const source = await (await fetch('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/src/App.tsx')).text(); - const storeUrl = source.match(/from "([^"]*useDAWStore\.ts[^"]*)"/)?.[1]; - if (!storeUrl) throw new Error("App store import was not found"); - const { useDAWStore: store, createDefaultTrack } = await import(/* @vite-ignore */ storeUrl) as typeof import("../../src/store/useDAWStore"); + await page.getByRole('contentinfo', { name: 'Transport controls' }).waitFor(); + // As in the ruler/track-copy fixtures, let the document own module loading: + // Chromium can collect a CDP evaluation's pending dynamic-import Promise. + const response = await page.request.get('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/src/App.tsx'); + if (!response.ok()) throw new Error("App source was not available"); + const source = await response.text(); + const storeUrl = source.match(/from "([^"]*useDAWStore\.ts[^"]*)"/)?.[1]; + if (!storeUrl) throw new Error("App store import was not found"); + await page.addScriptTag({ type: 'module', content: ` + import { useDAWStore as store, createDefaultTrack } from ${JSON.stringify(storeUrl)}; + window.__transportKeyModules = { store, createDefaultTrack }; + ` }); + await page.waitForFunction(() => Boolean(window.__transportKeyModules)); + await page.evaluate(() => { + const { store, createDefaultTrack } = window.__transportKeyModules; const qa: TransportKeyQa = window.__transportKeyQA = { store, calls: [], keys: window.__trustedTransportKeys }; const track = createDefaultTrack('record-key', 'Record key QA', '#4379ee', 'audio', []); track.armed = true; diff --git a/tools/run-windows-certification.ps1 b/tools/run-windows-certification.ps1 index b4773a9a..a42adfe0 100644 --- a/tools/run-windows-certification.ps1 +++ b/tools/run-windows-certification.ps1 @@ -37,7 +37,9 @@ try { $summary.requiredTestsStatus = if ($required.Count -gt 0 -and $summary.requiredIssues.Count -eq 0 -and $test.ExitCode -eq 0 -and $xml.DocumentElement.GetAttribute('PARTIAL_RUN') -ieq 'FALSE') { 'pass' } else { 'not_passed' } # Keep the aggregate gate strict. Informational findings are preserved for # review, not silently converted into a Store certification pass. - if ($test.ExitCode -ne 0 -or $overall -ine 'PASS') { throw "WACK did not pass: result '$overall', exit $($test.ExitCode). Review the retained XML." } + if ($test.ExitCode -ne 0 -or $overall -ine 'PASS' -or $summary.requiredTestsStatus -ne 'pass') { + throw "WACK did not pass: result '$overall', required tests '$($summary.requiredTestsStatus)', exit $($test.ExitCode). Review the retained XML." + } $summary.status = 'pass' } catch { $summary.reason = $_.Exception.Message From ef239a25b15cc3dc1604b935fccd6b6c5aa8eb13 Mon Sep 17 00:00:00 2001 From: Sourav Das Date: Wed, 4 Nov 2026 23:23:58 +0530 Subject: [PATCH 19/33] Record final Windows WACK pass and Linux package qualification --- docs/certification-0.1.09.md | 62 +++++++++++++++++++++++------------- docs/releases/0.1.09.md | 14 ++++---- 2 files changed, 47 insertions(+), 29 deletions(-) diff --git a/docs/certification-0.1.09.md b/docs/certification-0.1.09.md index 398139f9..2635afc5 100644 --- a/docs/certification-0.1.09.md +++ b/docs/certification-0.1.09.md @@ -15,21 +15,23 @@ The user requested certification checks and a release PR, with publication held. Its Store version is `0.1.9.0` (MSIX requires canonical numeric components); the application and notes retain `0.1.09`. This unsigned package was not registered over the user's installed Store app or submitted to Microsoft. -- **Pass — full Windows installer and MSIX:** the [candidate package job](https://github.com/sdevil7th/OpenStudio/actions/runs/41858351056/job/126673030017) +- **Pass — full Windows installer and MSIX:** the [final candidate package job](https://github.com/sdevil7th/OpenStudio/actions/runs/41868876801/job/126710168849) built the production app, created and validated its MSIX, installed the actual setup executable, passed installed window-lifecycle and frozen Support checks, - and uninstalled it. These packages predate the Support display-scaling fix - below; a rebuilt package and another WACK run are required. -- **Pass — full Linux candidate:** the [final Linux job](https://github.com/sdevil7th/OpenStudio/actions/runs/41868876801/job/126710168141) + and uninstalled it. The [candidate packages](https://github.com/sdevil7th/OpenStudio/actions/runs/41868876801/artifacts/13345943940) + include the Support display-scaling fix at `d8202953`. WACK and Microsoft's + account-backed certification are separate checks. +- **Pass — full Linux candidate:** the [final Linux job](https://github.com/sdevil7th/OpenStudio/actions/runs/41875186464/job/126732566058) built the production app and Support, installed the actual DEB, checked normal and safe desktop launches, render/features, and the AppImage payload and - launchers. The [candidate packages](https://github.com/sdevil7th/OpenStudio/actions/runs/41868876801/artifacts/13343059637) - include the final Support display changes at `d8202953`. AppImage launcher + launchers. The [candidate packages](https://github.com/sdevil7th/OpenStudio/actions/runs/41875186464/artifacts/13348345909) + are from `8f75022a`, with the same production app/Support source as `d8202953`. + AppImage launcher tests used extract-and-run mode; physical desktop/FUSE and Fedora RPM installation remain in the manual matrix. -- **Pass — final Windows browser checks:** all four groups at `d8202953` passed, +- **Pass — final Windows browser checks:** all four groups at `8f75022a` passed, with 295 passing cases and 33 native-schema/explicit-input skips, zero failures and zero - retries. The [run](https://github.com/sdevil7th/OpenStudio/actions/runs/41868876801) + retries. The [run](https://github.com/sdevil7th/OpenStudio/actions/runs/41875186464) retains the evidence. Earlier Windows runs exposed two test-fixture problems: driver round trips made a purported sustained gesture exceed its 200 ms idle boundary, and a second @@ -42,16 +44,29 @@ The user requested certification checks and a release PR, with publication held. the evaluation's pending module-import promise. That fixture now uses the existing document-owned loading pattern from the ruler/track-copy tests, without changing keyboard assertions. Its focused local check passes; the - Linux rerun remains required. Linux now also uses four test groups for faster - feedback, with the system WebKitGTK check retained once per run. + Linux groups 1–3 at `8f75022a` passed (218 cases, 33 declared skips), including + the system WebKitGTK check. Group 4 was cancelled after 30 minutes downloading + Ubuntu browser dependencies, before its tests started. The remaining group + still needs a completed run; GitHub initially refused the scoped retry while + the cancelled run remained open. Linux now also uses four test groups for + faster feedback, with the system WebKitGTK check retained once per run. Native-schema editor tests run separately after the native app build. Tests requiring a private project or explicit native tempo-preview input do not run automatically in this general browser suite. + +Windows package/WACK evidence is from `d8202953`; the Linux package and latest +browser evidence above is from `8f75022a`. Between those commits only CI, a test +fixture, the WACK result checker and this qualification document changed. App, +Support and runtime dependency source is identical. Later documentation updates +do not retroactively change the packaged release notes or certify a new binary. - **Blocked — full macOS candidate execution:** GitHub repeatedly failed to allocate either Mac architecture before any steps ran. The workflow now uses supported macOS 15 images and includes installed universal-DMG checks on both architectures, but these have not completed for this candidate. No alternative - runner is configured. Earlier fixture passes do not clear this gate. + runner is configured. The user requested finishing the available Windows/Linux + work. Native Support builds/tests subsequently passed on both Mac architectures + in [the current run](https://github.com/sdevil7th/OpenStudio/actions/runs/41875186464), + but these component checks do not clear the full application/DMG gate. - **Pass — support packaging:** the [six-job qualification](https://github.com/sdevil7th/OpenStudio/actions/runs/41841385666) covers Windows installer/MSIX fixtures, native macOS and universal Finder/DMG launches on both architectures, and Linux DEB/RPM/AppImage payload checks. @@ -73,24 +88,25 @@ The user requested certification checks and a release PR, with publication held. permits only `v*` tags. It rejected the certification branch before any Store request. The policy has not been changed. The local installed Store app is 0.1.8.0; it is not the proposed candidate and must not be replaced for a test. -- **Not passed — WACK:** the [actual report](https://github.com/sdevil7th/OpenStudio/actions/runs/41858351056/artifacts/13341803458) - returned `WARNING`, exit 0, on Windows Server 2025 (kit 10.0.26100.9457), with - a complete rather than partial run. The required DPI-awareness test warned - about `OpenStudioSupport.exe`. Support now has a PerMonitorV2 manifest, - event-driven font scaling and scrollable content; the rebuilt frozen helper - passes its PMv2-context and 100/150/200% font-scaling checks. Physical mixed-DPI - monitor transitions and the rebuilt MSIX's WACK result remain unasserted. - The tested package SHA-256 was - `039cae8db65124a9212e1ab6b1b786e1f71d4ea78789d0f77f75072ce3566c80`. -- **Informational findings retained:** that WACK XML marks application count +- **Pass — WACK:** the [final report](https://github.com/sdevil7th/OpenStudio/actions/runs/41868876801/artifacts/13348650296) + returned aggregate `PASS`, exit 0, on Windows Server 2025 (kit + 10.0.26100.9457), with `PARTIAL_RUN=FALSE` and all 13 required tests passing. + The earlier required DPI-awareness warning is cleared in the rebuilt MSIX. + Support has a PerMonitorV2 manifest, event-driven font scaling and scrollable + content; its frozen PMv2-context and 100/150/200% font-scaling checks also pass. + Physical mixed-DPI monitor transitions remain **not_asserted**. The tested + package SHA-256 is + `158012e4ea54fe0a3a32e9f970ea85abe37dfc56040f73fe173c633666f05f52`. + This is a certification-kit pass, not Microsoft Store submission or approval. +- **Informational findings retained:** the final WACK XML marks application count (the independent Support entry) and blocked-executable references as optional failures. Microsoft's [Desktop Bridge test guidance](https://learn.microsoft.com/en-us/windows/uwp/debug-test-perf/windows-desktop-bridge-app-tests) distinguishes these from required tests. Process-launch references span the app, Support, Python/Tcl and Microsoft WebView2; this static scan does not prove any process was started during recording. The independent launcher remains; the strict aggregate WACK gate has not been weakened. S-mode compatibility and - Microsoft's final certification remain **not_asserted**. The report retains - every finding for review rather than relabelling `WARNING` as `PASS`. + Microsoft's final certification remain **not_asserted**. WACK itself reports + aggregate `PASS` with these two optional failures; every finding is retained. Merging a PR does not currently run Store submission. The release workflow uses release tags and contains public-release steps. Do not create a `v0.1.09` tag or diff --git a/docs/releases/0.1.09.md b/docs/releases/0.1.09.md index ce5c4e5e..154bf21e 100644 --- a/docs/releases/0.1.09.md +++ b/docs/releases/0.1.09.md @@ -60,12 +60,14 @@ development versions. No 0.1.09 release or Store update has been published. sharing. Crash dumps are optional and can contain private information. - Full Windows installer/MSIX packaging and installed-app checks passed; Linux DEB installation and AppImage qualification passed. These are unpublished test - artifacts. Final Windows browser checks are being rerun. GitHub could not - allocate Mac test runners, and Apple signing/notarization credentials are - unavailable. WACK reported a required Support DPI warning and two - informational findings; the DPI fix needs a rebuilt-package certification run. - Microsoft certification remains outstanding. Earlier Mac support - fixture passes do not certify the full candidate. See the + artifacts. Final Windows browser checks passed. The rebuilt MSIX passed + Windows App Certification Kit overall and all 13 required tests, including + the corrected Support DPI check; two optional findings remain in its report. + Microsoft Store approval remains outstanding. Full macOS qualification has + not completed after runner allocation failures; native Support component + checks passed on both architectures, but Apple signing/notarization + credentials are unavailable. Component passes do not certify the full + candidate. See the [candidate qualification record](../certification-0.1.09.md). - Automated render and MIDI checks establish objective behavior for their test inputs. They do not establish subjective sound quality or compatibility with From b80d47c43f36ee926d5d7f8bca83a39d34117d61 Mon Sep 17 00:00:00 2001 From: Sourav Das Date: Wed, 4 Nov 2026 23:25:35 +0530 Subject: [PATCH 20/33] Document Actions outage blocking final Linux browser qualification --- docs/certification-0.1.09.md | 10 ++++++++-- docs/releases/0.1.09.md | 4 +++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/certification-0.1.09.md b/docs/certification-0.1.09.md index 2635afc5..3ca4e632 100644 --- a/docs/certification-0.1.09.md +++ b/docs/certification-0.1.09.md @@ -48,8 +48,14 @@ The user requested certification checks and a release PR, with publication held. the system WebKitGTK check. Group 4 was cancelled after 30 minutes downloading Ubuntu browser dependencies, before its tests started. The remaining group still needs a completed run; GitHub initially refused the scoped retry while - the cancelled run remained open. Linux now also uses four test groups for - faster feedback, with the system WebKitGTK check retained once per run. + the cancelled run remained open. At 17:55 UTC GitHub was reporting an + [Actions incident](https://www.githubstatus.com/incidents/h02n9572mx9y), opened + at 17:40 UTC; the documentation push had not produced a new run either. + **The remaining Linux browser group is infrastructure-blocked, not passed.** + Linux now also uses four test groups for faster feedback, with the system + WebKitGTK check retained once per run. After Actions recovers, finish group 4 + on the unchanged application source and retain its result before clearing this + software verification item. No more retries were started during the outage. Native-schema editor tests run separately after the native app build. Tests requiring a private project or explicit native tempo-preview input do not run automatically in this general browser suite. diff --git a/docs/releases/0.1.09.md b/docs/releases/0.1.09.md index 154bf21e..e71709f8 100644 --- a/docs/releases/0.1.09.md +++ b/docs/releases/0.1.09.md @@ -63,7 +63,9 @@ development versions. No 0.1.09 release or Store update has been published. artifacts. Final Windows browser checks passed. The rebuilt MSIX passed Windows App Certification Kit overall and all 13 required tests, including the corrected Support DPI check; two optional findings remain in its report. - Microsoft Store approval remains outstanding. Full macOS qualification has + One final Linux browser-test group remains unqualified after a dependency + download stall and a GitHub Actions outage. Microsoft Store approval remains + outstanding. Full macOS qualification has not completed after runner allocation failures; native Support component checks passed on both architectures, but Apple signing/notarization credentials are unavailable. Component passes do not certify the full From 2ba27083a6247da24b545b7bd3bc2eb85006b614 Mon Sep 17 00:00:00 2001 From: Sourav Das Date: Wed, 4 Nov 2026 23:51:21 +0530 Subject: [PATCH 21/33] Add explicit contention controls and record final Linux qualification --- Source/Main.cpp | 2 + Source/NAMLiveCallbackProbe.h | 76 ++++++++++++++++++++++++++++++----- docs/certification-0.1.09.md | 76 +++++++++++++++++++++++++++++------ docs/releases/0.1.09.md | 11 +++-- 4 files changed, 139 insertions(+), 26 deletions(-) diff --git a/Source/Main.cpp b/Source/Main.cpp index a0ff9052..b78f5949 100644 --- a/Source/Main.cpp +++ b/Source/Main.cpp @@ -1497,6 +1497,8 @@ class OpenStudioApplication : public juce::JUCEApplication options.buffers.push_back(value.getIntValue()); } options.recording = commandLineHasFlag(commandLine, "--probe-recording"); + options.threadCycles = commandLineHasFlag(commandLine, "--probe-thread-cycles"); + options.bypassFX = commandLineHasFlag(commandLine, "--probe-bypass-fx"); #if JUCE_WINDOWS ::SetPriorityClass(::GetCurrentProcess(), ABOVE_NORMAL_PRIORITY_CLASS); #endif diff --git a/Source/NAMLiveCallbackProbe.h b/Source/NAMLiveCallbackProbe.h index 24bfc88d..790170a7 100644 --- a/Source/NAMLiveCallbackProbe.h +++ b/Source/NAMLiveCallbackProbe.h @@ -9,6 +9,9 @@ #include #include #include +#if JUCE_WINDOWS + #include +#endif // Explicit opt-in hardware diagnostic. It never exposes physical input samples // to the engine, and the device receives silence regardless of the engine output. @@ -23,6 +26,8 @@ struct Options int warmupSeconds = 10, measurementSeconds = 10; std::vector buffers {8, 16, 128}; bool recording = false; + bool threadCycles = false; + bool bypassFX = false; int workerCount = -1; int workerSpinMicroseconds = 0; int recoveryGraceMicroseconds = 0; @@ -47,7 +52,38 @@ inline bool validOptions(const Options& value) class Callback final : public juce::AudioIODeviceCallback { public: - struct Record { juce::int64 frame, arrival, duration; int samples; }; + struct ThreadSample + { + juce::uint64 cycles = 0; + int processor = -1; + bool valid = false; + }; + static ThreadSample sampleThread(bool requested) noexcept + { + ThreadSample result; + #if JUCE_WINDOWS + if (requested) + { + ULONG64 cycles = 0; + result.valid = QueryThreadCycleTime(GetCurrentThread(), &cycles) != FALSE; + result.cycles = cycles; + PROCESSOR_NUMBER processor {}; + GetCurrentProcessorNumberEx(&processor); + result.processor = static_cast(processor.Group) * 256 + processor.Number; + } + #else + juce::ignoreUnused(requested); + #endif + return result; + } + struct Record + { + juce::int64 frame, arrival, duration; + int samples; + juce::uint64 cycles = 0; + int processorStart = -1, processorEnd = -1; + bool cyclesValid = false; + }; Callback(AudioEngine& owner, const Options& requested) : engine(owner), options(requested), cycleFrames(static_cast(options.sampleRate * 2.0)), input(options.trackCount, cycleFrames + scratchCapacity), output(2, scratchCapacity), records(65536), missedRecords(4096) @@ -108,12 +144,17 @@ class Callback final : public juce::AudioIODeviceCallback float* scratch[] { output.getWritePointer(0), output.getWritePointer(1) }; output.clear(0, samples); const auto before = juce::Time::getHighResolutionTicks(); + const auto threadStart = sampleThread(options.threadCycles); engine.audioDeviceIOCallbackWithContext(synthetic.data(), options.trackCount, scratch, 2, samples, context); + const auto threadEnd = sampleThread(options.threadCycles); const auto duration = juce::Time::getHighResolutionTicks() - before; if (frames >= warmupFrames && frames < warmupFrames + targetFrames) { const auto interval = previousArrival == 0 ? 0 : arrival - previousArrival; - const Record record { frames, interval, duration, samples }; + const bool cyclesValid = threadStart.valid && threadEnd.valid && threadEnd.cycles >= threadStart.cycles; + const Record record { frames, interval, duration, samples, + cyclesValid ? threadEnd.cycles - threadStart.cycles : 0, + threadStart.processor, threadEnd.processor, cyclesValid }; if (measuredBlocks % traceStride == 0 && count < records.size()) records[count++] = record; ++measuredBlocks; measuredFrames += samples; totalTicks += duration; maximumTicks = juce::jmax(maximumTicks, duration); ++durationHistogram[histogramBin(duration)]; @@ -138,14 +179,16 @@ class Callback final : public juce::AudioIODeviceCallback juce::var report(const juce::File& csv) const // Only after removeAudioCallback has joined callbacks. { auto* result = new juce::DynamicObject(); - juce::String text("kind,frame,samples,engine_microseconds,arrival_interval_microseconds\n"); - text.preallocateBytes(112 * (count + missedCount) + 128); + juce::String text("kind,frame,samples,engine_microseconds,arrival_interval_microseconds,thread_cycles,cycles_valid,processor_start,processor_end\n"); + text.preallocateBytes(160 * (count + missedCount) + 256); const double budget = requestedBlock * 1.0e6 / options.sampleRate; const auto append = [&](const Record& row, const char* kind) { const double elapsed = juce::Time::highResolutionTicksToSeconds(row.duration) * 1.0e6; const double interval = juce::Time::highResolutionTicksToSeconds(row.arrival) * 1.0e6; - text << kind << ',' << row.frame << ',' << row.samples << ',' << juce::String(elapsed, 3) << ',' << juce::String(interval, 3) << '\n'; + text << kind << ',' << row.frame << ',' << row.samples << ',' << juce::String(elapsed, 3) << ',' << juce::String(interval, 3) + << ',' << juce::String(row.cycles) << ',' << (row.cyclesValid ? 1 : 0) + << ',' << row.processorStart << ',' << row.processorEnd << '\n'; }; for (size_t index = 0; index < count; ++index) append(records[index], "uniform"); for (size_t index = 0; index < missedCount; ++index) append(missedRecords[index], "deadline_miss"); @@ -161,6 +204,8 @@ class Callback final : public juce::AudioIODeviceCallback result->setProperty("durationHistogramOverflow", durationHistogram.back()); result->setProperty("arrivalHistogramOverflow", arrivalHistogram.back()); result->setProperty("uniformTraceStride", traceStride); result->setProperty("uniformTraceRows", static_cast(count)); result->setProperty("deadlineTraceRows", static_cast(missedCount)); result->setProperty("traceScope", "Bounded uniform sample plus first 4096 deadline misses; duplicate rows are intentional. Summary counts, extrema and histograms include every measured callback."); + result->setProperty("threadCyclesRequested", options.threadCycles); + result->setProperty("threadCycleScope", "Diagnostic only: raw thread cycles, not CPU time. Processor endpoints do not exclude intermediate migration or interrupts. Optional queries are included in callback wall duration."); result->setProperty("arrivalIntervalsAbove150Percent", lateArrivals); result->setProperty("arrivalStatus", "diagnostic_only: driver batching and scheduling both affect arrival intervals"); result->setProperty("outputPeak", peak); result->setProperty("outputSamplesAtOrAboveFullScale", clipped); result->setProperty("nonFiniteOutputSamples", nonFinite); result->setProperty("clippingStatus", "diagnostic_only: synthetic signal and unchanged preset gains"); @@ -394,6 +439,14 @@ inline int run(const juce::String& nameFragment, const juce::File& guitar, const const auto xml = actual.createXml(); stateReadbacks.add(xml != nullptr ? xml->toString() : juce::String()); } check("state_restore_" + juce::String(block), ready, stateReadbacks); + if (options.bypassFX) + for (const auto& id : tracks) + { + engine->bypassTrackFX(id, 0, true); + const auto fx = engine->getTrackFX(id); + ready = ready && fx.isArray() && fx.size() == 1 && static_cast(fx[0].getProperty("bypassed", false)); + } + check("fx_workload_" + juce::String(block), ready, options.bypassFX ? "Host-bypassed NAM control; not a loaded-NAM capacity result." : "Loaded NAM workload unchanged."); const auto recordingDirectory = directory.getChildFile("recordings-buffer-" + juce::String(block)); if (options.recording) { @@ -431,7 +484,8 @@ inline int run(const juce::String& nameFragment, const juce::File& guitar, const for (const auto& id : tracks) { const auto state = engine->getNAMRackDiagnostics(id, "track", 0); diagnostics.add(state); - witnesses = witnesses && static_cast(state.getProperty("lastAmpDspFrames", 0)) > 0 + if (!options.bypassFX) + witnesses = witnesses && static_cast(state.getProperty("lastAmpDspFrames", 0)) > 0 && static_cast(state.getProperty("embeddedDriveRequestedOversamplingFactor", 0)) == options.oversamplingFactor && static_cast(state.getProperty("embeddedDriveActiveOversamplingFactor", 0)) == options.oversamplingFactor && static_cast(state.getProperty("routedInputChannelCount", 0)) == 1 @@ -493,8 +547,10 @@ inline int run(const juce::String& nameFragment, const juce::File& guitar, const if (workerCountersValid && !workerCapacity) { detail->setProperty("capacityStatus", "fail"); detail->setProperty("engineCapacityStatus", "fail"); } if (accepted && xruns > 0) detail->setProperty("capacityStatus", "fail"); detail->setProperty("engineCapacityScope", "Callback-duration deadlines and worker completion; cancelled/rejected/quarantined DSP is a capacity failure. Driver telemetry remains separate."); - check("loaded_callback_" + juce::String(block), valid, "Driver settings, finite nonzero synthetic output, loaded NAM execution, " - + juce::String(options.oversamplingFactor) + "x oversampling, MMCSS and callback safety counters."); + check(juce::String(options.bypassFX ? "bypassed_callback_" : "loaded_callback_") + juce::String(block), valid, + options.bypassFX ? "Driver settings, finite nonzero synthetic output, verified host bypass, MMCSS and callback safety counters. Not loaded-NAM capacity." + : "Driver settings, finite nonzero synthetic output, loaded NAM execution, " + + juce::String(options.oversamplingFactor) + "x oversampling, MMCSS and callback safety counters."); cases.add(row); manager.closeAudioDevice(); if (!accepted) break; } @@ -524,6 +580,8 @@ inline int run(const juce::String& nameFragment, const juce::File& guitar, const result->setProperty("trackCount", options.trackCount); result->setProperty("requestedWorkerCount", options.workerCount); result->setProperty("recordingRequested", options.recording); result->setProperty("requestedWorkerSpinMicroseconds", options.workerSpinMicroseconds); result->setProperty("requestedRecoveryGraceMicroseconds", options.recoveryGraceMicroseconds); + result->setProperty("workload", options.bypassFX ? "host_bypassed_nam_control" : "loaded_nam"); + result->setProperty("threadCyclesRequested", options.threadCycles); result->setProperty("inputFixtureVersion", 2); result->setProperty("inputFixturePeriodSeconds", 2); juce::Array requestedBuffers; for (int buffer : options.buffers) requestedBuffers.add(buffer); result->setProperty("requestedBuffers", requestedBuffers); #if JUCE_DEBUG @@ -534,7 +592,7 @@ inline int run(const juce::String& nameFragment, const juce::File& guitar, const const auto executable = juce::File::getSpecialLocation(juce::File::currentExecutableFile); result->setProperty("executable", executable.getFullPathName()); result->setProperty("executableSha256", juce::SHA256(executable).toHexString()); result->setProperty("inputPolicy", "Physical input pointers are never read or recorded; independent periodic guitar/bass mono fixtures feed unique synthetic channels. Physical I/O stays at two channels, and physical outputs are always silent."); - result->setProperty("timingScope", "Wall time of the full AudioEngine callback on the real ASIO callback thread, including engine telemetry. Excludes proxy preparation/inspection and surrounding JUCE/ASIO wrapper work. No WebView is loaded."); + result->setProperty("timingScope", "Wall time of the full AudioEngine callback on the real ASIO callback thread, including engine telemetry and optional requested thread-cycle queries. Excludes proxy preparation/inspection and surrounding JUCE/ASIO wrapper work. No WebView is loaded."); result->setProperty("audioQuality", "not_asserted: silent synthetic hardware qualification cannot establish audible live crackle, tone or instrument routing"); result->setProperty("restorationScope", "Inactive ASIO observed rate/preferred buffer restored; other audio clients must be closed. User settings and source presets are never saved."); const bool written = directory.getChildFile("result.json").replaceWithText(juce::JSON::toString(juce::var(result), true)); diff --git a/docs/certification-0.1.09.md b/docs/certification-0.1.09.md index 3ca4e632..98f05cce 100644 --- a/docs/certification-0.1.09.md +++ b/docs/certification-0.1.09.md @@ -44,24 +44,23 @@ The user requested certification checks and a release PR, with publication held. the evaluation's pending module-import promise. That fixture now uses the existing document-owned loading pattern from the ruler/track-copy tests, without changing keyboard assertions. Its focused local check passes; the - Linux groups 1–3 at `8f75022a` passed (218 cases, 33 declared skips), including - the system WebKitGTK check. Group 4 was cancelled after 30 minutes downloading - Ubuntu browser dependencies, before its tests started. The remaining group - still needs a completed run; GitHub initially refused the scoped retry while - the cancelled run remained open. At 17:55 UTC GitHub was reporting an - [Actions incident](https://www.githubstatus.com/incidents/h02n9572mx9y), opened - at 17:40 UTC; the documentation push had not produced a new run either. - **The remaining Linux browser group is infrastructure-blocked, not passed.** - Linux now also uses four test groups for faster feedback, with the system - WebKitGTK check retained once per run. After Actions recovers, finish group 4 - on the unchanged application source and retain its result before clearing this - software verification item. No more retries were started during the outage. + **Final Linux browser check: pass.** All four groups at `b80d47c4` completed + in [run 41882180129](https://github.com/sdevil7th/OpenStudio/actions/runs/41882180129): + 54 + 88 + 76 + 77 = 295 passing cases, 33 declared skips, zero failures and + zero retries. The system WebKitGTK check also passed. The previously outstanding + [group 4](https://github.com/sdevil7th/OpenStudio/actions/runs/41882180129/job/126757203827) + completed at 18:16 UTC, retaining its + [test artifact](https://github.com/sdevil7th/OpenStudio/actions/runs/41882180129/artifacts/13351596111). + The earlier dependency-download stall and Actions outage did not run those + assertions; this subsequent completed run clears that software verification + item. No behavior assertions or retry policy were relaxed. Native-schema editor tests run separately after the native app build. Tests requiring a private project or explicit native tempo-preview input do not run automatically in this general browser suite. Windows package/WACK evidence is from `d8202953`; the Linux package and latest -browser evidence above is from `8f75022a`. Between those commits only CI, a test +Windows browser evidence above is from `8f75022a`; final Linux browser evidence +is from the documentation-only `b80d47c4`. Between those commits only CI, a test fixture, the WACK result checker and this qualification document changed. App, Support and runtime dependency source is identical. Later documentation updates do not retroactively change the packaged release notes or certify a new binary. @@ -161,6 +160,57 @@ Local evidence is retained under `output/candidate-0.1.09/hardware/`, with separate `1-tracks/result.json` and `4-tracks/result.json` reports. Those contain local device/preset details and are not automatically uploaded or committed. +## Game-contention investigation — 4 November, 18:00–18:18 UTC + +The user explicitly requested leaving Dota 2 running. These are contention +diagnostics on the Ryzen 9 7900X / Audient ASIO host, with Balanced power mode, +48 kHz / 8 samples, synthetic input and silent physical outputs. Support was +closed. No game setting, power mode, affinity or buffer requirement was changed. +There is no matched game-closed baseline and no claim that the game caused each +miss. The four loaded tracks alternate the same guitar/bass presets with 4x +oversampling. Each case requests three seconds of warmup and 30 measured seconds. + +| Condition | Measured callbacks | Mean / p99 / max, microseconds | Outer deadline misses | Result | +| --- | --- | --- | --- | --- | +| Loaded NAM, normal workers | 180,000 | 107.8 / 222.0 / 488.5 | 7,667 | **fail** | +| Loaded NAM, workers disabled | 145,080 | 284.4 / 554.0 / 1,202.1 | 145,080 | Incomplete measurement; every observed callback exceeded budget | +| Loaded NAM, explicit 200 µs worker spin experiment | 180,000 | 110.5 / 193.4 / 399.3 | 3,073 | **fail**; extra CPU use, not adopted | +| Four host-bypassed tracks, normal workers, cycle queries enabled | 180,000 | 12.3 / 34.5 / 431.0 | 5 | **fail**, control only | +| One host-bypassed track, no workers, cycle queries disabled | 180,000 | 1.95 / 10.4 / 161.6 | 0 | **pass**, this minimal control only | + +The loaded normal-worker case also had 35,178 worker deadline misses, 41,237 +quarantined blocks and 135 cancelled jobs over warmup plus measurement. The spin +experiment still had 11,674 worker misses, 16,687 quarantined blocks and four +cancelled jobs. Worker counts cover a different interval and are not added to +outer callback counts. All five raw-recording comparisons passed; this does not +qualify the processed monitoring path. The incomplete serial run is not counted +as a processing-integrity pass. + +The first three cases use Release SHA-256 +`84e529369dd7e2655f420f5130558ece85359dd92c470865889b692dcfa5719b`. +The controls use `4224e2d78c1153541e95610205577b493d2ae79888d6da7d1ec6b07264fc72d3`, +which adds only explicit diagnostic-probe options, not a production audio fix. +`--probe-bypass-fx` verifies host bypass and labels the report as a control; +`--probe-thread-cycles` adds raw Windows thread cycles and processor endpoints. +Both are off by default and only used inside `--nam-live-callback-probe`. +The probe-only source change builds in Windows Release and Debug with zero +C++ warnings; the packaged frontend in Debug matches `frontend/dist`. It does +not extend the earlier installer/WACK result to this new executable hash. +Cycle-query cost is included in reported callback duration. Two of the five +late four-track control callbacks changed processor between their endpoints. +That demonstrates migration, not the duration or cause of the delay; cycles +are not converted to CPU time and are not proof of a particular driver fault. + +An offline stage-isolation diagnostic also completed with processing/parity +checks passing. Disabling whole FX stages changes the workload and sound, so +those timings do not justify silently bypassing FX or establish a production +optimization. No worker-spin, extra-buffer, hidden-delay or dropped-work +acceptance change was promoted. The minimal control pass does not clear either +the loaded NAM failure or the earlier 31-track UI/recording gate. **PR #36 remains +a draft: the eight-sample failure has not been resolved.** Physical driver xruns +are unsupported; monitoring continuity, loopback latency and audition remain +**not_asserted**. Results under `output/eight-sample-investigation/` remain local. + ## Physical test matrix Use copies of projects, and record the candidate file hash, app version, OS, diff --git a/docs/releases/0.1.09.md b/docs/releases/0.1.09.md index e71709f8..65f971af 100644 --- a/docs/releases/0.1.09.md +++ b/docs/releases/0.1.09.md @@ -52,8 +52,11 @@ development versions. No 0.1.09 release or Store update has been published. that does not establish continuous monitoring. Short 64-sample controls had no measured misses; they do not clear the 8-sample failure or qualify long sessions. These are different workloads from the earlier 31-track test, not - a before/after comparison. Physical recording, audition and game contention - still require exact-artifact hardware qualification. + a before/after comparison. A subsequent Dota 2 contention test also failed + with four loaded NAM tracks. Disabling parallel processing made that workload + worse; a worker-spin experiment still failed and was not adopted. Minimal + FX-bypassed controls do not clear the loaded-workload gate. Physical recording, + monitoring continuity and audition still require exact-artifact qualification. - Offline support cannot reconstruct unmeasured CPU scheduling or RAM pressure at a past glitch, prove that every intended instrument was audible, or replace a physical signal reference. Redaction is best effort; inspect archives before @@ -63,8 +66,8 @@ development versions. No 0.1.09 release or Store update has been published. artifacts. Final Windows browser checks passed. The rebuilt MSIX passed Windows App Certification Kit overall and all 13 required tests, including the corrected Support DPI check; two optional findings remain in its report. - One final Linux browser-test group remains unqualified after a dependency - download stall and a GitHub Actions outage. Microsoft Store approval remains + All four final Linux browser-test groups passed: 295 cases, 33 declared skips, + zero failures and zero retries. Microsoft Store approval remains outstanding. Full macOS qualification has not completed after runner allocation failures; native Support component checks passed on both architectures, but Apple signing/notarization From fa87409e783ed739ae1ba012c1e6d38b86c1998f Mon Sep 17 00:00:00 2001 From: Sourav Das Date: Thu, 5 Nov 2026 00:33:53 +0530 Subject: [PATCH 22/33] Prepare native NAM track effects for the actual host block --- Source/NAMLiveCallbackProbe.h | 26 +++++-- Source/TrackProcessor.cpp | 135 +++++++++++++++++++++++++++++++--- docs/certification-0.1.09.md | 89 ++++++++++++++++++++++ docs/nam-rack.md | 6 ++ docs/releases/0.1.09.md | 11 +++ 5 files changed, 250 insertions(+), 17 deletions(-) diff --git a/Source/NAMLiveCallbackProbe.h b/Source/NAMLiveCallbackProbe.h index 790170a7..c748a082 100644 --- a/Source/NAMLiveCallbackProbe.h +++ b/Source/NAMLiveCallbackProbe.h @@ -56,6 +56,7 @@ class Callback final : public juce::AudioIODeviceCallback { juce::uint64 cycles = 0; int processor = -1; + unsigned long threadId = 0; bool valid = false; }; static ThreadSample sampleThread(bool requested) noexcept @@ -67,6 +68,7 @@ class Callback final : public juce::AudioIODeviceCallback ULONG64 cycles = 0; result.valid = QueryThreadCycleTime(GetCurrentThread(), &cycles) != FALSE; result.cycles = cycles; + result.threadId = GetCurrentThreadId(); PROCESSOR_NUMBER processor {}; GetCurrentProcessorNumberEx(&processor); result.processor = static_cast(processor.Group) * 256 + processor.Number; @@ -83,6 +85,8 @@ class Callback final : public juce::AudioIODeviceCallback juce::uint64 cycles = 0; int processorStart = -1, processorEnd = -1; bool cyclesValid = false; + juce::int64 startTicks = 0; + unsigned long threadId = 0; }; Callback(AudioEngine& owner, const Options& requested) : engine(owner), options(requested), cycleFrames(static_cast(options.sampleRate * 2.0)), @@ -154,7 +158,7 @@ class Callback final : public juce::AudioIODeviceCallback const bool cyclesValid = threadStart.valid && threadEnd.valid && threadEnd.cycles >= threadStart.cycles; const Record record { frames, interval, duration, samples, cyclesValid ? threadEnd.cycles - threadStart.cycles : 0, - threadStart.processor, threadEnd.processor, cyclesValid }; + threadStart.processor, threadEnd.processor, cyclesValid, before, threadStart.threadId }; if (measuredBlocks % traceStride == 0 && count < records.size()) records[count++] = record; ++measuredBlocks; measuredFrames += samples; totalTicks += duration; maximumTicks = juce::jmax(maximumTicks, duration); ++durationHistogram[histogramBin(duration)]; @@ -179,8 +183,11 @@ class Callback final : public juce::AudioIODeviceCallback juce::var report(const juce::File& csv) const // Only after removeAudioCallback has joined callbacks. { auto* result = new juce::DynamicObject(); - juce::String text("kind,frame,samples,engine_microseconds,arrival_interval_microseconds,thread_cycles,cycles_valid,processor_start,processor_end\n"); - text.preallocateBytes(160 * (count + missedCount) + 256); + // String concatenation rescans the accumulated CSV on every append. + // Stream into a pre-sized buffer so large diagnostic reports finish in + // linear time after the audio callback has been removed. + juce::MemoryOutputStream text(208 * (count + missedCount) + 256); + text << "kind,frame,samples,engine_microseconds,arrival_interval_microseconds,thread_cycles,cycles_valid,processor_start,processor_end,start_ticks,end_ticks,thread_id\n"; const double budget = requestedBlock * 1.0e6 / options.sampleRate; const auto append = [&](const Record& row, const char* kind) { @@ -188,7 +195,9 @@ class Callback final : public juce::AudioIODeviceCallback const double interval = juce::Time::highResolutionTicksToSeconds(row.arrival) * 1.0e6; text << kind << ',' << row.frame << ',' << row.samples << ',' << juce::String(elapsed, 3) << ',' << juce::String(interval, 3) << ',' << juce::String(row.cycles) << ',' << (row.cyclesValid ? 1 : 0) - << ',' << row.processorStart << ',' << row.processorEnd << '\n'; + << ',' << row.processorStart << ',' << row.processorEnd + << ',' << row.startTicks << ',' << (row.startTicks + row.duration) + << ',' << juce::String(static_cast(row.threadId)) << '\n'; }; for (size_t index = 0; index < count; ++index) append(records[index], "uniform"); for (size_t index = 0; index < missedCount; ++index) append(missedRecords[index], "deadline_miss"); @@ -205,13 +214,20 @@ class Callback final : public juce::AudioIODeviceCallback result->setProperty("uniformTraceStride", traceStride); result->setProperty("uniformTraceRows", static_cast(count)); result->setProperty("deadlineTraceRows", static_cast(missedCount)); result->setProperty("traceScope", "Bounded uniform sample plus first 4096 deadline misses; duplicate rows are intentional. Summary counts, extrema and histograms include every measured callback."); result->setProperty("threadCyclesRequested", options.threadCycles); + result->setProperty("clockTicksPerSecond", juce::Time::getHighResolutionTicksPerSecond()); + #if JUCE_WINDOWS + result->setProperty("clockDomain", "Windows QueryPerformanceCounter; boundaries include requested diagnostic queries."); + result->setProperty("processId", static_cast(GetCurrentProcessId())); + #else + result->setProperty("clockDomain", "JUCE high-resolution clock; Windows ETW correlation unavailable."); + #endif result->setProperty("threadCycleScope", "Diagnostic only: raw thread cycles, not CPU time. Processor endpoints do not exclude intermediate migration or interrupts. Optional queries are included in callback wall duration."); result->setProperty("arrivalIntervalsAbove150Percent", lateArrivals); result->setProperty("arrivalStatus", "diagnostic_only: driver batching and scheduling both affect arrival intervals"); result->setProperty("outputPeak", peak); result->setProperty("outputSamplesAtOrAboveFullScale", clipped); result->setProperty("nonFiniteOutputSamples", nonFinite); result->setProperty("clippingStatus", "diagnostic_only: synthetic signal and unchanged preset gains"); result->setProperty("capacityStatus", !valid ? "not_asserted" : deadlineMisses == 0 ? "pass" : "fail"); result->setProperty("engineCapacityStatus", !valid ? "not_asserted" : deadlineMisses == 0 ? "pass" : "fail"); - result->setProperty("traceWritten", !csv.exists() && csv.replaceWithText(text)); + result->setProperty("traceWritten", !csv.exists() && csv.replaceWithData(text.getData(), text.getDataSize())); return juce::var(result); } std::atomic finished {false}, invalid {false}; diff --git a/Source/TrackProcessor.cpp b/Source/TrackProcessor.cpp index 0cc4441f..7ee08d12 100644 --- a/Source/TrackProcessor.cpp +++ b/Source/TrackProcessor.cpp @@ -508,8 +508,15 @@ static void logToDisk(const juce::String& msg) f.appendText(juce::Time::getCurrentTime().toString(true, true) + ": " + msg + "\n"); } -static int getSafeHostedPluginBlockSize(int requestedBlockSize) -{ +static int getSafeHostedPluginBlockSize(int requestedBlockSize, + const juce::AudioProcessor* processor = nullptr) +{ + // Native NAM supports the actual host block size. Preparing its zero-latency + // cabinet convolution for 512 while processing eight samples repeats a + // 1024-point head FFT every callback. Keep the compatibility reserve for + // other hosted processors; this choice runs only during preparation. + if (requestedBlockSize > 0 && dynamic_cast(processor) != nullptr) + return requestedBlockSize; return juce::jmax(kMinimumHostedPluginBlockSize, requestedBlockSize > 0 ? requestedBlockSize : kMinimumHostedPluginBlockSize); } @@ -2232,7 +2239,7 @@ static void preparePluginPreservingLayout(juce::AudioProcessor* plugin, double s int routedInputChannels = 2) { const juce::ScopedLock pluginCallbackGuard(plugin->getCallbackLock()); - const int safeMaxBlock = getSafeHostedPluginBlockSize(maxBlock); + const int safeMaxBlock = getSafeHostedPluginBlockSize(maxBlock, plugin); // NAM graph topology depends on the host route width. Publish it before // prepare/reset so a newly inserted mono guitar Rack cannot begin in its @@ -2335,9 +2342,8 @@ void TrackProcessor::prepareToPlay (double sampleRate, int samplesPerBlock) pdcCurrentDelaySamples)); } - // Prepare plugins with the actual device block size so realtime hosting - // matches the hardware callback configuration. - int pluginMaxBlock = getSafeHostedPluginBlockSize(samplesPerBlock); + // Preserve the actual device size until the per-processor policy is applied. + const int pluginMaxBlock = samplesPerBlock; // Propagate new sample rate and buffer size to all internal FX plugins, // preserving each plugin's bus layout (see preparePluginPreservingLayout). @@ -3039,11 +3045,116 @@ juce::var TrackProcessor::runParallelNAMEligibilityRegression() const bool releasedRejected = rejectsUnchanged(parallel->getParallelNAMGraphGeneration()); parallel->removeTrackFX(0); const bool emptyPublicationCleared = parallel->getParallelNAMGraphGeneration() == 0; + const auto nativePreparation = [] + { + struct OtherProcessor final : OpenStudioEQ + { + // A matching display name must not select the native preparation policy. + const juce::String getName() const override { return "OpenStudio NAM Rack"; } + void prepareToPlay(double rate, int block) override + { lastPreparedBlock = block; OpenStudioEQ::prepareToPlay(rate, block); } + int lastPreparedBlock = 0; + }; + auto track = std::make_unique(); + auto input = std::make_unique(); + auto output = std::make_unique(); + auto otherInput = std::make_unique(); + auto otherOutput = std::make_unique(); + auto* inputRack = input.get(); auto* outputRack = output.get(); + auto* inputOther = otherInput.get(); auto* outputOther = otherOutput.get(); + bool preparation = track->addInputFX(std::move(input), 48000.0, 8) + && track->addTrackFX(std::move(output), 48000.0, 8) + && track->addInputFX(std::move(otherInput), 48000.0, 8) + && track->addTrackFX(std::move(otherOutput), 48000.0, 8); + const auto matches = [&](int block) + { + return static_cast(inputRack->getDiagnosticState()["preparedBlockSize"]) == block + && static_cast(outputRack->getDiagnosticState()["preparedBlockSize"]) == block + && inputOther->lastPreparedBlock == juce::jmax(512, block) + && outputOther->lastPreparedBlock == juce::jmax(512, block); + }; + preparation = preparation && matches(8); + for (int block : {8, 16, 64, 512, 1024, 8}) + { + track->prepareToPlay(48000.0, block); + preparation = matches(block) && preparation; + track->setInputFXPrecisionOverride(0, true); + track->setTrackFXPrecisionOverride(0, true); + preparation = matches(block) && preparation; + track->setProcessingPrecisionMode(ProcessingPrecisionMode::Hybrid64); + preparation = matches(block) && preparation; + track->setProcessingPrecisionMode(ProcessingPrecisionMode::Float32); + preparation = matches(block) && preparation; + } + track->releaseResources(); + + float maximumDifference = 0.0f, referencePeak = 0.0f; + bool convolutionParity = true, zeroLatency = true; + int comparedSamples = 0; + // Same JUCE cabinet algorithm and IR normalization, changing only its + // prepared maximum block. Include mono/stereo IRs, dual-mono selection, + // source-rate conversion, an impulse, signal and the complete tail. + for (int mode : {0, 1, 2}) + for (int block : {8, 16, 64}) + { + juce::AudioBuffer ir(mode == 0 ? 1 : 2, 1537); + for (int channel = 0; channel < ir.getNumChannels(); ++channel) + for (int frame = 0; frame < ir.getNumSamples(); ++frame) + ir.setSample(channel, frame, frame == 0 ? 0.8f + : 0.06f * std::sin(static_cast(frame) * (0.071f + channel * 0.019f)) + * std::exp(-static_cast(frame) / 300.0f)); + juce::dsp::Convolution reference {juce::dsp::Convolution::NonUniform {256}}; + juce::dsp::Convolution candidate {juce::dsp::Convolution::NonUniform {256}}; + const auto stereo = mode == 2 ? juce::dsp::Convolution::Stereo::no + : juce::dsp::Convolution::Stereo::yes; + for (auto* convolution : {&reference, &candidate}) + convolution->loadImpulseResponse(juce::AudioBuffer(ir), 44100.0, stereo, + juce::dsp::Convolution::Trim::yes, juce::dsp::Convolution::Normalise::yes); + reference.prepare({48000.0, 512, 2}); + candidate.prepare({48000.0, static_cast(block), 2}); + zeroLatency = reference.getLatency() == 0 && candidate.getLatency() == 0 && zeroLatency; + juce::AudioBuffer expected(2, block), actual(2, block); + for (int offset = 0; offset < 6144; offset += block) + { + for (int channel = 0; channel < 2; ++channel) + for (int frame = 0; frame < block; ++frame) + { + const int absoluteFrame = offset + frame; + const float value = absoluteFrame == 0 ? 0.2f + : absoluteFrame < 2048 ? 0.1f * std::sin(absoluteFrame * (0.031f + channel * 0.007f)) : 0.0f; + expected.setSample(channel, frame, value); actual.setSample(channel, frame, value); + } + auto expectedBlock = juce::dsp::AudioBlock(expected); + auto actualBlock = juce::dsp::AudioBlock(actual); + reference.process(juce::dsp::ProcessContextReplacing(expectedBlock)); + candidate.process(juce::dsp::ProcessContextReplacing(actualBlock)); + for (int channel = 0; channel < 2; ++channel) + for (int frame = 0; frame < block; ++frame) + { + const float wanted = expected.getSample(channel, frame), got = actual.getSample(channel, frame); + convolutionParity = std::isfinite(wanted) && std::isfinite(got) && convolutionParity; + maximumDifference = juce::jmax(maximumDifference, std::abs(wanted - got)); + referencePeak = juce::jmax(referencePeak, std::abs(wanted)); + ++comparedSamples; + } + } + } + auto* evidence = new juce::DynamicObject(); + evidence->setProperty("pass", preparation && convolutionParity && zeroLatency + && referencePeak > 0.01f && maximumDifference < 0.00002f); + evidence->setProperty("nativeHostSizeAndOtherProcessorReserve", preparation); + evidence->setProperty("zeroAddedConvolutionLatency", zeroLatency); + evidence->setProperty("maximumPCMError", maximumDifference); + evidence->setProperty("comparedSamples", comparedSamples); + evidence->setProperty("scope", "Insertion, device/render-size reprepare and precision changes; native NAM gets actual capacity, other processors retain >=512. Cabinet impulse/signal/tail parity within 2e-5; no audition or hardware capacity claim."); + return juce::var(evidence); + }(); auto* result = new juce::DynamicObject(); - result->setProperty("pass", static_cast(tinyRegression["pass"]) && automationOrderParity && static_cast(probeRegression["pass"]) && emptyRejected && zeroRejected && parity && staleRejected && midiRejected + result->setProperty("pass", static_cast(nativePreparation["pass"]) && static_cast(tinyRegression["pass"]) && automationOrderParity && static_cast(probeRegression["pass"]) && emptyRejected && zeroRejected && parity && staleRejected && midiRejected && instrumentRejected && araRejected && busyRejected && nonNAMRejected && bypassedNonNAMRejected && eligibilityRecovered && releasedRejected && emptyPublicationCleared); result->setProperty("emptyAndZeroRejected", emptyRejected && zeroRejected); + result->setProperty("nativeNAMBlockPreparation", nativePreparation); result->setProperty("eightSampleDiscovery", probeRegression); result->setProperty("automationDataFirstExactParity", automationOrderParity); result->setProperty("automationEightSampleCases", automationCases); @@ -5152,7 +5263,7 @@ bool TrackProcessor::addInputFX(std::unique_ptr plugin, do // Prefer caller-supplied rate (from AudioEngine), fall back to our own, // then to 44100 as last resort. Use the realtime device block size when known. double sr = callerSampleRate > 0 ? callerSampleRate : getSampleRate(); - int bs = getSafeHostedPluginBlockSize(callerBlockSize > 0 ? callerBlockSize : getBlockSize()); + int bs = getSafeHostedPluginBlockSize(callerBlockSize > 0 ? callerBlockSize : getBlockSize(), plugin.get()); if (sr <= 0) sr = 44100.0; if (bs <= 0) bs = kMinimumHostedPluginBlockSize; @@ -5197,7 +5308,7 @@ bool TrackProcessor::addTrackFX(std::unique_ptr plugin, do // Prefer caller-supplied rate (from AudioEngine), fall back to our own, // then to 44100 as last resort. Use the realtime device block size when known. double sr = callerSampleRate > 0 ? callerSampleRate : getSampleRate(); - int bs = getSafeHostedPluginBlockSize(callerBlockSize > 0 ? callerBlockSize : getBlockSize()); + int bs = getSafeHostedPluginBlockSize(callerBlockSize > 0 ? callerBlockSize : getBlockSize(), plugin.get()); if (sr <= 0) sr = 44100.0; if (bs <= 0) bs = kMinimumHostedPluginBlockSize; @@ -7419,7 +7530,7 @@ void TrackProcessor::setInputFXPrecisionOverride(int index, bool forceFloat) if (auto* plugin = inputFXPlugins[static_cast(index)].get()) { double sr = getSampleRate() > 0 ? getSampleRate() : 44100.0; - int bs = getSafeHostedPluginBlockSize(getBlockSize()); + int bs = getSafeHostedPluginBlockSize(getBlockSize(), plugin); preparePluginPreservingLayout(plugin, sr, bs, resolvePluginPrecisionMode(processingPrecisionMode, forceFloat), inputChannelCount.load(std::memory_order_acquire)); @@ -7437,7 +7548,7 @@ void TrackProcessor::setTrackFXPrecisionOverride(int index, bool forceFloat) if (auto* plugin = trackFXPlugins[static_cast(index)].get()) { double sr = getSampleRate() > 0 ? getSampleRate() : 44100.0; - int bs = getSafeHostedPluginBlockSize(getBlockSize()); + int bs = getSafeHostedPluginBlockSize(getBlockSize(), plugin); preparePluginPreservingLayout(plugin, sr, bs, resolvePluginPrecisionMode(processingPrecisionMode, forceFloat), inputChannelCount.load(std::memory_order_acquire)); @@ -7491,7 +7602,7 @@ void TrackProcessor::setProcessingPrecisionMode(ProcessingPrecisionMode mode) processingPrecisionMode = mode; double sr = getSampleRate() > 0 ? getSampleRate() : 44100.0; - int bs = getSafeHostedPluginBlockSize(getBlockSize()); + const int bs = getBlockSize(); for (int index = 0; index < static_cast(inputFXPlugins.size()); ++index) if (auto* plugin = inputFXPlugins[static_cast(index)].get()) diff --git a/docs/certification-0.1.09.md b/docs/certification-0.1.09.md index 98f05cce..4c9598ff 100644 --- a/docs/certification-0.1.09.md +++ b/docs/certification-0.1.09.md @@ -6,6 +6,12 @@ The user requested certification checks and a release PR, with publication held. ## Current evidence +The 5 November local investigation below changes native NAM preparation and the +explicit diagnostic probe. Earlier package/WACK results remain valid for their +listed artifacts, but do **not** certify this newer application binary. The +remaining Linux browser check passed; the strict 8-sample hardware gate still +fails. PR #36 remains a draft. + - **Pass — complete Windows candidate, locally:** Release and Debug were rebuilt with callback profiling compiled out and no C++ warnings. The current frontend build and all 3,136 tests passed; the native render/export matrix passed all 422 @@ -211,6 +217,89 @@ a draft: the eight-sample failure has not been resolved.** Physical driver xruns are unsupported; monitoring continuity, loopback latency and audition remain **not_asserted**. Results under `output/eight-sample-investigation/` remain local. +## Approved scheduling trace and NAM preparation — 5 November 2026 (IST) + +The user approved an explicit 75-second Windows scheduling trace with Dota 2 +running. Two captures were saved, without changing power mode, game settings, +drivers or affinity. Both stopped automatically. The first exposed a diagnostic +CSV writer that repeatedly scanned its accumulated string and timed out after +recording. The probe now writes its report through a pre-sized stream, after the +callback has been removed. The repeat completed normally. Absolute clock ticks +and the explicitly requested thread ID permit correlation with Windows ETW; +these additions exist only in the manually launched headless probe. Support and +ordinary callback profiling remained off. + +The usable repeat started at 18:44:12 UTC on 4 November and saved at 18:46:09 +UTC, including trace stop/rundown. It has zero lost events, matching retained +executable/PDB files, and 62,802 unique retained callback intervals with complete +scheduling coverage. Of 7,768 retained misses, 508 overlap more than 10 µs of +ordinary DPC/ISR activity on the callback's executing CPU; five include more +than 10 µs off-CPU. The maximum unioned interrupt overlap is 294.4 µs, exceeding +the 166.7 µs buffer budget. Graphics modules `dxgkrnl.sys` and `nvlddmkm.sys` +appear in these overlaps. Threaded DPCs are excluded from interrupt subtraction, +and nested interrupts are unioned rather than double-counted. + +This does not attribute every miss to a driver: 7,384 retained misses still +exceed budget after subtracting observed interrupt/off-CPU intervals. The +retained set combines uniform samples with the first 4,096 misses; it is not +all 14,923 measured misses. Residual wall time includes app processing, worker +waits, diagnostic queries, tracing and unobserved costs, not exclusive app CPU +time. All trace findings are **diagnostic_only**, not a normal-use capacity pass +or proof of a past unobserved glitch. Microsoft's [CPU analysis guidance](https://learn.microsoft.com/en-us/windows-hardware/test/wpt/cpu-analysis) +and [threaded-DPC distinction](https://learn.microsoft.com/en-us/windows-hardware/drivers/kernel/introduction-to-threaded-dpcs) +describe the scheduling interpretation. + +Matching stack samples repeatedly identified NAM cabinet FFT work. Each track +Rack was prepared for 512 samples while receiving eight. TrackProcessor now +prepares native NAM input/track FX for the actual host block capacity, preserving +the existing minimum-512 compatibility reserve for other processors. Selection +uses the native type, not its display name, and runs only during preparation. +This reduces repeated convolution work without buffering additional audio or +changing the zero-latency cabinet algorithm. Master/bus preparation is unchanged. +No worker-spin, work-dropping policy, extra recording monitor or Support hook was +added. + +The focused regression checks insertion, device/render-size re-preparation and +precision changes at 8/16/64/512/1,024 samples. It also compares 110,592 cabinet +samples, including mono/stereo IRs, dual-mono selection, source-rate conversion, +impulses, signal and tails. Maximum PCM difference is `1.490116119384766e-08` +against a `2e-5` tolerance, with zero reported convolution latency. This is +**pass** for those deterministic invariants; subjective sound is **not_asserted**. +Windows Release and Debug builds completed without C++ compiler warnings, and +the focused realtime safety regression passed in both configurations. The full +NAM matrix passed 206 objective checks (18 diagnostic-only and eight not-asserted +items retained); the render/export matrix passed all 422 checks. Debug's copied +frontend is current. These local passes do not extend the earlier installer or +WACK result to the modified executable. + +Three subsequent physical-interface runs used four loaded NAM tracks, normal +workers, 4x oversampling, Audient ASIO / 48 kHz / 8 samples, three seconds of +warmup and 30 measured seconds. ETW and optional thread-cycle queries were off; +the explicit probe still measured each callback. Dota 2 remained running. + +| Run order | Preparation | Mean / p99 / max, µs | Measured misses / 180,000 | Worker misses, warmup + measurement | +| --- | --- | --- | --- | --- | +| 1 | Actual host block | 87.3 / 201.8 / 386.7 | 3,958 | 6,299 | +| 2 | Saved previous 512-block build | 116.6 / 237.6 / 406.1 | 8,948 | 36,173 | +| 3 | Actual host block, repeat | 85.5 / 197.9 / 343.2 | 3,955 | 6,183 | + +All three passed raw synthetic-recording checks and failed engine/worker +capacity. Worker counts cover a different interval and must not be added to +the measured outer misses. These runs support the direction of improvement +under this test setup, not a universal percentage claim: game activity was not +a controlled replay. They do not repeat or clear the earlier 31-track UI test. +Unsupported physical driver xruns, processed-monitor continuity, loopback +latency and audition remain **not_asserted**. **The zero-miss 8-sample gate fails.** + +Previous build / trace executable SHA-256: +`663b9d97ac4d3a5f277b2c0f46315750a2fa0ca78e97c9b539fdb1ecb3870249`. +Updated Release executable SHA-256: +`bc404150f7494ae493a4ca3c7075bc93debc51c9fed99430a404e362a234b21a`. +Local traces, matching binaries, analysis and reports remain under +`output/eight-sample-investigation/`; private machine traces are not committed +or uploaded. Trace artifacts are evidence of this explicit diagnostic session, +not a background telemetry facility. + ## Physical test matrix Use copies of projects, and record the candidate file hash, app version, OS, diff --git a/docs/nam-rack.md b/docs/nam-rack.md index e8b2ccc3..74c575f1 100644 --- a/docs/nam-rack.md +++ b/docs/nam-rack.md @@ -286,6 +286,12 @@ The main implementation lives in: - Prepared models and IRs are published atomically and retired after readers have left the previous graph. - The callback uses preallocated buffers and bounded work. +- Native NAM input/track FX are prepared for the actual host block capacity, + including tiny device buffers and offline-render re-preparation. The host's + minimum-512 preparation reserve remains for other processors. This avoids + oversized cabinet FFT work without adding convolution latency; master/bus + preparation is unchanged. Small-buffer hardware capacity remains a separate + qualification requirement. - Sample-rate conversion is explicit around NAM models whose expected rate differs from the host. - Fixed latency is reported to the host, with aligned dry paths during bypass diff --git a/docs/releases/0.1.09.md b/docs/releases/0.1.09.md index 65f971af..9bb0e7ee 100644 --- a/docs/releases/0.1.09.md +++ b/docs/releases/0.1.09.md @@ -25,6 +25,10 @@ development versions. No 0.1.09 release or Store update has been published. before capture starts. The recording writer publishes prepared FIFO data without waking its disk thread from each audio callback. These changes do not establish a universal glitch-free minimum buffer size. +- Prepare native NAM input/track effects for the actual device block size, + reducing unnecessary cabinet-convolution work at small buffers while keeping + the existing preparation reserve for other effects. This adds no cabinet + latency; the strict 8-sample contention target still fails. - Limit timeline waveform and canvas work to the visible region, keep recording previews updating during navigation, and reduce ruler label crowding without changing the snap grid. Mixer resizing releases keyboard focus correctly. @@ -57,6 +61,11 @@ development versions. No 0.1.09 release or Store update has been published. worse; a worker-spin experiment still failed and was not adopted. Minimal FX-bypassed controls do not clear the loaded-workload gate. Physical recording, monitoring continuity and audition still require exact-artifact qualification. + After the NAM preparation correction, repeated four-track contention probes + measured 3,958 and 3,955 late callbacks out of 180,000, versus 8,948 with the + saved previous build. These measurements remain failures. An explicitly + approved trace also found graphics-driver interruptions exceeding the buffer + budget; they do not explain every miss or prove a historical glitch's cause. - Offline support cannot reconstruct unmeasured CPU scheduling or RAM pressure at a past glitch, prove that every intended instrument was audible, or replace a physical signal reference. Redaction is best effort; inspect archives before @@ -74,6 +83,8 @@ development versions. No 0.1.09 release or Store update has been published. credentials are unavailable. Component passes do not certify the full candidate. See the [candidate qualification record](../certification-0.1.09.md). + Those package and WACK passes apply to the recorded earlier artifacts; the + subsequent native NAM preparation change requires fresh package qualification. - Automated render and MIDI checks establish objective behavior for their test inputs. They do not establish subjective sound quality or compatibility with every external plugin, interface or driver. From bba16515f3129803b6504dec5c931ccd8bfb56e8 Mon Sep 17 00:00:00 2001 From: Sourav Das Date: Thu, 5 Nov 2026 00:45:34 +0530 Subject: [PATCH 23/33] Preserve device capacity when inserting native NAM effects --- Source/AudioEngine.cpp | 7 ++++-- Source/AudioEngineParallelRegression.inc | 30 ++++++++++++++++++++---- docs/certification-0.1.09.md | 17 +++++++++++--- docs/releases/0.1.09.md | 4 +++- 4 files changed, 48 insertions(+), 10 deletions(-) diff --git a/Source/AudioEngine.cpp b/Source/AudioEngine.cpp index f24fd031..cf9757e4 100644 --- a/Source/AudioEngine.cpp +++ b/Source/AudioEngine.cpp @@ -66940,14 +66940,17 @@ bool AudioEngine::addTrackBuiltInFX(const juce::String& trackId, const juce::Str auto it = trackMap.find(trackId); if (it != trackMap.end() && it->second) { + // The track applies its per-processor preparation policy. Preserve + // the device capacity here so native NAM does not inherit the + // compatibility reserve used for the initial generic preparation. if (isInputFX) { - success = it->second->addInputFX(std::move(plugin), sr, bs); + success = it->second->addInputFX(std::move(plugin), sr, currentBlockSize); } else { - success = it->second->addTrackFX(std::move(plugin), sr, bs); + success = it->second->addTrackFX(std::move(plugin), sr, currentBlockSize); } } diff --git a/Source/AudioEngineParallelRegression.inc b/Source/AudioEngineParallelRegression.inc index 11005b5f..0bc6faeb 100644 --- a/Source/AudioEngineParallelRegression.inc +++ b/Source/AudioEngineParallelRegression.inc @@ -7,7 +7,8 @@ juce::var AudioEngine::runParallelTrackRegression() class FixtureDevice final : public juce::AudioIODevice { public: - FixtureDevice() : AudioIODevice("Parallel track fixture", "Offline fixture") {} + explicit FixtureDevice(int block = 512) + : AudioIODevice("Parallel track fixture", "Offline fixture"), blockSize(block) {} juce::StringArray getOutputChannelNames() override { return { "Left", "Right" }; } juce::StringArray getInputChannelNames() override { @@ -16,8 +17,8 @@ juce::var AudioEngine::runParallelTrackRegression() return names; } juce::Array getAvailableSampleRates() override { return { 48000.0 }; } - juce::Array getAvailableBufferSizes() override { return { 512 }; } - int getDefaultBufferSize() override { return 512; } + juce::Array getAvailableBufferSizes() override { return { blockSize }; } + int getDefaultBufferSize() override { return blockSize; } juce::String open(const juce::BigInteger&, const juce::BigInteger&, double, int) override { return "Offline fixture cannot open hardware"; } void close() override {} @@ -26,13 +27,15 @@ juce::var AudioEngine::runParallelTrackRegression() void stop() override {} bool isPlaying() override { return false; } juce::String getLastError() override { return {}; } - int getCurrentBufferSizeSamples() override { return 512; } + int getCurrentBufferSizeSamples() override { return blockSize; } double getCurrentSampleRate() override { return 48000.0; } int getCurrentBitDepth() override { return 32; } juce::BigInteger getActiveOutputChannels() const override { return juce::BigInteger(3); } juce::BigInteger getActiveInputChannels() const override { return juce::BigInteger(4095); } int getOutputLatencyInSamples() override { return 0; } int getInputLatencyInSamples() override { return 0; } + private: + const int blockSize; } device; juce::Array checks, configurations; @@ -48,6 +51,25 @@ juce::var AudioEngine::runParallelTrackRegression() checks.add(result); passed = passed && success; }; + for (const int blockSize : { 8, 64 }) + { + FixtureDevice tinyDevice(blockSize); + auto engine = std::make_unique(DeviceStartup::offline); + engine->requestedRealtimeWorkers = 0; + engine->audioDeviceAboutToStart(&tinyDevice); + const juce::String id = "native-nam-insertion"; + const bool inserted = engine->addTrack(id, "audio") == id + && engine->addTrackBuiltInFX(id, "OpenStudio NAM Rack", true) + && engine->addTrackBuiltInFX(id, "OpenStudio NAM Rack", false); + const auto input = engine->getNAMRackDiagnostics(id, "input", 0); + const auto output = engine->getNAMRackDiagnostics(id, "track", 0); + check("native_nam_insert_after_device_start_" + juce::String(blockSize), inserted + && static_cast(input.getProperty("preparedBlockSize", 0)) == blockSize + && static_cast(output.getProperty("preparedBlockSize", 0)) == blockSize + && engine->hardwareCallbackRegistrationCount.load() == 0, + "The app's built-in insertion route prepares both input and track NAM for the running device size without another device restart; the fixture opens no physical device."); + engine->audioDeviceStopped(); + } { // Use the real query and response serialization with a simulated driver. // An equal cloned current object must not detach later draft edits from diff --git a/docs/certification-0.1.09.md b/docs/certification-0.1.09.md index 4c9598ff..fdab335e 100644 --- a/docs/certification-0.1.09.md +++ b/docs/certification-0.1.09.md @@ -272,7 +272,15 @@ items retained); the render/export matrix passed all 422 checks. Debug's copied frontend is current. These local passes do not extend the earlier installer or WACK result to the modified executable. -Three subsequent physical-interface runs used four loaded NAM tracks, normal +The final call-path review also corrected the app's built-in insertion entry +point: it must pass the actual device capacity into TrackProcessor rather than +the generic creation reserve. Two engine-level fixtures verify inserting NAM +into both chains after a simulated device has already started at 8 or 64 samples, +without requiring a device restart. They pass in Release and Debug. The full +NAM and 422-check render/export matrices were repeated and passed after this +correction; both configurations rebuilt without C++ compiler warnings. + +Four subsequent physical-interface runs used four loaded NAM tracks, normal workers, 4x oversampling, Audient ASIO / 48 kHz / 8 samples, three seconds of warmup and 30 measured seconds. ETW and optional thread-cycle queries were off; the explicit probe still measured each callback. Dota 2 remained running. @@ -282,8 +290,9 @@ the explicit probe still measured each callback. Dota 2 remained running. | 1 | Actual host block | 87.3 / 201.8 / 386.7 | 3,958 | 6,299 | | 2 | Saved previous 512-block build | 116.6 / 237.6 / 406.1 | 8,948 | 36,173 | | 3 | Actual host block, repeat | 85.5 / 197.9 / 343.2 | 3,955 | 6,183 | +| 4 | Final app-insertion correction | 82.6 / 190.8 / 408.7 | 3,658 | 7,360 | -All three passed raw synthetic-recording checks and failed engine/worker +All four passed raw synthetic-recording checks and failed engine/worker capacity. Worker counts cover a different interval and must not be added to the measured outer misses. These runs support the direction of improvement under this test setup, not a universal percentage claim: game activity was not @@ -293,8 +302,10 @@ latency and audition remain **not_asserted**. **The zero-miss 8-sample gate fail Previous build / trace executable SHA-256: `663b9d97ac4d3a5f277b2c0f46315750a2fa0ca78e97c9b539fdb1ecb3870249`. -Updated Release executable SHA-256: +Updated Release executable SHA-256 for runs 1 and 3: `bc404150f7494ae493a4ca3c7075bc93debc51c9fed99430a404e362a234b21a`. +Final insertion-corrected Release executable SHA-256 for run 4: +`77306c0434c3e050974d5c99c259b40656ded55a2d3379b850ce2100eef5bbe9`. Local traces, matching binaries, analysis and reports remain under `output/eight-sample-investigation/`; private machine traces are not committed or uploaded. Trace artifacts are evidence of this explicit diagnostic session, diff --git a/docs/releases/0.1.09.md b/docs/releases/0.1.09.md index 9bb0e7ee..22816d08 100644 --- a/docs/releases/0.1.09.md +++ b/docs/releases/0.1.09.md @@ -63,7 +63,9 @@ development versions. No 0.1.09 release or Store update has been published. monitoring continuity and audition still require exact-artifact qualification. After the NAM preparation correction, repeated four-track contention probes measured 3,958 and 3,955 late callbacks out of 180,000, versus 8,948 with the - saved previous build. These measurements remain failures. An explicitly + saved previous build. These measurements remain failures. The final + insertion correction measured 3,658 late callbacks in another + 180,000-callback run and still missed worker deadlines. An explicitly approved trace also found graphics-driver interruptions exceeding the buffer budget; they do not explain every miss or prove a historical glitch's cause. - Offline support cannot reconstruct unmeasured CPU scheduling or RAM pressure From b6aba8ae9282c4a6457b887c263d68fd55f47b30 Mon Sep 17 00:00:00 2001 From: Sourav Das Date: Thu, 5 Nov 2026 01:13:13 +0530 Subject: [PATCH 24/33] Initialize preview source binding explicitly for warning-free builds --- Source/PlaybackEngine.cpp | 2 +- docs/certification-0.1.09.md | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/Source/PlaybackEngine.cpp b/Source/PlaybackEngine.cpp index e6df3454..34b50dce 100644 --- a/Source/PlaybackEngine.cpp +++ b/Source/PlaybackEngine.cpp @@ -1651,7 +1651,7 @@ bool PlaybackEngine::setClipRenderedPreviewSegment(const juce::String& clipId, return sameWindow || overlaps; }), segments.end()); - segments.push_back({ audioFile, startSec, endSec, fileOffsetSec, segmentReaderKey }); + segments.push_back({ audioFile, startSec, endSec, fileOffsetSec, segmentReaderKey, {} }); // Publication owns the same lock as fillTrackBuffer. Prepare scratch capacity // here, never while the audio callback traverses a large edited segment set. reusableChunkBoundaries.reserve(juce::jmax(reusableChunkBoundaries.capacity(), segments.size() * 2 + 2)); diff --git a/docs/certification-0.1.09.md b/docs/certification-0.1.09.md index fdab335e..92edf6e8 100644 --- a/docs/certification-0.1.09.md +++ b/docs/certification-0.1.09.md @@ -12,6 +12,26 @@ listed artifacts, but do **not** certify this newer application binary. The remaining Linux browser check passed; the strict 8-sample hardware gate still fails. PR #36 remains a draft. +- **Pass — Linux qualification after the NAM preparation/insertion fix:** + [job 126802823878](https://github.com/sdevil7th/OpenStudio/actions/runs/41895223774/job/126802823878) + at `bba16515` completed the native build, realtime safety fixtures, actual DEB + installation, normal/safe startup, render/features, AppImage payload checks + and packaged main/detached-window lifecycle. The retained + [qualification report](https://github.com/sdevil7th/OpenStudio/actions/runs/41895223774/artifacts/13356283313) + and [candidate packages](https://github.com/sdevil7th/OpenStudio/actions/runs/41895223774/artifacts/13356358288) + belong to that exact commit. AppImage still uses extract-and-run; physical + FUSE/interface and Fedora installation are not asserted. +- **Pass — browser checks on that native revision:** all four Windows and all + four Linux groups in [run 41895223774](https://github.com/sdevil7th/OpenStudio/actions/runs/41895223774) + passed, each platform totaling 295 cases and 33 declared skips. The 3,136 + frontend unit tests also passed. No behavior gate or retry policy changed. +- **Compiler-warning follow-up:** inspection of the successful Linux log found + one `-Wmissing-field-initializers` warning in preview-segment publication. + The follow-up explicitly value-initializes the existing source binding, whose + defaults remain generation zero and null pointers. It changes no playback + behavior. This correction is not represented as a retroactive warning-free + Linux build; its compiler verification is separate from the package above. + - **Pass — complete Windows candidate, locally:** Release and Debug were rebuilt with callback profiling compiled out and no C++ warnings. The current frontend build and all 3,136 tests passed; the native render/export matrix passed all 422 From 93d03c15ab25349076df370ae4d6721545407d9a Mon Sep 17 00:00:00 2001 From: Sourav Das Date: Thu, 5 Nov 2026 02:05:31 +0530 Subject: [PATCH 25/33] Record final package certification and contention limits --- docs/certification-0.1.09.md | 94 +++++++++++++++++++++++++++++++++--- docs/releases/0.1.09.md | 49 +++++++++---------- 2 files changed, 110 insertions(+), 33 deletions(-) diff --git a/docs/certification-0.1.09.md b/docs/certification-0.1.09.md index 92edf6e8..7a6e58ef 100644 --- a/docs/certification-0.1.09.md +++ b/docs/certification-0.1.09.md @@ -7,10 +7,13 @@ The user requested certification checks and a release PR, with publication held. ## Current evidence The 5 November local investigation below changes native NAM preparation and the -explicit diagnostic probe. Earlier package/WACK results remain valid for their -listed artifacts, but do **not** certify this newer application binary. The -remaining Linux browser check passed; the strict 8-sample hardware gate still -fails. PR #36 remains a draft. +explicit diagnostic probe. Final Windows/Linux package checks and WACK passed +at `b6aba8ae`, including the warning-only follow-up to the functional NAM fix. +Each result applies to its listed artifact. The remaining Linux browser check +passed; the strict 8-sample hardware gate still fails. An automatic Mac run also +exposed three geometry-check failures after successful frontend startup. +PR #36 remains a draft, with no submitted formal review approval. Subsequent +qualification/release-note edits do not change the tested application source. - **Pass — Linux qualification after the NAM preparation/insertion fix:** [job 126802823878](https://github.com/sdevil7th/OpenStudio/actions/runs/41895223774/job/126802823878) @@ -27,10 +30,75 @@ fails. PR #36 remains a draft. frontend unit tests also passed. No behavior gate or retry policy changed. - **Compiler-warning follow-up:** inspection of the successful Linux log found one `-Wmissing-field-initializers` warning in preview-segment publication. - The follow-up explicitly value-initializes the existing source binding, whose + `b6aba8ae` explicitly value-initializes the existing source binding, whose defaults remain generation zero and null pointers. It changes no playback - behavior. This correction is not represented as a retroactive warning-free - Linux build; its compiler verification is separate from the package above. + behavior. Its [completed Linux job](https://github.com/sdevil7th/OpenStudio/actions/runs/41899667263/job/126818406535) + has no C++ compiler warnings and repeats the native safety, DEB installation, + AppImage and packaged-window checks successfully. Its + [qualification report](https://github.com/sdevil7th/OpenStudio/actions/runs/41899667263/artifacts/13358612965) + and [packages](https://github.com/sdevil7th/OpenStudio/actions/runs/41899667263/artifacts/13359177604) + retain the exact follow-up evidence. Packaging-tool and Node deprecation + notices remain in the log; this is specifically a C++ compiler-warning result. + Local Release and Debug rebuilt without C++ warnings, and all 422 render/export + checks passed again. The local follow-up Release SHA-256 is + `d8ea6942a99ddff57ea2ab1c056cc6b57511482d05e096a6cd45b671c3cd7f99`; + it was not used for the physical timing runs below. +- **Pass — final Windows packages at `b6aba8ae`:** + [job 126818406515](https://github.com/sdevil7th/OpenStudio/actions/runs/41899667263/job/126818406515) + repeated the native, protected-startup, installer/MSIX, installed-app/Support + and uninstall checks successfully, with no C++ compiler warnings. All 422 + render/export checks passed. CI's NAM run passed 205 objective checks, retaining + 18 diagnostic-only and nine not-asserted items; the additional local-library + model check is not asserted on the hosted runner, whereas it passed locally. + The [final Windows packages](https://github.com/sdevil7th/OpenStudio/actions/runs/41899667263/artifacts/13360003582) + belong to this exact revision. +- **Pass — final WACK at `b6aba8ae`:** + [job 126843251747](https://github.com/sdevil7th/OpenStudio/actions/runs/41899667263/job/126843251747) + and [report 13361266415](https://github.com/sdevil7th/OpenStudio/actions/runs/41899667263/artifacts/13361266415) + record aggregate `PASS`, exit 0, all 13 required tests passing, no required + issues and `published: false`. The tested MSIX SHA-256 is + `5a31875dd078ce0f22672bb39ddbb1328a82c67ec38718b9009301282b4d2f01`. + Both optional findings remain: application count and blocked-executable + references. This is a certification-kit pass, not Microsoft Store approval + or S-mode qualification. No test gate was weakened. +- **Pass — Windows packages after the NAM preparation/insertion fix:** + [job 126802824305](https://github.com/sdevil7th/OpenStudio/actions/runs/41895223774/job/126802824305) + at `bba16515` completed the warning-free native build, safety/NAM/render + regressions, native-schema FX editor tests, protected normal/safe WebView + startup, actual installer/MSIX construction, installed window/Support checks + and uninstall. The [packages](https://github.com/sdevil7th/OpenStudio/actions/runs/41895223774/artifacts/13357514971) + are unpublished qualification artifacts. +- **Pass — WACK for that functional fix:** + [job 126824894671](https://github.com/sdevil7th/OpenStudio/actions/runs/41895223774/job/126824894671) + and its [retained report](https://github.com/sdevil7th/OpenStudio/actions/runs/41895223774/artifacts/13359366796) + returned aggregate `PASS`, exit 0, with all 13 required tests passing and + `published: false`. The MSIX SHA-256 is + `bb91233deaf1c2ccae87b3ee822a789ae0d572e65b21d07ca27757c000ebc258`. + Application-count and blocked-executable optional findings remain. This is + not Store approval and does not certify a later rebuilt MSIX. +- **Fail — newly available Apple Silicon window check:** the automatic + `b6aba8ae` [Mac job](https://github.com/sdevil7th/OpenStudio/actions/runs/41899667263/job/126826803610) + got a runner and built the universal app. Its + [lifecycle report](https://github.com/sdevil7th/OpenStudio/actions/runs/41899667263/artifacts/13360536593) + fails `main_move_resize`, `main_bounds_stable` and + `plugin_restore_preferred_geometry`. Main, mixer, pitch, MIDI and plugin + documents all reached frontend readiness, and teardown/no-hardware checks + passed. This narrows the failure to geometry assertions; it does not establish + whether the cause is application behavior or the hosted display constraints. + Full DMG construction/installed qualification was skipped after the failure. + The compiler also reports five distinct unnecessary lambda captures in + `NAMFuzzRegression.h`, `AudioEngine.cpp`, `FreePluginReverbBalance.h` and + `FreePluginRegression.cpp` (repeated for both universal slices). The Windows/ + Linux warning-free results do not imply a warning-free Mac build. Universal + Support component launch tests passed on both Mac architectures. No manual + rerun or new Mac workaround was attempted; notarization remains blocked on + credentials. The earlier runner-allocation blocker below is historical, not + the explanation for this executed failure. + +## Earlier package evidence and remaining platform limits + +The following records retain previous artifacts and their limitations; use the +commit-specific results above for the subsequent NAM correction. - **Pass — complete Windows candidate, locally:** Release and Debug were rebuilt with callback profiling compiled out and no C++ warnings. The current frontend @@ -269,6 +337,18 @@ or proof of a past unobserved glitch. Microsoft's [CPU analysis guidance](https: and [threaded-DPC distinction](https://learn.microsoft.com/en-us/windows-hardware/drivers/kernel/introduction-to-threaded-dpcs) describe the scheduling interpretation. +The same saved capture also contains 2,322 disk-backed hard-fault events across +the machine (one event's process owner was unresolved). Of the 21 attributed to +OpenStudio within the retained callback span, two occurred on the audio thread +during the same 416.5 µs late callback. Their elapsed intervals are 159.1 and +111.8 µs; they overlap time already accounted for by scheduling and must not be +added to off-CPU duration. The backing file was not resolved. This is evidence +of paging during that reproduction, **not** proof of insufficient RAM or an +explanation for every miss. Faults can include first access to mapped code/data; +Microsoft's [hard-fault counter guidance](https://learn.microsoft.com/en-us/troubleshoot/windows-client/performance/how-to-determine-the-appropriate-page-file-size-for-64-bit-versions-of-windows#memorypagesec-and-other-hard-page-fault-counters) +explicitly distinguishes them from a low-memory condition. +This analysis reads the existing capture only; no new collector was started. + Matching stack samples repeatedly identified NAM cabinet FFT work. Each track Rack was prepared for 512 samples while receiving eight. TrackProcessor now prepares native NAM input/track FX for the actual host block capacity, preserving diff --git a/docs/releases/0.1.09.md b/docs/releases/0.1.09.md index 22816d08..c1648a6e 100644 --- a/docs/releases/0.1.09.md +++ b/docs/releases/0.1.09.md @@ -49,25 +49,18 @@ development versions. No 0.1.09 release or Store update has been published. ## Known Issues -- The strict Audient ASIO 48 kHz / 8-sample target remains unqualified. Fresh - candidate probes recorded 227 late callbacks with one NAM track and 451 with - four tracks in separate 30-second measurements; the four-track case also - missed worker deadlines. Raw synthetic-input recording integrity passed, but - that does not establish continuous monitoring. Short 64-sample controls had - no measured misses; they do not clear the 8-sample failure or qualify long - sessions. These are different workloads from the earlier 31-track test, not - a before/after comparison. A subsequent Dota 2 contention test also failed - with four loaded NAM tracks. Disabling parallel processing made that workload - worse; a worker-spin experiment still failed and was not adopted. Minimal - FX-bypassed controls do not clear the loaded-workload gate. Physical recording, - monitoring continuity and audition still require exact-artifact qualification. - After the NAM preparation correction, repeated four-track contention probes - measured 3,958 and 3,955 late callbacks out of 180,000, versus 8,948 with the - saved previous build. These measurements remain failures. The final - insertion correction measured 3,658 late callbacks in another - 180,000-callback run and still missed worker deadlines. An explicitly - approved trace also found graphics-driver interruptions exceeding the buffer - budget; they do not explain every miss or prove a historical glitch's cause. +- The strict Audient ASIO 48 kHz / 8-sample target still fails. After the NAM + preparation/insertion correction, a four-track probe with Dota 2 running + recorded 3,658 late callbacks out of 180,000 and also missed worker deadlines. + The saved previous build recorded 8,948 misses in another run; game activity + was not a controlled replay, so this is not a universal improvement claim. + Raw synthetic-input recording integrity passed, but continuous monitoring, + physical driver loss and subjective quality are not established. The separate + 31-track hardware gate also remains failed. Short 64-sample and FX-bypassed + controls do not qualify the loaded 8-sample workload or long sessions. + An explicitly approved trace found graphics-driver interruptions exceeding + the buffer budget and paging during one late callback. These do not explain + every miss, prove RAM exhaustion or establish a past unobserved glitch's cause. - Offline support cannot reconstruct unmeasured CPU scheduling or RAM pressure at a past glitch, prove that every intended instrument was audible, or replace a physical signal reference. Redaction is best effort; inspect archives before @@ -79,14 +72,18 @@ development versions. No 0.1.09 release or Store update has been published. the corrected Support DPI check; two optional findings remain in its report. All four final Linux browser-test groups passed: 295 cases, 33 declared skips, zero failures and zero retries. Microsoft Store approval remains - outstanding. Full macOS qualification has - not completed after runner allocation failures; native Support component - checks passed on both architectures, but Apple signing/notarization - credentials are unavailable. Component passes do not certify the full - candidate. See the + outstanding. A subsequently available Apple Silicon runner built the app and + reached frontend readiness in main/popout windows, but three window-geometry + checks failed; full DMG qualification did not complete. Native and universal + Support component checks passed on both architectures. Apple signing/ + notarization credentials are unavailable. Component passes do not certify + the full candidate. See the [candidate qualification record](../certification-0.1.09.md). - Those package and WACK passes apply to the recorded earlier artifacts; the - subsequent native NAM preparation change requires fresh package qualification. + Windows/Linux packages and WACK were requalified after the native NAM + preparation/insertion change at `bba16515`. A subsequent warning-only preview + initializer correction at `b6aba8ae` has passed Windows/Linux qualification + and its separate WACK check. Results apply to the exact + artifacts recorded in the qualification report. - Automated render and MIDI checks establish objective behavior for their test inputs. They do not establish subjective sound quality or compatibility with every external plugin, interface or driver. From d504f799e7f1579682ec989d717cc8cb2b64dfca Mon Sep 17 00:00:00 2001 From: Sourav Das Date: Thu, 5 Nov 2026 09:40:11 +0530 Subject: [PATCH 26/33] Add offline evidence and symbol matching; disable routine diagnostics --- .github/workflows/release.yml | 39 ++++ .github/workflows/support-qualification.yml | 6 +- .github/workflows/verify.yml | 1 + .gitignore | 1 + CMakeLists.txt | 20 +- Source/ARADebug.h | 10 +- Source/ARAHostController.cpp | 53 +++-- Source/AudioEngine.cpp | 25 ++ Source/CrashDiagnostics.cpp | 27 ++- Source/CrashDiagnostics.h | 4 +- Source/CrashReporterMain.cpp | 34 ++- Source/CrashReporterProtocol.h | 2 +- Source/MainComponent.cpp | 2 + Source/PluginWindowManager.cpp | 32 +-- Source/TrackProcessor.cpp | 94 +++----- build.py | 2 + docs/USER_MANUAL.md | 7 + docs/certification-0.1.09.md | 58 ++++- docs/offline-support.md | 91 +++++++- docs/release-runbook.md | 10 + docs/releases/0.1.09.md | 13 ++ frontend/vite.config.ts | 29 +++ tests/test_linux_native_packaging.py | 2 +- tests/test_release_build_policy.py | 12 +- tests/test_support_identity.py | 239 +++++++++++++++++++ tools/archive-runtime-symbols.ps1 | 23 +- tools/build-symbols.py | 140 +++++++++++ tools/openstudio_support/cli.py | 6 +- tools/openstudio_support/collection.py | 74 +++++- tools/openstudio_support/gui.py | 34 +-- tools/openstudio_support/identity.py | 247 ++++++++++++++++++++ tools/openstudio_support/selftest.py | 13 ++ tools/openstudio_support/snapshots.py | 112 +++++++++ tools/package-windows-store.ps1 | 2 +- tools/release_build_policy.py | 4 +- tools/test-support-linux.py | 2 +- 36 files changed, 1304 insertions(+), 166 deletions(-) create mode 100644 tests/test_support_identity.py create mode 100644 tools/build-symbols.py create mode 100644 tools/openstudio_support/identity.py create mode 100644 tools/openstudio_support/snapshots.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 05451527..e2cd04d6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -149,6 +149,10 @@ jobs: if: env.DOPPLER_CONFIGURED == 'true' uses: dopplerhq/cli-action@014df23b1329b615816a38eb5f473bb9000700b1 # v3 + - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + with: + python-version: '3.13' + - name: Configure CMake shell: pwsh env: @@ -203,6 +207,15 @@ jobs: shell: pwsh run: cmake --build $env:BUILD_DIR --config Release --target OpenStudio + - name: Archive matching native and frontend symbols (windows) + run: python tools/build-symbols.py archive --app "$env:BUILD_DIR/OpenStudio_artefacts/Release/OpenStudio.exe" --build-dir $env:BUILD_DIR --output output/symbols + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: windows-debug-symbols + path: output/symbols/ + retention-days: 90 + if-no-files-found: error + - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: '3.13' @@ -594,6 +607,10 @@ jobs: security list-keychains -d user -s "$KEYCHAIN_PATH" $EXISTING_KEYCHAINS echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV" + - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + with: + python-version: '3.13' + - name: Configure CMake shell: bash env: @@ -639,6 +656,15 @@ jobs: shell: bash run: cmake --build "$BUILD_DIR" --config Release --target OpenStudio + - name: Archive matching native and frontend symbols (macos) + run: python tools/build-symbols.py archive --app "$BUILD_DIR/OpenStudio_artefacts/Release/OpenStudio.app" --build-dir "$BUILD_DIR" --output output/symbols + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: macos-debug-symbols + path: output/symbols/ + retention-days: 90 + if-no-files-found: error + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: name: support-macos-universal @@ -828,6 +854,10 @@ jobs: if: env.DOPPLER_CONFIGURED == 'true' uses: dopplerhq/cli-action@014df23b1329b615816a38eb5f473bb9000700b1 # v3 + - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + with: + python-version: '3.13' + - name: Configure CMake env: DOPPLER_TOKEN: ${{ secrets.DOPPLER_TOKEN }} @@ -871,6 +901,15 @@ jobs: - name: Build OpenStudio run: cmake --build "$BUILD_DIR" --config Release --target OpenStudio + - name: Archive matching native and frontend symbols (Linux) + run: python tools/build-symbols.py archive --app "$BUILD_DIR/OpenStudio_artefacts/Release/OpenStudio" --build-dir "$BUILD_DIR" --output output/symbols + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: linux-debug-symbols + path: output/symbols/ + retention-days: 90 + if-no-files-found: error + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: name: support-linux-x64 diff --git a/.github/workflows/support-qualification.yml b/.github/workflows/support-qualification.yml index 36924000..5fe323f3 100644 --- a/.github/workflows/support-qualification.yml +++ b/.github/workflows/support-qualification.yml @@ -44,7 +44,7 @@ jobs: if: runner.os == 'Windows' shell: pwsh run: | - ./.venv-support/Scripts/python.exe -m unittest tools.test_openstudio_support -q + ./.venv-support/Scripts/python.exe -m unittest tools.test_openstudio_support tests.test_support_identity -q if ($LASTEXITCODE -ne 0) { throw 'Support tests failed' } $env:OPENSTUDIO_SUPPORT_TEST_BUNDLE = "$PWD/output/support-package/OpenStudioSupport" python tests/test_windows_installer.py --require-tools @@ -56,13 +56,13 @@ jobs: if: runner.os == 'macOS' timeout-minutes: 6 run: | - .venv-support/bin/python -c "import faulthandler, unittest; faulthandler.dump_traceback_later(180, exit=True); unittest.main(module=None, argv=['unittest', 'tools.test_openstudio_support', '-v'])" + .venv-support/bin/python -c "import faulthandler, unittest; faulthandler.dump_traceback_later(180, exit=True); unittest.main(module=None, argv=['unittest', 'tools.test_openstudio_support', 'tests.test_support_identity', '-v'])" python tools/test-packaged-support.py --bundle output/support-package/OpenStudioSupport.app codesign --verify --deep --strict output/support-package/OpenStudioSupport.app - name: Linux support tests and native packaging if: runner.os == 'Linux' run: | - xvfb-run -a .venv-support/bin/python -m unittest tools.test_openstudio_support -q + xvfb-run -a .venv-support/bin/python -m unittest tools.test_openstudio_support tests.test_support_identity -q python -m unittest tests.test_linux_native_packaging tests.test_linux_abi tests.test_release_notes -q xvfb-run -a python tools/test-packaged-support.py --bundle output/support-package/OpenStudioSupport xvfb-run -a python tools/test-support-linux.py --bundle output/support-package/OpenStudioSupport --version "$SUPPORT_VERSION" diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 971faf20..572c1b8d 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -45,6 +45,7 @@ jobs: python3 -m unittest tests.test_release_notes tests.test_release_build_policy + tests.test_support_identity tests.test_build_process_lifecycle tests.test_juce_lv2_identifier_patch tests.test_juce_mac_browser_navigation_patch diff --git a/.gitignore b/.gitignore index 9f881710..54a00be5 100644 --- a/.gitignore +++ b/.gitignore @@ -189,3 +189,4 @@ Desktop.ini # Download cache for local AI runtime assembly /.cache/ +/frontend/debug-symbols/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 7a0d3b6c..918a7049 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -649,6 +649,14 @@ if(WIN32) COMMENT "Copying out-of-process crash reporter") endif() +# Release debug information is retained separately at packaging time. +if(NOT MSVC) + target_compile_options(OpenStudio PRIVATE $<$:-g>) + if(NOT APPLE) + target_link_options(OpenStudio PRIVATE "-Wl,--build-id=sha1") + endif() +endif() + # Non-MSVC developer builds keep the production audio callback path optimized # with source-local options. MSVC optimizes the full Debug host above so that # shared header COMDAT helpers cannot come from an unoptimized translation unit. @@ -1065,7 +1073,7 @@ else() endif() set(OPENSTUDIO_BUILD_POLICY "${CMAKE_CURRENT_BINARY_DIR}/OpenStudio.build-policy.json") file(WRITE "${OPENSTUDIO_BUILD_POLICY}" - "{\"version\":\"${OPENSTUDIO_APP_VERSION}\",\"callbackDiagnostics\":${OPENSTUDIO_CALLBACK_DIAGNOSTICS_JSON}}\n") + "{\"version\":\"${OPENSTUDIO_APP_VERSION}\",\"callbackDiagnostics\":${OPENSTUDIO_CALLBACK_DIAGNOSTICS_JSON},\"araDiagnostics\":${OPENSTUDIO_CALLBACK_DIAGNOSTICS_JSON},\"hangMonitoring\":${OPENSTUDIO_CALLBACK_DIAGNOSTICS_JSON}}\n") openstudio_copy_runtime_file("${OPENSTUDIO_BUILD_POLICY}" "OpenStudio.build-policy.json" "Copying callback instrumentation build policy") openstudio_copy_runtime_file( @@ -1552,6 +1560,8 @@ openstudio_remove_runtime_file( # 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") + # Remove obsolete hashed chunks before copying; identities/maps describe this build only. + openstudio_remove_runtime_path("webui" "Removing obsolete packaged frontend chunks") openstudio_copy_runtime_directory( "${FRONTEND_DIST_DIR}" "webui" @@ -1572,3 +1582,11 @@ endif() # ) # Ensure binary data is linked (generated by juce_add_binary_data) # target_link_libraries(OpenStudio PRIVATE OpenStudio_rc_lib) + +# Build-host Python only; the DAW never reads this identity or starts Support. +find_package(Python3 3.11 REQUIRED COMPONENTS Interpreter) +add_custom_command(TARGET OpenStudio POST_BUILD + COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tools/build-symbols.py" identity + --app "$" --version "${OPENSTUDIO_APP_VERSION}" --configuration "$" + COMMENT "Writing offline native and frontend build identity" + VERBATIM) diff --git a/Source/ARADebug.h b/Source/ARADebug.h index 45aa4121..b55365d7 100644 --- a/Source/ARADebug.h +++ b/Source/ARADebug.h @@ -1,10 +1,12 @@ -#include "AppPaths.h" #pragma once +#include "AppPaths.h" +#include "RealtimeDiagnosticsConfig.h" #include -inline constexpr bool kEnableARADebugDiagnostics = true; +inline constexpr bool kEnableARADebugDiagnostics = OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS != 0; +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS inline void logARADebugLine(const juce::String& msg) { if (!kEnableARADebugDiagnostics) @@ -15,3 +17,7 @@ inline void logARADebugLine(const juce::String& msg) logFile.appendText(juce::Time::getCurrentTime().toString(true, true) + ": " + msg + "\n"); } +#else +// Discard arguments too: formatting a disabled diagnostic is still audio work. +#define logARADebugLine(...) do {} while (false) +#endif diff --git a/Source/ARAHostController.cpp b/Source/ARAHostController.cpp index eeacef1a..4dd545de 100644 --- a/Source/ARAHostController.cpp +++ b/Source/ARAHostController.cpp @@ -61,6 +61,7 @@ bool ARAHostController::OpenStudioAudioAccessController::readAudioSamples ( auto* sourceEntry = SourceConverter::fromHostRef (readerEntry->sourceHostRef); if (! sourceEntry || ! sourceEntry->reader) return false; +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS static int araReadCount = 0; if (araReadCount < 5) { @@ -72,6 +73,7 @@ bool ARAHostController::OpenStudioAudioAccessController::readAudioSamples ( ++araReadCount; } +#endif if (readerEntry->use64Bit) return false; @@ -318,6 +320,7 @@ void ARAHostController::OpenStudioModelUpdateController::notifyAudioSourceAnalys owner.debugAnalysisStarted.store(true, std::memory_order_release); owner.debugLastAnalysisProgressValue.store(value, std::memory_order_release); +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS const int bucket = static_cast(value * 20.0f); const int previousBucket = lastLoggedAnalysisBucket.exchange(bucket); if (bucket != previousBucket || state == ARA::kARAAnalysisProgressCompleted) @@ -327,6 +330,7 @@ void ARAHostController::OpenStudioModelUpdateController::notifyAudioSourceAnalys + " state=" + juce::String(static_cast(state)) + " value=" + juce::String(value, 3)); } +#endif if (state == ARA::kARAAnalysisProgressCompleted) analysisComplete.store (true); @@ -335,23 +339,35 @@ void ARAHostController::OpenStudioModelUpdateController::notifyAudioSourceAnalys void ARAHostController::OpenStudioModelUpdateController::notifyAudioSourceContentChanged ( ARA::ARAAudioSourceHostRef audioSourceHostRef, const ARA::ARAContentTimeRange*, ARA::ContentUpdateScopes) noexcept { +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS using SrcConverter = ARAHostModel::ConversionFunctions; auto* entry = SrcConverter::fromHostRef(audioSourceHostRef); owner.noteEditDrivenDebugEvent("notifyAudioSourceContentChanged", entry != nullptr ? entry->clipId : juce::String()); +#else + juce::ignoreUnused(audioSourceHostRef); +#endif } void ARAHostController::OpenStudioModelUpdateController::notifyAudioModificationContentChanged ( ARA::ARAAudioModificationHostRef audioModificationHostRef, const ARA::ARAContentTimeRange*, ARA::ContentUpdateScopes) noexcept { +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS using ModConverter = ARAHostModel::ConversionFunctions; auto* entry = ModConverter::fromHostRef(audioModificationHostRef); owner.noteEditDrivenDebugEvent("notifyAudioModificationContentChanged", entry != nullptr ? entry->clipId : juce::String()); +#else + juce::ignoreUnused(audioModificationHostRef); +#endif } void ARAHostController::OpenStudioModelUpdateController::notifyPlaybackRegionContentChanged ( ARA::ARAPlaybackRegionHostRef playbackRegionHostRef, const ARA::ARAContentTimeRange*, ARA::ContentUpdateScopes) noexcept { +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS using RegConverter = ARAHostModel::ConversionFunctions; auto* entry = RegConverter::fromHostRef(playbackRegionHostRef); owner.noteEditDrivenDebugEvent("notifyPlaybackRegionContentChanged", entry != nullptr ? entry->clipId : juce::String()); +#else + juce::ignoreUnused(playbackRegionHostRef); +#endif } // Playback Controller @@ -585,7 +601,6 @@ juce::String ARAHostController::addAudioSource (const juce::File& audioFile, auto& dc = araDocController->getDocumentController(); - auto araLog = [] (const juce::String& msg) { logARADebugLine(msg); }; try { @@ -638,7 +653,7 @@ juce::String ARAHostController::addAudioSource (const juce::File& audioFile, RegConverter::toHostRef (entry.get()), dc, *entry->audioModification, regionProps); - araLog ("ARA: created source + modification + region inside edit guard"); + logARADebugLine ("ARA: created source + modification + region inside edit guard"); } // ===================================================================== // Edit guard released — document is now in a stable state @@ -652,7 +667,7 @@ juce::String ARAHostController::addAudioSource (const juce::File& audioFile, // ===================================================================== entry->audioSource->enableAudioSourceSamplesAccess (true); debugSamplesAccessEnabledCount.fetch_add(1, std::memory_order_acq_rel); - araLog ("ARA: enableAudioSourceSamplesAccess(true) for " + clipId); + logARADebugLine ("ARA: enableAudioSourceSamplesAccess(true) for " + clipId); // ===================================================================== // Step 2: Add region to BOTH renderers @@ -666,19 +681,23 @@ juce::String ARAHostController::addAudioSource (const juce::File& audioFile, if (editorRenderer && editorRenderer->isValid()) editorRenderer->add (*entry->playbackRegion); - araLog ("ARA: added region to playbackRenderer + editorRenderer"); + logARADebugLine ("ARA: added region to playbackRenderer + editorRenderer"); // ===================================================================== // Step 3: Request analysis OUTSIDE the edit guard, after renderers updated // ===================================================================== +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS double srcSampleRate = entry->reader->sampleRate; int srcChannels = static_cast (entry->reader->numChannels); auto srcLength = entry->reader->lengthInSamples; +#endif 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); +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS noteDebugOperation("clip_attach", clipId); +#endif if (!requestedAnalysisContentTypes.empty()) { @@ -696,7 +715,7 @@ juce::String ARAHostController::addAudioSource (const juce::File& audioFile, static_cast (requestedAnalysisContentTypes.size()), requestedAnalysisContentTypes.data()); - araLog ("ARA: requestAudioSourceContentAnalysis called for " + clipId + logARADebugLine ("ARA: requestAudioSourceContentAnalysis called for " + clipId + " contentTypes=" + describeRequestedAnalysisContentTypes()); } else @@ -710,10 +729,10 @@ juce::String ARAHostController::addAudioSource (const juce::File& audioFile, 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"); + logARADebugLine ("ARA: skipped analysis request for " + clipId + " because plugin exposed no analyzeable content types"); } - araLog ("ARA: addAudioSource complete — " + clipId + logARADebugLine ("ARA: addAudioSource complete — " + clipId + " sr=" + juce::String (srcSampleRate) + " ch=" + juce::String (srcChannels) + " len=" + juce::String (srcLength) @@ -722,7 +741,7 @@ juce::String ARAHostController::addAudioSource (const juce::File& audioFile, } catch (const std::exception& e) { - araLog ("ARA: ERROR in addAudioSource: " + juce::String (e.what())); + juce::Logger::writeToLog ("ARA: ERROR in addAudioSource: " + juce::String (e.what())); return {}; } } @@ -761,7 +780,9 @@ void ARAHostController::removeAudioSource (const juce::String& clipId) 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); +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS noteDebugOperation("clip_remove", clipId); +#endif } void ARAHostController::updatePlaybackRegion (const juce::String& clipId, @@ -789,7 +810,9 @@ void ARAHostController::updatePlaybackRegion (const juce::String& clipId, regionProps.color = nullptr; entry->playbackRegion->update (regionProps); +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS noteEditDrivenDebugEvent("updatePlaybackRegion", clipId); +#endif } void ARAHostController::processBlock (juce::AudioBuffer& buffer, int numSamples, @@ -944,6 +967,7 @@ ARAHostController::DebugSnapshot ARAHostController::getDebugSnapshot() const && debugSamplesAccessEnabledCount.load(std::memory_order_acquire) >= snapshot.sourceCount; snapshot.editorRendererAttached = editorRenderer != nullptr && editorRenderer->isValid(); snapshot.playbackRendererAttached = playbackRenderer != nullptr && playbackRenderer->isValid(); +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS snapshot.transportPlaying = debugTransportPlaying.load(std::memory_order_acquire); snapshot.transportPositionSeconds = debugTransportPositionSeconds.load(std::memory_order_acquire); snapshot.lastEditTimestampMs = debugLastEditTimestampMs.load(std::memory_order_acquire); @@ -958,6 +982,7 @@ ARAHostController::DebugSnapshot ARAHostController::getDebugSnapshot() const snapshot.lastEditType = debugLastEditType; snapshot.lastOperation = debugLastOperation; snapshot.lastClipId = debugLastClipId; +#endif return snapshot; } @@ -1004,6 +1029,7 @@ void ARAHostController::noteEditDrivenDebugEvent(const juce::String& eventType, debugLastClipId = clipId; } +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS 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); @@ -1016,6 +1042,7 @@ void ARAHostController::noteEditDrivenDebugEvent(const juce::String& eventType, + " transportPlaying=" + juce::String(transportPlaying ? "true" : "false") + " transportPosition=" + juce::String(positionSeconds, 3) + " timeSinceLastPlayStartMs=" + juce::String(timeSinceLastPlayStartMs, 2)); +#endif } juce::String ARAHostController::describeRequestedAnalysisContentTypes() const @@ -1034,7 +1061,7 @@ void ARAHostController::requestStartPlaybackFromPlugin() const if (!playbackRequestHandlers.startPlayback) return; - juce::Logger::writeToLog("ARAHostController: Plugin requested start playback"); + logARADebugLine("ARAHostController: Plugin requested start playback"); auto handler = playbackRequestHandlers.startPlayback; juce::MessageManager::callAsync([handler]() { handler(); }); } @@ -1044,7 +1071,7 @@ void ARAHostController::requestStopPlaybackFromPlugin() const if (!playbackRequestHandlers.stopPlayback) return; - juce::Logger::writeToLog("ARAHostController: Plugin requested stop playback"); + logARADebugLine("ARAHostController: Plugin requested stop playback"); auto handler = playbackRequestHandlers.stopPlayback; juce::MessageManager::callAsync([handler]() { handler(); }); } @@ -1054,7 +1081,7 @@ void ARAHostController::requestSetPlaybackPositionFromPlugin(double timePosition if (!playbackRequestHandlers.setPlaybackPosition) return; - juce::Logger::writeToLog("ARAHostController: Plugin requested playback position " + juce::String(timePosition)); + logARADebugLine("ARAHostController: Plugin requested playback position " + juce::String(timePosition)); auto handler = playbackRequestHandlers.setPlaybackPosition; juce::MessageManager::callAsync([handler, timePosition]() { handler(timePosition); }); } @@ -1064,7 +1091,7 @@ void ARAHostController::requestSetCycleRangeFromPlugin(double startTime, double if (!playbackRequestHandlers.setCycleRange) return; - juce::Logger::writeToLog("ARAHostController: Plugin requested cycle range start=" + logARADebugLine("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); }); @@ -1075,7 +1102,7 @@ void ARAHostController::requestEnableCycleFromPlugin(bool enable) const if (!playbackRequestHandlers.enableCycle) return; - juce::Logger::writeToLog("ARAHostController: Plugin requested cycle " + juce::String(enable ? "enable" : "disable")); + logARADebugLine("ARAHostController: Plugin requested cycle " + juce::String(enable ? "enable" : "disable")); auto handler = playbackRequestHandlers.enableCycle; juce::MessageManager::callAsync([handler, enable]() { handler(enable); }); } diff --git a/Source/AudioEngine.cpp b/Source/AudioEngine.cpp index cf9757e4..320593ad 100644 --- a/Source/AudioEngine.cpp +++ b/Source/AudioEngine.cpp @@ -2763,6 +2763,9 @@ static void logPitchEditorFormant(const juce::String& msg) #ifndef OPENSTUDIO_AUDIO_PATH_DEBUG #define OPENSTUDIO_AUDIO_PATH_DEBUG 0 #endif +#if OPENSTUDIO_AUDIO_PATH_DEBUG && !OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS + #error Audio-path logging requires an explicit diagnostic build. +#endif static constexpr bool kAudioPathDebugLogs = OPENSTUDIO_AUDIO_PATH_DEBUG != 0; static void logAudioTransport(const juce::String& msg) @@ -6504,8 +6507,10 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha // midway through a stopped callback must not advance that unrecorded block. const bool callbackPlaying = isPlaying.load(std::memory_order_acquire); const bool callbackRecording = isRecordMode.load(std::memory_order_acquire); +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS const bool firstCallbackAfterTransportStart = callbackPlaying && firstCallbackAfterTransportStartPending.exchange(false, std::memory_order_acq_rel); +#endif const bool callbackStartedWhileRecording = callbackPlaying && callbackRecording; @@ -6785,8 +6790,12 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha clearRealtimeBuffer(entry.sidechainOutputBuffer); clearRealtimeBuffer(entry.sendAccumBuffer); } +#if OPENSTUDIO_AUDIO_PATH_DEBUG const bool shouldLogCallbackSummary = kAudioPathDebugLogs && (((callbackCounter % 50) == 1) || ((callbackPlaying || callbackRecording) && firstCallbackAfterTransportStart)); +#else + constexpr bool shouldLogCallbackSummary = false; +#endif #if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS const bool shouldSampleSignalChainPeaks = shouldSampleAudioCallbackStages @@ -6891,7 +6900,9 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha // Both execution modes share input preparation, raw recording and final // reduction. Only the independent per-track DSP runs on audio workers. +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS bool anyActiveARAInCallback = false; +#endif const auto prepareTrack = [&](const RealtimeTrackEntry& trackEntry, RealtimeTrackWork& work) -> bool { @@ -6901,6 +6912,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha return false; const bool hasActiveARA = track->hasActiveARA(); +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS if (hasActiveARA) { anyActiveARAInCallback = true; @@ -6913,6 +6925,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha } } +#endif const bool excludedBySolo = anySoloed && !track->getSolo() && !track->getSoloSafe(); const bool hasActiveAudioRecording = callbackRecording && track->getRecordArmed() @@ -7651,6 +7664,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha std::memory_order_relaxed); } +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS // Only measure callback duration when an ARA plugin is active — the QPC call is // non-trivial at 32-sample block sizes (1500/sec) and the result is only used for // ARA slow-block diagnostics. @@ -7678,6 +7692,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext (const float* const* inputCha 3)); } } +#endif #if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS const double callbackEndWallTimeMs = @@ -9631,6 +9646,7 @@ void AudioEngine::setTransportPlaying(bool playing) } else { + if (!isRecordMode) OpenStudioCrashDiagnostics::flushRealtimeFaults(); logToDisk("Transport STOP at position: " + juce::String(getTransportPosition()) + "s"); const juce::ScopedLock sl(mainProcessorGraph->getCallbackLock()); queueAllNotesOffForAllTracks(); @@ -9650,6 +9666,7 @@ void AudioEngine::setTransportPlaying(bool playing) // Don't reset position here - let the stop() action control that } +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS const double currentPositionSeconds = getTransportPosition(); const auto focusedEditorTarget = pluginWindowManager.getFocusedEditorTarget(); @@ -9670,9 +9687,12 @@ void AudioEngine::setTransportPlaying(bool playing) editorFocusedAtPlayStart); } } +#endif if (playing) { +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS firstCallbackAfterTransportStartPending.store(true, std::memory_order_release); +#endif isPlaying.store(true, std::memory_order_release); } } @@ -10002,6 +10022,7 @@ void AudioEngine::setTransportRecording(bool recording) lastCompletedClips.insert(lastCompletedClips.end(), std::make_move_iterator(completedAudioTakes.begin()), std::make_move_iterator(completedAudioTakes.end())); + OpenStudioCrashDiagnostics::flushRealtimeFaults(); logToDisk("Recordings stopped. Completed " + juce::String(lastCompletedClips.size()) + " audio clips."); logAudioRecord("record stop completedAudio=" + juce::String(static_cast(lastCompletedClips.size())) + " completedMidi=" + juce::String(static_cast(lastCompletedMIDIClips.size()))); @@ -76238,6 +76259,10 @@ juce::var AudioEngine::runRenderExportRegression(const juce::File& outputDirecto && snapshot["audioCallbackStageTimings"].isVoid() && compact["audioCallbackDeadlineMissCount"].isVoid() && !stats.enabled && stats.published == 0 && stats.pending == 0; + int araArgumentEvaluations = 0; + logARADebugLine(juce::String(++araArgumentEvaluations)); + addCheck("ara_logging_build_contract", araArgumentEvaluations == (expected ? 1 : 0), + "ARA logging arguments are not evaluated in ordinary builds."); addCheck("callback_diagnostics_build_contract", availability && timing, "Production omits measurements and cannot enable tracing; diagnostic builds collect explicit fixture callbacks. No hardware timing claim."); } diff --git a/Source/CrashDiagnostics.cpp b/Source/CrashDiagnostics.cpp index 9c717a59..075cf21c 100644 --- a/Source/CrashDiagnostics.cpp +++ b/Source/CrashDiagnostics.cpp @@ -1,6 +1,7 @@ #include "RuntimeLocation.h" #include "AppPaths.h" #include "CrashDiagnostics.h" +#include "RealtimeDiagnosticsConfig.h" #include #include @@ -53,7 +54,7 @@ void flushBreadcrumbs() #if JUCE_WINDOWS HANDLE crashRequested = nullptr, crashFinished = nullptr, fallbackRequested = nullptr; -HANDLE externalReporter = nullptr, reporterMapping = nullptr; +HANDLE externalReporter = nullptr, reporterMapping = nullptr, monitorChanged = nullptr; CrashReporterProtocol::SharedState fallbackReport {}; CrashReporterProtocol::SharedState* reportState = &fallbackReport; volatile LONG crashStarted = 0; @@ -73,7 +74,7 @@ bool launchExternalReporter() if (ready) CloseHandle(ready); return false; } - HANDLE inherited[] = { reporterMapping, crashRequested, crashFinished, ready, parent }; + HANDLE inherited[] = { reporterMapping, crashRequested, crashFinished, ready, parent, monitorChanged }; SIZE_T attributeBytes = 0; InitializeProcThreadAttributeList(nullptr, 1, 0, &attributeBytes); std::vector attributeStorage(attributeBytes); @@ -230,7 +231,8 @@ void installCrashHandlers() crashRequested = CreateEventW(&security, TRUE, FALSE, nullptr); crashFinished = CreateEventW(&security, TRUE, FALSE, nullptr); fallbackRequested = CreateEventW(nullptr, TRUE, FALSE, nullptr); - if (crashRequested && crashFinished && fallbackRequested) + monitorChanged = CreateEventW(&security, FALSE, FALSE, nullptr); + if (crashRequested && crashFinished && fallbackRequested && monitorChanged) { HANDLE thread = CreateThread(nullptr, 0, writeCrashDump, nullptr, 0, nullptr); if (thread != nullptr) @@ -251,18 +253,21 @@ void pulseMessageThread() noexcept { #if JUCE_WINDOWS InterlockedExchange64(&reportState->heartbeatTicks, static_cast(GetTickCount64())); - InterlockedExchange(&reportState->watchdogArmed, 1); + if (InterlockedExchange(&reportState->watchdogArmed, 1) == 0 && monitorChanged != nullptr) + SetEvent(monitorChanged); #endif } void beginFinalShutdown() { + flushRealtimeFaults(); recordBreadcrumb("final_shutdown_committed", "Drain owned workers; preserve recovery evidence if a native call cannot return."); #if JUCE_WINDOWS // Refresh heartbeat so a slow but healthy operation before Quit does not // consume shutdown's diagnostic grace period. pulseMessageThread(); InterlockedExchange(&reportState->finalQuitRequested, 1); + if (monitorChanged != nullptr) SetEvent(monitorChanged); if (externalReporter == nullptr || WaitForSingleObject(externalReporter, 0) != WAIT_TIMEOUT) recordBreadcrumb("shutdown_watchdog_failed", "External reporter unavailable; safe joins may still wait indefinitely."); #endif @@ -274,6 +279,20 @@ int runSelfTest(const juce::File& isolatedDirectory, bool simulateCrash, bool si selfTestReporterUnavailable = simulateReporterUnavailable; if (! selfTestDirectory.createDirectory() || getBreadcrumbLogFile().existsAsFile()) return 2; installCrashHandlers(); + #if JUCE_WINDOWS + if (reportState->watchdogArmed != 0 || reportState->finalQuitRequested != 0) return 11; + if (!simulateCrash && !simulateHang && !simulateShutdownHang) + { + // An old heartbeat must do nothing until explicitly armed. The reporter + // is blocked on events during ordinary app use, including recording. + reportState->hangTimeoutMs = 1; + reportState->heartbeatTicks = 1; + juce::Thread::sleep(1500); + if (reportState->hangReportState != 0) return 12; + reportState->heartbeatTicks = 0; + reportState->hangTimeoutMs = 30000; + } + #endif for (int index = 0; index < 128; ++index) recordBreadcrumb("fixture_operation", "sequence=" + juce::String(index) + ";"); if (getBreadcrumbLogFile().existsAsFile()) return 3; diff --git a/Source/CrashDiagnostics.h b/Source/CrashDiagnostics.h index cb84f386..1d1a111d 100644 --- a/Source/CrashDiagnostics.h +++ b/Source/CrashDiagnostics.h @@ -8,9 +8,9 @@ namespace OpenStudioCrashDiagnostics trackBufferContract, pluginBufferContract, recordingBufferContract, processorException, processorNonFinite, processorRemote, count }; void recordRealtimeFault(RealtimeFault fault) noexcept; - void flushRealtimeFaults(); // Message thread, rate-limited, disk writes only after a failure. + void flushRealtimeFaults(); // Control thread at stop/shutdown; disk writes only after a failure. void installCrashHandlers(); - void pulseMessageThread() noexcept; // One atomic timestamp; never writes to disk. + void pulseMessageThread() noexcept; // Explicit diagnostic builds/self-tests or committed final Quit only. // Only after the user committed to quitting. No thread termination; the // external reporter bounds the entire already-quitting process lifetime. void beginFinalShutdown(); diff --git a/Source/CrashReporterMain.cpp b/Source/CrashReporterMain.cpp index 50e506dd..78630ec3 100644 --- a/Source/CrashReporterMain.cpp +++ b/Source/CrashReporterMain.cpp @@ -37,9 +37,9 @@ DWORD WINAPI boundFinalQuit(void* argument) int wmain(int argc, wchar_t** argv) { - if (argc != 6) return 2; - HANDLE handles[5] {}; - for (int index = 0; index < 5; ++index) + if (argc != 7) return 2; + HANDLE handles[6] {}; + for (int index = 0; index < 6; ++index) { wchar_t* end = nullptr; const auto value = _wcstoui64(argv[index + 1], &end, 10); @@ -52,15 +52,16 @@ int wmain(int argc, wchar_t** argv) || state->protocolVersion != CrashReporterProtocol::version || GetProcessId(handles[4]) != state->processId) return 3; QuitWatchdog watchdog { state, handles[4], CreateEventW(nullptr, TRUE, FALSE, nullptr) }; - HANDLE watchdogThread = watchdog.stop ? CreateThread(nullptr, 0, boundFinalQuit, &watchdog, 0, nullptr) : nullptr; - if (!watchdogThread) return 6; + if (!watchdog.stop) return 6; + HANDLE watchdogThread = nullptr; // Created only when diagnostics or final Quit explicitly arm monitoring. SetEvent(handles[3]); // Parent installs the external path only after readiness. - const HANDLE waitHandles[] = { handles[1], handles[4] }; + const HANDLE waitHandles[] = { handles[1], handles[4], handles[5] }; int result = 0; auto lastWakeTicks = GetTickCount64(); for (;;) { - const DWORD wake = WaitForMultipleObjects(2, waitHandles, FALSE, 1000); + const bool monitor = InterlockedCompareExchange(&state->watchdogArmed, 0, 0) != 0; + const DWORD wake = WaitForMultipleObjects(3, waitHandles, FALSE, monitor ? 1000 : INFINITE); if (wake == WAIT_OBJECT_0) { result = CrashReporterProtocol::writeReport(*state, handles[4]) ? 0 : 4; @@ -68,6 +69,18 @@ int wmain(int argc, wchar_t** argv) break; } if (wake == WAIT_OBJECT_0 + 1) break; + if (wake == WAIT_OBJECT_0 + 2) + { + // In a diagnostic build, keep the Quit bound available even if a + // preceding hang dump later blocks inside the OS dump writer. + if (!watchdogThread) + { + watchdogThread = CreateThread(nullptr, 0, boundFinalQuit, &watchdog, 0, nullptr); + if (!watchdogThread) { result = 6; break; } + } + lastWakeTicks = GetTickCount64(); + continue; + } if (wake != WAIT_TIMEOUT) { result = 5; break; } const auto nowTicks = GetTickCount64(); const auto wakeGap = nowTicks - lastWakeTicks; @@ -87,8 +100,11 @@ int wmain(int argc, wchar_t** argv) InterlockedExchange(&state->hangReportState, captured ? 2 : 3); } SetEvent(watchdog.stop); - WaitForSingleObject(watchdogThread, INFINITE); - CloseHandle(watchdogThread); + if (watchdogThread) + { + WaitForSingleObject(watchdogThread, INFINITE); + CloseHandle(watchdogThread); + } CloseHandle(watchdog.stop); UnmapViewOfFile(state); for (const auto handle : handles) CloseHandle(handle); diff --git a/Source/CrashReporterProtocol.h b/Source/CrashReporterProtocol.h index f56772b4..114120b4 100644 --- a/Source/CrashReporterProtocol.h +++ b/Source/CrashReporterProtocol.h @@ -6,7 +6,7 @@ namespace CrashReporterProtocol { constexpr DWORD magic = 0x4f534352; -constexpr DWORD version = 3; +constexpr DWORD version = 4; constexpr int entryCount = 64; struct Entry { diff --git a/Source/MainComponent.cpp b/Source/MainComponent.cpp index 13d069f9..30d13f75 100644 --- a/Source/MainComponent.cpp +++ b/Source/MainComponent.cpp @@ -17658,9 +17658,11 @@ void MainComponent::timerCallback() // acquires the recorder's audio-writer lock. if (isMainWindow()) audioEngine.maintainRecordingPreviews(); +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS if (isMainWindow() && frontendStartupState == FrontendStartupState::ready) OpenStudioCrashDiagnostics::pulseMessageThread(); OpenStudioCrashDiagnostics::flushRealtimeFaults(); +#endif if (secondaryWindowClosing) return; const auto faultGeneration = ProcessorSafety::changeGeneration.load(std::memory_order_acquire); diff --git a/Source/PluginWindowManager.cpp b/Source/PluginWindowManager.cpp index 7d44e8d9..2d34c654 100644 --- a/Source/PluginWindowManager.cpp +++ b/Source/PluginWindowManager.cpp @@ -9,10 +9,8 @@ namespace { -void logPluginWindowDebug(const juce::String& msg) -{ - logARADebugLine(msg); -} +// Preserve compile-time argument elision through this local logging wrapper. +#define logPluginWindowDebug(...) logARADebugLine(__VA_ARGS__) juce::String scopeToString(PluginWindowManager::PluginEditorTarget::Scope scope) { @@ -271,7 +269,7 @@ PluginWindowManager::PluginWindow::PluginWindow(PluginWindowManager& ownerIn, owner.positionWindow(*this); setVisible(true); toFront(true); - owner.logWindowEvent(target, "editor_opened", "title=" + title); + if constexpr (kEnableARADebugDiagnostics) owner.logWindowEvent(target, "editor_opened", "title=" + title); } } @@ -284,7 +282,7 @@ PluginWindowManager::PluginWindow::~PluginWindow() void PluginWindowManager::PluginWindow::closeButtonPressed() { - owner.logWindowEvent(target, "editor_close_requested"); + if constexpr (kEnableARADebugDiagnostics) owner.logWindowEvent(target, "editor_close_requested"); setVisible(false); } @@ -297,7 +295,7 @@ bool PluginWindowManager::PluginWindow::keyPressed(const juce::KeyPress& key) // unhandled Undo/Redo chords before any application-level forwarding. if (isNativePluginHistoryShortcut(key)) { - owner.logWindowEvent(target, "host_claimed_plugin_history_key", + if constexpr (kEnableARADebugDiagnostics) owner.logWindowEvent(target, "host_claimed_plugin_history_key", "key=" + normaliseKeyForDom(key) + " code=" + normaliseCodeForDom(key)); return true; } @@ -305,7 +303,7 @@ bool PluginWindowManager::PluginWindow::keyPressed(const juce::KeyPress& key) if (DocumentWindow::keyPressed(key)) { if (shouldLogForwardedShortcut(key)) - owner.logWindowEvent(target, "plugin_consumed_key", + if constexpr (kEnableARADebugDiagnostics) owner.logWindowEvent(target, "plugin_consumed_key", "key=" + normaliseKeyForDom(key) + " code=" + normaliseCodeForDom(key)); return true; } @@ -322,7 +320,7 @@ void PluginWindowManager::PluginWindow::activeWindowStatusChanged() && owner.focusedEditorTarget->getStableKey() == target.getStableKey()) owner.focusedEditorTarget.reset(); - owner.logWindowEvent(target, isActiveWindow() ? "editor_focused" : "editor_unfocused"); + if constexpr (kEnableARADebugDiagnostics) owner.logWindowEvent(target, isActiveWindow() ? "editor_focused" : "editor_unfocused"); } //============================================================================== @@ -370,7 +368,7 @@ void PluginWindowManager::openEditor(juce::AudioProcessor* processor, const juce it->second->setVisible(true); positionWindow(*it->second); it->second->toFront(true); - logWindowEvent(target, "editor_reopened_to_front"); + if constexpr (kEnableARADebugDiagnostics) logWindowEvent(target, "editor_reopened_to_front"); } return; } @@ -400,7 +398,7 @@ void PluginWindowManager::openEditor(std::shared_ptr proce it->second->setVisible(true); positionWindow(*it->second); it->second->toFront(true); - logWindowEvent(target, "editor_reopened_to_front"); + if constexpr (kEnableARADebugDiagnostics) logWindowEvent(target, "editor_reopened_to_front"); } return; } @@ -427,7 +425,7 @@ void PluginWindowManager::closeEditor(juce::AudioProcessor* processor) if (focusedEditorTarget.has_value() && focusedEditorTarget->getStableKey() == it->second->target.getStableKey()) focusedEditorTarget.reset(); - logWindowEvent(it->second->target, "editor_closed"); + if constexpr (kEnableARADebugDiagnostics) logWindowEvent(it->second->target, "editor_closed"); activeWindows.erase(it); juce::Logger::writeToLog("PluginWindowManager: Closed window for " + name); } @@ -445,7 +443,7 @@ void PluginWindowManager::closeEditorSync(juce::AudioProcessor* processor) if (focusedEditorTarget.has_value() && focusedEditorTarget->getStableKey() == it->second->target.getStableKey()) focusedEditorTarget.reset(); - logWindowEvent(it->second->target, "editor_closed_sync"); + if constexpr (kEnableARADebugDiagnostics) logWindowEvent(it->second->target, "editor_closed_sync"); activeWindows.erase(it); } } @@ -464,7 +462,7 @@ void PluginWindowManager::closeEditorsForTrack(const std::vectorgetStableKey() == it->second->target.getStableKey()) focusedEditorTarget.reset(); - logWindowEvent(it->second->target, "editor_closed_for_track"); + if constexpr (kEnableARADebugDiagnostics) logWindowEvent(it->second->target, "editor_closed_for_track"); activeWindows.erase(it); } } @@ -613,7 +611,7 @@ void PluginWindowManager::timerCallback() if (focusedEditorTarget.has_value() && focusedEditorTarget->getStableKey() == window.target.getStableKey()) focusedEditorTarget.reset(); - logWindowEvent(window.target, "editor_closed_after_hidden"); + if constexpr (kEnableARADebugDiagnostics) logWindowEvent(window.target, "editor_closed_after_hidden"); it = activeWindows.erase(it); continue; } @@ -624,6 +622,7 @@ void PluginWindowManager::timerCallback() void PluginWindowManager::logWindowEvent(const PluginEditorTarget& target, const juce::String& event, const juce::String& extra) const { +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS juce::String line = "PluginWindow event: event=" + event + " scope=" + scopeToString(target.scope) + " trackId=" + (target.trackId.isNotEmpty() ? target.trackId : juce::String("")) @@ -631,6 +630,9 @@ void PluginWindowManager::logWindowEvent(const PluginEditorTarget& target, const if (extra.isNotEmpty()) line += " " + extra; logPluginWindowDebug(line); +#else + juce::ignoreUnused(target, event, extra); +#endif } //============================================================================== diff --git a/Source/TrackProcessor.cpp b/Source/TrackProcessor.cpp index 7ee08d12..877a71d7 100644 --- a/Source/TrackProcessor.cpp +++ b/Source/TrackProcessor.cpp @@ -500,13 +500,15 @@ static bool hasInternalAuditionSourceActive( return false; } -// Debug logging — always active for FX diagnostics +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS +// Explicit diagnostic builds only; never used by ordinary recording. static void logToDisk(const juce::String& msg) { auto f = AppPaths::diagnostics().getChildFile("debug_log.txt"); f.getParentDirectory().createDirectory(); f.appendText(juce::Time::getCurrentTime().toString(true, true) + ": " + msg + "\n"); } +#endif static int getSafeHostedPluginBlockSize(int requestedBlockSize, const juce::AudioProcessor* processor = nullptr) @@ -3636,6 +3638,7 @@ bool TrackProcessor::tryProcessTinyEmptyGraph(juce::AudioBuffer& buffer, void TrackProcessor::processBlockInternal (juce::AudioBuffer& buffer, juce::MidiBuffer& midiMessages, const RealtimeGraphSnapshot* workerGraph) { + #if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS const int activeARAFXIndexForBlock = workerGraph != nullptr ? -1 : araFXIndexForRealtime.load(std::memory_order_acquire); @@ -3649,6 +3652,7 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer& buffer, juc const bool isARATrack = kEnableARADebugDiagnostics && activeARAControllerForBlock != nullptr; const double trackProcessStartMs = isARATrack ? juce::Time::getMillisecondCounterHiRes() : 0.0; +#endif juce::ScopedNoDenormals noDenormals; auto totalNumInputChannels = getTotalNumInputChannels(); auto totalNumOutputChannels = getTotalNumOutputChannels(); @@ -3877,11 +3881,13 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer& buffer, juc } } +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS if (activeARAControllerForBlock != nullptr) activeARAControllerForBlock->updateTransportDebugState( araTransportPlayingDebugState.load( std::memory_order_acquire), blockTimeSeconds); +#endif // Safety: only clear channels that actually exist in the buffer int bufferChannels = buffer.getNumChannels(); @@ -3903,66 +3909,6 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer& buffer, juc processTrackPDC(buffer); - bool hasAnyFX = (inputFXSnapshot && !inputFXSnapshot->empty()) - || (trackFXSnapshot && !trackFXSnapshot->empty()); - - // One-time diagnostic log on first processBlock call with FX loaded - // (helps diagnose crashes — last log entry before crash shows where it stopped) - static bool enableRealtimeFirstFXLog = false; - static bool loggedFirstFXProcess = false; - if (enableRealtimeFirstFXLog && hasAnyFX && !loggedFirstFXProcess) - { - loggedFirstFXProcess = true; - logToDisk("TrackProcessor::processBlock FIRST CALL WITH FX"); - logToDisk(" buffer channels: " + juce::String(bufferChannels) + - " samples: " + juce::String(buffer.getNumSamples())); - logToDisk(" totalNumInputChannels: " + juce::String(totalNumInputChannels) + - " totalNumOutputChannels: " + juce::String(totalNumOutputChannels)); - logToDisk(" inputFX count: " + juce::String(inputFXSnapshot ? (int)inputFXSnapshot->size() : 0) + - " trackFX count: " + juce::String(trackFXSnapshot ? (int)trackFXSnapshot->size() : 0)); - logToDisk(" fxProcessBuffer channels: " + juce::String(fxProcessBuffer.getNumChannels()) + - " samples: " + juce::String(fxProcessBuffer.getNumSamples())); - logToDisk(" sampleRate: " + juce::String(getSampleRate()) + - " blockSize: " + juce::String(getBlockSize())); - - for (int i = 0; inputFXSnapshot && i < (int)inputFXSnapshot->size(); ++i) - { - auto* proc = (*inputFXSnapshot)[i].get(); - if (proc) - { - logToDisk(" inputFX[" + juce::String(i) + "]: " + proc->getName() + - " inCh=" + juce::String(proc->getTotalNumInputChannels()) + - " outCh=" + juce::String(proc->getTotalNumOutputChannels()) + - " sr=" + juce::String(proc->getSampleRate()) + - " bs=" + juce::String(proc->getBlockSize())); - } - } - - for (int i = 0; trackFXSnapshot && i < (int)trackFXSnapshot->size(); ++i) - { - auto* proc = (*trackFXSnapshot)[i].get(); - if (proc) - { - logToDisk(" trackFX[" + juce::String(i) + "]: " + proc->getName() + - " inCh=" + juce::String(proc->getTotalNumInputChannels()) + - " outCh=" + juce::String(proc->getTotalNumOutputChannels()) + - " sr=" + juce::String(proc->getSampleRate()) + - " bs=" + juce::String(proc->getBlockSize())); - } - } - - // Log safeProcessFX path decision for first plugin - if (trackFXSnapshot && !trackFXSnapshot->empty() && (*trackFXSnapshot)[0]) - { - auto* proc = (*trackFXSnapshot)[0].get(); - int pluginChannels = juce::jmax(proc->getTotalNumInputChannels(), - proc->getTotalNumOutputChannels()); - logToDisk(" safeProcessFX: pluginChannels=" + juce::String(pluginChannels) + - " bufferChannels=" + juce::String(bufferChannels) + - " path=" + juce::String(pluginChannels == bufferChannels ? "DIRECT" : "EXPANDED")); - } - } - const auto resolveFXContinuity = [&] (juce::AudioProcessor* proc, bool isInputFXChain, @@ -4145,6 +4091,7 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer& buffer, juc pluginAutomationRoutesForBlock, buffer.getNumSamples()); +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS const bool isARAProcessor = activeARAControllerForBlock != nullptr && trackFXSnapshot @@ -4156,6 +4103,7 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer& buffer, juc activeARAFXIndexForBlock)] .get() == proc; const double envelopeStartMs = isARAProcessor ? juce::Time::getMillisecondCounterHiRes() : 0.0; +#endif const bool useDoublePrecision = processingPrecisionMode == ProcessingPrecisionMode::Hybrid64 && !forceFloat @@ -4164,6 +4112,7 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer& buffer, juc const int expandedCh = useDoublePrecision ? juce::jmin(pluginChannels, fxProcessBufferDouble.getNumChannels()) : juce::jmin(pluginChannels, fxProcessBuffer.getNumChannels()); +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS double preProcessMs = 0.0; double processDurationMs = 0.0; auto logARAProcessDuration = [&](double postProcessMs) @@ -4225,12 +4174,17 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer& buffer, juc } }; +#endif if (!useDoublePrecision && pluginChannels == bufferChannels) { +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS const double processStartMs = isARAProcessor ? juce::Time::getMillisecondCounterHiRes() : 0.0; preProcessMs = isARAProcessor ? (processStartMs - envelopeStartMs) : 0.0; +#endif if (!safety->process(*proc, buffer, midiMessages)) return false; +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS processDurationMs = isARAProcessor ? (juce::Time::getMillisecondCounterHiRes() - processStartMs) : 0.0; +#endif // Mono plugin on stereo track: duplicate processed output to all channels // (matches Reaper behaviour — avoids dry right channel when plugin is mono out) int outCh = proc->getTotalNumOutputChannels(); @@ -4239,7 +4193,9 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer& buffer, juc for (int ch = outCh; ch < juce::jmin(2, bufferChannels); ++ch) buffer.copyFrom (ch, 0, buffer, 0, 0, numSamps); } +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS logARAProcessDuration(isARAProcessor ? (juce::Time::getMillisecondCounterHiRes() - (processStartMs + processDurationMs)) : 0.0); +#endif } else { @@ -4265,10 +4221,14 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer& buffer, juc channelPtrs[ch] = fxProcessBufferDouble.getWritePointer(ch); juce::AudioBuffer pluginBuffer(channelPtrs, expandedCh, numSamps); +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS const double processStartMs = isARAProcessor ? juce::Time::getMillisecondCounterHiRes() : 0.0; preProcessMs = isARAProcessor ? (processStartMs - envelopeStartMs) : 0.0; +#endif if (!safety->process(*proc, pluginBuffer, midiMessages)) return false; +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS processDurationMs = isARAProcessor ? (juce::Time::getMillisecondCounterHiRes() - processStartMs) : 0.0; +#endif if (expandedCh == 1 && bufferChannels > 1) { @@ -4290,7 +4250,9 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer& buffer, juc dest[sample] = static_cast(src[sample]); } } +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS logARAProcessDuration(isARAProcessor ? (juce::Time::getMillisecondCounterHiRes() - (processStartMs + processDurationMs)) : 0.0); +#endif } else { @@ -4316,10 +4278,14 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer& buffer, juc channelPtrs[ch] = fxProcessBuffer.getWritePointer(ch); juce::AudioBuffer pluginBuffer(channelPtrs, expandedCh, numSamps); +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS const double processStartMs = isARAProcessor ? juce::Time::getMillisecondCounterHiRes() : 0.0; preProcessMs = isARAProcessor ? (processStartMs - envelopeStartMs) : 0.0; +#endif if (!safety->process(*proc, pluginBuffer, midiMessages)) return false; +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS processDurationMs = isARAProcessor ? (juce::Time::getMillisecondCounterHiRes() - processStartMs) : 0.0; +#endif if (expandedCh == 1 && bufferChannels > 1) { @@ -4331,7 +4297,9 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer& buffer, juc for (int ch = 0; ch < juce::jmin(bufferChannels, proc->getTotalNumOutputChannels()); ++ch) buffer.copyFrom(ch, 0, pluginBuffer, ch < expandedCh ? ch : 0, 0, numSamps); } +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS logARAProcessDuration(isARAProcessor ? (juce::Time::getMillisecondCounterHiRes() - (processStartMs + processDurationMs)) : 0.0); +#endif } } for (const auto metadata : midiMessages) @@ -5128,6 +5096,7 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer& buffer, juc } } +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS const double trackProcessDurationMs = isARATrack ? (juce::Time::getMillisecondCounterHiRes() - trackProcessStartMs) : 0.0; if (kEnableARADebugDiagnostics && trackProcessDurationMs > 10.0 @@ -5141,6 +5110,7 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer& buffer, juc + " numSamples=" + juce::String(buffer.getNumSamples())); } +#endif const float peak = processTrackOutput(buffer, sendSnapshot && !sendSnapshot->empty(), blockTimeSeconds, processingSampleRate, blockPanLaw); finishRealtimeFXTailDrain(peak); @@ -7995,6 +7965,7 @@ void TrackProcessor::noteARATransportPlaybackStateChanged(const juce::String& tr if (playing) { araEditorFocusedAtPlaybackStart.store(editorFocusedAtPlayStart, std::memory_order_release); +#if OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS const auto snapshotBeforeStart = araController->getDebugSnapshot(); const uint64 playbackRun = araPlaybackRunCounter.fetch_add(1, std::memory_order_acq_rel) + 1; araStructuredPlaySessionLogged.store(true, std::memory_order_release); @@ -8022,6 +7993,7 @@ void TrackProcessor::noteARATransportPlaybackStateChanged(const juce::String& tr + " audioSourceSamplesAccessEnabled=" + juce::String(snapshotBeforeStart.audioSourceSamplesAccessEnabled ? "true" : "false")); } +#endif araController->notePlaybackStart(positionSeconds); } else diff --git a/build.py b/build.py index 9ec07295..86ed096e 100644 --- a/build.py +++ b/build.py @@ -321,6 +321,8 @@ def main(): "--output", support_output, "--stage", (str(Path(get_cpp_exe_path("Release")).parents[2]) if platform.system() == "Darwin" else os.path.dirname(get_cpp_exe_path("Release")))], shell=False) + run_command([sys.executable, "tools/build-symbols.py", "archive", + "--app", get_cpp_exe_path("Release"), "--build-dir", "build"], shell=False) print("\nProduction Build Complete.") print(f"Run: {get_cpp_exe_path('Release')}") diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md index 1945ca07..a08d5e40 100644 --- a/docs/USER_MANUAL.md +++ b/docs/USER_MANUAL.md @@ -3111,6 +3111,13 @@ from the DMG), or Linux application menu/companion AppImage. Collect existing ev an audio/MIDI export, and use **Open output folder** to review the report. Choose **Create reviewed ZIP** to prepare an attachment for support. No Python or terminal is needed in packaged builds. Nothing is uploaded automatically. +Collection includes plugin scan logs and filtered plugin/audio settings. Optional +fields accept the app executable, an existing OS crash report, and a developer's +matching symbol archive. Incident debug IDs remain separate from the currently +installed build; later settings cannot establish conditions at an earlier glitch. +Ordinary builds omit ARA callback logging and periodic hang monitoring. Crash +capture and final-Quit protection remain; a running-session freeze does not +automatically start Support or a timed hang dump. Close the support window before reopening the DAW. This adds no work to normal recording, playback or exports; the explicitly opened tool uses CPU, RAM and disk. Dumps are optional and unredacted. See [Offline support and export inspection](offline-support.md) diff --git a/docs/certification-0.1.09.md b/docs/certification-0.1.09.md index 7a6e58ef..f1c8fe4c 100644 --- a/docs/certification-0.1.09.md +++ b/docs/certification-0.1.09.md @@ -6,6 +6,12 @@ The user requested certification checks and a release PR, with publication held. ## Current evidence +Further offline collection, symbol matching and diagnostic-removal changes after +`93d03c15` change the native binary and Support payload. The older installer, +WACK and Linux package results below do **not** certify these new artifacts. +The local verification recorded under "Offline support follow-up" applies to +the new source. No release, Store submission or website deployment was performed. + The 5 November local investigation below changes native NAM preparation and the explicit diagnostic probe. Final Windows/Linux package checks and WACK passed at `b6aba8ae`, including the warning-only follow-up to the functional NAM fix. @@ -13,7 +19,7 @@ Each result applies to its listed artifact. The remaining Linux browser check passed; the strict 8-sample hardware gate still fails. An automatic Mac run also exposed three geometry-check failures after successful frontend startup. PR #36 remains a draft, with no submitted formal review approval. Subsequent -qualification/release-note edits do not change the tested application source. +qualification/release-note edits through `93d03c15` did not change that tested application source. - **Pass — Linux qualification after the NAM preparation/insertion fix:** [job 126802823878](https://github.com/sdevil7th/OpenStudio/actions/runs/41895223774/job/126802823878) @@ -523,3 +529,53 @@ provided by the OS/device. No such monitor is added to normal DAW use. Official references: [Microsoft's WACK prerequisites and commands](https://learn.microsoft.com/en-us/windows/uwp/debug-test-perf/windows-app-certification-kit), [manual Store publishing holds](https://learn.microsoft.com/en-us/windows/apps/publish/publish-your-app/msix/manage-submission-options), and [Apple's notarization requirements](https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution). + + +## Offline support follow-up — 5 November 2026 + +This is local, unpublished development verification. The release executable SHA-256 +is `4d6161e54da1638406acead9e32103f72766ac4f80af525e08f67bffbb04d163`; +its debug identity is `pdb:44C93176-1CDA-4CA2-A53F-26FB29CE1FCB:101`. +Build metadata records the pre-commit working tree after `93d03c15`, including its +dirty-state flag; it does not pretend that these bytes came from that older commit. +The matching helper identity is `pdb:C645676D-7ED4-40C8-8EC5-30CE705D7A5F:11`. + +- **Pass:** Windows CMake Debug and Release builds, zero compiler warnings. + Current frontend assets are copied to both; no pre-running Vite server is needed. +- **Pass:** final Release runtime safety: 388 native contracts and all crash, + dedicated-thread fallback, dump-write failure, explicit hang, hang-then-crash, + recovery and bounded-final-Quit fixtures. The idle-reporter fixture confirms + that an old heartbeat does not arm normal-use hang collection. + Local evidence: `output/review/runtime-Release-20261105-093549-111955/qualification.json`. +- **Pass:** final Release render/export suite: 423 objective checks, zero failures, + including disabled callback instrumentation and unevaluated ARA log arguments. + Local evidence: `output/support-improvements-render-final/20261105_093549_render-export-regression/render_export_regression_result.json`. +- **Pass:** 50 support/policy checks; nine native Unix/package cases are skipped + on this Windows host. Separate release-tool checks passed (35 run, three skipped). + Cases include invalid/truncated PE/PDB/Mach-O/ELF/dump metadata, wrong symbols, + changed runtime JavaScript, XML entities, privacy filtering, review and no-overwrite. +- **Pass:** frozen Support tests with external Python/Node absent from PATH, + including GUI construction, MIDI/audio inspection, plugin settings and native + identity parsing. Production policy validation and all 1,052 Support inventory + files validate. No Support autostart registration or DAW launch hook was added. +- **Pass:** Windows executable/PDB and frontend archives. All 68 JavaScript chunk + hashes match emitted files; maps are absent from runtime `dist`. Original source + locations were resolved in 60 application chunks (the others are vendor chunks). + Frontend build completes; its CSS source-map warning is not a CSS-map qualification. +- **Diagnostic only:** Support can match exact native IDs in incident evidence; + this does not identify crash cause or assert complete third-party symbols. + Dump memory is excluded unless explicitly selected. Ordinary app use no longer + polls UI heartbeats, flushes fault counters periodically or emits ARA callback logs. + Existing crash capture and failure protection remain, and final-Quit monitoring + arms only when quitting (or in an explicit diagnostic build/self-test). +- **Not asserted:** new full macOS/Linux app builds, native dSYM/ELF extraction on + those hosts, installer/Store recertification, notarization, physical recording, + subjective audio quality or resolution of the earlier strict 8-sample failure. + Synthetic cross-platform identity fixtures pass here; native compiler fixtures + are wired into the existing support qualification workflow for those platforms. + +Symbol archives are separate from installers and Store inputs. Release CI will +retain them as separate Actions artifacts; the workflow was not invoked here. +Automatic timed dumps of running-session UI hangs are deliberately disabled in +ordinary builds. No claim of zero total DAW cost or historical CPU/RAM attribution +is made from the offline reports. diff --git a/docs/offline-support.md b/docs/offline-support.md index f8a8654b..364b69b2 100644 --- a/docs/offline-support.md +++ b/docs/offline-support.md @@ -3,7 +3,8 @@ Unreleased development work added after app commit `f45637be`. The support companion is packaged separately for Windows, macOS and Linux. It adds no collectors, timers, threads, buffers, callbacks, uploads, or logging to the running DAW. Ordinary -0.1.09 candidate builds also compile out callback timing profiling and trace storage. +0.1.09 candidate builds also compile out callback timing profiling, trace storage, +ARA callback timing/log formatting and per-block ARA debug-state updates. Essential engine scheduling, meters and existing fault handling remain; this is not a claim that those engine operations cost zero. Missing timing is reported as unavailable, never as zero failures. See [candidate qualification](certification-0.1.09.md). @@ -40,8 +41,10 @@ Users install no Python, Node.js, libraries, or terminal tools. The companion bundles its interpreter, Tk window, audio decoder and a bounded JavaScript engine containing the app's MIDI serializer. Nothing is registered to run at login, DAW startup, recording, export, crash or shutdown. Installation space increases; -normal DAW execution loads none of this payload. Existing native crash reporting -is unaffected. Explicitly launching this companion does consume resources. +normal DAW execution loads none of this payload. Native crash capture remains; +the Windows crash helper waits on events during normal use. Automatic periodic +UI-hang monitoring is disabled in ordinary builds. Its final-Quit watchdog starts +only after Quit is committed. Explicitly launching Support does consume resources. ## Developer source launch @@ -83,7 +86,15 @@ Automatic discovery checks the platform's existing OpenStudio diagnostic locatio On Windows this includes the actual Documents known folder, including relocation, and `%APPDATA%/OpenStudio/logs`. Use `--log-dir "path"` (repeatable) to replace the default locations, for example when collecting a startup log beside an executable. -Only exact known startup/debug/crash/hang filenames are read, without recursion. +Only exact known startup/debug/crash/hang/plugin-scan filenames and allowlisted +settings files are read, without recursion. The added `plugin_scan_debug.txt` +preserves scanner failures. Structured snapshots of `PluginList.xml`, +`PluginSearchPaths.xml`, `PluginBlacklist.txt`, `PluginScanDeadMansPedal.txt` and +`PluginHosting.json` retain plugin inventory, versions, paths after redaction, +blacklist/unfinished-scan entries and isolation choices. Only selected device-type, +rate, buffer and channel-mask fields from `audio_device_settings.xml` are included. +Plugin state, arbitrary XML fields and device names are excluded. No plugin is loaded, +scanned or executed. Settings describe collection time, not the original incident. Use `--route-report "mix_render_route_report.json"` to select existing render reports and `--description "What happened and what I expected"` to supply context. @@ -92,6 +103,27 @@ The output contains `summary.txt`, `report.json`, explicitly selected evidence a **at collection time**, not evidence of conditions during an earlier incident. Logs may span sessions and builds; their contents are not automatically assigned to a particular incident. Missing evidence is `not_asserted`, not a successful check. +Original filenames, sizes, modification times and tail truncation are recorded; +modification time is not treated as the time of an incident. + +The window also offers optional **App executable**, **Symbol archive** and +**OS crash report** fields. The installed app is found beside the frozen companion +or in its enclosing macOS app when possible. Separate AppImages cannot inspect an +unmounted sibling image: use an extracted app executable to identify its payload. +Source launches and other portable layouts can also select the app explicitly. +Equivalent developer flags are `--app`, `--symbols` and repeatable `--crash-report`. +OS reports must be existing `.ips`, `.crash`, `.txt`, `.log` or `.json` files. +Support does not enable OS dumps, modify system settings, run a debugger, collect a +live process dump, or query unrelated system events. Text is redacted for review. + +Windows dump module IDs are extracted even when **Include unredacted crash dumps** +is unticked; only module basenames and debug IDs are retained, not memory. This +metadata inspection is bounded to dumps of 512 MiB or less, and does not change the +32 MiB limit for copying a dump. Apple image UUIDs and explicit Linux build-ID +fields are recognized in selected OS report formats. Missing/unrecognized IDs stay +unavailable. The current installed binary has its own hash and debug identity and +is never silently assigned to an older crash. Matching IDs establish a debug-file +match, not a diagnosis or a trustworthy signature. Privacy and bounds: @@ -101,6 +133,8 @@ Privacy and bounds: addresses and common credential fields. **Redaction is best effort.** Free-form text can still contain private information. Read every file before sharing. - Only the latest 512 KiB of each log is retained, with truncation disclosed. +- Settings and OS report identity parsing are bounded to 2 MiB; at most 90 evidence + files are copied. XML entities/DTDs, symbolic-link files and special files are rejected. - Dumps are excluded unless `--include-dumps` is explicitly selected. Included dumps are **unredacted memory** and can contain credentials or audio fragments. Each dump is limited to 32 MiB; the collection is limited to 128 MiB. Oversized @@ -203,9 +237,56 @@ python -m unittest tools.test_openstudio_support -v Tests use synthetic files and temporary folders, never audio devices or user media. Production crash analysis also requires symbols/source maps matching the original -build. Optional diagnostic re-exports and new runtime telemetry are outside this +build. The mechanisms below preserve and match those files; they do not create a +missing JavaScript stack trace or reconstruct past resource pressure. Optional +diagnostic re-exports and new runtime telemetry are outside this implementation; neither is enabled by installing or running these tools. +## Build identities and developer symbols + +CMake writes `OpenStudio.build-identity.json` beside runtime assets after building. +It records the build version/configuration, source revision and dirty-state flag, +native debug IDs, pre-signing binary hashes and packaged JavaScript hashes. The DAW +never reads it. Build hosts need Python 3.11+; installed users still need no Python. +Signing, stripping or packaging can change the file hash while preserving debug IDs. +An identity match is therefore separate from a pre-signing byte match. + +`build.py prod` and the release workflow archive symbols before signing/packaging: + +```sh +python tools/build-symbols.py archive --app build/OpenStudio_artefacts/Release/OpenStudio.exe --build-dir build +``` + +Use `OpenStudio.app` on macOS or `OpenStudio` on Linux. The existing PowerShell +`archive-runtime-symbols.ps1` delegates to this cross-platform archiver. Each +`output/symbols/` directory is immutable; mismatched or missing +symbols, stale frontend maps and changed binaries fail the archive operation. +Windows checks PE/PDB GUID plus age, macOS checks all Mach-O/dSYM UUIDs, and Linux +checks ELF/GNU build IDs against extracted debug files. Native binaries are retained +with their symbols. macOS also retains the dSYM bundle for standard Apple tools. + +Frontend builds place hidden JavaScript maps and matching JavaScript in +`frontend/debug-symbols`, outside `dist`. Maps include source content; do not add +this directory or native symbols to installers. There is no `sourceMappingURL` +and no map code or collector in the app. CSS source mapping is not qualified. +Archives include a hash-checked frontend inventory; native symbol matching does +not assert that a supplied frontend error came from that JavaScript build. + +Release CI retains separate `*-debug-symbols` Actions artifacts for up to 90 days, +subject to repository retention settings. They are not release attachments or +Store payloads, and Actions artifact access follows repository permissions. +Before a future release, retain the exact archive and source commit in the team's +long-term support storage; CI retention is not permanent. Rebuilding the same +version later is not a substitute. No release workflow was invoked for this work. + +Ordinary builds also omit message-thread heartbeat/fault polling. Existing +failure-only recording/processor protection remains; pending fault counts are +flushed from control-side stop/shutdown paths. Explicit diagnostic builds can opt +into timing and ARA logs with `OPENSTUDIO_ENABLE_CALLBACK_DIAGNOSTICS=ON`, and incur +overhead. Release packaging rejects those builds. The removed normal UI-hang +watchdog means a freeze during a running session no longer automatically produces +a timed dump; crash capture and committed-final-Quit protection are separate. + ## Packaging and qualification After installing frontend dependencies, run: diff --git a/docs/release-runbook.md b/docs/release-runbook.md index 9020c9a6..cfe4d00c 100644 --- a/docs/release-runbook.md +++ b/docs/release-runbook.md @@ -921,3 +921,13 @@ payloads with a dummy DAW and disposable package identities. They do not replace final signed-artifact testing, Store certification, Gatekeeper/notarization, oldest-supported-OS testing or full-DAW hardware qualification. Retain those release gates and verify that support remains absent until explicitly opened. + +Build-time symbol retention now uses `tools/build-symbols.py`: exact PE/PDB IDs, +Mach-O/dSYM UUIDs and ELF build IDs must match; packaged JavaScript must match the +hidden maps in `frontend/debug-symbols`. `build.py prod` and release CI fail the +archive step for missing/stale companions. Keep `OpenStudio.build-identity.json` +in the payload, but keep symbol archives/maps outside installer and Store inputs. +Signing can change binary hashes without changing debug IDs. Retain Actions +`*-debug-symbols` archives in long-term support storage before their 90-day maximum +retention expires; artifacts inherit repository permissions. Never substitute a +later rebuild with the same app version. See [offline support](offline-support.md#build-identities-and-developer-symbols). diff --git a/docs/releases/0.1.09.md b/docs/releases/0.1.09.md index c1648a6e..a2db303a 100644 --- a/docs/releases/0.1.09.md +++ b/docs/releases/0.1.09.md @@ -14,6 +14,10 @@ development versions. No 0.1.09 release or Store update has been published. exports and create a support archive without installing Python or using a terminal. Collection and analysis require OpenStudio to be closed. Nothing is uploaded automatically; users review and share the resulting archive. +- Expand offline evidence with plugin scan logs, filtered plugin/audio settings, + selected OS crash reports and exact native debug identities. Keep incident IDs + separate from the installed version, and preserve matching native symbols and + frontend maps outside installers at build time. - Add audio and MIDI tempo analysis with candidate audition and Undo, editable linked crossfades, exact mixer level entry and a resizable single-row mixer. - Add optional project-rate copies of imported audio while retaining original @@ -21,6 +25,10 @@ development versions. No 0.1.09 release or Store update has been published. ## Fixes +- Compile out ARA callback timing, logging arguments and per-block debug-state + updates in ordinary builds. Remove periodic heartbeat/fault polling; flush + failure counts at stop/shutdown. The Windows crash helper waits on events during + normal use, while crash capture and committed-final-Quit protection remain. - Reduce repeated work for tiny audio blocks and prepare recording storage before capture starts. The recording writer publishes prepared FIFO data without waking its disk thread from each audio callback. These changes do @@ -90,6 +98,11 @@ development versions. No 0.1.09 release or Store update has been published. ## Upgrade Notes +- These further support/diagnostic changes are source changes after `93d03c15`. + Earlier installer/WACK/Linux certification results do not qualify these new + binaries. Automatic timed dumps for running-session UI hangs are disabled in + ordinary builds; explicitly selected OS evidence or a diagnostic reproduction + is needed for those cases. No publication or new certification is claimed. - Back up projects before opening them in this candidate. Existing project files remain supported; save a separate copy when testing new editing flows. The 0.1.08 plugin Undo corrections remain included. diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index fb3867a7..207617ba 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,6 +1,9 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' +import { createHash } from 'node:crypto' +import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' // https://vitejs.dev/config/ export default defineConfig({ @@ -8,6 +11,31 @@ export default defineConfig({ plugins: [ react(), tailwindcss(), + { + name: 'private-offline-source-maps', + apply: 'build', + // Emit maps outside dist. No sourceMappingURL, runtime code or map payload. + writeBundle(options, bundle) { + if (!options.dir) throw new Error('Source maps require a directory build') + const root = resolve(import.meta.dirname, 'debug-symbols') + const chunks: { file: string; sha256: string; map: string; mapSha256: string }[] = [] + const digest = (text: string) => createHash('sha256').update(text).digest('hex') + for (const item of Object.values(bundle)) { + if (item.type !== 'chunk') continue + if (!item.map) throw new Error(`Missing source map: ${item.fileName}`) + const mapName = item.fileName + '.map' + const code = readFileSync(resolve(options.dir, item.fileName), 'utf8') + const map = readFileSync(resolve(options.dir, mapName), 'utf8') + mkdirSync(dirname(resolve(root, item.fileName)), { recursive: true }) + writeFileSync(resolve(root, item.fileName), code) + writeFileSync(resolve(root, mapName), map) + chunks.push({ file: item.fileName, sha256: digest(code), map: mapName, mapSha256: digest(map) }) + unlinkSync(resolve(options.dir, mapName)) + } + mkdirSync(root, { recursive: true }) + writeFileSync(resolve(root, 'manifest.json'), JSON.stringify({ kind: 'openstudio_frontend_symbols', chunks }, null, 2)) + }, + }, { name: 'remove-crossorigin-from-production-html', apply: 'build', @@ -21,6 +49,7 @@ export default defineConfig({ strictPort: true, }, build: { + sourcemap: 'hidden', outDir: 'dist', emptyOutDir: true, // NAM Rack is deliberately isolated as an on-demand feature chunk. Its diff --git a/tests/test_linux_native_packaging.py b/tests/test_linux_native_packaging.py index ecb57ea1..3700f039 100644 --- a/tests/test_linux_native_packaging.py +++ b/tests/test_linux_native_packaging.py @@ -30,7 +30,7 @@ def setUp(self): shutil.copyfile('/bin/true', self.bundle / name) (self.bundle / name).chmod(0o755) (self.bundle / 'OpenStudio.version').write_text('1.2.3\n') - (self.bundle / 'OpenStudio.build-policy.json').write_text('{"version":"1.2.3","callbackDiagnostics":false}') + (self.bundle / 'OpenStudio.build-policy.json').write_text('{"version":"1.2.3","callbackDiagnostics":false,"araDiagnostics":false,"hangMonitoring":false}') shutil.copy2('/bin/true', self.bundle / 'OpenStudioSupport') resources = self.bundle / 'support-runtime' (resources / 'support-licenses').mkdir(parents=True) diff --git a/tests/test_release_build_policy.py b/tests/test_release_build_policy.py index 7027f8a2..fdfe0c1b 100644 --- a/tests/test_release_build_policy.py +++ b/tests/test_release_build_policy.py @@ -19,8 +19,16 @@ def test_release_requires_explicit_false_and_matching_version(self): policy.write_text(json.dumps({'version': '0.1.09', 'callbackDiagnostics': value})) with self.assertRaises(ValueError): validate(bundle, '0.1.09') - policy.write_text(json.dumps({'version': '0.1.08', 'callbackDiagnostics': False})) + policy.write_text(json.dumps({'version': '0.1.08', 'callbackDiagnostics': False, 'araDiagnostics': False, 'hangMonitoring': False})) with self.assertRaises(ValueError): validate(bundle, '0.1.09') - policy.write_text(json.dumps({'version': '0.1.09', 'callbackDiagnostics': False})) + policy.write_text(json.dumps({'version': '0.1.09', 'callbackDiagnostics': False, 'araDiagnostics': False, 'hangMonitoring': False})) validate(bundle, '0.1.09') + for key in ('araDiagnostics', 'hangMonitoring'): + value = json.loads(policy.read_text()) + value[key] = True + policy.write_text(json.dumps(value)) + with self.assertRaises(ValueError): + validate(bundle, '0.1.09') + value[key] = False + policy.write_text(json.dumps(value)) diff --git a/tests/test_support_identity.py b/tests/test_support_identity.py new file mode 100644 index 00000000..74edb1fa --- /dev/null +++ b/tests/test_support_identity.py @@ -0,0 +1,239 @@ +"""Synthetic native identities and hostile evidence; no OS crash configuration.""" +import hashlib +import importlib.util +import json +from pathlib import Path +import struct +import sys +import shutil +import subprocess +import tempfile +import unittest +import uuid + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'tools')) +from openstudio_support.identity import native_ids, dump_modules, match_ids, os_report_ids +from openstudio_support.collection import collect, bundle +from openstudio_support.common import Redactor, save_report +from openstudio_support.snapshots import plugin_snapshot, symbol_archive_ids + +GUID = uuid.UUID('00112233-4455-6677-8899-aabbccddeeff') +ID = f'pdb:{str(GUID).upper()}:3' + + +def pe(guid=GUID): + data = bytearray(1024) + data[:2] = b'MZ' + struct.pack_into('II', 0xcafebabe, 2) + data += struct.pack('>5I', 0x100000c, 0, 48, 56, 0) + data += struct.pack('>5I', 0x1000007, 0, 104, 56, 0) + data += macho() + macho(other) + self.assertEqual(len(native_ids(self.write('fat', data))), 2) + data = bytearray(data); struct.pack_into('>I', data, 20, 0xffffffff) + with self.assertRaises(ValueError): + native_ids(self.write('bad-fat', data)) + + def test_dump_metadata_without_sharing_memory_or_claiming_new_build(self): + path = self.write('last_crash.dmp', dump()) + modules = dump_modules(path) + self.assertEqual(modules, [{'module': 'OpenStudio.exe', 'debugIds': [ID]}]) + app = self.write('OpenStudio.exe', pe(uuid.uuid4())) + result, files, redactor = collect([self.root], app=app) + self.assertFalse(files) + self.assertEqual(result['incidentModules'][0]['modules'], modules) + self.assertNotEqual(result['installedBuild']['debugIds'], [ID]) + self.assertEqual(result['incidentModules'][0]['symbolMatches'][0]['status'], 'not_asserted') + self.assertNotIn('private', json.dumps(result['incidentModules'])) + self.assertEqual(match_ids([ID], {ID})[0]['status'], 'pass') + path.write_bytes(dump()[:70]) + with self.assertRaises(ValueError): + dump_modules(path) + + def test_plugin_allowlist_redaction_and_reviewed_bundle(self): + self.write('plugin_scan_debug.txt', b'Load failed C:\\Users\\private\\fx.vst3\npassword=hidden') + self.write('PluginList.xml', b'') + self.write('PluginHosting.json', b'["C:/private/x.vst3"]') + result, files, redactor = collect([self.root]) + self.assertEqual(len(files), 3) + text = b''.join(files.values()).decode() + self.assertIn('Synth', text) + for secret in ('private', 'PRIVATE', 'hidden'): + self.assertNotIn(secret, text) + self.assertTrue(all('modifiedAtUtc' in item for item in result['artifacts'])) + review = self.root / 'review' + save_report(review, result, files, redactor) + bundle(review, self.root / 'review.zip', True) + + def test_plugin_xml_entities_and_malformed_data_rejected(self): + for data in (b']>', b'') + value = plugin_snapshot(path, Redactor()) + self.assertEqual(value['entries'], [{'deviceType': 'ASIO', 'audioDeviceRate': '48000', 'audioDeviceBufferSize': '8'}]) + + def test_selected_os_reports_are_explicit_and_redacted(self): + path = self.write('OpenStudio.ips', b'{"path":"/Users/private/app", "exception":"EXC_BAD_ACCESS"}') + result, files, _ = collect([self.root]) + self.assertFalse(files) + result, files, _ = collect([self.root], crash_reports=[path]) + self.assertNotIn(b'private', next(iter(files.values()))) + self.assertIn(b'EXC_BAD_ACCESS', next(iter(files.values()))) + with self.assertRaises(ValueError): + collect([], crash_reports=[self.root / 'core']) + + def test_os_incident_ids_ignore_unrelated_uuid_and_version(self): + report = self.write('test.ips', ('{"incident_id":"' + str(uuid.uuid4()) + '"}\n' + + json.dumps({'usedImages': [{'uuid': str(GUID), 'name': 'OpenStudio'}]})).encode()) + self.assertEqual(os_report_ids(report), ['macho:' + str(GUID).upper()]) + linux = self.write('core.txt', b'OpenStudio version 0.1.09\nModule has build-id: 12345678abcdef00') + self.assertEqual(os_report_ids(linux), ['elf:12345678abcdef00']) + + @unittest.skipUnless(sys.platform in ('linux', 'darwin') and shutil.which('cc'), 'Native Unix compiler required') + def test_platform_generated_debug_companion_matches(self): + source = self.root / 'test.c' + source.write_text('int main(void) { return 0; }') + app = self.root / 'native' + subprocess.run(['cc', '-g', str(source), '-o', str(app), + *(['-Wl,--build-id=sha1'] if sys.platform == 'linux' else [])], check=True) + if sys.platform == 'linux': + debug = self.root / 'native.debug' + subprocess.run(['objcopy', '--only-keep-debug', str(app), str(debug)], check=True) + subprocess.run(['strip', '--strip-debug', str(app)], check=True) + else: + subprocess.run(['dsymutil', str(app)], check=True) + debug = self.root / 'native.dSYM/Contents/Resources/DWARF/native' + self.assertTrue(native_ids(app)) + self.assertEqual(native_ids(app), native_ids(debug)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/archive-runtime-symbols.ps1 b/tools/archive-runtime-symbols.ps1 index 39f3a475..a74ebbce 100644 --- a/tools/archive-runtime-symbols.ps1 +++ b/tools/archive-runtime-symbols.ps1 @@ -1,20 +1,7 @@ -param([ValidateSet('Debug', 'Release', 'ASan')] [string]$Configuration = 'Debug') +param([ValidateSet('Debug', 'Release', 'ASan')] [string]$Configuration = 'Debug', + [string]$BuildDirectory = 'build') $ErrorActionPreference = 'Stop' $repoRoot = Split-Path -Parent $PSScriptRoot -$binaryRoot = Join-Path $repoRoot "build/OpenStudio_artefacts/$Configuration" -$app = Join-Path $binaryRoot 'OpenStudio.exe' -$hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $app).Hash -$destination = Join-Path $repoRoot "output/symbols/$Configuration-$hash" -$files = @($app, (Join-Path $binaryRoot 'OpenStudio.pdb'), - (Join-Path $binaryRoot 'OpenStudioCrashReporter.exe'), - (Join-Path $repoRoot "build/$Configuration/OpenStudioCrashReporter.pdb")) -foreach ($file in $files) { if (-not (Test-Path -LiteralPath $file)) { throw "Missing matching build artifact: $file" } } -if (Test-Path -LiteralPath $destination) { throw "Archive already exists; it will not be overwritten: $destination" } -New-Item -ItemType Directory -Path $destination | Out-Null -$manifest = foreach ($file in $files) { - Copy-Item -LiteralPath $file -Destination $destination - @{ file = (Split-Path -Leaf $file); sha256 = (Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash } -} -@{ configuration = $Configuration; createdUtc = [DateTime]::UtcNow.ToString('o'); artifacts = $manifest } | - ConvertTo-Json -Depth 5 | Set-Content -Encoding UTF8 -LiteralPath (Join-Path $destination 'manifest.json') -Write-Host "Matching binaries and PDBs archived: $destination" +$app = Join-Path $BuildDirectory "OpenStudio_artefacts/$Configuration/OpenStudio.exe" +& python (Join-Path $PSScriptRoot 'build-symbols.py') archive --app $app --build-dir $BuildDirectory --output (Join-Path $repoRoot 'output/symbols') +if ($LASTEXITCODE -ne 0) { throw 'Matching symbol archive failed; no archive was published.' } diff --git a/tools/build-symbols.py b/tools/build-symbols.py new file mode 100644 index 00000000..c9ee46cf --- /dev/null +++ b/tools/build-symbols.py @@ -0,0 +1,140 @@ +"""Build-time identity and separate symbol archiving; never started by the DAW.""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import shutil +import subprocess +import tempfile + +from openstudio_support.common import read_json, sha256, utc_now +from openstudio_support.identity import native_ids + + +ROOT = Path(__file__).resolve().parents[1] + + +def inventory(path): + ids = native_ids(path) + if not ids: + raise ValueError(f'Native debug identity is missing: {path.name}') + return {'module': path.name, 'debugIds': ids, 'sha256': sha256(path)} + + +def layout(app): + if app.suffix == '.app': + app = app / 'Contents/MacOS/OpenStudio' + assets = app.parent.parent / 'Resources' if app.parent.name == 'MacOS' else app.parent + return app, assets + + +def write_identity(app, version, configuration): + app, assets = layout(app) + modules = [inventory(app)] + helper = app.with_name('OpenStudioCrashReporter.exe') + if helper.is_file(): + modules.append(inventory(helper)) + git = lambda *args: subprocess.check_output(['git', *args], cwd=ROOT, text=True).strip() + try: + source = {'commit': git('rev-parse', 'HEAD'), 'dirty': bool(git('status', '--porcelain', '--untracked-files=normal'))} + except (OSError, subprocess.SubprocessError): + source = {'commit': None, 'dirty': None} + frontend = [{'file': str(path.relative_to(assets / 'webui')).replace('\\', '/'), 'sha256': sha256(path)} + for path in sorted((assets / 'webui').rglob('*.js'))] + value = {'schemaVersion': 1, 'kind': 'openstudio_build_identity', 'version': version, + 'configuration': configuration, 'createdAtUtc': utc_now(), 'source': source, + 'hashScope': 'before packaging/signing; debug IDs survive signing and stripping', + 'modules': modules, 'frontend': frontend} + (assets / 'OpenStudio.build-identity.json').write_text(json.dumps(value, indent=2), encoding='utf-8') + + +def archive(app, build_dir, output, frontend_symbols): + app, assets = layout(app) + identity = read_json(assets / 'OpenStudio.build-identity.json') + actual = inventory(app) + if identity.get('kind') != 'openstudio_build_identity' or actual not in identity.get('modules', []): + raise ValueError('Rebuild before archiving: application bytes/identity changed after the build') + destination = output / actual['sha256'] + if destination.exists(): + raise ValueError(f'Symbol archive already exists; never overwritten: {destination}') + output.mkdir(parents=True, exist_ok=True) + # Build in a temporary sibling; failed validation must not leave a plausible archive. + with tempfile.TemporaryDirectory(prefix='.symbols-', dir=output) as temporary: + stage = Path(temporary) / 'archive' + stage.mkdir() + entries = [] + for module in identity['modules']: + binary = app.with_name(module['module']) + if inventory(binary) != module: + raise ValueError('Native module changed after its build manifest was written') + shutil.copy2(binary, stage / binary.name) + if module['debugIds'][0].startswith('pdb:'): + pdb = binary.with_suffix('.pdb') + if not pdb.is_file(): + pdb = build_dir / identity['configuration'] / pdb.name + target = stage / pdb.name + shutil.copy2(pdb, target) + elif module['debugIds'][0].startswith('macho:'): + dsym = Path(temporary) / (binary.name + '.dSYM') + subprocess.run(['dsymutil', str(binary), '-o', str(dsym)], check=True) + target = stage / (binary.name + '.dwarf') + shutil.copy2(dsym / 'Contents/Resources/DWARF' / binary.name, target) + shutil.copytree(dsym, stage / dsym.name) + else: + target = stage / (binary.name + '.debug') + subprocess.run(['objcopy', '--only-keep-debug', str(binary), str(target)], check=True) + symbols = inventory(target) + if symbols['debugIds'] != module['debugIds']: + raise ValueError(f'Mismatched symbols for {binary.name}') + entries.append({'file': target.name, 'sha256': symbols['sha256'], 'debugIds': symbols['debugIds']}) + frontend = read_json(frontend_symbols / 'manifest.json') + if frontend.get('kind') != 'openstudio_frontend_symbols': + raise ValueError('Missing frontend symbol inventory') + current = {item['file']: item['sha256'] for item in identity['frontend']} + runtime = {path.relative_to(assets / 'webui').as_posix(): sha256(path) + for path in (assets / 'webui').rglob('*.js')} + if runtime != current: + raise ValueError('Packaged JavaScript changed after its build identity was written') + if current != {item['file']: item['sha256'] for item in frontend['chunks']} or not current: + raise ValueError('Frontend symbols do not match packaged JavaScript') + for chunk in frontend['chunks']: + for key in ('file', 'map'): + relative = Path(chunk[key]) + if relative.is_absolute() or '..' in relative.parts or '\\' in chunk[key] or ':' in chunk[key]: + raise ValueError('Unsafe frontend symbol path') + source = frontend_symbols / relative + expected = chunk['sha256'] if key == 'file' else chunk['mapSha256'] + if sha256(source) != expected: + raise ValueError('Frontend symbol bytes changed') + target = stage / 'frontend' / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + shutil.copy2(frontend_symbols / 'manifest.json', stage / 'frontend/manifest.json') + manifest = {'schemaVersion': 1, 'kind': 'openstudio_symbol_archive', 'build': identity, + 'symbols': entries, 'createdAtUtc': utc_now()} + (stage / 'manifest.json').write_text(json.dumps(manifest, indent=2), encoding='utf-8') + stage.rename(destination) + return destination + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('action', choices=('identity', 'archive')) + parser.add_argument('--app', type=Path, required=True) + parser.add_argument('--version') + parser.add_argument('--configuration', default='Release') + parser.add_argument('--build-dir', type=Path, default=ROOT / 'build') + parser.add_argument('--output', type=Path, default=ROOT / 'output/symbols') + parser.add_argument('--frontend-symbols', type=Path, default=ROOT / 'frontend/debug-symbols') + args = parser.parse_args() + if args.action == 'identity': + if not args.version: + parser.error('--version is required for identity generation') + write_identity(args.app, args.version, args.configuration) + else: + print(archive(args.app, args.build_dir, args.output, args.frontend_symbols)) + + +if __name__ == '__main__': + main() diff --git a/tools/openstudio_support/cli.py b/tools/openstudio_support/cli.py index e09b5121..f2c68189 100644 --- a/tools/openstudio_support/cli.py +++ b/tools/openstudio_support/cli.py @@ -16,6 +16,9 @@ def parser(): collect = actions.add_parser("collect", help="Prepare existing evidence for review") collect.add_argument("--log-dir", type=Path, action="append", help="Additional exact log directory; not scanned recursively") collect.add_argument("--route-report", type=Path, action="append", default=[]) + collect.add_argument('--app', type=Path, help='Installed app executable (or macOS .app) to identify offline') + collect.add_argument('--symbols', type=Path, help='Optional developer symbol archive directory to verify and match') + collect.add_argument('--crash-report', type=Path, action='append', default=[], help='Existing OS crash report text to redact; no OS configuration changes') collect.add_argument("--include-dumps", action="store_true", help="Include unredacted memory dumps; may contain private data") collect.add_argument("--description", default="", help="What happened and what you expected") collect.add_argument("--output", type=Path, required=True, help="New review directory (must not exist)") @@ -46,7 +49,8 @@ def run(args): if args.action == "collect": from .collection import collect, default_directories result, artifacts, redactor = collect(args.log_dir or default_directories(), args.route_report, - args.include_dumps, args.description) + args.include_dumps, args.description, app=args.app, + symbols=args.symbols, crash_reports=args.crash_report) save_report(args.output, result, artifacts, redactor) elif args.action == "audio": from .audio import inspect_audio diff --git a/tools/openstudio_support/collection.py b/tools/openstudio_support/collection.py index 7a159fb4..3c36bb0d 100644 --- a/tools/openstudio_support/collection.py +++ b/tools/openstudio_support/collection.py @@ -9,11 +9,14 @@ import sys import uuid import zipfile +from datetime import datetime, timezone from .common import Redactor, check, collection_environment, read_json, regular_file, report +from .identity import dump_modules, match_ids, os_report_ids +from .snapshots import PLUGIN_FILES, installed_app, installed_snapshot, plugin_snapshot, symbol_archive_ids LOG_NAMES = ("OpenStudio_Startup.log", "OpenStudio_Debug.log", "debug_log.txt", - "crash_breadcrumbs.log", "crash_breadcrumbs.previous.log", "hang_breadcrumbs.log") + "crash_breadcrumbs.log", "crash_breadcrumbs.previous.log", "hang_breadcrumbs.log", "plugin_scan_debug.txt") DUMP_NAMES = ("last_crash.dmp", "last_crash.previous.dmp", "last_hang.dmp", "last_hang.previous.dmp") LOG_LIMIT = 512 * 1024 DUMP_LIMIT = 32 * 1024 * 1024 @@ -30,13 +33,16 @@ def default_directories(): if ctypes.windll.shell32.SHGetKnownFolderPath(ctypes.byref(guid), 0, None, ctypes.byref(value)) == 0: documents = Path(value.value) ctypes.windll.ole32.CoTaskMemFree(value) - return [documents / "OpenStudio", Path(os.environ.get("APPDATA", home / "AppData/Roaming")) / "OpenStudio/logs"] + settings = Path(os.environ.get("APPDATA", home / "AppData/Roaming")) / "OpenStudio" + return [documents / "OpenStudio", settings / "logs", settings] if sys.platform == "darwin": - return [home / "Library/Logs/OpenStudio", home / "Library/OpenStudio/logs"] - return [home / "Documents/OpenStudio", Path(os.environ.get("XDG_CONFIG_HOME", home / ".config")) / "OpenStudio/logs"] + return [home / "Library/Logs/OpenStudio", home / "Library/OpenStudio/logs", + home / "Library/Application Support/OpenStudio/Plugins", home / "Library/OpenStudio"] + settings = Path(os.environ.get("XDG_CONFIG_HOME", home / ".config")) / "OpenStudio" + return [home / "Documents/OpenStudio", settings / "logs", settings] -def collect(directories, route_reports=(), include_dumps=False, description=""): +def collect(directories, route_reports=(), include_dumps=False, description="", *, app=None, symbols=None, crash_reports=()): result = report("existing_evidence") result["environment"] = collection_environment() result["userDescription"] = description[:8000] @@ -46,9 +52,42 @@ def collect(directories, route_reports=(), include_dumps=False, description=""): artifacts = {} seen = set() total = 0 + result['installedBuild'] = {'status': 'not_asserted', 'detail': 'Installed application not located; choose its executable if needed.'} + app = app or installed_app() + if app: + try: + result['installedBuild'] = installed_snapshot(app) + except (OSError, ValueError, KeyError, TypeError) as exc: + check(result, 'installed_identity_unavailable', 'not_asserted', str(exc)) + symbol_ids = set() + if symbols: + try: + symbol_ids = symbol_archive_ids(symbols) + except (OSError, ValueError, KeyError, TypeError) as exc: + check(result, 'symbol_archive_invalid', 'fail', str(exc)) + result['incidentModules'] = [] + # Inspect only module metadata even when unredacted memory is not requested. + for path in dict.fromkeys(directory / name for directory in directories for name in DUMP_NAMES): + if path.exists(): + try: + modules = dump_modules(path) + result['incidentModules'].append({'source': str(path), 'modules': modules, + 'symbolMatches': match_ids([identity for module in modules for identity in module['debugIds']], symbol_ids)}) + except (OSError, ValueError) as exc: + check(result, 'dump_identity_unavailable', 'not_asserted', str(exc), source=str(path)) candidates = [(directory / name, False) for directory in directories - for name in (*LOG_NAMES, *(DUMP_NAMES if include_dumps else ()))] + for name in (*LOG_NAMES, *PLUGIN_FILES, *(DUMP_NAMES if include_dumps else ()))] candidates += [(path, True) for path in route_reports] + for path in crash_reports: + if path.suffix.lower() not in {'.txt', '.log', '.ips', '.crash', '.json'}: + raise ValueError('Select an existing OS crash text report (.ips, .crash, .txt, .log or .json)') + try: + ids = os_report_ids(path) + result.setdefault('osReportIdentities', []).append({'source': str(path), 'debugIds': ids, + 'symbolMatches': match_ids(ids, symbol_ids), 'recognizedIdentity': bool(ids)}) + except (OSError, ValueError, TypeError) as exc: + check(result, 'os_report_identity_unavailable', 'not_asserted', str(exc), source=str(path)) + candidates += [(path, False) for path in crash_reports] for path, route in candidates: if path.absolute() in seen: continue @@ -58,14 +97,21 @@ def collect(directories, route_reports=(), include_dumps=False, description=""): check(result, "route_report_missing", "fail", "The selected route report does not exist.") continue dump = path.suffix == ".dmp" - name = f"evidence-{len(artifacts) + 1:02d}" + (".dmp" if dump else ".json" if route else ".txt") + settings = path.name in PLUGIN_FILES + name = f"evidence-{len(artifacts) + 1:02d}" + (".dmp" if dump else ".json" if route or settings else ".txt") try: info = regular_file(path) truncated = False + if len(artifacts) >= 90: + raise ValueError('Evidence file count exceeds 90') if dump: + if not include_dumps: + raise ValueError('Memory dumps require explicit inclusion') if info.st_size > DUMP_LIMIT: raise ValueError("Dump exceeds 32 MiB; not copied. Share separately only after review.") data = path.read_bytes() + elif settings: + data = json.dumps(plugin_snapshot(path, redactor), indent=2).encode('utf-8') elif route: value = read_json(path) if not isinstance(value, dict) or value.get("purpose") != "manual_render_route_report": @@ -83,14 +129,26 @@ def collect(directories, route_reports=(), include_dumps=False, description=""): artifacts[name] = data total += len(data) result["artifacts"].append({"file": name, "source": str(path.absolute()), + "sourceKind": path.name if path.name in (*LOG_NAMES, *DUMP_NAMES, *PLUGIN_FILES) else 'selected_report', + "modifiedAtUtc": datetime.fromtimestamp(info.st_mtime, timezone.utc).isoformat(), "sourceBytes": info.st_size, "tailOnly": truncated, "unredactedDump": dump}) - except (OSError, ValueError) as exc: + except (OSError, ValueError, UnicodeError) as exc: check(result, "evidence_not_collected", "not_asserted", str(exc), source=str(path)) check(result, "existing_evidence", "pass" if artifacts else "not_asserted", f"Collected {len(artifacts)} existing evidence files; original files were not modified.") if include_dumps: result["limitations"].append("Included dumps are unredacted and can contain private memory, audio fragments, and credentials.") + incident_ids = {identity for evidence in result['incidentModules'] for module in evidence['modules'] + for identity in module['debugIds']} + incident_ids.update(identity for evidence in result.get('osReportIdentities', []) for identity in evidence['debugIds']) + check(result, 'incident_debug_identities', 'diagnostic_only' if incident_ids else 'not_asserted', + f'{len(incident_ids)} native debug identities found in incident evidence; logs without IDs are not assigned to the installed build.') + if symbols: + matched = incident_ids & symbol_ids + check(result, 'matching_native_symbols', 'diagnostic_only' if matched else 'not_asserted', + f'{len(matched)} of {len(incident_ids)} incident identities match verified symbol files. Unmatched modules need their own symbols; cause and stack symbolication are not asserted.') + result['limitations'].append('File modification times are not incident times. Plugin settings and installed build are later snapshots. Symbol identity matching does not determine the cause of a crash. OS report text is collected only when selected; no OS dump settings are changed.') return result, artifacts, redactor diff --git a/tools/openstudio_support/gui.py b/tools/openstudio_support/gui.py index 979337a5..27ff61e1 100644 --- a/tools/openstudio_support/gui.py +++ b/tools/openstudio_support/gui.py @@ -27,22 +27,23 @@ def create_window(root): ttk.Label(frame, text="Operation").grid(row=2, column=0, sticky="w") combo = ttk.Combobox(frame, textvariable=operation, values=list(choices), state="readonly") combo.grid(row=2, column=1, columnspan=2, sticky="ew", pady=5) - variables = {key: tk.StringVar() for key in ("input", "context", "output", "description", "clips")} + variables = {key: tk.StringVar() for key in ("input", "context", "output", "description", "clips", "app", "symbols", "crash")} stamp = datetime.now().strftime("%Y%m%d-%H%M%S") variables["output"].set(str(Path.home() / "OpenStudio-support" / stamp)) labels, fields, operation_controls = {}, {}, {} controls = [combo] for index, (key, label) in enumerate((("input", "Log folder (optional)"), ("context", "Render report (optional)"), ("output", "New review folder"), ("description", "What happened?"), - ("clips", "MIDI clip IDs (optional)")), start=3): + ("clips", "MIDI clip IDs (optional)"), ("app", "App executable (optional)"), + ("symbols", "Symbol archive (optional)"), ("crash", "OS crash report (optional)")), start=3): labels[key] = ttk.Label(frame, text=label) labels[key].grid(row=index, column=0, sticky="w", padx=(0, 12)) fields[key] = [labels[key]] entry = ttk.Entry(frame, textvariable=variables[key]) - entry.grid(row=index, column=1, columnspan=1 if key in {"input", "context", "output"} else 2, sticky="ew", pady=5) + entry.grid(row=index, column=1, columnspan=1 if key in {"input", "context", "output", "app", "symbols", "crash"} else 2, sticky="ew", pady=5) controls.append(entry) fields[key].append(entry) - if key in {"input", "context", "output"}: + if key in {"input", "context", "output", "app", "symbols", "crash"}: def browse(which=key): action = choices[operation.get()] if which == "output": @@ -51,7 +52,7 @@ def browse(which=key): else: parent = filedialog.askdirectory(title="Parent folder for a NEW review folder") value = str(Path(parent) / datetime.now().strftime("support-%Y%m%d-%H%M%S")) if parent else "" - elif which == "input" and action in {"collect", "bundle"}: + elif which == "symbols" or which == "input" and action in {"collect", "bundle"}: value = filedialog.askdirectory(title="Evidence folder") else: value = filedialog.askopenfilename(title="Choose existing file") @@ -62,16 +63,16 @@ def browse(which=key): controls.append(button) fields[key].append(button) dumps, reviewed = tk.BooleanVar(), tk.BooleanVar() - for row, variable, label in ((8, dumps, "Include unredacted crash dumps (can contain private memory and credentials)"), - (9, reviewed, "I reviewed every file in the report folder before creating this ZIP")): + for row, variable, label in ((11, dumps, "Include unredacted crash dumps (can contain private memory and credentials)"), + (12, reviewed, "I reviewed every file in the report folder before creating this ZIP")): control = ttk.Checkbutton(frame, text=label, variable=variable) control.grid(row=row, column=0, columnspan=3, sticky="w", pady=5) controls.append(control) operation_controls[row] = control - ttk.Label(frame, text="Redaction is best effort. Review the report folder before creating a ZIP.\nAttach the ZIP to your support request yourself; nothing is sent automatically.", wraplength=680).grid(row=10, column=0, columnspan=3, sticky="w", pady=8) + ttk.Label(frame, text="Redaction is best effort. Review the report folder before creating a ZIP.\nAttach the ZIP to your support request yourself; nothing is sent automatically.", wraplength=680).grid(row=13, column=0, columnspan=3, sticky="w", pady=8) display = tk.Text(frame, height=12, wrap="word", state="disabled") - display.grid(row=12, column=0, columnspan=3, sticky="nsew", pady=(12, 0)) - frame.rowconfigure(12, weight=1) + display.grid(row=15, column=0, columnspan=3, sticky="nsew", pady=(12, 0)) + frame.rowconfigure(15, weight=1) executor = ThreadPoolExecutor(max_workers=1) state = {"future": None, "lastReview": None, "lastOutput": None} @@ -94,13 +95,13 @@ def selected(_event=None): dumps.set(False) reviewed.set(False) for key, widgets in fields.items(): - visible = (key not in {'description', 'clips', 'context'} or - key == 'description' and action == 'collect' or + visible = (key not in {'description', 'clips', 'context', 'app', 'symbols', 'crash'} or + key in {'description', 'app', 'symbols', 'crash'} and action == 'collect' or key == 'clips' and action == 'midi' or key == 'context' and action != 'bundle') for widget in widgets: widget.grid() if visible else widget.grid_remove() - for row, visible in ((8, action == 'collect'), (9, action == 'bundle')): + for row, visible in ((11, action == 'collect'), (12, action == 'bundle')): operation_controls[row].grid() if visible else operation_controls[row].grid_remove() def poll(): @@ -136,6 +137,9 @@ def start(): args += ["--project" if action == "midi" else "--route-report", context] if action == "collect": args += ["--description", variables["description"].get()] + for key, option in (("app", "--app"), ("symbols", "--symbols"), ("crash", "--crash-report")): + if variables[key].get().strip(): + args += [option, variables[key].get().strip()] if dumps.get(): args.append("--include-dumps") if action == "midi": @@ -153,7 +157,7 @@ def start(): root.after(100, poll) button = ttk.Button(frame, text="Run selected operation", command=start) - button.grid(row=11, column=0, columnspan=3, sticky="w") + button.grid(row=14, column=0, columnspan=3, sticky="w") controls.append(button) def open_output(): import os @@ -173,7 +177,7 @@ def open_output(): except OSError as exc: messagebox.showerror('Could not open folder', str(exc)) folder_button = ttk.Button(frame, text='Open output folder', command=open_output) - folder_button.grid(row=11, column=1, columnspan=2, sticky='e') + folder_button.grid(row=14, column=1, columnspan=2, sticky='e') controls.append(folder_button) combo.bind("<>", selected) selected() diff --git a/tools/openstudio_support/identity.py b/tools/openstudio_support/identity.py new file mode 100644 index 00000000..7b6073c8 --- /dev/null +++ b/tools/openstudio_support/identity.py @@ -0,0 +1,247 @@ +"""Bounded, offline native debug identities. Never loads or executes inspected code. + +PE/PDB use CodeView GUID + age; Mach-O uses LC_UUID; ELF uses GNU build-id. +These identify debug companions, not signatures, provenance, or crash causation. +""" +from __future__ import annotations + +import mmap +import json +import re +from pathlib import Path +import struct +import uuid + +from .common import regular_file + + +def _bytes(data, offset, size): + if offset < 0 or size < 0 or offset + size > len(data): + raise ValueError('Truncated native identity data') + return data[offset:offset + size] + + +def _unpack(data, offset, fmt): + return struct.unpack(fmt, _bytes(data, offset, struct.calcsize(fmt))) + + +def _codeview(data, offset, size): + if size < 24 or _bytes(data, offset, 4) != b'RSDS': + return [] + guid = str(uuid.UUID(bytes_le=_bytes(data, offset + 4, 16))).upper() + age, = _unpack(data, offset + 20, ' 96 or optional_size < directory + 56: + raise ValueError('Unsupported PE layout') + rva, size = _unpack(data, optional + directory + 48, ' 28 * 4096 or size % 28: + raise ValueError('Invalid PE debug directory size') + table = file_offset(rva, size) + result = [] + for offset in range(table, table + size, 28): + kind, length, _, raw = _unpack(data, offset + 12, ' len(data): + raise ValueError('Invalid PDB block layout') + if directory_size > 16 * 1024 * 1024: + raise ValueError('PDB directory exceeds inspection limit') + count = (directory_size + block - 1) // block + if count * 4 > block: + raise ValueError('Unsupported PDB directory map') + ids = _unpack(data, block_map * block, f'<{count}I') + directory = b''.join(_bytes(data, item * block, block) for item in ids)[:directory_size] + streams, = _unpack(directory, 0, ' 1 or start + length > len(data): + raise ValueError('Invalid Mach-O slice') + magic = _bytes(data, start, 4) + if magic in (b'\xca\xfe\xba\xbe', b'\xca\xfe\xba\xbf'): + count, = _unpack(data, start + 4, '>I') + if depth or not 1 <= count <= 16: + raise ValueError('Invalid universal Mach-O architecture count') + wide = magic[-1] == 0xbf + result = [] + for index in range(count): + entry = start + 8 + index * (32 if wide else 20) + offset, size = _unpack(data, entry + 8, '>QQ' if wide else '>II') + result += _macho(data, offset, size, depth + 1) + return sorted(set(result)) + formats = {b'\xcf\xfa\xed\xfe': ('<', 32), b'\xce\xfa\xed\xfe': ('<', 28), + b'\xfe\xed\xfa\xcf': ('>', 32), b'\xfe\xed\xfa\xce': ('>', 28)} + if magic not in formats: + raise ValueError('Unsupported Mach-O header') + endian, header = formats[magic] + count, size = _unpack(data, start + 16, endian + 'II') + if count > 65536 or header + size > length: + raise ValueError('Invalid Mach-O commands') + cursor = start + header + end = cursor + size + result = [] + for _ in range(count): + command, size = _unpack(data, cursor, endian + 'II') + if size < 8 or cursor + size > end: + raise ValueError('Invalid Mach-O command size') + if command == 0x1b: + if size != 24: + raise ValueError('Invalid Mach-O UUID command') + result.append('macho:' + str(uuid.UUID(bytes=_bytes(data, cursor + 8, 16))).upper()) + cursor += size + return sorted(set(result)) + + +def _elf(data): + width, encoding = _unpack(data, 4, 'BB') + if width not in (1, 2) or encoding not in (1, 2): + raise ValueError('Unsupported ELF header') + endian = '<' if encoding == 1 else '>' + wide = width == 2 + table, = _unpack(data, 40 if wide else 32, endian + ('Q' if wide else 'I')) + stride, count = _unpack(data, 58 if wide else 46, endian + 'HH') + if not table or not count: + return [] + if stride < (64 if wide else 40): + raise ValueError('Invalid ELF section size') + result = [] + for index in range(count): + section = table + index * stride + kind, = _unpack(data, section + 4, endian + 'I') + if kind != 7: # SHT_NOTE; retained by objcopy --only-keep-debug. + continue + offset, size = _unpack(data, section + (24 if wide else 16), endian + ('QQ' if wide else 'II')) + if size > 1024 * 1024: + raise ValueError('ELF notes exceed inspection limit') + notes = _bytes(data, offset, size) + cursor = 0 + while cursor + 12 <= size: + names, descs, kind = _unpack(notes, cursor, endian + 'III') + cursor += 12 + name = _bytes(notes, cursor, names) + cursor += (names + 3) & ~3 + value = _bytes(notes, cursor, descs) + cursor += (descs + 3) & ~3 + if name == b'GNU\0' and kind == 3 and 4 <= descs <= 64: + result.append('elf:' + value.hex()) + return sorted(set(result)) + + +def native_ids(path: Path): + if regular_file(path).st_size == 0: + raise ValueError('Empty native file') + with path.open('rb') as stream, mmap.mmap(stream.fileno(), 0, access=mmap.ACCESS_READ) as data: + magic = data[:4] + if magic[:2] == b'MZ': + return _pe(data) + if data[:32] == b'Microsoft C/C++ MSF 7.00\r\n\x1aDS\0\0\0': + return _pdb(data) + if magic == b'\x7fELF': + return _elf(data) + return _macho(data) + + +def dump_modules(path: Path): + """Extract IDs without copying memory; available even when dumps aren't shared.""" + size = regular_file(path).st_size + if not 32 <= size <= 512 * 1024 * 1024: + raise ValueError('Dump identity inspection requires 32 bytes–512 MiB') + result = [] + with path.open('rb') as stream, mmap.mmap(stream.fileno(), 0, access=mmap.ACCESS_READ) as data: + if data[:4] != b'MDMP': + raise ValueError('Not a Windows minidump') + streams, directory = _unpack(data, 8, ' 4096: + raise ValueError('Too many minidump streams') + for index in range(streams): + kind, length, offset = _unpack(data, directory + index * 12, ' 4096 or length < 4 + count * 108: + raise ValueError('Invalid minidump module list') + for module in range(count): + cursor = offset + 4 + module * 108 + name_rva, = _unpack(data, cursor + 20, ' 32768: + raise ValueError('Minidump module name too long') + name = _bytes(data, name_rva + 4, name_size).decode('utf-16-le', errors='replace') + cv_size, cv_rva = _unpack(data, cursor + 76, ' 2 * 1024 * 1024: + raise ValueError('OS report identity inspection exceeds 2 MiB') + text = path.read_text(encoding='utf-8-sig', errors='replace') + result = set() + if path.suffix.lower() == '.ips': + # Apple .ips may contain one metadata JSON line followed by the report JSON. + decoder = json.JSONDecoder() + remaining = text.lstrip() + for _ in range(2): + value, end = decoder.raw_decode(remaining) + if isinstance(value, dict): + for module in value.get('usedImages', []): + if isinstance(module, dict) and isinstance(module.get('uuid'), str): + result.add('macho:' + str(uuid.UUID(module['uuid'])).upper()) + remaining = remaining[end:].lstrip() + if not remaining: + break + elif path.suffix.lower() == '.crash' and 'Binary Images:' in text: + for value in re.findall(r'<([0-9a-fA-F-]{32,36})>', text.split('Binary Images:', 1)[1]): + result.add('macho:' + str(uuid.UUID(value)).upper()) + else: + for value in re.findall(r'(?i)\bbuild[- ]id[:=]\s*([0-9a-f]{8,128})\b', text): + if len(value) % 2 == 0: + result.add('elf:' + value.lower()) + return sorted(result) diff --git a/tools/openstudio_support/selftest.py b/tools/openstudio_support/selftest.py index 7fb361b1..8ea0bc14 100644 --- a/tools/openstudio_support/selftest.py +++ b/tools/openstudio_support/selftest.py @@ -37,12 +37,25 @@ def run_selftest(output): close() with tempfile.TemporaryDirectory(prefix='support-self-test-') as directory: path = Path(directory) + from .collection import collect + from .identity import native_ids + import struct + import uuid + identity = uuid.UUID('00112233-4455-6677-8899-aabbccddeeff') + native = path / 'fixture.macho' + native.write_bytes(struct.pack('<8I', 0xfeedfacf, 0x100000c, 0, 2, 1, 24, 0, 0) + + struct.pack('') + evidence, files, _ = collect([path]) + assert len(files) == 1 and b'private' not in next(iter(files.values())) sf.write(path / 'silence.wav', np.zeros((16, 2)), 48000, subtype='PCM_16') assert sf.info(path / 'silence.wav').frames == 16 project = path / 'test.osproj' project.write_text(json.dumps({'version': '1.2.0', 'tempo': 120, 'tracks': []})) assert prepare_project(project)['tracks'] == [] result = {'status': 'pass', 'gui': 'constructed_hidden', 'audioDecoder': 'pass', + 'nativeIdentity': 'pass', 'pluginSettingsCollection': 'pass', 'embeddedMIDISerializer': 'pass', 'closedAppGuard': 'pass', 'displayScaling': display_check} if sys.platform == 'win32': import ctypes diff --git a/tools/openstudio_support/snapshots.py b/tools/openstudio_support/snapshots.py new file mode 100644 index 00000000..79c0fd1e --- /dev/null +++ b/tools/openstudio_support/snapshots.py @@ -0,0 +1,112 @@ +"""Explicit offline collection only; no DAW imports, plugin loading or discovery scans.""" +from __future__ import annotations + +import json +from pathlib import Path +import sys +import xml.etree.ElementTree as ET + +from .common import read_json, regular_file, sha256 +from .identity import native_ids + +PLUGIN_FILES = ('PluginList.xml', 'PluginSearchPaths.xml', 'PluginBlacklist.txt', + 'PluginScanDeadMansPedal.txt', 'PluginHosting.json', 'audio_device_settings.xml') + + +def plugin_snapshot(path, redactor): + if regular_file(path).st_size > 2 * 1024 * 1024: + raise ValueError('Plugin settings exceed the 2 MiB inspection limit') + text = path.read_text(encoding='utf-8-sig') + if path.suffix == '.xml': + if ' 10000: + raise ValueError('Too many plugin settings entries') + return {'kind': path.name, 'when': 'at_collection_time_only', 'entries': items} + + +def installed_app(): + if not getattr(sys, 'frozen', False): + return None + executable = Path(sys.executable) + if sys.platform == 'darwin': + for parent in executable.parents: + if parent.name == 'OpenStudio.app': + return parent / 'Contents/MacOS/OpenStudio' + else: + path = executable.with_name('OpenStudio.exe' if sys.platform == 'win32' else 'OpenStudio') + if path.is_file(): + return path + return None # Separate AppImages cannot inspect each other's unmounted payload. + + +def installed_snapshot(path): + if path.suffix == '.app': + path = path / 'Contents/MacOS/OpenStudio' + ids = native_ids(path) + root = path.parent.parent / 'Resources' if path.parent.name == 'MacOS' else path.parent + result = {'when': 'at_collection_time_only', 'module': path.name, + 'sha256': sha256(path), 'debugIds': ids, 'originalIncidentAssociation': 'not_asserted'} + manifest = root / 'OpenStudio.build-identity.json' + if manifest.is_file(): + value = read_json(manifest) + if not isinstance(value, dict): + raise ValueError('Invalid installed build manifest') + modules = value.get('modules', []) + if not isinstance(modules, list) or not all(isinstance(item, dict) for item in modules): + raise ValueError('Invalid installed module inventory') + matching = [item for item in modules if item.get('module') == path.name and item.get('debugIds') == ids and ids] + if value.get('kind') != 'openstudio_build_identity' or not matching: + raise ValueError('Installed build identity does not match its native executable') + result['build'] = value + result['preSigningBytesMatch'] = matching[0].get('sha256') == result['sha256'] + return result + + +def symbol_archive_ids(directory): + manifest = read_json(directory / 'manifest.json') + if not isinstance(manifest, dict) or manifest.get('kind') != 'openstudio_symbol_archive': + raise ValueError('Not an OpenStudio symbol archive') + entries = manifest.get('symbols', []) + if not isinstance(entries, list) or not 1 <= len(entries) <= 32: + raise ValueError('Invalid symbol archive inventory') + found = set() + for item in entries: + name = item['file'] + # Archive stores native debug files flat. No arbitrary manifest paths. + if not isinstance(name, str) or '/' in name or '\\' in name or ':' in name or name in ('', '.', '..'): + raise ValueError('Unsafe symbol archive path') + path = directory / name + if sha256(path) != item['sha256'] or native_ids(path) != item['debugIds']: + raise ValueError('Symbol archive bytes or debug identity changed') + found.update(item['debugIds']) + return found diff --git a/tools/package-windows-store.ps1 b/tools/package-windows-store.ps1 index 81380b77..eb96ac64 100644 --- a/tools/package-windows-store.ps1 +++ b/tools/package-windows-store.ps1 @@ -107,7 +107,7 @@ if ([Diagnostics.FileVersionInfo]::GetVersionInfo($runtimeExe[0].FullName).Produ Copy-Item -LiteralPath $runtimeExe[0].DirectoryName -Destination (Join-Path $stage 'WebView2Runtime') -Recurse # A deliberate allowlist excludes PDBs, prerequisite installers, debug and AI # download caches. Optional AI runtimes are installed into writable user storage. -foreach ($file in @('OpenStudio.exe', 'OpenStudioCrashReporter.exe', 'OpenStudioSupport.exe', 'ffmpeg.exe', 'OpenStudio.version', 'OpenStudio.build-policy.json', 'LICENSE', 'THIRD_PARTY_LICENSES.md')) { +foreach ($file in @('OpenStudio.exe', 'OpenStudioCrashReporter.exe', 'OpenStudioSupport.exe', 'ffmpeg.exe', 'OpenStudio.version', 'OpenStudio.build-policy.json', 'OpenStudio.build-identity.json', 'LICENSE', 'THIRD_PARTY_LICENSES.md')) { Copy-Item -LiteralPath (Join-Path $source $file) -Destination $stage } foreach ($file in Get-ChildItem -LiteralPath $source -Filter '*.dll' -File) { diff --git a/tools/release_build_policy.py b/tools/release_build_policy.py index 9de5f8c8..ce7a3bb0 100644 --- a/tools/release_build_policy.py +++ b/tools/release_build_policy.py @@ -9,8 +9,8 @@ def validate(bundle: Path, version: str) -> None: if root.suffix == '.app': root = root / 'Contents/Resources' policy = json.loads((root / 'OpenStudio.build-policy.json').read_text(encoding='utf-8')) - if policy.get('version') != version or policy.get('callbackDiagnostics') is not False: - raise ValueError('Release packaging requires matching application version and callback diagnostics compiled out') + if policy.get('version') != version or any(policy.get(key) is not False for key in ('callbackDiagnostics', 'araDiagnostics', 'hangMonitoring')): + raise ValueError('Release packaging requires matching application version and callback/ARA diagnostics and periodic hang monitoring compiled out') if __name__ == '__main__': diff --git a/tools/test-support-linux.py b/tools/test-support-linux.py index 609472c1..36226b5e 100644 --- a/tools/test-support-linux.py +++ b/tools/test-support-linux.py @@ -33,7 +33,7 @@ def main(): for name in ('OpenStudio', 'OpenStudioUpdateInstaller'): shutil.copy2('/bin/true', bundle / name) (bundle / 'OpenStudio.version').write_text(args.version) - (bundle / 'OpenStudio.build-policy.json').write_text(json.dumps({'version': args.version, 'callbackDiagnostics': False})) + (bundle / 'OpenStudio.build-policy.json').write_text(json.dumps({'version': args.version, 'callbackDiagnostics': False, 'araDiagnostics': False, 'hangMonitoring': False})) for name in ('webui/index.html', 'effects/fixture', 'scripts/fixture', 'licenses/fixture', 'models/basic_pitch_nmp.onnx'): path = bundle / name; path.parent.mkdir(exist_ok=True); path.write_text('QA fixture, not the DAW') native.validate_bundle(bundle, args.version) From 3f28b8ac6c4e2ad6c228bf4d7a1b6fd422a8fa7e Mon Sep 17 00:00:00 2001 From: Sourav Das Date: Thu, 5 Nov 2026 09:46:28 +0530 Subject: [PATCH 27/33] Accept eight-sample limit and qualify Linux symbol archives --- .github/workflows/verify.yml | 11 +++++++++++ docs/certification-0.1.09.md | 13 ++++++++++++- docs/releases/0.1.09.md | 3 +++ docs/roadmap.md | 5 ++++- 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 572c1b8d..f1a1c2e7 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -921,6 +921,17 @@ jobs: shell: bash run: cmake --build "$BUILD_DIR" --config Release --target OpenStudio + - name: Verify and archive Linux native/frontend symbol matches + run: | + python3 -m unittest tests.test_support_identity tests.test_release_build_policy -v + python3 tools/build-symbols.py archive --app "$BUILD_DIR/OpenStudio_artefacts/Release/OpenStudio" --build-dir "$BUILD_DIR" --output output/linux-symbols + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: candidate-linux-debug-symbols + path: output/linux-symbols/ + if-no-files-found: error + retention-days: 90 + - name: Validate Linux runtime bundle shell: pwsh run: ./tools/validate-runtime-bundle.ps1 -Platform linux -BundlePath "$env:BUILD_DIR/OpenStudio_artefacts/Release" -ExpectedVersion $env:VERSION -EnforceLeanBundle diff --git a/docs/certification-0.1.09.md b/docs/certification-0.1.09.md index f1c8fe4c..4fc97b51 100644 --- a/docs/certification-0.1.09.md +++ b/docs/certification-0.1.09.md @@ -6,6 +6,17 @@ The user requested certification checks and a release PR, with publication held. ## Current evidence +**Merge acceptance decision — 5 November 2026:** the user explicitly accepted the +strict 8-sample contention failure as a non-blocking known limitation for PR #36 +and requested that optimization work on that target stop. Its measured result +remains **fail**; no threshold, recording-integrity assertion, or timing result is +changed. This is a merge-scope decision, not evidence that further optimization is +impossible, that eight samples is glitch-free, or that larger buffers qualify that +workload. Complete the current Linux checks before making the PR ready. Support +must remain manual-only; publication and Store/notarization remain separate gates. +Earlier statements tying draft status to the 8-sample result are historical and +superseded by this acceptance decision. + Further offline collection, symbol matching and diagnostic-removal changes after `93d03c15` change the native binary and Support payload. The older installer, WACK and Linux package results below do **not** certify these new artifacts. @@ -18,7 +29,7 @@ at `b6aba8ae`, including the warning-only follow-up to the functional NAM fix. Each result applies to its listed artifact. The remaining Linux browser check passed; the strict 8-sample hardware gate still fails. An automatic Mac run also exposed three geometry-check failures after successful frontend startup. -PR #36 remains a draft, with no submitted formal review approval. Subsequent +At that earlier qualification, PR #36 remained a draft with no submitted formal review approval. Subsequent qualification/release-note edits through `93d03c15` did not change that tested application source. - **Pass — Linux qualification after the NAM preparation/insertion fix:** diff --git a/docs/releases/0.1.09.md b/docs/releases/0.1.09.md index a2db303a..ed4a64f6 100644 --- a/docs/releases/0.1.09.md +++ b/docs/releases/0.1.09.md @@ -69,6 +69,9 @@ development versions. No 0.1.09 release or Store update has been published. An explicitly approved trace found graphics-driver interruptions exceeding the buffer budget and paging during one late callback. These do not explain every miss, prove RAM exhaustion or establish a past unobserved glitch's cause. + On 5 November the user accepted this as a non-blocking known limitation for + merging PR #36. The measured failure is retained; no glitch-free eight-sample + claim or relaxed test threshold follows from that decision. - Offline support cannot reconstruct unmeasured CPU scheduling or RAM pressure at a past glitch, prove that every intended instrument was audible, or replace a physical signal reference. Redaction is best effort; inspect archives before diff --git a/docs/roadmap.md b/docs/roadmap.md index 6b4ea847..815698de 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -56,7 +56,10 @@ lives in [Testing](testing.md) and the parallel while hosted, MIDI/ARA and routed tracks retain serial processing. The conservative eligibility gate adds no device buffering. The required target is four recording, monitored NAM Racks at 16 samples and 4x Drive oversampling; 8 samples is a - separate stretch target. Bounded Release probes show lower average callback + separate stretch target. For PR #36, the user accepted the measured 8-sample + contention failure as non-blocking on 5 November 2026; further work on that + stretch target is deferred. Recorded failures and recording-safety checks remain + unchanged. Bounded Release probes show lower average callback costs, but four- and twelve-track cases still report missed or skipped work. The target remains **not met**; bounded ownership/recording tests and a low average CPU meter do not close the live deadline, recording-duration or listening gates. From dd92c7652666d5e4ad093720f75d0e605b2ae6a8 Mon Sep 17 00:00:00 2001 From: Sourav Das Date: Thu, 5 Nov 2026 10:01:13 +0530 Subject: [PATCH 28/33] Keep Linux DEB debug sections in the symbol archive --- docs/offline-support.md | 3 +++ tests/test_linux_native_packaging.py | 21 +++++++++++++++++++++ tools/package-linux-native.py | 12 ++++++++++++ 3 files changed, 36 insertions(+) diff --git a/docs/offline-support.md b/docs/offline-support.md index 364b69b2..1a780e53 100644 --- a/docs/offline-support.md +++ b/docs/offline-support.md @@ -264,6 +264,9 @@ symbols, stale frontend maps and changed binaries fail the archive operation. Windows checks PE/PDB GUID plus age, macOS checks all Mach-O/dSYM UUIDs, and Linux checks ELF/GNU build IDs against extracted debug files. Native binaries are retained with their symbols. macOS also retains the dSYM bundle for standard Apple tools. +DEB packaging strips debug sections from its staged executable and verifies that +the GNU build ID stays unchanged. The archive's original binary and the frozen +Support payload are untouched. RPM and AppImage retain their existing stripping. Frontend builds place hidden JavaScript maps and matching JavaScript in `frontend/debug-symbols`, outside `dist`. Maps include source content; do not add diff --git a/tests/test_linux_native_packaging.py b/tests/test_linux_native_packaging.py index 3700f039..799001ef 100644 --- a/tests/test_linux_native_packaging.py +++ b/tests/test_linux_native_packaging.py @@ -101,6 +101,27 @@ def test_deb_round_trip_preserves_launcher_assets_and_dependencies(self): self.assertFalse(runtime.stat().st_mode & 0o022) self.assertEqual((unpacked / 'usr/lib/openstudio/OpenStudio.package').read_text(), 'deb\n') + @unittest.skipUnless(shutil.which('cc') and shutil.which('strip'), 'Native compiler and strip required') + def test_deb_excludes_debug_sections_preserving_archive_source_and_build_id(self): + from openstudio_support.common import sha256 + from openstudio_support.identity import native_ids + source = self.root / 'fixture.c' + source.write_text('int main(void) { return 0; }\n') + original = self.bundle / 'OpenStudio' + subprocess.run(['cc', '-g', '-Wl,--build-id=sha1', source, '-o', original], check=True) + original_hash = sha256(original) + support_hash = sha256(self.bundle / 'OpenStudioSupport') + self.assertIn('.debug_info', subprocess.check_output(['readelf', '-S', original], text=True)) + runtime = packaging.stage_bundle(self.bundle, self.root / 'package', 'deb') + packaged = runtime / 'OpenStudio' + self.assertNotIn('.debug_info', subprocess.check_output(['readelf', '-S', packaged], text=True)) + self.assertTrue(native_ids(original)) + self.assertEqual(native_ids(packaged), native_ids(original)) + self.assertEqual(sha256(original), original_hash) + self.assertEqual(sha256(runtime / 'OpenStudioSupport'), support_hash) + self.assertLess(packaged.stat().st_size, original.stat().st_size) + self.assertEqual(subprocess.run([packaged]).returncode, 0) + def test_release_entry_point_rejects_missing_notes_before_build(self): process = subprocess.run(['python3', ROOT / 'tools/package-linux-native.py', '--format', 'deb', '--version', '1.2.3', diff --git a/tools/package-linux-native.py b/tools/package-linux-native.py index 670847e4..70c5093d 100644 --- a/tools/package-linux-native.py +++ b/tools/package-linux-native.py @@ -11,6 +11,7 @@ from pathlib import Path from support_package import validate as validate_support from release_build_policy import validate as validate_release_build_policy +from openstudio_support.identity import native_ids import re import shutil import subprocess @@ -79,6 +80,17 @@ def stage_bundle(bundle, stage, package_format): destination = stage / 'usr/lib/openstudio' shutil.copytree(bundle, destination, symlinks=True, ignore=shutil.ignore_patterns('*.log', '__pycache__', '*.pyc')) + if package_format == 'deb': + # Unlike rpmbuild/linuxdeploy, dpkg-deb does not strip debug sections. + # Retain the original binary for the separate symbol archive; only + # change this package's copy, never the frozen Support runtime. + binary = destination / 'OpenStudio' + debug_ids = native_ids(binary) + if not debug_ids: + raise ValueError('Missing native build identity before DEB stripping') + run(['strip', '--strip-debug', binary]) + if native_ids(binary) != debug_ids: + raise ValueError('DEB stripping changed the native build identity') marker = destination / 'OpenStudio.package' marker.write_text(package_format + '\n') marker.chmod(0o644) From 5a9e9035dfecd5900319f862afb24764feaede22 Mon Sep 17 00:00:00 2001 From: Sourav Das Date: Thu, 5 Nov 2026 12:13:38 +0530 Subject: [PATCH 29/33] Fix portable Vorbis export and Mac window qualification --- .github/workflows/verify.yml | 8 +++ Source/AudioEngine.cpp | 87 +++++++++++++++++++++++--------- Source/FreePluginRegression.cpp | 4 +- Source/FreePluginReverbBalance.h | 2 +- Source/Main.cpp | 63 ++++++++++++++++++----- Source/NAMFuzzRegression.h | 2 +- Source/OfflineVorbisEncoder.h | 71 ++++++++++++++++++++++++++ docs/USER_MANUAL.md | 4 +- docs/certification-0.1.09.md | 49 ++++++++++++++++++ docs/implemented_features.md | 2 +- docs/releases/0.1.09.md | 4 ++ 11 files changed, 254 insertions(+), 42 deletions(-) create mode 100644 Source/OfflineVorbisEncoder.h diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index f1a1c2e7..ae4eed58 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -815,6 +815,14 @@ jobs: shell: pwsh run: ./tools/run-render-export-headless-regression.ps1 -AppPath "$env:APP_BUNDLE/Contents/MacOS/OpenStudio" -Configuration Release -SkipBuild -OutputRoot "$env:RUNNER_TEMP/candidate-render" -TimeoutSeconds 300 + - name: Retain Mac render/export diagnostics + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: render-export-${{ matrix.os }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/candidate-render/**/render_export_regression_result.json + if-no-files-found: warn + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 if: env.CALLBACK_DIAGNOSTICS == 'OFF' with: diff --git a/Source/AudioEngine.cpp b/Source/AudioEngine.cpp index 320593ad..20128317 100644 --- a/Source/AudioEngine.cpp +++ b/Source/AudioEngine.cpp @@ -3,6 +3,7 @@ #include "PluginAutomationDelivery.h" #include "OfflinePluginTransport.h" #include "OfflineHostedControl.h" +#include "OfflineVorbisEncoder.h" #include "BuiltInAllPassFit.h" #include "BuiltInSpectralPhaseFit.h" #include "BuiltInInstrumentPreviewBindings.h" @@ -74175,7 +74176,7 @@ bool AudioEngine::renderProject(const juce::String& source, double startTime, do juce::String formatLower = format.toLowerCase(); bool isLossyFormat = (formatLower == "mp3" || formatLower == "ogg"); const bool isRawFormat = formatLower == "raw"; - bool needsFFmpegPostProcess = isLossyFormat || isRawFormat; + bool needsExportPostProcess = isLossyFormat || isRawFormat; // For lossy formats, bitDepth holds codec quality: // MP3: bitrate in kbps (128, 192, 256, 320) @@ -74489,7 +74490,7 @@ bool AudioEngine::renderProject(const juce::String& source, double startTime, do + " so FFmpeg handles final SRC to " + juce::String(requestedOutputSampleRate)); actualSampleRate = includedClipSampleRate; - needsFFmpegPostProcess = true; + needsExportPostProcess = true; } } @@ -74519,7 +74520,7 @@ bool AudioEngine::renderProject(const juce::String& source, double startTime, do } const bool quantizeExport = (ditherMode > 0 || reducedPrecision > 0) && !isLossyFormat && bitDepth < 32; - const bool quantizeAfterSRC = quantizeExport && needsFFmpegPostProcess; + const bool quantizeAfterSRC = quantizeExport && needsExportPostProcess; // ========== 5. Create format writer ========== // For lossy formats (mp3/ogg) or sample rate conversion, render to temp WAV first @@ -74531,7 +74532,7 @@ bool AudioEngine::renderProject(const juce::String& source, double startTime, do + "_intermediate.wav")); juce::File renderFile = stagedOutput.getFile(); - if (needsFFmpegPostProcess) + if (needsExportPostProcess) { // Render an intermediate WAV while the final encoded file is staged // separately. Neither temporary can replace an existing user file @@ -74540,7 +74541,7 @@ bool AudioEngine::renderProject(const juce::String& source, double startTime, do } std::unique_ptr audioFormat; - juce::String renderFormatLower = needsFFmpegPostProcess ? "wav" : formatLower; + juce::String renderFormatLower = needsExportPostProcess ? "wav" : formatLower; if (renderFormatLower == "wav") audioFormat = std::make_unique(); @@ -75047,7 +75048,9 @@ bool AudioEngine::renderProject(const juce::String& source, double startTime, do root->setProperty("format", format); root->setProperty("requestedOutputSampleRate", requestedOutputSampleRate); root->setProperty("actualSampleRate", actualSampleRate); - root->setProperty("ffmpegPostProcess", needsFFmpegPostProcess); + root->setProperty("ffmpegPostProcess", needsExportPostProcess + && (formatLower != "ogg" || std::abs(actualSampleRate - requestedOutputSampleRate) > 1.0)); + root->setProperty("oggEncoder", formatLower == "ogg" ? "native_libvorbis" : "not_applicable"); root->setProperty("lossyIntermediateRender", isLossyFormat); root->setProperty("bitDepth", bitDepth); root->setProperty("channels", numChannels); @@ -75926,20 +75929,33 @@ bool AudioEngine::renderProject(const juce::String& source, double startTime, do logToDisk ("renderProject: signal-chain debug report=" + reportFile.getFullPathName()); } - // ========== 9. FFmpeg post-processing (encoding or final SRC) ========== - if (needsFFmpegPostProcess) + // ========== 9. Offline encoding or final SRC ========== + if (needsExportPostProcess) { - logToDisk("renderProject: Starting FFmpeg post-processing (encoding/final SRC)..."); - bool ffmpegOk = false; - if (quantizeAfterSRC) + logToDisk("renderProject: Starting post-processing (encoding/final SRC)..."); + bool postProcessOk = false; + if (formatLower == "ogg") + { + // System FFmpeg builds need not include libvorbis (including current + // macOS builds). Keep existing final SRC, then encode with bundled + // libvorbis. Both paths stay within the export publication transaction. + juce::TemporaryFile resampled(outputFile.getSiblingFile("vorbis-src.wav")); + const bool needsSRC = std::abs(actualSampleRate - requestedOutputSampleRate) > 1.0; + postProcessOk = !needsSRC || convertWithFFmpeg(renderFile, resampled.getFile(), "wav", + requestedOutputSampleRate, 0, 32, numChannels, keepRunning); + if (postProcessOk) + postProcessOk = encodeOfflineVorbis(needsSRC ? resampled.getFile() : renderFile, + stagedOutput.getFile(), codecQuality, keepRunning); + } + else if (quantizeAfterSRC) { // SRC must precede the sole integer quantizer. Retain float precision // through FFmpeg; raw PCM only needs a lossless container removal afterwards. juce::TemporaryFile resampled(outputFile.getSiblingFile("quantizer-src.wav")); juce::TemporaryFile quantizedRaw(outputFile.getSiblingFile("quantizer-raw.wav")); - ffmpegOk = convertWithFFmpeg(renderFile, resampled.getFile(), "wav", + postProcessOk = convertWithFFmpeg(renderFile, resampled.getFile(), "wav", requestedOutputSampleRate, 0, 32, numChannels, keepRunning); - if (ffmpegOk) + if (postProcessOk) { juce::AudioFormatManager manager; manager.registerBasicFormats(); std::unique_ptr reader(manager.createReaderFor(resampled.getFile())); @@ -75951,32 +75967,32 @@ bool AudioEngine::renderProject(const juce::String& source, double startTime, do std::unique_ptr stream = target.createOutputStream(); auto finalWriter = finalFormat->createWriterFor(stream, juce::AudioFormatWriterOptions() .withSampleRate(requestedOutputSampleRate).withNumChannels(numChannels).withBitsPerSample(bitDepth)); - ffmpegOk = reader != nullptr && finalWriter != nullptr; + postProcessOk = reader != nullptr && finalWriter != nullptr; juce::AudioBuffer block(numChannels, 4096); - if (ffmpegOk) - for (juce::int64 offset = 0; offset < reader->lengthInSamples && ffmpegOk; offset += 4096) + if (postProcessOk) + for (juce::int64 offset = 0; offset < reader->lengthInSamples && postProcessOk; offset += 4096) { const int count = static_cast(juce::jmin(juce::int64{4096}, reader->lengthInSamples - offset)); - ffmpegOk = (!keepRunning || keepRunning()) + postProcessOk = (!keepRunning || keepRunning()) && reader->read(&block, 0, count, offset, true, numChannels > 1) && exportQuantizer.write(*finalWriter, block, 0, count); } finalWriter.reset(); - if (ffmpegOk && isRawFormat) - ffmpegOk = convertWithFFmpeg(target, stagedOutput.getFile(), "raw", + if (postProcessOk && isRawFormat) + postProcessOk = convertWithFFmpeg(target, stagedOutput.getFile(), "raw", requestedOutputSampleRate, 0, bitDepth, numChannels, keepRunning); } } else - ffmpegOk = convertWithFFmpeg(renderFile, stagedOutput.getFile(), formatLower, + postProcessOk = convertWithFFmpeg(renderFile, stagedOutput.getFile(), formatLower, requestedOutputSampleRate, codecQuality, bitDepth, numChannels, keepRunning); // Clean up temp file renderFile.deleteFile(); - if (!ffmpegOk) + if (!postProcessOk) { - logToDisk("renderProject: FAIL - FFmpeg post-processing failed"); + logToDisk("renderProject: FAIL - export post-processing failed"); restorePreparedTracksForRealtime(); return false; } @@ -76525,6 +76541,30 @@ juce::var AudioEngine::runRenderExportRegression(const juce::File& outputDirecto fixtureWritten, fixtureFile.getFullPathName()); + { + const auto previousExport = outputDirectory.getChildFile("native-vorbis-cancel.ogg"); + const bool previousWritten = previousExport.replaceWithText("previous export remains intact"); + juce::File stagingPath; + int cancellationChecks = 0; + bool cancelled = false; + { + juce::TemporaryFile staging(previousExport); + stagingPath = staging.getFile(); + cancelled = !encodeOfflineVorbis(fixtureFile, stagingPath, 6, + [&cancellationChecks] { return ++cancellationChecks < 4; }); + } + addCheck("native_vorbis_cancellation_preserves_previous_export", + fixtureWritten && previousWritten && cancelled && cancellationChecks == 4 + && previousExport.loadFileAsString() == "previous export remains intact" && !stagingPath.exists(), + "Cancelling the bounded native encoder mid-stream removes staging without publishing over an existing export."); + const auto missingStage = outputDirectory.getChildFile("native-vorbis-missing-input.ogg"); + addCheck("native_vorbis_invalid_input_and_existing_stage_rejected", + !encodeOfflineVorbis(outputDirectory.getChildFile("missing-vorbis-input.wav"), missingStage, 6, {}) + && !missingStage.exists() && !encodeOfflineVorbis(fixtureFile, previousExport, 6, {}) + && previousExport.loadFileAsString() == "previous export remains intact", + "Missing input fails before creating output; an existing destination cannot be appended to or overwritten by the staging encoder."); + } + // Long sources must use complete worker reads, regardless of the realtime // full-decode budget, render speed, seek position or block partitioning. { @@ -77207,6 +77247,7 @@ juce::var AudioEngine::runRenderExportRegression(const juce::File& outputDirecto { "mp3_256", "master", "mp3", 44100.0, 256, 2 }, { "mp3_320", "master", "mp3", 96000.0, 320, 2 }, { "ogg_q3", "master", "ogg", 44100.0, 3, 2 }, + { "ogg_q0_mono_96000", "master", "ogg", 96000.0, 0, 1 }, { "ogg_q5", "master", "ogg", 48000.0, 5, 2 }, { "ogg_q6", "master", "ogg", 48000.0, 6, 2 }, { "ogg_q7", "master", "ogg", 48000.0, 7, 2 }, @@ -80249,7 +80290,7 @@ bool AudioEngine::exportProjectMIDI(const juce::String& outputPath, const juce:: // Preserve closely spaced controller changes when exchanging with DAWs // that collapse multiple values for the same controller at one SMF tick. constexpr int exportTicksPerQuarterNote = 9600; - const auto secondsToTicks = [&tempoMap, exportTicksPerQuarterNote] (double seconds) + const auto secondsToTicks = [&tempoMap, exportTicksPerQuarterNote=exportTicksPerQuarterNote] (double seconds) { const double end = juce::jmax(0.0, seconds); double previous = 0.0, rate = tempoMap.front().second, ticks = 0.0; diff --git a/Source/FreePluginRegression.cpp b/Source/FreePluginRegression.cpp index 6ea8e6a0..80d0f59f 100644 --- a/Source/FreePluginRegression.cpp +++ b/Source/FreePluginRegression.cpp @@ -2797,12 +2797,12 @@ juce::var checkPreviewCallbackBounds() constexpr int capacity = 512; constexpr float poison = 0.375f; const std::array blockSizes { 5, 8, 16, 127, capacity }; - const auto poisonBuffer = [poison] (juce::AudioBuffer& buffer) + const auto poisonBuffer = [poison=poison] (juce::AudioBuffer& buffer) { for (int channel = 0; channel < buffer.getNumChannels(); ++channel) juce::FloatVectorOperations::fill(buffer.getWritePointer(channel), poison, buffer.getNumSamples()); }; - const auto suffixUntouched = [poison] (const juce::AudioBuffer& buffer, int start) + const auto suffixUntouched = [poison=poison] (const juce::AudioBuffer& buffer, int start) { for (int channel = 0; channel < buffer.getNumChannels(); ++channel) for (int sample = start; sample < buffer.getNumSamples(); ++sample) diff --git a/Source/FreePluginReverbBalance.h b/Source/FreePluginReverbBalance.h index 5628ac75..242ea1f8 100644 --- a/Source/FreePluginReverbBalance.h +++ b/Source/FreePluginReverbBalance.h @@ -77,7 +77,7 @@ inline juce::var renderReverbBalancePack() else tailEnergy += full * full; } maximumHalfError = juce::jmax(maximumHalfError, halfError); maximumGainError = juce::jmax(maximumGainError, gainError); - const auto rms = [rate] (double energy) { return std::sqrt(energy / (2 * rate * 3)); }; + const auto rms = [rate=rate] (double energy) { return std::sqrt(energy / (2 * rate * 3)); }; const auto ratio = [] (double wet, double dry) { return 10 * std::log10(juce::jmax(1e-30, wet) / juce::jmax(1e-30, dry)); }; auto* row = new juce::DynamicObject(); row->setProperty("type", type); row->setProperty("name", type < 27 ? juce::String(names[static_cast(type)]) : juce::String(BuiltInRetroReverb::names[static_cast(type - 27)])); diff --git a/Source/Main.cpp b/Source/Main.cpp index b78f5949..676ce6f0 100644 --- a/Source/Main.cpp +++ b/Source/Main.cpp @@ -2660,6 +2660,34 @@ class OpenStudioApplication : public juce::JUCEApplication auto checks = std::make_shared>(); auto inputEvidence = std::make_shared>(); auto frontendDocuments = std::make_shared>(); + auto geometryEvidence = std::make_shared>(); + // Cocoa constrains native frames to the usable display. A requested + // client size must leave room for its native title bar (the arm64 CI + // desktop is 1024x768). Assert exact predetermined, achievable bounds; + // never replace an expectation with whatever the window returned. + const auto fitNativeBounds = [](juce::DocumentWindow* window, juce::Rectangle requested) + { + const auto* display = juce::Desktop::getInstance().getDisplays().getDisplayForRect(requested); + auto* peer = window != nullptr ? window->getPeer() : nullptr; + return display != nullptr && peer != nullptr + ? requested.constrainedWithin(peer->getFrameSize().subtractedFrom(display->userBounds.getLargestIntegerWithin())) + : requested; + }; + const auto checkGeometry = [geometryEvidence](const juce::String& id, juce::DocumentWindow* window, + juce::Rectangle requested, juce::Rectangle expected) + { + auto* evidence = new juce::DynamicObject(); + evidence->setProperty("id", id); + evidence->setProperty("requested", rectangleToVar(requested)); + evidence->setProperty("expected", rectangleToVar(expected)); + evidence->setProperty("actual", rectangleToVar(window != nullptr ? window->getBounds() : juce::Rectangle{})); + if (const auto* display = juce::Desktop::getInstance().getDisplays().getDisplayForRect(expected)) + evidence->setProperty("usableDisplay", rectangleToVar(display->userBounds.getLargestIntegerWithin())); + if (window != nullptr && window->getPeer() != nullptr) + evidence->setProperty("nativeFrame", rectangleToVar(window->getPeer()->getFrameSize().addedTo(expected))); + geometryEvidence->add(juce::var(evidence)); + return window != nullptr && window->getBounds() == expected; + }; auto steps = std::make_shared>(); const auto addDocumentCheck = [steps, frontendDocuments](const juce::String& role, std::function getWindow) @@ -2687,7 +2715,8 @@ class OpenStudioApplication : public juce::JUCEApplication addDocumentCheck("main", [this]() { return mainWindow.get(); }); const auto originalBounds = mainWindow->getBounds(); - const auto geometryBounds = originalBounds.withSizeKeepingCentre(1000, 700).translated(17, 13); + const auto requestedGeometryBounds = originalBounds.withSizeKeepingCentre(1000, 700).translated(17, 13); + const auto geometryBounds = fitNativeBounds(mainWindow.get(), requestedGeometryBounds); steps->push_back({ "main_native_chrome", 0, [this]() { if (!mainWindow || !mainWindow->isUsingNativeTitleBar()) return false; #if JUCE_WINDOWS @@ -2706,12 +2735,13 @@ class OpenStudioApplication : public juce::JUCEApplication && SUCCEEDED(::DwmGetWindowAttribute(static_cast(mainWindow->getPeer()->getNativeHandle()), 20, &dark, sizeof(dark))) && dark; }}); #endif - steps->push_back({ "main_move_resize", 900, [this, geometryBounds]() { + steps->push_back({ "main_move_resize", 900, [this, geometryBounds, requestedGeometryBounds, originalBounds, checkGeometry]() { mainWindow->setBounds(geometryBounds); - return mainWindow->getBounds() == geometryBounds; + return checkGeometry("main_move_resize", mainWindow.get(), requestedGeometryBounds, geometryBounds) + && geometryBounds != originalBounds && geometryBounds.getWidth() >= 800 && geometryBounds.getHeight() >= 600; }}); - steps->push_back({ "main_bounds_stable", 0, [this, geometryBounds]() { - return mainWindow->getBounds() == geometryBounds; + steps->push_back({ "main_bounds_stable", 0, [this, geometryBounds, requestedGeometryBounds, checkGeometry]() { + return checkGeometry("main_bounds_stable", mainWindow.get(), requestedGeometryBounds, geometryBounds); }}); #if JUCE_WINDOWS steps->push_back({ "main_os_maximize", 300, [this]() { @@ -2873,15 +2903,15 @@ class OpenStudioApplication : public juce::JUCEApplication }; addInputChecks("main", [this]() { return mainWindow.get(); }); #endif - auto addGeometryChecks = [steps](const juce::String& role, std::function getWindow) + auto addGeometryChecks = [steps, checkGeometry](const juce::String& role, std::function getWindow) { auto expected = std::make_shared>(); - steps->push_back({ role + "_native_move_resize", 900, [getWindow, expected]() { + steps->push_back({ role + "_native_move_resize", 900, [getWindow, expected, role, checkGeometry]() { auto* window = getWindow(); if (!window || !window->isUsingNativeTitleBar()) return false; *expected = window->getBounds().translated(11, 9).withWidth(window->getWidth() + 20); window->setBounds(*expected); - return window->getBounds() == *expected; + return checkGeometry(role + "_native_move_resize", window, *expected, *expected); }}); steps->push_back({ role + "_bounds_stable", 0, [getWindow, expected]() { return getWindow() && getWindow()->getBounds() == *expected; @@ -3146,12 +3176,20 @@ class OpenStudioApplication : public juce::JUCEApplication return window != nullptr && window->getWidth() == 640 && window->getHeight() == 480 && pluginEditorWindowManagers.at(pluginSessionId)->isFrontendReady(); }}); - steps->push_back({ "plugin_restore_preferred_geometry", 300, [this, pluginSessionId, pluginBounds]() + auto restoredPluginBounds = std::make_shared>(); + steps->push_back({ "plugin_restore_preferred_geometry", 300, [this, pluginSessionId, pluginBounds, restoredPluginBounds, fitNativeBounds, checkGeometry]() { auto* window = pluginEditorWindowManagers.at(pluginSessionId)->getNativeWindow(); if (window == nullptr) return false; - window->setBounds(pluginBounds); - return window->getBounds() == pluginBounds; + *restoredPluginBounds = fitNativeBounds(window, pluginBounds); + window->setBounds(*restoredPluginBounds); + return checkGeometry("plugin_restore_preferred_geometry", window, pluginBounds, *restoredPluginBounds) + && restoredPluginBounds->getWidth() >= 640 && restoredPluginBounds->getHeight() >= 480; + }}); + steps->push_back({ "plugin_restored_bounds_stable", 0, [this, pluginSessionId, pluginBounds, restoredPluginBounds, checkGeometry]() + { + return checkGeometry("plugin_restored_bounds_stable", pluginEditorWindowManagers.at(pluginSessionId)->getNativeWindow(), + pluginBounds, *restoredPluginBounds); }}); if (editorReviewHoldMs > 0) steps->push_back({ "plugin_editor_review_hold", editorReviewHoldMs, []() { return true; } }); @@ -3270,7 +3308,7 @@ class OpenStudioApplication : public juce::JUCEApplication auto stepAttempt = std::make_shared(0); auto runner = std::make_shared>(); const std::weak_ptr> weakRunner = runner; - *runner = [this, reportFile, checks, inputEvidence, frontendDocuments, steps, stepIndex, stepAttempt, weakRunner, midiSessionId, offlineRequested]() mutable + *runner = [this, reportFile, checks, inputEvidence, frontendDocuments, geometryEvidence, steps, stepIndex, stepAttempt, weakRunner, midiSessionId, offlineRequested]() mutable { // Timers own the next invocation. The function must not own itself, // otherwise all check results survive application shutdown. @@ -3298,6 +3336,7 @@ class OpenStudioApplication : public juce::JUCEApplication root->setProperty("nativeBrowserComponents", MainComponent::getBrowserInstanceCounts()); root->setProperty("inputMeasurements", juce::var(*inputEvidence)); root->setProperty("frontendDocuments", juce::var(*frontendDocuments)); + root->setProperty("geometryMeasurements", juce::var(*geometryEvidence)); root->setProperty("offlineRequested", offlineRequested); root->setProperty("audioDeviceOpened", audioDeviceOpened); root->setProperty("audioDeviceStartCount", deviceStarts); diff --git a/Source/NAMFuzzRegression.h b/Source/NAMFuzzRegression.h index e94ea083..9e21912b 100644 --- a/Source/NAMFuzzRegression.h +++ b/Source/NAMFuzzRegression.h @@ -415,7 +415,7 @@ class NAMFuzzRegression final input.setSample(0,position,value);input.setSample(1,position,value); } struct GateRun {juce::AudioBuffer audio;std::array snapshots;}; - const auto process=[&input,&lengths,sampleRate] (int blockSize,float excitationGain) + const auto process=[&input,&lengths,sampleRate=sampleRate] (int blockSize,float excitationGain) { GateRun run;run.audio.makeCopyOf(input);NAMFuzz processor;NAMFuzz::Parameters controls; controls.enabled=true;controls.bass=true;controls.voice=2;controls.drive=0.5f;processor.prepare(sampleRate,controls); diff --git a/Source/OfflineVorbisEncoder.h b/Source/OfflineVorbisEncoder.h new file mode 100644 index 00000000..84213ba2 --- /dev/null +++ b/Source/OfflineVorbisEncoder.h @@ -0,0 +1,71 @@ +#pragma once + +#include +#include + +// Offline export only. The caller owns atomic publication of this staged file. +// Use JUCE's bundled libvorbis rather than requiring an optional FFmpeg encoder. +inline bool encodeOfflineVorbis(const juce::File& input, const juce::File& stagedOutput, + int quality, const std::function& keepRunning) +{ + if ((keepRunning && !keepRunning()) || stagedOutput.exists()) return false; + juce::WavAudioFormat wav; + auto inputStream = input.createInputStream(); + if (!inputStream) return false; + std::unique_ptr reader(wav.createReaderFor(inputStream.release(), true)); + if (!reader || reader->lengthInSamples <= 0 || reader->numChannels < 1 || reader->numChannels > 2) + return false; + // JUCE's Vorbis codec buffers its final packet until destruction and does + // not propagate every OutputStream write failure. Observe the file status + // through that final flush before allowing publication. + struct CheckedStream final : juce::OutputStream + { + CheckedStream(std::unique_ptr fileIn, bool& successIn) + : file(std::move(fileIn)), success(successIn) {} + ~CheckedStream() override { flush(); } + bool write(const void* data, size_t size) override + { + const bool written = file->write(data, size); + success = success && written; + return written; + } + void flush() override { file->flush(); success = success && file->getStatus().wasOk(); } + juce::int64 getPosition() override { return file->getPosition(); } + bool setPosition(juce::int64 position) override + { + const bool positioned = file->setPosition(position); + success = success && positioned; + return positioned; + } + std::unique_ptr file; + bool& success; + }; + auto file = stagedOutput.createOutputStream(); + if (!file) return false; + bool ioSucceeded = true; + juce::OggVorbisAudioFormat vorbis; + std::unique_ptr stream = std::make_unique(std::move(file), ioSucceeded); + auto writer = vorbis.createWriterFor(stream, juce::AudioFormatWriterOptions() + .withSampleRate(reader->sampleRate).withNumChannels(static_cast(reader->numChannels)) + .withBitsPerSample(24).withQualityOptionIndex(juce::jlimit(0, 10, quality))); + if (!writer) return false; + juce::AudioBuffer block(static_cast(reader->numChannels), 4096); + for (juce::int64 offset = 0; offset < reader->lengthInSamples; offset += block.getNumSamples()) + { + const int count = static_cast(juce::jmin(juce::int64{4096}, reader->lengthInSamples - offset)); + if ((keepRunning && !keepRunning()) + || !reader->read(&block, 0, count, offset, true, reader->numChannels > 1) + || !writer->writeFromAudioSampleBuffer(block, 0, count)) + return false; + } + // The destructor emits the final Vorbis packets. Check the completed stream + // before the caller may replace an existing export, including write failures + // that the codec's buffered write API cannot report until finalisation. + writer.reset(); + if (!ioSucceeded || (keepRunning && !keepRunning())) return false; + auto encodedStream = stagedOutput.createInputStream(); + if (!encodedStream) return false; + std::unique_ptr verified(vorbis.createReaderFor(encodedStream.release(), true)); + return verified && verified->lengthInSamples == reader->lengthInSamples + && verified->numChannels == reader->numChannels && verified->sampleRate == reader->sampleRate; +} diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md index a08d5e40..9cf9aa2d 100644 --- a/docs/USER_MANUAL.md +++ b/docs/USER_MANUAL.md @@ -2255,7 +2255,7 @@ Choose the time range to render: | **AIFF** | Uncompressed output with the available bit-depth selection | | **FLAC** | Lossless compressed output with the available bit-depth selection | | **MP3** | FFmpeg-encoded output using the selected bitrate; FFmpeg is bundled on Windows and must be installed on `PATH` on macOS/Linux | -| **OGG Vorbis** | FFmpeg-encoded output using the selected quality; FFmpeg is bundled on Windows and must be installed on `PATH` on macOS/Linux | +| **OGG Vorbis** | Built-in Vorbis encoding using the selected quality (0–10), including when system FFmpeg lacks a Vorbis encoder. Final sample-rate conversion may still use FFmpeg. | **Sample rate**: Select the target render rate. The offline engine renders at that rate while the playback path converts source files as required. @@ -3307,7 +3307,7 @@ OpenStudio project files (`.osproj`) are saved to the location you choose when s **Import formats**: WAV, AIFF, FLAC, MP3, OGG Vorbis, MIDI, and video audio extraction where FFmpeg is available. The audited executable is bundled on Windows; FFmpeg-backed operations require a system `ffmpeg` on `PATH` on macOS/Linux. -**Export formats**: WAV, AIFF, FLAC, MP3, OGG Vorbis, MIDI, and DDP export. MP3/OGG and other FFmpeg conversions require the bundled Windows executable or a system `ffmpeg` on macOS/Linux. +**Export formats**: WAV, AIFF, FLAC, MP3, OGG Vorbis, MIDI, and DDP export. OGG encoding uses the built-in Vorbis codec. MP3 and final FFmpeg conversions require the bundled Windows executable or a system `ffmpeg` on macOS/Linux. **Plugin formats**: VST3 is the stable primary path. CLAP and LV2 code paths are present where available. Experimental 32-bit bridging controls are not part of the stable plugin-hosting path. diff --git a/docs/certification-0.1.09.md b/docs/certification-0.1.09.md index 4fc97b51..bda1f720 100644 --- a/docs/certification-0.1.09.md +++ b/docs/certification-0.1.09.md @@ -6,6 +6,10 @@ The user requested certification checks and a release PR, with publication held. ## Current evidence +The follow-up "Mac export/window qualification" below records the latest source +changes and the completed Windows certification at `dd92c765`. PR #36 retains +final-head CI links; earlier results remain tied to their listed artifacts. + **Merge acceptance decision — 5 November 2026:** the user explicitly accepted the strict 8-sample contention failure as a non-blocking known limitation for PR #36 and requested that optimization work on that target stop. Its measured result @@ -590,3 +594,48 @@ retain them as separate Actions artifacts; the workflow was not invoked here. Automatic timed dumps of running-session UI hangs are deliberately disabled in ordinary builds. No claim of zero total DAW cost or historical CPU/RAM attribution is made from the offline reports. + +## Mac export/window qualification — 5 November 2026 + +Windows certification completed at `dd92c765` before this follow-up. The +[WACK job](https://github.com/sdevil7th/OpenStudio/actions/runs/41968313366/job/127064166209) +passed overall, exit 0, with all 13 required tests and no required findings. +The [retained report](https://github.com/sdevil7th/OpenStudio/actions/runs/41968313366/artifacts/13389764270) +identifies MSIX SHA-256 +`b32b78294ca5473e52a7cbe42f75d6c71481362a5c6c2a11903c802bdf059e46`. +Application-count and blocked-executable findings remain optional; no WACK gate +was weakened. `published: false`; this does not establish Store approval. + +The Mac Intel job passed callback/ARA contracts but failed six OGG cases. Its +FFmpeg 9.0.1 configuration lacks `libvorbis`. The export path now uses JUCE's +bundled Vorbis encoder with the existing quality 0–10 mapping. Final SRC keeps +the existing FFmpeg WAV conversion. Encoding is bounded and cancellable, checks +file-write/finalisation status and decoded stream length, and stays inside the +existing atomic export transaction. Tests cover cancellation, missing input, +refusal to append over an existing staging destination, mono/stereo, quality, +sample-rate changes and Unicode paths. This changes only explicit offline export. + +The Apple Silicon runner has a 1024x768 display with only 1024x681 usable points. +The failing main/preferred-plugin geometry requests did not reserve native title +bar space. The explicit lifecycle harness now fits those expected client bounds +to the usable native-frame area before requesting them. Exact requested-result +and delayed stability assertions remain, with an additional restored-plugin +stability check. Requested/expected/actual rectangles and usable display/native +frame geometry are retained only in the explicit harness report. Normal window +startup gains no diagnostic timer or collector. Five redundant-capture warnings +are addressed using explicit initialised captures accepted by MSVC and Clang. + +Local Windows Release validation of this source follow-up: **pass**, zero C++ +compiler warnings and all 426 render/export checks passing, including the new +native Vorbis contracts. Desktop-qualification/release-note tools: 43 tests, +31 passed and 12 platform-specific skips. The updated development website guides +passed TypeScript checking; they were not deployed. Local native evidence was +built from the dirty source tree before committing this follow-up, not a clean +`dd92c765` binary. New Mac/Windows/Linux artifacts require their own CI results; +PR #36 records those results rather than relabelling older artifacts. + +Support remains manual-only. No callback diagnostics or ARA logging was enabled. +The accepted 8-sample failure remains **fail, non-blocking**; no new hardware +measurement was made. Listening quality, physical devices, Apple notarization, +Gatekeeper acceptance and Store approval remain **not_asserted**. Nothing was +published, submitted or merged. diff --git a/docs/implemented_features.md b/docs/implemented_features.md index 66feb269..0bd0a5e8 100644 --- a/docs/implemented_features.md +++ b/docs/implemented_features.md @@ -160,7 +160,7 @@ See [built-in plugin contracts and limits](free-plugins.md); implemented feature | Feature | Impact | Complexity | |---|---:|---:| | Offline project render through the same playback/FX engine | H | H | -| Render formats: WAV, AIFF, FLAC, MP3, OGG (FFmpeg-backed export uses the bundled audited binary on Windows and a system dependency on macOS/Linux) | H | H | +| Render formats: WAV, AIFF, FLAC, MP3, OGG (OGG uses built-in Vorbis; MP3 and final FFmpeg conversions use the bundled audited binary on Windows and a system dependency on macOS/Linux) | H | H | | Render options: sample rate, bit depth/quality, mono/stereo, normalize, tail | H | M | | Dithered render path | M | H | | Master and per-track stem render paths, plus region/razor range orchestration | H | H | diff --git a/docs/releases/0.1.09.md b/docs/releases/0.1.09.md index ed4a64f6..2808b5dc 100644 --- a/docs/releases/0.1.09.md +++ b/docs/releases/0.1.09.md @@ -25,6 +25,10 @@ development versions. No 0.1.09 release or Store update has been published. ## Fixes +- Encode OGG with the built-in Vorbis codec, preserving quality 0–10, instead of + requiring a system FFmpeg build with the optional Vorbis encoder. Final + sample-rate conversion retains the existing FFmpeg path. Encoding remains + offline and cancellable, and a failed export cannot replace an existing file. - Compile out ARA callback timing, logging arguments and per-block debug-state updates in ordinary builds. Remove periodic heartbeat/fault polling; flush failure counts at stop/shutdown. The Windows crash helper waits on events during From f92fc4303e26c45e54a1f9b6d8018ac93334e394 Mon Sep 17 00:00:00 2001 From: Sourav Das Date: Thu, 5 Nov 2026 12:14:37 +0530 Subject: [PATCH 30/33] Refresh export feature coverage inventory --- docs/ui-coverage-inventory.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/ui-coverage-inventory.json b/docs/ui-coverage-inventory.json index 9226d118..cfb13092 100644 --- a/docs/ui-coverage-inventory.json +++ b/docs/ui-coverage-inventory.json @@ -2232,10 +2232,10 @@ "coverage": [] }, { - "id": "feature:a9562580513610e0", + "id": "feature:c4149a3f1e372238", "kind": "feature", "section": "Rendering / Export / Interchange", - "description": "Render formats: WAV, AIFF, FLAC, MP3, OGG (FFmpeg-backed export uses the bundled audited binary on Windows and a system dependency on macOS/Linux)", + "description": "Render formats: WAV, AIFF, FLAC, MP3, OGG (OGG uses built-in Vorbis; MP3 and final FFmpeg conversions use the bundled audited binary on Windows and a system dependency on macOS/Linux)", "source": "docs/implemented_features.md", "coverage": [] }, From 191d91c207da4152648942670eef704292633105 Mon Sep 17 00:00:00 2001 From: Sourav Das Date: Thu, 5 Nov 2026 12:39:32 +0530 Subject: [PATCH 31/33] Use current JUCE frame geometry API in window qualification --- Source/Main.cpp | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/Source/Main.cpp b/Source/Main.cpp index 676ce6f0..dc4bc456 100644 --- a/Source/Main.cpp +++ b/Source/Main.cpp @@ -2669,9 +2669,10 @@ class OpenStudioApplication : public juce::JUCEApplication { const auto* display = juce::Desktop::getInstance().getDisplays().getDisplayForRect(requested); auto* peer = window != nullptr ? window->getPeer() : nullptr; - return display != nullptr && peer != nullptr - ? requested.constrainedWithin(peer->getFrameSize().subtractedFrom(display->userBounds.getLargestIntegerWithin())) - : requested; + if (display == nullptr || peer == nullptr) return requested; + const auto frame = peer->getFrameSizeIfPresent(); + const auto usable = display->userBounds.getLargestIntegerWithin(); + return requested.constrainedWithin(frame ? frame->subtractedFrom(usable) : usable); }; const auto checkGeometry = [geometryEvidence](const juce::String& id, juce::DocumentWindow* window, juce::Rectangle requested, juce::Rectangle expected) @@ -2684,7 +2685,11 @@ class OpenStudioApplication : public juce::JUCEApplication if (const auto* display = juce::Desktop::getInstance().getDisplays().getDisplayForRect(expected)) evidence->setProperty("usableDisplay", rectangleToVar(display->userBounds.getLargestIntegerWithin())); if (window != nullptr && window->getPeer() != nullptr) - evidence->setProperty("nativeFrame", rectangleToVar(window->getPeer()->getFrameSize().addedTo(expected))); + { + const auto frame = window->getPeer()->getFrameSizeIfPresent(); + evidence->setProperty("nativeFrameKnown", static_cast(frame)); + if (frame) evidence->setProperty("nativeFrame", rectangleToVar(frame->addedTo(expected))); + } geometryEvidence->add(juce::var(evidence)); return window != nullptr && window->getBounds() == expected; }; From 85d7717cc0dc61377f3fc98df3c958e1ef9068b6 Mon Sep 17 00:00:00 2001 From: Sourav Das Date: Thu, 5 Nov 2026 12:51:21 +0530 Subject: [PATCH 32/33] Retain Mac candidate and detailed spectral export evidence --- .github/workflows/verify.yml | 2 +- Source/AudioEngine.cpp | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index ae4eed58..c0517a70 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -824,7 +824,7 @@ jobs: if-no-files-found: warn - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - if: env.CALLBACK_DIAGNOSTICS == 'OFF' + if: always() && env.CALLBACK_DIAGNOSTICS == 'OFF' with: name: candidate-macos-universal path: dist/macos/* diff --git a/Source/AudioEngine.cpp b/Source/AudioEngine.cpp index 20128317..d16cb2e3 100644 --- a/Source/AudioEngine.cpp +++ b/Source/AudioEngine.cpp @@ -78096,8 +78096,14 @@ juce::var AudioEngine::runRenderExportRegression(const juce::File& outputDirecto spectralChange = juce::jmax(spectralChange, std::abs(wet - dry)); } } + auto* spectralEvidence = new juce::DynamicObject(); + spectralEvidence->setProperty("pdc", spectralPDC); + spectralEvidence->setProperty("renderAndLengths", spectralRenderPass); + spectralEvidence->setProperty("neutralError", spectralNeutralError); + spectralEvidence->setProperty("routeError", spectralMixError); + spectralEvidence->setProperty("activeChange", spectralChange); addCheck("spectral_eq_export_alignment", spectralPDC && spectralRenderPass && spectralNeutralError < 2e-6 && spectralMixError < 2e-6 && spectralChange > 1e-6, - "Prepared spectral delay preserves neutral parallel alignment; active corrections agree in master, stem and selected-item exports.", spectralMixError); + "Prepared spectral delay preserves neutral parallel alignment; active corrections agree in master, stem and selected-item exports.", juce::var(spectralEvidence)); bool spectralRecall = setBuiltInPluginParam(phaseTrack, "track", 0, "phaseMode", 1) && phaseEQ->getLatencySamples() == 4352 && trackMap[parallelTrack]->getPDCDelay() == 4352; spectralRecall = setBuiltInPluginState(phaseTrack, "track", 0, juce::JSON::toString(spectralState)) && spectralRecall From 074cc2e7ff65b72e8eb90c0e9cfaba23cf18f53c Mon Sep 17 00:00:00 2001 From: Sourav Das Date: Thu, 5 Nov 2026 13:27:43 +0530 Subject: [PATCH 33/33] Keep neutral EQ exact on fused-math platforms --- Source/AudioEngine.cpp | 12 ++++++++++ Source/BuiltInEffects.cpp | 8 +++++++ docs/certification-0.1.09.md | 44 ++++++++++++++++++++++++++++++++++-- docs/releases/0.1.09.md | 36 +++++++++++------------------ 4 files changed, 75 insertions(+), 25 deletions(-) diff --git a/Source/AudioEngine.cpp b/Source/AudioEngine.cpp index d16cb2e3..a4170d19 100644 --- a/Source/AudioEngine.cpp +++ b/Source/AudioEngine.cpp @@ -77972,6 +77972,18 @@ juce::var AudioEngine::runRenderExportRegression(const juce::File& outputDirecto juce::AudioBuffer phaseBaseline, phaseMaster, phaseStem, phaseSelected; bool phaseRenderPass = renderPhaseCase("master", "baseline", phaseBaseline); const bool phaseMounted = addTrackBuiltInFX(phaseTrack, "OpenStudio EQ", false); + juce::AudioBuffer neutralEQ; + bool neutralEQExact = phaseRenderPass && phaseMounted + && renderPhaseCase("master", "eq-neutral", neutralEQ) + && neutralEQ.getNumSamples() == phaseBaseline.getNumSamples(); + double neutralEQError = 0; + if (neutralEQExact) + for (int channel = 0; channel < 2; ++channel) + for (int sample = 0; sample < phaseBaseline.getNumSamples(); ++sample) + neutralEQError = juce::jmax(neutralEQError, std::abs(static_cast( + neutralEQ.getSample(channel, sample) - phaseBaseline.getSample(channel, sample)))); + addCheck("neutral_eq_export_identity", neutralEQExact && neutralEQError == 0, + "Default zero-gain EQ preserves PCM exactly, including on fused-math platforms; no recursive unity-filter residue.", neutralEQError); const bool phaseEnabled = phaseMounted && setBuiltInPluginParam(phaseTrack, "track", 0, "phaseMode", 1); auto* phaseEQ = phaseMounted ? dynamic_cast(trackMap[phaseTrack]->getTrackFXProcessor(0)) : nullptr; const auto phaseState = phaseEnabled ? getBuiltInPluginState(phaseTrack, "track", 0) : juce::var(); diff --git a/Source/BuiltInEffects.cpp b/Source/BuiltInEffects.cpp index b2baaea4..80b40b6a 100644 --- a/Source/BuiltInEffects.cpp +++ b/Source/BuiltInEffects.cpp @@ -233,6 +233,14 @@ constexpr OpenStudioIIRCoefficientSet kIdentityBiquad { OpenStudioIIRCoefficientSet normaliseBiquad( const std::array& coefficients) noexcept { + // Equal numerator/denominator polynomials are exactly unity. Retaining + // cancelling recursive terms lets fused multiply-add rounding excite the + // otherwise cancelled poles on ARM, even with every EQ gain at zero. + if (std::isfinite(coefficients[0]) && std::isfinite(coefficients[1]) + && std::isfinite(coefficients[2]) && std::abs(coefficients[3]) > 1.0e-12f + && coefficients[0] == coefficients[3] && coefficients[1] == coefficients[4] + && coefficients[2] == coefficients[5]) + return kIdentityBiquad; const float inverseA0 = std::abs(coefficients[3]) > 1.0e-12f ? 1.0f / coefficients[3] : 0.0f; diff --git a/docs/certification-0.1.09.md b/docs/certification-0.1.09.md index bda1f720..56de0e59 100644 --- a/docs/certification-0.1.09.md +++ b/docs/certification-0.1.09.md @@ -6,8 +6,8 @@ The user requested certification checks and a release PR, with publication held. ## Current evidence -The follow-up "Mac export/window qualification" below records the latest source -changes and the completed Windows certification at `dd92c765`. PR #36 retains +The follow-ups "Mac export/window qualification" and "Neutral EQ precision" +below record the latest source changes and artifact-specific results. PR #36 retains final-head CI links; earlier results remain tied to their listed artifacts. **Merge acceptance decision — 5 November 2026:** the user explicitly accepted the @@ -639,3 +639,43 @@ The accepted 8-sample failure remains **fail, non-blocking**; no new hardware measurement was made. Listening quality, physical devices, Apple notarization, Gatekeeper acceptance and Store approval remain **not_asserted**. Nothing was published, submitted or merged. + + +## Neutral EQ precision — 5 November 2026 + +At `f92fc430`, the [Intel Mac native job](https://github.com/sdevil7th/OpenStudio/actions/runs/41982480859/job/127097795850) +passed without C++ compiler warnings, including all seven OGG cases, native +Vorbis failure/cancellation contracts, window lifecycle and spectral export. +Apple Silicon passed 88 window checks and installed-DMG/Support/signature checks, +but its newly reached spectral-export assertion failed. Windows native/installer +qualification and [WACK](https://github.com/sdevil7th/OpenStudio/actions/runs/41982480859/job/127106661326) +passed; the [certification report](https://github.com/sdevil7th/OpenStudio/actions/runs/41982480859/artifacts/13396536108) +is retained. Linux package qualification passed. These results are tied to those +artifacts; they do not assert that a later binary has passed. + +`191d91c2` replaced the window harness's deprecated Linux frame-size API with the +current optional API. `85d7717c` retained the exact Mac candidate even when later +export tests fail and expanded the explicit spectral test's evidence. The +[Apple Silicon report](https://github.com/sdevil7th/OpenStudio/actions/runs/41986658088/artifacts/13398186415) +then identified the sole failing condition: neutral error +`2.447515726089478e-6` exceeded the unchanged `2e-6` limit. PDC and output lengths +passed, route error was `1.862645149230957e-9`, and active correction was +`0.03571418300271`. The current Linux build passed without C++ warnings. One +Windows browser test in that run failed before app startup because Chromium +could not fetch React (`ERR_NO_BUFFER_SPACE`); its trace identifies a runner +network-resource failure, not a batch-converter assertion failure. + +The neutral-EQ correction recognizes exactly equal numerator/denominator +polynomials during coefficient construction and uses exact identity coefficients. +This avoids exciting cancelled recursive poles through fused-math rounding. +Non-unity coefficient construction and the existing spectral error thresholds +are unchanged. A new export regression requires exact PCM equality with a +neutral default EQ. Local Release builds have zero C++ warnings; all 427 export +checks and six focused EQ suites (spectral, phase, minimum FIR, shapes, adaptive +dynamics and draft processing modes) pass. New Mac/Windows/Linux artifacts must +pass their own CI; final results are recorded on PR #36. + +Support remains inactive until explicitly opened. No recording-time diagnostics +were added. Physical device behavior and subjective audio quality remain +**not_asserted**; the accepted 8-sample hardware result remains **fail, +non-blocking**. No Store submission, notarization, release or publication occurred. diff --git a/docs/releases/0.1.09.md b/docs/releases/0.1.09.md index 2808b5dc..5d5318e5 100644 --- a/docs/releases/0.1.09.md +++ b/docs/releases/0.1.09.md @@ -25,6 +25,8 @@ development versions. No 0.1.09 release or Store update has been published. ## Fixes +- Preserve exact unity coefficients for neutral EQ bands, avoiding small + recursive rounding residuals on platforms that use fused floating-point math. - Encode OGG with the built-in Vorbis codec, preserving quality 0–10, instead of requiring a system FFmpeg build with the optional Vorbis encoder. Final sample-rate conversion retains the existing FFmpeg path. Encoding remains @@ -80,36 +82,24 @@ development versions. No 0.1.09 release or Store update has been published. at a past glitch, prove that every intended instrument was audible, or replace a physical signal reference. Redaction is best effort; inspect archives before sharing. Crash dumps are optional and can contain private information. -- Full Windows installer/MSIX packaging and installed-app checks passed; Linux - DEB installation and AppImage qualification passed. These are unpublished test - artifacts. Final Windows browser checks passed. The rebuilt MSIX passed - Windows App Certification Kit overall and all 13 required tests, including - the corrected Support DPI check; two optional findings remain in its report. - All four final Linux browser-test groups passed: 295 cases, 33 declared skips, - zero failures and zero retries. Microsoft Store approval remains - outstanding. A subsequently available Apple Silicon runner built the app and - reached frontend readiness in main/popout windows, but three window-geometry - checks failed; full DMG qualification did not complete. Native and universal - Support component checks passed on both architectures. Apple signing/ - notarization credentials are unavailable. Component passes do not certify - the full candidate. See the - [candidate qualification record](../certification-0.1.09.md). - Windows/Linux packages and WACK were requalified after the native NAM - preparation/insertion change at `bba16515`. A subsequent warning-only preview - initializer correction at `b6aba8ae` has passed Windows/Linux qualification - and its separate WACK check. Results apply to the exact - artifacts recorded in the qualification report. +- Package checks use unpublished Windows, Linux and macOS artifacts. Windows + App Certification Kit has passed overall and all 13 required tests; two + optional findings remain in its report. This is not Microsoft Store approval. + Mac candidate signing is ad-hoc: Developer ID/notarization credentials are + unavailable, and Gatekeeper acceptance is not asserted. Results apply only to + the exact artifacts recorded in the + [candidate qualification record](../certification-0.1.09.md) and linked PR checks; + earlier passes do not qualify subsequently changed binaries. - Automated render and MIDI checks establish objective behavior for their test inputs. They do not establish subjective sound quality or compatibility with every external plugin, interface or driver. ## Upgrade Notes -- These further support/diagnostic changes are source changes after `93d03c15`. - Earlier installer/WACK/Linux certification results do not qualify these new - binaries. Automatic timed dumps for running-session UI hangs are disabled in +- Use the symbol archive matching the exact app build when investigating an + incident. Automatic timed dumps for running-session UI hangs are disabled in ordinary builds; explicitly selected OS evidence or a diagnostic reproduction - is needed for those cases. No publication or new certification is claimed. + is needed for those cases. - Back up projects before opening them in this candidate. Existing project files remain supported; save a separate copy when testing new editing flows. The 0.1.08 plugin Undo corrections remain included.