From cb9a41c0fafe34cd79d8b7566df32d57d2c2b2b2 Mon Sep 17 00:00:00 2001 From: Soficis Date: Wed, 30 Sep 2026 18:59:18 -0500 Subject: [PATCH 01/68] fix(renderers): search AUTOMIX_ASSET_ROOT before default PhaseLimiter roots defaultRoots() collected candidates in a std::set, so roots were searched in lexicographic order rather than priority order. AUTOMIX_ASSET_ROOT only acted as an override when its path happened to sort before the executable's tree; from a checkout under C:\2AHOLD the repo's own bundled binary won and "PhaseLimiter discovery supports AUTOMIX_ASSET_ROOT override" failed. Roots are now an ordered vector (override first), deduplicated. Co-Authored-By: Claude Opus 5.5 --- src/renderers/PhaseLimiterDiscovery.cpp | 26 +++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/renderers/PhaseLimiterDiscovery.cpp b/src/renderers/PhaseLimiterDiscovery.cpp index 47bae99..643a514 100644 --- a/src/renderers/PhaseLimiterDiscovery.cpp +++ b/src/renderers/PhaseLimiterDiscovery.cpp @@ -439,22 +439,28 @@ std::optional resolveFromAutoDownload() { return std::nullopt; } +// Ordered by priority: AUTOMIX_ASSET_ROOT is an override, so it is searched +// first. (A std::set here once sorted roots lexicographically, which let the +// executable's own tree win whenever its path sorted before the override.) std::vector defaultRoots() { - std::set roots; - roots.insert(std::filesystem::current_path()); + std::vector candidates; + if (const auto assetRoot = readEnvironment("AUTOMIX_ASSET_ROOT"); assetRoot.has_value()) { + candidates.emplace_back(assetRoot.value()); + } + candidates.push_back(std::filesystem::current_path()); const juce::File executable = juce::File::getSpecialLocation(juce::File::currentExecutableFile); const std::filesystem::path executableDir(executable.getParentDirectory().getFullPathName().toStdString()); - roots.insert(executableDir); - roots.insert(executableDir / ".." / "Resources"); - if (const auto assetRoot = readEnvironment("AUTOMIX_ASSET_ROOT"); assetRoot.has_value()) { - roots.insert(assetRoot.value()); - } + candidates.push_back(executableDir); + candidates.push_back(executableDir / ".." / "Resources"); + std::set seen; std::vector output; - output.reserve(roots.size()); - for (const auto& root : roots) { - output.push_back(root); + output.reserve(candidates.size()); + for (const auto& root : candidates) { + if (seen.insert(root).second) { + output.push_back(root); + } } return output; } From 1b5610f076c3e5f819021c6cd57a7952e087031a Mon Sep 17 00:00:00 2001 From: Soficis Date: Wed, 30 Sep 2026 18:59:29 -0500 Subject: [PATCH 02/68] feat(ai): tensor inference and opt-in BS-RoFormer vocal separation Adds a tensor-graph inference path alongside the scalar IModelInference one, and wires an MIT-licensed BS-RoFormer ONNX export into single-mix import as an opt-in separator. Off by default; with the flag off, separation output is byte-identical (tested). - TensorTypes / ITensorInference / OnnxTensorInference: float32 tensor I/O with graph spec probing; ORT 1.30.0, CPU provider only. Without the native SDK every load fails with a diagnostic, never an approximation. - SpectrogramFrontEnd: torch.stft-compatible STFT/iSTFT (golden fixture). - SeparationRunner: chunked overlap-add, mask/direct output modes, residual stems (instrumental = mix - vocals), all-or-nothing on failure. - ModelPack tensor_contract (optional, no schema bump) with load-time checks that name the tensor and both shapes on mismatch. - Catalog: xycld/BS-RoFormer-ONNX, pinned to the single-file quantized build (the alphabetically-first .onnx is an fp32 stub that needs a 640 MB sidecar). Install probes the graph and writes real tensor names. MIT: attribution in NOTICE and the licensing audit, no consent prompt. - RenderSettings::tensorSeparationEnabled (default false) -> ImportController -> StemSeparator. Any tensor failure falls back to the existing separator and says why in the log. - Latent fixes reached by the ON build: EP header glob for the flat release zip, EnableProfiling wchar_t path, corrupt model no longer recorded as a failed cpu provider. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 11 +- NOTICE | 128 + README.md | 8 +- docs/model-licensing-audit.json | 127 + src/ai/BsRoformerPack.h | 47 + src/ai/GitHubReleaseModelHub.cpp | 2 +- src/ai/HuggingFaceModelHub.cpp | 33 +- src/ai/HuggingFaceModelHub.h | 7 + src/ai/ITensorInference.h | 50 + src/ai/ModelCatalogValidator.cpp | 43 + src/ai/ModelCatalogValidator.h | 11 + src/ai/ModelPackLoader.cpp | 346 ++- src/ai/ModelPackLoader.h | 64 + src/ai/OnnxModelInference.cpp | 27 +- src/ai/OnnxModelInference.h | 6 + src/ai/OnnxTensorInference.cpp | 284 +++ src/ai/OnnxTensorInference.h | 48 + src/ai/SeparationRunner.cpp | 368 +++ src/ai/SeparationRunner.h | 82 + src/ai/StemSeparator.cpp | 123 +- src/ai/StemSeparator.h | 12 + src/ai/TensorTypes.cpp | 56 + src/ai/TensorTypes.h | 48 + src/analysis/SpectrogramFrontEnd.cpp | 251 ++ src/analysis/SpectrogramFrontEnd.h | 61 + src/app/controllers/ImportController.cpp | 21 +- src/app/controllers/ImportController.h | 3 +- src/app/ui/MainLayout.cpp | 3 +- src/domain/RenderSettings.h | 4 + tests/fixtures/tensor/external_data.onnx | Bin 0 -> 179 bytes tests/fixtures/tensor/external_data.onnx.data | Bin 0 -> 16 bytes tests/fixtures/tensor/identity_mask.onnx | Bin 0 -> 440 bytes tests/fixtures/tensor/int64_io.onnx | Bin 0 -> 126 bytes tests/fixtures/tensor/make_fixtures.py | 77 + tests/unit/GpuBenchmarkTests.cpp | 5 + tests/unit/TensorInferenceTests.cpp | 986 ++++++++ tests/unit/TorchStftGolden.h | 2128 +++++++++++++++++ 37 files changed, 5449 insertions(+), 21 deletions(-) create mode 100644 NOTICE create mode 100644 docs/model-licensing-audit.json create mode 100644 src/ai/BsRoformerPack.h create mode 100644 src/ai/ITensorInference.h create mode 100644 src/ai/OnnxTensorInference.cpp create mode 100644 src/ai/OnnxTensorInference.h create mode 100644 src/ai/SeparationRunner.cpp create mode 100644 src/ai/SeparationRunner.h create mode 100644 src/ai/TensorTypes.cpp create mode 100644 src/ai/TensorTypes.h create mode 100644 src/analysis/SpectrogramFrontEnd.cpp create mode 100644 src/analysis/SpectrogramFrontEnd.h create mode 100644 tests/fixtures/tensor/external_data.onnx create mode 100644 tests/fixtures/tensor/external_data.onnx.data create mode 100644 tests/fixtures/tensor/identity_mask.onnx create mode 100644 tests/fixtures/tensor/int64_io.onnx create mode 100644 tests/fixtures/tensor/make_fixtures.py create mode 100644 tests/unit/TensorInferenceTests.cpp create mode 100644 tests/unit/TorchStftGolden.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 3cac42c..7c495cc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -108,6 +108,7 @@ add_library(automix_core src/analysis/ArtifactRiskEstimator.cpp src/analysis/StemHealthAssistant.cpp src/analysis/StemAnalyzer.cpp + src/analysis/SpectrogramFrontEnd.cpp src/automix/HeuristicAutoMixStrategy.cpp src/automaster/HeuristicAutoMasterStrategy.cpp src/automaster/ItoMasterStrategy.cpp @@ -144,6 +145,9 @@ add_library(automix_core src/ai/GpuCapabilityDetector.cpp src/ai/StemSeparator.cpp src/ai/StemRoleClassifierAI.cpp + src/ai/TensorTypes.cpp + src/ai/SeparationRunner.cpp + src/ai/OnnxTensorInference.cpp src/util/StringUtils.cpp src/util/LameDownloader.cpp src/util/MetadataPolicy.cpp @@ -170,7 +174,11 @@ if(ENABLE_ONNX) # the headers. ONNX Runtime does not expose a reliable single version macro, and # a header either declares an entry point or it does not, which is exact where a # parsed version is only a guess. - file(GLOB _ort_api_headers "${ONNXRUNTIME_INCLUDE_DIR}/onnxruntime/core/session/onnxruntime_c*_api.h") + # The official release archives ship a flat include/ directory; source-tree + # installs nest the same headers under onnxruntime/core/session/. + file(GLOB _ort_api_headers + "${ONNXRUNTIME_INCLUDE_DIR}/onnxruntime_c*_api.h" + "${ONNXRUNTIME_INCLUDE_DIR}/onnxruntime/core/session/onnxruntime_c*_api.h") foreach(_ort_header IN LISTS _ort_api_headers) file(STRINGS "${_ort_header}" _ort_header_text) if(_ort_header_text MATCHES "RegisterExecutionProviderLibrary") @@ -401,6 +409,7 @@ if(BUILD_TESTING) tests/unit/OfflineRenderPipelineTests.cpp tests/unit/OnnxModelInferenceTests.cpp tests/unit/StemSeparatorTests.cpp + tests/unit/TensorInferenceTests.cpp tests/unit/ModelBenchmarkTests.cpp tests/unit/GpuBenchmarkTests.cpp tests/regression/RegressionHarness.cpp diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..34ec6f1 --- /dev/null +++ b/NOTICE @@ -0,0 +1,128 @@ +AutoMixMaster — NOTICE +====================== + +This file records third-party material that AutoMixMaster can use, and the +terms it is offered under. It is maintained by hand alongside +docs/model-licensing-audit.json, which is the machine-readable manifest that +tests assert against. + + +1. AutoMixMaster itself +----------------------- + +AutoMixMaster is licensed under the GNU General Public License, version 3 +(GPLv3). The GPLv3 notice is reproduced in the LICENSE file at the root of the +source distribution. + + +2. No model weights are distributed with this software +----------------------------------------------------- + +AutoMixMaster ships NO model weights. No trained .onnx, .pt, .pth, +.safetensors or .ckpt file is committed to the repository or bundled into a +release build, and .gitignore excludes the model-hub install directories. + +The only .onnx files in the repository are the synthetic test graphs under +tests/fixtures/tensor/ (a few hundred bytes each). They contain no trained +weights - only fixed arithmetic such as an identity mask - and are regenerated +by tests/fixtures/tensor/make_fixtures.py. + +Every model listed below is downloaded at runtime, by the user, directly from +the publisher's own distribution point — a Hugging Face repository or a GitHub +release. AutoMixMaster does not mirror, re-host, or redistribute any of them. + +This separation is deliberate and load-bearing: under GPLv3 section 5, an +aggregate formed by a GPL-licensed program together with separately-licensed +material is not itself a single combined work. Because the weights are never +part of the distributed GPL work, a non-commercial or otherwise +restrictive weight licence does not retroactively restrict the licence of the +application, and equally does not relicense the weights. Users are +responsible for their own compliance with each weight's terms. + + +3. Attribution for the curated model catalogue +---------------------------------------------- + +These are the repositories AutoMixMaster offers in its curated catalog. The +"Terms" column reproduces the license reported by the publisher's model card as +of the audit date in docs/model-licensing-audit.json. + + Repository Terms Consent + ------------------------------------------------------ -------------- ------- + rysertio/Demucs-onnx MIT no + onnx-community/whisper-tiny.en MIT no + onnx-community/whisper-small.en MIT no + onnx-community/Musical-Instrument-Classification-ONNX MIT no + onnx-community/Speech-Emotion-Classification-ONNX UNVERIFIED YES + onnx-community/Musical-genres-Classification-Hubert-V1-ONNX UNVERIFIED YES + openai/whisper-tiny MIT no + laion/clap-htsat-unfused CC BY 4.0 no + pranjal-pravesh/PANNs_CNN14_ONNX MIT no + StemSplitio/htdemucs-ft-onnx MIT no + StemSplitio/htdemucs-6s-onnx MIT no + SonyCSLParis/music2latent CC BY-NC 4.0 YES + kramp/ito-master-onnx CC BY-NC 4.0 YES + xycld/BS-RoFormer-ONNX MIT no + + GitHub release sources, terms not yet verified: + smartdaze/otowake-oto (htdemucs_6s.onnx) + instant-high/Resemble-Denoiser-ONNX (denoiser_fp16.onnx) + + +4. How "Consent: YES" behaves in the application +----------------------------------------------- + +A model marked "Consent: YES" is refused download and refused activation until +the user explicitly acknowledges its terms. The acknowledgement is recorded in +license_consents.json inside the model-hub directory, together with the model +id, repository, license identifier, license URL, attribution string and a UTC +timestamp. + +The consent decision is licence-driven rather than a fixed list: any model +whose reported license is non-commercial (CC BY-NC, CC BY-NC-SA, and the +like), or which declares no license at all, requires the same acknowledgement. +An undeclared license is treated as requiring consent because permitted use +cannot be established from it, and research-only use is the only defensible +default until the publisher clarifies. + +Where a publisher's terms conflict — for example laion/clap-htsat-unfused, +whose model card lists apache-2.0 while the LAION research repository is +CC BY 4.0 — the more restrictive reading should be assumed until the publisher +resolves it. + + +5. Known attribution requirements +--------------------------------- + + laion/clap-htsat-unfused (CC BY 4.0) + Attribution required. Credit LAION-AI / CLAP and link the licence: + https://creativecommons.org/licenses/by/4.0/ + + kramp/ito-master-onnx (CC BY-NC 4.0) + Non-commercial use only. Derived from ITO-Master by Sony + Research Center. Attribution required: + https://creativecommons.org/licenses/by-nc/4.0/ + + SonyCSLParis/music2latent (CC BY-NC 4.0) + Non-commercial use only. Attribution required: + https://creativecommons.org/licenses/by-nc/4.0/ + + xycld/BS-RoFormer-ONNX (MIT) + BS-RoFormer (Lu et al., arXiv 2309.02612), ONNX export by xycld. + https://opensource.org/license/mit + The catalog installs the uint8-quantized build; its quality relative + to the fp32 build has not been measured. The model separates vocals + only: the instrumental stem is the residual (mix - vocals), not a + second separation. + + +6. Third-party build dependencies +--------------------------------- + +The application links against ONNX Runtime (MIT), JUCE (AGPLv3 or +commercial), libebur128 (MIT) and nlohmann/json (MIT). Full third-party +dependency details are listed in docs/third_party_registry.json. + +Note that ONNX Runtime is an OPTIONAL dependency. Builds made without it fall +back to a deterministic adapter and model-backed features degrade to +heuristics rather than failing. diff --git a/README.md b/README.md index aa0a25f..c3f8415 100644 --- a/README.md +++ b/README.md @@ -400,9 +400,9 @@ Two consequences worth knowing before authoring a pack: | `role_classifier` | 66 floats per stem | `prob_vocals`, `prob_bass`, `prob_drums`, `prob_fx` | `StemRoleClassifierAI` | | `stem_separation` | per-4096-sample-frame feature vector | `stem_weight` \| `source_weight` \| `mask_` \| `_weight` | `StemSeparator` | | `mix_master_override` | all stems' features, concatenated | `dryWet`, `targetLufs`, `preGainDb` (legacy) | `ModelStrategy` | -| `ito_fxencoder`, `ito_predictor` | audio tensors `[1,2,N]` → `[1,2048]` → `[1,46]` | 46 normalized chain parameters | `ItoMasterModelRunner` | +| `ito_fxencoder`, `ito_predictor` | the first N stereo samples flattened channel-major into one `features` vector (encoder); the same vector with the encoder's 2048 outputs appended to it (predictor) — fed positionally, never bound by name | 2048-dim embedding, then 46 normalized chain parameters | `ItoMasterModelRunner` | -**Consequence: a model whose input is raw audio, a complex STFT, or a multi-tensor bundle cannot be used through this interface.** That excludes essentially the whole published audio ecosystem — Demucs/HTDemucs, BS-Roformer and Mel-Band Roformer, Open-Unmix, Spleeter, Whisper, CLAP, PANNs, CED, Basic Pitch, CREPE, skey, beat-this, chordmini — regardless of license. Installing one yields a pack that validates and downloads, then either fails the `features.size() != input_feature_count` check or receives a feature vector where it expects audio. +**Consequence: a model whose output is an audio-shaped tensor, or whose input needs audio semantics, cannot be used through this interface.** The wall is not the number of graph inputs — `xycld/BS-RoFormer-ONNX`, for example, has exactly one input and one output. It is two things the contract has no words for: (1) **output rank and volume** — BS-RoFormer returns a rank-5 `[1, 1, 2050, 801, 2]` float tensor (~3.3 M values), and `InferenceResult::outputs` is a `map` that cannot carry a tensor at any rank; (2) **audio semantics on the way in** — `features` is a flat vector with no shape, no axis meaning, no channel identity, no phase and no STFT front-end, so there is no way to say "801 frames × 1025 bins × 2 channels × real/imag". That excludes essentially the whole published audio ecosystem — Demucs/HTDemucs, BS-Roformer and Mel-Band Roformer, Open-Unmix, Spleeter, Whisper, CLAP, PANNs, CED, Basic Pitch, CREPE, skey, beat-this, chordmini — regardless of license. Installing one yields a pack that validates and downloads, then either fails the `features.size() != input_feature_count` check or receives a feature vector where it expects audio. This applies to the three **already-curated** separation models (`rysertio/Demucs-onnx`, `StemSplitio/htdemucs-ft-onnx`, `StemSplitio/htdemucs-6s-onnx`): the separator feeds them a per-frame feature vector and reads back per-stem weights, so with no weight key in the response it applies its own heuristic. `StemSeparator` now reports that case honestly — `SeparationResult::usedModel` is `false` and the log says the fallback weights were used — rather than claiming "Model-backed overlap-add separation completed". @@ -410,13 +410,13 @@ The verified-later candidates below are held back by that single missing fronten | Model | License | Why it is not curated yet | | :--- | :--- | :--- | -| `xycld/BS-RoFormer-ONNX` | MIT | consumes a complex STFT; needs a tensor-level audio input | +| `xycld/BS-RoFormer-ONNX` | MIT | emits a real-valued rank-5 mask tensor (real/imag on the trailing axis) that the caller multiplies against a host-side STFT; needs a tensor-level interface and an STFT front-end | | `musetric/skey-onnx` | MIT | expects 22.05 kHz audio, not the 66-float vector | | `musetric/chordmini-onnx` | MIT | expects a 144-bin log-CQT the host does not compute | | `musetric/beat-this-onnx` | MIT | expects a 128-bin log-mel the host does not compute | | `mispeech/ced-base` | Apache-2.0 | expects 16 kHz waveform input | | Basic Pitch `nmp.onnx` | Apache-2.0 | expects a 43844-sample CQT input | -**The unblock is one interface, not a bigger catalog.** `ItoMasterModelRunner` already drives a real audio→audio→parameters graph in-process, so the pattern is proven; generalising it into a tensor-level audio interface (multi-input/multi-output tensors alongside `IModelInference`) is what would make the entire download ecosystem reachable, and is the reason adding more curated ids before then only adds download size. +**The unblock is one interface, not a bigger catalog.** `ItoMasterModelRunner` already drives a real audio→audio→parameters graph in-process, so the pattern is proven; generalising it into a tensor-level audio interface (shaped, named float32 tensors in and out, plus a host-side STFT, alongside `IModelInference`) is what would make the entire download ecosystem reachable, and is the reason adding more curated ids before then only adds download size. diff --git a/docs/model-licensing-audit.json b/docs/model-licensing-audit.json new file mode 100644 index 0000000..0e444a2 --- /dev/null +++ b/docs/model-licensing-audit.json @@ -0,0 +1,127 @@ +{ + "schemaVersion": 1, + "models": [ + { + "id": "rysertio/Demucs-onnx", + "license": "MIT", + "commercialUsable": true, + "attributionRequired": false, + "flagged": false, + "notes": "Demucs ONNX port; MIT license." + }, + { + "id": "onnx-community/whisper-tiny.en", + "license": "MIT", + "commercialUsable": true, + "attributionRequired": false, + "flagged": false, + "notes": "onnx-community export of OpenAI Whisper tiny.en; MIT license inherited." + }, + { + "id": "onnx-community/whisper-small.en", + "license": "MIT", + "commercialUsable": true, + "attributionRequired": false, + "flagged": false, + "notes": "onnx-community export of OpenAI Whisper small.en; MIT license inherited." + }, + { + "id": "onnx-community/Speech-Emotion-Classification-ONNX", + "license": "unverified", + "commercialUsable": false, + "attributionRequired": false, + "flagged": true, + "notes": "No license declared on HF card (checked 2026-07-31); wav2vec2-based, base prithivMLmods/Speech-Emotion-Classification. Verify before commercial ship." + }, + { + "id": "onnx-community/Musical-Instrument-Classification-ONNX", + "license": "MIT", + "commercialUsable": true, + "attributionRequired": false, + "flagged": false, + "notes": "HF card declares license:mit (checked 2026-07-31)." + }, + { + "id": "onnx-community/Musical-genres-Classification-Hubert-V1-ONNX", + "license": "unverified", + "commercialUsable": false, + "attributionRequired": false, + "flagged": true, + "notes": "No license declared on HF card (checked 2026-07-31); HuBERT-derived, base SeyedAli/Musical-genres-Classification-Hubert-V1. onnx-community HuBERT re-distributions are typically CC BY-NC 4.0; verify before commercial ship." + }, + { + "id": "openai/whisper-tiny", + "license": "MIT", + "commercialUsable": true, + "attributionRequired": false, + "flagged": false, + "notes": "OpenAI Whisper tiny; MIT license." + }, + { + "id": "laion/clap-htsat-unfused", + "license": "CC BY 4.0", + "commercialUsable": true, + "attributionRequired": true, + "flagged": true, + "notes": "CC BY 4.0 per LAION research repo; HF card metadata currently lists apache-2.0 (checked 2026-07-31). Attribution required; commercially usable with attribution." + }, + { + "id": "pranjal-pravesh/PANNs_CNN14_ONNX", + "license": "MIT", + "commercialUsable": true, + "attributionRequired": false, + "flagged": false, + "notes": "PANNs CNN14 ONNX port; MIT license." + }, + { + "id": "SonyCSLParis/music2latent", + "license": "CC BY-NC 4.0", + "commercialUsable": false, + "attributionRequired": true, + "flagged": true, + "notes": "Non-commercial; user opt-in download only, never bundled in a commercial installer." + }, + { + "id": "StemSplitio/htdemucs-ft-onnx", + "license": "MIT", + "commercialUsable": true, + "attributionRequired": false, + "flagged": false, + "notes": "Demucs 4-stem ONNX port; MIT license." + }, + { + "id": "StemSplitio/htdemucs-6s-onnx", + "license": "MIT", + "commercialUsable": true, + "attributionRequired": false, + "flagged": false, + "notes": "Demucs 6-stem ONNX port; MIT license." + }, + { + "id": "kramp/ito-master-onnx", + "license": "CC BY-NC 4.0", + "commercialUsable": false, + "attributionRequired": true, + "flagged": true, + "assets": [ + "fxencoder.onnx", + "mastering_tcn.onnx", + "config.json" + ], + "attribution": "ITO-Master, Koo et al., Sony Research (github.com/SonyResearch/ITO-Master); ONNX export by kramp (huggingface.co/kramp/ito-master-onnx)", + "notes": "ITO-Master ONNX; CC BY-NC 4.0 confirmed on HF card (checked 2026-07-31). Multi-file pack (fxencoder.onnx + mastering_tcn.onnx + config.json) consumed by the mastering-assistant route. User opt-in download only, never bundled in a commercial installer/redistribution; runtime hub download under the user's license keeps it legal." + }, + { + "id": "xycld/BS-RoFormer-ONNX", + "license": "MIT", + "commercialUsable": true, + "attributionRequired": false, + "flagged": false, + "assets": [ + "bs_roformer_ep317_sdr12.9755_quantized_uint8.onnx" + ], + "attribution": "BS-RoFormer, Lu et al., arXiv 2309.02612; ONNX export by xycld (huggingface.co/xycld/BS-RoFormer-ONNX)", + "notes": "BS-RoFormer vocal separation ONNX; MIT confirmed on HF card (cardData.license and license:mit tag, checked 2026-09-30). Catalog installs the single-file uint8-quantized build (~166 MB); the fp32 build needs a ~640 MB external-data sidecar and is not the default. Graph outputs vocals only; instrumental is the residual mix - vocals. Download-only, never bundled." + } + ] +} diff --git a/src/ai/BsRoformerPack.h b/src/ai/BsRoformerPack.h new file mode 100644 index 0000000..8e3d9be --- /dev/null +++ b/src/ai/BsRoformerPack.h @@ -0,0 +1,47 @@ +#pragma once + +#include "ai/ModelPackLoader.h" + +namespace automix::ai { + +// BS-RoFormer vocal separation (arXiv 2309.02612), ONNX export by xycld. MIT, +// download-only; attribution lives in NOTICE. +inline constexpr const char* kBsRoformerRepoId = "xycld/BS-RoFormer-ONNX"; +// Spec D4: the catalog installs the single-file uint8-quantized build. The repo's +// alphabetically-first .onnx is the fp32 graph, which is unusable without its +// ~640 MB external-data sidecar, so the primary file is pinned by name. +inline constexpr const char* kBsRoformerQuantizedFile = "bs_roformer_ep317_sdr12.9755_quantized_uint8.onnx"; +inline constexpr const char* kBsRoformerIntendedUse = + "Vocal separation (BS-RoFormer). The graph produces vocals only; the instrumental stem is the " + "residual mix - vocals, not a second separation. Quantized build: quality versus fp32 is not " + "yet measured."; + +// Catalog form of the pack's tensor contract (spec section 6). Tensor names are +// omitted because the repo does not publish them; the install-time probe fills +// them in, and until then checkTensorContract() matches positionally. +inline TensorContract bsRoformerCatalogContract() { + TensorContract contract; + contract.engine = "bs_roformer"; + contract.sampleRate = 44100; + contract.stereo = true; + contract.chunkSamples = 352800; + contract.overlapSamples = 88200; + contract.stft.nFft = 2048; + contract.stft.hopLength = 441; + contract.stft.winLength = 2048; + contract.stft.window = "hann"; + contract.stft.periodicWindow = true; + contract.stft.center = true; + contract.stft.padMode = "reflect"; + contract.stft.normalized = false; + contract.stft.zeroDc = true; + contract.inputLayout = "folded_stereo"; + contract.inputs = {{"", {1, 801, 4100}, "float32"}}; + contract.outputs = {{"", {1, 1, 2050, 801, 2}, "float32"}}; + contract.outputMode = "mask"; + contract.targetStem = "vocals"; + contract.stems = {{"vocals", ""}, {"instrumental", "vocals"}}; + return contract; +} + +} // namespace automix::ai diff --git a/src/ai/GitHubReleaseModelHub.cpp b/src/ai/GitHubReleaseModelHub.cpp index e5de8f1..e231d42 100644 --- a/src/ai/GitHubReleaseModelHub.cpp +++ b/src/ai/GitHubReleaseModelHub.cpp @@ -700,7 +700,7 @@ HubInstallResult GitHubReleaseModelHub::installModel(const std::string& modelId, writeJson(result.metadataPath, metadata); std::string manifestError; - if (!writeTurnkeyModelPackManifest(installPath, info, result, compatibility, &manifestError)) { + if (!writeTurnkeyModelPackManifest(installPath, info, result, compatibility, nullptr, &manifestError)) { std::filesystem::remove(primaryPath, error); result.message = "Failed writing turnkey model pack metadata: " + manifestError; appendInstallLog(destinationRoot, info, result); diff --git a/src/ai/HuggingFaceModelHub.cpp b/src/ai/HuggingFaceModelHub.cpp index 51c0a1f..ef0aa0a 100644 --- a/src/ai/HuggingFaceModelHub.cpp +++ b/src/ai/HuggingFaceModelHub.cpp @@ -13,8 +13,10 @@ #include #include +#include "ai/BsRoformerPack.h" #include "ai/ItoMasterAdapter.h" #include "ai/ModelCatalogValidator.h" +#include "ai/OnnxTensorInference.h" #include "util/Sha256.h" #include "util/StringUtils.h" @@ -453,6 +455,18 @@ void appendInstallLog(const std::filesystem::path& root, out << event.dump() << "\n"; } +std::string primaryFileForRepo(const std::string& repoId, const std::vector& files, bool* hasOnnxOut) { + auto primary = pickPrimaryFile(files, hasOnnxOut); + if (repoId == kBsRoformerRepoId) { + // Pinned by name (see kBsRoformerQuantizedFile). If the repo ever drops the + // file, the entry becomes undiscoverable instead of installing the fp32 + // graph without its sidecar. + const bool hasQuantized = std::find(files.begin(), files.end(), kBsRoformerQuantizedFile) != files.end(); + primary = hasQuantized ? kBsRoformerQuantizedFile : ""; + } + return primary; +} + std::vector curatedModelIds() { return { "rysertio/Demucs-onnx", @@ -468,6 +482,7 @@ std::vector curatedModelIds() { "StemSplitio/htdemucs-ft-onnx", "StemSplitio/htdemucs-6s-onnx", "kramp/ito-master-onnx", + "xycld/BS-RoFormer-ONNX", // == kBsRoformerRepoId; a literal because licensing tests parse this list }; } @@ -586,7 +601,7 @@ std::optional HuggingFaceModelHub::modelInfo(const std::string& mo } } - info.primaryFile = pickPrimaryFile(info.files, &info.hasOnnx); + info.primaryFile = primaryFileForRepo(info.repoId, info.files, &info.hasOnnx); info.useCase = HuggingFaceModelHub::inferUseCase(info.repoId, info.tags, ""); const auto compatibility = validateCatalogModel(info); info.compatible = compatibility.compatible; @@ -869,8 +884,22 @@ HubInstallResult HuggingFaceModelHub::installModel(const std::string& modelIdOrR result.metadataPath = installPath / "modelhub.json"; writeJson(result.metadataPath, metadata); + std::optional tensorContract; + if (info->repoId == kBsRoformerRepoId) { + std::string probeError; + tensorContract = resolveInstalledTensorContract(bsRoformerCatalogContract(), primaryPath, probeError); + if (!tensorContract.has_value()) { + std::filesystem::remove(primaryPath, error); + result.message = "Downloaded model does not match its tensor contract: " + probeError; + appendInstallLog(destinationRoot, info.value(), result); + return result; + } + } + std::string manifestError; - if (!writeTurnkeyModelPackManifest(installPath, info.value(), result, compatibility, &manifestError)) { + if (!writeTurnkeyModelPackManifest(installPath, info.value(), result, compatibility, + tensorContract.has_value() ? &tensorContract.value() : nullptr, + &manifestError)) { std::filesystem::remove(primaryPath, error); result.message = "Failed writing turnkey model pack metadata: " + manifestError; appendInstallLog(destinationRoot, info.value(), result); diff --git a/src/ai/HuggingFaceModelHub.h b/src/ai/HuggingFaceModelHub.h index 2878ab5..76c6f8e 100644 --- a/src/ai/HuggingFaceModelHub.h +++ b/src/ai/HuggingFaceModelHub.h @@ -92,4 +92,11 @@ class HuggingFaceModelHub { // license-coverage tests and downstream hub tooling. std::vector curatedModelIds(); +// The repo file an install downloads as the pack's model file; empty when the +// repo offers nothing installable. Generic preference order, except for repos +// whose correct file cannot be inferred from names (BS-RoFormer, spec D4). +std::string primaryFileForRepo(const std::string& repoId, + const std::vector& files, + bool* hasOnnxOut = nullptr); + } // namespace automix::ai diff --git a/src/ai/ITensorInference.h b/src/ai/ITensorInference.h new file mode 100644 index 0000000..27780f4 --- /dev/null +++ b/src/ai/ITensorInference.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include +#include + +#include "ai/TensorTypes.h" + +namespace automix::ai { + +// Sibling of IModelInference for graphs whose inputs and outputs are shaped +// float32 tensors rather than a feature vector and named scalars. The spec +// probes (inputSpecs/outputSpecs) are the load-bearing addition: tensor names +// are frequently unpublished, so binding by name requires reading them back +// from the loaded graph. +class ITensorInference { + public: + virtual ~ITensorInference() = default; + + virtual bool isAvailable() const = 0; + virtual bool loadModel(const std::filesystem::path& modelPath) = 0; + virtual std::vector inputSpecs() const = 0; + virtual std::vector outputSpecs() const = 0; + virtual TensorInferenceResult run(const std::vector& inputs) const = 0; +}; + +class NullTensorInference final : public ITensorInference { + public: + bool isAvailable() const override { return false; } + bool loadModel(const std::filesystem::path&) override { + lastLog_ = "NullTensorInference: no tensor backend is configured."; + return false; + } + std::vector inputSpecs() const override { return {}; } + std::vector outputSpecs() const override { return {}; } + TensorInferenceResult run(const std::vector& inputs) const override { + TensorInferenceResult result; + result.usedModel = false; + result.logMessage = "NullTensorInference: skipped run with " + std::to_string(inputs.size()) + + " input binding(s) (no model loaded)."; + return result; + } + + const std::string& lastLog() const { return lastLog_; } + + private: + mutable std::string lastLog_; +}; + +} // namespace automix::ai diff --git a/src/ai/ModelCatalogValidator.cpp b/src/ai/ModelCatalogValidator.cpp index 881c106..274f715 100644 --- a/src/ai/ModelCatalogValidator.cpp +++ b/src/ai/ModelCatalogValidator.cpp @@ -7,9 +7,15 @@ #include +#include "ai/BsRoformerPack.h" #include "ai/ItoMasterAdapter.h" +#include "ai/OnnxTensorInference.h" #include "util/StringUtils.h" +#ifndef AUTOMIX_HAS_NATIVE_ORT +#define AUTOMIX_HAS_NATIVE_ORT 0 +#endif + namespace automix::ai { namespace { @@ -168,10 +174,40 @@ std::string normalizeModelIdForPack(const std::string& modelId) { return sanitizePackId(toLower(modelId)); } +std::optional resolveInstalledTensorContract(const TensorContract& catalogContract, + const std::filesystem::path& modelPath, + std::string& errorOut) { +#if AUTOMIX_HAS_NATIVE_ORT + OnnxTensorInference inference; + inference.setTensorContract(catalogContract); + if (!inference.loadModel(modelPath)) { + errorOut = inference.backendDiagnostics(); + return std::nullopt; + } + // loadModel() ran checkTensorContract(), so the probed and declared lists + // agree in count and position. + auto installed = catalogContract; + const auto inputs = inference.inputSpecs(); + const auto outputs = inference.outputSpecs(); + for (std::size_t i = 0; i < installed.inputs.size() && i < inputs.size(); ++i) { + installed.inputs[i].name = inputs[i].name; + } + for (std::size_t i = 0; i < installed.outputs.size() && i < outputs.size(); ++i) { + installed.outputs[i].name = outputs[i].name; + } + return installed; +#else + (void)modelPath; + (void)errorOut; + return catalogContract; +#endif +} + bool writeTurnkeyModelPackManifest(const std::filesystem::path& installPath, const HubModelInfo& model, const HubInstallResult& installResult, const ModelCompatibilityResult& compatibility, + const TensorContract* tensorContract, std::string* errorOut) { if (!compatibility.compatible) { if (errorOut != nullptr) { @@ -223,11 +259,18 @@ bool writeTurnkeyModelPackManifest(const std::filesystem::path& installPath, {"output_names", nlohmann::json::array()}, }; + if (tensorContract != nullptr) { + manifest["tensor_contract"] = tensorContractToJson(*tensorContract); + } + if (model.repoId == kItoMasterRepoId) { manifest["intended_use"] = std::string("Experimental ITO-Master AI mastering route (non-default, off by default). ") + "License: " + kItoMasterLicense + ". Attribution: " + kItoMasterAttribution; } + if (model.repoId == kBsRoformerRepoId) { + manifest["intended_use"] = kBsRoformerIntendedUse; + } const auto manifestPath = installPath / "model.json"; std::ofstream out(manifestPath); diff --git a/src/ai/ModelCatalogValidator.h b/src/ai/ModelCatalogValidator.h index 03ce69c..26f9705 100644 --- a/src/ai/ModelCatalogValidator.h +++ b/src/ai/ModelCatalogValidator.h @@ -1,10 +1,12 @@ #pragma once #include +#include #include #include #include "ai/HuggingFaceModelHub.h" +#include "ai/ModelPackLoader.h" namespace automix::ai { @@ -20,10 +22,19 @@ struct ModelCompatibilityResult { std::string inferTaskScope(const HubModelInfo& model); ModelCompatibilityResult validateCatalogModel(const HubModelInfo& model); std::string normalizeModelIdForPack(const std::string& modelId); +// Installed form of a catalog tensor contract: loads the downloaded graph +// against the contract and writes the graph's real tensor names in. Without +// native ONNX Runtime nothing can be probed, so the names stay omitted and +// load-time checks match positionally. With it, a graph that does not satisfy +// the contract fails here (errorOut names the mismatch) rather than at import. +std::optional resolveInstalledTensorContract(const TensorContract& catalogContract, + const std::filesystem::path& modelPath, + std::string& errorOut); bool writeTurnkeyModelPackManifest(const std::filesystem::path& installPath, const HubModelInfo& model, const HubInstallResult& installResult, const ModelCompatibilityResult& compatibility, + const TensorContract* tensorContract, std::string* errorOut); } // namespace automix::ai diff --git a/src/ai/ModelPackLoader.cpp b/src/ai/ModelPackLoader.cpp index 477ec70..00ea9db 100644 --- a/src/ai/ModelPackLoader.cpp +++ b/src/ai/ModelPackLoader.cpp @@ -2,11 +2,13 @@ #include #include +#include #include #include #include "ai/FeatureSchema.h" +#include "analysis/SpectrogramFrontEnd.h" #include "util/HashUtils.h" #include "util/Sha256.h" #include "util/StringUtils.h" @@ -113,8 +115,338 @@ bool hasRequiredOutputKeysForScope(const std::string& scope, const std::vector +bool readRequired(const nlohmann::json& object, const char* key, T& out, const std::string& where, std::string& errorOut) { + if (!object.contains(key)) { + errorOut = where + " is missing required field '" + key + "'"; + return false; + } + try { + out = object.at(key).get(); + } catch (const nlohmann::json::exception&) { + errorOut = where + " field '" + key + "' has the wrong type"; + return false; + } + return true; +} + +bool parseIoList(const nlohmann::json& block, + const char* key, + std::vector& out, + std::string& errorOut) { + const std::string where = std::string("tensor_contract.") + key; + if (!block.contains(key) || !block.at(key).is_array()) { + errorOut = where + " must be an array"; + return false; + } + for (const auto& entry : block.at(key)) { + TensorContract::Io io; + if (!entry.is_object()) { + errorOut = where + " entries must be objects"; + return false; + } + if (entry.contains("name")) { + if (!entry.at("name").is_string()) { + errorOut = where + " name must be a string"; + return false; + } + io.name = entry.at("name").get(); + } + if (!readRequired(entry, "shape", io.shape, where, errorOut) || + !readRequired(entry, "dtype", io.dtype, where, errorOut)) { + return false; + } + out.push_back(std::move(io)); + } + return true; +} + +// Can a graph whose probed dims are `probed` accept/produce `declared`? A +// dynamic probed axis accepts any declared extent. +bool graphAccepts(const std::vector& probed, const std::vector& declared) { + if (probed.size() != declared.size()) { + return false; + } + for (std::size_t axis = 0; axis < probed.size(); ++axis) { + if (probed[axis] != -1 && declared[axis] != -1 && probed[axis] != declared[axis]) { + return false; + } + } + return true; +} + +bool checkProbedSide(const char* side, + const std::vector& declared, + const std::vector& probed, + std::string& errorOut) { + const bool namesDeclared = std::any_of(declared.begin(), declared.end(), [](const auto& io) { return !io.name.empty(); }); + if (namesDeclared) { + std::set declaredNames; + std::set probedNames; + for (const auto& io : declared) { + declaredNames.insert(io.name); + } + for (const auto& spec : probed) { + probedNames.insert(spec.name); + } + for (const auto& name : declaredNames) { + if (probedNames.count(name) == 0) { + errorOut = std::string("declared ") + side + " '" + name + "' does not exist in the graph"; + return false; + } + } + for (const auto& name : probedNames) { + if (declaredNames.count(name) == 0) { + errorOut = std::string("graph ") + side + " '" + name + "' is not declared in tensor_contract"; + return false; + } + } + } + if (declared.size() != probed.size()) { + errorOut = std::string("tensor_contract declares ") + std::to_string(declared.size()) + " " + side + + "(s) but the graph has " + std::to_string(probed.size()); + return false; + } + for (std::size_t i = 0; i < declared.size(); ++i) { + const auto& io = declared[i]; + const auto match = namesDeclared ? std::find_if(probed.begin(), probed.end(), + [&](const TensorSpec& spec) { return spec.name == io.name; }) + : probed.begin() + static_cast(i); + if (!graphAccepts(match->dims, io.shape)) { + errorOut = std::string(side) + " '" + match->name + "' is " + describeShape(match->dims) + + " in the graph but declared " + describeShape(io.shape); + return false; + } + } + return true; +} + } // namespace +std::optional parseTensorContract(const nlohmann::json& block, std::string& errorOut) { + if (!block.is_object()) { + errorOut = "tensor_contract must be an object"; + return std::nullopt; + } + TensorContract contract; + const std::string where = "tensor_contract"; + if (!readRequired(block, "engine", contract.engine, where, errorOut) || + !readRequired(block, "sample_rate", contract.sampleRate, where, errorOut) || + !readRequired(block, "stereo", contract.stereo, where, errorOut) || + !readRequired(block, "chunk_samples", contract.chunkSamples, where, errorOut) || + !readRequired(block, "overlap_samples", contract.overlapSamples, where, errorOut) || + !readRequired(block, "input_layout", contract.inputLayout, where, errorOut) || + !readRequired(block, "output_mode", contract.outputMode, where, errorOut)) { + return std::nullopt; + } + if (!block.contains("stft") || !block.at("stft").is_object()) { + errorOut = "tensor_contract.stft must be an object"; + return std::nullopt; + } + const auto& stft = block.at("stft"); + const std::string stftWhere = "tensor_contract.stft"; + if (!readRequired(stft, "n_fft", contract.stft.nFft, stftWhere, errorOut) || + !readRequired(stft, "hop_length", contract.stft.hopLength, stftWhere, errorOut) || + !readRequired(stft, "win_length", contract.stft.winLength, stftWhere, errorOut) || + !readRequired(stft, "window", contract.stft.window, stftWhere, errorOut) || + !readRequired(stft, "periodic_window", contract.stft.periodicWindow, stftWhere, errorOut) || + !readRequired(stft, "center", contract.stft.center, stftWhere, errorOut) || + !readRequired(stft, "pad_mode", contract.stft.padMode, stftWhere, errorOut) || + !readRequired(stft, "normalized", contract.stft.normalized, stftWhere, errorOut) || + !readRequired(stft, "zero_dc", contract.stft.zeroDc, stftWhere, errorOut)) { + return std::nullopt; + } + if (!parseIoList(block, "inputs", contract.inputs, errorOut) || + !parseIoList(block, "outputs", contract.outputs, errorOut)) { + return std::nullopt; + } + if (block.contains("target_stem")) { + if (!readRequired(block, "target_stem", contract.targetStem, where, errorOut)) { + return std::nullopt; + } + } + if (!block.contains("stems") || !block.at("stems").is_array()) { + errorOut = "tensor_contract.stems must be an array"; + return std::nullopt; + } + for (const auto& entry : block.at("stems")) { + TensorContract::Stem stem; + if (!entry.is_object() || !readRequired(entry, "name", stem.name, "tensor_contract.stems", errorOut)) { + if (errorOut.empty()) { + errorOut = "tensor_contract.stems entries must be objects"; + } + return std::nullopt; + } + if (entry.contains("residual_of") && + !readRequired(entry, "residual_of", stem.residualOf, "tensor_contract.stems", errorOut)) { + return std::nullopt; + } + contract.stems.push_back(std::move(stem)); + } + return contract; +} + +nlohmann::json tensorContractToJson(const TensorContract& contract) { + const auto ioList = [](const std::vector& list) { + nlohmann::json out = nlohmann::json::array(); + for (const auto& io : list) { + nlohmann::json entry = {{"shape", io.shape}, {"dtype", io.dtype}}; + if (!io.name.empty()) { + entry["name"] = io.name; + } + out.push_back(entry); + } + return out; + }; + nlohmann::json stems = nlohmann::json::array(); + for (const auto& stem : contract.stems) { + nlohmann::json entry = {{"name", stem.name}}; + if (!stem.residualOf.empty()) { + entry["residual_of"] = stem.residualOf; + } + stems.push_back(entry); + } + nlohmann::json block = { + {"engine", contract.engine}, + {"sample_rate", contract.sampleRate}, + {"stereo", contract.stereo}, + {"chunk_samples", contract.chunkSamples}, + {"overlap_samples", contract.overlapSamples}, + {"stft", + {{"n_fft", contract.stft.nFft}, + {"hop_length", contract.stft.hopLength}, + {"win_length", contract.stft.winLength}, + {"window", contract.stft.window}, + {"periodic_window", contract.stft.periodicWindow}, + {"center", contract.stft.center}, + {"pad_mode", contract.stft.padMode}, + {"normalized", contract.stft.normalized}, + {"zero_dc", contract.stft.zeroDc}}}, + {"input_layout", contract.inputLayout}, + {"inputs", ioList(contract.inputs)}, + {"outputs", ioList(contract.outputs)}, + {"output_mode", contract.outputMode}, + {"stems", stems}, + }; + if (!contract.targetStem.empty()) { + block["target_stem"] = contract.targetStem; + } + return block; +} + +std::optional runnerConfigFromContract(const TensorContract& contract, std::string& errorOut) { + const auto layout = inputLayoutFromString(contract.inputLayout); + if (!layout.has_value()) { + errorOut = "tensor_contract input_layout '" + contract.inputLayout + + "' is not one of folded_stereo, split_channels"; + return std::nullopt; + } + const auto mode = outputModeFromString(contract.outputMode); + if (!mode.has_value()) { + errorOut = "tensor_contract output_mode '" + contract.outputMode + "' is not one of direct, mask"; + return std::nullopt; + } + if (contract.stft.padMode != "reflect") { + errorOut = "tensor_contract.stft pad_mode '" + contract.stft.padMode + "' is not implemented (only reflect)"; + return std::nullopt; + } + if (contract.stft.window != "hann" || !contract.stft.periodicWindow) { + errorOut = "tensor_contract.stft window '" + contract.stft.window + + (contract.stft.periodicWindow ? "" : " (symmetric)") + "' is not implemented (only periodic hann)"; + return std::nullopt; + } + if (contract.sampleRate <= 0) { + errorOut = "tensor_contract sample_rate must be positive"; + return std::nullopt; + } + + RunnerConfig config; + config.chunkSamples = contract.chunkSamples; + config.overlapSamples = contract.overlapSamples; + config.sampleRate = static_cast(contract.sampleRate); + config.channels = contract.stereo ? 2 : 1; + config.stft.nFft = contract.stft.nFft; + config.stft.hopLength = contract.stft.hopLength; + config.stft.winLength = contract.stft.winLength; + config.stft.center = contract.stft.center; + config.stft.normalized = contract.stft.normalized; + config.stft.zeroDc = contract.stft.zeroDc; + config.inputLayout = *layout; + config.outputMode = *mode; + config.targetStem = contract.targetStem; + config.stems.clear(); + for (const auto& stem : contract.stems) { + config.stems.push_back({stem.name, stem.residualOf}); + } + if (const auto error = validateRunnerConfig(config); !error.empty()) { + errorOut = "tensor_contract: " + error; + return std::nullopt; + } + if (config.stft.nFft <= 0 || (config.stft.nFft & (config.stft.nFft - 1)) != 0 || config.stft.hopLength <= 0 || + config.stft.winLength <= 0 || config.stft.winLength > config.stft.nFft) { + errorOut = "tensor_contract.stft n_fft " + std::to_string(config.stft.nFft) + " / hop_length " + + std::to_string(config.stft.hopLength) + " / win_length " + std::to_string(config.stft.winLength) + + " is not a supported STFT"; + return std::nullopt; + } + return config; +} + +bool checkTensorContract(const TensorContract& contract, + const std::vector& probedInputs, + const std::vector& probedOutputs, + std::string& errorOut) { + const auto config = runnerConfigFromContract(contract, errorOut); + if (!config.has_value()) { + return false; + } + if (contract.inputs.size() != 1 || contract.outputs.size() != 1) { + errorOut = "tensor_contract must declare exactly one input and one output, got " + + std::to_string(contract.inputs.size()) + " and " + std::to_string(contract.outputs.size()); + return false; + } + + const int freqBins = config->stft.nFft / 2 + 1; + const int frames = analysis::stftFrameCount(config->chunkSamples, config->stft); + const int graphStems = static_cast(std::count_if(contract.stems.begin(), contract.stems.end(), + [](const auto& stem) { return stem.residualOf.empty(); })); + const int stemAxis = config->outputMode == OutputMode::Mask ? 1 : graphStems; + const auto impliedInput = tensorInputDims(config->inputLayout, config->channels, freqBins, frames); + const auto impliedOutput = tensorOutputDims(config->inputLayout, stemAxis, config->channels, freqBins, frames); + const auto stftSummary = "n_fft " + std::to_string(config->stft.nFft) + " (" + std::to_string(freqBins) + + " bins), " + std::to_string(frames) + " frames per " + + std::to_string(config->chunkSamples) + "-sample chunk, " + + std::to_string(config->channels) + " channel(s), " + contract.inputLayout; + + const auto label = [](const TensorContract::Io& io, const char* fallback) { + return io.name.empty() ? std::string(fallback) : io.name; + }; + const auto& input = contract.inputs.front(); + const auto& output = contract.outputs.front(); + for (const auto* io : {&input, &output}) { + if (io->dtype != "float32") { + errorOut = "tensor '" + label(*io, io == &input ? "input" : "output") + "' declares dtype '" + io->dtype + + "'; only float32 is supported"; + return false; + } + } + const TensorSpec declaredInput{input.name, TensorElementType::Float32, input.shape}; + if (!shapesMatch(declaredInput, TensorSpec{input.name, TensorElementType::Float32, impliedInput})) { + errorOut = "input '" + label(input, "input") + "' declares " + describeShape(input.shape) + " but " + stftSummary + + " implies " + describeShape(impliedInput); + return false; + } + const TensorSpec declaredOutput{output.name, TensorElementType::Float32, output.shape}; + if (!shapesMatch(declaredOutput, TensorSpec{output.name, TensorElementType::Float32, impliedOutput})) { + errorOut = "output '" + label(output, "output") + "' declares " + describeShape(output.shape) + " but " + + stftSummary + " implies " + describeShape(impliedOutput); + return false; + } + + return checkProbedSide("input", contract.inputs, probedInputs, errorOut) && + checkProbedSide("output", contract.outputs, probedOutputs, errorOut); +} + std::optional ModelPackLoader::load(const std::filesystem::path& directory) const { const auto metadataPath = directory / "model.json"; if (!std::filesystem::exists(metadataPath)) { @@ -184,6 +516,15 @@ std::optional ModelPackLoader::load(const std::filesystem::path& dire pack.featureSchemaVersion = json.at("feature_schema").value("version", ""); } + if (json.contains("tensor_contract")) { + std::string contractError; + auto contract = parseTensorContract(json.at("tensor_contract"), contractError); + if (!contract.has_value()) { + return std::nullopt; + } + pack.tensorContract = std::move(contract); + } + pack.rootPath = directory; if (!hasRequiredMetadata(pack)) { @@ -235,7 +576,10 @@ std::optional ModelPackLoader::load(const std::filesystem::path& dire } else if (pack.checksum != computeLegacyChecksum(modelPath)) { return std::nullopt; } - if (!hasRequiredOutputKeysForScope(pack.taskScope, pack.expectedOutputKeys)) { + // A tensor pack's outputs are the named tensors of its contract, not scalar + // keys, so the scalar output-key requirement does not apply to it. + if (!pack.tensorContract.has_value() && + !hasRequiredOutputKeysForScope(pack.taskScope, pack.expectedOutputKeys)) { return std::nullopt; } diff --git a/src/ai/ModelPackLoader.h b/src/ai/ModelPackLoader.h index f25985c..83bce54 100644 --- a/src/ai/ModelPackLoader.h +++ b/src/ai/ModelPackLoader.h @@ -1,12 +1,74 @@ #pragma once +#include #include #include #include #include +#include + +#include "ai/SeparationRunner.h" +#include "ai/TensorTypes.h" + namespace automix::ai { +// Declared audio-tensor interface of a pack (spec section 6). Strings are kept +// verbatim from the manifest so that checkTensorContract() can name an +// unrecognised value instead of the parser silently mapping it to a default. +struct TensorContract { + struct Stft { + int nFft = 0; + int hopLength = 0; + int winLength = 0; + std::string window; + bool periodicWindow = true; + bool center = true; + std::string padMode; + bool normalized = false; + bool zeroDc = true; + }; + struct Io { + std::string name; // empty in the catalog form; the install-time probe fills it + std::vector shape; + std::string dtype; + }; + struct Stem { + std::string name; + std::string residualOf; + }; + + std::string engine; + int sampleRate = 0; + bool stereo = true; + int chunkSamples = 0; + int overlapSamples = 0; + Stft stft; + std::string inputLayout; + std::vector inputs; + std::vector outputs; + std::string outputMode; + std::string targetStem; + std::vector stems; +}; + +// Structural parse of a `tensor_contract` block. Fails (nullopt + reason) only +// on missing or mistyped fields; value checks belong to checkTensorContract(). +std::optional parseTensorContract(const nlohmann::json& block, std::string& errorOut); +nlohmann::json tensorContractToJson(const TensorContract& contract); + +// The spec 5.3 load-time cross-check: the declared contract must be internally +// consistent (STFT geometry vs declared shapes, layout, mode, stems) and must +// agree with the graph's probed inputs/outputs. Every failure names the tensor +// or field and prints both shapes where shapes are involved. +bool checkTensorContract(const TensorContract& contract, + const std::vector& probedInputs, + const std::vector& probedOutputs, + std::string& errorOut); + +// The runner configuration a (checked) contract describes. +std::optional runnerConfigFromContract(const TensorContract& contract, std::string& errorOut); + struct ModelPack { int schemaVersion = 1; std::string id; @@ -35,6 +97,8 @@ struct ModelPack { std::vector expectedOutputKeys; std::vector inputNames; std::vector outputNames; + // Absent for scalar-only packs; existing packs load unchanged. + std::optional tensorContract; std::filesystem::path rootPath; }; diff --git a/src/ai/OnnxModelInference.cpp b/src/ai/OnnxModelInference.cpp index d24b753..c375760 100644 --- a/src/ai/OnnxModelInference.cpp +++ b/src/ai/OnnxModelInference.cpp @@ -433,7 +433,7 @@ bool OnnxModelInference::loadModel(const std::filesystem::path& modelPath) { if (profilingEnabled_) { nativeState->profilingPrefix = makeProfilePrefix(modelPath_); - nativeState->sessionOptions->EnableProfiling(nativeState->profilingPrefix.string().c_str()); + nativeState->sessionOptions->EnableProfiling(nativeState->profilingPrefix.c_str()); } try { @@ -515,7 +515,10 @@ bool OnnxModelInference::loadModel(const std::filesystem::path& modelPath) { gpuOomCount_.fetch_add(1); gpuRecoveryCount_.fetch_add(1); } - { + // CPU is the floor resolution always returns, so a CPU session that fails + // to open is a model problem (unreadable or corrupt file), not a provider + // failure worth recording. + if (activeExecutionProvider_ != gpu::kProviderCpu) { std::scoped_lock lock(failedProvidersMutex_); failedProviders_.push_back(activeExecutionProvider_); } @@ -908,6 +911,14 @@ void OnnxModelInference::recordProviderFailure(const std::string& provider, } } +void OnnxModelInference::pinExecutionProvidersForTesting(std::vector providers) { + availableExecutionProviders_ = providers; + pinnedProviders_ = std::move(providers); + if (loaded_) { + activeExecutionProvider_ = resolveExecutionProvider(); + } +} + void OnnxModelInference::setGraphOptimizationEnabled(const bool enabled) { graphOptimizationEnabled_ = enabled; } void OnnxModelInference::setWarmupEnabled(const bool enabled) { warmupEnabled_ = enabled; } @@ -978,12 +989,16 @@ std::string OnnxModelInference::resolveExecutionProvider() const { // Probe runtime providers and walk the priority chain std::vector runtimeProviders; + if (pinnedProviders_.has_value()) { + runtimeProviders = *pinnedProviders_; + } else { #if AUTOMIX_HAS_NATIVE_ORT - try { - runtimeProviders = Ort::GetAvailableProviders(); - } catch (...) { - } + try { + runtimeProviders = Ort::GetAvailableProviders(); + } catch (...) { + } #endif + } // If runtime probe succeeded, use it; otherwise fall back to metadata list const auto& probeProviders = runtimeProviders.empty() diff --git a/src/ai/OnnxModelInference.h b/src/ai/OnnxModelInference.h index 700bf95..4841d6b 100644 --- a/src/ai/OnnxModelInference.h +++ b/src/ai/OnnxModelInference.h @@ -40,6 +40,11 @@ class OnnxModelInference final : public IModelInference { void recordProviderFailure(const std::string& provider, ProviderFailureKind kind); + // Test seam: replaces both the manifest provider list and the runtime probe, + // then re-resolves. Lets the provider-recovery bookkeeping be exercised on a + // machine whose ONNX Runtime has no GPU provider. + void pinExecutionProvidersForTesting(std::vector providers); + [[nodiscard]] std::string activeExecutionProvider() const; [[nodiscard]] std::string backendDiagnostics() const; [[nodiscard]] std::vector profilingArtifacts() const; @@ -75,6 +80,7 @@ class OnnxModelInference final : public IModelInference { std::vector outputNames_; std::vector allowedTasks_; std::vector availableExecutionProviders_; + std::optional> pinnedProviders_; std::string requestedExecutionProvider_ = "auto"; std::string activeExecutionProvider_ = "cpu"; std::string preferredPrecision_ = "auto"; diff --git a/src/ai/OnnxTensorInference.cpp b/src/ai/OnnxTensorInference.cpp new file mode 100644 index 0000000..2bb1596 --- /dev/null +++ b/src/ai/OnnxTensorInference.cpp @@ -0,0 +1,284 @@ +#include "ai/OnnxTensorInference.h" + +#include +#include +#include +#include +#include +#include +#include + +#ifndef AUTOMIX_HAS_NATIVE_ORT +#define AUTOMIX_HAS_NATIVE_ORT 0 +#endif + +#if AUTOMIX_HAS_NATIVE_ORT +#include +#endif + +namespace automix::ai { +namespace { + +#if AUTOMIX_HAS_NATIVE_ORT + +std::string describeOnnxElementType(const ONNXTensorElementDataType type) { + switch (type) { + case ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT: return "float32"; + case ONNX_TENSOR_ELEMENT_DATA_TYPE_UINT8: return "uint8"; + case ONNX_TENSOR_ELEMENT_DATA_TYPE_INT8: return "int8"; + case ONNX_TENSOR_ELEMENT_DATA_TYPE_UINT16: return "uint16"; + case ONNX_TENSOR_ELEMENT_DATA_TYPE_INT16: return "int16"; + case ONNX_TENSOR_ELEMENT_DATA_TYPE_INT32: return "int32"; + case ONNX_TENSOR_ELEMENT_DATA_TYPE_INT64: return "int64"; + case ONNX_TENSOR_ELEMENT_DATA_TYPE_STRING: return "string"; + case ONNX_TENSOR_ELEMENT_DATA_TYPE_BOOL: return "bool"; + case ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT16: return "float16"; + case ONNX_TENSOR_ELEMENT_DATA_TYPE_DOUBLE: return "float64"; + case ONNX_TENSOR_ELEMENT_DATA_TYPE_UINT32: return "uint32"; + case ONNX_TENSOR_ELEMENT_DATA_TYPE_UINT64: return "uint64"; + case ONNX_TENSOR_ELEMENT_DATA_TYPE_COMPLEX64: return "complex64"; + case ONNX_TENSOR_ELEMENT_DATA_TYPE_COMPLEX128: return "complex128"; + case ONNX_TENSOR_ELEMENT_DATA_TYPE_BFLOAT16: return "bfloat16"; + default: return "onnx element type " + std::to_string(static_cast(type)); + } +} + +// Reads one graph input or output back as a TensorSpec. Anything that is not a +// float32 tensor is refused here, naming the tensor and what it actually is. +bool probeSpec(const std::string& role, + const std::string& name, + const Ort::TypeInfo& info, + std::vector& specs, + std::string& errorOut) { + if (info.GetONNXType() != ONNX_TYPE_TENSOR) { + errorOut = "graph " + role + " '" + name + "' is not a tensor (ONNX value type " + + std::to_string(static_cast(info.GetONNXType())) + "); only float32 tensors are supported."; + return false; + } + const auto tensorInfo = info.GetTensorTypeAndShapeInfo(); + const auto elementType = tensorInfo.GetElementType(); + if (elementType != ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT) { + errorOut = "graph " + role + " '" + name + "' has element type " + describeOnnxElementType(elementType) + + "; only float32 is supported."; + return false; + } + specs.push_back(TensorSpec{name, TensorElementType::Float32, tensorInfo.GetShape()}); + return true; +} + +bool isConcrete(const std::vector& dims) { + for (const auto dim : dims) { + if (dim <= 0) { + return false; + } + } + return true; +} + +#endif + +} // namespace + +struct OnnxTensorInference::NativeState { +#if AUTOMIX_HAS_NATIVE_ORT + std::unique_ptr env; + std::unique_ptr session; +#endif +}; + +OnnxTensorInference::OnnxTensorInference() = default; +OnnxTensorInference::~OnnxTensorInference() noexcept = default; + +void OnnxTensorInference::setTensorContract(std::optional contract) { contract_ = std::move(contract); } + +bool OnnxTensorInference::isAvailable() const { return nativeState_ != nullptr; } + +bool OnnxTensorInference::usingNativeSession() const { return nativeState_ != nullptr; } + +std::string OnnxTensorInference::backendDiagnostics() const { return diagnostics_; } + +std::vector OnnxTensorInference::inputSpecs() const { return inputs_; } + +std::vector OnnxTensorInference::outputSpecs() const { return outputs_; } + +void OnnxTensorInference::unload(std::string diagnostics) { + nativeState_.reset(); + inputs_.clear(); + outputs_.clear(); + diagnostics_ = std::move(diagnostics); +} + +bool OnnxTensorInference::loadModel(const std::filesystem::path& modelPath) { + std::error_code error; + if (!std::filesystem::is_regular_file(modelPath, error) || error) { + unload("ONNX tensor load failed: missing model file '" + modelPath.string() + "'."); + return false; + } + +#if AUTOMIX_HAS_NATIVE_ORT + auto state = std::make_unique(); + std::vector inputs; + std::vector outputs; + try { + state->env = std::make_unique(ORT_LOGGING_LEVEL_WARNING, "AutoMixMasterTensor"); + Ort::SessionOptions options; + options.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL); +#if defined(_WIN32) + state->session = std::make_unique(*state->env, modelPath.wstring().c_str(), options); +#else + state->session = std::make_unique(*state->env, modelPath.string().c_str(), options); +#endif + + Ort::AllocatorWithDefaultOptions allocator; + std::string probeError; + for (std::size_t i = 0; i < state->session->GetInputCount(); ++i) { + const auto name = std::string(state->session->GetInputNameAllocated(i, allocator).get()); + if (!probeSpec("input", name, state->session->GetInputTypeInfo(i), inputs, probeError)) { + unload("ONNX tensor load failed for '" + modelPath.string() + "': " + probeError); + return false; + } + } + for (std::size_t i = 0; i < state->session->GetOutputCount(); ++i) { + const auto name = std::string(state->session->GetOutputNameAllocated(i, allocator).get()); + if (!probeSpec("output", name, state->session->GetOutputTypeInfo(i), outputs, probeError)) { + unload("ONNX tensor load failed for '" + modelPath.string() + "': " + probeError); + return false; + } + } + } catch (const std::exception& exception) { + // Session creation is where a missing external-data sidecar surfaces; ORT's + // message names the file it could not open. + unload("ONNX tensor load failed for '" + modelPath.string() + "': " + exception.what()); + return false; + } + + if (contract_.has_value()) { + std::string contractError; + if (!checkTensorContract(*contract_, inputs, outputs, contractError)) { + unload("ONNX tensor load failed for '" + modelPath.string() + "': tensor contract mismatch: " + contractError); + return false; + } + } + + inputs_ = std::move(inputs); + outputs_ = std::move(outputs); + nativeState_ = std::move(state); + diagnostics_ = "backend=native_onnxruntime; model=" + modelPath.filename().string() + + "; inputs=" + std::to_string(inputs_.size()) + "; outputs=" + std::to_string(outputs_.size()); + return true; +#else + unload("ONNX tensor load failed for '" + modelPath.string() + + "': this build has no native ONNX Runtime (AUTOMIX_HAS_NATIVE_ORT is off)."); + return false; +#endif +} + +TensorInferenceResult OnnxTensorInference::run(const std::vector& inputs) const { + TensorInferenceResult result; + if (nativeState_ == nullptr) { + result.logMessage = "OnnxTensorInference: no model loaded (" + diagnostics_ + ")"; + return result; + } + +#if AUTOMIX_HAS_NATIVE_ORT + if (inputs.size() != inputs_.size()) { + result.logMessage = "OnnxTensorInference: graph declares " + std::to_string(inputs_.size()) + + " input(s) but " + std::to_string(inputs.size()) + " binding(s) were supplied."; + return result; + } + + // ORT takes a mutable pointer; copying keeps the caller's bindings const + // without casting it away. The copy is small next to the inference itself. + std::vector> buffers; + std::vector> shapes; + std::vector inputNames; + buffers.reserve(inputs_.size()); + shapes.reserve(inputs_.size()); + inputNames.reserve(inputs_.size()); + for (const auto& spec : inputs_) { + const TensorBinding* binding = nullptr; + for (const auto& candidate : inputs) { + if (candidate.expected.name == spec.name) { + binding = &candidate; + break; + } + } + if (binding == nullptr) { + result.logMessage = "OnnxTensorInference: no binding for graph input '" + spec.name + "'."; + return result; + } + const auto& dims = binding->expected.dims; + if (!isConcrete(dims) || !shapesMatch(spec, binding->expected)) { + result.logMessage = "OnnxTensorInference: binding for '" + spec.name + "' has shape " + describeShape(dims) + + " but the graph declares " + describeShape(spec.dims) + "."; + return result; + } + const auto expectedCount = elementCount(binding->expected); + if (!expectedCount.has_value() || *expectedCount != binding->data.size()) { + result.logMessage = "OnnxTensorInference: binding for '" + spec.name + "' carries " + + std::to_string(binding->data.size()) + " value(s) for shape " + describeShape(dims) + "."; + return result; + } + buffers.push_back(binding->data); + shapes.push_back(dims); + inputNames.push_back(spec.name.c_str()); + } + + std::vector outputNames; + outputNames.reserve(outputs_.size()); + for (const auto& spec : outputs_) { + outputNames.push_back(spec.name.c_str()); + } + + try { + const auto memoryInfo = Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault); + std::vector values; + values.reserve(buffers.size()); + for (std::size_t i = 0; i < buffers.size(); ++i) { + values.push_back(Ort::Value::CreateTensor( + memoryInfo, buffers[i].data(), buffers[i].size(), shapes[i].data(), shapes[i].size())); + } + + auto produced = nativeState_->session->Run(Ort::RunOptions{nullptr}, + inputNames.data(), + values.data(), + values.size(), + outputNames.data(), + outputNames.size()); + + for (std::size_t i = 0; i < produced.size(); ++i) { + const auto& value = produced[i]; + if (!value.IsTensor()) { + result.logMessage = "OnnxTensorInference: graph output '" + outputs_[i].name + "' is not a tensor."; + return result; + } + const auto info = value.GetTensorTypeAndShapeInfo(); + if (info.GetElementType() != ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT) { + result.logMessage = "OnnxTensorInference: graph output '" + outputs_[i].name + "' has element type " + + describeOnnxElementType(info.GetElementType()) + "; only float32 is supported."; + return result; + } + const float* data = value.GetTensorData(); + Tensor tensor; + tensor.spec = TensorSpec{outputs_[i].name, TensorElementType::Float32, info.GetShape()}; + tensor.data.assign(data, data + info.GetElementCount()); + result.outputs.push_back(std::move(tensor)); + } + } catch (const std::exception& exception) { + result.outputs.clear(); + result.logMessage = std::string("OnnxTensorInference: run failed: ") + exception.what(); + return result; + } + + result.usedModel = true; + result.logMessage = "OnnxTensorInference: ran " + std::to_string(inputs_.size()) + " input(s) -> " + + std::to_string(result.outputs.size()) + " output(s)."; + return result; +#else + static_cast(inputs); + result.logMessage = "OnnxTensorInference: this build has no native ONNX Runtime."; + return result; +#endif +} + +} // namespace automix::ai diff --git a/src/ai/OnnxTensorInference.h b/src/ai/OnnxTensorInference.h new file mode 100644 index 0000000..6c632d5 --- /dev/null +++ b/src/ai/OnnxTensorInference.h @@ -0,0 +1,48 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "ai/ITensorInference.h" +#include "ai/ModelPackLoader.h" + +namespace automix::ai { + +// ONNX Runtime backend for tensor graphs. Without the native SDK +// (AUTOMIX_HAS_NATIVE_ORT undefined) it is a deterministic no-op: every load +// fails with a diagnostic and nothing ever reports usedModel == true. There is +// no approximate fallback, because a tensor graph has none that is honest. +class OnnxTensorInference final : public ITensorInference { + public: + OnnxTensorInference(); + ~OnnxTensorInference() noexcept override; + + // Checked against the probed graph on the next loadModel(); a mismatch fails + // the load. Without a contract the graph only has to be float32 throughout. + void setTensorContract(std::optional contract); + + bool isAvailable() const override; + bool loadModel(const std::filesystem::path& modelPath) override; + std::vector inputSpecs() const override; + std::vector outputSpecs() const override; + TensorInferenceResult run(const std::vector& inputs) const override; + + [[nodiscard]] bool usingNativeSession() const; + [[nodiscard]] std::string backendDiagnostics() const; + + private: + struct NativeState; + + void unload(std::string diagnostics); + + std::optional contract_; + std::vector inputs_; + std::vector outputs_; + std::string diagnostics_; + std::unique_ptr nativeState_; +}; + +} // namespace automix::ai diff --git a/src/ai/SeparationRunner.cpp b/src/ai/SeparationRunner.cpp new file mode 100644 index 0000000..e8ad41f --- /dev/null +++ b/src/ai/SeparationRunner.cpp @@ -0,0 +1,368 @@ +#include "ai/SeparationRunner.h" + +#include +#include +#include +#include + +namespace automix::ai { +namespace { + +constexpr double kPi = 3.14159265358979323846; + +struct ChunkFailure : std::runtime_error { + using std::runtime_error::runtime_error; +}; + +std::size_t volume(const std::vector& dims) { + std::size_t count = 1; + for (const auto dim : dims) { + count *= static_cast(dim); + } + return count; +} + +std::vector encodeInput(const analysis::Spectrogram& spec, const InputLayout layout) { + const int channels = spec.channels; + const int bins = spec.freqBins; + const int frames = spec.frames; + std::vector data(static_cast(channels) * static_cast(bins) * + static_cast(frames) * 2); + for (int ch = 0; ch < channels; ++ch) { + for (int bin = 0; bin < bins; ++bin) { + for (int frame = 0; frame < frames; ++frame) { + const auto source = spec.index(ch, bin, frame); + std::size_t target = 0; + if (layout == InputLayout::FoldedStereo) { + const auto folded = static_cast(bin) * static_cast(channels) + + static_cast(ch); + target = (static_cast(frame) * static_cast(bins * channels) + folded) * 2; + } else { + target = source * 2; + } + data[target] = spec.real[source]; + data[target + 1] = spec.imag[source]; + } + } + } + return data; +} + +analysis::Spectrogram decodeOutputStem(const std::vector& data, + const int stemIndex, + const InputLayout layout, + const analysis::Spectrogram& geometry) { + analysis::Spectrogram spec; + spec.channels = geometry.channels; + spec.freqBins = geometry.freqBins; + spec.frames = geometry.frames; + spec.sampleRate = geometry.sampleRate; + const std::size_t plane = static_cast(spec.channels) * static_cast(spec.freqBins) * + static_cast(spec.frames); + spec.real.assign(plane, 0.0f); + spec.imag.assign(plane, 0.0f); + + const std::size_t stemOffset = static_cast(stemIndex) * plane * 2; + for (int ch = 0; ch < spec.channels; ++ch) { + for (int bin = 0; bin < spec.freqBins; ++bin) { + for (int frame = 0; frame < spec.frames; ++frame) { + const auto target = spec.index(ch, bin, frame); + std::size_t source = 0; + if (layout == InputLayout::FoldedStereo) { + const auto folded = static_cast(bin) * static_cast(spec.channels) + + static_cast(ch); + source = stemOffset + (folded * static_cast(spec.frames) + static_cast(frame)) * 2; + } else { + source = stemOffset + target * 2; + } + spec.real[target] = data[source]; + spec.imag[target] = data[source + 1]; + } + } + } + return spec; +} + +// Crossfade weight of sample `i` within a chunk. Consecutive chunks overlap by +// exactly `overlap` samples, and the fade-in of one chunk and the fade-out of +// the previous are sin^2 / cos^2 of the same phase, so they sum to one there. +double chunkWeight(const int i, const int chunkSamples, const int overlap, const bool fadeIn, const bool fadeOut) { + if (overlap <= 0) { + return 1.0; + } + if (fadeIn && i < overlap) { + const double phase = 0.5 * kPi * (static_cast(i) + 0.5) / static_cast(overlap); + return std::sin(phase) * std::sin(phase); + } + const int fromEnd = chunkSamples - 1 - i; + if (fadeOut && fromEnd < overlap) { + const int j = overlap - 1 - fromEnd; + const double phase = 0.5 * kPi * (static_cast(j) + 0.5) / static_cast(overlap); + return std::cos(phase) * std::cos(phase); + } + return 1.0; +} + +} // namespace + +std::string validateRunnerConfig(const RunnerConfig& config) { + if (config.chunkSamples <= 0 || config.overlapSamples < 0 || config.overlapSamples >= config.chunkSamples) { + return "invalid chunking: chunk_samples " + std::to_string(config.chunkSamples) + ", overlap_samples " + + std::to_string(config.overlapSamples); + } + if (config.channels <= 0) { + return "invalid channel count " + std::to_string(config.channels); + } + const auto isResidual = [](const SeparationStem& stem) { return !stem.residualOf.empty(); }; + const auto graphStems = std::count_if(config.stems.begin(), config.stems.end(), + [&](const SeparationStem& stem) { return !isResidual(stem); }); + if (graphStems == 0) { + return "the stem list names no graph-produced stem"; + } + if (config.outputMode == OutputMode::Mask) { + if (graphStems != 1) { + return "mask mode produces exactly one graph stem, but " + std::to_string(graphStems) + " are declared"; + } + const auto target = std::find_if(config.stems.begin(), config.stems.end(), + [&](const SeparationStem& stem) { return stem.name == config.targetStem; }); + if (target == config.stems.end() || isResidual(*target)) { + return "mask target stem '" + config.targetStem + "' is not a graph-produced stem in the stem list"; + } + } + for (const auto& stem : config.stems) { + if (!isResidual(stem)) { + continue; + } + const auto source = std::find_if(config.stems.begin(), config.stems.end(), + [&](const SeparationStem& other) { return other.name == stem.residualOf; }); + if (source == config.stems.end() || isResidual(*source)) { + return "stem '" + stem.name + "' is a residual of '" + stem.residualOf + + "', which is not a graph-produced stem"; + } + } + return {}; +} + +std::optional inputLayoutFromString(const std::string& value) { + if (value == "folded_stereo") { + return InputLayout::FoldedStereo; + } + if (value == "split_channels") { + return InputLayout::SplitChannels; + } + return std::nullopt; +} + +std::optional outputModeFromString(const std::string& value) { + if (value == "direct") { + return OutputMode::Direct; + } + if (value == "mask") { + return OutputMode::Mask; + } + return std::nullopt; +} + +std::vector tensorInputDims(const InputLayout layout, const int channels, const int freqBins, const int frames) { + if (layout == InputLayout::FoldedStereo) { + return {1, frames, static_cast(freqBins) * channels * 2}; + } + return {1, channels, freqBins, frames, 2}; +} + +std::vector tensorOutputDims(const InputLayout layout, + const int stemAxis, + const int channels, + const int freqBins, + const int frames) { + if (layout == InputLayout::FoldedStereo) { + return {1, stemAxis, static_cast(freqBins) * channels, frames, 2}; + } + return {1, stemAxis, channels, freqBins, frames, 2}; +} + +int SeparationRunner::chunkCount(const int samples, const RunnerConfig& config) { + if (samples <= 0) { + return 0; + } + if (samples <= config.chunkSamples) { + return 1; + } + const int stride = config.chunkSamples - config.overlapSamples; + return 1 + (samples - config.chunkSamples + stride - 1) / stride; +} + +SeparationRunner::Result SeparationRunner::separate(const engine::AudioBuffer& mix, + const ITensorInference& inference, + const RunnerConfig& config) { + Result result; + const auto fail = [&](const std::string& reason) { + Result failed; + failed.usedModel = false; + failed.logMessage = "Tensor separation not used: " + reason; + return failed; + }; + + if (const auto configError = validateRunnerConfig(config); !configError.empty()) { + return fail(configError); + } + const int samples = mix.getNumSamples(); + if (mix.getNumChannels() <= 0 || samples <= 0) { + return fail("input mix has no audio samples"); + } + if (std::abs(mix.getSampleRate() - config.sampleRate) > 1.0e-6) { + return fail("model expects " + std::to_string(static_cast(config.sampleRate)) + + " Hz audio but the mix is " + std::to_string(static_cast(mix.getSampleRate())) + + " Hz; resampling is not implemented"); + } + if (mix.getNumChannels() != config.channels) { + return fail("model expects " + std::to_string(config.channels) + "-channel audio but the mix has " + + std::to_string(mix.getNumChannels()) + " channel(s); channels are never duplicated or mixed down"); + } + if (!inference.isAvailable()) { + return fail("tensor inference backend is unavailable"); + } + + const auto inputs = inference.inputSpecs(); + const auto outputs = inference.outputSpecs(); + if (inputs.size() != 1 || outputs.size() != 1) { + return fail("graph declares " + std::to_string(inputs.size()) + " input(s) and " + + std::to_string(outputs.size()) + " output(s); this runner binds exactly one of each"); + } + const auto& inputSpec = inputs.front(); + const auto& outputSpec = outputs.front(); + + std::vector graphStemIndex; // index into config.stems, in stem-axis order + for (std::size_t i = 0; i < config.stems.size(); ++i) { + if (config.stems[i].residualOf.empty()) { + graphStemIndex.push_back(i); + } + } + const int stemAxis = config.outputMode == OutputMode::Mask ? 1 : static_cast(graphStemIndex.size()); + + const int chunk = config.chunkSamples; + const int overlap = config.overlapSamples; + const int stride = chunk - overlap; + const int channels = config.channels; + const int totalChunks = chunkCount(samples, config); + + // Output stems accumulate in place. The crossfade weights of overlapping + // chunks sum to one, so no separate normalisation pass is needed. + std::vector stemAudio; + stemAudio.reserve(config.stems.size()); + for (std::size_t i = 0; i < config.stems.size(); ++i) { + stemAudio.emplace_back(channels, samples, mix.getSampleRate()); + } + + try { + for (int chunkIndex = 0; chunkIndex < totalChunks; ++chunkIndex) { + const int start = chunkIndex * stride; + const int valid = std::min(chunk, samples - start); + + // The graph's geometry is fixed, so a short final chunk is zero-padded to + // the full chunk length and its output trimmed back to `valid` samples. + engine::AudioBuffer chunkAudio(channels, chunk, mix.getSampleRate()); + for (int ch = 0; ch < channels; ++ch) { + const float* source = mix.getReadPointer(ch); + float* destination = chunkAudio.getWritePointer(ch); + std::copy(source + start, source + start + valid, destination); + } + + const auto spectrum = analysis::analyze(chunkAudio, config.stft); + TensorBinding binding; + binding.expected.name = inputSpec.name; + binding.expected.elementType = TensorElementType::Float32; + binding.expected.dims = tensorInputDims(config.inputLayout, channels, spectrum.freqBins, spectrum.frames); + if (!shapesMatch(inputSpec, binding.expected)) { + throw ChunkFailure("input '" + inputSpec.name + "' expects " + describeShape(inputSpec.dims) + + " but the STFT of a " + std::to_string(chunk) + "-sample chunk is " + + describeShape(binding.expected.dims)); + } + binding.data = encodeInput(spectrum, config.inputLayout); + + const auto inferred = inference.run({binding}); + if (!inferred.usedModel) { + throw ChunkFailure("chunk " + std::to_string(chunkIndex + 1) + "/" + std::to_string(totalChunks) + + " inference failed: " + inferred.logMessage); + } + const auto produced = std::find_if(inferred.outputs.begin(), inferred.outputs.end(), + [&](const Tensor& tensor) { return tensor.spec.name == outputSpec.name; }); + if (produced == inferred.outputs.end()) { + throw ChunkFailure("graph did not return the declared output '" + outputSpec.name + "'"); + } + const auto expectedOutputDims = + tensorOutputDims(config.inputLayout, stemAxis, channels, spectrum.freqBins, spectrum.frames); + const TensorSpec expectedOutput{outputSpec.name, TensorElementType::Float32, expectedOutputDims}; + if (!shapesMatch(expectedOutput, produced->spec) || produced->data.size() != volume(expectedOutputDims)) { + throw ChunkFailure("output '" + outputSpec.name + "' has shape " + describeShape(produced->spec.dims) + + " with " + std::to_string(produced->data.size()) + " values; expected " + + describeShape(expectedOutputDims)); + } + + std::vector> chunkStems(config.stems.size()); + if (config.outputMode == OutputMode::Mask) { + const auto mask = decodeOutputStem(produced->data, 0, config.inputLayout, spectrum); + const auto masked = analysis::complexMultiply(spectrum, mask, config.stft.zeroDc); + chunkStems[graphStemIndex.front()] = analysis::synthesize(masked, config.stft); + } else { + for (std::size_t n = 0; n < graphStemIndex.size(); ++n) { + const auto stemSpec = decodeOutputStem(produced->data, static_cast(n), config.inputLayout, spectrum); + chunkStems[graphStemIndex[n]] = analysis::synthesize(stemSpec, config.stft); + } + } + for (std::size_t i = 0; i < config.stems.size(); ++i) { + if (config.stems[i].residualOf.empty()) { + continue; + } + const auto sourceIt = std::find_if(config.stems.begin(), config.stems.end(), [&](const SeparationStem& s) { + return s.name == config.stems[i].residualOf; + }); + const auto& source = *chunkStems[static_cast(std::distance(config.stems.begin(), sourceIt))]; + engine::AudioBuffer residual(channels, chunk, mix.getSampleRate()); + for (int ch = 0; ch < channels; ++ch) { + for (int s = 0; s < chunk; ++s) { + residual.setSample(ch, s, chunkAudio.getSample(ch, s) - source.getSample(ch, s)); + } + } + chunkStems[i] = std::move(residual); + } + + for (const auto& stem : chunkStems) { + if (!stem.has_value() || stem->getNumSamples() != chunk || stem->getNumChannels() != channels) { + throw ChunkFailure("chunk " + std::to_string(chunkIndex + 1) + " reconstructed " + + std::to_string(stem.has_value() ? stem->getNumSamples() : 0) + " samples, expected " + + std::to_string(chunk)); + } + } + + const bool fadeIn = chunkIndex > 0; + const bool fadeOut = chunkIndex + 1 < totalChunks; + for (std::size_t stemIndex = 0; stemIndex < chunkStems.size(); ++stemIndex) { + for (int ch = 0; ch < channels; ++ch) { + float* destination = stemAudio[stemIndex].getWritePointer(ch) + start; + for (int s = 0; s < valid; ++s) { + const double weight = chunkWeight(s, chunk, overlap, fadeIn, fadeOut); + destination[s] += static_cast(weight * static_cast(chunkStems[stemIndex]->getSample(ch, s))); + } + } + } + + if (config.progressCallback) { + config.progressCallback(chunkIndex + 1, totalChunks); + } + } + } catch (const std::exception& error) { + return fail(error.what()); + } + + result.stemAudio = std::move(stemAudio); + for (const auto& stem : config.stems) { + result.stemNames.push_back(stem.name); + } + result.usedModel = true; + result.logMessage = "Tensor separation completed (" + std::to_string(totalChunks) + " chunk(s), " + + std::to_string(config.stems.size()) + " stem(s))."; + return result; +} + +} // namespace automix::ai diff --git a/src/ai/SeparationRunner.h b/src/ai/SeparationRunner.h new file mode 100644 index 0000000..f1f571e --- /dev/null +++ b/src/ai/SeparationRunner.h @@ -0,0 +1,82 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "ai/ITensorInference.h" +#include "analysis/SpectrogramFrontEnd.h" +#include "engine/AudioBuffer.h" + +namespace automix::ai { + +// How stereo spectra are laid out in the graph's input and output tensors. +// +// FoldedStereo (ZFTurbo / upstream BS-RoFormer, 'b s f t c -> b (f s) t c'): +// input [1, T, F*C*2] element (t, (f*C + ch)*2 + reim) +// output [1, N, F*C, T, 2] element (n, f*C + ch, t, reim) +// SplitChannels (channels on their own axis): +// input [1, C, F, T, 2] +// output [1, N, C, F, T, 2] +// +// N is the stem axis: 1 in Mask mode, one entry per graph-produced stem in +// Direct mode. The trailing axis of length 2 is always (real, imag). +enum class InputLayout { FoldedStereo, SplitChannels }; + +// Mask: the graph returns one complex mask for the target stem; the caller +// multiplies it against the input spectrum. Direct: the graph returns one +// spectrogram per stem. +enum class OutputMode { Direct, Mask }; + +std::optional inputLayoutFromString(const std::string& value); +std::optional outputModeFromString(const std::string& value); + +std::vector tensorInputDims(InputLayout layout, int channels, int freqBins, int frames); +std::vector tensorOutputDims(InputLayout layout, int stemAxis, int channels, int freqBins, int frames); + +struct SeparationStem { + std::string name; + // Empty for a stem the graph produces. Otherwise the stem is computed + // host-side as (input mix - residualOf), e.g. instrumental = mix - vocals. + std::string residualOf; +}; + +struct RunnerConfig { + int chunkSamples = 352800; + int overlapSamples = 88200; // chunkSamples / 4 + double sampleRate = 44100.0; + int channels = 2; + analysis::StftParams stft{}; // stft.zeroDc is applied in Mask mode + InputLayout inputLayout = InputLayout::FoldedStereo; + OutputMode outputMode = OutputMode::Mask; + std::string targetStem = "vocals"; + std::vector stems{{"vocals", ""}, {"instrumental", "vocals"}}; + // JUCE-free on purpose: the runner stays testable with no message loop; the + // controller that adapts it to the UI owns the SafePointer marshalling. + std::function progressCallback; +}; + +// Empty when the configuration is runnable; otherwise the reason it is not. +std::string validateRunnerConfig(const RunnerConfig& config); + +class SeparationRunner final { + public: + struct Result { + bool usedModel = false; + std::vector stemAudio; + std::vector stemNames; + std::string logMessage; + }; + + // Chunks the track, runs every chunk through `inference`, and crossfades the + // chunks back together. All-or-nothing: any chunk failure discards every stem + // and returns usedModel == false with the reason. + static Result separate(const engine::AudioBuffer& mix, const ITensorInference& inference, const RunnerConfig& config); + + // Number of chunks separate() will run for a track of `samples` samples. + static int chunkCount(int samples, const RunnerConfig& config); +}; + +} // namespace automix::ai diff --git a/src/ai/StemSeparator.cpp b/src/ai/StemSeparator.cpp index feeb138..72b98c2 100644 --- a/src/ai/StemSeparator.cpp +++ b/src/ai/StemSeparator.cpp @@ -14,7 +14,10 @@ #include +#include "ai/ModelPackLoader.h" #include "ai/OnnxModelInference.h" +#include "ai/OnnxTensorInference.h" +#include "ai/SeparationRunner.h" #include "domain/StemOrigin.h" #include "domain/StemRole.h" #include "engine/AudioFileIO.h" @@ -982,6 +985,110 @@ void writeQaBundle(const std::filesystem::path& path, out << qa.dump(2); } +// A tensor pack at `modelRoot`, or nullopt with the reason. Never throws: a +// malformed manifest is a reason to fall back, not a failed import. +std::optional loadTensorPack(const std::filesystem::path& modelRoot, std::string& reasonOut) { + try { + auto pack = ModelPackLoader().load(modelRoot); + if (!pack.has_value()) { + reasonOut = "no model pack manifest at " + modelRoot.string(); + return std::nullopt; + } + if (!pack->tensorContract.has_value()) { + reasonOut = "model pack '" + pack->id + "' has no tensor_contract"; + return std::nullopt; + } + std::error_code error; + if (pack->modelFile.empty() || !std::filesystem::is_regular_file(modelRoot / pack->modelFile, error) || error) { + reasonOut = "model pack '" + pack->id + "' is missing its model file '" + pack->modelFile + "'"; + return std::nullopt; + } + return pack; + } catch (const std::exception& exception) { + reasonOut = std::string("model pack manifest could not be read: ") + exception.what(); + return std::nullopt; + } +} + +domain::StemRole roleForTensorStem(const std::string& name) { + if (name == "vocals") { + return domain::StemRole::Vocals; + } + if (name == "instrumental") { + return domain::StemRole::Music; + } + return domain::StemRole::Unknown; +} + +// Runs the tensor pack end to end and writes one WAV per stem. On any failure +// returns success == false with the reason and leaves no stems in the result; +// the caller then takes the existing path. +StemSeparator::SeparationResult runTensorSeparation(const std::filesystem::path& modelRoot, + const engine::AudioBuffer& mix, + const std::filesystem::path& outputDir, + const StemSeparator::SeparationOptions& options) { + StemSeparator::SeparationResult result; + std::string reason; + const auto pack = loadTensorPack(modelRoot, reason); + if (!pack.has_value()) { + result.logMessage = reason; + return result; + } + + auto config = runnerConfigFromContract(*pack->tensorContract, reason); + if (!config.has_value()) { + result.logMessage = "tensor_contract rejected: " + reason; + return result; + } + config->progressCallback = options.tensorProgress; + + OnnxTensorInference inference; + inference.setTensorContract(pack->tensorContract); + if (!inference.loadModel(modelRoot / pack->modelFile)) { + result.logMessage = "tensor model did not load: " + inference.backendDiagnostics(); + return result; + } + + auto separated = SeparationRunner::separate(mix, inference, *config); + if (!separated.usedModel) { + result.logMessage = "tensor separation failed: " + separated.logMessage; + return result; + } + + util::WavWriter writer; + for (std::size_t index = 0; index < separated.stemAudio.size(); ++index) { + const auto& name = separated.stemNames[index]; + const auto stemPath = outputDir / ("stem_" + name + ".wav"); + writer.write(stemPath, separated.stemAudio[index], 24); + result.generatedFiles.push_back(stemPath); + + // No separationConfidence / separationArtifactRisk: the graph reports + // neither, and a hardcoded number would read as a measurement. + domain::Stem stem; + stem.id = "sep_" + name; + stem.name = "Separated " + titleCase(name); + stem.filePath = stemPath.string(); + stem.role = roleForTensorStem(name); + stem.origin = domain::StemOrigin::Separated; + stem.enabled = true; + result.stems.push_back(std::move(stem)); + } + + std::string residuals; + for (const auto& stem : pack->tensorContract->stems) { + if (!stem.residualOf.empty()) { + residuals += " '" + stem.name + "' is the residual (mix - " + stem.residualOf + "), not a second separation."; + } + } + + result.success = true; + result.usedModel = true; + result.stemVariantCount = static_cast(result.stems.size()); + result.qaMetrics = computeQaMetrics(mix, separated.stemAudio); + result.logMessage = "Tensor separation via pack '" + pack->id + "'." + residuals + " " + separated.logMessage; + return result; +} + } // namespace StemSeparator::StemSeparator(std::filesystem::path modelRoot) : modelRoot_(std::move(modelRoot)) {} @@ -1010,6 +1117,11 @@ bool StemSeparator::isModelAvailable() const { return !resolveModelPath().empty(); } +bool StemSeparator::isTensorModelAvailable() const { + std::string reason; + return loadTensorPack(modelRoot_, reason).has_value(); +} + StemSeparator::SeparationResult StemSeparator::separate(const std::filesystem::path& mixPath, const std::filesystem::path& outputDir, const SeparationOptions& options) const { @@ -1025,6 +1137,15 @@ StemSeparator::SeparationResult StemSeparator::separate(const std::filesystem::p std::filesystem::create_directories(outputDir); + std::string tensorFallbackNote; + if (options.useTensorModel) { + auto tensorResult = runTensorSeparation(modelRoot_, mixBuffer, outputDir, options); + if (tensorResult.success) { + return tensorResult; + } + tensorFallbackNote = "Tensor separation unavailable (" + tensorResult.logMessage + "); existing separator used. "; + } + auto variants = discoverModelVariants(modelRoot_); if (variants.empty()) { const auto fallbackModel = resolveModelPath(); @@ -1079,7 +1200,7 @@ StemSeparator::SeparationResult StemSeparator::separate(const std::filesystem::p result.stemVariantCount = separated.stemCount; result.qaMetrics = computeQaMetrics(mixBuffer, separated.stems); result.qaReportPath = outputDir / "separation_qa_report.json"; - result.logMessage = separated.logMessage; + result.logMessage = tensorFallbackNote + separated.logMessage; writeQaBundle(result.qaReportPath, result, separated.stemRoles, separated, selectedVariant); diff --git a/src/ai/StemSeparator.h b/src/ai/StemSeparator.h index d3e16ee..8fd0cb6 100644 --- a/src/ai/StemSeparator.h +++ b/src/ai/StemSeparator.h @@ -1,6 +1,8 @@ #pragma once #include +#include +#include #include #include @@ -14,6 +16,13 @@ class StemSeparator final { std::optional targetStemCount; std::optional gpuMemoryBudgetMb; std::optional maxStreams; + // Set from RenderSettings::tensorSeparationEnabled. When the model root holds + // a pack with a tensor_contract, separation runs through SeparationRunner; + // any failure falls back to the existing path. Off: behaviour is unchanged. + bool useTensorModel = false; + // Tensor path only, called from the separating thread. JUCE-free so the + // caller owns any message-thread marshalling. + std::function tensorProgress; }; struct SeparationQaMetrics { @@ -36,6 +45,9 @@ class StemSeparator final { explicit StemSeparator(std::filesystem::path modelRoot = "assets/models/stem-separator"); [[nodiscard]] bool isModelAvailable() const; + // True when the model root is a pack carrying a tensor_contract whose model + // file exists. Says nothing about whether ONNX Runtime can open it. + [[nodiscard]] bool isTensorModelAvailable() const; SeparationResult separate(const std::filesystem::path& mixPath, const std::filesystem::path& outputDir, const SeparationOptions& options = {}) const; diff --git a/src/ai/TensorTypes.cpp b/src/ai/TensorTypes.cpp new file mode 100644 index 0000000..38c59bf --- /dev/null +++ b/src/ai/TensorTypes.cpp @@ -0,0 +1,56 @@ +#include "ai/TensorTypes.h" + +namespace automix::ai { + +std::optional elementCount(const TensorSpec& spec) { + std::size_t count = 1; + int dynamicAxes = 0; + for (const auto dim : spec.dims) { + if (dim == -1) { + ++dynamicAxes; + continue; + } + if (dim < 0) { + return std::nullopt; + } + count *= static_cast(dim); + } + if (dynamicAxes > 1) { + return std::nullopt; + } + return count; +} + +bool shapesMatch(const TensorSpec& expected, const TensorSpec& actual) { + if (expected.dims.size() != actual.dims.size()) { + return false; + } + for (std::size_t axis = 0; axis < expected.dims.size(); ++axis) { + if (expected.dims[axis] != -1 && expected.dims[axis] != actual.dims[axis]) { + return false; + } + } + return true; +} + +std::string describeShape(const std::vector& dims) { + std::string text = "["; + for (std::size_t axis = 0; axis < dims.size(); ++axis) { + if (axis > 0) { + text += ", "; + } + text += std::to_string(dims[axis]); + } + text += "]"; + return text; +} + +std::string describeDtype(const TensorElementType type) { + switch (type) { + case TensorElementType::Float32: + return "float32"; + } + return "unknown"; +} + +} // namespace automix::ai diff --git a/src/ai/TensorTypes.h b/src/ai/TensorTypes.h new file mode 100644 index 0000000..a634ead --- /dev/null +++ b/src/ai/TensorTypes.h @@ -0,0 +1,48 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace automix::ai { + +// Deliberately a single member. ONNX Runtime has no complex dtype, so complex +// spectra travel as float32 with an explicit real/imag axis; any other graph +// dtype is rejected at load rather than widened silently. +enum class TensorElementType { Float32 }; + +struct TensorSpec { + std::string name; + TensorElementType elementType = TensorElementType::Float32; + std::vector dims; // -1 == dynamic axis +}; + +struct TensorBinding { + TensorSpec expected; + std::vector data; +}; + +struct Tensor { + TensorSpec spec; + std::vector data; +}; + +struct TensorInferenceResult { + bool usedModel = false; + std::vector outputs; // named, never positional + std::string logMessage; +}; + +// Product of the static dims, treating a single dynamic axis as 1. nullopt when +// more than one axis is dynamic, because the volume is then undetermined. +std::optional elementCount(const TensorSpec& spec); + +// Equal rank, and every non-dynamic expected dim equals the actual dim. +bool shapesMatch(const TensorSpec& expected, const TensorSpec& actual); + +std::string describeShape(const std::vector& dims); +std::string describeDtype(TensorElementType type); + +} // namespace automix::ai diff --git a/src/analysis/SpectrogramFrontEnd.cpp b/src/analysis/SpectrogramFrontEnd.cpp new file mode 100644 index 0000000..fa35512 --- /dev/null +++ b/src/analysis/SpectrogramFrontEnd.cpp @@ -0,0 +1,251 @@ +#include "analysis/SpectrogramFrontEnd.h" + +#include +#include +#include + +#include + +namespace automix::analysis { +namespace { + +bool isPowerOfTwo(const int value) { + return value > 0 && (value & (value - 1)) == 0; +} + +int fftOrderFor(const int nFft) { + int order = 0; + while ((1 << order) < nFft) { + ++order; + } + return order; +} + +void validate(const StftParams& params) { + if (!isPowerOfTwo(params.nFft)) { + throw std::invalid_argument("STFT n_fft must be a power of two, got " + std::to_string(params.nFft)); + } + if (params.hopLength <= 0) { + throw std::invalid_argument("STFT hop_length must be positive, got " + std::to_string(params.hopLength)); + } + if (params.winLength <= 0 || params.winLength > params.nFft) { + throw std::invalid_argument("STFT win_length must be in (0, n_fft], got " + std::to_string(params.winLength)); + } +} + +// analyze() side of `center`: samples of reflect padding added at each end. +int centrePadSamples(const StftParams& params) { + return params.center ? params.nFft / 2 : 0; +} + +// synthesize() side of `center`: samples dropped from each end of the +// overlap-added signal. Numerically equal to the pad, deliberately named apart. +int centreTrimSamples(const StftParams& params) { + return params.center ? params.nFft / 2 : 0; +} + +// Periodic Hann of winLength (torch.hann_window(periodic=True)), zero-padded +// centred into nFft as torch.stft does when win_length < n_fft. JUCE's hann is +// the symmetric form, so the periodic window of length N is the first N taps of +// the symmetric window of length N + 1. +std::vector makeAnalysisWindow(const StftParams& params) { + std::vector symmetric(static_cast(params.winLength) + 1, 0.0f); + juce::dsp::WindowingFunction::fillWindowingTables( + symmetric.data(), symmetric.size(), juce::dsp::WindowingFunction::hann, false); + + std::vector window(static_cast(params.nFft), 0.0f); + const auto offset = static_cast((params.nFft - params.winLength) / 2); + for (std::size_t i = 0; i < static_cast(params.winLength); ++i) { + window[offset + i] = symmetric[i]; + } + return window; +} + +} // namespace + +int stftFrameCount(const int samples, const StftParams& params) { + if (params.center) { + return 1 + samples / params.hopLength; + } + if (samples < params.nFft) { + return 0; + } + return 1 + (samples - params.nFft) / params.hopLength; +} + +int istftLength(const int frames, const StftParams& params) { + const int overlapAdded = params.nFft + params.hopLength * (frames - 1); + return overlapAdded - 2 * centreTrimSamples(params); +} + +Spectrogram analyze(const engine::AudioBuffer& audio, const StftParams& params) { + validate(params); + const int channels = audio.getNumChannels(); + const int samples = audio.getNumSamples(); + const int pad = centrePadSamples(params); + if (params.center && samples <= pad) { + throw std::invalid_argument("STFT reflect padding of " + std::to_string(pad) + + " samples needs more input than the " + std::to_string(samples) + " samples given"); + } + const int frames = stftFrameCount(samples, params); + if (channels <= 0 || frames <= 0) { + throw std::invalid_argument("STFT input has no complete frame (" + std::to_string(channels) + " channels, " + + std::to_string(samples) + " samples)"); + } + + Spectrogram spectrogram; + spectrogram.channels = channels; + spectrogram.freqBins = params.nFft / 2 + 1; + spectrogram.frames = frames; + spectrogram.sampleRate = audio.getSampleRate(); + const std::size_t planeSize = + static_cast(channels) * static_cast(spectrogram.freqBins) * static_cast(frames); + spectrogram.real.assign(planeSize, 0.0f); + spectrogram.imag.assign(planeSize, 0.0f); + + const auto window = makeAnalysisWindow(params); + juce::dsp::FFT fft(fftOrderFor(params.nFft)); + const float scale = params.normalized ? 1.0f / std::sqrt(static_cast(params.nFft)) : 1.0f; + + const int paddedLength = samples + 2 * pad; + std::vector padded(static_cast(paddedLength), 0.0f); + std::vector fftData(static_cast(params.nFft) * 2, 0.0f); + + for (int ch = 0; ch < channels; ++ch) { + const float* source = audio.getReadPointer(ch); + for (int i = 0; i < samples; ++i) { + padded[static_cast(pad + i)] = source[i]; + } + // torch pad_mode='reflect': mirror about the edge sample, edge excluded. + for (int j = 0; j < pad; ++j) { + padded[static_cast(pad - 1 - j)] = source[j + 1]; + padded[static_cast(pad + samples + j)] = source[samples - 2 - j]; + } + + for (int frame = 0; frame < frames; ++frame) { + const std::size_t start = static_cast(frame) * static_cast(params.hopLength); + std::fill(fftData.begin(), fftData.end(), 0.0f); + for (std::size_t n = 0; n < static_cast(params.nFft); ++n) { + fftData[n] = padded[start + n] * window[n]; + } + fft.performRealOnlyForwardTransform(fftData.data(), true); + for (int bin = 0; bin < spectrogram.freqBins; ++bin) { + const auto index = spectrogram.index(ch, bin, frame); + spectrogram.real[index] = fftData[static_cast(2 * bin)] * scale; + spectrogram.imag[index] = fftData[static_cast(2 * bin + 1)] * scale; + } + } + } + return spectrogram; +} + +engine::AudioBuffer synthesize(const Spectrogram& spectrogram, const StftParams& params) { + validate(params); + if (spectrogram.freqBins != params.nFft / 2 + 1) { + throw std::invalid_argument("iSTFT expects " + std::to_string(params.nFft / 2 + 1) + " frequency bins for n_fft " + + std::to_string(params.nFft) + ", got " + std::to_string(spectrogram.freqBins)); + } + const std::size_t planeSize = static_cast(spectrogram.channels) * + static_cast(spectrogram.freqBins) * + static_cast(spectrogram.frames); + if (spectrogram.channels <= 0 || spectrogram.frames <= 0 || spectrogram.real.size() != planeSize || + spectrogram.imag.size() != planeSize) { + throw std::invalid_argument("iSTFT spectrogram planes do not match their declared geometry"); + } + + const int frames = spectrogram.frames; + const int nyquist = params.nFft / 2; + const int overlapAddedLength = params.nFft + params.hopLength * (frames - 1); + const int trim = centreTrimSamples(params); + const int outputLength = istftLength(frames, params); + if (outputLength <= 0) { + throw std::invalid_argument("iSTFT of " + std::to_string(frames) + " frames yields no samples"); + } + + const auto window = makeAnalysisWindow(params); + juce::dsp::FFT fft(fftOrderFor(params.nFft)); + const float scale = params.normalized ? std::sqrt(static_cast(params.nFft)) : 1.0f; + + // Window-squared envelope: frames overlap heavily (hop < win), so the + // overlap-added signal is divided by sum(w^2) at each sample, as torch.istft + // does. Identical for every channel. + std::vector envelope(static_cast(overlapAddedLength), 0.0); + for (int frame = 0; frame < frames; ++frame) { + const std::size_t start = static_cast(frame) * static_cast(params.hopLength); + for (std::size_t n = 0; n < static_cast(params.nFft); ++n) { + envelope[start + n] += static_cast(window[n]) * static_cast(window[n]); + } + } + + engine::AudioBuffer output(spectrogram.channels, outputLength, spectrogram.sampleRate); + std::vector accumulator(static_cast(overlapAddedLength), 0.0); + std::vector fftData(static_cast(params.nFft) * 2, 0.0f); + + for (int ch = 0; ch < spectrogram.channels; ++ch) { + std::fill(accumulator.begin(), accumulator.end(), 0.0); + for (int frame = 0; frame < frames; ++frame) { + std::fill(fftData.begin(), fftData.end(), 0.0f); + for (int bin = 0; bin <= nyquist; ++bin) { + const auto index = spectrogram.index(ch, bin, frame); + fftData[static_cast(2 * bin)] = spectrogram.real[index] * scale; + // irfft semantics: the imaginary parts of the DC and Nyquist bins of a + // real signal's spectrum are discarded, not folded into the output. + const bool realOnlyBin = bin == 0 || bin == nyquist; + fftData[static_cast(2 * bin + 1)] = realOnlyBin ? 0.0f : spectrogram.imag[index] * scale; + } + fft.performRealOnlyInverseTransform(fftData.data()); + const std::size_t start = static_cast(frame) * static_cast(params.hopLength); + for (std::size_t n = 0; n < static_cast(params.nFft); ++n) { + accumulator[start + n] += static_cast(fftData[n]) * static_cast(window[n]); + } + } + + float* destination = output.getWritePointer(ch); + for (int i = 0; i < outputLength; ++i) { + const auto source = static_cast(i + trim); + const double norm = envelope[source]; + destination[i] = norm > 1.0e-11 ? static_cast(accumulator[source] / norm) : 0.0f; + } + } + return output; +} + +Spectrogram complexMultiply(const Spectrogram& signal, const Spectrogram& mask, const bool zeroDc) { + const auto describe = [](const Spectrogram& s) { + return "[" + std::to_string(s.channels) + " ch, " + std::to_string(s.freqBins) + " bins, " + + std::to_string(s.frames) + " frames, 2 planes]"; + }; + const auto planeSize = [](const Spectrogram& s) { + return static_cast(s.channels) * static_cast(s.freqBins) * + static_cast(s.frames); + }; + if (signal.channels != mask.channels || signal.freqBins != mask.freqBins || signal.frames != mask.frames) { + throw std::invalid_argument("mask shape " + describe(mask) + " does not match signal shape " + describe(signal)); + } + if (signal.real.size() != planeSize(signal) || signal.imag.size() != planeSize(signal) || + mask.real.size() != planeSize(mask) || mask.imag.size() != planeSize(mask)) { + throw std::invalid_argument("real/imag planes do not match the declared shape " + describe(signal)); + } + + Spectrogram product = signal; + for (std::size_t i = 0; i < product.real.size(); ++i) { + const float a = signal.real[i]; + const float b = signal.imag[i]; + const float c = mask.real[i]; + const float d = mask.imag[i]; + product.real[i] = a * c - b * d; + product.imag[i] = a * d + b * c; + } + if (zeroDc) { + for (int ch = 0; ch < product.channels; ++ch) { + for (int frame = 0; frame < product.frames; ++frame) { + const auto index = product.index(ch, 0, frame); + product.real[index] = 0.0f; + product.imag[index] = 0.0f; + } + } + } + return product; +} + +} // namespace automix::analysis diff --git a/src/analysis/SpectrogramFrontEnd.h b/src/analysis/SpectrogramFrontEnd.h new file mode 100644 index 0000000..88c0b84 --- /dev/null +++ b/src/analysis/SpectrogramFrontEnd.h @@ -0,0 +1,61 @@ +#pragma once + +#include +#include + +#include "engine/AudioBuffer.h" + +namespace automix::analysis { + +// torch.stft / torch.istft conventions, named explicitly because each one +// silently changes the spectrum if misread. The window is always a periodic +// Hann (torch.hann_window default); padding under `center` is always reflect. +struct StftParams { + int nFft = 2048; + int hopLength = 441; + int winLength = 2048; + // One flag, two opposite operations: analyze() reflect-pads nFft/2 samples at + // each end, synthesize() drops nFft/2 samples at each end. + bool center = true; + // torch.stft(normalized=True) scales the forward transform by 1/sqrt(nFft). + bool normalized = false; + // Applied by complexMultiply(): zero bin 0 of the masked spectrum before + // synthesis. Carried here because it is a property of how a mask is applied. + bool zeroDc = true; +}; + +// Complex spectrum stored as two float planes, laid out [channel][bin][frame] +// (torch's (C, F, T) order). Keeps what StemAnalyzer discards: phase and +// channel identity. +struct Spectrogram { + int channels = 0; + int freqBins = 0; + int frames = 0; + double sampleRate = 44100.0; + std::vector real; + std::vector imag; + + [[nodiscard]] std::size_t index(int channel, int bin, int frame) const { + return (static_cast(channel) * static_cast(freqBins) + static_cast(bin)) * + static_cast(frames) + + static_cast(frame); + } +}; + +// Number of frames analyze() produces for `samples` input samples. +int stftFrameCount(int samples, const StftParams& params); + +// Exact synthesize() output length: hop * (frames - 1) under `center`. +int istftLength(int frames, const StftParams& params); + +// Throws std::invalid_argument for parameters outside the implemented set and +// for inputs too short to reflect-pad (torch raises in the same case). +Spectrogram analyze(const engine::AudioBuffer& audio, const StftParams& params); +engine::AudioBuffer synthesize(const Spectrogram& spectrogram, const StftParams& params); + +// Mask-mode application. Throws std::invalid_argument naming both shapes when +// the mask's geometry does not match the signal exactly: a plausible but wrong +// mask shape would otherwise yield numerically valid garbage. +Spectrogram complexMultiply(const Spectrogram& signal, const Spectrogram& mask, bool zeroDc); + +} // namespace automix::analysis diff --git a/src/app/controllers/ImportController.cpp b/src/app/controllers/ImportController.cpp index 6dccf0b..1a7f487 100644 --- a/src/app/controllers/ImportController.cpp +++ b/src/app/controllers/ImportController.cpp @@ -28,7 +28,8 @@ void ImportController::importFiles(std::vector files, const bool useSeparation, const int preferredStemCount, std::atomic_bool& cancelFlag, - std::optional separationModelRoot) { + std::optional separationModelRoot, + const bool useTensorModel) { if (files.empty()) { return; } @@ -67,6 +68,7 @@ void ImportController::importFiles(std::vector files, bool useSeparation; int preferredStemCount; std::optional separationModelRoot; + bool useTensorModel; std::atomic_bool* cancelFlag; Callbacks callbacks; @@ -74,6 +76,7 @@ void ImportController::importFiles(std::vector files, bool sep, int stemCount, std::optional separationRoot, + bool tensor, std::atomic_bool* cancel, Callbacks cb) : juce::ThreadPoolJob("ImportJob"), @@ -81,6 +84,7 @@ void ImportController::importFiles(std::vector files, useSeparation(sep), preferredStemCount(stemCount), separationModelRoot(std::move(separationRoot)), + useTensorModel(tensor), cancelFlag(cancel), callbacks(std::move(cb)) {} @@ -128,7 +132,9 @@ void ImportController::importFiles(std::vector files, if (!result.cancelled) { ai::StemSeparator separator(separationModelRoot.value_or(std::filesystem::path("assets/models/stem-separator"))); if (separationModelRoot.has_value()) { - if (separator.isModelAvailable()) { + if (useTensorModel && separator.isTensorModelAvailable()) { + importLines.push_back("Tensor separation pack: " + separationModelRoot->string()); + } else if (separator.isModelAvailable()) { importLines.push_back("Separation model pack: " + separationModelRoot->string()); } else { importLines.push_back("Separation model pack unavailable, falling back to bundled separator."); @@ -137,6 +143,15 @@ void ImportController::importFiles(std::vector files, } ai::StemSeparator::SeparationOptions separationOptions; separationOptions.targetStemCount = preferredStemCount; + separationOptions.useTensorModel = useTensorModel; + if (useTensorModel) { + // Chunk progress fills the band between the 0.12 and 0.78 marks. + separationOptions.tensorProgress = [cb = callbacks](const int done, const int total) { + if (total > 0) { + emitProgress(cb, 0.12 + 0.66 * static_cast(done) / static_cast(total)); + } + }; + } const auto separationResult = separator.separate(mixPath, outputDir, separationOptions); emitProgress(callbacks, 0.78); if (separationResult.success) { @@ -224,7 +239,7 @@ void ImportController::importFiles(std::vector files, }; threadPool_.addJob( - new ImportJob(std::move(files), useSeparation, preferredStemCount, std::move(separationModelRoot), &cancelFlag, callbacks_), + new ImportJob(std::move(files), useSeparation, preferredStemCount, std::move(separationModelRoot), useTensorModel, &cancelFlag, callbacks_), true); } diff --git a/src/app/controllers/ImportController.h b/src/app/controllers/ImportController.h index 44e302d..03e24ce 100644 --- a/src/app/controllers/ImportController.h +++ b/src/app/controllers/ImportController.h @@ -36,7 +36,8 @@ class ImportController { bool useSeparation, int preferredStemCount, std::atomic_bool& cancelFlag, - std::optional separationModelRoot = std::nullopt); + std::optional separationModelRoot = std::nullopt, + bool useTensorModel = false); private: juce::ThreadPool& threadPool_; diff --git a/src/app/ui/MainLayout.cpp b/src/app/ui/MainLayout.cpp index f018096..14d6ab4 100644 --- a/src/app/ui/MainLayout.cpp +++ b/src/app/ui/MainLayout.cpp @@ -1164,7 +1164,8 @@ void MainLayout::importFiles(std::vector files) { useSeparation, sessionManager_.session().preferredStemCount, taskOrchestrator_->cancelFlag(ActiveTask::Import), - std::move(separationModelRoot)); + std::move(separationModelRoot), + sessionManager_.session().renderSettings.tensorSeparationEnabled); } bool MainLayout::startAiSeparationBeforeAutoMixIfNeeded() { diff --git a/src/domain/RenderSettings.h b/src/domain/RenderSettings.h index 51cfff8..a951c1f 100644 --- a/src/domain/RenderSettings.h +++ b/src/domain/RenderSettings.h @@ -27,6 +27,10 @@ struct RenderSettings { // additionally requires the experimental toggle, CC BY-NC consent and a complete // pack. Never default this on. bool itoMasteringEnabled = false; + // Tensor (BS-RoFormer) stem separation on single-mix import. Not sufficient on + // its own: StemSeparator additionally requires an active separation pack that + // carries a tensor_contract and a native ONNX Runtime build. Never default this on. + bool tensorSeparationEnabled = false; std::string metadataPolicy = "copy_all"; std::map metadataTemplate; std::string rendererName = "PhaseLimiter"; diff --git a/tests/fixtures/tensor/external_data.onnx b/tests/fixtures/tensor/external_data.onnx new file mode 100644 index 0000000000000000000000000000000000000000..081e4e809c8aac6ef30020cab0d6bcf7fd442905 GIT binary patch literal 179 zcmZ9FF%E)25CB~yWV0f{wXoODM52v_owWYI#+-5}1n&$i;rNK}6-zCqnM^W>9IICB zvv8ZdaMISgIlO@)0!Pp>^vm%v%Xq4t=-R02k||N^69W1%q|lWs!ph#16xQ(Kr%J3b wF8TK5gaVuAjTVM&8B$PbbC(BuyZsB!^Wnu2g$Rt~Aq@D}LH7SL^=TB(J}OlyH~;_u literal 0 HcmV?d00001 diff --git a/tests/fixtures/tensor/external_data.onnx.data b/tests/fixtures/tensor/external_data.onnx.data new file mode 100644 index 0000000000000000000000000000000000000000..8db91db9674aa2d6dfc82f483ab345d9ad0f29f7 GIT binary patch literal 16 UcmZQzXs~BsU~m8;AZ~B~01$%$KmY&$ literal 0 HcmV?d00001 diff --git a/tests/fixtures/tensor/identity_mask.onnx b/tests/fixtures/tensor/identity_mask.onnx new file mode 100644 index 0000000000000000000000000000000000000000..e59ebcc132c53c240cbadcfad6788e3f67c386e4 GIT binary patch literal 440 zcmZ8dO-sZu5N+DJ&5pQsg9t8)Yf%>pg5b@Ib&mxvq8Bfr(kTYFA2tcBdlK6ix2jb6Yp^bj^> zwGfKSr=&vW)+qA8yTum53A6{WT^7izg_gYW;DUL1BA>a+0_QqS;T+buCo&Zb@9LzG zRVgtXgIi&pM^T#tk6x%tSBihN-efZ;*#THE6LAJ%_|n-g3$5#r6o!no++t$C7C6a uLL8ZSC1xh^nfXx?aFu*qJRIyo99&Esj6lqiB*TTIR|rkkiG_ output [1, 1, 2050, 801, 2] + x = helper.make_tensor_value_info("input", TensorProto.FLOAT, [1, 801, 4100]) + y = helper.make_tensor_value_info("output", TensorProto.FLOAT, [1, 1, 2050, 801, 2]) + inits = [ + numpy_helper.from_array(np.array(0.0, dtype=np.float32), "zero"), + numpy_helper.from_array(np.array([1, 801, 2050, 2], dtype=np.int64), "split_shape"), + numpy_helper.from_array(np.array([1.0, 0.0], dtype=np.float32), "one_plus_zero_i"), + numpy_helper.from_array(np.array([1], dtype=np.int64), "stem_axis"), + ] + nodes = [ + helper.make_node("Mul", ["input", "zero"], ["zeroed"]), + helper.make_node("Reshape", ["zeroed", "split_shape"], ["reim"]), + helper.make_node("Add", ["reim", "one_plus_zero_i"], ["mask_tf"]), + helper.make_node("Transpose", ["mask_tf"], ["mask_ft"], perm=[0, 2, 1, 3]), + helper.make_node("Unsqueeze", ["mask_ft", "stem_axis"], ["output"]), + ] + save(helper.make_graph(nodes, "identity_mask", [x], [y], inits), "identity_mask.onnx") + + +def int64_io(): + # A quantized/tokenised export whose I/O is not float32: must be refused at load. + x = helper.make_tensor_value_info("tokens", TensorProto.INT64, [1, 4]) + y = helper.make_tensor_value_info("tokens_out", TensorProto.INT64, [1, 4]) + nodes = [helper.make_node("Identity", ["tokens"], ["tokens_out"])] + save(helper.make_graph(nodes, "int64_io", [x], [y]), "int64_io.onnx") + + +def external_data(): + # fp32 exports keep weights in a `.onnx.data` sidecar. + x = helper.make_tensor_value_info("x", TensorProto.FLOAT, [1, 4]) + y = helper.make_tensor_value_info("y", TensorProto.FLOAT, [1, 4]) + weight = numpy_helper.from_array(np.array([[1.0, 2.0, 3.0, 4.0]], dtype=np.float32), "w") + nodes = [helper.make_node("Add", ["x", "w"], ["y"])] + save(helper.make_graph(nodes, "external_data", [x], [y], [weight]), + "external_data.onnx", + save_as_external_data=True, + all_tensors_to_one_file=True, + location="external_data.onnx.data", + size_threshold=0) + + +if __name__ == "__main__": + identity_mask() + int64_io() + external_data() diff --git a/tests/unit/GpuBenchmarkTests.cpp b/tests/unit/GpuBenchmarkTests.cpp index 9b5716d..65635b4 100644 --- a/tests/unit/GpuBenchmarkTests.cpp +++ b/tests/unit/GpuBenchmarkTests.cpp @@ -241,6 +241,7 @@ TEST_CASE("OnnxModelInference OOM failure triggers recovery counters and CPU re- const auto modelPath = createDummyModel(tempDir); createDummyMetadataWithGpu(modelPath); REQUIRE(inference.loadModel(modelPath)); + inference.pinExecutionProvidersForTesting({"cuda", "cpu"}); // Setup proves the GPU provider is actually selectable before any failure: // the recovery assertions are only meaningful if resolution picks "cuda". @@ -286,6 +287,7 @@ TEST_CASE("OnnxModelInference device-lost failure triggers recovery counters and const auto modelPath = createDummyModel(tempDir); createDummyMetadataWithGpu(modelPath); REQUIRE(inference.loadModel(modelPath)); + inference.pinExecutionProvidersForTesting({"cuda", "cpu"}); REQUIRE(inference.activeExecutionProvider() == "cuda"); // When: a GPU device-lost failure is simulated on the active provider. @@ -320,6 +322,7 @@ TEST_CASE("OnnxModelInference resolution skips failed provider on later re-resol const auto modelPath = createDummyModel(tempDir); createDummyMetadataWithGpu(modelPath); REQUIRE(inference.loadModel(modelPath)); + inference.pinExecutionProvidersForTesting({"cuda", "cpu"}); REQUIRE(inference.activeExecutionProvider() == "cuda"); // Given: the GPU provider has failed once and resolution fell back to CPU. @@ -347,6 +350,7 @@ TEST_CASE("OnnxModelInference repeated failure counts each recovery but records const auto modelPath = createDummyModel(tempDir); createDummyMetadataWithGpu(modelPath); REQUIRE(inference.loadModel(modelPath)); + inference.pinExecutionProvidersForTesting({"cuda", "cpu"}); REQUIRE(inference.activeExecutionProvider() == "cuda"); // When: the same provider fails twice. @@ -375,6 +379,7 @@ TEST_CASE("OnnxModelInference non-recoverable failure marks provider failed with const auto modelPath = createDummyModel(tempDir); createDummyMetadataWithGpu(modelPath); REQUIRE(inference.loadModel(modelPath)); + inference.pinExecutionProvidersForTesting({"cuda", "cpu"}); REQUIRE(inference.activeExecutionProvider() == "cuda"); // When: a non-OOM / non-device-lost failure is simulated. diff --git a/tests/unit/TensorInferenceTests.cpp b/tests/unit/TensorInferenceTests.cpp new file mode 100644 index 0000000..fe5b282 --- /dev/null +++ b/tests/unit/TensorInferenceTests.cpp @@ -0,0 +1,986 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "ai/BsRoformerPack.h" +#include "ai/ITensorInference.h" +#include "ai/ModelCatalogValidator.h" +#include "ai/ModelLicensePolicy.h" +#include "ai/ModelPackLoader.h" +#include "ai/OnnxTensorInference.h" +#include "ai/SeparationRunner.h" +#include "ai/StemSeparator.h" +#include "ai/TensorTypes.h" +#include "analysis/SpectrogramFrontEnd.h" +#include "domain/RenderSettings.h" +#include "engine/AudioBuffer.h" +#include "engine/AudioFileIO.h" +#include "util/WavWriter.h" +#include "TorchStftGolden.h" + +namespace ai = automix::ai; +namespace analysis = automix::analysis; +namespace engine = automix::engine; + +namespace { + +// BS-RoFormer's STFT (spec 5.4.1). +analysis::StftParams roformerStft() { + analysis::StftParams params; + params.nFft = 2048; + params.hopLength = 441; + params.winLength = 2048; + params.center = true; + params.normalized = false; + params.zeroDc = true; + return params; +} + +engine::AudioBuffer makeTestSignal(const int channels, const int samples, const double sampleRate) { + engine::AudioBuffer buffer(channels, samples, sampleRate); + for (int ch = 0; ch < channels; ++ch) { + for (int i = 0; i < samples; ++i) { + const double t = static_cast(i) / sampleRate; + const double value = 0.4 * std::sin(2.0 * 3.14159265358979323846 * (220.0 + 110.0 * ch) * t) + + 0.2 * std::sin(2.0 * 3.14159265358979323846 * 3130.0 * t + 0.3 * ch) + + 0.05 * std::sin(0.37 * static_cast(i * (ch + 3))); + buffer.setSample(ch, i, static_cast(value)); + } + } + return buffer; +} + +float maxAbsDifference(const engine::AudioBuffer& a, const engine::AudioBuffer& b) { + float worst = 0.0f; + for (int ch = 0; ch < a.getNumChannels(); ++ch) { + for (int i = 0; i < a.getNumSamples(); ++i) { + worst = std::max(worst, std::abs(a.getSample(ch, i) - b.getSample(ch, i))); + } + } + return worst; +} + +// Records the bindings it is handed and answers with a scripted output. This is +// the stand-in for a real graph that lets the runner be proven with no ORT SDK. +class FakeTensorInference final : public ai::ITensorInference { + public: + using Script = std::function&)>; + + FakeTensorInference(std::vector inputs, std::vector outputs, Script script) + : inputs_(std::move(inputs)), outputs_(std::move(outputs)), script_(std::move(script)) {} + + bool isAvailable() const override { return true; } + bool loadModel(const std::filesystem::path&) override { return true; } + std::vector inputSpecs() const override { return inputs_; } + std::vector outputSpecs() const override { return outputs_; } + ai::TensorInferenceResult run(const std::vector& inputs) const override { + ++calls; + lastBindings = inputs; + return script_(inputs); + } + + mutable int calls = 0; + mutable std::vector lastBindings; + + private: + std::vector inputs_; + std::vector outputs_; + Script script_; +}; + +const ai::TensorSpec kRoformerInput{"mix_spec", ai::TensorElementType::Float32, {1, 801, 4100}}; +const ai::TensorSpec kRoformerOutput{"vocals_mask", ai::TensorElementType::Float32, {1, 1, 2050, 801, 2}}; + +// A mask of 1 + 0i: the masked spectrum is the input spectrum. +ai::TensorInferenceResult identityMask(const std::vector&) { + ai::TensorInferenceResult result; + result.usedModel = true; + ai::Tensor mask; + mask.spec = kRoformerOutput; + mask.data.assign(1 * 1 * 2050 * 801 * 2, 0.0f); + for (std::size_t i = 0; i < mask.data.size(); i += 2) { + mask.data[i] = 1.0f; + } + result.outputs.push_back(std::move(mask)); + return result; +} + +ai::RunnerConfig roformerRunnerConfig() { + ai::RunnerConfig config; + config.stft = roformerStft(); + config.inputLayout = ai::InputLayout::FoldedStereo; + config.outputMode = ai::OutputMode::Mask; + config.targetStem = "vocals"; + config.stems = {{"vocals", ""}, {"instrumental", "vocals"}}; + return config; +} + +// Spec section 6, installed form (names present). +nlohmann::json roformerContractJson() { + return nlohmann::json::parse(R"({ + "engine": "bs_roformer", + "sample_rate": 44100, + "stereo": true, + "chunk_samples": 352800, + "overlap_samples": 88200, + "stft": { "n_fft": 2048, "hop_length": 441, "win_length": 2048, + "window": "hann", "periodic_window": true, + "center": true, "pad_mode": "reflect", "normalized": false, "zero_dc": true }, + "input_layout": "folded_stereo", + "inputs": [ { "name": "input", "shape": [1, 801, 4100], "dtype": "float32" } ], + "outputs": [ { "name": "output", "shape": [1, 1, 2050, 801, 2], "dtype": "float32" } ], + "output_mode": "mask", + "target_stem": "vocals", + "stems": [ { "name": "vocals" }, { "name": "instrumental", "residual_of": "vocals" } ] + })"); +} + +ai::TensorContract parseContract(const nlohmann::json& json) { + std::string error; + const auto contract = ai::parseTensorContract(json, error); + REQUIRE(contract.has_value()); + REQUIRE(error.empty()); + return *contract; +} + +const std::vector kProbedInputs{{"input", ai::TensorElementType::Float32, {1, 801, 4100}}}; +const std::vector kProbedOutputs{{"output", ai::TensorElementType::Float32, {1, 1, 2050, 801, 2}}}; + +std::string contractError(const nlohmann::json& json, + const std::vector& inputs = kProbedInputs, + const std::vector& outputs = kProbedOutputs) { + const auto contract = parseContract(json); + std::string error; + const bool ok = ai::checkTensorContract(contract, inputs, outputs, error); + REQUIRE_FALSE(ok); + REQUIRE_FALSE(error.empty()); + return error; +} + +} // namespace + +TEST_CASE("Tensor contract validation table", "[ai][tensor]") { + const ai::TensorSpec dynamicBatch{"input", ai::TensorElementType::Float32, {-1, 801, 4100}}; + const ai::TensorSpec probed{"input", ai::TensorElementType::Float32, {1, 801, 4100}}; + const ai::TensorSpec probedBatch4{"input", ai::TensorElementType::Float32, {4, 801, 4100}}; + REQUIRE(ai::shapesMatch(dynamicBatch, probed)); + REQUIRE(ai::shapesMatch(dynamicBatch, probedBatch4)); + + const ai::TensorSpec wrongRank{"input", ai::TensorElementType::Float32, {1, 2, 1025, 801}}; + REQUIRE_FALSE(ai::shapesMatch(dynamicBatch, wrongRank)); + REQUIRE_FALSE(ai::shapesMatch(probed, wrongRank)); + + const ai::TensorSpec wrongStaticDim{"input", ai::TensorElementType::Float32, {1, 796, 4100}}; + REQUIRE_FALSE(ai::shapesMatch(probed, wrongStaticDim)); + REQUIRE_FALSE(ai::shapesMatch(dynamicBatch, wrongStaticDim)); + + // An expected dynamic axis matches anything, but a dynamic probed axis does not + // satisfy a static expectation: the graph promised less than was declared. + const ai::TensorSpec probedDynamic{"input", ai::TensorElementType::Float32, {1, -1, 4100}}; + REQUIRE_FALSE(ai::shapesMatch(probed, probedDynamic)); + + REQUIRE(ai::elementCount(probed) == std::optional(3284100)); + REQUIRE(ai::elementCount(dynamicBatch) == std::optional(3284100)); + const ai::TensorSpec twoDynamic{"input", ai::TensorElementType::Float32, {-1, -1, 4100}}; + REQUIRE_FALSE(ai::elementCount(twoDynamic).has_value()); + const ai::TensorSpec output{"output", ai::TensorElementType::Float32, {1, 1, 2050, 801, 2}}; + REQUIRE(ai::elementCount(output) == std::optional(3284100)); + REQUIRE(ai::elementCount(ai::TensorSpec{"scalar", ai::TensorElementType::Float32, {}}) == + std::optional(1)); + + REQUIRE(ai::describeShape({1, 801, 4100}) == "[1, 801, 4100]"); + REQUIRE(ai::describeShape({-1, 2}) == "[-1, 2]"); + REQUIRE(ai::describeShape({}) == "[]"); + REQUIRE(ai::describeDtype(ai::TensorElementType::Float32) == "float32"); +} + +TEST_CASE("Null tensor inference reports unavailable", "[ai][tensor]") { + ai::NullTensorInference null; + REQUIRE(!null.isAvailable()); + REQUIRE(!null.loadModel("any.onnx")); + REQUIRE(!null.lastLog().empty()); + REQUIRE(null.inputSpecs().empty()); + REQUIRE(null.outputSpecs().empty()); + const auto result = null.run({}); + REQUIRE(result.usedModel == false); + REQUIRE(result.outputs.empty()); + REQUIRE(result.logMessage.find("NullTensorInference") != std::string::npos); +} + +TEST_CASE("STFT round-trip identity with exact length", "[ai][tensor][stft]") { + const auto params = roformerStft(); + const auto input = makeTestSignal(2, 352800, 44100.0); + const auto spectrum = analysis::analyze(input, params); + REQUIRE(spectrum.channels == 2); + REQUIRE(spectrum.freqBins == 1025); + REQUIRE(spectrum.frames == 801); + + const auto synth = analysis::synthesize(spectrum, params); + // Equality, not tolerance: any drift here accumulates at every chunk boundary. + REQUIRE(synth.getNumSamples() == 441 * (801 - 1)); + REQUIRE(synth.getNumSamples() == input.getNumSamples()); + REQUIRE(synth.getNumChannels() == 2); + REQUIRE(maxAbsDifference(input, synth) < 1.0e-5f); +} + +TEST_CASE("Analytic DC check", "[ai][tensor][stft]") { + // Premise: reflect-padding a constant yields a constant, so every frame of a + // constant signal is constant and bin 0 equals sum(window) exactly. Switching + // the pad mode (e.g. to zero padding) invalidates this expected value and must + // be a deliberate, visible change. + const auto params = roformerStft(); + engine::AudioBuffer ones(2, 44100, 44100.0); + for (int ch = 0; ch < 2; ++ch) { + for (int i = 0; i < ones.getNumSamples(); ++i) { + ones.setSample(ch, i, 1.0f); + } + } + const auto spectrum = analysis::analyze(ones, params); + // Periodic Hann of length 2048 sums to exactly 1024. + constexpr double windowSum = 1024.0; + double worstDc = 0.0; + double worstDcImag = 0.0; + for (int ch = 0; ch < spectrum.channels; ++ch) { + for (int frame = 0; frame < spectrum.frames; ++frame) { + const auto index = spectrum.index(ch, 0, frame); + worstDc = std::max(worstDc, std::abs(static_cast(spectrum.real[index]) - windowSum)); + worstDcImag = std::max(worstDcImag, std::abs(static_cast(spectrum.imag[index]))); + } + } + REQUIRE(worstDc < 1.0e-3); + REQUIRE(worstDcImag < 1.0e-3); +} + +TEST_CASE("Complex multiply rejects a mask whose geometry differs", "[ai][tensor][stft]") { + const auto params = roformerStft(); + const auto spectrum = analysis::analyze(makeTestSignal(2, 8820, 44100.0), params); + auto shortMask = spectrum; + shortMask.frames -= 1; + bool threw = false; + try { + static_cast(analysis::complexMultiply(spectrum, shortMask, true)); + } catch (const std::invalid_argument& error) { + threw = true; + REQUIRE(std::string(error.what()).find("does not match") != std::string::npos); + } + REQUIRE(threw); + + // zeroDc clears bin 0 of the product and nothing else. + auto unitMask = spectrum; + std::fill(unitMask.real.begin(), unitMask.real.end(), 1.0f); + std::fill(unitMask.imag.begin(), unitMask.imag.end(), 0.0f); + const auto product = analysis::complexMultiply(spectrum, unitMask, true); + REQUIRE(product.real[product.index(1, 0, 3)] == 0.0f); + REQUIRE(product.real[product.index(1, 7, 3)] == spectrum.real[spectrum.index(1, 7, 3)]); + REQUIRE(product.imag[product.index(1, 7, 3)] == spectrum.imag[spectrum.index(1, 7, 3)]); +} + +TEST_CASE("Runner binds by declared name", "[ai][tensor][runner]") { + FakeTensorInference fake({kRoformerInput}, {kRoformerOutput}, identityMask); + const auto mix = makeTestSignal(2, 352800, 44100.0); + const auto result = ai::SeparationRunner::separate(mix, fake, roformerRunnerConfig()); + REQUIRE(result.usedModel); + REQUIRE(fake.calls == 1); + REQUIRE(fake.lastBindings.size() == 1); + const auto& binding = fake.lastBindings.front(); + REQUIRE(binding.expected.name == "mix_spec"); + REQUIRE(binding.expected.elementType == ai::TensorElementType::Float32); + REQUIRE(binding.expected.dims == std::vector{1, 801, 4100}); + REQUIRE(binding.data.size() == std::size_t{801} * 4100); + + // Folded-stereo placement: element (t, (f * channels + ch) * 2 + reim). + const auto spectrum = analysis::analyze(mix, roformerStft()); + for (const int frame : {0, 17, 800}) { + for (const int bin : {0, 5, 1024}) { + for (const int ch : {0, 1}) { + const auto base = static_cast(frame) * 4100 + (static_cast(bin) * 2 + static_cast(ch)) * 2; + REQUIRE(binding.data[base] == spectrum.real[spectrum.index(ch, bin, frame)]); + REQUIRE(binding.data[base + 1] == spectrum.imag[spectrum.index(ch, bin, frame)]); + } + } + } + + REQUIRE(result.stemNames == std::vector{"vocals", "instrumental"}); + REQUIRE(result.stemAudio.size() == 2); + REQUIRE(result.stemAudio[0].getNumSamples() == mix.getNumSamples()); +} + +TEST_CASE("Wrong-size output falls back honestly", "[ai][tensor][runner]") { + const auto mix = makeTestSignal(2, 352800, 44100.0); + + FakeTensorInference wrongFrames({kRoformerInput}, {kRoformerOutput}, [](const auto&) { + ai::TensorInferenceResult result; + result.usedModel = true; + ai::Tensor mask; + mask.spec = {"vocals_mask", ai::TensorElementType::Float32, {1, 1, 2050, 796, 2}}; + mask.data.assign(2050 * 796 * 2, 1.0f); + result.outputs.push_back(std::move(mask)); + return result; + }); + const auto wrongShape = ai::SeparationRunner::separate(mix, wrongFrames, roformerRunnerConfig()); + REQUIRE_FALSE(wrongShape.usedModel); + REQUIRE(wrongShape.stemAudio.empty()); + REQUIRE(wrongShape.logMessage.find("vocals_mask") != std::string::npos); + REQUIRE(wrongShape.logMessage.find("[1, 1, 2050, 796, 2]") != std::string::npos); + REQUIRE(wrongShape.logMessage.find("[1, 1, 2050, 801, 2]") != std::string::npos); + + FakeTensorInference wrongName({kRoformerInput}, {kRoformerOutput}, [](const auto& bindings) { + auto result = identityMask(bindings); + result.outputs.front().spec.name = "out_spec_real"; + return result; + }); + const auto misnamed = ai::SeparationRunner::separate(mix, wrongName, roformerRunnerConfig()); + REQUIRE_FALSE(misnamed.usedModel); + REQUIRE(misnamed.logMessage.find("vocals_mask") != std::string::npos); + + // All-or-nothing: a failure in a later chunk discards the chunks that worked. + const auto longMix = makeTestSignal(2, 352800 * 2, 44100.0); + int progressCalls = 0; + auto config = roformerRunnerConfig(); + config.progressCallback = [&](int, int) { ++progressCalls; }; + int call = 0; + FakeTensorInference failsOnSecondChunk({kRoformerInput}, {kRoformerOutput}, [&](const auto& bindings) { + ++call; + if (call == 2) { + ai::TensorInferenceResult failed; + failed.logMessage = "scripted failure on chunk 2"; + return failed; + } + return identityMask(bindings); + }); + const auto partial = ai::SeparationRunner::separate(longMix, failsOnSecondChunk, config); + REQUIRE_FALSE(partial.usedModel); + REQUIRE(partial.stemAudio.empty()); + REQUIRE(partial.logMessage.find("chunk 2/3") != std::string::npos); + REQUIRE(progressCalls == 1); +} + +TEST_CASE("Window-squared normalization under chunking", "[ai][tensor][runner]") { + // Three chunks (stride 264600): the constant must survive every chunk + // boundary and crossfade. zero_dc is off here because it would, correctly, + // remove a constant. + constexpr int samples = 352800 + 2 * 264600 - 1000; + engine::AudioBuffer constant(2, samples, 44100.0); + for (int ch = 0; ch < 2; ++ch) { + for (int i = 0; i < samples; ++i) { + constant.setSample(ch, i, 0.5f); + } + } + auto config = roformerRunnerConfig(); + config.stft.zeroDc = false; + std::vector> progress; + config.progressCallback = [&](int done, int total) { progress.emplace_back(done, total); }; + FakeTensorInference fake({kRoformerInput}, {kRoformerOutput}, identityMask); + + REQUIRE(ai::SeparationRunner::chunkCount(samples, config) == 3); + const auto result = ai::SeparationRunner::separate(constant, fake, config); + REQUIRE(result.usedModel); + REQUIRE(fake.calls == 3); + REQUIRE(progress == std::vector>{{1, 3}, {2, 3}, {3, 3}}); + REQUIRE(result.stemAudio[0].getNumSamples() == samples); + REQUIRE(maxAbsDifference(result.stemAudio[0], constant) < 1.0e-5f); + + engine::AudioBuffer silence(2, samples, 44100.0); + REQUIRE(maxAbsDifference(result.stemAudio[1], silence) < 1.0e-5f); + + // A non-constant signal also reconstructs through the same path, and the + // residual stem is exactly mix - target. + const auto music = makeTestSignal(2, samples, 44100.0); + const auto separated = ai::SeparationRunner::separate(music, fake, config); + REQUIRE(separated.usedModel); + REQUIRE(maxAbsDifference(separated.stemAudio[0], music) < 1.0e-4f); + float worstSum = 0.0f; + for (int ch = 0; ch < 2; ++ch) { + for (int i = 0; i < samples; ++i) { + const float sum = separated.stemAudio[0].getSample(ch, i) + separated.stemAudio[1].getSample(ch, i); + worstSum = std::max(worstSum, std::abs(sum - music.getSample(ch, i))); + } + } + REQUIRE(worstSum < 1.0e-5f); +} + +TEST_CASE("Runner refuses input the contract does not describe", "[ai][tensor][runner]") { + FakeTensorInference fake({kRoformerInput}, {kRoformerOutput}, identityMask); + + const auto at48k = makeTestSignal(2, 48000, 48000.0); + const auto wrongRate = ai::SeparationRunner::separate(at48k, fake, roformerRunnerConfig()); + REQUIRE_FALSE(wrongRate.usedModel); + REQUIRE(wrongRate.logMessage.find("44100") != std::string::npos); + REQUIRE(wrongRate.logMessage.find("48000") != std::string::npos); + + const auto mono = makeTestSignal(1, 44100, 44100.0); + const auto monoResult = ai::SeparationRunner::separate(mono, fake, roformerRunnerConfig()); + REQUIRE_FALSE(monoResult.usedModel); + REQUIRE(monoResult.logMessage.find("1 channel") != std::string::npos); + REQUIRE(fake.calls == 0); + + // A track shorter than one chunk is one zero-padded partial chunk; with no + // neighbour the crossfade is the identity. + const auto shortMix = makeTestSignal(2, 100000, 44100.0); + const auto shortResult = ai::SeparationRunner::separate(shortMix, fake, roformerRunnerConfig()); + REQUIRE(shortResult.usedModel); + REQUIRE(fake.calls == 1); + REQUIRE(shortResult.stemAudio[0].getNumSamples() == 100000); + auto noDc = roformerRunnerConfig(); + noDc.stft.zeroDc = false; + const auto shortIdentity = ai::SeparationRunner::separate(shortMix, fake, noDc); + REQUIRE(maxAbsDifference(shortIdentity.stemAudio[0], shortMix) < 1.0e-4f); +} + +TEST_CASE("Golden torch.stft fixture", "[ai][tensor][stft]") { + // Catches a consistent-but-wrong convention (window periodicity, pad mode, + // sign of the exponent, bin order) that the round-trip test cannot, because + // forward and inverse would share the misreading. See TorchStftGolden.h for + // the generating expression. + analysis::StftParams params; + params.nFft = 256; + params.hopLength = 64; + params.winLength = 256; + params.center = true; + params.normalized = false; + params.zeroDc = false; + + engine::AudioBuffer ramp(1, 4096, 44100.0); + for (int i = 0; i < 4096; ++i) { + ramp.setSample(0, i, static_cast(i) / 4096.0f); + } + const auto spectrum = analysis::analyze(ramp, params); + REQUIRE(spectrum.freqBins == torch_stft_golden::kFreqBins); + REQUIRE(spectrum.frames == torch_stft_golden::kFrames); + + const auto toFloat = [](const std::uint32_t bits) { + float value = 0.0f; + std::memcpy(&value, &bits, sizeof(value)); + return value; + }; + double worst = 0.0; + for (int bin = 0; bin < spectrum.freqBins; ++bin) { + for (int frame = 0; frame < spectrum.frames; ++frame) { + const auto golden = static_cast(bin * spectrum.frames + frame); + const auto index = spectrum.index(0, bin, frame); + worst = std::max(worst, std::abs(static_cast(spectrum.real[index]) - toFloat(torch_stft_golden::kReal[golden]))); + worst = std::max(worst, std::abs(static_cast(spectrum.imag[index]) - toFloat(torch_stft_golden::kImag[golden]))); + } + } + // Peak magnitude here is ~128 (bin 0, sum of a 256-tap Hann); 1e-5 absolute + // is float32 rounding at that scale, far below any convention error. + REQUIRE(worst < 1.0e-5); +} + +TEST_CASE("Tensor contract load-time failures", "[ai][tensor][contract]") { + std::string error; + REQUIRE(ai::checkTensorContract(parseContract(roformerContractJson()), kProbedInputs, kProbedOutputs, error)); + // A graph with a dynamic batch axis accepts the declared static shape. + REQUIRE(ai::checkTensorContract(parseContract(roformerContractJson()), + {{"input", ai::TensorElementType::Float32, {-1, 801, 4100}}}, kProbedOutputs, error)); + + auto wrongFft = roformerContractJson(); + wrongFft["stft"]["n_fft"] = 1024; + wrongFft["stft"]["win_length"] = 1024; + auto message = contractError(wrongFft); + REQUIRE(message.find("input 'input'") != std::string::npos); + REQUIRE(message.find("[1, 801, 4100]") != std::string::npos); + REQUIRE(message.find("[1, 801, 2052]") != std::string::npos); + + message = contractError(roformerContractJson(), {{"input", ai::TensorElementType::Float32, {1, 796, 4100}}}); + REQUIRE(message.find("'input'") != std::string::npos); + REQUIRE(message.find("[1, 796, 4100]") != std::string::npos); + REQUIRE(message.find("[1, 801, 4100]") != std::string::npos); + + message = contractError(roformerContractJson(), kProbedInputs, + {{"output", ai::TensorElementType::Float32, {1, 2, 2050, 801, 2}}}); + REQUIRE(message.find("'output'") != std::string::npos); + + message = contractError(roformerContractJson(), {{"mix", ai::TensorElementType::Float32, {1, 801, 4100}}}); + REQUIRE(message.find("'input'") != std::string::npos); + + message = contractError(roformerContractJson(), {{"input", ai::TensorElementType::Float32, {1, 801, 4100}}, + {"extra", ai::TensorElementType::Float32, {1}}}); + REQUIRE(message.find("'extra'") != std::string::npos); + + auto badLayout = roformerContractJson(); + badLayout["input_layout"] = "interleaved"; + REQUIRE(contractError(badLayout).find("'interleaved'") != std::string::npos); + + auto badMode = roformerContractJson(); + badMode["output_mode"] = "banana"; + REQUIRE(contractError(badMode).find("'banana'") != std::string::npos); + + auto badPad = roformerContractJson(); + badPad["stft"]["pad_mode"] = "constant"; + REQUIRE(contractError(badPad).find("'constant'") != std::string::npos); + + auto badDtype = roformerContractJson(); + badDtype["outputs"][0]["dtype"] = "float16"; + message = contractError(badDtype); + REQUIRE(message.find("'output'") != std::string::npos); + REQUIRE(message.find("float16") != std::string::npos); + + auto badTarget = roformerContractJson(); + badTarget["target_stem"] = "drums"; + REQUIRE(contractError(badTarget).find("'drums'") != std::string::npos); + + auto missingField = roformerContractJson(); + missingField["stft"].erase("zero_dc"); + REQUIRE_FALSE(ai::parseTensorContract(missingField, error).has_value()); + REQUIRE(error.find("zero_dc") != std::string::npos); +} + +TEST_CASE("Tensor contract manifest round-trips in installed and catalog form", "[ai][tensor][contract]") { + // Catalog form: names omitted, filled by the install-time probe. + auto catalog = roformerContractJson(); + catalog["inputs"][0].erase("name"); + catalog["outputs"][0].erase("name"); + const auto catalogContract = parseContract(catalog); + REQUIRE(catalogContract.inputs.front().name.empty()); + std::string error; + REQUIRE(ai::checkTensorContract(catalogContract, {{"whatever_the_export_called_it", ai::TensorElementType::Float32, + {1, 801, 4100}}}, + kProbedOutputs, error)); + REQUIRE(ai::tensorContractToJson(catalogContract) == catalog); + + const auto installed = parseContract(roformerContractJson()); + REQUIRE(ai::tensorContractToJson(installed) == roformerContractJson()); + + const auto config = ai::runnerConfigFromContract(installed, error); + REQUIRE(config.has_value()); + REQUIRE(config->chunkSamples == 352800); + REQUIRE(config->overlapSamples == 88200); + REQUIRE(config->channels == 2); + REQUIRE(config->stft.nFft == 2048); + REQUIRE(config->stft.hopLength == 441); + REQUIRE(config->stft.zeroDc); + REQUIRE(config->inputLayout == ai::InputLayout::FoldedStereo); + REQUIRE(config->outputMode == ai::OutputMode::Mask); + REQUIRE(config->stems.size() == 2); + REQUIRE(config->stems[1].residualOf == "vocals"); + + // Writer -> loader, end to end: the tensor pack loads, and a null contract + // leaves the manifest without the block (scalar packs unchanged). + const auto root = std::filesystem::temp_directory_path() / "automix_tensor_contract_manifest"; + std::filesystem::remove_all(root); + std::filesystem::create_directories(root); + { + std::ofstream model(root / "bs_roformer.onnx", std::ios::binary); + model << "not a real graph"; + } + ai::HubModelInfo info; + info.repoId = "xycld/BS-RoFormer-ONNX"; + info.modelId = "xycld/BS-RoFormer-ONNX"; + info.license = "mit"; + info.sourceUrl = "https://huggingface.co/xycld/BS-RoFormer-ONNX"; + ai::HubInstallResult install; + install.primaryFilePath = root / "bs_roformer.onnx"; + ai::ModelCompatibilityResult compatibility; + compatibility.compatible = true; + compatibility.taskScope = "separation"; + compatibility.packType = "separation_model"; + compatibility.engine = "bs_roformer"; + compatibility.expectedOutputKeys = {"output"}; + + REQUIRE(ai::writeTurnkeyModelPackManifest(root, info, install, compatibility, &installed, &error)); + ai::ModelPackLoader loader; + const auto pack = loader.load(root); + REQUIRE(pack.has_value()); + REQUIRE(pack->tensorContract.has_value()); + REQUIRE(ai::tensorContractToJson(*pack->tensorContract) == roformerContractJson()); + + REQUIRE(ai::writeTurnkeyModelPackManifest(root, info, install, compatibility, nullptr, &error)); + { + // Scoped: Windows cannot remove_all a directory holding an open file. + std::ifstream manifestIn(root / "model.json"); + REQUIRE_FALSE(nlohmann::json::parse(manifestIn).contains("tensor_contract")); + } + const auto scalarPack = loader.load(root); + REQUIRE(scalarPack.has_value()); + REQUIRE_FALSE(scalarPack->tensorContract.has_value()); + std::filesystem::remove_all(root); +} + +TEST_CASE("Onnx tensor inference without a model stays unavailable", "[ai][tensor][onnx]") { + ai::OnnxTensorInference inference; + REQUIRE_FALSE(inference.isAvailable()); + REQUIRE_FALSE(inference.loadModel(std::filesystem::temp_directory_path() / "automix_no_such_model.onnx")); + REQUIRE(inference.backendDiagnostics().find("automix_no_such_model.onnx") != std::string::npos); + REQUIRE(inference.inputSpecs().empty()); + const auto result = inference.run({}); + REQUIRE_FALSE(result.usedModel); + REQUIRE(result.outputs.empty()); +} + +#ifdef AUTOMIX_HAS_NATIVE_ORT + +namespace { + +// Synthetic graphs generated by tests/fixtures/tensor/make_fixtures.py. +std::filesystem::path tensorFixture(const char* name) { + return std::filesystem::path(AUTOMIX_SOURCE_DIR) / "tests" / "fixtures" / "tensor" / name; +} + +} // namespace + +TEST_CASE("Native tensor inference probes the real graph", "[ai][tensor][onnx][native]") { + // Weights are download-only and never committed. Point this at an installed + // BS-RoFormer export to run the probe against the real graph. + const char* modelPath = std::getenv("AUTOMIX_BS_ROFORMER_ONNX"); + if (modelPath == nullptr || !std::filesystem::is_regular_file(modelPath)) { + SKIP("AUTOMIX_BS_ROFORMER_ONNX does not name a BS-RoFormer .onnx file"); + } + + auto catalog = roformerContractJson(); + catalog["inputs"][0].erase("name"); + catalog["outputs"][0].erase("name"); + ai::OnnxTensorInference inference; + inference.setTensorContract(parseContract(catalog)); + const bool loaded = inference.loadModel(modelPath); + INFO(inference.backendDiagnostics()); + REQUIRE(loaded); + REQUIRE(inference.usingNativeSession()); + REQUIRE(inference.inputSpecs().size() == 1); + REQUIRE(inference.outputSpecs().size() == 1); + REQUIRE(ai::shapesMatch(inference.inputSpecs().front(), kRoformerInput)); + REQUIRE(ai::shapesMatch(inference.outputSpecs().front(), kRoformerOutput)); +} + +TEST_CASE("Native tensor inference separates through a synthetic mask graph", "[ai][tensor][onnx][native]") { + ai::OnnxTensorInference inference; + inference.setTensorContract(parseContract(roformerContractJson())); + const bool loaded = inference.loadModel(tensorFixture("identity_mask.onnx")); + INFO(inference.backendDiagnostics()); + REQUIRE(loaded); + REQUIRE(inference.usingNativeSession()); + REQUIRE(inference.inputSpecs().size() == 1); + REQUIRE(inference.inputSpecs().front().name == "input"); + REQUIRE(inference.inputSpecs().front().dims == std::vector{1, 801, 4100}); + REQUIRE(inference.outputSpecs().front().name == "output"); + REQUIRE(inference.outputSpecs().front().dims == std::vector{1, 1, 2050, 801, 2}); + + // Two chunks, so the native session runs more than once and the crossfade is exercised. + const int samples = 352800 + 120000; + const auto mix = makeTestSignal(2, samples, 44100.0); + // zero_dc is off so a 1 + 0i mask is an exact identity; with it on, each + // frame's DC bin is (correctly) removed and the vocals differ from the mix. + auto config = roformerRunnerConfig(); + config.stft.zeroDc = false; + REQUIRE(ai::SeparationRunner::chunkCount(samples, config) == 2); + const auto result = ai::SeparationRunner::separate(mix, inference, config); + INFO(result.logMessage); + REQUIRE(result.usedModel); + REQUIRE(result.stemNames == std::vector{"vocals", "instrumental"}); + REQUIRE(result.stemAudio[0].getNumSamples() == samples); + REQUIRE(maxAbsDifference(result.stemAudio[0], mix) < 1.0e-4f); + const engine::AudioBuffer silence(2, samples, 44100.0); + REQUIRE(maxAbsDifference(result.stemAudio[1], silence) < 1.0e-4f); + + // With the contract's zero_dc on, the residual still sums back to the mix exactly. + const auto zeroDc = ai::SeparationRunner::separate(mix, inference, roformerRunnerConfig()); + REQUIRE(zeroDc.usedModel); + float worstSum = 0.0f; + for (int ch = 0; ch < 2; ++ch) { + for (int i = 0; i < samples; ++i) { + const float sum = zeroDc.stemAudio[0].getSample(ch, i) + zeroDc.stemAudio[1].getSample(ch, i); + worstSum = std::max(worstSum, std::abs(sum - mix.getSample(ch, i))); + } + } + REQUIRE(worstSum < 1.0e-5f); + + // Bindings are matched by name; an unknown name is refused, never bound by position. + ai::TensorBinding wrongName{{"mix", ai::TensorElementType::Float32, {1, 801, 4100}}, + std::vector(801 * 4100, 0.0f)}; + const auto refused = inference.run({wrongName}); + REQUIRE_FALSE(refused.usedModel); + REQUIRE(refused.logMessage.find("'input'") != std::string::npos); + + ai::TensorBinding wrongShape{{"input", ai::TensorElementType::Float32, {1, 800, 4100}}, + std::vector(800 * 4100, 0.0f)}; + const auto misshapen = inference.run({wrongShape}); + REQUIRE_FALSE(misshapen.usedModel); + REQUIRE(misshapen.logMessage.find("[1, 800, 4100]") != std::string::npos); +} + +TEST_CASE("Native tensor inference refuses a contract the graph does not match", "[ai][tensor][onnx][native]") { + auto wrongGeometry = roformerContractJson(); + wrongGeometry["outputs"][0]["shape"] = {1, 2, 2050, 801, 2}; + wrongGeometry["output_mode"] = "direct"; + wrongGeometry["stems"] = {{{"name", "vocals"}}, {{"name", "instrumental"}}}; + ai::OnnxTensorInference inference; + inference.setTensorContract(parseContract(wrongGeometry)); + REQUIRE_FALSE(inference.loadModel(tensorFixture("identity_mask.onnx"))); + REQUIRE_FALSE(inference.isAvailable()); + REQUIRE(inference.backendDiagnostics().find("'output'") != std::string::npos); +} + +TEST_CASE("Native tensor inference rejects non-float32 graph I/O", "[ai][tensor][onnx][native]") { + ai::OnnxTensorInference inference; + REQUIRE_FALSE(inference.loadModel(tensorFixture("int64_io.onnx"))); + REQUIRE_FALSE(inference.isAvailable()); + const auto diagnostics = inference.backendDiagnostics(); + REQUIRE(diagnostics.find("'tokens'") != std::string::npos); + REQUIRE(diagnostics.find("int64") != std::string::npos); +} + +TEST_CASE("Native tensor inference names a missing external-data sidecar", "[ai][tensor][onnx][native]") { + const auto root = std::filesystem::temp_directory_path() / "automix_tensor_external_data"; + std::filesystem::remove_all(root); + std::filesystem::create_directories(root); + std::filesystem::copy_file(tensorFixture("external_data.onnx"), root / "external_data.onnx"); + std::filesystem::copy_file(tensorFixture("external_data.onnx.data"), root / "external_data.onnx.data"); + + ai::OnnxTensorInference present; + const bool loaded = present.loadModel(root / "external_data.onnx"); + INFO(present.backendDiagnostics()); + REQUIRE(loaded); + const auto ran = present.run({{{"x", ai::TensorElementType::Float32, {1, 4}}, {0.5f, 0.5f, 0.5f, 0.5f}}}); + REQUIRE(ran.usedModel); + REQUIRE(ran.outputs.size() == 1); + REQUIRE(ran.outputs.front().spec.name == "y"); + REQUIRE(ran.outputs.front().data == std::vector{1.5f, 2.5f, 3.5f, 4.5f}); + + std::filesystem::remove(root / "external_data.onnx.data"); + ai::OnnxTensorInference missing; + REQUIRE_FALSE(missing.loadModel(root / "external_data.onnx")); + REQUIRE_FALSE(missing.isAvailable()); + REQUIRE(missing.backendDiagnostics().find("external_data.onnx.data") != std::string::npos); + std::filesystem::remove_all(root); +} + +#endif + +// Spec test 9. Tensor separation is opt-in per session; a default-on flag would +// route every separated import through a new backend and move golden files. +TEST_CASE("Tensor separation defaults off", "[tensor][settings]") { + const automix::domain::RenderSettings settings; + REQUIRE_FALSE(settings.tensorSeparationEnabled); +} + +namespace { + +// Real sibling list of xycld/BS-RoFormer-ONNX (HF API, 2026-09-30), in the +// alphabetical order the API returns. +const std::vector kBsRoformerSiblings = { + ".gitattributes", + "README.md", + "bs_roformer_ep317_sdr12.9755.onnx", + "bs_roformer_ep317_sdr12.9755.onnx.data", + "bs_roformer_ep317_sdr12.9755_quantized_uint8.onnx", +}; + +nlohmann::json roformerCatalogJson() { + auto catalog = roformerContractJson(); + catalog["inputs"][0].erase("name"); + catalog["outputs"][0].erase("name"); + return catalog; +} + +// An installed tensor pack as the hub writes it: model.json carrying the +// catalog contract, plus the model file (copied, or a stand-in that no backend +// can open). +std::filesystem::path writeTensorPack(const std::filesystem::path& root, const std::filesystem::path* modelSource) { + std::filesystem::remove_all(root); + std::filesystem::create_directories(root); + const auto modelPath = root / "bs_roformer.onnx"; + if (modelSource != nullptr) { + std::filesystem::copy_file(*modelSource, modelPath); + } else { + std::ofstream model(modelPath, std::ios::binary); + model << "not a real graph"; + } + ai::HubModelInfo info; + info.repoId = ai::kBsRoformerRepoId; + info.modelId = ai::kBsRoformerRepoId; + info.license = "mit"; + ai::HubInstallResult install; + install.primaryFilePath = modelPath; + ai::ModelCompatibilityResult compatibility; + compatibility.compatible = true; + compatibility.taskScope = "separation"; + compatibility.packType = "separation_model"; + compatibility.engine = "bs_roformer"; + const auto contract = ai::bsRoformerCatalogContract(); + std::string error; + REQUIRE(ai::writeTurnkeyModelPackManifest(root, info, install, compatibility, &contract, &error)); + return root; +} + +std::vector readBytes(const std::filesystem::path& path) { + std::ifstream in(path, std::ios::binary); + return {std::istreambuf_iterator(in), std::istreambuf_iterator()}; +} + +} // namespace + +TEST_CASE("BS-RoFormer catalog entry installs the quantized build as a separation pack", "[ai][tensor][catalog]") { + const auto curated = ai::curatedModelIds(); + REQUIRE(std::find(curated.begin(), curated.end(), std::string(ai::kBsRoformerRepoId)) != curated.end()); + + // The generic preference takes the first .onnx: the fp32 graph, which is + // useless without its ~640 MB sidecar. The pin is what prevents that. + bool hasOnnx = false; + REQUIRE(ai::primaryFileForRepo("someone/else", kBsRoformerSiblings, &hasOnnx) == + "bs_roformer_ep317_sdr12.9755.onnx"); + REQUIRE(ai::primaryFileForRepo(ai::kBsRoformerRepoId, kBsRoformerSiblings, &hasOnnx) == + ai::kBsRoformerQuantizedFile); + REQUIRE(hasOnnx); + const std::vector withoutQuantized(kBsRoformerSiblings.begin(), kBsRoformerSiblings.end() - 1); + REQUIRE(ai::primaryFileForRepo(ai::kBsRoformerRepoId, withoutQuantized).empty()); + + // Classified from the real card data, the way HuggingFaceModelHub::modelInfo does. + ai::HubModelInfo info; + info.repoId = ai::kBsRoformerRepoId; + info.tags = {"onnxruntime", "onnx", "music", "music-source-separation", "audio", "bs-roformer", "roformer", + "vocals", "instrumental", "quantized", "audio-to-audio", "license:mit"}; + info.files = kBsRoformerSiblings; + info.primaryFile = ai::primaryFileForRepo(info.repoId, info.files, &info.hasOnnx); + info.useCase = ai::HuggingFaceModelHub::inferUseCase(info.repoId, info.tags, ""); + const auto compatibility = ai::validateCatalogModel(info); + INFO(compatibility.reason); + REQUIRE(compatibility.compatible); + REQUIRE(compatibility.taskScope == "separation"); + + // MIT: licence-driven consent does not prompt; attribution is in NOTICE. + REQUIRE_FALSE(ai::ModelLicensePolicy::requiresUserConsent("mit")); +} + +TEST_CASE("BS-RoFormer catalog contract is the spec block with names omitted", "[ai][tensor][catalog]") { + const auto contract = ai::bsRoformerCatalogContract(); + REQUIRE(ai::tensorContractToJson(contract) == roformerCatalogJson()); + std::string error; + REQUIRE(ai::checkTensorContract(contract, kProbedInputs, kProbedOutputs, error)); +} + +TEST_CASE("Installed tensor contract takes the graph's names only when a graph can be probed", "[ai][tensor][catalog]") { + const auto catalog = ai::bsRoformerCatalogContract(); + std::string error; +#ifdef AUTOMIX_HAS_NATIVE_ORT + const auto fixture = std::filesystem::path(AUTOMIX_SOURCE_DIR) / "tests" / "fixtures" / "tensor" / "identity_mask.onnx"; + const auto installed = ai::resolveInstalledTensorContract(catalog, fixture, error); + INFO(error); + REQUIRE(installed.has_value()); + REQUIRE(ai::tensorContractToJson(*installed) == roformerContractJson()); + + // A graph that does not satisfy the contract fails the install, named. + const auto int64Graph = fixture.parent_path() / "int64_io.onnx"; + REQUIRE_FALSE(ai::resolveInstalledTensorContract(catalog, int64Graph, error).has_value()); + REQUIRE_FALSE(error.empty()); +#else + const auto installed = + ai::resolveInstalledTensorContract(catalog, std::filesystem::temp_directory_path() / "unprobed.onnx", error); + REQUIRE(installed.has_value()); + REQUIRE(ai::tensorContractToJson(*installed) == roformerCatalogJson()); +#endif +} + +TEST_CASE("Tensor flag off leaves separation byte-identical; on falls back honestly", "[ai][tensor][separator]") { + const auto tempRoot = std::filesystem::temp_directory_path() / "automix_tensor_flag_separation"; + std::filesystem::remove_all(tempRoot); + std::filesystem::create_directories(tempRoot); + const auto mixPath = tempRoot / "mix.wav"; + automix::util::WavWriter writer; + writer.write(mixPath, makeTestSignal(2, 44100, 44100.0), 24); + + const auto packRoot = writeTensorPack(tempRoot / "pack", nullptr); + const auto emptyRoot = tempRoot / "no_pack"; + std::filesystem::create_directories(emptyRoot); + + ai::StemSeparator withPack(packRoot); + ai::StemSeparator withoutPack(emptyRoot); + REQUIRE(withPack.isTensorModelAvailable()); + REQUIRE_FALSE(withoutPack.isTensorModelAvailable()); + // The legacy path never adopts a tensor pack's model file. + REQUIRE_FALSE(withPack.isModelAvailable()); + + const auto baseline = withoutPack.separate(mixPath, tempRoot / "out_baseline"); + const auto flagOff = withPack.separate(mixPath, tempRoot / "out_off"); + REQUIRE(baseline.success); + REQUIRE(flagOff.success); + REQUIRE(flagOff.logMessage == baseline.logMessage); + REQUIRE(flagOff.usedModel == baseline.usedModel); + REQUIRE(flagOff.generatedFiles.size() == baseline.generatedFiles.size()); + for (std::size_t i = 0; i < baseline.generatedFiles.size(); ++i) { + REQUIRE(flagOff.generatedFiles[i].filename() == baseline.generatedFiles[i].filename()); + REQUIRE(readBytes(flagOff.generatedFiles[i]) == readBytes(baseline.generatedFiles[i])); + } + + ai::StemSeparator::SeparationOptions tensorOn; + tensorOn.useTensorModel = true; + const auto fellBack = withPack.separate(mixPath, tempRoot / "out_on", tensorOn); + INFO(fellBack.logMessage); + REQUIRE(fellBack.success); + REQUIRE_FALSE(fellBack.usedModel); + REQUIRE(fellBack.logMessage.rfind("Tensor separation unavailable (tensor model did not load", 0) == 0); + REQUIRE(fellBack.stems.size() == baseline.stems.size()); + std::filesystem::remove_all(tempRoot); +} + +#ifdef AUTOMIX_HAS_NATIVE_ORT + +TEST_CASE("Tensor separation runs end to end through StemSeparator", "[ai][tensor][separator][native]") { + const auto tempRoot = std::filesystem::temp_directory_path() / "automix_tensor_separator_e2e"; + std::filesystem::remove_all(tempRoot); + std::filesystem::create_directories(tempRoot); + const auto mixPath = tempRoot / "mix.wav"; + automix::util::WavWriter writer; + writer.write(mixPath, makeTestSignal(2, 100000, 44100.0), 24); + + const auto fixture = std::filesystem::path(AUTOMIX_SOURCE_DIR) / "tests" / "fixtures" / "tensor" / "identity_mask.onnx"; + const auto packRoot = writeTensorPack(tempRoot / "pack", &fixture); + + ai::StemSeparator separator(packRoot); + ai::StemSeparator::SeparationOptions options; + options.useTensorModel = true; + int lastDone = -1; + int lastTotal = -1; + options.tensorProgress = [&](const int done, const int total) { + lastDone = done; + lastTotal = total; + }; + const auto result = separator.separate(mixPath, tempRoot / "out", options); + INFO(result.logMessage); + REQUIRE(result.success); + REQUIRE(result.usedModel); + REQUIRE(lastTotal == 1); + REQUIRE(lastDone == lastTotal); + REQUIRE(result.logMessage.find("'instrumental' is the residual (mix - vocals), not a second separation") != + std::string::npos); + + REQUIRE(result.stems.size() == 2); + REQUIRE(result.stems[0].role == automix::domain::StemRole::Vocals); + REQUIRE(result.stems[1].role == automix::domain::StemRole::Music); + for (const auto& stem : result.stems) { + REQUIRE(stem.origin == automix::domain::StemOrigin::Separated); + REQUIRE_FALSE(stem.separationConfidence.has_value()); + REQUIRE_FALSE(stem.separationArtifactRisk.has_value()); + REQUIRE(std::filesystem::is_regular_file(stem.filePath)); + } + + // zero_dc is on in the contract, so vocals are not the mix, but the residual + // sums back to it; the only error left is each stem's 24-bit quantization. + automix::engine::AudioFileIO io; + const auto mix = io.readAudioFile(mixPath); + const auto vocals = io.readAudioFile(result.stems[0].filePath); + const auto instrumental = io.readAudioFile(result.stems[1].filePath); + REQUIRE(vocals.getNumSamples() == mix.getNumSamples()); + float worst = 0.0f; + for (int ch = 0; ch < 2; ++ch) { + for (int i = 0; i < mix.getNumSamples(); ++i) { + worst = std::max(worst, std::abs(vocals.getSample(ch, i) + instrumental.getSample(ch, i) - mix.getSample(ch, i))); + } + } + REQUIRE(worst < 1.0e-5f); + std::filesystem::remove_all(tempRoot); +} + +#endif \ No newline at end of file diff --git a/tests/unit/TorchStftGolden.h b/tests/unit/TorchStftGolden.h new file mode 100644 index 0000000..a3cbe78 --- /dev/null +++ b/tests/unit/TorchStftGolden.h @@ -0,0 +1,2128 @@ +#pragma once + +// Golden fixture for the "Golden torch.stft fixture" test in TensorInferenceTests.cpp. +// Generated with torch 2.14.0+cu130 (CPU), float32 bit patterns, planes laid out [bin][frame] +// (129 bins x 65 frames), by exactly: +// +// x = torch.arange(4096, dtype=torch.float32) / 4096.0 +// w = torch.hann_window(256, periodic=True) +// S = torch.stft(x, n_fft=256, hop_length=64, win_length=256, window=w, +// center=True, pad_mode='reflect', normalized=False, return_complex=False) +// real = S[..., 0], imag = S[..., 1] +// +// (return_complex=False is deprecated but still accepted by this torch version; +// the planes were verified bit-identical to torch.view_as_real of return_complex=True.) +// Do not regenerate with any other STFT implementation. + +#include + +namespace torch_stft_golden { + +inline constexpr int kFreqBins = 129; +inline constexpr int kFrames = 65; + +inline constexpr std::uint32_t kReal[] = { + 0x3f983decu, 0x40060f7au, 0x40800000u, 0x40c00000u, 0x41000000u, 0x41200000u, 0x41400000u, 0x41600000u, + 0x41800000u, 0x41900000u, 0x41a00000u, 0x41b00000u, 0x41c00000u, 0x41d00000u, 0x41e00000u, 0x41f00000u, + 0x42000000u, 0x42080000u, 0x42100000u, 0x42180000u, 0x42200000u, 0x42280000u, 0x42300000u, 0x42380000u, + 0x42400000u, 0x42480000u, 0x42500000u, 0x42580000u, 0x42600000u, 0x42680000u, 0x42700000u, 0x42780000u, + 0x42800000u, 0x42840000u, 0x42880000u, 0x428c0000u, 0x42900000u, 0x42940000u, 0x42980000u, 0x429c0000u, + 0x42a00000u, 0x42a40000u, 0x42a80000u, 0x42ac0000u, 0x42b00000u, 0x42b40000u, 0x42b80000u, 0x42bc0000u, + 0x42c00000u, 0x42c40000u, 0x42c80000u, 0x42cc0000u, 0x42d00000u, 0x42d40000u, 0x42d80000u, 0x42dc0000u, + 0x42e00000u, 0x42e40000u, 0x42e80000u, 0x42ec0000u, 0x42f00000u, 0x42f40000u, 0x42f80000u, 0x42fbcc8cu, + 0x42fd8ee8u, 0xbe41ef6bu, 0xbf722f72u, 0xc0000000u, 0xc0400000u, 0xc07fffffu, 0xc0a00000u, 0xc0c00000u, + 0xc0dfffffu, 0xc1000000u, 0xc1100000u, 0xc1200000u, 0xc1300000u, 0xc13fffffu, 0xc1500000u, 0xc1600000u, + 0xc1700000u, 0xc1800000u, 0xc1880000u, 0xc1900000u, 0xc197ffffu, 0xc1a00000u, 0xc1a80000u, 0xc1b00000u, + 0xc1b80000u, 0xc1bfffffu, 0xc1c80000u, 0xc1d00000u, 0xc1d80001u, 0xc1e00000u, 0xc1e80000u, 0xc1f00000u, + 0xc1f80000u, 0xc1ffffffu, 0xc2040000u, 0xc207ffffu, 0xc20c0000u, 0xc20fffffu, 0xc2140000u, 0xc2180000u, + 0xc21c0000u, 0xc2200000u, 0xc2240000u, 0xc2280000u, 0xc22c0000u, 0xc2300000u, 0xc2340000u, 0xc2380000u, + 0xc23c0000u, 0xc2400000u, 0xc2440000u, 0xc2480000u, 0xc24bffffu, 0xc2500000u, 0xc2540000u, 0xc2580000u, + 0xc25c0000u, 0xc2600000u, 0xc2640000u, 0xc2680000u, 0xc26c0000u, 0xc2700000u, 0xc2740000u, 0xc2780000u, + 0xc27c3971u, 0xc27f2dd1u, 0xbee69533u, 0xbcb8484du, 0x33b1a2f6u, 0x340a48d9u, 0x34314036u, 0x342243eau, + 0x34413efbu, 0x3472b2a5u, 0x3472ac7au, 0x3444bc83u, 0x34955952u, 0x34ac0ed6u, 0x34b9c949u, 0x34c691ecu, + 0x34e140a7u, 0x34ef06d2u, 0x34e988acu, 0x350f1c51u, 0x34b74cfdu, 0x34fa480eu, 0x34f29501u, 0x3516a0f1u, + 0x35261ac7u, 0x353cbac7u, 0x3522984fu, 0x35157c02u, 0x352e79dau, 0x3522c22du, 0x355a2d84u, 0x3586c5aeu, + 0x35469a78u, 0x353cf713u, 0x3571109au, 0x3563f04bu, 0x356fdff3u, 0x35786a6fu, 0x3581b5aeu, 0x3582464cu, + 0x359cd3fcu, 0x355cf3feu, 0x356e0ce6u, 0x35ab4271u, 0x35b50b0au, 0x35988184u, 0x35c8f893u, 0x35a30ce4u, + 0x35a2bf34u, 0x35b19909u, 0x35c7957du, 0x35b8eabbu, 0x35acaa1du, 0x35af055fu, 0x35abed33u, 0x35c0a6b9u, + 0x35b95fd2u, 0x359c60bfu, 0x35ceb980u, 0x35fa0105u, 0x35b64bd0u, 0x35cf666au, 0x3600cdd0u, 0x35ca3931u, + 0x35cdc4b8u, 0x3cd4779bu, 0x3ee6756cu, 0x3db88870u, 0xbd668fd8u, 0x33054f98u, 0x3025ef04u, 0x334e25fau, + 0x330bd25fu, 0x333e1554u, 0x336eb3d9u, 0x33b30947u, 0x33958286u, 0xb32c5d2bu, 0x33d5c0f4u, 0x33a24813u, + 0x3412eaf9u, 0x33ca6a92u, 0x33ee71e1u, 0x33763f51u, 0x3319c421u, 0x3493cc1fu, 0x33a21f79u, 0x34119642u, + 0x34271effu, 0x340549aeu, 0xb2be068cu, 0x3496fe4cu, 0x3370f72du, 0x3297e811u, 0x33cac73du, 0x34191612u, + 0x3498dad4u, 0xb403b9e2u, 0xb3310bf7u, 0x34761e2au, 0x34b93819u, 0xb3a6e0e5u, 0x3457f003u, 0x3352f72du, + 0xb3f2103fu, 0x33aff4d6u, 0x34f489e7u, 0x3481e652u, 0x338435acu, 0x34cba8bfu, 0x34c13de7u, 0x341d48cdu, + 0x34ecc924u, 0x34752439u, 0x33e508a0u, 0xb4cedf99u, 0x33c1a210u, 0xb352aa7du, 0x350fa40au, 0x34eaff6eu, + 0x34b0ee99u, 0x34e6accbu, 0x357b2880u, 0x35089cdcu, 0x345728a9u, 0x3118f95au, 0x351ce407u, 0x3539b370u, + 0x35361505u, 0x3522e4d6u, 0x3d74239bu, 0xbdb8086eu, 0xbd7b04a9u, 0xbcfb04aeu, 0xb1c26307u, 0x328509f6u, + 0x31cf4ee4u, 0xb2fd2203u, 0xb2e0ec2du, 0xb3310c13u, 0x31c173e9u, 0x33301392u, 0xb29a1c46u, 0xb3dd0fccu, + 0x331c69dcu, 0xb3d1e352u, 0xb4085420u, 0xb38b7fc6u, 0xb48fec67u, 0xb45aa600u, 0xb3e70555u, 0xb38b4adeu, + 0xb413a05au, 0xb48072f9u, 0xb3c59e7du, 0x34032901u, 0x32b3e2ebu, 0xb424dfe3u, 0xb41477d3u, 0x3232050cu, + 0xb42fef0bu, 0xb41372f1u, 0x338022d8u, 0xb493750eu, 0xb3ada88eu, 0xb523b6b7u, 0xb5537b71u, 0xb54e593fu, + 0xb5285c9fu, 0xb534be16u, 0xb5062c50u, 0xb5095281u, 0xb40264d1u, 0xb4e1c97fu, 0xb51da90du, 0xb4b311f8u, + 0xb414c189u, 0xb1dc23d1u, 0xb4b5a0a4u, 0xb498b11au, 0xb4d8688du, 0xb450b88au, 0xb4c1f952u, 0xb4dfd1e6u, + 0xb40ceff9u, 0xb31b19dcu, 0xb41ab5fcu, 0xb475ca5du, 0xb40cc019u, 0x33e4e907u, 0xb438a2e1u, 0xb511533cu, + 0xb4249a71u, 0xb4855127u, 0xb2a39571u, 0x3cf49569u, 0x3d7a0466u, 0x3d04f873u, 0x3ba250b9u, 0x313e4234u, + 0x3288d98cu, 0x32a9598cu, 0xb2cee125u, 0xb28d6214u, 0xb1be1adcu, 0x331be6ffu, 0x329e7e2eu, 0xb258f25eu, + 0xb144ced9u, 0xb38d1904u, 0xb40092bdu, 0x337adeb8u, 0xb3b88bb7u, 0x318543a5u, 0xb3b9ef98u, 0x33fa60ebu, + 0xb43acf1bu, 0x333c4a88u, 0x33f6139fu, 0x33b0cfdbu, 0x33777836u, 0x339d72f3u, 0x349b7ff4u, 0xb408ecf2u, + 0xb30223fau, 0x33925708u, 0xb30ec498u, 0xb3d63e02u, 0xb40d81ebu, 0x341db979u, 0xb39ddd12u, 0xb430b6a1u, + 0x34249a1au, 0xb2ce0cc5u, 0xb38db95fu, 0xb469cc69u, 0x33e93ee9u, 0xb0bc7764u, 0x34593f1eu, 0x345844a9u, + 0x30a590f0u, 0x34656466u, 0xb4ab5b82u, 0xb2bfcb6du, 0x3486fbe6u, 0xb3fca8d1u, 0xb4888ce4u, 0xb3af0c10u, + 0x34443cefu, 0xb394d915u, 0x33e91cb9u, 0xb4a6c04au, 0x348b48beu, 0x3416f4f3u, 0xb40a7aa1u, 0x34ae3c8au, + 0x350667c2u, 0x33d27bffu, 0xb4b7bea8u, 0xb491ef2cu, 0xbbe37106u, 0xbd03f884u, 0xbcc8e4f4u, 0x3c28ac15u, + 0x31ebda75u, 0x32ab55e8u, 0x3225ac5bu, 0x32826b34u, 0x32f1babeu, 0x331fb134u, 0x32f8f228u, 0x336cff22u, + 0x33d848d8u, 0x33c1f550u, 0x31aff290u, 0xb0cb8380u, 0x3384453fu, 0x332842b8u, 0x339448b9u, 0x3429c3c1u, + 0x331f5d89u, 0x339cff4fu, 0x33a55459u, 0x32fb244eu, 0x334536c6u, 0x32b45191u, 0xb3a1f153u, 0x32aa2758u, + 0xb191a65du, 0xb246e06fu, 0x33827647u, 0xb46253deu, 0x3416e5d5u, 0xb34ad798u, 0xb319e012u, 0xb3f9dec6u, + 0x34654992u, 0xb3cfb172u, 0x3422a165u, 0x3492dddfu, 0x347d1a67u, 0xb48634e9u, 0x34a0b0d7u, 0x3317ce44u, + 0x331a4e27u, 0x34c5bf79u, 0x34161e91u, 0x349711c3u, 0xb3fb1094u, 0x3485101fu, 0xb36d713du, 0xb12ba73eu, + 0x34a0865du, 0x347c01e8u, 0x347b9fe6u, 0x34a4f4afu, 0x341fb888u, 0x34e40db1u, 0x34d978b6u, 0x34850d9cu, + 0xb419c14du, 0xb487928fu, 0x34acd44fu, 0x34a1992fu, 0xb2d583bbu, 0xbc22000au, 0x3cc6e5a0u, 0x3c87d90cu, + 0xbb43bdfbu, 0xb2c62aa2u, 0x31742c9du, 0x337e5534u, 0xb15b95f0u, 0x317e74f4u, 0x3229d34bu, 0xb1a88e18u, + 0xb35e9660u, 0xb22c9d28u, 0x341367cfu, 0xb36710a9u, 0x32501eacu, 0x33df950au, 0x34480d92u, 0xb22c1a1bu, + 0x33b3dd0bu, 0xb302c489u, 0xb3887f51u, 0xb1077cb7u, 0x34c3f5c0u, 0xb44b14e8u, 0x30c5a3bfu, 0x3424784au, + 0x33f88771u, 0xb2ce0789u, 0xb2f8ace5u, 0x33b350e0u, 0x3394dbeeu, 0xb34f9bafu, 0x34df4e14u, 0x334158a2u, + 0xb30abfbau, 0x34b4ab43u, 0x345c04fcu, 0x333c1b50u, 0xb3ab65fcu, 0xb4a58364u, 0xb3bfc7eau, 0xb41fc0fau, + 0x33af53c0u, 0xb3e1ff15u, 0x34613560u, 0x34d069b0u, 0xb472b911u, 0xb40f9c69u, 0x34a804cdu, 0x341f46dau, + 0xb47c08e9u, 0x34a66d88u, 0x346a5ed5u, 0xb3714e71u, 0xb4753c5bu, 0x32dea6bdu, 0xb3b01fc3u, 0x33dd9ddeu, + 0xb566b37eu, 0x33b40174u, 0xb4f99b21u, 0x34b906d0u, 0xb0ed5dbfu, 0x34b22d88u, 0x3b904d7cu, 0xbc85d825u, + 0xbc5a28d1u, 0xbbda28bbu, 0x330b0ccbu, 0xb21957acu, 0x33a045d3u, 0xb2f4d963u, 0x337309d0u, 0x336175c0u, + 0x33bba841u, 0xb2f88f95u, 0x339f7b6bu, 0xb27e97d5u, 0x33ec5652u, 0x32ee9976u, 0x32461b54u, 0x342a9204u, + 0x33dd66a3u, 0x3316218cu, 0xb3afd3b2u, 0xb4098a72u, 0x34840e75u, 0x349de3dbu, 0xb42f0496u, 0x34b7e6efu, + 0x332daf0cu, 0x34d62070u, 0x34281b94u, 0x34b3bd3eu, 0x344b25e9u, 0x3494879bu, 0x34f01fdbu, 0x34aa7a98u, + 0x34dfacf4u, 0xb48b7b43u, 0x34d0779cu, 0x3477b2d5u, 0x3415c6d5u, 0x340fa1d4u, 0x34b135f1u, 0x34a9f68au, + 0x3388bdaau, 0xb3893391u, 0x342235f1u, 0x3232fcf0u, 0xb33d7f07u, 0xb4a5cd8eu, 0xb439fc08u, 0x33d9b88du, + 0x342459b8u, 0xb4ab6819u, 0x33e4d49bu, 0x34da7f1cu, 0xb5123e0cu, 0xb52519b5u, 0xb3e7edf7u, 0x3276c46au, + 0xb5259588u, 0xb4b4f658u, 0x342803f8u, 0x34d1291cu, 0xb4bcef11u, 0xb4c68263u, 0x34546a4eu, 0x3bd0f7b1u, + 0x3c56275du, 0x3c249fa0u, 0x3a7935cdu, 0xb1589b5bu, 0xb2375b2au, 0x3196f05du, 0xb204409cu, 0x33693a3du, + 0x3324503bu, 0x3329b490u, 0xb261fd3cu, 0x338f4002u, 0xb29577a1u, 0xb204ebd4u, 0xb3a2350eu, 0xb323f211u, + 0x343e1469u, 0x333b04c9u, 0x3399dbfeu, 0xb3222b61u, 0xb3161ac6u, 0xb311220du, 0x34de4a5eu, 0x33f558c6u, + 0x330be16bu, 0xb41859e3u, 0xb35d00fau, 0x33990abdu, 0xb4108512u, 0xb3df905au, 0x346ebb17u, 0x348783afu, + 0x33512162u, 0x335ca299u, 0x34e715afu, 0x35104f5eu, 0x34834b3du, 0xb49a0caau, 0xb32c6ebbu, 0xb4a5396au, + 0xb4b352cbu, 0xb532fcd6u, 0xb385e9fcu, 0x34809746u, 0x34ad2285u, 0x34b8ddddu, 0xb232af68u, 0x33a5fd52u, + 0xb37ffb44u, 0xb405590eu, 0x35033c03u, 0xb4086138u, 0xb3a531a2u, 0xb35ee485u, 0x342fd964u, 0x330451b9u, + 0xb5269d75u, 0xb4e01a9du, 0x3407f85fu, 0xb530bd83u, 0xb42412b2u, 0x35331917u, 0xb4759e03u, 0xb4a37680u, + 0xbb0662f2u, 0xbc209ee4u, 0xbc09861fu, 0x3b816bb1u, 0x31a8bac9u, 0xb26c70eeu, 0xb31ac90du, 0x32e59c1fu, + 0xb1c1c088u, 0x324a0ad3u, 0xb34b3f30u, 0x33266680u, 0xb10b73cbu, 0x32b33440u, 0xb2925645u, 0x32166938u, + 0x335591f4u, 0xb2e1abc8u, 0x322ec724u, 0xb4126a53u, 0x339df312u, 0xb45d229au, 0xb35d4057u, 0xb3f10882u, + 0xb4470b47u, 0x321ed74cu, 0xb41d2433u, 0xb3acb427u, 0xb40a99bbu, 0xb3a05a4fu, 0x32a9c6f3u, 0x33057fd3u, + 0x337fc64du, 0x33309ebeu, 0x33891766u, 0xb2c8368fu, 0x34538d6cu, 0x3401efbbu, 0x3357d6e7u, 0x343c683cu, + 0x345577c2u, 0x31c0c7efu, 0xb4584991u, 0xb39fb9d3u, 0xb3ab6097u, 0xb4a60232u, 0x3411970au, 0xb32080c5u, + 0xb267ab7fu, 0xb3d8c715u, 0xb23a7e09u, 0x347925b4u, 0x33f30da3u, 0xb2b54b44u, 0x3403c562u, 0x3230df65u, + 0xb3fe9517u, 0x34244a9du, 0x33e43856u, 0xb49ab3d6u, 0xb3e03bc5u, 0xb408187fu, 0xb4cf93cdu, 0xb381ac84u, + 0x34290b2fu, 0xbb7437b3u, 0x3c058700u, 0x3bdcd93au, 0xba386254u, 0x3287c4b4u, 0x3204279fu, 0x32cc5109u, + 0x330c870bu, 0x326d88b5u, 0xb2d964fdu, 0x3369b016u, 0xb37e984fu, 0x329871bcu, 0x3366d27eu, 0x33f2a0cfu, + 0x32f9e7f9u, 0xb2aac93eu, 0x33b6ee3cu, 0x33949b5bu, 0x346a698cu, 0xb3a414ddu, 0xb42c05eau, 0x339e9b78u, + 0x339e2bf4u, 0x33f3a73eu, 0x3483d0fdu, 0x32f5bee0u, 0x3463c466u, 0x3425dd7du, 0x3484fd44u, 0xb2c7f36cu, + 0x34c6f75au, 0xb2b014cdu, 0x340d7effu, 0x34129ab0u, 0x339d4e95u, 0x34915addu, 0x34d18d41u, 0x33496726u, + 0xb33a7f3fu, 0x340a55e3u, 0xb41e4dc8u, 0x34ee5ee5u, 0x34a91c27u, 0xb3f22b4fu, 0x352f945au, 0x33facfa3u, + 0x345afc7eu, 0x345046a9u, 0xb10c243bu, 0x350eeb80u, 0x3409e47fu, 0x33b9d4b5u, 0x34aad69au, 0xb4a03d3cu, + 0xb3e5c51au, 0x34848219u, 0x34d3f797u, 0xb437648au, 0x35110277u, 0x350a7047u, 0x35200753u, 0xb3c73f48u, + 0x340f99b9u, 0x354b1f18u, 0x3ad1ff62u, 0xbbd4d713u, 0xbbbdad23u, 0xbb3dad34u, 0xb10c2e84u, 0xb2558428u, + 0x32af0771u, 0xb281b81au, 0xb30b9400u, 0xb2a74288u, 0xb3896722u, 0xb39b378fu, 0x339624bbu, 0x33b36ad0u, + 0x331f450au, 0xb405989eu, 0xb43725c4u, 0xb45f141eu, 0x3344ce25u, 0xb4c1f28fu, 0xb4a95da9u, 0xb360e897u, + 0xb4affd78u, 0x31f15b7fu, 0x33e33296u, 0xb4168154u, 0xb4999e18u, 0xb4943d5fu, 0xb473fe01u, 0xb4356d7au, + 0x3289eb30u, 0xb399098cu, 0xb4901172u, 0xb422969eu, 0xb4c857b8u, 0xb51653fbu, 0xb445e18eu, 0x340d550du, + 0xb4ac09c6u, 0xb4b2c1fcu, 0xb44b7d34u, 0xb4c4fd1eu, 0xb526a2c4u, 0xb490604eu, 0xb51ea0b9u, 0xb530084eu, + 0xb4c99faau, 0xb3b3a00au, 0xb4d0258fu, 0xb3951495u, 0xb56291a8u, 0xb4b23709u, 0xb5323fafu, 0xb4aa24e5u, + 0xb500fe6cu, 0xb33d9b98u, 0xb445e3ebu, 0xb4893c7cu, 0xb48bc8aeu, 0xb50711fbu, 0xb518a19du, 0xb4c17060u, + 0xb5134430u, 0xb4cc2316u, 0xb4b3eb35u, 0x3b3110fdu, 0x3bb5b03du, 0x3b9e811cu, 0x39ad3233u, 0x327418d6u, + 0x32652240u, 0x331a3658u, 0x334d4cd5u, 0x32de2004u, 0x31d5b963u, 0x339669a2u, 0x32badb43u, 0x341808acu, + 0x301eeaecu, 0x34319e5eu, 0x3413b6d0u, 0xb184b66fu, 0x3426d1fcu, 0x32ecada2u, 0x3471052cu, 0x33191abbu, + 0x341e99dau, 0x33ee1ef2u, 0xb2deb274u, 0x3456acddu, 0x33c8a53au, 0x34294daeu, 0x327d6459u, 0x33c0a113u, + 0x34ab54b1u, 0x33440f9du, 0x33e882e2u, 0x348f9b65u, 0x32da22b0u, 0x341d2507u, 0x34007ca0u, 0x3473e318u, + 0x346b7421u, 0x34ab9578u, 0x342272ffu, 0x339fabb5u, 0x34b23a3bu, 0x3483d983u, 0xb4217872u, 0x35257291u, + 0xb38ac292u, 0x345e8f95u, 0x34a7aef6u, 0x341e60d7u, 0xb3bb2536u, 0x350f3d7eu, 0xb29b335au, 0x34886060u, + 0x33eab25bu, 0x34d086b3u, 0x324394e2u, 0x334fc79eu, 0x34b741b2u, 0x32881d31u, 0x34d27d8du, 0x3593ee13u, + 0x320be4d3u, 0x316b7f97u, 0xb3da15a7u, 0x3433dc4cu, 0xba8ea941u, 0xbb967be3u, 0xbb8af255u, 0x3b06c37du, + 0xb059ad27u, 0x322e04c7u, 0x33228e2du, 0x32968bcfu, 0x32b6f3dfu, 0x33171048u, 0xb1a124f0u, 0x33445ecfu, + 0x3392b49eu, 0x33b40ee1u, 0x335594d9u, 0x32965daau, 0xb18dc0b6u, 0x33e819c8u, 0x33dd82f3u, 0x3300a8d9u, + 0xb3c3825du, 0x3270c622u, 0x33117743u, 0x342f3f32u, 0x3310c508u, 0x33e81a66u, 0x340e57cau, 0x31513a08u, + 0x33e8ceb0u, 0x33922ab3u, 0xb403466du, 0x33a4b008u, 0xb37f2188u, 0x341f1846u, 0xb342888du, 0x34955798u, + 0x320435ccu, 0xb3aefcb6u, 0x3416bf3du, 0x3468b4a2u, 0x34b4ee97u, 0x344a692cu, 0xb4376d7bu, 0xb3d548e0u, + 0x346163d0u, 0x3471704eu, 0x33d51847u, 0x34971441u, 0x33a0f7a3u, 0xb2b2a4bbu, 0x34d8a3fau, 0x349af6a8u, + 0x344dcb65u, 0x349d5c8fu, 0x3311d31au, 0x34d37ecbu, 0x349d7c42u, 0x353b0f9fu, 0x3476ea0au, 0x34e02b3eu, + 0x341872a1u, 0x34c575a9u, 0x34ce892fu, 0x34f72a25u, 0x35298ac6u, 0xbaf6bb86u, 0x3b82f143u, 0x3b6ec7d3u, + 0xb98bb70au, 0xb2a486dau, 0xb304cca9u, 0xb2e4d884u, 0xb21c9a08u, 0xb35cc5e4u, 0x33546b57u, 0xb3fcbe3au, + 0xb3c51135u, 0xb48c2e67u, 0x33b98b69u, 0x32d403f1u, 0xb38979a5u, 0xb47058f6u, 0x33a44590u, 0xb3bf0e62u, + 0x32b69c32u, 0xb4fb328fu, 0xb4d2582cu, 0xb314ab89u, 0x3315e418u, 0x337f3eeeu, 0xb4dd1c85u, 0xb3ade3bau, + 0xb28004beu, 0x33283f03u, 0xb3b60b4cu, 0x3457b1e8u, 0xb50ced50u, 0xb43b33bfu, 0x34258aadu, 0xb4b26d02u, + 0xb47040a6u, 0x3496da8fu, 0xb558e385u, 0xb36ad8e8u, 0xb472eff5u, 0xb50744f9u, 0xb4fac6bau, 0xb48534ecu, + 0xb54571edu, 0xb4fa3c23u, 0xb49eef49u, 0xb563464cu, 0xb49733acu, 0xb2e44e69u, 0xb518aa7du, 0xb4f16649u, + 0x34f33c29u, 0x332f493bu, 0xb56a3b22u, 0xb4dd8328u, 0x34b85f63u, 0xb3597d82u, 0xb4a898ddu, 0x3454a950u, + 0xb51646b9u, 0xb54f0ef1u, 0x3380c1ddu, 0xb54abe3eu, 0xb591679fu, 0xb56dc76cu, 0x3a7200cfu, 0xbb5ec28eu, + 0xbb54a42du, 0xbad4a4e1u, 0xb2020becu, 0x33a17c20u, 0x3389e031u, 0x331980fdu, 0x33f3fe2du, 0x3392062du, + 0x32b6ff71u, 0xb2a9f861u, 0xb48fe4ccu, 0xb435040cu, 0xb40b7e69u, 0xb4d5a20cu, 0xb4b2ff35u, 0xb343f10au, + 0xb4377f6au, 0x348b7e0bu, 0xb41975c7u, 0x34d0c3d5u, 0xb3c3f085u, 0x345d9897u, 0xb26eac76u, 0xb40c6f69u, + 0xb26eac76u, 0x33a61b2du, 0xb3099da5u, 0xb4fdb72bu, 0xb4fdb72bu, 0xb26ebc27u, 0xb4ccbb2bu, 0xb48775e1u, + 0xb505bf59u, 0xb443eef8u, 0xb50b7dbeu, 0xb50b7dbeu, 0xb50b7dbeu, 0xb4b503fcu, 0xb56600beu, 0xac64663au, + 0xb4b50386u, 0xb443eef8u, 0x34261908u, 0xb4b503fcu, 0xb4b503fcu, 0x34261908u, 0xb443eef8u, 0xb3a9e078u, + 0xb443eef8u, 0x34ec8362u, 0x34b50404u, 0xb443eef8u, 0xb53504feu, 0x351fc7f1u, 0xb580dfb6u, 0x348a8be2u, + 0x2a00f780u, 0x33ddfd88u, 0xb50b7dbeu, 0xb3a9e078u, 0x34ec8362u, 0xb46ae83cu, 0x34a18762u, 0x3abf7854u, + 0x3b44b69du, 0x3b3a8052u, 0x391ed6d2u, 0xb311793au, 0xb3331901u, 0xb324e7bau, 0x332fc219u, 0xb3e30594u, + 0xb429cda9u, 0x3195738au, 0xb43a49fdu, 0xb45fa8ffu, 0xb3a19ef8u, 0xb4892cc6u, 0xb4426ffeu, 0xb341ece3u, + 0xb4417f4fu, 0xb41f6ebcu, 0xb3b31d68u, 0xb3d443a7u, 0xb36a74e5u, 0xb41c2aa1u, 0xb425b26du, 0xb4a674cau, + 0xb49f8fd7u, 0xb3469360u, 0xb442bb91u, 0x34275af8u, 0xb4d1d593u, 0x3485289bu, 0xb3c6215cu, 0x330de5aau, + 0xb4a9edb2u, 0xb3cdb9a3u, 0xb51fd21eu, 0xb3ecbd1cu, 0x33ee4cfbu, 0x343f1c58u, 0x31c1d75du, 0x3492e7ddu, + 0x336d1daau, 0x336151d8u, 0xb4e87ab7u, 0xb336c96cu, 0xb50c8e15u, 0xb581a304u, 0xb46eb9efu, 0xb58026d2u, + 0xb4d88a6cu, 0xb1b0df15u, 0xb41ac906u, 0xb4a9f346u, 0xb566db6au, 0x34263c3cu, 0xb4aeba6cu, 0xb562df80u, + 0xb5907d48u, 0xb5a039adu, 0xb4846874u, 0xb55b9595u, 0xb5bd6049u, 0xb2a408b4u, 0xb4793c5cu, 0xb53176bau, + 0xba3efaeeu, 0xbb2a7e5cu, 0xbb282c74u, 0x3aa51f68u, 0x3222cdacu, 0xb1f700e3u, 0xb21dc1b8u, 0x304b2056u, + 0xb26afde0u, 0xb1215f73u, 0x3189134au, 0x3351714eu, 0x3332cc1cu, 0x32b96517u, 0x318e9aa1u, 0xb3b08681u, + 0xb28303efu, 0xb387884cu, 0x33a272ddu, 0xb2b0feccu, 0x33f59d7fu, 0xb3357339u, 0xb33e5675u, 0xb41e07afu, + 0xb280cff3u, 0xb4630a3fu, 0xb3b7dec5u, 0xb3f81eb6u, 0xb456a64eu, 0x327b8953u, 0xb2dcb4d8u, 0x34632f21u, + 0xb30958beu, 0x3317fb16u, 0xb4d9415du, 0xb0de6ea3u, 0xb49bb3dcu, 0xb32e1885u, 0x3318980eu, 0x33895118u, + 0x33028587u, 0x34738873u, 0x3476ac93u, 0xb31fb6e0u, 0x342e493bu, 0x339a397eu, 0xb43c4833u, 0x3433ee5cu, + 0x343f4070u, 0xb34ddb1du, 0x342a7d8bu, 0xb4988f8fu, 0xb5090becu, 0xb3661e83u, 0x33fa0cc7u, 0x34a33201u, + 0xb3e34f93u, 0xb3375871u, 0xb40fa3fau, 0xb33c4513u, 0x342b6277u, 0xb43547dbu, 0x333244fcu, 0x34639e6du, + 0x341c3cd5u, 0xba910c9eu, 0x3b1827fcu, 0x3b15d8c9u, 0xb90663fbu, 0xb0957538u, 0x3358f773u, 0x3263f11cu, + 0x326ee9adu, 0x3253b54au, 0xb1bc53c4u, 0x3341d597u, 0x329050d1u, 0xb34d508cu, 0xb36f4d79u, 0x337ef749u, + 0x3383cae9u, 0x338790beu, 0xb41cdb50u, 0x33bc0366u, 0x3407ec34u, 0x3233ac25u, 0x33d859d1u, 0x33b32e9fu, + 0x331cd354u, 0x327914dbu, 0xb3d30ea5u, 0xb3a20b2du, 0xb39fd4fbu, 0x339c71d4u, 0xb408bec8u, 0xb484fa78u, + 0x33051bd8u, 0x322db200u, 0xb32f3719u, 0xb1d6ecc0u, 0xb3a4f49cu, 0x34dca7b5u, 0x33e3876cu, 0x33fd68d1u, + 0xb4998a93u, 0xb3b51bafu, 0xb318c3f5u, 0xb394dd4fu, 0x32485308u, 0xb39ba71fu, 0x351f3e3fu, 0x33354fffu, + 0x3480373cu, 0xb4995cd9u, 0xb4e46647u, 0x34b845ecu, 0x3413b329u, 0xb3a0b65au, 0x342aa71fu, 0xb4d9ec40u, + 0xb399b1b6u, 0x34a46e9fu, 0x346dfdd7u, 0x34102ad7u, 0xb490d873u, 0xb4b7626au, 0x34f5dffbu, 0x3230378au, + 0x3538a187u, 0xb4ae8c53u, 0x3a28529eu, 0xbb05db13u, 0xbb0880d3u, 0xba888059u, 0x31f44bb4u, 0x31350398u, + 0xb2a2ececu, 0x334a3f22u, 0x33027042u, 0x33abd741u, 0x334cf5eau, 0x33c331f8u, 0xb3a43263u, 0x339feb5bu, + 0x334f0192u, 0x333200b7u, 0xb3d62f13u, 0xb398e442u, 0x341a11a0u, 0xb4a77ab9u, 0x340cb4c3u, 0xb3f116e0u, + 0xb4358ec0u, 0x33be76d1u, 0x33cf0335u, 0xb39ed1e0u, 0x349342b1u, 0x34803ad9u, 0x33df910eu, 0x34212a94u, + 0x34473342u, 0x33c861e4u, 0x34720716u, 0x33a79f77u, 0x3477ffd7u, 0x34a7753bu, 0xb3b19c15u, 0xb4781f60u, + 0x34c8cd70u, 0x33c30c79u, 0xb2a7c81eu, 0x34e6ac34u, 0x341e8ab3u, 0x34ef3d43u, 0x34974696u, 0x340c9ee2u, + 0x3435c096u, 0x351dfd8bu, 0x32f9457du, 0x346d3c9bu, 0x33c60f0du, 0x345d1a5cu, 0xb46b0e9cu, 0x338a5de9u, + 0xb37a8fcbu, 0x34d3ccb5u, 0xb339dfe4u, 0x34586793u, 0x3417a528u, 0xb38c6e15u, 0x34795635u, 0x348b1d8fu, + 0xb326bbc3u, 0x34b1b2e8u, 0xb384c579u, 0x3a6a4affu, 0x3af109f0u, 0x3af651c8u, 0x38ab29c4u, 0x3228813bu, + 0xafef69d4u, 0xb27016c7u, 0xb205557fu, 0xb3393d1bu, 0xb2ec0f89u, 0x3292e6f9u, 0xb2374e51u, 0xb312e9beu, + 0xb43811a8u, 0xb238dc71u, 0xb2e1e1ecu, 0x3295bb54u, 0xb2f31441u, 0x336669ecu, 0x32e90f87u, 0xb1b74f1bu, + 0x33f67f9cu, 0xb383a484u, 0xb38a80f6u, 0xb3e3f6f1u, 0xb35cd925u, 0x33b909abu, 0x32c6aea3u, 0xb374e926u, + 0x33fb8a3du, 0xb465bc38u, 0xb388c687u, 0x3290ee0eu, 0xb2ace241u, 0x337d1765u, 0xb4dd7088u, 0x34761ec1u, + 0x31c11505u, 0xb48255d3u, 0xb4c1c52au, 0x33f62492u, 0xb446a894u, 0xb482a6e3u, 0x341221deu, 0xb4bc856au, + 0xb3d5db10u, 0xb44dd1b0u, 0xb49dfe1bu, 0xb443d9c2u, 0xb42a99a8u, 0xb3de85a5u, 0x328b57a0u, 0xb51b66a4u, + 0xb4188011u, 0xb472da09u, 0xb4983afeu, 0x33cf9308u, 0xb2dec81fu, 0xb4d70b55u, 0xb52044d4u, 0xb42af3e9u, + 0x331350f2u, 0xb3e99db0u, 0xb4ce4badu, 0xb4fc1de3u, 0xba0ed53cu, 0xbad66452u, 0xbae249b5u, 0x3a5f8e50u, + 0xb0ee7dc5u, 0x326ad57bu, 0x3361317bu, 0x3332be34u, 0x331851ceu, 0xb17db714u, 0x33ed41bau, 0x33f4e64du, + 0xb2ea27c8u, 0xb2362712u, 0x341a6521u, 0x33c28fcbu, 0x33491591u, 0x33445fb2u, 0x34422014u, 0xb28f1aeeu, + 0x33a41e7eu, 0x33ef6a88u, 0x334abff3u, 0x33f67cb4u, 0x340cbf19u, 0x34a8479du, 0xb2bf838cu, 0x33795ccau, + 0x32b74db4u, 0xb422096du, 0x33c92216u, 0x3499df7cu, 0xb3659fb8u, 0x34536dd5u, 0x333b6281u, 0x34575440u, + 0xb32dfa92u, 0xb409dfd6u, 0x34f0c884u, 0x333a0889u, 0xb4588e23u, 0x33dcd1a3u, 0x31ff990du, 0x34718911u, + 0x343ac464u, 0x33e06e17u, 0x34221422u, 0x33de27beu, 0x347b1254u, 0x34509583u, 0xb3f93750u, 0x335d17fau, + 0x338b59b5u, 0x34878d02u, 0x333b0aa4u, 0x3413cd21u, 0x349647ebu, 0x34c79973u, 0xb1693932u, 0x343167d4u, + 0x34056871u, 0x34fd7560u, 0x31a62b64u, 0x338490e4u, 0x352967fdu, 0xba3a05ccu, 0x3ac25235u, 0x3ace4187u, + 0xb89554d4u, 0xb27392f1u, 0xb36f2f68u, 0xb3451b69u, 0xb2cccfcau, 0xb39d7f10u, 0xb3a2ab35u, 0xb4351853u, + 0xb41b7c7cu, 0xb39e4ca8u, 0xb4258c8du, 0xb4102dfcu, 0xb387ec14u, 0xb42c49e8u, 0xb443ba5eu, 0xb3ad74e8u, + 0xb457b666u, 0xb40f1218u, 0xb423af52u, 0xb3ca0d2du, 0xb4d958e1u, 0xb4b5d90fu, 0xb3c9b4ffu, 0xb432a4d6u, + 0x3221d521u, 0xb3c24b00u, 0xb3bf15e8u, 0xb4a32bcbu, 0xb3422f00u, 0xb4130184u, 0xb50833cfu, 0xb4433a89u, + 0xb4d42f11u, 0xb4440d4cu, 0xb48a6f2bu, 0xb506cbdeu, 0xb4b6668eu, 0xb5111caeu, 0xb4b3914au, 0xb404c2f5u, + 0xb53fde02u, 0xb4f0c1e2u, 0xb54c89e6u, 0xb5193c5bu, 0xb57ea3cfu, 0xb483542au, 0xb519e471u, 0xb5a6c6e1u, + 0xb53242bfu, 0xb495cc26u, 0xb50f5772u, 0xb4c8c596u, 0xb55475a1u, 0x33671de2u, 0xb55957a3u, 0x343da1c1u, + 0xb50e3a21u, 0xb5893280u, 0xb4edf97bu, 0xb51fec73u, 0xb5483a14u, 0xb4eb28dfu, 0x3a00f14eu, 0xbaae54e4u, + 0xbabed717u, 0xba3ed6b1u, 0xb2a402cdu, 0xb386cad7u, 0x33661e7eu, 0x3195ad50u, 0x32f3eaf5u, 0xb275a422u, + 0xb372ef40u, 0xb35ba9bbu, 0x33ad0e08u, 0x32cd7f95u, 0xb3bdcda8u, 0xb3c67958u, 0x3249bfe3u, 0xb3ade0f8u, + 0x340185e2u, 0x34657beau, 0x33f60677u, 0x3433d940u, 0x3455a79bu, 0x3480410du, 0xb2295013u, 0x347c798au, + 0x32d7924eu, 0x34698348u, 0x341d54e1u, 0x343b11a4u, 0x34b122abu, 0x331ec58eu, 0xb2bb2709u, 0x33b20385u, + 0x3451e382u, 0x34d0c83cu, 0x33db5f8du, 0xb3e4aef8u, 0x33f484fcu, 0x34c7ca31u, 0xb3d88f96u, 0xb45f2727u, + 0xb4eb797eu, 0x336da156u, 0x348e82c4u, 0x3492c6f9u, 0x34ec2ed1u, 0x33771aa6u, 0x3409e354u, 0x34e7f480u, + 0x3539bab3u, 0x34890a39u, 0x355d4ad2u, 0x3596fbe0u, 0x3497e2cfu, 0x351b641eu, 0x3504eeccu, 0x33ac27f6u, + 0x33f132e0u, 0x3558f83au, 0x341d66a8u, 0x35073fe0u, 0x33f8932eu, 0x3454459du, 0x35222788u, 0x3a1a629fu, + 0x3a9ecdcau, 0x3aaf6c90u, 0x384d1584u, 0x326b507fu, 0x3271af78u, 0xb218bb8au, 0x32ab2ef9u, 0x321cef0bu, + 0x32866eaau, 0xb1a7cd93u, 0x316a4eeeu, 0xb38ad15eu, 0xb34670e9u, 0x33d28f8cu, 0x33b05c83u, 0x32be0091u, + 0xb36be17cu, 0xb347ac14u, 0x3459a962u, 0xb3bdddfdu, 0x3372637eu, 0x338b9e52u, 0xb4ad7e74u, 0xb418a50eu, + 0xb35da101u, 0x3150eca1u, 0x34144b14u, 0xb38ce99du, 0xb46a1d66u, 0xb3f34958u, 0xb3886948u, 0xb38187d1u, + 0xb31bf3cau, 0xb2e27b7eu, 0x3412c534u, 0xb49e3efdu, 0x340e7829u, 0xb4556554u, 0x344a6245u, 0xb370789du, + 0x347f7767u, 0xb51b60a8u, 0x3476694bu, 0xb3ed81bcu, 0x3332e89fu, 0xb416516cu, 0x339727f2u, 0x34934a3eu, + 0x34265c91u, 0xb333a301u, 0x33f43fc3u, 0xb44bd9dbu, 0x3484ebf9u, 0x34535e44u, 0xb4354a2fu, 0x34acc4c4u, + 0x348c6cf9u, 0x351494b9u, 0xb502785du, 0x348e08abu, 0x348ddfc5u, 0xb47f9854u, 0x34a0d5e6u, 0xb45e5982u, + 0xb9e3b093u, 0xba8f6e70u, 0xbaa34eedu, 0x3a21e869u, 0x3162c299u, 0x30047e6au, 0x31e233f4u, 0xb2093afdu, + 0x32ca12c6u, 0xb2b9ef7eu, 0x325e5901u, 0x331960b5u, 0x323857a9u, 0xb39b1de2u, 0xb3aae0a3u, 0xb34b32e3u, + 0x32aefafbu, 0xb35c5143u, 0x322c18afu, 0xb3eb52beu, 0x3303a0cfu, 0xb39abd8bu, 0xb282a54au, 0xb3ef2cfdu, + 0x33ece0d7u, 0x3204e832u, 0x34152f86u, 0xb30216d9u, 0xb44a30e3u, 0xb3beee3eu, 0xb393b196u, 0xb4501a0bu, + 0xb2c84cffu, 0xb269eba1u, 0xb46e21aeu, 0xb3fab229u, 0x32cb39e4u, 0xb3fc4f0au, 0xb265856fu, 0xb43b8649u, + 0x33bdccc4u, 0x33f00ebau, 0x31a54d60u, 0xb4e459ddu, 0xb4937124u, 0xb39262d6u, 0xb49812a4u, 0x34316579u, + 0x313441b6u, 0xb356de64u, 0x34535491u, 0xb41d9606u, 0x320ba38cu, 0xb45d1f3fu, 0xb3a1aeb1u, 0xb4a09a3eu, + 0xb429b767u, 0xb417cf36u, 0xb4a544d3u, 0xb487eb20u, 0xb438afd1u, 0xb42692bbu, 0xb53fdf49u, 0x32c25ecbu, + 0xb45fd9a9u, 0xb9fbf3d3u, 0x3a8354f7u, 0x3a9730ccu, 0xb836a5d4u, 0x3168b591u, 0xb3086e87u, 0x33362476u, + 0x31f87239u, 0x334a48b8u, 0x3356df77u, 0x33b6d908u, 0x33bda3d8u, 0x330d959du, 0x338c7dc5u, 0x33a3dd0au, + 0xb3a9e821u, 0x32ea5105u, 0x33bb7d23u, 0x338a88aeu, 0x33d65564u, 0xb31f8371u, 0x32a40ae7u, 0x33d84160u, + 0x33f3c017u, 0x34050b22u, 0x3400eb4cu, 0xb3668464u, 0x344936b0u, 0xb3942e73u, 0x3423c021u, 0x32f65d5eu, + 0x33b674e2u, 0x342e3b40u, 0x340a3f06u, 0x31994c98u, 0xb47c54b8u, 0x3432e3f3u, 0xb39a6156u, 0xb42d7505u, + 0x3430cfefu, 0x34c82535u, 0x33b6c3f3u, 0xb4c77667u, 0x34b478aeu, 0x34a17a6eu, 0x3507daafu, 0x341db3e8u, + 0x349329bau, 0x350bde5au, 0x35300a52u, 0x34e55ef2u, 0x34ec144au, 0xb3d480c9u, 0x34a0e739u, 0xb35a89dbu, + 0x343dca80u, 0x33a23e7au, 0x34c572a4u, 0x33d09667u, 0x34abf054u, 0xb2363cceu, 0x3540852eu, 0xb32d5182u, + 0x34e40e80u, 0x353d6e70u, 0x39d0e418u, 0xba6e4c8bu, 0xba8d7e99u, 0xba0d7eeeu, 0xb14c9971u, 0xb18505cdu, + 0xb2aff441u, 0x332302b9u, 0xb2885bf7u, 0xb30808beu, 0x3248166au, 0xb2801423u, 0x33472cb8u, 0x319fbc56u, + 0xb38ef4d0u, 0x31cfb5ecu, 0x33eee281u, 0xb3e31428u, 0x34566a5au, 0xb383e40du, 0x3308c4e8u, 0x347d888eu, + 0x3412c9f1u, 0x34a92f13u, 0x34fc47feu, 0x34aaebc7u, 0x343e6893u, 0x3396c560u, 0x3481c7ecu, 0x348ffab6u, + 0x3421d142u, 0x33eee214u, 0x33a90523u, 0x34f6e2dcu, 0xb4b44dd9u, 0x348b2ea2u, 0x348f73a1u, 0xb437246bu, + 0x33ace767u, 0x344ec56eu, 0x344f4129u, 0x33b01da5u, 0x34faf4fau, 0x33f1b0b6u, 0x34171aa6u, 0x33f50355u, + 0xb4fb49cbu, 0x33e588e4u, 0x349240acu, 0x33d565fau, 0xb43bbf82u, 0x342404abu, 0x341cbd2fu, 0x355a457fu, + 0x34b4af0fu, 0x3488cfdfu, 0x34a8ca3fu, 0x345bccc4u, 0x345fa521u, 0x3272feb7u, 0x32d0725cu, 0x34d1e6b5u, + 0x34961f1eu, 0x34c00f6fu, 0x34aba399u, 0x39d40c4du, 0x3a5af98fu, 0x3a83ccb6u, 0x38044cabu, 0xb27a8f00u, + 0xb3044078u, 0xb2baec56u, 0xb390547eu, 0x32d05048u, 0xb3259f4cu, 0xb31609eau, 0xb3d75e71u, 0xb3d0df98u, + 0xb3de1bf8u, 0xb3dc5790u, 0xb43413d3u, 0xb3ee0503u, 0xb4813529u, 0xb3e29413u, 0xb3aeeb96u, 0xb44348a3u, + 0xb477d711u, 0xb3cebd75u, 0xb455f794u, 0xb46d1080u, 0xb41b267du, 0xb4162bf9u, 0xb3a5bf3eu, 0xb4951e05u, + 0xb44459cbu, 0xb4d15b55u, 0xb4ead60eu, 0xb4e7df28u, 0xb399b4ffu, 0xb490efedu, 0xb49df53bu, 0xb41c5777u, + 0xb4a53123u, 0xb48b34bbu, 0xb490aee5u, 0xb3dbf2f3u, 0xb4a94c66u, 0xb4cd8ccdu, 0xb2838b7au, 0xb4a57ad8u, + 0xb48c9430u, 0xb4f0150fu, 0xb3a34716u, 0xb48aa589u, 0xb50a3ba8u, 0xb44f3fb8u, 0xb4c9f63au, 0xb4d744a4u, + 0xb4a36c88u, 0xb53d1176u, 0xb4dd0286u, 0xb54903eau, 0xb5432afcu, 0xb4582181u, 0xb48a7e16u, 0xb4eba570u, + 0xb527a1f0u, 0x344af236u, 0xb5099d79u, 0xb5698951u, 0xb9bd0951u, 0xba47bbbau, 0xba77d846u, 0x39f645e7u, + 0x3262d099u, 0x3283dc2cu, 0x332ad5f5u, 0x32f3177eu, 0x33a049d0u, 0x32dbf1a4u, 0x32c3b456u, 0x339a1e83u, + 0x33261b7du, 0x33c4a8f2u, 0x3443dbdau, 0x33d35053u, 0x33500590u, 0x3392786au, 0x341ce0dau, 0x3421050cu, + 0x3414d2acu, 0x34444973u, 0x34774126u, 0x34799a85u, 0x347e0aa5u, 0x33f0be6eu, 0x346ed563u, 0x34397401u, + 0x344a4036u, 0x3441ad70u, 0x34228eccu, 0x3448233bu, 0x345e86f1u, 0x3505b09fu, 0x348d62d8u, 0x348da54fu, + 0x34e43025u, 0x3503acbbu, 0x34de9a4fu, 0x344e224eu, 0x339f520bu, 0x34c8b63bu, 0x349ad968u, 0x344aa4ccu, + 0x34a12b42u, 0x323887a6u, 0x34c404a7u, 0x3497b071u, 0x351486e6u, 0x34f1597fu, 0x3455e5ddu, 0x34dc2718u, + 0x34d769a8u, 0x34b43abeu, 0x34ffb61cu, 0x34bb95f6u, 0x34c55df8u, 0x3525b5cbu, 0x34f02077u, 0x351faef9u, + 0x353a13bbu, 0x35625c07u, 0x3541b7eau, 0x341da5beu, 0x355c7f16u, 0xb9b0010du, 0x3a381408u, 0x3a6817c3u, + 0xb7ef53a1u, 0x32834eb6u, 0xb32a79a5u, 0xb25eee52u, 0x3425eda7u, 0xb318d908u, 0xb2b5ff4au, 0x332de29du, + 0xb477dc32u, 0x338bd67cu, 0x33746abeu, 0xb4c4c39au, 0xb3d53504u, 0xb40bbab2u, 0xb02c8000u, 0x326c09f0u, + 0x34579640u, 0x3287a0e8u, 0x344a5c5fu, 0x33f5c43eu, 0xb3de02fcu, 0xb4d89920u, 0x34a99290u, 0xb25e6610u, + 0x32f16ff0u, 0x34c87f41u, 0xb435ae06u, 0xb5399362u, 0xb3bac142u, 0x342b81e1u, 0x33b80f42u, 0xb48b6472u, + 0x355da2b0u, 0xb52031a0u, 0x34b262deu, 0xb3978008u, 0xb3978008u, 0xb4aea8a2u, 0x34938840u, 0x34b262deu, + 0x34938840u, 0xb55df900u, 0x33db507cu, 0xb5947d20u, 0xb46ab484u, 0xb56495f0u, 0xb49434e0u, 0xb49434e0u, + 0xb46ab484u, 0x34081c00u, 0xb5897800u, 0x353c8280u, 0xb52d6b82u, 0x35193e70u, 0xb4829423u, 0xb3978008u, + 0xb49434e0u, 0x354552dfu, 0xb52d6b82u, 0xb507e1d0u, 0x35a3c859u, 0xb4aea8a2u, 0x39ae81dfu, 0xba27f369u, + 0xba5b1f8du, 0xb9db1ed8u, 0x335a82f7u, 0x337ffff7u, 0x33da827cu, 0x28800000u, 0x33b504f5u, 0x33b504f5u, + 0x34b504f7u, 0x34b504f4u, 0x343504f4u, 0x351a827au, 0x343504e7u, 0xb43504ffu, 0xaa400000u, 0xaa400000u, + 0x34da827au, 0x353504f0u, 0xaa400000u, 0x353504f6u, 0x34b504edu, 0x353504f6u, 0x34b504fau, 0x2a600000u, + 0x355a827du, 0x355a8276u, 0x353504f6u, 0x34b504edu, 0x34b504edu, 0x355a8276u, 0x359a8278u, 0x34b504edu, + 0x34b504edu, 0xaa400000u, 0xaa400000u, 0xaa400000u, 0xaa400000u, 0x353504f0u, 0xaa400000u, 0xaa400000u, + 0xaa400000u, 0xaa400000u, 0xaa400000u, 0x353504f0u, 0x353504f0u, 0xaa400000u, 0xaa400000u, 0xb53504f6u, + 0xaa400000u, 0xaa400000u, 0x353504f0u, 0xaa400000u, 0x353504f0u, 0x3495f614u, 0xb53504f6u, 0xb53504f6u, + 0x353504f0u, 0xaa400000u, 0x357ffffdu, 0xb53504f6u, 0xaa400000u, 0xb53504f6u, 0xaa400000u, 0x399672f3u, + 0x3a1aefbbu, 0x3a4e258fu, 0x37b4afcau, 0x33283358u, 0x33086b4fu, 0x33298e0cu, 0x340bb900u, 0x33c2a16fu, + 0x340df23du, 0x34040400u, 0x335f8518u, 0xb26bbd79u, 0x341fc2f7u, 0x34388317u, 0x32de8aceu, 0x3434111cu, + 0x346d2912u, 0xb3bd9587u, 0xb4583cbfu, 0xb4077f98u, 0x33cdbfb4u, 0x34645364u, 0x341b8064u, 0xb34aee22u, + 0xb3809cafu, 0x348f0617u, 0x33fe1aa9u, 0xb39ccff4u, 0xb4c39498u, 0xb42d06c4u, 0x3407f006u, 0x358fe2fcu, + 0x33f956c7u, 0x34d695a1u, 0x3525ed83u, 0x342647e9u, 0x34a2dacdu, 0x3526dffeu, 0x35a206bcu, 0x348bcbc1u, + 0x358a9d0fu, 0xb28474bbu, 0x3511e68bu, 0xb4b23feeu, 0x35242e28u, 0x34eed62fu, 0x34575006u, 0x354779f2u, + 0x35c80719u, 0x358e282bu, 0x357a4275u, 0x34f01946u, 0x34e0dac0u, 0x3499bb94u, 0x33623e05u, 0x333ee38fu, + 0xb3457a98u, 0xb322904au, 0x358e282bu, 0x35504f80u, 0x35289f54u, 0x3580ea6cu, 0x35a438e3u, 0xb54c54a5u, + 0xb99fc9edu, 0xba0e05fdu, 0xba435724u, 0x39c25f36u, 0xb3167dabu, 0xb2fe0a25u, 0xb2e16080u, 0xb3856033u, + 0xb3b1244fu, 0xb3cbf65au, 0xb3485420u, 0xb401619bu, 0xb39708ddu, 0xb3c7b1c9u, 0xb45c1902u, 0xb409d32eu, + 0xb3a6e939u, 0xb430e6b8u, 0xb4664857u, 0xb205fa06u, 0xb4668effu, 0xb4197664u, 0xb3b5315fu, 0xb49704fcu, + 0xb33e8b4du, 0xb484275cu, 0xb402b1dcu, 0xb449f291u, 0xb3b728d5u, 0xb2ce182cu, 0xb4be1acau, 0xb4ab40a2u, + 0xb4ace482u, 0xb4a554b5u, 0xb4bcd2cfu, 0xb472e68au, 0xb4a39730u, 0xb503526fu, 0xb4e1c00du, 0xb4e14713u, + 0xb4eb08b4u, 0xb4df1752u, 0xb452d12bu, 0xb488a9a3u, 0xb4c7ddf8u, 0xb536fcdau, 0xb4bb576du, 0xb4824defu, + 0xb4a01172u, 0xb3f03839u, 0xb4e1fb60u, 0xb4f44f6au, 0xb548b8b5u, 0xb4fb695eu, 0xb52fa4cdu, 0xb433256cu, + 0xb4c5e834u, 0xb5293a6eu, 0xb4e4124fu, 0xb4ef1c1au, 0xb5131c49u, 0xb5331ff9u, 0xb532b67au, 0xb5068975u, + 0xb57ccd38u, 0xb97c4c1du, 0x3a03165fu, 0x3a388842u, 0xb7a52ad1u, 0x329a36d5u, 0xb2175d05u, 0x32d69e1eu, + 0x3181d7cfu, 0x330b23f5u, 0x32cffba9u, 0x32535e06u, 0x32219258u, 0xb332273fu, 0x329b393eu, 0x338bc8bbu, + 0xb37653e9u, 0x3295e26du, 0xb29479dcu, 0xb22d8e2bu, 0x33582a38u, 0xb3b2fb45u, 0xb2f1c4d7u, 0xb4906bd9u, + 0x33242027u, 0x3186651du, 0xb3e77d4cu, 0xb44378a0u, 0x339392bfu, 0xb2084a13u, 0xb406f60du, 0xb18caf57u, + 0x3429ae2au, 0xb42ff427u, 0xb3faf180u, 0x33729be6u, 0xb3bb8846u, 0x33582857u, 0x33cc1618u, 0x34358721u, + 0xb36d564fu, 0xb45efc18u, 0xb356ecc1u, 0x3387b404u, 0xb3d00102u, 0x33d8e992u, 0xb41b1c32u, 0x344e3fb9u, + 0x33e9d2c4u, 0x33f753b1u, 0x33b472d9u, 0x346823bcu, 0x338cf0eau, 0xb3e62701u, 0xb4287a6fu, 0xb433972du, + 0xb4240058u, 0xb44d74f0u, 0x33206f34u, 0x324f5971u, 0x34874a13u, 0xb4897086u, 0xb4ab3417u, 0xb50803e4u, + 0x3507de2cu, 0xb4a64adau, 0x3995c874u, 0xb9f13ff3u, 0xba2f6c6bu, 0xb9af64f0u, 0x33dbdd44u, 0x340e66b9u, + 0x344469adu, 0x34680d17u, 0x348414e6u, 0x3454afcdu, 0x34a43330u, 0x347a494du, 0x34d5c90eu, 0x34c6c21cu, + 0x34cd550bu, 0x34d3796eu, 0x34ab7072u, 0x3508de16u, 0x351599d0u, 0x353f49d2u, 0x358805b1u, 0x354f4959u, + 0x35831309u, 0x353bbfceu, 0x354d3217u, 0x3572bd77u, 0x3563f069u, 0x355bb951u, 0x358f0ae1u, 0x3589c33au, + 0x358407b8u, 0x35896778u, 0x35904040u, 0x35678435u, 0x35aaa765u, 0x3575de55u, 0x35adee22u, 0x35817116u, + 0x35b4870cu, 0x35b5f95bu, 0x35b99b5du, 0x35977a54u, 0x35c1c5d7u, 0x35900ab1u, 0x359b2170u, 0x359e650eu, + 0x3601e7a6u, 0x35cf5102u, 0x3602a4afu, 0x35fbc7fcu, 0x35cb0af1u, 0x35f6b772u, 0x35cff647u, 0x35c5e769u, + 0x35ea5d28u, 0x35f5fb21u, 0x35f40480u, 0x36030469u, 0x35f17e37u, 0x35cf09b8u, 0x35ee5069u, 0x35dd5d6cu, + 0x36093e24u, 0x360ff0f4u, 0x3602c257u, 0x3958a1c6u, 0x39dfeb07u, 0x3a2655abu, 0x378095d6u, 0xb0063530u, + 0xb236d938u, 0xb232ed0au, 0xb2aa6eafu, 0xb311a325u, 0xb290ddb5u, 0xb28784b1u, 0xb10a105eu, 0x31cab619u, + 0xb2cde34du, 0x33814305u, 0xb2db99f5u, 0x33893164u, 0xb3eba48bu, 0x32c92c5bu, 0x33813ad9u, 0xb2ccb1d1u, + 0xb362208cu, 0x33b5b26au, 0xb3189d64u, 0x34523710u, 0xb20781bau, 0x33b40e72u, 0x335801fdu, 0x3412f25du, + 0xaf9521f7u, 0x3494cca6u, 0x323d649bu, 0x33b86c16u, 0x33d02f96u, 0x30f3ff46u, 0x33f0a3b1u, 0x34109294u, + 0xb3f019b8u, 0xb1aa6386u, 0xb3eb44a7u, 0x33326d75u, 0x314077a0u, 0xb2bbb4a4u, 0x348726c6u, 0x344c07d6u, + 0x34e90d54u, 0x3366cbb8u, 0x338bbdaau, 0x349cb099u, 0x344ddbf0u, 0x335e4570u, 0x34dfc903u, 0x346d673eu, + 0x3489eb06u, 0x32987795u, 0xb4ef54bcu, 0x34c23ab2u, 0xb4d25ff5u, 0x34f78b49u, 0xb3e05a61u, 0x3480caa3u, + 0xb5112063u, 0x33f8c4deu, 0xb3ba3ffbu, 0x3402fe93u, 0xb98ad204u, 0xb9cc6604u, 0xba1e9d0du, 0x399df48cu, + 0xb3c52173u, 0xb417824cu, 0xb4374903u, 0xb4607255u, 0xb473299bu, 0xb49bb91eu, 0xb4912fdbu, 0xb4bede69u, + 0xb4cc7997u, 0xb4caf8b8u, 0xb4b34636u, 0xb4e1bd08u, 0xb4fc745bu, 0xb4f2cb82u, 0xb5184037u, 0xb5023ab5u, + 0xb5251cceu, 0xb52f9775u, 0xb53e9279u, 0xb5428979u, 0xb506976cu, 0xb55ddcf0u, 0xb547b489u, 0xb541d139u, + 0xb573710fu, 0xb5586ff4u, 0xb56ca35bu, 0xb5860cd0u, 0xb57272a2u, 0xb588b1f1u, 0xb579690bu, 0xb5a09237u, + 0xb57a0e16u, 0xb5b9132bu, 0xb5bba779u, 0xb59f24c0u, 0xb58e527eu, 0xb5aa4ac6u, 0xb58703d2u, 0xb5b1b1a4u, + 0xb5b22171u, 0xb5b13f73u, 0xb59c5cfcu, 0xb5ca94a1u, 0xb5de6662u, 0xb5bbdf8du, 0xb5b978b1u, 0xb5d61ce9u, + 0xb5ec4964u, 0xb5d178b2u, 0xb5d82cf9u, 0xb5d18019u, 0xb5d59a70u, 0xb5f07638u, 0xb5f9dae3u, 0xb5ce1732u, + 0xb5c4bc4au, 0xb5ef40dcu, 0xb5dc1febu, 0xb5df137au, 0xb61f28d2u, 0xb936ba0fu, 0x39bc360cu, 0x3a16de86u, + 0xb76e4392u, 0xb2d3a2deu, 0xb3c1d688u, 0xb3f29088u, 0xb4063af5u, 0xb428d063u, 0xb3fc2684u, 0xb3d7a90au, + 0xb316dbe4u, 0xb41ed27bu, 0xb43df859u, 0xb4889711u, 0xb4257f36u, 0xb42152aeu, 0xb4c83687u, 0xb433bcb6u, + 0xb49f0799u, 0xb49c93b3u, 0xb49c7d73u, 0xb4c8aef2u, 0xb502f076u, 0xb4dd437bu, 0xb492d639u, 0xb4c7fcd8u, + 0xb53e99b7u, 0xb50c07dbu, 0xb4c0f2a9u, 0xb4b97413u, 0xb4b25e8fu, 0xb4caaa16u, 0xb4c48c9eu, 0xb009422fu, + 0xb51604abu, 0xb5740c40u, 0xb524847au, 0xb47af639u, 0xb5a89529u, 0xb509e1c0u, 0xb45e731au, 0xb54f1f5eu, + 0xb513e4abu, 0xb53c4c98u, 0xb5396969u, 0xb4d1d566u, 0xb58f497au, 0xb50af573u, 0xb51226c5u, 0xb5a2b61au, + 0xb57c48d5u, 0xb55acd05u, 0xb562a600u, 0xb58227c1u, 0xb51ed0f7u, 0xb504b275u, 0xb54c4b22u, 0xb59b3277u, + 0xb534580cu, 0xb46df18bu, 0xb5349a56u, 0xb5a91a37u, 0xb5756da9u, 0xb55f6a6cu, 0x39825b22u, 0xb9ae4748u, + 0xba1040a7u, 0xb9903fa5u, 0x32ff744eu, 0x32b694b7u, 0xb29b368fu, 0x30d9e72bu, 0x33972565u, 0xb1fd0ef1u, + 0x33be6800u, 0x340fa0c8u, 0x32d936dcu, 0x3440ca51u, 0x33c29781u, 0x34a93f1bu, 0x345149ffu, 0x34ad1f18u, + 0x3455c0adu, 0x35285be5u, 0x35169682u, 0x350513eau, 0x351702e3u, 0x350664d5u, 0x35086f8fu, 0x348b6a6au, + 0x3511cf21u, 0x3552db28u, 0x350bf181u, 0x354a2c5fu, 0x3534593du, 0x34f1e116u, 0x3527ef35u, 0x34a5ea88u, + 0x351f9287u, 0xb35e1ae3u, 0x34aca132u, 0x34acebebu, 0x33da9df9u, 0x352c4d06u, 0x34d393a4u, 0x3436e8d9u, + 0x3534870eu, 0x35246dcau, 0x3420591au, 0x3486c9d7u, 0xb485a038u, 0x345ce20bu, 0x33867bd8u, 0x33b88134u, + 0x342f4e01u, 0xb45ee2d2u, 0xb316076du, 0x353a553eu, 0xb5031613u, 0xb33de58eu, 0x34c3407au, 0x34cd3ea8u, + 0x34924de0u, 0x343bca43u, 0x350bf0efu, 0x34d02b74u, 0x345a4e35u, 0x3548cf5cu, 0x3446f4c5u, 0x391a2f18u, + 0x39a120b1u, 0x3a09a249u, 0x373d90f2u, 0x315a8273u, 0xb2ce8486u, 0xb2a3c9f1u, 0x3208e609u, 0xb2b0793du, + 0x31fac999u, 0xb350553cu, 0x3376470eu, 0x32bd09d1u, 0x33ae482cu, 0xb3e91a0cu, 0x32c89ac7u, 0xb3174172u, + 0xb36c305au, 0xb3aa025bu, 0xb3925e32u, 0xb4877902u, 0xb40695dcu, 0x3401dfd6u, 0xb3162612u, 0x3265808fu, + 0xafd5b927u, 0xb35cd67fu, 0xb0634151u, 0xb45294a8u, 0xb493f339u, 0xb3de6d6du, 0xb3e1b527u, 0xb4b0bec8u, + 0x331e0830u, 0xb2b7a7ffu, 0xb31c5084u, 0xb41977beu, 0xb4546389u, 0xb4923d42u, 0x318ac38fu, 0xb4150262u, + 0x33ef9db4u, 0xb45693efu, 0x34bb7944u, 0xb3e9ea27u, 0xb3261457u, 0xb40d0bc4u, 0x33ab9de3u, 0xb59765cdu, + 0xb359d366u, 0xb4954494u, 0x3427f169u, 0xb522890cu, 0xb43f6774u, 0xb500d45eu, 0x34bd3f6eu, 0xb53aa9d4u, + 0x34dd5954u, 0xb4f43c54u, 0x34ca620au, 0xb5140fdcu, 0x33980181u, 0xb5489ce9u, 0x3445fbb5u, 0xb579c923u, + 0xb9747b5au, 0xb9937ddeu, 0xba03eb89u, 0x398385afu, 0x324320fbu, 0x31ce8d8du, 0x33253d48u, 0x3324a49cu, + 0x32daaeffu, 0x335e5452u, 0x33d5e34fu, 0x337151c8u, 0x33e342a7u, 0x33f76aecu, 0x330a552au, 0x32cfb95du, + 0xb329167fu, 0x33b49cfeu, 0x33a8d2c8u, 0x348e7ca4u, 0x338504cbu, 0x3328cb95u, 0x3460537bu, 0x3235b878u, + 0xb2947f72u, 0x343f79bcu, 0x331912ecu, 0x345ea123u, 0x345a2b7du, 0x343a2484u, 0x33d96922u, 0x3467b8f3u, + 0x3418ac8cu, 0x3293431au, 0x3421a470u, 0x34c704b8u, 0x34b3c66cu, 0x341804bbu, 0x346e30bbu, 0x341cef98u, + 0x346e87f7u, 0x349ccb88u, 0x34ebe086u, 0x351ae034u, 0x34568bf3u, 0x327a3d2fu, 0x34821665u, 0x34946811u, + 0x35027a70u, 0x3492d405u, 0x33eb8da3u, 0x34f00ce8u, 0x3537b6c0u, 0x34dd5fd7u, 0x34c09651u, 0x34edfb5cu, + 0x34f6af91u, 0x34db9909u, 0x34a6cc54u, 0x34a3879cu, 0x348ce141u, 0x35063f7du, 0x341fa45eu, 0x3515569cu, + 0x350d7116u, 0xb9005a11u, 0x3987e5b2u, 0x39fc69ecu, 0xb7305a40u, 0xb22da052u, 0xb1ede638u, 0xb3151982u, + 0xb2815791u, 0x32fb072au, 0x2e8aa329u, 0xb337409fu, 0x32ef4fbeu, 0xb41aeb2eu, 0x33d8dc09u, 0xb3b74059u, + 0xb330a2d2u, 0xb30d6c33u, 0xb309f8d4u, 0x331c7bd2u, 0xb3b74e46u, 0x34132aa4u, 0xb35221a7u, 0xb425bd1bu, + 0xb3af4717u, 0xb326911fu, 0x3312c40au, 0x32c5018du, 0x3304213bu, 0x31243e95u, 0xb32d4e97u, 0xb3da4ac7u, + 0x3256021du, 0x334d30ecu, 0xb427365du, 0xb4179ca7u, 0x33509c55u, 0xb4945101u, 0xb40b0c89u, 0xb4b61745u, + 0xb42b0c2eu, 0x332c4eafu, 0xb47296f9u, 0xb485dd28u, 0xb2c12eb3u, 0x340eccd9u, 0xb259b4a0u, 0xb360ec45u, + 0xb3d3d01au, 0xb3b8301fu, 0xb4de37c5u, 0x34c5d551u, 0xb499c88au, 0x3465bcb7u, 0x34e62bc2u, 0xb483075eu, + 0xb4be1a92u, 0xb5096389u, 0xb49d11acu, 0xb50ffff4u, 0xb4319074u, 0xb380512du, 0xb4890041u, 0x341589d3u, + 0xb452971bu, 0x34adc286u, 0x3966179eu, 0xb978d477u, 0xb9f27ea6u, 0xb9727862u, 0xb10e634au, 0xb2c8f780u, + 0x30b0edfau, 0x31d3e358u, 0x33266a81u, 0x324fd2eeu, 0x3343777au, 0x33a16a52u, 0x32fb33bbu, 0x33180a2du, + 0xb3673e01u, 0x343462f9u, 0x32ab2508u, 0x32fe0fc9u, 0x319d7bd0u, 0xb34700fdu, 0xb4c5c500u, 0x339bfc26u, + 0xb4976eb8u, 0xb49fcd59u, 0xb4ab658fu, 0xb4afbac9u, 0xb4aa124fu, 0xb38f0a60u, 0xb371c48du, 0xb49fdc24u, + 0xb49c4980u, 0xb4af4fa1u, 0xb4221872u, 0xb485de94u, 0x342037c3u, 0x33a06f45u, 0xb478182cu, 0xb4059814u, + 0x32fd1526u, 0xb40ba53cu, 0x335020a2u, 0xb49aa226u, 0xb501979du, 0xb2557414u, 0x3489e9e3u, 0xb47f0db4u, + 0x3483602du, 0xb3033b3bu, 0x34b214acu, 0x320d81f2u, 0x32df3779u, 0x34df8816u, 0x344b8fd6u, 0xb45b8a6au, + 0x34986404u, 0xb283f5dau, 0x34088e58u, 0xb12c8436u, 0xb19e13dau, 0x3406c0dfu, 0xb4ace89cu, 0xb49c49f3u, + 0x33e34653u, 0xb386cfc5u, 0xb41b1710u, 0x38d80c0du, 0x3964e1e9u, 0x39e89045u, 0x370f53c0u, 0xb236fba4u, + 0xb2996ccbu, 0xb30bf26bu, 0xb2c76386u, 0xb275b62fu, 0xb396154bu, 0xb18ca24fu, 0x31f99c8fu, 0xb3761587u, + 0xb3e2827eu, 0x3260a2cdu, 0x338c2dd1u, 0x3398d1abu, 0xb2368818u, 0xb324ab97u, 0xb30bc479u, 0xb3d95f47u, + 0xb1aa9d17u, 0xb355f108u, 0xb4701a42u, 0xb436f305u, 0xb37a7c65u, 0xb415659du, 0xb3a722a3u, 0x32a9f175u, + 0xb3a36422u, 0xb4d63a27u, 0xb436569fu, 0xb44bf9ebu, 0xb40b56cdu, 0xb40572b1u, 0xb38dd295u, 0xb4af9056u, + 0xb4dfc137u, 0xb46376a1u, 0xb2b5fa45u, 0xb512cde1u, 0xb4227a14u, 0xb4822453u, 0xb46e52b5u, 0xb5013b01u, + 0xb409beb5u, 0xb454b56du, 0xb4a2c6cbu, 0x3427d47bu, 0xb4b7d9f1u, 0xb4cb5f0du, 0xb4b28293u, 0xb4d21169u, + 0xb4a9e709u, 0xb42d67e4u, 0xb46ef21bu, 0x334e1c34u, 0xb4557127u, 0xb3cf375au, 0xb4ae7b82u, 0xb3a5354bu, + 0xb440cb4bu, 0xb413565cu, 0xb50b1255u, 0xb3c9a20fu, 0xb959b3ebu, 0xb951bdf5u, 0xb9dfe393u, 0x395f53d3u, + 0x3316e6ceu, 0xb0caeb65u, 0x32c4c416u, 0x32970e65u, 0xb2beba13u, 0xb280585fu, 0x3384130du, 0x33ca142bu, + 0x310c1096u, 0x32444f8au, 0x3383fb29u, 0x33bdc4ffu, 0x3354781cu, 0x33e3f902u, 0x33657a8au, 0x33d1baa5u, + 0x34050a9cu, 0x33e42f84u, 0x3184a52eu, 0x346e69cdu, 0x33ebf247u, 0x33e5c2f1u, 0x34cedf64u, 0x345f5406u, + 0x3417970fu, 0x34595dafu, 0x33fc4c67u, 0x349beb59u, 0x34864cd6u, 0x341465d5u, 0x345f44b4u, 0x3463535fu, + 0x33088fd4u, 0x34f6f167u, 0x344d7597u, 0x344f5121u, 0x34a9cc37u, 0x348d7cdau, 0x34f3538fu, 0x35139869u, + 0x353e3c94u, 0x34b2a549u, 0x34f38959u, 0x34c0d5b6u, 0x350d6c79u, 0x3529a567u, 0x34fc1e46u, 0x35066798u, + 0x3423138cu, 0x34bcc2fdu, 0x34b0b8d3u, 0x352a3312u, 0x346a5eb4u, 0x34e4982eu, 0xb39eaddbu, 0x3538015au, + 0x34d5098fu, 0x34d886fau, 0x34db72ecu, 0x34db4bceu, 0x34ef6a9fu, 0xb8b22cacu, 0x39401402u, 0x39d735f4u, + 0xb7069997u, 0xb1d407d1u, 0xb322f4bdu, 0xb23119c7u, 0x3392147fu, 0xb2dfcdfau, 0xb3c59f62u, 0xb3b7944fu, + 0xb3df3cd7u, 0xb39b80b5u, 0xb3c3288du, 0xb18ceb0bu, 0xb494211cu, 0x323fc5b6u, 0xb2f2fac9u, 0xb4802b6eu, + 0x33eee6f0u, 0xb46c5d68u, 0x34bb4896u, 0xb46ba8a0u, 0xb4bed110u, 0xb41f7950u, 0x31827a3fu, 0x32b7cf59u, + 0xb41f7720u, 0xb42d165du, 0x33f8d24cu, 0x349f3885u, 0xb49e22c1u, 0xb4b579e7u, 0xb4a00e96u, 0x345983d7u, + 0xb5581240u, 0x3325eeccu, 0xb5624feau, 0xb49cb416u, 0x34298941u, 0xb446d9e6u, 0x3527fb10u, 0xb4074568u, + 0xb4f833ccu, 0xb4c5fa37u, 0xb4c1cf2au, 0x34daffb2u, 0xb42bcb53u, 0x34423191u, 0xb41060fau, 0xb4cccd06u, + 0x34a00851u, 0xb46e7e69u, 0xb5ae3ec8u, 0x341e2b7au, 0x32ec5bc3u, 0x34bcaa15u, 0x32834e34u, 0xb5030e1fu, + 0xb5be52d0u, 0xb50924c4u, 0xb4f85be0u, 0xb3f83b8cu, 0x341ae054u, 0xb491db93u, 0x394d4897u, 0xb92dab24u, + 0xb9cf973eu, 0xb94f9f2bu, 0x301fd1aeu, 0x33463998u, 0xb2c699deu, 0x3229eb24u, 0xb2c02259u, 0x33bc7acau, + 0x33c7f90fu, 0x3480e028u, 0xb44cba90u, 0x34350550u, 0x3450c487u, 0x31813044u, 0x342b46f7u, 0x33ec840fu, + 0xb4cb7e0du, 0xb4d0c3f6u, 0xb329e30du, 0xb3a61a14u, 0x346c83b7u, 0x34c2fcdcu, 0x34920116u, 0x353c00e6u, + 0x34920116u, 0x335dfb1du, 0x3405f9b7u, 0xb377d59du, 0xb377d59du, 0x34920113u, 0xb4953c64u, 0x33100899u, + 0x342188b1u, 0x34ec838au, 0x3550c43au, 0x3550c43au, 0x3550c43au, 0x34b50513u, 0x3595a35cu, 0x2bc3bfdau, + 0x34b50521u, 0x34ec838au, 0x33ddfa4eu, 0x34b50513u, 0x34b50513u, 0x33ddfa66u, 0x34ec838au, 0x3580e001u, + 0x34ec838au, 0x3443ef68u, 0xb4b504c6u, 0x34ec838au, 0x35350509u, 0x34997624u, 0x351fc878u, 0x35273d8au, + 0x2bbec8acu, 0x350b7e4fu, 0x3550c43au, 0x3580e001u, 0x3443ef68u, 0x341cf628u, 0xb529869eu, 0x38939ba0u, + 0x391f23f7u, 0x39c7f7e7u, 0x36dd307au, 0x32aec9edu, 0xb28a667eu, 0xb3941ba4u, 0xb20301cbu, 0x333a2618u, + 0x336d09dau, 0xb41379fau, 0xb3b7fc3du, 0xb32160c6u, 0x33e79c83u, 0x33cc6e4bu, 0xb4ab74f8u, 0xb46cbb63u, + 0xb41255eeu, 0xb41f8573u, 0xb32fbe9cu, 0xb4a67539u, 0xb4926c62u, 0x339a2de5u, 0xb006b599u, 0xb43773c3u, + 0x32ea087eu, 0xb27bb73cu, 0x327783a5u, 0xb44980e8u, 0x34be189bu, 0xb1fe3849u, 0x31e6f7fbu, 0xb5134a3du, + 0xb4bb3b00u, 0x34b67f16u, 0x339e81dau, 0x3432dc0cu, 0xb5ac5748u, 0xb51cb469u, 0x349c2832u, 0xb4a7fb1du, + 0xb5915eaau, 0xb41eeed0u, 0xb507d2b6u, 0xb5346ef8u, 0x320d9607u, 0x34dd0433u, 0xb4d0e0c5u, 0xb4057308u, + 0xb4e31adau, 0xb52e7430u, 0xb49c21dau, 0xb4748a74u, 0xb5256aaeu, 0xb42ca8f0u, 0x35706706u, 0xb555fab2u, + 0xb3599c91u, 0xb4fbb4ccu, 0xb57b2cd6u, 0xb549ea9cu, 0xb4a3662bu, 0x3548c8f9u, 0x33e5cf5eu, 0xb4867750u, + 0xb94107d7u, 0xb91159f9u, 0xb9c13c69u, 0x3940d207u, 0xb2343f00u, 0x31deaabbu, 0xb2e8fe2eu, 0xb26cac2cu, + 0xb2d3c0b9u, 0xb1d6dcedu, 0x32052867u, 0x3356fb72u, 0x323bfd29u, 0xb3af527eu, 0xb1faad8du, 0x338b4f2du, + 0xb3c316edu, 0xb35cd201u, 0x330e1d25u, 0xb45f559bu, 0xb470778cu, 0xb41e4f10u, 0xb34ddcfbu, 0xb3732c67u, + 0xb431de52u, 0x3306b2e1u, 0xb3d43e0bu, 0xb48610c2u, 0xb384e4ecu, 0xb4243adeu, 0xb26b881cu, 0xb29f9d12u, + 0x325c7b7au, 0x3296bb69u, 0x3431ce59u, 0xb48425a1u, 0x33dcf0d4u, 0x32b072f5u, 0xb443233au, 0xb45b6b19u, + 0xb3c2ba80u, 0xb2ac20ceu, 0xb4183569u, 0xb4409d8fu, 0xb4bce864u, 0x34626f17u, 0xb447237du, 0xb4d1555au, + 0x33b42260u, 0xb3a60e16u, 0xb4013134u, 0xb3c804d8u, 0xb4a19403u, 0xb2f1629du, 0x34351175u, 0xb41274a0u, + 0xb21747adu, 0x338400aeu, 0x33d66b54u, 0xb3f452e3u, 0xb4a260d9u, 0x3459d2d9u, 0xb3efa34cu, 0x33fd0b59u, + 0xb484b1d2u, 0xb870c379u, 0x3902429cu, 0x39ba80a7u, 0xb6d0a494u, 0x3218c1e8u, 0xb220a90fu, 0xaf96336du, + 0x31d7e49du, 0xb27c2afeu, 0xb3a0a8fdu, 0xb3c27517u, 0xb24f3641u, 0xb25782f7u, 0x3383a576u, 0xb37bf3c2u, + 0xb3acd173u, 0xb350d885u, 0x32d6ae49u, 0xb2c34c0eu, 0xb2d26c5fu, 0x32810e1eu, 0x3330ba72u, 0xb297a13bu, + 0xb2ddd338u, 0xb29cac52u, 0x33026f20u, 0x340aec4eu, 0x34aaea08u, 0x32e0ed50u, 0x339485d7u, 0x343df1b2u, + 0xb46075b6u, 0xb40fbc31u, 0xb3a6bedeu, 0x34208f33u, 0xb327e029u, 0xb494fb96u, 0xb33325feu, 0x34b28859u, + 0x34d136cau, 0x34539535u, 0xb28d2f28u, 0x3464ac34u, 0xb4085aa9u, 0xb011d9f1u, 0xb4af9dbeu, 0x3488d671u, + 0x33efdb22u, 0x335bcc34u, 0x34d8bc53u, 0x3404dd05u, 0xb51bc951u, 0xb39c5aa9u, 0x349c8ef7u, 0xb4f8daf2u, + 0xb351d31bu, 0xb33023ccu, 0xb3edd2a3u, 0x34059352u, 0x33229d1au, 0xb510dbfau, 0xb4348cb0u, 0xb516de99u, + 0xb3ba8f8cu, 0xb3718382u, 0x393783e2u, 0xb8e9ee60u, 0xb9b486cbu, 0xb9348777u, 0xb2a9f6f4u, 0xb1824391u, + 0xb1825563u, 0xb2288e3cu, 0xb2cb1204u, 0xb32fdeaeu, 0x3271e0dfu, 0xb2f4c156u, 0x338ea4afu, 0xb373c7f3u, + 0xb37218f9u, 0x31962c12u, 0x33756ed8u, 0x336c00c7u, 0x31e186a8u, 0x343b7e19u, 0x3308c245u, 0x33cf10a5u, + 0x33e4235au, 0x343190ebu, 0x34429f7cu, 0xb28b5297u, 0xb37887a3u, 0xb33a1a20u, 0xb39476b8u, 0x344acca9u, + 0x31b8e614u, 0x342b5d94u, 0xb4202070u, 0xaf8a2c77u, 0x3454cc5du, 0x3487d0a7u, 0x3471fc44u, 0x34877710u, + 0xb490b9ffu, 0xb4460de5u, 0xb4cc4c55u, 0x343a1544u, 0x33d22235u, 0xb39acdb2u, 0x340834aeu, 0x34df6506u, + 0x33ac41f9u, 0x349566ccu, 0x345f49f2u, 0xb4b1279eu, 0xb17d682cu, 0x342c81a3u, 0x344e17a1u, 0xb335cb65u, + 0x33fa79c2u, 0xb4b3dcf8u, 0xb39dd36du, 0x3445056fu, 0x32aed16au, 0x34842cf1u, 0xb2eb4d31u, 0xb48ab6f8u, + 0x34c5c71fu, 0xb4cae138u, 0x34eed973u, 0x383fb84fu, 0x38d25867u, 0x39ae8eb2u, 0x36af217eu, 0x326c3823u, + 0x321e9a7cu, 0x33066d41u, 0x3265a932u, 0x33267ac0u, 0xb1e0e99fu, 0x3291a5ebu, 0x33835394u, 0xb3166352u, + 0x327dcb93u, 0xb1b3dca8u, 0x339c927du, 0x3320bdb5u, 0x3372a80cu, 0x338dccfau, 0xb3fc3fc2u, 0x3431bebcu, + 0x329f7364u, 0x3269b855u, 0x341523ebu, 0x33d739a7u, 0xb3dee722u, 0xb398d581u, 0x3422166au, 0xb3b3fbb0u, + 0x33f34177u, 0x341f8e9du, 0x33d1aed9u, 0x3402d13du, 0xb35e2906u, 0x33e184a0u, 0x330cfdb9u, 0x342aa698u, + 0xb36e0426u, 0xb43305f1u, 0x34cb973cu, 0x34371606u, 0x33e010c6u, 0x349c376du, 0xb31c814au, 0xb48145c5u, + 0xb49f2945u, 0x325f815bu, 0x34177e40u, 0x318539d9u, 0x33f6e345u, 0x2f958da1u, 0x33b4d06cu, 0x33888208u, + 0x325a16a9u, 0x346954b3u, 0x34c92969u, 0x33dea439u, 0x34dec398u, 0xb3c479b9u, 0x32157a48u, 0x35072fe0u, + 0x348f75ddu, 0xb434b7dau, 0x344e5abau, 0x34c7b249u, 0xb92e0a5du, 0xb8b98d33u, 0xb9a93bc9u, 0x3928efb4u, + 0x316aa63bu, 0xb21cf133u, 0xb34200a8u, 0xb35ff666u, 0x32a01549u, 0x33523e5au, 0x30971410u, 0xb3091425u, + 0xb3da18c4u, 0xb3acf4c8u, 0xb3f18ff5u, 0x32af8dd1u, 0xb294a039u, 0xb3b6c008u, 0xb3ed219cu, 0x3253f1c0u, + 0xb38146f5u, 0xb416a698u, 0xaf5eb42eu, 0xb2691ccbu, 0xb3a1a7ccu, 0x337aa5aeu, 0xb37d4b10u, 0x33d5cfaau, + 0xb2d5e7f0u, 0xb35c4b61u, 0x341e73d8u, 0xb36f5a2bu, 0x337daf28u, 0xb42400afu, 0x33e25b51u, 0xb2e2d225u, + 0x34992aa9u, 0x348a9af4u, 0x33996464u, 0xb4a52bdeu, 0xb2b97080u, 0xb3a3a690u, 0x3181c943u, 0xb36562fbu, + 0xb44f3a65u, 0x33dc2fa1u, 0x340fdd57u, 0x33c2ab59u, 0x3481bdf9u, 0xb3bdf51du, 0x32b8bd55u, 0x340b2effu, + 0xb45141ccu, 0x347649ccu, 0xb3e2edf0u, 0x331cf4c5u, 0xb448994au, 0x335f2b53u, 0xb4cc1013u, 0xb48d0402u, + 0xb3b49fffu, 0x3365f90eu, 0xb4e444a6u, 0xb47495e5u, 0xb4bd82a7u, 0xb8154949u, 0x38a4877cu, 0x39a3e9deu, + 0xb6a5a057u, 0xb1fec1d9u, 0xb216af3eu, 0x3310a8edu, 0x330a5ed6u, 0x3346e27bu, 0x337618d2u, 0xb293ae39u, + 0x3365c8afu, 0x32354d69u, 0xb3c5e3bau, 0xb2bd403cu, 0x33127f9du, 0x34370bdbu, 0x3409b496u, 0x330a228fu, + 0xb244b74bu, 0x344678deu, 0x32f4607bu, 0x336a4229u, 0xb231c378u, 0x345a85c7u, 0xb2ee7197u, 0xb34cb4cau, + 0xb400357du, 0x347ccba7u, 0x34e50821u, 0xb43c2e10u, 0x338ae285u, 0xb2ebaf1eu, 0x33e55413u, 0x32f41ce1u, + 0xb4477f80u, 0xb286fcb3u, 0x34f90286u, 0x340557ceu, 0xb3d16efdu, 0x34bc29f1u, 0x337d7240u, 0xb3e5e6fbu, + 0x33f124bbu, 0xb3a4528bu, 0xb505f7b5u, 0x347adbdeu, 0xb217f05au, 0xb1cc348fu, 0x34a0a298u, 0xb3117baau, + 0xb44380a1u, 0x32754eeeu, 0x3499aa23u, 0x3556b7c0u, 0x3452f388u, 0x350212e4u, 0x35125637u, 0x3520f823u, + 0x34739d1bu, 0x3331a441u, 0xb45c8480u, 0x35985ffeu, 0xb4da35b5u, 0x350a16ceu, 0x39253c2bu, 0xb88f4a40u, + 0xb99f2744u, 0xb91f2b8bu, 0xb12e9c5fu, 0xae9121dfu, 0xb37121dfu, 0xb3f622b5u, 0xb401f3cfu, 0xb40f5f1eu, + 0xb3a95bcdu, 0x33d47077u, 0x3385542du, 0x3355609au, 0x33e89c47u, 0x32212a1bu, 0xb1d78535u, 0x3356189eu, + 0xb388b2b3u, 0xb46a7becu, 0xb481b9b8u, 0xb4c42e3fu, 0xb4fb5fa7u, 0xb342ada6u, 0xb528a646u, 0xb4927436u, + 0xb4e40ea9u, 0xb40f02bbu, 0xb46514fbu, 0xb51a3246u, 0xb4934c08u, 0xb504307cu, 0xb404b5f9u, 0xb48f1e7au, + 0xb54fe7f2u, 0xb3a611b2u, 0x34288e0du, 0xb4e3dbe2u, 0xb4b2ed2au, 0xb4ee3d2eu, 0x34935048u, 0x32032f24u, + 0x33eda742u, 0x31eefb2bu, 0x34a66f49u, 0xb5361425u, 0xb5036b23u, 0xb58a346au, 0xb4c18789u, 0xb422d0e4u, + 0xb5111d70u, 0xb55d9bbbu, 0xb4a4b6d0u, 0xb5318a78u, 0xb3afedadu, 0xb56a6ae6u, 0xb5018ab1u, 0xb53b61c0u, + 0xb56838dfu, 0xb559ca4fu, 0xb4650e80u, 0x3513379du, 0xb57d1370u, 0xb4f3fd51u, 0xb4d5a7a3u, 0x37d51ba8u, + 0x38770b6fu, 0x399a64c7u, 0x368b9c96u, 0xb1c44d1fu, 0xb2cb3490u, 0x3237a93eu, 0xaf6c87e6u, 0xb39350d7u, + 0xb333a24eu, 0xb3816721u, 0x33c9b9a5u, 0xb38b2e30u, 0x32c82f2fu, 0xb3cd8aa9u, 0xb3915922u, 0x338b3aa9u, + 0xb3b19ae9u, 0x33e50f38u, 0xb483a564u, 0xb32ada8du, 0xb36726cau, 0x3437bdaau, 0xb22feab0u, 0xb39f2764u, + 0x33b4396eu, 0xb42bddc2u, 0xb464c104u, 0x34336f31u, 0xb43201bfu, 0xb45f3207u, 0xb4bf1f37u, 0xb48cd3bau, + 0x3402dcd4u, 0xb423733fu, 0xb4c0f7adu, 0xb3682898u, 0xb47ed430u, 0xb403714eu, 0xb4ae82fcu, 0xb38b3ce1u, + 0xb4ee76a9u, 0xb29989d5u, 0xb4e41010u, 0xb46e3c4cu, 0xb3cd9ab4u, 0xb4f679c5u, 0xb4f1b318u, 0xb3b6eb28u, + 0xb498d02du, 0xb4887f39u, 0xb3c9309du, 0xb4b84d47u, 0xb443a714u, 0xb4a0b6a0u, 0xb52e550fu, 0x34a6a6c5u, + 0x344fbb25u, 0xb54cea39u, 0xb547238cu, 0x33b1d7eeu, 0xb492914du, 0xb4cae231u, 0xb4ee3c62u, 0xb4e2bd97u, + 0xb91cdec8u, 0xb8537c6du, 0xb9961ff5u, 0x3915e7c5u, 0xb1d42c06u, 0x30263f5au, 0x32375a7au, 0x31bf7978u, + 0xb15bef07u, 0x3140f2eau, 0xb24d384fu, 0x33074cabu, 0xb1990468u, 0xb2bede64u, 0x32f22dfeu, 0x32efedc5u, + 0x333dba45u, 0x32fb6daeu, 0x338d3276u, 0x34108e15u, 0x33c35950u, 0x3478f0bau, 0x3240dd6bu, 0x3444d34cu, + 0x330064dcu, 0xb30068e9u, 0x330b1b55u, 0x3418b7ecu, 0x342db3adu, 0xb2f5caabu, 0x33f5362fu, 0xb1b47e93u, + 0xb3e7db32u, 0x33f8e9ecu, 0x33602d7au, 0x342a4765u, 0xb400f485u, 0x322ede9cu, 0xb340af47u, 0xb3d30779u, + 0x33da62e8u, 0xb4402045u, 0xb428e0c4u, 0xb2c236a3u, 0x348c5c41u, 0x336ede29u, 0xb4414a1bu, 0x338eec5du, + 0x33632da0u, 0xb332b6f3u, 0xb2e696a0u, 0x32d0e280u, 0x34003db9u, 0x3490dcb7u, 0x33d009e8u, 0x3407e08fu, + 0xb424919du, 0x3450a500u, 0x341d56adu, 0x32ec6b32u, 0x347ecbdbu, 0x33fcd5c1u, 0x347f49b4u, 0xb432fa93u, + 0x33abd76au, 0xb7903609u, 0x3831227bu, 0x3991da92u, 0xb684f7a9u, 0x316b2d67u, 0x3185edc9u, 0x329676ccu, + 0xb325ad86u, 0xb32037a4u, 0xb2347b21u, 0xb36a8cb6u, 0xb319a6f6u, 0x30cd22d6u, 0xb3175ce5u, 0x33c38a59u, + 0x30b2442cu, 0xb18e10deu, 0xb28b6dd8u, 0xb381f9dfu, 0x3164da3au, 0x324b585du, 0x334ca045u, 0x336e4d52u, + 0x3379f391u, 0x328d0942u, 0x345f0a32u, 0xb315be30u, 0x3236ba1du, 0xb43cff16u, 0xb409c541u, 0x33024a5eu, + 0x33b860e5u, 0xb32dac45u, 0xb3c009edu, 0xb3b8ef9fu, 0x3497e1f0u, 0x33db4545u, 0x3426946au, 0x3307714cu, + 0x336bac31u, 0xb491eb17u, 0xb39f210fu, 0x342ca96eu, 0xb3d88d20u, 0x3342bb53u, 0x342e83cau, 0xb4de6de7u, + 0x3456c887u, 0x3318f4ecu, 0xb4533b64u, 0xb42bdde6u, 0x34b96dacu, 0xb38de7ceu, 0x347c1f8eu, 0xb504547du, + 0xb43e6a0fu, 0xb3a2a040u, 0x345599dcu, 0x334bcaa7u, 0xb3186c82u, 0xb356691du, 0x343794bcu, 0x33b08011u, + 0x330d6667u, 0xb3da68f7u, 0x3914aae9u, 0xb80e3066u, 0xb98e030eu, 0xb90e00d5u, 0x32ed38e0u, 0x3298c95du, + 0x323ded66u, 0xb32527a9u, 0xb2a76b0fu, 0xb31f249au, 0xb25f49c1u, 0x33437589u, 0x33fa8564u, 0xb0b8db42u, + 0x33424532u, 0x32891f93u, 0x2f072021u, 0x33386008u, 0xb455b314u, 0x32a3ce58u, 0x340b1c87u, 0x3480d054u, + 0xb403695au, 0x33855de5u, 0x322d1b94u, 0xb402f200u, 0xb31069b1u, 0x347db1d8u, 0x3404bae7u, 0x3421a027u, + 0x317d6df0u, 0x3436dddfu, 0xb381cca1u, 0xb3df8065u, 0x3501ff63u, 0x35125e22u, 0x328ec6dbu, 0xb304035du, + 0x341b1212u, 0x34e18c84u, 0x3046c432u, 0x3423dd9du, 0xb3bfb0f0u, 0x3383477fu, 0xb3b8a6dau, 0x3499e057u, + 0xb4646ceau, 0xb4936ab8u, 0xb506c8a7u, 0xb4eeb3ccu, 0xb4e8da59u, 0xb422979au, 0x33f1279eu, 0xb3e246c3u, + 0xb32e9cf7u, 0xb3aed5e9u, 0xb2891690u, 0xb47f60d8u, 0xb2a7b976u, 0xb2898d58u, 0xb51c12e9u, 0xb4f8030cu, + 0xb4f1292fu, 0xb51739d8u, 0xb461302du, 0x372d738fu, 0x37e3c518u, 0x398a2a4cu, 0x36634b72u, 0x31668574u, + 0xb1d2d249u, 0x3277033bu, 0x32780ad1u, 0x330a028bu, 0x33108c21u, 0xb31bb77eu, 0x3166a9afu, 0x33e8319cu, + 0x3303401eu, 0xb2ecbff9u, 0x324936b7u, 0xb227f12au, 0x32c87cf9u, 0xb3c24474u, 0x338a4e55u, 0x330b1777u, + 0x3376abdcu, 0xb41bf789u, 0x332870cdu, 0xb33e70f1u, 0x3442d47cu, 0x334dce00u, 0x33828adfu, 0x3293322cu, + 0x326fb912u, 0x343d022au, 0x3443f6cbu, 0x34d49a60u, 0x35060c75u, 0x33caf08au, 0x33e8085au, 0x341a9c84u, + 0xb432a715u, 0xb431d2bcu, 0xb3e431aeu, 0xb332ed21u, 0x340cd0acu, 0x32f23452u, 0xb21f087bu, 0x344d84edu, + 0x348a0ccau, 0x3399cbbeu, 0xb217d736u, 0xb39dc6d1u, 0xb329b694u, 0x33af229bu, 0xb3c07d79u, 0x34f7a681u, + 0xb2aa9295u, 0x34c28b10u, 0x3410cbeeu, 0xb3bdb19fu, 0x329ae8cdu, 0x3500f0dfu, 0x34ce0e52u, 0x3416f85du, + 0x33c62b17u, 0x3126fe11u, 0x349bfe46u, 0xb4829eb9u, 0xb90d4cccu, 0xb7a0d2c5u, 0xb986afe6u, 0x390680deu, + 0xb1c0fa9fu, 0xb2f24968u, 0x31964bc7u, 0xb3290495u, 0xb32ac77eu, 0x3303f95eu, 0xb35d6136u, 0xb32204f6u, + 0xb3843705u, 0xb385c435u, 0xb3021749u, 0xb227007eu, 0xb366bab7u, 0xb3f265fau, 0xb364750au, 0xb3c85e62u, + 0x337d6a68u, 0x33685633u, 0xb1d52e8cu, 0xb4394839u, 0xb40e4fe5u, 0xb1b2cbceu, 0xb1080be8u, 0xb3db2419u, + 0x33f1a9cbu, 0xb295e74du, 0xb4419cceu, 0xb4925a3eu, 0xb4020252u, 0xb458b37cu, 0xb34f9124u, 0x3392c7e2u, + 0xb2dff87cu, 0xb3da2fdeu, 0xb410439au, 0x33c3b3ffu, 0x3304a12au, 0xb2da5757u, 0xb1842379u, 0xb4f979f6u, + 0xb429e63eu, 0x33831c54u, 0xb40c40c0u, 0xb4c1395fu, 0xb453427bu, 0xb464aee1u, 0xb4af14beu, 0x33dccde3u, + 0x33801480u, 0xb4e45d3fu, 0xb537da7eu, 0xb50bf2c3u, 0xb4c8fc3eu, 0xb508cc2cu, 0xb2647009u, 0xb467e624u, + 0xb522cbc1u, 0xb49c178bu, 0xb457b388u, 0xb50842b0u, 0xb0eaf64au, 0xb65a3598u, 0x375347a4u, 0x398337c6u, + 0xb6573722u, 0x336d85d4u, 0x336f7231u, 0xb168aee6u, 0x33f9a3b7u, 0x33f96aa3u, 0xb154fa33u, 0x33f5ffdau, + 0x340055f7u, 0x33fb1d09u, 0x34025900u, 0xb40294cbu, 0xb4c134aau, 0xb40395adu, 0x33f95e6eu, 0x33fa3f52u, + 0x33fa5b0au, 0xb4c0d26cu, 0x34026bf3u, 0x33f791deu, 0x3402469bu, 0x34098570u, 0xb4c328e0u, 0x33e8aadeu, + 0x351e20f6u, 0xb4b9cb9cu, 0x33fb68a0u, 0xb4bd80a4u, 0x33ff94fbu, 0x33f02ff7u, 0x33fd99b1u, 0x3404b253u, + 0x3590b167u, 0xb56285efu, 0x3407bf01u, 0x33e6849bu, 0x340e4a87u, 0x33f3c323u, 0x35905e36u, 0x340ef688u, + 0x3409a4b1u, 0x35904dbbu, 0x3608ee08u, 0x3403d8b7u, 0x33d265f8u, 0x33e0d34du, 0x340fa222u, 0x34080d3du, + 0xb55dab54u, 0xb55fad50u, 0x33eb612fu, 0x33ff728bu, 0xb55f25afu, 0x340195edu, 0xb56183d5u, 0x33fca3d8u, + 0x33f047f4u, 0xb563cd6eu, 0xb5607e86u, 0x340f4362u, 0xb5f1e315u, 0xb55d6d5eu, 0x39072a67u, 0xb6d7006fu, + 0xb9801400u, 0xb9001000u, 0x00000000u, 0x00000000u, 0x34800000u, 0x00000000u, 0x00000000u, 0x00000000u, + 0x34800000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, + 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, + 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, + 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, + 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, + 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, + 0x36000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0xb6000000u, + 0x00000000u, 0x3979de6du, 0x363a7111u, 0x32fe2356u, 0x332871cfu, 0x34065ffcu, 0x33d059c9u, 0xb402b612u, + 0x31eb111bu, 0xb307ebb4u, 0x34915a44u, 0x3414febcu, 0xb3b59681u, 0xb23d8156u, 0x33c6fea7u, 0x34df1c36u, + 0xb3724ddbu, 0x34993f8bu, 0xb3bc17cau, 0x34ada22cu, 0x33f776dau, 0xb4e191d8u, 0x3425e1a5u, 0xb49e6ab8u, + 0x3385097fu, 0xb3913f5eu, 0x34ebdcf4u, 0xb4f5db83u, 0xb4387ad0u, 0xb55bd446u, 0xb38b29fbu, 0x356eae49u, + 0x34ec4613u, 0xb4a3779au, 0xb508d016u, 0xb4ac8fb2u, 0xb52c1e08u, 0xb58eee76u, 0xb4ebfef3u, 0xb4cf8688u, + 0xb2cc5b92u, 0x35253b6eu, 0x343ec74fu, 0x34c03e04u, 0xb594b191u, 0x33bc2e11u, 0x34e49862u, 0xb46d91e7u, + 0xb59d0850u, 0x3505fb89u, 0x3587660eu, 0xb3d8a702u, 0x34e1e734u, 0x35964c2bu, 0x34ff92bdu, 0xb42e932du, + 0x34298614u, 0xb5814ce2u, 0xb35e2666u, 0x360c86bbu, 0xb4486fa8u, 0x3507bb9fu, 0xb5a8caa7u, 0x35cd9edfu, + 0xb900ea41u, 0x36bf5a6fu, 0xb9742a5bu, 0x38f3e4c8u, 0x32598557u, 0x324e14d7u, 0x32ec6f13u, 0xb3090e6bu, + 0xb2a155feu, 0x31aa2111u, 0xb1610caau, 0x3329a6f2u, 0x33ed6746u, 0x32e7efd3u, 0x33aecd65u, 0xb12075f5u, + 0x32d56d70u, 0x33a29db9u, 0x33855b85u, 0x34437d50u, 0x348f7603u, 0xb309f033u, 0xb3a75c1au, 0x340f397au, + 0x340ae804u, 0x2fc8fc16u, 0xb2d3cf82u, 0x349d5de7u, 0xb2599859u, 0x33830494u, 0x3403944fu, 0x33334bf1u, + 0x34474a12u, 0x33bddaf8u, 0x32cef346u, 0xb4c36359u, 0xb28c2279u, 0x3376e3c3u, 0x33b33a36u, 0xb3023e7fu, + 0x3480020bu, 0xb2855fafu, 0xb40eb8e3u, 0x343a562bu, 0xb41025e1u, 0x308ddac3u, 0x33501d80u, 0x33ea38fdu, + 0xb4cd3c02u, 0xb50137a0u, 0x335f8e6au, 0x33e90addu, 0x345d5580u, 0x32548be5u, 0xb415f8c6u, 0xb4b5408au, + 0xb3eddc0au, 0xb44ffe92u, 0xb4057381u, 0xb2e11bd9u, 0xb4584b9eu, 0x33a9e8e8u, 0xb21f1b78u, 0xb2d5c508u, + 0xb4398693u, 0x370cbe36u, 0xb73bde44u, 0x396e75d0u, 0xb63285f2u, 0x30b2c481u, 0x32c0db89u, 0x3330f32fu, + 0x3067529eu, 0x326911e7u, 0xb332c49du, 0xb23f83f7u, 0xb20dac69u, 0x322d0b12u, 0xb3d65bd1u, 0xb36e8efeu, + 0x32b68ba4u, 0x339ba6a3u, 0xb3a7c282u, 0x32d54fd5u, 0xb3ea7715u, 0x3337cb09u, 0xb2acc4c7u, 0xb3e0deeeu, + 0xb41f0a54u, 0xb34b3d0du, 0xb38b44b7u, 0xb2ce42fau, 0x32d5c982u, 0xb38beacdu, 0xb3ac06e4u, 0x33df24acu, + 0x33bc4daau, 0xb3a91a01u, 0x32ca0463u, 0x3416197bu, 0x34672cf2u, 0x3319426eu, 0xb3b2a2dcu, 0xb49a0052u, + 0x3461b457u, 0xb513e6e6u, 0xb42a36ebu, 0x339f732au, 0x34724ee8u, 0xb46e8c2du, 0x335df135u, 0xb3a6f9bdu, + 0x337b334fu, 0x34270947u, 0x344986e5u, 0x34a26f09u, 0x347a307du, 0x33acc1fdu, 0x3490103au, 0x34fc5230u, + 0x34b2c6b9u, 0x34fdd568u, 0xb486a48du, 0xb4bf308du, 0x348396aeu, 0xb3cd4efbu, 0x34ce944au, 0xb26d3784u, + 0xb387ca17u, 0xb3898220u, 0x38f45c51u, 0x378d6625u, 0xb9694525u, 0xb8e949edu, 0xb0cf8dd4u, 0x32473b4au, + 0xb29487afu, 0x336d06acu, 0xb1e88429u, 0x33128716u, 0x32a301dbu, 0x33497c7bu, 0x3416a38du, 0xb33123a1u, + 0x335cc34fu, 0xb11cf4a9u, 0x33fc5f70u, 0xb2e36b0cu, 0xb2f9bc54u, 0xb4393eaau, 0xb239f086u, 0xb4155268u, + 0xb3344b19u, 0x3415d41du, 0x34ae340bu, 0x33ad0d44u, 0xb410f634u, 0x34433ae8u, 0xb441ef66u, 0xb0a60b6du, + 0x33d9d091u, 0x31c3a653u, 0x346a89c1u, 0xb3d7579bu, 0xb42c13dbu, 0xb4308669u, 0x3345b394u, 0xb40ff5c8u, + 0x34a359b7u, 0x351d15beu, 0x34e3b9feu, 0x34e3e9a9u, 0xb3bb1ff0u, 0x3450b34fu, 0x34a6b91fu, 0x34cf8741u, + 0x35947981u, 0x350baf88u, 0x3492d026u, 0x3432a29au, 0x34908d91u, 0x351cbbc3u, 0x34365de2u, 0xb3f0e3e0u, + 0x340ae82du, 0x354db96du, 0x3464d8b3u, 0x358601d7u, 0x34e8b8f8u, 0x35003097u, 0x35516105u, 0x351ab4deu, + 0x34e0bea7u, 0x3533bda9u, 0x35758914u, 0xb76d23c0u, 0xb7b55458u, 0x39641239u, 0x3619efe4u, 0xb2a6ba1du, + 0xb2ced48bu, 0xb2f34d31u, 0xb34786c8u, 0xb33cb0dau, 0xb356ad28u, 0x32ec53ecu, 0x332c8a34u, 0x3360f8b3u, + 0xb2a10bc7u, 0xb385fbb6u, 0xb2f60bb9u, 0x32e71740u, 0x3316972cu, 0xb4591af1u, 0xb438d4a6u, 0x3306a270u, + 0xb327c639u, 0xb45df324u, 0xb41aadf3u, 0xb3df9cb0u, 0xb28c2910u, 0xb4aa6ecau, 0xb435c521u, 0xb3035fb2u, + 0xb3c4cc9fu, 0xb425cafau, 0xb4998901u, 0xb2e80f88u, 0xb44f3f29u, 0xb4a63b38u, 0xb4779aedu, 0xb526f0e9u, + 0x32d8eaafu, 0xb4c68ca1u, 0xb4c4a0b7u, 0xb271dcebu, 0xb423b728u, 0xb4dd1737u, 0xb46c2ab1u, 0xb4c1e9a2u, + 0xb4c8b3edu, 0xb3166b90u, 0xb42d0039u, 0xb3d3e7f6u, 0x32829921u, 0xb3e1feeeu, 0xb51dcfd6u, 0xb4281317u, + 0xb45065eeu, 0x32e4daa6u, 0xb455e12eu, 0xb2984d0au, 0xb4a2041fu, 0xb539719au, 0xb26f8ae9u, 0xb4165368u, + 0xb4bb002du, 0xb4042fd8u, 0xb4cc8b43u, 0xb4697734u, 0xb8e7badfu, 0x37dbaea3u, 0xb95f5eadu, 0x38df176bu, + 0xb2d60c7cu, 0xb2e97780u, 0xb39d4793u, 0xb39168f8u, 0xb3b217cau, 0xb3be26b8u, 0xb3e8e91au, 0xb3d485f4u, + 0xb4165b6bu, 0xb2ab099bu, 0xb3dfea3fu, 0xb42f3c39u, 0xb420bc22u, 0xb40cd7a6u, 0xb494ba8du, 0xb480b682u, + 0xb4aeb9d4u, 0xb4837de7u, 0xb417a1b8u, 0xb2a0afe1u, 0xb420d148u, 0xb3f1dfa6u, 0xb4e26289u, 0xb4a92bb5u, + 0xb4b3c047u, 0xb500e35bu, 0xb4bcbafcu, 0xb481885eu, 0xb474677au, 0xb4c6a51bu, 0xb4ba7640u, 0xb4eb4432u, + 0xb5158487u, 0xb4e19d65u, 0xb522b714u, 0xb501b868u, 0xb56487deu, 0xb54bfc47u, 0xb446dfcdu, 0xb4cd77c6u, + 0xb371a88eu, 0xb541998bu, 0xb508e881u, 0xb4f2ce98u, 0xb5666951u, 0xb511d828u, 0xb4e6a7c5u, 0xb5863baeu, + 0xb52aaa96u, 0xb55f6f2cu, 0xb54409c4u, 0xb50aa6e4u, 0xb567ed80u, 0xb584b5ecu, 0xb4c1bc46u, 0xb4cabb05u, + 0xb4ecbbaeu, 0xb55cfb99u, 0xb56daf2du, 0xb4c77bf6u, 0xb58ba1bbu, 0x37937ea1u, 0xb805cdd3u, 0x395aa09eu, + 0xb612134bu, 0x32ce9b3eu, 0x3314f982u, 0x33b50494u, 0x33868ea3u, 0x33881239u, 0x337abed0u, 0x34580effu, + 0x33ce7177u, 0x32ad09bdu, 0x33b24a36u, 0x33f4e5c5u, 0x345e911eu, 0xb2ecef1fu, 0x33df48c8u, 0x33f764c5u, + 0x331f5715u, 0x34a84131u, 0x340eb125u, 0x3440d3b4u, 0x34c4f01eu, 0x34ac391fu, 0x34b0951bu, 0x349fbe39u, + 0x34b81832u, 0x34debb50u, 0x34773d00u, 0x34d8082du, 0x3484c9a7u, 0x34b6fa92u, 0x351270f3u, 0x35015118u, + 0x347419c8u, 0x34a5715au, 0x3519df80u, 0x34c00f0cu, 0x34b3345au, 0x3581347au, 0x357c92fdu, 0x349a4b2eu, + 0x34ac2a50u, 0xb3e3ebc2u, 0x34e22c85u, 0x348c7cc4u, 0x34fe1bc1u, 0x34d878aau, 0x34d9094fu, 0x3510951cu, + 0x33c51386u, 0x33de78a4u, 0x34f64a22u, 0x3530d8c7u, 0x34f3b2ccu, 0x3478fab9u, 0x359561f7u, 0x349405adu, + 0x350e45bcu, 0x350bd9eeu, 0x356ed176u, 0x3425710du, 0x35610351u, 0x358b1caau, 0x38ddeee2u, 0x3817264cu, + 0xb95651e8u, 0xb8d65ae5u, 0xb2d70f91u, 0xb2d67034u, 0xb2e37b3bu, 0xb38bd7cdu, 0xb330d3f0u, 0xb18b35c5u, + 0xb39ead77u, 0x33a27fcbu, 0xb407deaau, 0x33558598u, 0x33a1f638u, 0xb41d2343u, 0xb3a7802fu, 0xb2bc182eu, + 0xb43d7537u, 0xb4618d02u, 0xb4489901u, 0xb4d2d208u, 0xb4c2e142u, 0xb4cdb083u, 0xb45e7577u, 0xb4fad57au, + 0xb17e6c94u, 0xb469f96au, 0xb4a47d73u, 0xb3f48aeeu, 0xb4de37b9u, 0xb48ea541u, 0xb4744617u, 0x31e4f69fu, + 0xb5101881u, 0xb3a62bb1u, 0xb52064c3u, 0xb4bdad66u, 0xb48cb1b0u, 0xb4b355fbu, 0xb5242d19u, 0xb552d1b0u, + 0xb59a58d8u, 0xb3b8ddd6u, 0xb52dbc49u, 0xb4975639u, 0xb51c6186u, 0xb516de0fu, 0xb4f37dbfu, 0x3422e10bu, + 0xb47e9f23u, 0xb4e65dd0u, 0xb4fcdf17u, 0xb54a827du, 0xb5138c09u, 0xb56a6887u, 0xb5337df5u, 0xb5a8f183u, + 0xb4fb95f4u, 0xb5027987u, 0xb4a47598u, 0xb4db7522u, 0xb58817acu, 0xb5778739u, 0xb516ebc2u, 0xb7c29110u, + 0xb82621c7u, 0x3951fa90u, 0x36005d81u, 0xb2c6089cu, 0xb2a18986u, 0xb350c8e5u, 0xb3c31fb7u, 0xb2ee4c9cu, + 0xb4212129u, 0xb357c3a2u, 0xb34c2e5au, 0xb40b9bccu, 0xb32ad638u, 0xb2aedea9u, 0x33250ae4u, 0xb2fe5eb2u, + 0xb49c3b23u, 0x338bc1e6u, 0xb4113de9u, 0xb33efdb0u, 0xb45519e1u, 0xb4d05dcbu, 0xb4566a6au, 0xb432d2bbu, + 0xb4ca6adfu, 0xb15b70f4u, 0xb438cd62u, 0x331f41dfu, 0x33c75100u, 0xb49c6c01u, 0xb450b9b3u, 0xb4a69188u, + 0xb42d1e39u, 0xb4933460u, 0xb5084985u, 0x3405d9d9u, 0xb46286a7u, 0x349eec99u, 0xb4781fcdu, 0xb33eafd5u, + 0x33f247fau, 0x34050bceu, 0xb571b6dcu, 0x34dff230u, 0xb4fb418du, 0xb4d40c25u, 0xb4039a19u, 0xb38517d3u, + 0xb500d531u, 0xb3709657u, 0xb440b523u, 0xb5094521u, 0xb51c7175u, 0xb57b0438u, 0x33c9f84fu, 0xb4cde067u, + 0xb5226ff4u, 0xb516ff88u, 0xb5127f0bu, 0x320010e7u, 0x34b88458u, 0xb4c25cfeu, 0xb4abf384u, 0x340af05au, + 0xb8d330feu, 0x3838f553u, 0xb94e0d66u, 0x38cddc60u, 0xb142d42au, 0xb29fd301u, 0xb30efd73u, 0xb36ba1fcu, + 0xb345bb9bu, 0xb3661a2au, 0xb2970082u, 0xb3d09657u, 0xb29c9a88u, 0x328b2d7fu, 0xb393ea72u, 0xb3d69577u, + 0xb4325e64u, 0xb3ea1c05u, 0xb2618c09u, 0xb4454327u, 0xb40340bfu, 0xb4194a44u, 0xb447ad5cu, 0xb397347du, + 0xb33db065u, 0xb40ddb67u, 0xb45d81e8u, 0xb14ab73du, 0xb476994bu, 0xb42d99d8u, 0xb3c6ba0fu, 0xb338829bu, + 0xb4a4f1f5u, 0xb45367b3u, 0xb4962314u, 0xb4a53aebu, 0xb491bfedu, 0xb1c1b787u, 0xb39e52ddu, 0xb4eda60bu, + 0xb53496e4u, 0xb42f6a09u, 0xb496046bu, 0xb283fe95u, 0xb5033787u, 0xb49cb146u, 0xb4770ca7u, 0xb524bd58u, + 0xb508e607u, 0xb463dc22u, 0xb524db1du, 0xb5045280u, 0xb4458bbeu, 0xb4b3a8f2u, 0xb5143ae2u, 0xb4aa82cfu, + 0xb524f40au, 0xb51eaef5u, 0xb54425feu, 0xb41e5cdcu, 0xb4cd8c48u, 0xb52bf9abu, 0xb4d53960u, 0xb57704aeu, + 0xb51d3039u, 0x37dc139cu, 0xb8490564u, 0x394a1e10u, 0xb5f37e17u, 0x3306669fu, 0x337c7512u, 0x3346a43au, + 0x33ac9901u, 0x33d72d15u, 0x3435f4c2u, 0x3397e89au, 0x3404b459u, 0x343a98cbu, 0x3430e5bbu, 0x347a2786u, + 0x3449800au, 0x34a264afu, 0x347475f8u, 0x345d13f0u, 0x349961c6u, 0x34a41e86u, 0x349def17u, 0x34f77946u, + 0x34d9826du, 0x34e73fd6u, 0x347e19acu, 0x344981d2u, 0x34abb74du, 0x34a33027u, 0x352939eau, 0x3495750fu, + 0x34e88648u, 0x34a0eb6du, 0x34d0f196u, 0x351c4850u, 0x34dd2827u, 0x34e0e38au, 0x34442c57u, 0x3520578cu, + 0x354c4211u, 0x3528797fu, 0x3548aea7u, 0x3532c119u, 0x352ca1f5u, 0x353e7ec5u, 0x351c2052u, 0x344a7485u, + 0x350aab81u, 0x35823d6eu, 0x34fda208u, 0x356d85c2u, 0x3551af27u, 0x3592acaeu, 0x34d648e5u, 0x350a01a0u, + 0x35166098u, 0x353186c1u, 0x358caf04u, 0x3582e1b0u, 0x35783a71u, 0x356ac32cu, 0x3551ca91u, 0x3556fc1au, + 0x355b0e51u, 0x34f34578u, 0x38c8c7adu, 0x385c2faau, 0xb9467671u, 0xb8c66fceu, 0x332f7601u, 0x3340d938u, + 0x3399cf96u, 0x33c2e75eu, 0x33a2c447u, 0x3419672cu, 0x3448f46cu, 0x3481fcdcu, 0x348f790bu, 0x347a7010u, + 0x3443ea61u, 0x3454f9e0u, 0x34a9b976u, 0x3464ced3u, 0x34130ab5u, 0x3416bb69u, 0x34b150c7u, 0x34e612bfu, + 0x34f46ff6u, 0x34d9bd8du, 0x344f4c2bu, 0x34d5f617u, 0x34cba8adu, 0x34c45e5cu, 0x3536cbd8u, 0x34f32eadu, + 0x352373f3u, 0x35323995u, 0x3513298bu, 0x3537ae75u, 0x355b7cacu, 0x358fa48eu, 0x357d5f7bu, 0x358f22f8u, + 0x3585230cu, 0x35618f43u, 0x35b250ebu, 0x35670ea9u, 0x3595268bu, 0x351c6c5au, 0x35a50016u, 0x359ce04au, + 0x35ac9c79u, 0x35a072fdu, 0x35e2454au, 0x35acad2cu, 0x3540ec3cu, 0x35b00ef1u, 0x35b77cd0u, 0x35bbdcb1u, + 0x35cd3c8au, 0x358d62e2u, 0x35945667u, 0x35d0928cu, 0x35ac698bu, 0x35ab95f4u, 0x35a54c1fu, 0x35dfc65eu, + 0x35c67e06u, 0x35a9f828u, 0x35d3ce9fu, 0xb7f13863u, 0xb862a2c7u, 0x3942de1eu, 0x35d75d94u, 0xb2d2d963u, + 0xb36074c6u, 0xb38ad6f8u, 0xb2da09ceu, 0xb3c23a45u, 0xb22329b7u, 0xb3ce9553u, 0xb3f21873u, 0xb2c5a5f6u, + 0xb3f669b1u, 0xb444bf00u, 0xb3680f91u, 0xb49fd10au, 0xb20bb162u, 0xb43244c0u, 0xb3f4d56bu, 0xb42c711cu, + 0xb3ac69dcu, 0xb4908a73u, 0xb4a2758fu, 0xb41bff43u, 0xb481f848u, 0xb4b0befbu, 0xb346e40eu, 0xb456e73bu, + 0xb4997b1fu, 0xb46dcbccu, 0xb482bfbau, 0xb48e4249u, 0xb4b1bd14u, 0xb455cc1fu, 0xb48e3fa3u, 0x332e2f43u, + 0xb4c832fcu, 0xb41c9afeu, 0xb4b45fedu, 0xb53bb0d3u, 0xb4f623f9u, 0xb4e59839u, 0xb413bf5eu, 0xb50d5825u, + 0xb5009a78u, 0xb5151fe7u, 0xb54d8261u, 0xb3e01527u, 0xb4adf775u, 0xb5845e7du, 0xb506e3b9u, 0xb4111e3bu, + 0xb50749b9u, 0x33eb33f2u, 0xb5176484u, 0xb5193c4du, 0xb5874e67u, 0xb50adca5u, 0xb48a5f88u, 0xb3ef7227u, + 0xb573061du, 0xb4d61cd2u, 0xb574ce72u, 0xb503407au, 0xb8bdd64du, 0x386fb405u, 0xb93f8fffu, 0x38bf6a72u, + 0xb28b6d60u, 0xb10b7318u, 0xb2f4913cu, 0x33041295u, 0xb30a9dcdu, 0xb1ff37dfu, 0x32cfa4d4u, 0x3332357eu, + 0x33505ae7u, 0x331f2382u, 0x332886d6u, 0xb3f3c201u, 0xb372c3feu, 0xb38b3334u, 0xb45a33b9u, 0xb3b0cebau, + 0xb1341a5fu, 0x32953999u, 0xb3207d0au, 0xb3e0ad4cu, 0xb39f5251u, 0xb4860800u, 0xb35ec3f1u, 0xb4147f40u, + 0xb3d05dddu, 0xb44dd8a0u, 0xb40d254cu, 0xb47c681eu, 0xb46343c4u, 0x3410cefcu, 0x3409ffd8u, 0xb45016c4u, + 0x33d2c1b4u, 0xb19c69f2u, 0xb1edb211u, 0xb110d4e2u, 0xb318bfedu, 0xb2856bf2u, 0x3252bc17u, 0xb4a876a8u, + 0xb340900bu, 0xb4649bdeu, 0xb40a8275u, 0xb4362292u, 0xb448d715u, 0xb4a0c0e6u, 0xb3a10f63u, 0x33b59557u, + 0x348c0d78u, 0xb3c060e4u, 0xb4ad99beu, 0xb4892940u, 0x3413e3ebu, 0x3411b785u, 0x33a2be35u, 0xb48b6b77u, + 0xb2c47fb7u, 0xb4aacbfau, 0x33cb3143u, 0xb354c7c3u, 0x32381c2au, 0x380bb5ecu, 0xb880fe70u, 0x393c3f73u, + 0xb5d4efc8u, 0xb2088b90u, 0xb38b56e8u, 0xb373a9aeu, 0xb355a319u, 0x338742b8u, 0xb3c1b6a3u, 0xb318f41du, + 0x34c10566u, 0x33e24762u, 0x33aa72ffu, 0x33eaa606u, 0x33ba65e8u, 0x341586d2u, 0xb419a756u, 0x31b9cecau, + 0x3434bb16u, 0x3340e128u, 0x343026b4u, 0x33b26416u, 0x3429522bu, 0x338b7983u, 0xb3adf01cu, 0xb4d43f0cu, + 0x34395341u, 0x33d28eb7u, 0xb307a0eeu, 0x3453b83eu, 0xb266c4d0u, 0x306f7578u, 0x34eba0bfu, 0xb4b51e4du, + 0xb45cf7f4u, 0xb36e87b0u, 0x34530d1du, 0x3554c91eu, 0xb4670c6au, 0xb3938182u, 0xb48d60d7u, 0xb4b06913u, + 0x346993ceu, 0xb324001cu, 0x340a7484u, 0x349ba7bbu, 0xb51d3e9bu, 0x33c981f7u, 0x343183a7u, 0x351a4ca0u, + 0xb4853c27u, 0xb3a66cfcu, 0xb4b7061cu, 0xb4590e17u, 0x34db5672u, 0xb408c74au, 0x3479b513u, 0x34fbd952u, + 0x3414014au, 0x344ffd4cu, 0xb5937f33u, 0x33449485u, 0xb3d7337eu, 0x3492a5acu, 0x38b3d589u, 0x3886f41du, + 0xb93939d3u, 0xb8b939fbu, 0x330d0855u, 0x336ecb95u, 0xb31cb2ebu, 0x339fc7abu, 0x33e50d9au, 0xb16eb88eu, + 0xb33bde09u, 0xb39976a4u, 0x3401bf8fu, 0x3435048fu, 0xb2ddfc12u, 0x33cc00bau, 0xb3827a24u, 0xb3ec8404u, + 0xb4530dc1u, 0x335df838u, 0xb49fc891u, 0xb48b7e69u, 0xb46c83b1u, 0xb53c00e5u, 0xb5238305u, 0xb4c2fcd9u, + 0xb5238305u, 0xb4d0c451u, 0xb57c00e5u, 0xb54b0520u, 0xb54b0520u, 0xb5238301u, 0xb50fe449u, 0xb5638301u, + 0xb49bbf68u, 0xb4ec8389u, 0xb3ddfa42u, 0xb3ddfa42u, 0xb3ddfa42u, 0x34b504cdu, 0x347b0cbfu, 0xabbd718fu, + 0x34b504c1u, 0xb4ec8389u, 0xb550c438u, 0x34b504cdu, 0x34b504cdu, 0xb550c43cu, 0xb4ec8389u, 0xb4997629u, + 0xb4ec8389u, 0xb443ef75u, 0xb4b5051bu, 0xb4ec8389u, 0x353504dfu, 0xb580e002u, 0x33a9e3beu, 0xb5273d8cu, + 0xabca6c10u, 0x34261a7bu, 0xb3ddfa42u, 0xb4997629u, 0xb443ef75u, 0xb5939ec6u, 0xb585bf2bu, 0xb8159583u, + 0xb88e3032u, 0x39362e80u, 0x35ba9ca9u, 0x323da66bu, 0x33acbc6du, 0x337d4ce1u, 0x338e3272u, 0x33466ae0u, + 0x3356c7b6u, 0x33c594f4u, 0x33bcc4e4u, 0x34074339u, 0x3357579eu, 0x344dade2u, 0x344f42a8u, 0xb27f70f1u, + 0x330a83efu, 0x34745c43u, 0x33be28afu, 0x34735dcdu, 0xb32a2f69u, 0x34b3d6feu, 0x34fb927fu, 0x34188397u, + 0x34c9f8c8u, 0x3505a23bu, 0xb3be0bfeu, 0x341e9947u, 0x34afce23u, 0x348e8dd8u, 0x3408c379u, 0xb38d08eau, + 0x34ad527bu, 0x34a881f8u, 0x32fa4b2bu, 0x3590d136u, 0x34b71b11u, 0x345c688bu, 0x34e44301u, 0x34f4ed24u, + 0x32c8f03du, 0x34284fb8u, 0x34f3b3fcu, 0x3522fe6eu, 0x35266b60u, 0x34740fdau, 0x355ef42au, 0x3528f767u, + 0x34e73811u, 0x355667dau, 0x35afbfd4u, 0xb361dd1cu, 0x35815d99u, 0x3363536du, 0x34babf5du, 0x349e5d82u, + 0x354de959u, 0x354350aeu, 0x35c49241u, 0x3580f3a9u, 0x35c758e1u, 0x3557c787u, 0x35c064a0u, 0x351cb056u, + 0xb8a88030u, 0x3895744du, 0xb933628eu, 0x38b3434au, 0xb2958bb0u, 0xb31ae73eu, 0xb36cfce9u, 0xb25adcceu, + 0x328b49f2u, 0xb39ee314u, 0xb32f08f0u, 0xb3585f5au, 0xb39f78eeu, 0xb3d85741u, 0xb411b731u, 0xb392924bu, + 0xb32cdd48u, 0xb395ac39u, 0xb3e8a48fu, 0x2f5aa0dfu, 0x32010d8du, 0xb4222a64u, 0xb40a85b3u, 0xb45e2472u, + 0xb455cd64u, 0xb3938fffu, 0xb2fb6db5u, 0xb2f96bfau, 0x3372ea37u, 0x33cf0888u, 0x342bbffcu, 0xb44b1e0eu, + 0xb36560cau, 0xb3efea94u, 0xb42ac263u, 0xb3b149f3u, 0x339c30a6u, 0xb2bd6cbfu, 0xb45a0f03u, 0x34470ca6u, + 0xb4ed8826u, 0xb2496512u, 0xb50c4221u, 0xb4323c5au, 0xb4c7bf68u, 0xb44e99aau, 0xb51024a9u, 0xb51d57feu, + 0xb4d3ccdeu, 0xb3bd7044u, 0xb3ee4423u, 0xb4993ef3u, 0xb43fcd4cu, 0xb462d260u, 0xb3e77278u, 0xb4aafcdbu, + 0xb45f1045u, 0xb45c4922u, 0xb4ae090bu, 0xb4e2e661u, 0xb488be81u, 0xb515f9f3u, 0xb4bed107u, 0xb4be3be2u, + 0xb4a875b0u, 0x38219f41u, 0xb89a744cu, 0x3930995bu, 0xb5ae184du, 0x32292051u, 0x321d19e1u, 0xb1694f33u, + 0xb3397432u, 0x33721eedu, 0x3359ac97u, 0xb2c5dac4u, 0x33d4f4b2u, 0x338859acu, 0xb27fee3au, 0x3416aef3u, + 0x338f8392u, 0x342ad694u, 0x324a3f9fu, 0xb30e4df2u, 0x3436f100u, 0x3412273bu, 0x331d43ccu, 0x33ee5064u, + 0x344acebcu, 0xb3c7ed42u, 0x34023abeu, 0x3465357fu, 0x3329f51bu, 0xb38bb323u, 0xb3fcf62fu, 0x343f414fu, + 0x331ef862u, 0x34a042dbu, 0x34ca3584u, 0x344cbe4cu, 0x33c2581fu, 0x343f4aadu, 0x3445cd2au, 0x3454783eu, + 0xb4350c76u, 0x34a5229cu, 0x32744cc8u, 0x343ecaa3u, 0x34dd9168u, 0x34d16e80u, 0x33be291au, 0x3548698du, + 0x34c70b9cu, 0x3333beeeu, 0x34d7d71fu, 0x34546242u, 0x34019486u, 0x338f2a92u, 0x333111cbu, 0xb42f3441u, + 0x34964606u, 0x34db5baau, 0x34b5ee41u, 0x348f2bf9u, 0x3409f2fdu, 0x34773bf8u, 0x34a1b6dcu, 0xb3ae5e24u, + 0x351fec92u, 0x34161a3eu, 0x38a19b8fu, 0x38a08860u, 0xb92e0973u, 0xb8ae0dedu, 0xb303eb59u, 0xb117b493u, + 0xb3722089u, 0xb3c59740u, 0xb2d626e5u, 0xb3a50e5eu, 0xb336d361u, 0xb416e775u, 0xb37631a2u, 0xb4273114u, + 0xb469c8fau, 0xb3884e05u, 0x333402ecu, 0xb3641f09u, 0x320775f9u, 0x31b6a33fu, 0xb36743f8u, 0xb4556b8eu, + 0xb33f74c2u, 0xb4dfb8d6u, 0xb4752080u, 0xb4a54238u, 0xb4ba1a8du, 0xb476aad7u, 0xb46ee448u, 0xb329bf5bu, + 0xb4880863u, 0xb48caa0fu, 0xb480cc57u, 0xb4800b12u, 0xb4e20d2du, 0xb4248efdu, 0xb4dea959u, 0xb520a6cfu, + 0xb4c2a340u, 0xb357ca98u, 0xb48c5467u, 0xb4bf9305u, 0xb521da11u, 0xb5284c6fu, 0xb493fe60u, 0xb53a144cu, + 0xb266cbe2u, 0xb53a0d17u, 0xb51ccd97u, 0xb520aa60u, 0x32589151u, 0xb40ce91eu, 0xb496ae56u, 0x335673b3u, + 0xb31677f2u, 0xb4dd04e1u, 0xb4a4adb8u, 0xb48a7aa9u, 0xb56e2e49u, 0xb58c6ad6u, 0xb532c2b9u, 0xb596fbceu, + 0xb51eebfdu, 0xb5211ba1u, 0xb513f6deu, 0xb82e9b22u, 0xb8a3593du, 0x392b79ecu, 0x3599086cu, 0xb236c3cdu, + 0xb225511eu, 0x32876266u, 0xb2e89814u, 0xb0dc3115u, 0x32284abfu, 0xb28f0c8au, 0x32ccbb82u, 0xb3877bbdu, + 0xb284df03u, 0xb3d41b57u, 0xb2def5ddu, 0x3352f275u, 0x322b726au, 0xb38e3f45u, 0x33915940u, 0xb23e3fccu, + 0xb319a659u, 0xb435912eu, 0x3402a714u, 0x32c970f5u, 0x3476de07u, 0x33bb161du, 0x325a6b0eu, 0x33d478f7u, + 0xb477d6ffu, 0x33c0398bu, 0x33c6f53du, 0xb3ca5995u, 0x3374080eu, 0xb4884310u, 0x327f50ddu, 0xb46cd2b6u, + 0xb43c1e04u, 0x33a6e413u, 0xb3b012c2u, 0xb4330a2du, 0x31d6d14bu, 0xb0d62233u, 0x33f7585bu, 0x3478ceffu, + 0xb30529c9u, 0xb47fcf8cu, 0xb4095948u, 0xb470bce7u, 0xb2597ba1u, 0x33303e96u, 0xb427e1a5u, 0x3369e75fu, + 0xb42e94b9u, 0x345c19cbu, 0xb428af13u, 0xb46767e0u, 0xb44e4d12u, 0x34200b70u, 0xb4ab920bu, 0xb4fc1b7au, + 0x312a6f87u, 0xb4c03609u, 0x2f8408c1u, 0x349c6ed7u, 0xb8987210u, 0x38a7fcb8u, 0xb92920fdu, 0x38a9014bu, + 0xb14bb073u, 0x3250ca8du, 0xb272d9c7u, 0x331dfea3u, 0xb2148693u, 0xb2559f4du, 0xb2faaaf9u, 0xb23a2900u, + 0x32bd64b9u, 0xb19a8372u, 0xb2e967cfu, 0xb354bc82u, 0x327c6e3cu, 0xb1a07811u, 0xb2b2b975u, 0xb393c54au, + 0xb3e065bfu, 0xb4060915u, 0xb33ae0d3u, 0x33ad3b17u, 0x318e9a25u, 0xb151d0d1u, 0x3361f51du, 0xb42cc816u, + 0xb46d1284u, 0xb38da2eau, 0xb3fb6e33u, 0xb41caf3du, 0xb43b8b3eu, 0xb407121fu, 0xb404ab4cu, 0xb4713006u, + 0xb396de2fu, 0x3413da2eu, 0x34029438u, 0xb27e0d18u, 0x340b8f6eu, 0xb4507643u, 0x32d23253u, 0xb4a7140du, + 0xb4a835d0u, 0x33f0a4dau, 0xb46c0984u, 0xb4ab8412u, 0xb3b73291u, 0xb41bf983u, 0xb3b0135eu, 0x333ddb4au, + 0xb4a12e67u, 0x3403ba63u, 0x342a629fu, 0xb444a57bu, 0x34878c0au, 0x33cd1e10u, 0xb4991dedu, 0xb338013fu, + 0xb417b0b7u, 0xb3486c1cu, 0x342b63e8u, 0xb28213cfu, 0xb3bf8419u, 0x3835b4c3u, 0xb8ae0392u, 0x3926c63eu, + 0xb59289e5u, 0x31e26110u, 0x331907d8u, 0x3281be11u, 0xb3b54fccu, 0xb3610442u, 0x33ce5f0bu, 0xb1234fc1u, + 0x32b1da7au, 0xb245d831u, 0x32fea16fu, 0x32d099a8u, 0xb36a5568u, 0x330f8bbfu, 0x31952dfcu, 0xb1e2ed06u, + 0xb3bf0684u, 0x33e64671u, 0xb3323a63u, 0x341ac72cu, 0x33b68968u, 0x349fae1bu, 0xb3c53336u, 0x33dd2858u, + 0x339d04ffu, 0xb3fb33eeu, 0x33764f28u, 0x348a02a6u, 0x32d62aaeu, 0x3497266fu, 0xb20bf8c0u, 0xb49039d9u, + 0x3440c65eu, 0x3362d638u, 0x348d68dau, 0x31b936c5u, 0x32431aabu, 0x348963ccu, 0xb484c94eu, 0xb39d44dfu, + 0xb41e5f04u, 0x3486dfaeu, 0xb3978da6u, 0xb41fdc88u, 0x34a8bee4u, 0xb468991eu, 0xb44c2899u, 0x330c8d43u, + 0xb5155dbau, 0x33d40cdfu, 0xb2199bf6u, 0x33f64c94u, 0x357cfda6u, 0x34b526aau, 0x33d89d28u, 0x349f1d34u, + 0xb413275du, 0xb5370378u, 0x34d1defau, 0x35214c2cu, 0x340cbd81u, 0x329973e1u, 0x388e9a10u, 0x38b31281u, + 0xb9249d04u, 0xb8a495b0u, 0x32bdb0bdu, 0x3284d8d8u, 0xb28aa8d9u, 0x3263b023u, 0x33465af6u, 0xb2ab307eu, + 0x342cb73bu, 0x33914a1bu, 0xb44275a0u, 0x3440ebdfu, 0x3445eab8u, 0x3282ae12u, 0xb3ed8b0cu, 0x32921f82u, + 0xb357c8acu, 0xb395c4dfu, 0xb17111b3u, 0x333b0446u, 0x34b8c840u, 0x3373e986u, 0xb2b159b4u, 0x34e524ceu, + 0x32752fd6u, 0xb43d621fu, 0x3489a806u, 0x344f6189u, 0x34935a5cu, 0x33be974au, 0xb28d9cc1u, 0x33ab6369u, + 0x3523b676u, 0xb35e1f00u, 0xb5074ec6u, 0xb433c421u, 0xb4d023fau, 0xb502c66bu, 0xb3746224u, 0xb2866acau, + 0x356d00bbu, 0x34ba9e8eu, 0x3449b2a5u, 0x34b8b9d1u, 0xb509ebedu, 0xb44cc73eu, 0xb4594212u, 0xb3b8a671u, + 0xb2138093u, 0xb3f602c5u, 0x344ba0a0u, 0x34e670d9u, 0xb3c55afeu, 0xb33dde58u, 0xb4326d3fu, 0x3509ee76u, + 0x34c438a5u, 0x354b53deu, 0x34899f82u, 0x34e4d7eau, 0x3539f839u, 0x35999d86u, 0x34dc0ae2u, 0xb83effe9u, + 0xb8b7f408u, 0x39227458u, 0x3581e405u, 0xb1815558u, 0x31d958d2u, 0x32a329a2u, 0x338ba611u, 0x3343c0afu, + 0x3355e2ffu, 0x3187ce92u, 0x303c324eu, 0x3400369au, 0x32f13579u, 0x30a7b660u, 0xb25d0fa4u, 0xb3b88c5au, + 0x336cc533u, 0x3320beecu, 0xb3439912u, 0xb38f272eu, 0x33e70758u, 0x3445e0d6u, 0x342c3161u, 0x3477a839u, + 0xb33d4dc3u, 0x33af3505u, 0x33320bbcu, 0xb4420569u, 0x33cc57b5u, 0x33160464u, 0xb1f771c2u, 0x33071f93u, + 0xb2496a98u, 0x3417f9a4u, 0x34039057u, 0x34f2afe5u, 0x3324e8a3u, 0x341eb9fdu, 0x33dfc91du, 0x348dd56au, + 0x345392f4u, 0x34d7444du, 0xb31993a8u, 0xb2405205u, 0x33f7e7e0u, 0xb2cbb986u, 0x344581ddu, 0x342d218au, + 0x34a8b988u, 0xb4a70ad6u, 0x34dd6254u, 0x34af3ef7u, 0xb3591804u, 0x33e8b158u, 0x33fdcc7bu, 0xb40e8e10u, + 0x346e74afu, 0x34819968u, 0x35377ee3u, 0xb4228c4du, 0x345c2c12u, 0xb4d2c420u, 0x34b358abu, 0xb4e0fa19u, + 0xb885f443u, 0x38bbf22au, 0xb9207998u, 0x38a06421u, 0xb235bf82u, 0x318fa782u, 0x3287fd91u, 0x3151f6deu, + 0xb19a1b21u, 0xb306dbf1u, 0xb251ebb5u, 0x324b616fu, 0xb2e796ccu, 0x339f58dcu, 0xb12e2c39u, 0x331c4844u, + 0xb215f8b7u, 0xb2464f28u, 0xb18b6583u, 0xb38b63cfu, 0xb39e6503u, 0xb3d050bfu, 0xb2e390e2u, 0xb4651ee4u, + 0xb353f77eu, 0xb37f0ae6u, 0xb350c6aeu, 0xb433eba2u, 0x32047cd7u, 0x32265be3u, 0xb4368b1cu, 0xb30532e8u, + 0x32f5960bu, 0x34147ebeu, 0xb486b938u, 0xb4331034u, 0xb4cbccccu, 0xb43c121eu, 0xb4b0fdddu, 0xb479bb0eu, + 0xb47f5ea7u, 0x32e8f13au, 0xb40052ebu, 0xb4e2edb6u, 0xb312a402u, 0x343215b0u, 0xb2fed974u, 0xb45938b2u, + 0xb3eb84b9u, 0xb4b8c494u, 0xb4586379u, 0xb43848d0u, 0xb30fc9bdu, 0xb53bdb84u, 0xb4ccd59eu, 0xb50ae1adu, + 0xb4d94dc0u, 0xb4ea43a0u, 0xb496e4aau, 0xb4998601u, 0x33b61d18u, 0xb3500aa2u, 0xb41f1991u, 0xb512ffbdu, + 0x331fdc76u, 0x38461ef4u, 0xb8bef4feu, 0x391e7dc8u, 0xb57a4984u, 0xb1c8b901u, 0xb20b3da4u, 0xb2927170u, + 0x3233351cu, 0x3311a6d0u, 0xb350e608u, 0xb26d91fdu, 0xb2f9565au, 0x32ab2c4du, 0xb38a2fd5u, 0x3310a43au, + 0xb415f4bdu, 0xb378790bu, 0xb3fd0b76u, 0xb2eff781u, 0x33a3ebb1u, 0xb339b938u, 0xb3c9abd1u, 0x33f1e3f6u, + 0xb0ba6bc8u, 0x34136d19u, 0xb46ca161u, 0x31240f12u, 0x344cbdfeu, 0xb41a6d66u, 0x34790864u, 0xb413a5f6u, + 0xb42c6103u, 0xb1a65053u, 0x349f8d9cu, 0x34326779u, 0x349c212du, 0xb2cbc6edu, 0x33d0f52du, 0x341be16au, + 0x34a73c71u, 0x338b1223u, 0xb3f5b03au, 0x33f87b56u, 0xb44540f7u, 0x3382fe2du, 0xb4ca86abu, 0x334c2055u, + 0xb4374867u, 0xb3875173u, 0x34bb4680u, 0xb3b96c61u, 0xb3c9664au, 0xaf52dbb7u, 0x34153087u, 0xb4d7e76fu, + 0x33d6d5e1u, 0xb4de39cau, 0xb4eabb8cu, 0xb3c813f1u, 0xb434833au, 0x3477f702u, 0x3306f3e3u, 0x3462cd9du, + 0xb2e49a4eu, 0xb47c979fu, 0x387dbab4u, 0x38c3552au, 0xb91cac5cu, 0xb89cacd0u, 0x31318419u, 0xb2aa8a3au, + 0x31841a9fu, 0x337ad170u, 0x32fec67bu, 0x336cc6efu, 0x335a0c80u, 0xb3be2cfau, 0xb40672e0u, 0xb3a99361u, + 0x3308d755u, 0xb3098d99u, 0x33ae19cbu, 0x31f074b0u, 0xb42ca2b5u, 0xb204bb2eu, 0x2f17019eu, 0xb3b3d155u, + 0xb3e6de99u, 0xb1f6902au, 0xb4075614u, 0xb4ad1334u, 0xb474ab92u, 0xb42c56f6u, 0xb310ba01u, 0xb3884bb2u, + 0xb40b44fbu, 0xb3d765e3u, 0xb33e52c5u, 0x3333f2b1u, 0x34a56d89u, 0x340c2a24u, 0x3450d571u, 0xb3609e69u, + 0x32be3349u, 0x34b4e2abu, 0xb484492eu, 0x33ebb720u, 0x34d4647cu, 0x35100b11u, 0x34a7e6e3u, 0x34e6a649u, + 0x34d3c285u, 0x34b7f13eu, 0x3517415bu, 0x34addc89u, 0xb45e4eb0u, 0x3436c124u, 0xb454b886u, 0xb3c59b02u, + 0xb39740e2u, 0x33ceb9e6u, 0x34faf9d3u, 0xb45d497bu, 0x3489ef5au, 0xb390cf84u, 0xb345d1c1u, 0xb3318302u, + 0x34b2ad9du, 0x3513a7a0u, 0xb416b48eu, 0xb84e0caau, 0xb8c7e24bu, 0x391adbffu, 0x355b76c3u, 0x3247b205u, + 0x3238ddddu, 0xb2fb00dau, 0x30e1db9eu, 0x31b26addu, 0xb2c07075u, 0x3317f87bu, 0x3398ff79u, 0x3310dc91u, + 0xb2cdb60eu, 0x33f68aa1u, 0x33ea7af0u, 0x33d597edu, 0x33b1c96bu, 0x3186a764u, 0x338c041bu, 0xb430d430u, + 0xb139df62u, 0x343180e6u, 0x33e7a11eu, 0xb40ead5bu, 0x33b3934eu, 0xb2b51443u, 0xb2bb2d3bu, 0xb35118a2u, + 0xb35349b9u, 0x3491494au, 0x33be9db2u, 0x340a0359u, 0x338c79aeu, 0x3486c6fcu, 0xb0936a98u, 0xb4417f77u, + 0x345466eau, 0x31cef76fu, 0x33d666f7u, 0x33b776e7u, 0x341e1f58u, 0x33f7f6edu, 0x344d1a9fu, 0x322496ecu, + 0x3491dedcu, 0xb41859c6u, 0x34bd3340u, 0x34c9165cu, 0x351cc279u, 0x3307b15eu, 0x33bd4ccbu, 0x348997bcu, + 0x34a94bc6u, 0xb4b1535du, 0x341b7e43u, 0x34a2dc57u, 0x34baa132u, 0x349edb81u, 0xb43705ceu, 0xb4d0b03au, + 0x3432b348u, 0x3545cfe1u, 0x332dbe4bu, 0x349f563cu, 0xb86b760au, 0x38ca1399u, 0xb91930c7u, 0x38991589u, + 0xb23b893eu, 0x31b7369eu, 0x3252bfceu, 0x32ff1bb9u, 0x32d9265eu, 0xb1e9b5dbu, 0xb131acaau, 0xb36e024eu, + 0x32f1dcd4u, 0xb2235079u, 0x33b68bf7u, 0xb3659b19u, 0x33fa482fu, 0x2f2575bbu, 0x33ae5959u, 0xb2fe6284u, + 0x331a3f94u, 0x337c7e56u, 0x3445743au, 0x348feef8u, 0x319f471fu, 0x3458a7deu, 0x339172bau, 0x34589a7fu, + 0x343552f6u, 0x339f5816u, 0x348e2d77u, 0x33945566u, 0x342d234cu, 0x33857376u, 0x340b667eu, 0x3453485eu, + 0xb380c1abu, 0xb2c16d75u, 0xb4327397u, 0xb2d78daau, 0xb3de6a36u, 0xb40bc724u, 0x3332da94u, 0x34c918acu, + 0x349fa4cfu, 0x3436ace8u, 0xb422bc56u, 0xb312e704u, 0x34939a38u, 0x3454b50cu, 0xb48a685cu, 0x338c9570u, + 0xb3a5f1b5u, 0x33c3a4c2u, 0x34ba836du, 0xb4cc4402u, 0xb2d4976au, 0xb42b1f17u, 0x34a1ecafu, 0xb433a5a7u, + 0x34bd0e94u, 0x34fa1a48u, 0xb31d3419u, 0x33640e9fu, 0xb3a86176u, 0x385624b6u, 0xb8ccd077u, 0x391784a2u, + 0xb54dd7dcu, 0x328909f0u, 0xb10f428du, 0xb3751228u, 0x326a74aau, 0xb42172dfu, 0x3246e4bcu, 0xb184c4e2u, + 0xb2ec082fu, 0x34291c0bu, 0xb32b8027u, 0xb2f11426u, 0x339313cau, 0xb2cd87abu, 0x33c4783fu, 0x33f25674u, + 0x343d9849u, 0x34b66d6bu, 0xb401843bu, 0x34b588e0u, 0xb417160bu, 0x34e68df7u, 0xb45a566au, 0xb4c1d5ecu, + 0x32da9665u, 0x34f46416u, 0xb4328a3au, 0x338ece7au, 0x32938055u, 0xb464b787u, 0xb4624b18u, 0xb4d460a7u, + 0xb4b3afc9u, 0x34482c1fu, 0xb50557c4u, 0x34b474fau, 0x35178f87u, 0x342eb50au, 0x34d7c0f9u, 0x342aeec9u, + 0x33a58c15u, 0xb525c593u, 0x321a78aau, 0x352f2cf3u, 0xb373bfceu, 0x355f1e14u, 0x350093ccu, 0xb4816691u, + 0x34c2b530u, 0x343a2d8bu, 0xb549d55cu, 0x35875f94u, 0x33c3a111u, 0x34cb49c3u, 0x3532efb3u, 0xb2887bc3u, + 0xb4816691u, 0xb426ddf5u, 0xb201d396u, 0x34b39070u, 0xb4d9a971u, 0x34779f06u, 0x385ebe1bu, 0x38d3354eu, + 0xb91601cdu, 0xb89604a1u, 0x3215f5f6u, 0x337ffff7u, 0x3295f608u, 0x28800000u, 0xb3b504feu, 0xb3b504feu, + 0xb4b504fcu, 0xb4b50500u, 0xb43504ffu, 0xb3d413feu, 0xb435050cu, 0x343504f4u, 0xaa400000u, 0xaa400000u, + 0x3395f602u, 0xb53504f3u, 0xaa400000u, 0xb53504ecu, 0xb4b504e6u, 0xb53504ecu, 0xb4b504f9u, 0x2a600000u, + 0x3415f60eu, 0x3415f634u, 0xb53504ecu, 0xb4b504e6u, 0xb4b504e6u, 0x3415f634u, 0xb45413ccu, 0xb4b504e6u, + 0xb4b504e6u, 0xaa400000u, 0xaa400000u, 0xaa400000u, 0xaa400000u, 0xb53504f3u, 0xaa400000u, 0xaa400000u, + 0xaa400000u, 0xaa400000u, 0xaa400000u, 0xb53504f3u, 0xb53504f3u, 0xaa400000u, 0xaa400000u, 0x353504edu, + 0xaa400000u, 0xaa400000u, 0xb53504f3u, 0xaa400000u, 0xb53504f3u, 0x35da8276u, 0x353504edu, 0x353504edu, + 0xb53504f3u, 0xaa400000u, 0x357ffffdu, 0x353504edu, 0xaa400000u, 0x353504edu, 0xaa400000u, 0xb8639790u, + 0xb8d37dd4u, 0x391479f2u, 0x353973efu, 0x327962f3u, 0xb2ab0ba9u, 0xb3a42210u, 0xb317b76du, 0x34263642u, + 0x33ad7ff3u, 0x34148728u, 0xb40823beu, 0x343a14d4u, 0xb43d1adbu, 0x3484c3b0u, 0x34b54d47u, 0xb3e88a59u, + 0xb47f536cu, 0xb40ec09bu, 0xb457964au, 0x34f785f8u, 0x349ad1d8u, 0xb3f5c414u, 0xb5643fa2u, 0xb4a766d9u, + 0xb3a649cfu, 0x3486f347u, 0xb2f17050u, 0xb4c87f45u, 0x3596b5beu, 0x34f326cau, 0xb4229f2cu, 0x33a8fc1du, + 0xb4ae03d6u, 0x32b6489cu, 0x35912ea6u, 0xb5afe730u, 0x3526ce8eu, 0x3512effcu, 0x3512effcu, 0x35abaa24u, + 0x3458ef71u, 0x3526ce8eu, 0x3458ef71u, 0xb5d10381u, 0x34c92bd8u, 0x3423e8e2u, 0xb48aa5c2u, 0xb51b6a14u, + 0xb535e592u, 0xb535e592u, 0xb48aa5c2u, 0xb5220701u, 0xb4da2008u, 0xb53c8280u, 0x352d6b7bu, 0x35f360ccu, + 0x35414a0cu, 0x3512effcu, 0xb535e592u, 0x353aad1fu, 0x352d6b7bu, 0xb5781e34u, 0xb5a3c858u, 0x35abaa24u, + 0xb8500ef9u, 0x38d79b42u, 0xb91312e8u, 0x38930464u, 0xb2816752u, 0xb2b3dc07u, 0x30a53716u, 0x3239cec5u, + 0x325dafb7u, 0x3190363du, 0xb384ece3u, 0x333bc36du, 0x324795d3u, 0x3356ae13u, 0xb0edd151u, 0x32057ac5u, + 0x31ffd110u, 0x331b0f31u, 0x33347cbfu, 0x32d7d7ccu, 0x33be5aa8u, 0x33876d2fu, 0x338d7d7fu, 0xb1b34f4cu, + 0xb200abd2u, 0x3397414du, 0xb1356737u, 0xb1ae832au, 0x3316ff3fu, 0x33d0a52cu, 0xb4228ebfu, 0xb3a44655u, + 0x343778f8u, 0xb0427ffeu, 0x349b9d11u, 0x34bf5ab2u, 0x34199fadu, 0x34afa673u, 0x348665cdu, 0x330776c5u, + 0x345a5707u, 0x32c49b65u, 0x346c4d33u, 0x3483ad9fu, 0x3417a98eu, 0xb41d88a0u, 0xb378249cu, 0x34b44f7cu, + 0x33cfc8f0u, 0x34074d0bu, 0x346a1a4bu, 0x343bb1d5u, 0x348b9658u, 0x334e29f7u, 0x344493beu, 0xb354afe4u, + 0x346d440cu, 0x34ba9463u, 0x34bfdf75u, 0x34afa1fcu, 0x34a0d875u, 0x34168fefu, 0x34919028u, 0x340e5a45u, + 0x345003b8u, 0x3860926du, 0xb8d8b444u, 0x3911af12u, 0xb52fba26u, 0xb2c2b7edu, 0x3220fef9u, 0x3292eb38u, + 0xb24d5b71u, 0x32b7b020u, 0xb36e609eu, 0xb237d79au, 0x30d7957fu, 0xb2bc811bu, 0xb30fc878u, 0xb405545bu, + 0x32193ae8u, 0xb383fb04u, 0xb42315c7u, 0xb38b6bd4u, 0xb3642958u, 0xb38e6e66u, 0xb16a4295u, 0xb272121eu, + 0xb4388889u, 0xb42aef7au, 0xb34765b6u, 0xb21d3eefu, 0xb4ef1047u, 0xb480e1f3u, 0xb3974cbcu, 0x343936bbu, + 0x33c95832u, 0x32b9f18bu, 0xb4cdd2c2u, 0xb43ea02du, 0x34ae3532u, 0xb44e287fu, 0x3411624fu, 0xb3b42cdfu, + 0x33497759u, 0x32bbcc3cu, 0xb46b6721u, 0xb380cd3eu, 0xb2ec7329u, 0x3381ebb1u, 0x320a899au, 0xb36f580bu, + 0xb4505c7cu, 0xb33cd3dbu, 0xb4ef48bdu, 0x33d37f81u, 0xb3304dcdu, 0xb4fafb5cu, 0x341558f9u, 0xb3fd746eu, + 0xb423fb05u, 0x34bdc7e7u, 0xb4b2a9f7u, 0xb4e5af44u, 0x3303f0aau, 0xb424b54bu, 0xb4e5fc28u, 0xb52fdc89u, + 0xb33027a4u, 0xb3d6b550u, 0x3842069au, 0x38dc4c4fu, 0xb9106adbu, 0xb89066afu, 0x325b28b5u, 0x328b4263u, + 0x320fe67du, 0x3394fe61u, 0xb11d25bcu, 0x33fe8460u, 0x33947cfcu, 0x33ff84e5u, 0x33f56996u, 0x339683d4u, + 0x3306ea6au, 0x348ea118u, 0x3487475du, 0xb1d6bc6eu, 0x346b95a8u, 0x34daf903u, 0x33d99dbeu, 0x35220ddcu, + 0x34989afdu, 0xb3e3bc59u, 0x34567004u, 0x3515ea21u, 0xb37ca1cau, 0x342a5d4du, 0x34fed825u, 0x3483e532u, + 0x3410aed0u, 0x34520ef8u, 0x3289ebe4u, 0x34a53d18u, 0x344fdbd4u, 0x347b62c6u, 0x32018c59u, 0x34e67239u, + 0x34e68623u, 0xb3ee0acau, 0x349bbf5du, 0x34a098aeu, 0x34924af7u, 0x34b513d6u, 0x33f34a97u, 0x33a87c7cu, + 0xb45dec69u, 0x34989db5u, 0x33fffdafu, 0xb373cc57u, 0xb437008du, 0x34c9ddaeu, 0x340302d5u, 0x3349a812u, + 0x33b44411u, 0x3485f010u, 0x32895c85u, 0x34afb990u, 0x34bccd6du, 0xb4096fe2u, 0xb36d3895u, 0xb278d88du, + 0x348340eeu, 0x34eeb098u, 0x3533fe35u, 0xb867336au, 0xb8df64b8u, 0x390f26ccu, 0x35146ca6u, 0xb2d5174au, + 0xb2c82293u, 0xb2189285u, 0xb2f01cd7u, 0xb3cd6413u, 0xb3a1af9eu, 0x305372a9u, 0xb39d241fu, 0x332969aeu, + 0xb17fbe5au, 0x328e8b7bu, 0xb3be97d9u, 0xb3a81471u, 0x339442bfu, 0x33aaf75du, 0x336c555bu, 0xb41e5ec4u, + 0xb3e84334u, 0xb38ac10bu, 0xb44fa03au, 0x3384aa04u, 0xb48755bau, 0xb39c3e53u, 0xb25f6b1bu, 0xb2b6469cu, + 0xb3cbbf9eu, 0xb3cbd6dcu, 0xb4063a8du, 0x320c47d9u, 0xb3a93de4u, 0xb296527du, 0xb4ab15cfu, 0xb4790417u, + 0xb103e0acu, 0x33aea989u, 0xb44bd03fu, 0xb485e545u, 0xb47dc1fdu, 0x341cad3cu, 0xb5364c6au, 0xb4c02a89u, + 0xb42c6a9fu, 0xb32bcf54u, 0xb30c4da4u, 0xb3d372fdu, 0xb4bd04a0u, 0xb4525dd5u, 0x3415376bu, 0xb4b9afbbu, + 0x353c4c52u, 0xb5067759u, 0xb38ad4f9u, 0xb4ce8f7au, 0xb4360577u, 0xb4a29561u, 0x350fd7e3u, 0x3424a3e8u, + 0xb3c3696cu, 0x34ecaa2bu, 0xb412fd2fu, 0x33b94c38u, 0xb83106dfu, 0x38e1d99au, 0xb90dfe45u, 0x388df54fu, + 0x31e898d1u, 0x32a1dc3cu, 0x325bea2au, 0x33990691u, 0x3231d5eau, 0x33b55c17u, 0x332c2905u, 0x32e0bed8u, + 0xb2015766u, 0xb37504abu, 0x338440bau, 0x338118fbu, 0x3354c14eu, 0x338048f2u, 0x3259e48bu, 0x341a197eu, + 0x34ab9be6u, 0x3480bf64u, 0xb3596df7u, 0x34319653u, 0xb3654213u, 0xb2ee72a4u, 0x346b0091u, 0x3405a5b3u, + 0x3466c12cu, 0x3422b708u, 0x34982462u, 0x34a0ecf3u, 0x33937348u, 0x344c2e7eu, 0x34e350ddu, 0x3496746du, + 0xb23f6c24u, 0x34da33a8u, 0x34b5c43du, 0x31cad309u, 0x3440896eu, 0x3502be2fu, 0x34f19ab8u, 0x3456f3a7u, + 0x33e2a4f1u, 0x3491a8d5u, 0x35013d5du, 0x34e1d521u, 0x33b6fe52u, 0x33e30ea4u, 0x351ce6d1u, 0x3508b58bu, + 0x34b50b1du, 0x34a36771u, 0x34f433d2u, 0x355ee117u, 0x350fc1e8u, 0x34bfbf94u, 0x35172659u, 0x33731774u, + 0x34380013u, 0x3483b153u, 0x33c8ba7du, 0x33a24788u, 0x34c56cdcu, 0x386bc4e9u, 0xb8e30b4du, 0x390cd672u, + 0xb50ef8d9u, 0xb2cde8c5u, 0xb2c7d9b3u, 0xb3121188u, 0x32f48ff4u, 0xb3559c34u, 0xb3cbec5eu, 0xb16262afu, + 0x32ee36a8u, 0x336f01ccu, 0xb3ac2832u, 0xb3bc8ffbu, 0xb3a64cc3u, 0x329f000fu, 0xb400b74fu, 0xb40aed00u, + 0xb405c15cu, 0xb4838478u, 0xb405f93eu, 0xb3d06e49u, 0xb448430du, 0x334f94ceu, 0xb45f7f56u, 0xb40b03a3u, + 0x3392ea22u, 0xb40cdac4u, 0xb4992559u, 0xb4d641dcu, 0xb35e8e82u, 0xb49ffe01u, 0xb4355acdu, 0xb4ae2449u, + 0xb41ffcd7u, 0xb50ec475u, 0x326302a8u, 0x33b51b54u, 0x3402c59eu, 0xb4470220u, 0xb4e607b0u, 0x34817d58u, + 0xb49f48ccu, 0xb421072du, 0xb4b3851bu, 0xb45b368du, 0xb4d6e9f7u, 0xb480c650u, 0xb52c4b29u, 0xb4304f1du, + 0xb49c0c1eu, 0xb45d9e3au, 0xb509d5fau, 0xb4c80f49u, 0x340b81cdu, 0xb4c938cdu, 0xb483e0eau, 0xb3acd66fu, + 0x338102e8u, 0xb484d4bdu, 0xb4525794u, 0xb48c1bc7u, 0x327cc0bdu, 0xb4a12b41u, 0x38239b48u, 0x38e396a4u, + 0xb90bcd7cu, 0xb88bcdebu, 0xb24979c2u, 0xb3104ad1u, 0xb2a67cdcu, 0xb3683661u, 0x32cad239u, 0xb3a68a9du, + 0xb2d15fa1u, 0xb4215562u, 0x33a1625bu, 0x32ccc192u, 0x335e9c44u, 0xb47f939au, 0x339ba7b9u, 0xb2acbbc5u, + 0xb3d88c5du, 0xb3cff7fau, 0x311b4c0du, 0xb508b657u, 0xb4e793d3u, 0xb4030a02u, 0x33741476u, 0xb484c0d5u, + 0x33c51a90u, 0xb50362dfu, 0xb4862e9fu, 0xb3ea4353u, 0xb48e26bbu, 0xb4cb5ca8u, 0xb486518bu, 0xb41081ecu, + 0xb4d075d9u, 0x34d0c3dau, 0x33ac7fc5u, 0x352811ecu, 0x3563336cu, 0x341e7369u, 0x34eb2c0bu, 0x35434bcdu, + 0x3478eb03u, 0xb4b72826u, 0x339a049eu, 0xb4509633u, 0x333ac91du, 0x34ad20d3u, 0xb48fe9bcu, 0xb4e7f463u, + 0xb438fb39u, 0xb464146cu, 0xb4a399b9u, 0x3543864du, 0x329ad3dcu, 0x351b61edu, 0xb49fd57cu, 0xb51c08f4u, + 0xb4bec4afu, 0xb4d27499u, 0xb4862b3cu, 0x34a68031u, 0xb525966eu, 0xb3907b30u, 0xb50ceb8eu, 0xb86ee44eu, + 0xb8e6917cu, 0x390ac753u, 0x34fa1c54u, 0x31bd1dc2u, 0x3370afc0u, 0x32edd85au, 0x33754778u, 0x33da1f5au, + 0x33c11b4fu, 0x33ea116fu, 0xb3c09779u, 0x33fcd4c8u, 0x3418cc8eu, 0x336b3764u, 0x340861cbu, 0x3417ee34u, + 0x3426c647u, 0x3445aa78u, 0x34c7fd53u, 0x33c7d42cu, 0x343ec74bu, 0x34ea1d49u, 0x34917f16u, 0x34d77912u, + 0x3473c2a6u, 0x34ace668u, 0xb2858dd9u, 0x35041752u, 0x348d6174u, 0xb30c8276u, 0x34247b90u, 0x352b2d59u, + 0x34291edfu, 0x34b25b26u, 0x348f1909u, 0x34c0b6d0u, 0x34be7d3du, 0x351333d7u, 0x339131d6u, 0x343efe5du, + 0x3505e7afu, 0x34e949a0u, 0x3421ac3au, 0x34bc8ff2u, 0x34fa9dc8u, 0x3551af63u, 0x34875392u, 0x31e30910u, + 0x341a6e3au, 0x344e133au, 0x34385308u, 0x3588f691u, 0x33eb1c00u, 0x34828761u, 0x3502f4acu, 0x3539f72fu, + 0x3546a290u, 0x34aa98e9u, 0x34c28c27u, 0x3560e0fbu, 0x35847266u, 0x3513f88eu, 0x34c90c24u, 0x35995d28u, + 0xb815011cu, 0x38ebc0aau, 0xb909d246u, 0x3889cbfcu, 0x324f91d4u, 0x322366ffu, 0x3316eeb9u, 0xb182f234u, + 0x33342db3u, 0x32d8f748u, 0xb29c6744u, 0xb2c598c4u, 0xb1d259a4u, 0xb2271b82u, 0xb2b7e903u, 0x34145028u, + 0x330b6a04u, 0x3226bfb6u, 0xb37a6fd0u, 0xb3966967u, 0xb338ec90u, 0x34439aa8u, 0xb3c917aau, 0x33045524u, + 0xb456d72au, 0xb40332c9u, 0x334a41b4u, 0xb36afb99u, 0xb41d5186u, 0xb0cb221au, 0xb28fc81cu, 0xb39295dfu, + 0xb2dda09cu, 0xb419a5d7u, 0xb2738b31u, 0x33b68fc1u, 0xb3f5fa8fu, 0xb353ff09u, 0x340bef2bu, 0xb39ca3f0u, + 0xb47799efu, 0x348d4d9du, 0x334ffb9bu, 0x341696dau, 0x340aab74u, 0xb363fc5cu, 0xb49f03fau, 0xb4c6ae1eu, + 0xb38124c8u, 0x33a24cf3u, 0xb417e0abu, 0x341e45d7u, 0x33de266du, 0xb34097ceu, 0x34ec74adu, 0x341a2ad6u, + 0x34d62c11u, 0xb4353711u, 0xb3e09dd5u, 0xb432843bu, 0xb426c0b6u, 0xb2ed99d5u, 0x3392ed47u, 0xb442809eu, + 0xb3255064u, 0x3872101cu, 0xb8ec0cedu, 0x3908e491u, 0xb4e60864u, 0x330f7897u, 0x3345ed2au, 0x32e2db5du, + 0x3380dea6u, 0x33a7d35fu, 0x336ab0ecu, 0x339529e3u, 0x332d53d1u, 0x33cb08ddu, 0x33cf432fu, 0x33c7bbdcu, + 0xb33a0081u, 0x32ec1545u, 0x330c9ab7u, 0x34348347u, 0x340477f2u, 0x349b4b2du, 0x334e3791u, 0x33f278afu, + 0x31bb562bu, 0xb34d325eu, 0xb23b78fau, 0x33f79db9u, 0x345399c8u, 0xb3f29ed7u, 0x343782f7u, 0x3481721du, + 0x34148b9cu, 0x340323fau, 0xb3c76715u, 0xb006301cu, 0x348ab176u, 0x33e7f63au, 0x328a2ce0u, 0x34b4f4acu, + 0x34161c6du, 0x3494a3a1u, 0x335e9ae6u, 0x3420cfe6u, 0x34b33bf3u, 0x34c83890u, 0x3481e8d7u, 0xb35781a4u, + 0x352e4f8du, 0xb36337b1u, 0x33ed0ae1u, 0xb47114c4u, 0x33a391cau, 0xb3087e16u, 0x349d4ef0u, 0xb4d939fau, + 0x3504e5f9u, 0x34fa442cu, 0x347bab22u, 0x341ca845u, 0x32b64b03u, 0xb4176a01u, 0x34af9fe2u, 0x33ea9a36u, + 0x353eb3d7u, 0x353bff03u, 0x380b3989u, 0x38ee485bu, 0xb9080f10u, 0xb88812ceu, 0x326924a8u, 0xb2797f51u, + 0xb1f7721bu, 0xb344907du, 0xb3c35f11u, 0xb3778f3du, 0xb3f1c98fu, 0xb3d7d1c4u, 0xb458431au, 0xb40045ceu, + 0xb3458eddu, 0xb4017b82u, 0xb40ccc76u, 0xb3402927u, 0xb38c6b36u, 0xb4f81f68u, 0xb4aeac43u, 0xb3d7122eu, + 0xb3ae6a88u, 0x34078f72u, 0x32b751a2u, 0xb31c6a1bu, 0xb49f968du, 0xb436c5cbu, 0xb39be175u, 0xb4dcc1a3u, + 0x33d1e2f2u, 0x326c2ff5u, 0x343d28a1u, 0x33ed8888u, 0xb3a6386eu, 0x34346dd1u, 0xb517c169u, 0xb527fb44u, + 0x32c845f1u, 0xb4c476d5u, 0xb4af53a5u, 0xb4b895f9u, 0xb468032fu, 0xb443f0a4u, 0xb4a25283u, 0xb4563eefu, + 0xb532854cu, 0xb4d019cau, 0xb51596e0u, 0xb4f05687u, 0xb542ede5u, 0xb4d1c12cu, 0xb485b2b0u, 0xb5431fd7u, + 0xb49261ebu, 0xb4cf87a9u, 0xb52afef0u, 0xb4f9002bu, 0xb3ea7263u, 0xb4523557u, 0xb54cddf2u, 0xb498059fu, + 0xb4894276u, 0xb42ed025u, 0xb511aa2fu, 0xb8778837u, 0xb8f0bddcu, 0x3907397du, 0x34bcc784u, 0xb2076e21u, + 0x33187f03u, 0xb209d221u, 0xb3049de6u, 0xb24e92c9u, 0xb267a911u, 0xb282b523u, 0x32c16f1eu, 0x3346a644u, + 0xb33faa0fu, 0x33991093u, 0xb33cf31du, 0xb3f30b54u, 0x32a8f67eu, 0x33a87cf6u, 0xb246e31du, 0xb374cb3eu, + 0xb2c14b6du, 0xb28e2a84u, 0x33da2eb5u, 0x340258d1u, 0x342ceaf6u, 0xb39093a2u, 0x3361eeb5u, 0xb30e736au, + 0x3313b5b9u, 0x342d2691u, 0x34e56764u, 0x34d44b5fu, 0x341fba23u, 0x34436b69u, 0x34989b2bu, 0x333c1af8u, + 0x32ff83bfu, 0xb4b09a38u, 0x33cdc1afu, 0xb3cffc2fu, 0xb4ce4c9du, 0x32ab6599u, 0xb2057658u, 0xb3547643u, + 0x343eb107u, 0x3474ba41u, 0xb4467795u, 0x34ac7c3fu, 0x33b9a25cu, 0x339ba4f0u, 0xb4504126u, 0x3428e95fu, + 0x348db2aau, 0x34993a51u, 0xb22c3039u, 0x34adf248u, 0x34a2cc1bu, 0x34ac0d41u, 0x3422348eu, 0x345ea372u, + 0xb3a5bf87u, 0x343b80b9u, 0xb418efd5u, 0x34e2e888u, 0xb7f85745u, 0x38f2435cu, 0xb9067706u, 0x38867a8au, + 0x33232bcau, 0x33800f8eu, 0x33c90802u, 0x339c0177u, 0x33e64019u, 0x33e401f6u, 0x343f6f3du, 0x339d5f5cu, + 0x3420d92cu, 0x3432bacdu, 0x345546ddu, 0x3458ab7eu, 0x34be3de7u, 0x34914e13u, 0x3459badbu, 0x34808608u, + 0x33c02efbu, 0x349db693u, 0x34bbfad9u, 0x33b7847eu, 0x35147bf1u, 0x348df6e5u, 0x34359f2cu, 0x3455e75eu, + 0x34942afcu, 0x3484b15eu, 0x34e4d7b4u, 0x34cc0b78u, 0x34c88907u, 0x348bc8aeu, 0x34f0ffa6u, 0x351543a6u, + 0x353d4aeeu, 0x3500549bu, 0x35031c75u, 0x354dbdc7u, 0x3536b5d2u, 0x34d609d4u, 0x34bfd96fu, 0x353af262u, + 0x354390bcu, 0x34e518b2u, 0x353871e8u, 0x358cb8b9u, 0x353429e2u, 0x3550e628u, 0x35661a8du, 0x349aff8au, + 0x355dbaf2u, 0x353cb50cu, 0x3531d08au, 0x356d3264u, 0x35349618u, 0x3520bb83u, 0x3568436au, 0x3522b626u, + 0x356181e0u, 0x354288f3u, 0x356f0ac0u, 0x35648feau, 0x3590bb61u, 0x387e51c7u, 0xb8f1a74bu, 0x3905bcffu, + 0xb4b4e29au, 0xb30370beu, 0x32f92c9fu, 0xb230632eu, 0xb3705c84u, 0x33b7c2d3u, 0xb3244630u, 0x335151f1u, + 0xb43479eeu, 0xb3b3b688u, 0xb15c229cu, 0xb35bb61eu, 0x32db8125u, 0xb44b88f5u, 0x343d3cbcu, 0xb4aa4c57u, + 0xb3f9f62au, 0xb41cddc7u, 0x346e9761u, 0xb45fdca9u, 0xb453d1e6u, 0xb42c2010u, 0x342717acu, 0x318419edu, + 0xb37b1e09u, 0xb418df64u, 0x3447a2d7u, 0xb4b2ef8bu, 0xb4696b4fu, 0xb51c805eu, 0xb531abb7u, 0xb443aaedu, + 0xb50fee1fu, 0xb50e692eu, 0xb3b8348du, 0xb3c43ba1u, 0xb51e072bu, 0xb391a093u, 0xb4745110u, 0xb516570eu, + 0xb3182aa3u, 0xb45251d7u, 0xb538f56fu, 0xb41cf81du, 0x32f5b2dbu, 0xb4b26ba4u, 0xb5633e3au, 0x338b06f3u, + 0xb30d1584u, 0xb4c2907eu, 0xb47d8a54u, 0x35905871u, 0xb50d6547u, 0xb49547d5u, 0xb531091au, 0xb4ff9ea0u, + 0xb54a4ac0u, 0xb3e96871u, 0xb517c367u, 0x3498c07du, 0xb569329bu, 0xb4093107u, 0x37cf19d6u, 0x38f4ad88u, + 0xb90514e9u, 0xb88519a9u, 0xb3b77df7u, 0xb377f7cbu, 0xb404effbu, 0xb400dffbu, 0xb4547edeu, 0xb4238319u, + 0xb4a5f40cu, 0xb41fc91bu, 0xb4153e04u, 0xb435038eu, 0xb3262863u, 0xb43ec3deu, 0xb482050cu, 0x3343ef9du, + 0x3490c3b9u, 0x33a6186du, 0x3500df6bu, 0xb35e014eu, 0x33c3ef59u, 0x340c6e93u, 0x34bc7949u, 0xb45d996du, + 0x34bc7949u, 0x348b7cd6u, 0xb3e722dau, 0x34b2bad6u, 0x34b2bad6u, 0x34bc7a10u, 0x3481bf11u, 0x33f1e840u, + 0xb4a9859fu, 0x3443eeadu, 0xb4261953u, 0xb4261953u, 0xb4261953u, 0xb4b50434u, 0xb5040955u, 0x2ca6fff6u, + 0xb4b50459u, 0x3443eeadu, 0x350b7dabu, 0xb4b50434u, 0xb4b50434u, 0x350b7dabu, 0x3443eeadu, 0xb51fc815u, + 0x3443eeadu, 0xb4ec83b4u, 0x34b503ccu, 0x3443eeadu, 0xb535051au, 0x33a9df59u, 0x349976d6u, 0xb48a8c2au, + 0xabd0e724u, 0xb550c3dau, 0xb4261953u, 0xb51fc815u, 0xb4ec83b4u, 0xb5454615u, 0xb51bbfdau, 0xb880561cu, + 0xb8f6eb74u, 0x39046e10u, 0x3493d79du, 0xb2c6024au, 0xb31f4e09u, 0x330e4ebfu, 0x338ecc3eu, 0xb36bd643u, + 0xb2781c6du, 0xb407e874u, 0x343c7cedu, 0xb341c3a5u, 0x33b46434u, 0xb3ec1134u, 0x3379d046u, 0x338f177bu, + 0x3459dd65u, 0xb1d80a4cu, 0xb41f9b0du, 0x3298ab51u, 0x32cb21dau, 0x33fdc3e1u, 0x3346e90au, 0x33479e25u, + 0xb486069eu, 0x3272dd2bu, 0xb438cce9u, 0x3358af88u, 0xb4ffa08bu, 0x33b915e9u, 0x336babbdu, 0xb4789402u, + 0x3479ae93u, 0x33c4328eu, 0xb520822cu, 0x33b61c97u, 0xb50331bbu, 0xb4259969u, 0x3421ab1au, 0x34db659fu, + 0xb5192e7eu, 0x34851528u, 0x3512e020u, 0x346f3309u, 0x342a8dc8u, 0x3526f04fu, 0xb43adbfau, 0xb4e16329u, + 0xb408a629u, 0xb57325fcu, 0xb4d1dd86u, 0x348df713u, 0xb514d6f8u, 0x34a05ef4u, 0xb4690515u, 0xb4f47840u, + 0x340de281u, 0xb5353c8eu, 0x35b639d4u, 0xb3d83149u, 0xb517663fu, 0xb32542beu, 0x35d07de8u, 0xb5cc27a6u, + 0xb7bf313au, 0x38f7119fu, 0xb903e08eu, 0x3883d1d9u, 0xb18ea80au, 0x32458effu, 0xb2e7a6b1u, 0xb2887393u, + 0xb30ef7d1u, 0xb2b87eb3u, 0x324c28b7u, 0xb392c60cu, 0xb3b25d3au, 0xb3609e1fu, 0xb346c823u, 0xb3b12679u, + 0xb2faa96fu, 0x33fd86c3u, 0x333fa63fu, 0xb311bb9eu, 0x32109536u, 0xb382c983u, 0xb2b629f7u, 0xb2a10358u, + 0x332d58aau, 0xb413aabcu, 0xb20887d0u, 0xb2e51a14u, 0x341037fcu, 0xb250174du, 0x341325ceu, 0xb38bee53u, + 0xb20bc121u, 0xb312fda5u, 0xb42b2952u, 0xb4605060u, 0x325df962u, 0xb5046b51u, 0xb5348c19u, 0x337becdau, + 0x3316c636u, 0xb4931b7bu, 0xb5066263u, 0x320305e3u, 0xb4656ff4u, 0x339d7eb6u, 0x33d636c7u, 0x34a2523au, + 0xb33ecc1au, 0x345edb9cu, 0xb40f90aau, 0xb312d839u, 0xb414c082u, 0xb380bdd9u, 0xb40854c4u, 0xb40c357du, + 0xb3d2cfa1u, 0xb415f542u, 0xb28c1474u, 0x33f55294u, 0xb409228cu, 0x3422d76fu, 0x34dd9524u, 0x34d62d84u, + 0xb39898d0u, 0x387ffa2fu, 0xb8f89b6fu, 0x3903517bu, 0xb4885858u, 0x32406b96u, 0xb1853ff5u, 0x32f94c32u, + 0x32a55957u, 0x32e5198au, 0x32fec9bau, 0xb0d58582u, 0x32623404u, 0x31e4e6ebu, 0x330b0a97u, 0xb28ef776u, + 0x339c6379u, 0x33a0eb25u, 0x32596c49u, 0x32e4cb6au, 0xb39b9a0au, 0xb309d4feu, 0x34087502u, 0xb2787bf9u, + 0x34893ed1u, 0x34857255u, 0x3415fd2fu, 0xb2e1f5b4u, 0x33e9237eu, 0xb445808fu, 0x33401914u, 0xb38058b7u, + 0x3344d8ddu, 0x34131194u, 0x34169cacu, 0xb33bb66au, 0x34b9f982u, 0xb21fc8c0u, 0x336eecadu, 0x351639a1u, + 0xb378873cu, 0xb202609cu, 0x34187b75u, 0xb414abecu, 0xb4466f83u, 0x34342e95u, 0x3290090eu, 0xb3df7f3bu, + 0x33be8430u, 0x33ab6e2eu, 0x339a14b3u, 0x34611a2bu, 0x33883b1bu, 0xb3d4ee78u, 0x3482975fu, 0x3334253au, + 0xb4c514adu, 0x330018fdu, 0x33774ea9u, 0x34a369a5u, 0xb4716b95u, 0x350e501du, 0xb362799fu, 0x341b01f7u, + 0xb325bb12u, 0x33361205u, 0x37a8fe46u, 0x38fa0fe3u, 0xb902d559u, 0xb882d08bu, 0xb2370e58u, 0x333bde33u, + 0x32f03fccu, 0x33be2115u, 0x33854b41u, 0x33f3ee34u, 0xb1cb9d06u, 0x316f0353u, 0x333499d5u, 0x341a4989u, + 0x32006314u, 0x345ba8eeu, 0x33e3abe7u, 0x34200421u, 0x345373e3u, 0x3448e5dau, 0x3317b201u, 0x341c1242u, + 0x341c81a6u, 0x33835c7eu, 0x3475f5d1u, 0xb305f7dau, 0xb2c79ea1u, 0xb289f4dau, 0x34188d2eu, 0x33917af1u, + 0x3484d8d6u, 0x34b99dffu, 0x341419cbu, 0x34cdaef4u, 0xb4838846u, 0xb46277b8u, 0xb4c42f43u, 0x340ae112u, + 0xb4489355u, 0xb48cd5a0u, 0x33ab9a65u, 0x3437ea86u, 0x34377b3eu, 0x34de3be6u, 0x34748a90u, 0x34bbad04u, + 0x35080e13u, 0x34c453e5u, 0x35084529u, 0x3460a987u, 0x335716cbu, 0xb20496aeu, 0xb372eb99u, 0x33706507u, + 0x34211a03u, 0x34a3068bu, 0x351956c7u, 0x34931058u, 0x34c8d4e1u, 0x34ec1fe6u, 0x350325b5u, 0x353415e2u, + 0x353ff416u, 0x35126378u, 0x3437ad2bu, 0xb87ee1b7u, 0xb8f87c8au, 0x39025d17u, 0x34463837u, 0xb2cd826au, + 0x326f5cb0u, 0xb3715564u, 0xb2af4d77u, 0xb31e6001u, 0x311ee5f3u, 0x331cb164u, 0xb1b33767u, 0xb3c9e0d2u, + 0xb3524fb0u, 0xb2c408d2u, 0xb3d20728u, 0xb3980e1au, 0xb37b5f37u, 0xb2dce5e1u, 0xb47aa957u, 0x3364b6f5u, + 0xb3c257e8u, 0x32e86761u, 0x336dab5bu, 0x330e292fu, 0xb3ea5174u, 0x340c2f55u, 0xb2e72d48u, 0x33987517u, + 0xb3adbb12u, 0x348c54cdu, 0x329d3a8bu, 0xb43783f5u, 0x347be195u, 0xb37970c5u, 0xb4c2ec0fu, 0x342b7b05u, + 0x33b4a5c1u, 0x349088eeu, 0xb4034fd0u, 0xb3926bbfu, 0x3417aafau, 0x340c326fu, 0xb43a002fu, 0x34b2128fu, + 0x31c91081u, 0xb4c9ec12u, 0xb4d3d54bu, 0xb45f9f3du, 0x34271639u, 0x3458cdfbu, 0x351b3cc5u, 0x338ed4b6u, + 0xb4b25eaeu, 0xb4345dfcu, 0xb4fef94bu, 0xb4539541u, 0xb3674755u, 0xb4a38145u, 0xb45a4956u, 0x343e31b6u, + 0x344c5411u, 0xb40076e9u, 0xb37a1e84u, 0xb4bbc589u, 0xb78eb078u, 0x38fa6dfau, 0xb901f7fau, 0x3881ed37u, + 0xb1dc86f8u, 0xb238806bu, 0xb2ba63afu, 0x324cb2b7u, 0x32a288c1u, 0x321e13e0u, 0xb34cb85eu, 0xb386f3c5u, + 0xb1dc4ce4u, 0x317d83a3u, 0x3320701fu, 0x32fdb5beu, 0x334cf09au, 0x326335f2u, 0xb396bb02u, 0xb23b46afu, + 0xb3b88d81u, 0x3396e9edu, 0xb32441b8u, 0xb2e7db0cu, 0xb46315cfu, 0xb38cb920u, 0x32284b1au, 0x3433abfdu, + 0xb2a8b96au, 0x3247ad20u, 0xb3f0727eu, 0x32c8f689u, 0xb2d0fb0cu, 0xb3c87235u, 0xb35329f3u, 0x340d87d8u, + 0x338eaa7bu, 0xb490388eu, 0x327ca39du, 0x33adf11bu, 0x31c51ef8u, 0x34271680u, 0xb40619f4u, 0xb44032fbu, + 0xb1083d3eu, 0x348f6252u, 0x349bf331u, 0xb419efe7u, 0x34552c16u, 0x33650911u, 0x31526bf5u, 0xb48602e4u, + 0xb3abf14cu, 0xb40437fcu, 0xb3a44b86u, 0xb4188a11u, 0x32af52c4u, 0xb3c214c2u, 0xb0a55050u, 0xb312a127u, + 0xb3ba8491u, 0xb464c8c2u, 0x345a761bu, 0x34806b14u, 0x33be2b40u, 0x3881c763u, 0xb8fcc619u, 0x39019108u, + 0xb43f17d1u, 0xb263a97cu, 0xb208c6fau, 0x32c5c358u, 0xb34beb73u, 0xb3738135u, 0xb33ad585u, 0xb29ed85cu, + 0xb3273b19u, 0x338b22a1u, 0xb34979a6u, 0xb434cf0bu, 0x33b87668u, 0xb3e9e93bu, 0xb3194f99u, 0x34342048u, + 0xb45bfaf5u, 0x339f9a8bu, 0x3409094fu, 0xb3a0aceau, 0xb41e4395u, 0xb3aab407u, 0x32a63890u, 0xb3331da0u, + 0xb46f011du, 0x337f32eeu, 0xb414dd85u, 0x33899149u, 0x3317cec6u, 0xb3ff8f9au, 0xb2e63b9du, 0xb4a12433u, + 0xb4601bb9u, 0x33b14a14u, 0xb489bc79u, 0xb30b26f1u, 0xb4d7a2d4u, 0xb3e4bbedu, 0x33e509d4u, 0x3384a6dcu, + 0xb4973e62u, 0xb41e0e53u, 0xb426652bu, 0xb4960e0bu, 0xb3f7ca62u, 0xb4d1009cu, 0xb45720b4u, 0xb4c5e4dfu, + 0xb4be98e5u, 0xb4885072u, 0xb508ce2bu, 0xb3649fcbu, 0x34c1a3d9u, 0xb51f78acu, 0xb5038f78u, 0xb40d0c1fu, + 0xb37062aau, 0x3486b395u, 0xb2e5654du, 0xb2bf17dau, 0x33bf5e9bu, 0xb465d5f8u, 0x375efb7bu, 0x38fc23edu, + 0xb90145a1u, 0xb881475cu, 0xb2dd13d0u, 0xb2db50c3u, 0x3209a31fu, 0x319fbe52u, 0xb3874971u, 0xb3126abdu, + 0xb3dda416u, 0xb41c6f2eu, 0xb227d023u, 0xb27d6c20u, 0xb36d3027u, 0x33ea597fu, 0xb3cdbf39u, 0x335db18eu, + 0xb307c967u, 0xb496d486u, 0xb43016adu, 0xb48e4a17u, 0xb3bf76eeu, 0xb510f187u, 0xb5037f3au, 0xb494a642u, + 0xb499b6d8u, 0xb4cea08du, 0xb4a70e9fu, 0xb5309f6cu, 0xb509891du, 0xb533c3deu, 0xb4e89f6au, 0xb4d3fa20u, + 0xb3faf384u, 0xb48b7307u, 0x3360bedau, 0xb408934du, 0xb46a898bu, 0xb30304afu, 0xb53322b9u, 0xb464ede5u, + 0xb4c84f9au, 0xb50ea9f4u, 0xb5031579u, 0x3493d71fu, 0xb5096793u, 0xb4d7b2ffu, 0xb52c08c3u, 0xb3d97502u, + 0xb35564feu, 0xb3694707u, 0xb15b538eu, 0xb3823c1au, 0xb4f2846du, 0xb5251d83u, 0xb58c04e1u, 0xb4c155aeu, + 0xb4bce35fu, 0x3499651fu, 0xb4a71185u, 0xb4862a93u, 0x31d41dffu, 0xb4948ecdu, 0x33bd2564u, 0xb8830fcfu, + 0xb8fefd75u, 0x3900f417u, 0x33fd4254u, 0xaf539ed8u, 0xb1de4187u, 0xb20141b5u, 0xb22728aau, 0x338792d7u, + 0x33d8ba52u, 0x31d4dd3au, 0x33914cbbu, 0x32db4b84u, 0x340a748eu, 0x3445891du, 0x340f6388u, 0x33ae6918u, + 0x341c6fc4u, 0x31dc4876u, 0x343d8ce7u, 0xb2ab9cb2u, 0xb2b61b9au, 0x3446429fu, 0x3419179au, 0x3485fa7eu, + 0xb0f20043u, 0x342527feu, 0x34806180u, 0x34af1edcu, 0x3362e73cu, 0x3496dd3eu, 0xb414d3dbu, 0xb35d6804u, + 0xb41d6317u, 0x34fbf312u, 0x33faa020u, 0xb40359c5u, 0x34eb3d5eu, 0x33305bfbu, 0x330b3a78u, 0x3488e447u, + 0xb44143b2u, 0x3445ddd5u, 0x348d9d1cu, 0xb2d81766u, 0x351c818du, 0x3545233cu, 0x339334d8u, 0x344f0180u, + 0x34ed2dcdu, 0x34a8ba4fu, 0x34252957u, 0x35075933u, 0x34883192u, 0x350ab386u, 0x33b37cc1u, 0x34472908u, + 0x32df95feu, 0x3350425eu, 0x353dec51u, 0xb4f86136u, 0x347ed5f0u, 0xb4217045u, 0xb429c618u, 0xb37a5434u, + 0xb72c4d73u, 0x38ff5bbdu, 0xb900b811u, 0x3880b385u, 0x325dd044u, 0x321545a6u, 0xb175e002u, 0xb2e28f99u, + 0x31f8e37eu, 0x337d4e74u, 0x328af04du, 0xb2f1f148u, 0xb2b559b4u, 0xb19e82fdu, 0xb34afb66u, 0x32397538u, + 0xb2a0f79cu, 0xb3ac1976u, 0x32c8c41au, 0x30a1144du, 0x339549f5u, 0x337ff2a3u, 0x31464802u, 0x31caf026u, + 0x34282f81u, 0x3401779fu, 0x343bb655u, 0x332eee81u, 0x3486c6fbu, 0x3477aeeeu, 0xb2abc9fcu, 0x3404d2ccu, + 0x319377b5u, 0x33f2738du, 0x339169eeu, 0x332fbb29u, 0x3302e83au, 0x3468986cu, 0x34829062u, 0x348dc310u, + 0x33c4cc5bu, 0x33d0ca5du, 0x3402dcc0u, 0x322cca59u, 0xb42b135au, 0xb34609d7u, 0xb21e0fb0u, 0x346ce1c3u, + 0x33bb9155u, 0xb33b8018u, 0x34066373u, 0x3358b8ddu, 0x33ed6719u, 0xb2dfeb29u, 0x3492f071u, 0x34241a15u, + 0xb2e5ad4du, 0x34096ba7u, 0x33c51b1bu, 0xb3e22aaau, 0x3494de0eu, 0x3302ad89u, 0xb212670fu, 0x34559103u, + 0x340ab240u, 0x3882a36cu, 0xb8feba2au, 0x39007b33u, 0xb3ed4e37u, 0x31efae97u, 0xb1840440u, 0xb14de0feu, + 0xb3307f92u, 0xb28ea443u, 0xb3324a2du, 0x32084bf6u, 0x32c57ad4u, 0xb27682dbu, 0x32417a3au, 0xb3ccd1afu, + 0xb2c79d80u, 0xb360a889u, 0x32a22540u, 0x30493d11u, 0xb2d4b2b6u, 0xb38a63b7u, 0x31d9d904u, 0xb32a3871u, + 0xb41f977bu, 0xb3056171u, 0xb3bac517u, 0xb38d7112u, 0xb4457e45u, 0x326ec9c5u, 0x339b1306u, 0xb3e86688u, + 0x343e2e64u, 0x3322700fu, 0xb41f80ccu, 0xb30d0a2du, 0xb32a201eu, 0xb49ba8cbu, 0x33cec611u, 0xb42fc5b3u, + 0x345e5a99u, 0x34654e01u, 0xb465a175u, 0xb42d81d9u, 0xb25431dau, 0x3295e3b1u, 0x3457a92cu, 0x32951bb6u, + 0x32e9a398u, 0xb47e8764u, 0x33ec25e2u, 0xb4ea9bfeu, 0xb19cdb92u, 0xb440fb8du, 0xb3b578d7u, 0x34c3f769u, + 0xb4ac0a64u, 0xb44e862eu, 0xb417882eu, 0x340596abu, 0x3424fa9fu, 0xb0fdabfeu, 0xb40c1894u, 0xb49d9f9du, + 0xb4688364u, 0xb4f9d1c9u, 0x36fc40e2u, 0x38ff3717u, 0xb9005544u, 0xb8805b20u, 0xb25e95edu, 0x3311a8e9u, + 0x32f47b79u, 0x331e692cu, 0xb0ea52dfu, 0x32f24f84u, 0xb33003adu, 0x32cfaf63u, 0x34174de4u, 0x3294702bu, + 0x3389ce3au, 0x33c9e1b2u, 0x3380b496u, 0x335aeed3u, 0x3427e253u, 0xb2ab3d93u, 0x34b13d47u, 0x338934d9u, + 0x342e9e37u, 0xb350ba4eu, 0x33a59a0du, 0xb3584a39u, 0x33b31bc8u, 0xb0885cb1u, 0x339f07ffu, 0x34b06a41u, + 0x3461de37u, 0x32bbcdbbu, 0x34997200u, 0x33d3b7efu, 0xb3647465u, 0xb4c008edu, 0xb4c4908au, 0xb47eaf48u, + 0xb3991814u, 0x3451f1afu, 0x33b38bfau, 0xb37ad965u, 0x32ab02b0u, 0xb48eb00eu, 0xb4da2c0au, 0xb50a38cdu, + 0x3438c81cu, 0x3389cf6fu, 0x3484a51au, 0xb35e51f5u, 0x34976ea3u, 0xb451477cu, 0x33be04e9u, 0x32ad4b5au, + 0x350db601u, 0x343acafdu, 0x351db1c9u, 0xb4744b47u, 0x34bf6313u, 0xb4a1c586u, 0x351ae4dau, 0x33989ae4u, + 0xb413634du, 0xb3d2c2f0u, 0xb474822du, 0xb881d698u, 0xb8ff7efau, 0x39002abeu, 0x33212c2au, 0xb15711ffu, + 0x32758638u, 0xb27879c8u, 0xb1a04812u, 0xb2fc3ce4u, 0xb1f5bc6du, 0xb0c41c95u, 0xb3ed671bu, 0xb2e75f2fu, + 0xb062dd3eu, 0xb3e23b03u, 0xb2a706feu, 0x3352c739u, 0xb3c6946du, 0xb3d70eeau, 0x338304fdu, 0x3351864fu, + 0x32e7fde8u, 0xb439a37au, 0x33f5d934u, 0xb4053986u, 0xb4783515u, 0xb3bff708u, 0xb4244b70u, 0xb3f5e6ccu, + 0xb4b0ad4cu, 0x319a2633u, 0xb3e77efeu, 0xb4a1256au, 0xb44bc74du, 0xb4aa12bcu, 0xb452848cu, 0x30c05457u, + 0xb40e0d6eu, 0xb42ab4c7u, 0xb287cfdbu, 0xb473f713u, 0x34fb754eu, 0xb439bd81u, 0x32ef6e9au, 0xb3155b12u, + 0x338af511u, 0x333ae676u, 0xb397240bu, 0xb42d2b48u, 0xb4c3457du, 0xb416358bu, 0x33ce208du, 0xb437533fu, + 0xb526a59du, 0xb417fa98u, 0xb178a38cu, 0xb4eab740u, 0xb28515a8u, 0xb4c13a8bu, 0xb50646b9u, 0xb5389b0du, + 0x34206455u, 0xb559b249u, 0xb5661756u, 0xb499ca63u, 0xb695e1ebu, 0x38fee0c6u, 0xb9001a42u, 0x38800c2bu, + 0x31d9e48fu, 0xb1179514u, 0xb28caac9u, 0xb225e545u, 0xb2115592u, 0xb32a9d3eu, 0xb3a54e9fu, 0xb2a586dcu, + 0x32558b08u, 0xb38fcfc1u, 0xb3e706c6u, 0x32975537u, 0xb41aa750u, 0xb317c5b2u, 0xb2966bfdu, 0xb3f8beecu, + 0xb39d4e9fu, 0xb1898bbeu, 0x341ad78fu, 0x31ad7912u, 0xb390beecu, 0xb13e2d42u, 0xb41a55eau, 0xb3ebbca9u, + 0xb3a39678u, 0x33c9c236u, 0xb450a871u, 0xb38b85a4u, 0x33565bf8u, 0xb3a7cfc1u, 0xb352770eu, 0xb3e0728cu, + 0xb4f3dca3u, 0xb429716cu, 0xb3fb3944u, 0xb383abd4u, 0x32cb08d7u, 0xb35098b7u, 0xb417cc5eu, 0x32f08fc6u, + 0xb470a871u, 0xb40bcc5eu, 0xb447f28eu, 0xb49c3794u, 0xb425e902u, 0xb4be7cebu, 0xb4ce5286u, 0xb47741b0u, + 0xb4bad7ddu, 0x347033a2u, 0xb45fa62eu, 0xb4be6f2au, 0x3313f275u, 0xb422dd33u, 0xb3ec74cfu, 0xb467ef29u, + 0xb42a73b0u, 0xb472cfc3u, 0xb4d678b6u, 0xb421e902u, 0xb4b6e6c0u, 0x3882c573u, 0xb8ffe6d9u, 0x39000518u, + 0xb338aa5au, 0x333cd6c6u, 0xb33e3488u, 0xb410e20bu, 0xb472c909u, 0xb39ccf64u, 0xb4d82758u, 0xb24ed590u, + 0xb3e3f104u, 0xb44a7300u, 0x344f81d7u, 0xb4cac4a9u, 0x328be180u, 0xb38dfc9cu, 0xb42c8977u, 0xb488ca50u, + 0xb4aeff3au, 0xb4e7cd24u, 0xb50d495cu, 0xb52043d1u, 0x34862a74u, 0x342749c0u, 0xb56603dau, 0xb5814568u, + 0xb3f88e20u, 0xb43ba038u, 0x353480fdu, 0xb4cfcbf0u, 0x35c0edb4u, 0x34d75992u, 0x34b78cfeu, 0xb5e0483bu, + 0x342463aau, 0xb5fcbf30u, 0xb318caa0u, 0xb60652bdu, 0xb484f528u, 0x35d6ba91u, 0xb4c496d0u, 0xb50b54c8u, + 0xb51b3d12u, 0xb52b235cu, 0xb54de2a6u, 0x358de35du, 0xb57a47e6u, 0x3562d0d0u, 0xb59672bdu, 0x353cbbe6u, + 0xb5afcd87u, 0x350a1652u, 0xb5c5fc27u, 0x34bb7226u, 0x3482643cu, 0x34456f50u, 0xb5fbcde6u, 0xb2f242c0u, + 0xb605da18u, 0xb41d7aa8u, 0xb4a0ec94u, 0xb4c0d928u, 0xb4f9a712u, 0xb5131228u, 0x365a8c42u, 0x38ff3196u, + 0xb9000000u, 0xb8804000u, 0x00000000u, 0x00000000u, 0xb4800000u, 0x00000000u, 0x00000000u, 0x00000000u, + 0x00000000u, 0x00000000u, 0x35800000u, 0x35800000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, + 0x35800000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, + 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, + 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, + 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, + 0x00000000u, 0x36800000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, + 0x00000000u, 0x36800000u, 0x00000000u, 0x00000000u, 0x36800000u, 0x00000000u, 0x36800000u, 0xb8800000u, + 0xb9000000u, +}; + +inline constexpr std::uint32_t kImag[] = { + 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, + 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, + 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, + 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, + 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, + 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, + 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, + 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, + 0x00000000u, 0xb3cceb7au, 0x3f61fd32u, 0x3f747642u, 0x3f747640u, 0x3f74763eu, 0x3f74763eu, 0x3f747639u, + 0x3f74763cu, 0x3f747634u, 0x3f747635u, 0x3f747630u, 0x3f747637u, 0x3f747635u, 0x3f747630u, 0x3f747627u, + 0x3f747626u, 0x3f747628u, 0x3f747623u, 0x3f74762eu, 0x3f747628u, 0x3f747623u, 0x3f747626u, 0x3f747629u, + 0x3f74761bu, 0x3f747626u, 0x3f747629u, 0x3f747614u, 0x3f747617u, 0x3f747629u, 0x3f74761cu, 0x3f747617u, + 0x3f747602u, 0x3f747614u, 0x3f7475ffu, 0x3f747629u, 0x3f747605u, 0x3f7475ffu, 0x3f74761au, 0x3f7475f6u, + 0x3f7475efu, 0x3f74761au, 0x3f747605u, 0x3f747610u, 0x3f74760au, 0x3f747616u, 0x3f747600u, 0x3f74760cu, + 0x3f747606u, 0x3f747600u, 0x3f74760au, 0x3f7475f6u, 0x3f7475f0u, 0x3f7475fcu, 0x3f747626u, 0x3f7475f0u, + 0x3f74761au, 0x3f7475f6u, 0x3f7475e0u, 0x3f7475dcu, 0x3f7475e6u, 0x3f7475f2u, 0x3f7475dcu, 0x3f7475f6u, + 0x3f60aeebu, 0xbca30128u, 0xb29af392u, 0xbe928cb9u, 0xbe594cb1u, 0xbe594cafu, 0xbe594cb1u, 0xbe594cafu, + 0xbe594cb2u, 0xbe594cafu, 0xbe594cb1u, 0xbe594cb2u, 0xbe594cadu, 0xbe594cb3u, 0xbe594cb9u, 0xbe594cadu, + 0xbe594cafu, 0xbe594cb0u, 0xbe594cb9u, 0xbe594cafu, 0xbe594cb2u, 0xbe594cacu, 0xbe594cb1u, 0xbe594ca9u, + 0xbe594cb0u, 0xbe594ca3u, 0xbe594caeu, 0xbe594cb2u, 0xbe594ca9u, 0xbe594cb3u, 0xbe594cabu, 0xbe594caau, + 0xbe594cb4u, 0xbe594cabu, 0xbe594ca8u, 0xbe594cadu, 0xbe594ca5u, 0xbe594ca9u, 0xbe594c9fu, 0xbe594cafu, + 0xbe594cb7u, 0xbe594c9eu, 0xbe594ca6u, 0xbe594ca0u, 0xbe594ca7u, 0xbe594c9au, 0xbe594ca6u, 0xbe594ca0u, + 0xbe594ca3u, 0xbe594c9eu, 0xbe594cacu, 0xbe594cadu, 0xbe594ca8u, 0xbe594cbdu, 0xbe594ca2u, 0xbe594cb6u, + 0xbe594cb4u, 0xbe594ca3u, 0xbe594ca6u, 0xbe594cacu, 0xbe594cc2u, 0xbe594cbbu, 0xbe594cacu, 0xbe594cafu, + 0xbe594cb8u, 0xbe943f30u, 0x3c5944f0u, 0x30be92b4u, 0xbd929762u, 0xbd594ca6u, 0xbd594ca3u, 0xbd594ca9u, + 0xbd594ca2u, 0xbd594c9au, 0xbd594c90u, 0xbd594c90u, 0xbd594c88u, 0xbd594c99u, 0xbd594c93u, 0xbd594c6au, + 0xbd594c88u, 0xbd594c88u, 0xbd594c87u, 0xbd594c61u, 0xbd594c90u, 0xbd594c61u, 0xbd594ca7u, 0xbd594c72u, + 0xbd594c5fu, 0xbd594c6au, 0xbd594c67u, 0xbd594c65u, 0xbd594c71u, 0xbd594c81u, 0xbd594c93u, 0xbd594c5au, + 0xbd594c72u, 0xbd594cb0u, 0xbd594c5au, 0xbd594c60u, 0xbd594c28u, 0xbd594c4au, 0xbd594c84u, 0xbd594c4au, + 0xbd594c41u, 0xbd594c1cu, 0xbd594c5eu, 0xbd594c75u, 0xbd594c86u, 0xbd594c08u, 0xbd594c66u, 0xbd594c15u, + 0xbd594c33u, 0xbd594c4au, 0xbd594bf1u, 0xbd594c3fu, 0xbd594bf2u, 0xbd594c85u, 0xbd594bffu, 0xbd594bddu, + 0xbd594be6u, 0xbd594bccu, 0xbd594befu, 0xbd594cccu, 0xbd594c1eu, 0xbd594c96u, 0xbd594c3cu, 0xbd594bf0u, + 0xbd594c5eu, 0xbd594bbfu, 0xbd8ef1ccu, 0xbbd930d8u, 0xb11b71bau, 0x3b7905f5u, 0xbcadd6f4u, 0xbcadd6f2u, + 0xbcadd71du, 0xbcadd6fbu, 0xbcadd701u, 0xbcadd6e9u, 0xbcadd71au, 0xbcadd6e7u, 0xbcadd6d7u, 0xbcadd708u, + 0xbcadd742u, 0xbcadd708u, 0xbcadd6eeu, 0xbcadd72eu, 0xbcadd71au, 0xbcadd773u, 0xbcadd737u, 0xbcadd728u, + 0xbcadd769u, 0xbcadd73du, 0xbcadd746u, 0xbcadd773u, 0xbcadd6edu, 0xbcadd7d4u, 0xbcadd7a8u, 0xbcadd6b5u, + 0xbcadd753u, 0xbcadd71au, 0xbcadd794u, 0xbcadd717u, 0xbcadd6e2u, 0xbcadd6d7u, 0xbcadd768u, 0xbcadd6fau, + 0xbcadd668u, 0xbcadd73bu, 0xbcadd7bdu, 0xbcadd70eu, 0xbcadd751u, 0xbcadd78au, 0xbcadd779u, 0xbcadd7edu, + 0xbcadd6e5u, 0xbcadd65eu, 0xbcadd6fdu, 0xbcadd632u, 0xbcadd6b1u, 0xbcadd68fu, 0xbcadd6ffu, 0xbcadd5c1u, + 0xbcadd78eu, 0xbcadd676u, 0xbcadd6a0u, 0xbcadd5e0u, 0xbcadd6a6u, 0xbcadd72du, 0xbcadd637u, 0xbcadd6cdu, + 0xbcadd63du, 0xbcadd660u, 0xbcadd60du, 0x3bd35cfau, 0x3badb473u, 0xb1d6b746u, 0x3c330560u, 0xbc2dd6eeu, + 0xbc2dd6e5u, 0xbc2dd705u, 0xbc2dd6fdu, 0xbc2dd6e4u, 0xbc2dd6e4u, 0xbc2dd6ecu, 0xbc2dd6e2u, 0xbc2dd708u, + 0xbc2dd70fu, 0xbc2dd6ebu, 0xbc2dd6d8u, 0xbc2dd78au, 0xbc2dd649u, 0xbc2dd719u, 0xbc2dd6fbu, 0xbc2dd6adu, + 0xbc2dd707u, 0xbc2dd78bu, 0xbc2dd784u, 0xbc2dd6cdu, 0xbc2dd712u, 0xbc2dd760u, 0xbc2dd71du, 0xbc2dd5c1u, + 0xbc2dd735u, 0xbc2dd7bfu, 0xbc2dd721u, 0xbc2dd7a8u, 0xbc2dd6dau, 0xbc2dd6e3u, 0xbc2dd73du, 0xbc2dd6e4u, + 0xbc2dd705u, 0xbc2dd7dbu, 0xbc2dd71cu, 0xbc2dd74du, 0xbc2dd74fu, 0xbc2dd6f2u, 0xbc2dd660u, 0xbc2dd5d3u, + 0xbc2dd56au, 0xbc2dd77cu, 0xbc2dd601u, 0xbc2dd7e3u, 0xbc2dd6cfu, 0xbc2dda08u, 0xbc2dd669u, 0xbc2dd7c5u, + 0xbc2dd838u, 0xbc2dd853u, 0xbc2dd5c6u, 0xbc2dd837u, 0xbc2dd60au, 0xbc2dd664u, 0xbc2dd5d6u, 0xbc2dd69cu, + 0xbc2dd5c1u, 0xbc2dd741u, 0xbc2dd6d3u, 0xbc2dd635u, 0x3c2b966au, 0xbb823b5cu, 0xb13fcb7cu, 0xbbe56e1du, + 0xbbc6ac77u, 0xbbc6ac37u, 0xbbc6ac6bu, 0xbbc6aca1u, 0xbbc6acb8u, 0xbbc6aca9u, 0xbbc6aca8u, 0xbbc6ad58u, + 0xbbc6ac18u, 0xbbc6ad49u, 0xbbc6adaeu, 0xbbc6ad1eu, 0xbbc6acb7u, 0xbbc6ad07u, 0xbbc6aca8u, 0xbbc6ac2fu, + 0xbbc6abd9u, 0xbbc6ac3eu, 0xbbc6ac7eu, 0xbbc6ac6fu, 0xbbc6ad40u, 0xbbc6ad2du, 0xbbc6ad1cu, 0xbbc6acfbu, + 0xbbc6ade9u, 0xbbc6aca7u, 0xbbc6ad6bu, 0xbbc6ad1du, 0xbbc6add3u, 0xbbc6ad20u, 0xbbc6abdau, 0xbbc6ad74u, + 0xbbc6af8au, 0xbbc6ae77u, 0xbbc6ae66u, 0xbbc6ae40u, 0xbbc6af36u, 0xbbc6aed3u, 0xbbc6ad5du, 0xbbc6ad76u, + 0xbbc6aeedu, 0xbbc6addbu, 0xbbc6ad1bu, 0xbbc6b006u, 0xbbc6ae57u, 0xbbc6ad4eu, 0xbbc6af4fu, 0xbbc6aec7u, + 0xbbc6ac18u, 0xbbc6aec5u, 0xbbc6adc9u, 0xbbc6ae72u, 0xbbc6af77u, 0xbbc6b208u, 0xbbc6aba8u, 0xbbc6ad63u, + 0xbbc6ae36u, 0xbbc6ad2cu, 0xbbc6ac66u, 0xbbc6b033u, 0xbbc6aee1u, 0xbc0d0f5bu, 0x3b5f1949u, 0xb08b414eu, + 0xbc26e3ffu, 0xbb785749u, 0xbb785776u, 0xbb785679u, 0xbb785722u, 0xbb78567eu, 0xbb7856a9u, 0xbb785694u, + 0xbb785700u, 0xbb7858c1u, 0xbb78562bu, 0xbb7858c6u, 0xbb785737u, 0xbb78589du, 0xbb785760u, 0xbb785876u, + 0xbb7858e0u, 0xbb7855ddu, 0xbb78578du, 0xbb7857adu, 0xbb7858dfu, 0xbb78562au, 0xbb78585fu, 0xbb78549au, + 0xbb785960u, 0xbb785827u, 0xbb7855afu, 0xbb785b66u, 0xbb785698u, 0xbb785919u, 0xbb7858d7u, 0xbb785440u, + 0xbb785a03u, 0xbb785619u, 0xbb785853u, 0xbb785423u, 0xbb78595au, 0xbb785a14u, 0xbb7851ccu, 0xbb785c4au, + 0xbb785092u, 0xbb78532eu, 0xbb785be2u, 0xbb78543du, 0xbb78570au, 0xbb785bfau, 0xbb785ba8u, 0xbb785077u, + 0xbb785241u, 0xbb78543bu, 0xbb7851ffu, 0xbb78551cu, 0xbb78530du, 0xbb785a73u, 0xbb786096u, 0xbb785aa3u, + 0xbb785d8eu, 0xbb784913u, 0xbb784b81u, 0xbb784c53u, 0xbb7854f5u, 0xbb785819u, 0xbc210344u, 0xbb39c884u, + 0x31744982u, 0x39c255b9u, 0xbb258f3bu, 0xbb258fe0u, 0xbb259014u, 0xbb258fbau, 0xbb259028u, 0xbb259073u, + 0xbb25901du, 0xbb258d99u, 0xbb258ce3u, 0xbb2590fbu, 0xbb25917eu, 0xbb258fd6u, 0xbb258c11u, 0xbb258e35u, + 0xbb258cfbu, 0xbb258e93u, 0xbb258db0u, 0xbb2590b9u, 0xbb258efeu, 0xbb258d99u, 0xbb258e91u, 0xbb258dc6u, + 0xbb258defu, 0xbb258ae7u, 0xbb258c40u, 0xbb25924eu, 0xbb258b45u, 0xbb258ceeu, 0xbb258c11u, 0xbb258a0fu, + 0xbb25980eu, 0xbb2597c4u, 0xbb259c0eu, 0xbb2596beu, 0xbb2598b4u, 0xbb258cfau, 0xbb259466u, 0xbb259cfeu, + 0xbb25981bu, 0xbb25a178u, 0xbb259ba5u, 0xbb2593b5u, 0xbb258b95u, 0xbb259ce5u, 0xbb2591cdu, 0xbb259cbdu, + 0xbb259942u, 0xbb2599abu, 0xbb259687u, 0xbb259c3eu, 0xbb258d31u, 0xbb25954cu, 0xbb259259u, 0xbb259e6au, + 0xbb258d28u, 0xbb2599f0u, 0xbb25917fu, 0xbb258f56u, 0xbb259a4fu, 0xbb259dfeu, 0xbb259491u, 0x3ad5b749u, + 0x3b250869u, 0x3200d7f2u, 0x3b87a69cu, 0xbae7c750u, 0xbae7c654u, 0xbae7c897u, 0xbae7c682u, 0xbae7c5d6u, + 0xbae7c62cu, 0xbae7c35bu, 0xbae7c193u, 0xbae7c55au, 0xbae7c3c0u, 0xbae7c571u, 0xbae7c2c6u, 0xbae7c5a5u, + 0xbae7c357u, 0xbae7c2b8u, 0xbae7c2b4u, 0xbae7c631u, 0xbae7bb72u, 0xbae7c967u, 0xbae7c1d2u, 0xbae7c2e6u, + 0xbae7c719u, 0xbae7b952u, 0xbae7b9b2u, 0xbae7bbc5u, 0xbae7c093u, 0xbae7c210u, 0xbae7bdd3u, 0xbae7cc18u, + 0xbae7bc78u, 0xbae7c1e6u, 0xbae7b79du, 0xbae7b6e2u, 0xbae7b7bfu, 0xbae7be13u, 0xbae7b9bau, 0xbae7c123u, + 0xbae7bf1cu, 0xbae7b8a7u, 0xbae7ad20u, 0xbae7b9fdu, 0xbae7be9fu, 0xbae7c2f6u, 0xbae7cb70u, 0xbae7a649u, + 0xbae7c71au, 0xbae7b01cu, 0xbae7c0fdu, 0xbae7c13cu, 0xbae7bbc2u, 0xbae7b964u, 0xbae7bbc2u, 0xbae7acd5u, + 0xbae7c48fu, 0xbae7c02fu, 0xbae7bd03u, 0xbae7b957u, 0xbae7b3a1u, 0xbae79635u, 0xbae7d29eu, 0xbae7c9e9u, + 0x3b800799u, 0xbb104198u, 0xb0bdb6dau, 0xbac0aef7u, 0xbaa891feu, 0xbaa89120u, 0xbaa891c9u, 0xbaa89242u, + 0xbaa892c3u, 0xbaa89173u, 0xbaa8912du, 0xbaa891e2u, 0xbaa8930eu, 0xbaa89048u, 0xbaa88f17u, 0xbaa891b1u, + 0xbaa88eb3u, 0xbaa8902fu, 0xbaa890f4u, 0xbaa8922au, 0xbaa88e97u, 0xbaa89468u, 0xbaa88da7u, 0xbaa88ddfu, + 0xbaa88987u, 0xbaa88d57u, 0xbaa8915bu, 0xbaa88b52u, 0xbaa8908cu, 0xbaa88ca7u, 0xbaa88f3au, 0xbaa891dfu, + 0xbaa88eedu, 0xbaa88d78u, 0xbaa89164u, 0xbaa8929cu, 0xbaa89288u, 0xbaa89442u, 0xbaa89598u, 0xbaa8997eu, + 0xbaa893f3u, 0xbaa891cau, 0xbaa88c8au, 0xbaa89151u, 0xbaa88730u, 0xbaa89935u, 0xbaa88d16u, 0xbaa8899cu, + 0xbaa88d0cu, 0xbaa89bfau, 0xbaa88b40u, 0xbaa8914au, 0xbaa88694u, 0xbaa89570u, 0xbaa88dbau, 0xbaa895c6u, + 0xbaa89514u, 0xbaa89876u, 0xbaa88c33u, 0xbaa89512u, 0xbaa890a8u, 0xbaa88975u, 0xbaa8884eu, 0xbaa888d8u, + 0xbaa88e77u, 0xbb208956u, 0x3b030f8au, 0xb1b0a033u, 0xbb7c74d4u, 0xba7cda97u, 0xba7cdafau, 0xba7cdafcu, + 0xba7cdd65u, 0xba7cda5bu, 0xba7cda98u, 0xba7cdddau, 0xba7cdd3au, 0xba7cddf8u, 0xba7cdc42u, 0xba7ce38au, + 0xba7cd9f3u, 0xba7cddaau, 0xba7cdd46u, 0xba7cdd47u, 0xba7ce3fau, 0xba7cdb6fu, 0xba7cdd2eu, 0xba7ce084u, + 0xba7cd1f6u, 0xba7ce62bu, 0xba7cd99eu, 0xba7ce191u, 0xba7ccefcu, 0xba7cdddau, 0xba7cd9a1u, 0xba7cd4cfu, + 0xba7cd0afu, 0xba7ce245u, 0xba7ce530u, 0xba7cda6du, 0xba7ce164u, 0xba7cefc3u, 0xba7ce726u, 0xba7ce3b3u, + 0xba7cf699u, 0xba7cfba6u, 0xba7cda26u, 0xba7ce1bfu, 0xba7ce468u, 0xba7cec19u, 0xba7ce104u, 0xba7ce643u, + 0xba7ce668u, 0xba7cfe63u, 0xba7ce834u, 0xba7cf117u, 0xba7ced29u, 0xba7cf259u, 0xba7ce6ccu, 0xba7ce13eu, + 0xba7cd10cu, 0xba7cf35cu, 0xba7ccbf9u, 0xba7ce131u, 0xba7cc9a5u, 0xba7cf11fu, 0xba7d044bu, 0xba7ce7a5u, + 0xba7cc8a5u, 0xba7cf3c7u, 0xbb6e87a4u, 0xbaeba3a0u, 0x31696065u, 0x38db81d7u, 0xba427fd1u, 0xba428028u, + 0xba427fd8u, 0xba427cd0u, 0xba428089u, 0xba427c47u, 0xba4277fdu, 0xba427d43u, 0xba428306u, 0xba427bd0u, + 0xba427c9eu, 0xba427be3u, 0xba42836fu, 0xba427f25u, 0xba428394u, 0xba428454u, 0xba427b17u, 0xba4276f5u, + 0xba4276f4u, 0xba428517u, 0xba427fdcu, 0xba42881cu, 0xba428764u, 0xba42785cu, 0xba428a23u, 0xba428ec9u, + 0xba428099u, 0xba427c1bu, 0xba427ac8u, 0xba4279b8u, 0xba428902u, 0xba4269b5u, 0xba4274d8u, 0xba426c15u, + 0xba426d20u, 0xba428313u, 0xba42828eu, 0xba428327u, 0xba428b5bu, 0xba426d47u, 0xba42606du, 0xba4288b6u, + 0xba429fa6u, 0xba4272a4u, 0xba42735fu, 0xba42743au, 0xba428b71u, 0xba427543u, 0xba428558u, 0xba428eb1u, + 0xba429199u, 0xba4276d7u, 0xba427ccdu, 0xba427d60u, 0xba427a33u, 0xba428454u, 0xba42a04eu, 0xba42892au, + 0xba4284e0u, 0xba428e50u, 0xba427096u, 0x3a74b695u, 0x3ad93b57u, 0xb0978ea3u, 0x3b0b66e5u, 0xba18d243u, + 0xba18d245u, 0xba18d1a5u, 0xba18d22fu, 0xba18d059u, 0xba18d470u, 0xba18cd1fu, 0xba18cb81u, 0xba18cf63u, + 0xba18d16du, 0xba18d36bu, 0xba18da79u, 0xba18cc9au, 0xba18d48au, 0xba18d20au, 0xba18d0a5u, 0xba18c92cu, + 0xba18caf6u, 0xba18d12cu, 0xba18d44fu, 0xba18c929u, 0xba18d26cu, 0xba18cccfu, 0xba18ccefu, 0xba18eb78u, + 0xba18da47u, 0xba18d846u, 0xba18d0d8u, 0xba18d4ecu, 0xba18e9ccu, 0xba18dbe2u, 0xba18e189u, 0xba18bbafu, + 0xba18c3dfu, 0xba18cb1du, 0xba18c3adu, 0xba18cf55u, 0xba18c6d9u, 0xba18cb66u, 0xba18c5b7u, 0xba18c9f1u, + 0xba18bc11u, 0xba18da85u, 0xba18c55eu, 0xba18f740u, 0xba18c723u, 0xba18dfaau, 0xba18d9b3u, 0xba18d662u, + 0xba18df95u, 0xba18df8eu, 0xba18b600u, 0xba18dce6u, 0xba18caf0u, 0xba18dc29u, 0xba18b9afu, 0xba18db63u, + 0xba18b2a8u, 0xba18bd91u, 0xba18d27fu, 0xba18dc04u, 0x3aff94ebu, 0xbac6ebf4u, 0x3164b065u, 0xba0b5b62u, + 0xb9f48244u, 0xb9f48408u, 0xb9f481b1u, 0xb9f484c6u, 0xb9f47dc5u, 0xb9f48700u, 0xb9f4846eu, 0xb9f47b42u, + 0xb9f483c1u, 0xb9f487bau, 0xb9f48b45u, 0xb9f48a5bu, 0xb9f4829eu, 0xb9f48b27u, 0xb9f48845u, 0xb9f47458u, + 0xb9f4832eu, 0xb9f47a7fu, 0xb9f480a3u, 0xb9f47b86u, 0xb9f4871eu, 0xb9f48263u, 0xb9f47ceau, 0xb9f489c5u, + 0xb9f48a33u, 0xb9f4915au, 0xb9f48886u, 0xb9f4936du, 0xb9f495f5u, 0xb9f47cffu, 0xb9f49af7u, 0xb9f46958u, + 0xb9f46a97u, 0xb9f49e06u, 0xb9f48958u, 0xb9f47ad2u, 0xb9f4881du, 0xb9f49332u, 0xb9f487b3u, 0xb9f45ed9u, + 0xb9f4723du, 0xb9f4a48bu, 0xb9f4add3u, 0xb9f49dd0u, 0xb9f4c62bu, 0xb9f4879du, 0xb9f47cf2u, 0xb9f459e8u, + 0xb9f4adfcu, 0xb9f44dc2u, 0xb9f49182u, 0xb9f469ddu, 0xb9f48b04u, 0xb9f4811au, 0xb9f44cbbu, 0xb9f48969u, + 0xb9f4b419u, 0xb9f49b50u, 0xb9f461d4u, 0xb9f48304u, 0xb9f48b5au, 0xbaa17470u, 0x3ab96120u, 0x302ea30eu, + 0xbb03ce13u, 0xb9c6a912u, 0xb9c6ace7u, 0xb9c6b2d3u, 0xb9c6ad20u, 0xb9c6ad98u, 0xb9c6b06bu, 0xb9c6a916u, + 0xb9c6ac41u, 0xb9c6a6ddu, 0xb9c6a1cau, 0xb9c6a1ddu, 0xb9c6a3edu, 0xb9c69127u, 0xb9c69830u, 0xb9c6840cu, + 0xb9c6acc2u, 0xb9c6ace2u, 0xb9c6b272u, 0xb9c6aa05u, 0xb9c69d1cu, 0xb9c6936bu, 0xb9c69b5cu, 0xb9c6a4ebu, + 0xb9c6925eu, 0xb9c67df6u, 0xb9c68b87u, 0xb9c68646u, 0xb9c68d38u, 0xb9c6cdd6u, 0xb9c6884cu, 0xb9c6b9c4u, + 0xb9c6a4a9u, 0xb9c65faeu, 0xb9c690f9u, 0xb9c6ade9u, 0xb9c6b0b0u, 0xb9c6c15au, 0xb9c68056u, 0xb9c6504au, + 0xb9c64ae5u, 0xb9c65420u, 0xb9c69080u, 0xb9c6c2e2u, 0xb9c65b7fu, 0xb9c69b37u, 0xb9c69628u, 0xb9c6e284u, + 0xb9c6cc15u, 0xb9c611d0u, 0xb9c68bdfu, 0xb9c63cb7u, 0xb9c6e915u, 0xb9c6fb26u, 0xb9c69816u, 0xb9c61ee5u, + 0xb9c6dee5u, 0xb9c6643fu, 0xb9c6a900u, 0xb9c6782du, 0xb9c726deu, 0xb9c6b0f6u, 0xbaf122b7u, 0xbaabab68u, + 0x318afc15u, 0x38362ee3u, 0xb9a39a33u, 0xb9a3a160u, 0xb9a3a2adu, 0xb9a39c1fu, 0xb9a397cau, 0xb9a3a693u, + 0xb9a3a3fbu, 0xb9a3a573u, 0xb9a3ac63u, 0xb9a39f0au, 0xb9a3ab92u, 0xb9a38b5au, 0xb9a3a042u, 0xb9a3b6e3u, + 0xb9a392cbu, 0xb9a37f0bu, 0xb9a392cbu, 0xb9a3905cu, 0xb9a395abu, 0xb9a3553bu, 0xb9a368fcu, 0xb9a3827cu, + 0xb9a388fcu, 0xb9a3bf0au, 0xb9a3553bu, 0xb9a38db9u, 0xb9a38db9u, 0xb9a3a17au, 0xb9a39028u, 0xb9a3817au, + 0xb9a3b788u, 0xb9a42b1bu, 0xb9a3fdd9u, 0xb9a43dd9u, 0xb9a3fdd9u, 0xb9a3c2b9u, 0xb9a3d098u, 0xb9a40bb9u, + 0xb9a41e78u, 0xb9a42b1bu, 0xb9a3fdd9u, 0xb9a3c2b9u, 0xb9a3c2b9u, 0xb9a3e357u, 0xb9a41098u, 0xb9a3bbd4u, + 0xb9a3d098u, 0xb9a3adf5u, 0xb9a36836u, 0xb9a41098u, 0xb9a39578u, 0xb9a37bd4u, 0xb9a3cad9u, 0xb9a42916u, + 0xb9a37af5u, 0xb9a41b36u, 0xb9a3bdd9u, 0xb9a3fbd4u, 0xb9a3edf5u, 0xb9a42916u, 0xb9a3c0b4u, 0x3a2cbecau, + 0x3aa17721u, 0xb1b388abu, 0x3aa975a1u, 0xb9884eb6u, 0xb9885244u, 0xb9884d71u, 0xb9884afeu, 0xb9885828u, + 0xb9884b3fu, 0xb98858c9u, 0xb98845ddu, 0xb9884bc9u, 0xb98844dau, 0xb9882faau, 0xb988395cu, 0xb988622eu, + 0xb988586du, 0xb9883162u, 0xb9884f5du, 0xb9887c63u, 0xb9885e07u, 0xb9885beau, 0xb988390du, 0xb9880892u, + 0xb98851fcu, 0xb9884957u, 0xb988643bu, 0xb9883849u, 0xb9882712u, 0xb98842b4u, 0xb988812cu, 0xb98841f8u, + 0xb9886f7eu, 0xb9887cf3u, 0xb988469bu, 0xb9885baau, 0xb9888db1u, 0xb9880d37u, 0xb988621au, 0xb98862ddu, + 0xb988e0a2u, 0xb988246au, 0xb988983fu, 0xb988056fu, 0xb98841adu, 0xb9886540u, 0xb98831a1u, 0xb9885a77u, + 0xb988422du, 0xb987cfabu, 0xb987fcaeu, 0xb9883ba3u, 0xb9889796u, 0xb987f95fu, 0xb988dbfcu, 0xb988cdb6u, + 0xb9884cc6u, 0xb9884f2bu, 0xb987fd3du, 0xb987f61au, 0xb987fae8u, 0xb9887a2cu, 0xb989129fu, 0xb987bdf9u, + 0x3a9548c2u, 0xba9704ceu, 0xb12d635eu, 0xb982b53du, 0xb9659c32u, 0xb965a03cu, 0xb965a2a8u, 0xb965ac1fu, + 0xb965a0aeu, 0xb9659f55u, 0xb96592b5u, 0xb965a5a3u, 0xb965b1d6u, 0xb965a24du, 0xb965b8e4u, 0xb96593b8u, + 0xb965a391u, 0xb965ab87u, 0xb965815au, 0xb965b8efu, 0xb965934fu, 0xb96592eau, 0xb965ba9bu, 0xb9658534u, + 0xb965a9afu, 0xb965c147u, 0xb965b7dau, 0xb965a1d8u, 0xb965b1f9u, 0xb965925cu, 0xb965a181u, 0xb9659f91u, + 0xb965c8b0u, 0xb965cf6au, 0xb965b129u, 0xb965a97fu, 0xb965a1f8u, 0xb965b524u, 0xb965ffd1u, 0xb965e3cfu, + 0xb965986bu, 0xb965ffe8u, 0xb965dbd8u, 0xb965b5bfu, 0xb965acc0u, 0xb965e90du, 0xb965b488u, 0xb965a94au, + 0xb965e36cu, 0xb965c7fbu, 0xb9659d57u, 0xb965ecd4u, 0xb965e90bu, 0xb96635bau, 0xb96572a4u, 0xb965b134u, + 0xb965a9c3u, 0xb965baf9u, 0xb965a9f8u, 0xb965eca6u, 0xb965b206u, 0xb9660f81u, 0xb9661ec4u, 0xb9660c70u, + 0xb965b707u, 0xba4f6d19u, 0x3a8eef22u, 0xb0835c57u, 0xbaa20b2cu, 0xb943222bu, 0xb9432a8eu, 0xb943274du, + 0xb9432968u, 0xb94333dau, 0xb94317dfu, 0xb9432ec6u, 0xb9432a64u, 0xb9431c6du, 0xb9432c72u, 0xb9433be9u, + 0xb9433268u, 0xb94313cdu, 0xb9433e35u, 0xb94355eeu, 0xb94327c7u, 0xb9431959u, 0xb9436c3au, 0xb943507bu, + 0xb9432cbdu, 0xb943103du, 0xb943505bu, 0xb94355efu, 0xb9434f3au, 0xb943164au, 0xb9432ef7u, 0xb94339d1u, + 0xb943397eu, 0xb9433fbbu, 0xb9437315u, 0xb9436187u, 0xb942ed18u, 0xb9430fdcu, 0xb94321a6u, 0xb9432c08u, + 0xb94347edu, 0xb9439017u, 0xb9433db8u, 0xb9436a4du, 0xb943abb2u, 0xb9435609u, 0xb94383e3u, 0xb9439974u, + 0xb942f896u, 0xb943773au, 0xb9432ce8u, 0xb942d4cbu, 0xb943a5c6u, 0xb9436b0fu, 0xb9431eafu, 0xb94385a6u, + 0xb9430f90u, 0xb9432c50u, 0xb9433349u, 0xb94344c8u, 0xb9439b5fu, 0xb943d056u, 0xb94376d1u, 0xb94322b8u, + 0xb9431ed2u, 0xb943769bu, 0xba8e3135u, 0xba86b70eu, 0x32354e92u, 0x37b8f7f2u, 0xb9274045u, 0xb92735dcu, + 0xb9272d28u, 0xb9273698u, 0xb92725c2u, 0xb92732d3u, 0xb9272030u, 0xb9272827u, 0xb9273d26u, 0xb9275270u, + 0xb92734ffu, 0xb927299fu, 0xb9272999u, 0xb9272b21u, 0xb9270e97u, 0xb9272fcdu, 0xb927269du, 0xb92746a2u, + 0xb9272aa2u, 0xb9270c91u, 0xb926f36eu, 0xb927680bu, 0xb927104eu, 0xb9270884u, 0xb9272151u, 0xb926e8dbu, + 0xb9271960u, 0xb9271f7eu, 0xb9271182u, 0xb92732f3u, 0xb9266ef8u, 0xb9274f96u, 0xb926c4aeu, 0xb926da17u, + 0xb926b7b4u, 0xb9274b9fu, 0xb927413eu, 0xb9266c1bu, 0xb9268bafu, 0xb9268b21u, 0xb9265e88u, 0xb9261b3au, + 0xb9266188u, 0xb926ca20u, 0xb9263b7bu, 0xb926b98bu, 0xb926c1ecu, 0xb926e93eu, 0xb926faeau, 0xb927358du, + 0xb9275018u, 0xb926faecu, 0xb9274406u, 0xb926d93eu, 0xb92736c8u, 0xb9273275u, 0xb9268debu, 0xb926a034u, + 0xb9270453u, 0xb9269746u, 0xb9262c69u, 0x3a0603e4u, 0x3a801f2eu, 0xb091774au, 0x3a6442f3u, 0xb9107063u, + 0xb91073e6u, 0xb9107aa1u, 0xb9106b1fu, 0xb9105ea2u, 0xb9106ff7u, 0xb9107fa8u, 0xb9106c75u, 0xb9107872u, + 0xb910702fu, 0xb9106b8bu, 0xb9107840u, 0xb9107399u, 0xb9108abdu, 0xb91080b3u, 0xb9108712u, 0xb9104abau, + 0xb9108c56u, 0xb9105454u, 0xb910655eu, 0xb9107c3fu, 0xb9107650u, 0xb91077e0u, 0xb91079b5u, 0xb9103137u, + 0xb91085edu, 0xb91074a9u, 0xb9109429u, 0xb9107e9du, 0xb910596du, 0xb9108327u, 0xb91055d5u, 0xb9105208u, + 0xb9105cf5u, 0xb910949cu, 0xb910512eu, 0xb910b586u, 0xb910cde1u, 0xb91056b6u, 0xb9107f51u, 0xb91096c1u, + 0xb9108a1fu, 0xb9107537u, 0xb9109343u, 0xb910494bu, 0xb9102bf6u, 0xb9103651u, 0xb910ffffu, 0xb9105ca2u, + 0xb910939du, 0xb910d246u, 0xb910be9fu, 0xb91067b4u, 0xb910d51fu, 0xb9105a6cu, 0xb9110dacu, 0xb9109728u, + 0xb9109ed4u, 0xb91050c4u, 0xb910777eu, 0xb910963fu, 0x3a3e7b9fu, 0xba72c74cu, 0xb0908f0bu, 0xb90eebdcu, + 0xb8fb327bu, 0xb8fb313du, 0xb8fb37b4u, 0xb8fb2f9eu, 0xb8fb3e55u, 0xb8fb438au, 0xb8fb357du, 0xb8fb456cu, + 0xb8fb49bau, 0xb8fb4f02u, 0xb8fb4a16u, 0xb8fb6476u, 0xb8fb2eb1u, 0xb8fb3f42u, 0xb8fb24a3u, 0xb8fb4d59u, + 0xb8fb6377u, 0xb8fb5718u, 0xb8faf8beu, 0xb8fb777du, 0xb8fb8593u, 0xb8fb9994u, 0xb8fb8d82u, 0xb8fb7888u, + 0xb8fb7ef1u, 0xb8fb8136u, 0xb8fb33ebu, 0xb8fb9f7bu, 0xb8fb5b02u, 0xb8fb6cacu, 0xb8fbb883u, 0xb8fba0deu, + 0xb8fab8d9u, 0xb8fb6be5u, 0xb8fba9d2u, 0xb8fba494u, 0xb8fbe316u, 0xb8fb7900u, 0xb8fbf558u, 0xb8fb7cd1u, + 0xb8fc6d90u, 0xb8fba69cu, 0xb8fc8fc3u, 0xb8fb56aeu, 0xb8fc849cu, 0xb8fba81au, 0xb8fbf6bau, 0xb8fb6b71u, + 0xb8fbf37bu, 0xb8fba24cu, 0xb8fc1c57u, 0xb8fb0909u, 0xb8fb7a51u, 0xb8fb6b1bu, 0xb8fbedb1u, 0xb8fac20au, + 0xb8faff2du, 0xb8fb2a88u, 0xb8fb6e9eu, 0xb8fae74cu, 0xb8fb087cu, 0xba173368u, 0x3a67bd17u, 0x312899e7u, + 0xba5bfd51u, 0xb8dbbee8u, 0xb8dbad34u, 0xb8dbb976u, 0xb8db94cdu, 0xb8dbb202u, 0xb8db9976u, 0xb8db8ff4u, + 0xb8dbb03fu, 0xb8db808fu, 0xb8dbb18eu, 0xb8dba604u, 0xb8db5c5eu, 0xb8dbcf0eu, 0xb8db224du, 0xb8db4ea3u, + 0xb8dbaea9u, 0xb8db91a7u, 0xb8db7191u, 0xb8db70a3u, 0xb8db5ae0u, 0xb8db1bbfu, 0xb8dc29d3u, 0xb8db1ef4u, + 0xb8db8413u, 0xb8dbb283u, 0xb8dc14e7u, 0xb8db10c0u, 0xb8db0bf3u, 0xb8db0edbu, 0xb8db21bfu, 0xb8db3c83u, + 0xb8db350bu, 0xb8dac862u, 0xb8db5380u, 0xb8db8c0eu, 0xb8dbaacbu, 0xb8db7c28u, 0xb8dc65fdu, 0xb8db7319u, + 0xb8da7d08u, 0xb8db0223u, 0xb8dae6c0u, 0xb8db61b0u, 0xb8dae351u, 0xb8dacbf3u, 0xb8db3c64u, 0xb8dae2a2u, + 0xb8da5309u, 0xb8db8df3u, 0xb8db5668u, 0xb8db0620u, 0xb8db0139u, 0xb8daaddeu, 0xb8db0a5eu, 0xb8dbda94u, + 0xb8dbed65u, 0xb8db91acu, 0xb8db6465u, 0xb8da7282u, 0xb8da24dbu, 0xb8dafaafu, 0xba368828u, 0xba5c9c6au, + 0xb1ff4ceeu, 0x3754e42fu, 0xb8c1655du, 0xb8c1799eu, 0xb8c19a1au, 0xb8c18f38u, 0xb8c1a24fu, 0xb8c1b5cfu, + 0xb8c18443u, 0xb8c1617du, 0xb8c16e52u, 0xb8c16e2eu, 0xb8c16a56u, 0xb8c16c8bu, 0xb8c1c960u, 0xb8c17a44u, + 0xb8c1743fu, 0xb8c1abedu, 0xb8c1c9e3u, 0xb8c151bcu, 0xb8c1a74fu, 0xb8c1c56fu, 0xb8c15f19u, 0xb8c175c4u, + 0xb8c178f5u, 0xb8c10613u, 0xb8c1ee6fu, 0xb8c1cc7bu, 0xb8c23822u, 0xb8c21472u, 0xb8c1b4e5u, 0xb8c2233eu, + 0xb8c1cb8bu, 0xb8c22decu, 0xb8c1fb37u, 0xb8c24192u, 0xb8c32793u, 0xb8c2f542u, 0xb8c318e4u, 0xb8c27335u, + 0xb8c22ae0u, 0xb8c28956u, 0xb8c2c770u, 0xb8c1f43cu, 0xb8c38e36u, 0xb8c2b515u, 0xb8c35067u, 0xb8c20284u, + 0xb8c3a823u, 0xb8c25a3au, 0xb8c30bc0u, 0xb8c2440cu, 0xb8c26664u, 0xb8c3242cu, 0xb8c30580u, 0xb8c2683fu, + 0xb8c2ad55u, 0xb8c25222u, 0xb8c39f6au, 0xb8c1e076u, 0xb8c2082eu, 0xb8c371e7u, 0xb8c225c4u, 0x39d9aba2u, + 0x3a530448u, 0x2ff22ec9u, 0x3a24bb71u, 0xb8ab0e01u, 0xb8ab13abu, 0xb8ab0b4bu, 0xb8ab2ddau, 0xb8ab4062u, + 0xb8ab271au, 0xb8aaff42u, 0xb8ab13f6u, 0xb8ab1cbbu, 0xb8ab3a17u, 0xb8ab1f70u, 0xb8aaebeeu, 0xb8aaf825u, + 0xb8aa92adu, 0xb8aae35cu, 0xb8aafa94u, 0xb8ab4c4fu, 0xb8ab4432u, 0xb8ab269cu, 0xb8aad614u, 0xb8aad9c8u, + 0xb8ab51fdu, 0xb8ab1821u, 0xb8aaf62fu, 0xb8aaf9a8u, 0xb8aaa1f2u, 0xb8aa82fdu, 0xb8aa8c80u, 0xb8ab699bu, + 0xb8ab7b8cu, 0xb8aa0e9bu, 0xb8aac7d4u, 0xb8aa8bc6u, 0xb8aaf5e9u, 0xb8aab30cu, 0xb8ab12b9u, 0xb8ab0680u, + 0xb8abe14au, 0xb8aac146u, 0xb8aa3932u, 0xb8aa80b0u, 0xb8ab5b1eu, 0xb8ab4d7au, 0xb8ab599du, 0xb8abc701u, + 0xb8aac496u, 0xb8ab4e8bu, 0xb8a99695u, 0xb8ab5687u, 0xb8ab588cu, 0xb8aa5aa9u, 0xb8abd698u, 0xb8aab22bu, + 0xb8aab897u, 0xb8aaf6d9u, 0xb8ab563bu, 0xb8ac9bffu, 0xb8aa9fb4u, 0xb8ab8e5eu, 0xb8a9fa17u, 0xb8ab8823u, + 0x3a009d1bu, 0xba49ef17u, 0x31afd8d6u, 0xb8acf72fu, 0xb8980b74u, 0xb8980306u, 0xb89813a1u, 0xb897f6afu, + 0xb89818e1u, 0xb897ef83u, 0xb8981159u, 0xb898316fu, 0xb897eaf8u, 0xb8980355u, 0xb897fc50u, 0xb89802a0u, + 0xb897fe7du, 0xb897e0ebu, 0xb897c223u, 0xb897ded3u, 0xb897e426u, 0xb8981b7fu, 0xb897fce2u, 0xb8982c8fu, + 0xb897b709u, 0xb897f2abu, 0xb897bb5fu, 0xb897ca51u, 0xb897c64du, 0xb8987e55u, 0xb8980687u, 0xb89860e2u, + 0xb89840eau, 0xb8983a27u, 0xb898261du, 0xb899116fu, 0xb8984a4cu, 0xb8988f5au, 0xb89963f5u, 0xb8987be8u, + 0xb89912cbu, 0xb8994033u, 0xb899324du, 0xb898a18au, 0xb898b779u, 0xb89898a9u, 0xb8986dc6u, 0xb898d928u, + 0xb89810fau, 0xb898cd1cu, 0xb8981891u, 0xb899077bu, 0xb897e425u, 0xb8981672u, 0xb8980c0cu, 0xb897f93fu, + 0xb897d1dfu, 0xb8984e41u, 0xb898497du, 0xb897d1e8u, 0xb8986634u, 0xb897c559u, 0xb8980963u, 0xb897a723u, + 0xb897a80du, 0xb9eccb5au, 0x3a4214afu, 0xb11d0792u, 0xba1fac0bu, 0xb887bbc2u, 0xb887c1cbu, 0xb887b7f4u, + 0xb887d692u, 0xb887ac06u, 0xb887d0d0u, 0xb8879132u, 0xb887ac25u, 0xb887ad4du, 0xb887cc8cu, 0xb8878d91u, + 0xb887d081u, 0xb88804efu, 0xb887a07fu, 0xb887e307u, 0xb8879ce3u, 0xb887b899u, 0xb8878c4fu, 0xb887a248u, + 0xb887a229u, 0xb88794e6u, 0xb8876e78u, 0xb887aa60u, 0xb8876834u, 0xb886fef5u, 0xb8876b0cu, 0xb8882373u, + 0xb8877649u, 0xb88745b7u, 0xb8872629u, 0xb88795eeu, 0xb887a6d2u, 0xb88787f0u, 0xb8875e03u, 0xb887005du, + 0xb887e66bu, 0xb88838ebu, 0xb8879082u, 0xb887d008u, 0xb887de53u, 0xb887fbf3u, 0xb887d223u, 0xb8878aafu, + 0xb8879fecu, 0xb887f4aau, 0xb887adf3u, 0xb8874576u, 0xb888c113u, 0xb88731aeu, 0xb887da7du, 0xb887cd31u, + 0xb887af54u, 0xb887de64u, 0xb887b2c2u, 0xb8878f06u, 0xb887d1dcu, 0xb8879b0bu, 0xb887a884u, 0xb887005fu, + 0xb887d578u, 0xb887910cu, 0xb9f7d2a7u, 0xba3a2604u, 0xb0cb59f9u, 0x370601d7u, 0xb8735c7bu, 0xb87342aau, + 0xb87371ccu, 0xb8736b85u, 0xb8738d7au, 0xb873401bu, 0xb8734435u, 0xb87383f2u, 0xb872f929u, 0xb8739ff0u, + 0xb87324f9u, 0xb8738522u, 0xb872fa1du, 0xb87382ccu, 0xb8732613u, 0xb872c86bu, 0xb873c1d1u, 0xb873a8b2u, + 0xb873997eu, 0xb873227eu, 0xb8730725u, 0xb872eb51u, 0xb872bb84u, 0xb87324b2u, 0xb87277d9u, 0xb8737d5au, + 0xb8733123u, 0xb871cf57u, 0xb8729883u, 0xb87246eeu, 0xb8726828u, 0xb87110f9u, 0xb870fa5fu, 0xb8722c46u, + 0xb872080du, 0xb870cf45u, 0xb87202cdu, 0xb87300ebu, 0xb871fa7cu, 0xb8717da0u, 0xb8722f5cu, 0xb8736f84u, + 0xb8732791u, 0xb8715a17u, 0xb8723d96u, 0xb8725098u, 0xb872330du, 0xb871bd85u, 0xb872ca92u, 0xb870ff01u, + 0xb87238b3u, 0xb871431eu, 0xb8727555u, 0xb872c996u, 0xb8723471u, 0xb8728c2eu, 0xb8726213u, 0xb8752b4au, + 0xb8744036u, 0xb87385f6u, 0xb8751377u, 0x39b74b31u, 0x3a333b2au, 0xb1c28c8bu, 0x39f9e951u, 0xb85b0146u, + 0xb85b021eu, 0xb85b35deu, 0xb85b1a28u, 0xb85b2b3cu, 0xb85b35b2u, 0xb85b32dau, 0xb85b4e7du, 0xb85b5203u, + 0xb85b1e61u, 0xb85b3855u, 0xb85bc1ffu, 0xb85b5a9cu, 0xb85b16abu, 0xb85b26e3u, 0xb85b9a58u, 0xb85b15aau, + 0xb85ad277u, 0xb85ae751u, 0xb85b67f4u, 0xb85b51c1u, 0xb85ada81u, 0xb85bcd13u, 0xb85ae648u, 0xb85a8fb0u, + 0xb85c057bu, 0xb85beab4u, 0xb85abe6cu, 0xb85b74fdu, 0xb85be38fu, 0xb85bd648u, 0xb85c3d43u, 0xb85ad192u, + 0xb85aa345u, 0xb85b11f3u, 0xb85bdc34u, 0xb85c0583u, 0xb85ced58u, 0xb85b734bu, 0xb85a9cb2u, 0xb85c4c69u, + 0xb8598dcbu, 0xb85bb0f1u, 0xb85bda13u, 0xb85b15edu, 0xb85caab2u, 0xb85bb219u, 0xb85ba9e1u, 0xb85c8334u, + 0xb85c86f0u, 0xb85bb405u, 0xb859d6dau, 0xb85af588u, 0xb85ca254u, 0xb85affaeu, 0xb85e313fu, 0xb85aa1e9u, + 0xb85b471eu, 0xb85c60c8u, 0xb85ad4feu, 0xb858e1deu, 0x39b38921u, 0xba2c4252u, 0xb0b17f00u, 0xb860f88au, + 0xb845ccc2u, 0xb845c9f7u, 0xb845ec86u, 0xb845c437u, 0xb845cf40u, 0xb8460545u, 0xb845de04u, 0xb84600cdu, + 0xb845b25eu, 0xb84624b1u, 0xb84623eau, 0xb8460ccfu, 0xb845d57fu, 0xb845f845u, 0xb8467b56u, 0xb845d8e8u, + 0xb8460c44u, 0xb8463652u, 0xb8466c69u, 0xb8459eacu, 0xb846c72cu, 0xb8467e9cu, 0xb8461517u, 0xb8463a13u, + 0xb846e52fu, 0xb845aa9du, 0xb84772ceu, 0xb846d8edu, 0xb845ffb2u, 0xb8463a8eu, 0xb845a1a1u, 0xb846c53bu, + 0xb846a321u, 0xb845811cu, 0xb8468b0cu, 0xb8471878u, 0xb84584a8u, 0xb846beb5u, 0xb8473bb6u, 0xb8465058u, + 0xb845f345u, 0xb846315au, 0xb84859a7u, 0xb84720a7u, 0xb847de7du, 0xb846c45fu, 0xb846ced1u, 0xb84535e2u, + 0xb8483bd9u, 0xb8457d97u, 0xb8468447u, 0xb8470ed8u, 0xb8470702u, 0xb84622bfu, 0xb84827f1u, 0xb846ca7fu, + 0xb8459ab1u, 0xb8467f2fu, 0xb8461199u, 0xb8469445u, 0xb84765c3u, 0xb9c1e655u, 0x3a260887u, 0xb1a8c8eeu, + 0xb9f34ac6u, 0xb8330847u, 0xb83313ddu, 0xb8334c23u, 0xb832fa78u, 0xb83348d8u, 0xb8330237u, 0xb8331cb3u, + 0xb83334f9u, 0xb8339a7eu, 0xb832b088u, 0xb832e128u, 0xb83353e6u, 0xb833069fu, 0xb8334b04u, 0xb8334467u, + 0xb831ee50u, 0xb8341eefu, 0xb833cf75u, 0xb831160du, 0xb8339ed5u, 0xb833046cu, 0xb832b959u, 0xb8342c29u, + 0xb8343dcau, 0xb8339ed5u, 0xb8344f90u, 0xb83188edu, 0xb83372c1u, 0xb833dcafu, 0xb8332347u, 0xb831c23bu, + 0xb8356121u, 0xb830cafau, 0xb8308d44u, 0xb83146acu, 0xb83146acu, 0xb83372e5u, 0xb834a7b8u, 0xb8308d44u, + 0xb834a7b8u, 0xb83334e8u, 0xb832f732u, 0xb831236bu, 0xb82fd3dcu, 0xb832f732u, 0xb82fee50u, 0xb82fee50u, + 0xb82fd3dcu, 0xb83334e8u, 0xb83546acu, 0xb8302c04u, 0xb8344f90u, 0xb8338d59u, 0xb8339627u, 0xb83146acu, + 0xb82fee50u, 0xb833d3dcu, 0xb8344f90u, 0xb832583du, 0xb8313d96u, 0xb83372e5u, 0xb9ad2175u, 0xba202de1u, + 0x3234f4beu, 0x36b2a181u, 0xb822b42au, 0xb8230acau, 0xb8225d89u, 0xb822f048u, 0xb8234acbu, 0xb8234acbu, + 0xb823254du, 0xb824da52u, 0xb823254du, 0xb823da52u, 0xb8258f57u, 0xb823254du, 0xb823da52u, 0xb824da52u, + 0xb825254du, 0xb827ae66u, 0xb824da52u, 0xb824da52u, 0xb828445cu, 0xb824da52u, 0xb8237048u, 0xb822063eu, + 0xb8237048u, 0xb826445cu, 0xb824da52u, 0xb826445cu, 0xb826445cu, 0xb826445cu, 0xb827ae66u, 0xb826445cu, + 0xb824445cu, 0xb824da52u, 0xb824da52u, 0xb824da52u, 0xb824da52u, 0xb827ae66u, 0xb820da52u, 0xb824da52u, + 0xb820da52u, 0xb824da52u, 0xb824da52u, 0xb827ae66u, 0xb827ae66u, 0xb828da52u, 0xb824da52u, 0xb81e063eu, + 0xb824da52u, 0xb824da52u, 0xb82bae66u, 0xb824da52u, 0xb823ae66u, 0xb826063eu, 0xb81e063eu, 0xb822063eu, + 0xb827ae66u, 0xb824da52u, 0xb824da52u, 0xb81e063eu, 0xb824da52u, 0xb822063eu, 0xb828da52u, 0x399d9201u, + 0x3a1a5538u, 0x31ba786bu, 0x39c4de5fu, 0xb8145cf4u, 0xb8148d62u, 0xb81450f0u, 0xb814a326u, 0xb8145b6du, + 0xb81488aau, 0xb813b220u, 0xb814117du, 0xb813e0ebu, 0xb81332edu, 0xb8141311u, 0xb813cfe6u, 0xb8154808u, + 0xb8129225u, 0xb81318b9u, 0xb8132dd6u, 0xb8147797u, 0xb8144b82u, 0xb8127c0eu, 0xb8138171u, 0xb815070bu, + 0xb815a9c2u, 0xb81450e5u, 0xb814c39cu, 0xb81241b3u, 0xb813c618u, 0xb813d53du, 0xb81559afu, 0xb815c3b6u, + 0xb810bd5bu, 0xb812ada0u, 0xb8158a48u, 0xb8121318u, 0xb8133d60u, 0xb8138171u, 0xb8111bfau, 0xb816535cu, + 0xb812d0ebu, 0xb8105c36u, 0xb81238f4u, 0xb8138171u, 0xb814efcbu, 0xb8133012u, 0xb817a050u, 0xb81650d6u, + 0xb80d0192u, 0xb8108ecbu, 0xb8173a2cu, 0xb8153662u, 0xb81aeabbu, 0xb8137423u, 0xb813130cu, 0xb8130f42u, + 0xb8120a29u, 0xb8133298u, 0xb8108ecbu, 0xb814a932u, 0xb8141beeu, 0xb81185e8u, 0xb8124833u, 0xb8139c71u, + 0x397fce20u, 0xba14c4deu, 0x313850bau, 0xb81a4fe7u, 0xb807a346u, 0xb80796c8u, 0xb807abc5u, 0xb807c449u, + 0xb807ccf4u, 0xb8078f28u, 0xb807d732u, 0xb8079a69u, 0xb807afd6u, 0xb8079888u, 0xb807782eu, 0xb8078b69u, + 0xb807670bu, 0xb806f242u, 0xb807ae38u, 0xb806f438u, 0xb807714cu, 0xb8072ea6u, 0xb806c607u, 0xb8069d90u, + 0xb806d97bu, 0xb8068c53u, 0xb8068e95u, 0xb806acb2u, 0xb806db8du, 0xb806251au, 0xb807566eu, 0xb8072398u, + 0xb8070111u, 0xb806ed52u, 0xb806636bu, 0xb806c8eau, 0xb805ec3cu, 0xb806b8e9u, 0xb807a9e4u, 0xb806cc50u, + 0xb806f67cu, 0xb8069eb3u, 0xb8065ab0u, 0xb8068d28u, 0xb8073557u, 0xb80603a4u, 0xb8062753u, 0xb805137eu, + 0xb8072367u, 0xb8052dc9u, 0xb8074372u, 0xb8079c47u, 0xb8064f96u, 0xb8043f73u, 0xb8069542u, 0xb808144fu, + 0xb8066bf5u, 0xb80757edu, 0xb8069f27u, 0xb805c5c6u, 0xb8056fafu, 0xb806ba68u, 0xb806091eu, 0xb8065743u, + 0xb80638cfu, 0xb9a35ebau, 0x3a1099b9u, 0xb2b28361u, 0xb9c05355u, 0xb7f95b5eu, 0xb7f959d4u, 0xb7f9a370u, + 0xb7f97867u, 0xb7fa6e15u, 0xb7fa1c6du, 0xb7faa3b9u, 0xb7faf919u, 0xb7fba425u, 0xb7fb7d84u, 0xb7fb878cu, + 0xb7fb82ccu, 0xb7fbb71au, 0xb7fb9311u, 0xb7fc2e64u, 0xb7fc719eu, 0xb7fc6191u, 0xb7fc9515u, 0xb7fc7a92u, + 0xb7fd9030u, 0xb7fb91f7u, 0xb7fdbbb3u, 0xb7fe37c6u, 0xb7fc991cu, 0xb7fdde67u, 0xb7ff5f29u, 0xb7ff17d0u, + 0xb7fe0b9au, 0xb7ff09a1u, 0xb7ff59e7u, 0xb80045f8u, 0xb8008cbeu, 0xb800137bu, 0xb7fef9b6u, 0xb7ff64f7u, + 0xb8013f00u, 0xb7ff8178u, 0xb7ffb332u, 0xb7fe7055u, 0xb8003f24u, 0xb7ffbe1cu, 0xb7ffd1b5u, 0xb800befdu, + 0xb8012afbu, 0xb801bfa5u, 0xb8021a10u, 0xb8021611u, 0xb8029013u, 0xb8004126u, 0xb802a13fu, 0xb8011098u, + 0xb800c693u, 0xb801ab77u, 0xb802e0cau, 0xb801c5cdu, 0xb800a884u, 0xb802891du, 0xb8015a93u, 0xb80413ffu, + 0xb803eed8u, 0xb803e091u, 0xb978fe15u, 0xba0c06e5u, 0x328f8f47u, 0x367d5535u, 0xb7e3fc09u, 0xb7e3b67fu, + 0xb7e40c6eu, 0xb7e418fau, 0xb7e3c4b1u, 0xb7e434e1u, 0xb7e351c1u, 0xb7e35e7eu, 0xb7e409afu, 0xb7e457e4u, + 0xb7e35c9bu, 0xb7e408c3u, 0xb7e44f07u, 0xb7e32c98u, 0xb7e474abu, 0xb7e24e4fu, 0xb7e3a18eu, 0xb7e25de5u, + 0xb7e34cb8u, 0xb7dfacdfu, 0xb7e204b5u, 0xb7e17ba6u, 0xb7e0e0e8u, 0xb7df926fu, 0xb7e19627u, 0xb7e08f52u, + 0xb7e076c8u, 0xb7df6c1fu, 0xb7ddcaa4u, 0xb7e083ebu, 0xb7e04b8au, 0xb7e268b8u, 0xb7e28128u, 0xb7dfed8au, + 0xb7e21caau, 0xb7e018a5u, 0xb7e1af9du, 0xb7e0c750u, 0xb7e04591u, 0xb7e00279u, 0xb7e18cbbu, 0xb7e21bdeu, + 0xb7dbe711u, 0xb7dcbe7eu, 0xb7df5730u, 0xb7e10fc6u, 0xb7e092f1u, 0xb7e07c80u, 0xb7e298dau, 0xb7db2c5bu, + 0xb7e2ca3fu, 0xb7df5101u, 0xb7df4b34u, 0xb7de203au, 0xb7e325ffu, 0xb7dd9b83u, 0xb7e05efau, 0xb7e0df5au, + 0xb7ddc42fu, 0xb7db481du, 0xb7db7ad8u, 0x3989fab8u, 0x3a07b294u, 0x33409b87u, 0x399fcc52u, 0xb7d15423u, + 0xb7d0f71au, 0xb7d09a41u, 0xb7cff430u, 0xb7cfdb0du, 0xb7cff1f6u, 0xb7cfd319u, 0xb7ced8ccu, 0xb7cec8e6u, + 0xb7ce454eu, 0xb7ceff84u, 0xb7cf72d7u, 0xb7ccf5efu, 0xb7cce680u, 0xb7ccd700u, 0xb7cca109u, 0xb7cccd22u, + 0xb7cb35cfu, 0xb7cc8de5u, 0xb7cb6392u, 0xb7c9cd0bu, 0xb7ca3b24u, 0xb7c98608u, 0xb7c879eau, 0xb7cbb916u, + 0xb7ca5bf8u, 0xb7c9872eu, 0xb7c82d49u, 0xb7c95a01u, 0xb7c6f913u, 0xb7c985eeu, 0xb7c7f92eu, 0xb7c66af6u, + 0xb7c78376u, 0xb7c587e3u, 0xb7c6e214u, 0xb7c5ac98u, 0xb7c6f21fu, 0xb7c30aa2u, 0xb7c6c7d7u, 0xb7c79d48u, + 0xb7c51656u, 0xb7c3cc8bu, 0xb7c56f3cu, 0xb7c1966au, 0xb7c59a9au, 0xb7c49c88u, 0xb7c41df6u, 0xb7c194c2u, + 0xb7c28b7bu, 0xb7bf78fau, 0xb7c3d8a0u, 0xb7c41464u, 0xb7c1696fu, 0xb7c29bc2u, 0xb7c25184u, 0xb7be9b9bu, + 0xb7bfb785u, 0xb7be2f03u, 0xb7b7fc1bu, 0xb7bfdfa2u, 0x3939dca2u, 0xba027431u, 0x324086cdu, 0xb7dc8cb8u, + 0xb7c1e1a6u, 0xb7c1c2f3u, 0xb7c18e37u, 0xb7c1b950u, 0xb7c1bebbu, 0xb7c210b1u, 0xb7c1524au, 0xb7c172b4u, + 0xb7c1a957u, 0xb7c1b12bu, 0xb7c145dfu, 0xb7c17ebcu, 0xb7c130f9u, 0xb7c1ed8fu, 0xb7c05f5cu, 0xb7c0a839u, + 0xb7c03226u, 0xb7c1d835u, 0xb7c14eb9u, 0xb7c0a5e5u, 0xb7c1438eu, 0xb7c0a683u, 0xb7c257a1u, 0xb7bff578u, + 0xb7bfc72au, 0xb7c1db2fu, 0xb7c05643u, 0xb7bfef33u, 0xb7bfc23fu, 0xb7c00627u, 0xb7c1775eu, 0xb7bf585eu, + 0xb7c1703eu, 0xb7c18c4au, 0xb7c31879u, 0xb7c1c52cu, 0xb7c1e3f5u, 0xb7c21deau, 0xb7c0dcc6u, 0xb7c2400bu, + 0xb7c1025fu, 0xb7bfd420u, 0xb7bd2784u, 0xb7befc45u, 0xb7bd8969u, 0xb7bfdc24u, 0xb7bfe849u, 0xb7bf03bau, + 0xb7bc2fabu, 0xb7be396eu, 0xb7bf3806u, 0xb7c24ea8u, 0xb7bd7b61u, 0xb7bff814u, 0xb7bdec7du, 0xb7bf5de4u, + 0xb7bcacbbu, 0xb7c188d6u, 0xb7c0576bu, 0xb7bdabe2u, 0xb7bcb0c8u, 0xb98c9fbeu, 0x39fea8beu, 0xb28d52d5u, + 0xb99c816au, 0xb7b41e2au, 0xb7b42473u, 0xb7b41e28u, 0xb7b48ab9u, 0xb7b3e420u, 0xb7b54f4du, 0xb7b4924eu, + 0xb7b528fdu, 0xb7b5ee92u, 0xb7b5251du, 0xb7b5a361u, 0xb7b57895u, 0xb7b5fbbau, 0xb7b6b647u, 0xb7b62cf9u, + 0xb7b5f6ffu, 0xb7b5a277u, 0xb7b560ffu, 0xb7b6830du, 0xb7b5de57u, 0xb7b81256u, 0xb7b8a14eu, 0xb7b78cd8u, + 0xb7b77e49u, 0xb7b67e5cu, 0xb7b71430u, 0xb7b9d501u, 0xb7b7338eu, 0xb7b87545u, 0xb7b64572u, 0xb7b76c69u, + 0xb7babb82u, 0xb7ba2ed2u, 0xb7babefeu, 0xb7ba8f2eu, 0xb7bb1623u, 0xb7b9fd0du, 0xb7bcde0du, 0xb7bbe8ceu, + 0xb7ba0b5cu, 0xb7b90913u, 0xb7bce7b6u, 0xb7b68802u, 0xb7b8fb2du, 0xb7bc716bu, 0xb7b7797du, 0xb7bdbbc3u, + 0xb7b8ec30u, 0xb7bd2760u, 0xb7bc8f50u, 0xb7b99cbfu, 0xb7bd2638u, 0xb7bb2afeu, 0xb7bbab31u, 0xb7bedea8u, + 0xb7b9ef8bu, 0xb7ba8e03u, 0xb7c056a7u, 0xb7bd3593u, 0xb7bfd927u, 0xb7ba10e6u, 0xb932754du, 0xb9f78b9du, + 0xb20014cau, 0x36361960u, 0xb7a640ddu, 0xb7a6282bu, 0xb7a6d124u, 0xb7a6d2d1u, 0xb7a6e67cu, 0xb7a6cad3u, + 0xb7a67c8cu, 0xb7a776a7u, 0xb7a6bec3u, 0xb7a754b9u, 0xb7a5c248u, 0xb7a7e994u, 0xb7a6c45eu, 0xb7a73ce4u, + 0xb7aa8ec0u, 0xb7a8a670u, 0xb7a85dbbu, 0xb7a9cdc7u, 0xb7a794b9u, 0xb7a88ce5u, 0xb7a82941u, 0xb7a80856u, + 0xb7aa87efu, 0xb7a94601u, 0xb7a9de5bu, 0xb7a9a2feu, 0xb7a7548au, 0xb7a8c9bfu, 0xb7a851beu, 0xb7aab591u, + 0xb7a8b8beu, 0xb7a654b3u, 0xb7a8239au, 0xb7a72b46u, 0xb7a51b2fu, 0xb7a97f7eu, 0xb7a50195u, 0xb7a769d1u, + 0xb7a2567du, 0xb7a84fa2u, 0xb7a427efu, 0xb7a9702du, 0xb7ab8aecu, 0xb7a3cb11u, 0xb7ab87d2u, 0xb7a6ae6au, + 0xb7a4e46au, 0xb7a95ffbu, 0xb7a9ed34u, 0xb7a73a84u, 0xb7a924acu, 0xb7a62dd2u, 0xb7a81560u, 0xb7a7d941u, + 0xb7a3f432u, 0xb7a6f379u, 0xb7a8c3dau, 0xb7aae847u, 0xb7a825a9u, 0xb7a49e97u, 0xb7abfd68u, 0x3972bb21u, + 0x39ef8e0bu, 0xb1adf352u, 0x3984cf64u, 0xb79a9104u, 0xb79a6ec3u, 0xb79a50b5u, 0xb79a6ac8u, 0xb79a79a2u, + 0xb79a69abu, 0xb79a38a6u, 0xb799c034u, 0xb79b5479u, 0xb79a7dc8u, 0xb79b551eu, 0xb79a3eb7u, 0xb79b0954u, + 0xb79a9ab3u, 0xb79ab1f0u, 0xb79a9578u, 0xb799e5d7u, 0xb799698eu, 0xb79b307bu, 0xb79afc86u, 0xb79a27a6u, + 0xb79903a8u, 0xb79c6ceeu, 0xb79aa281u, 0xb799ac80u, 0xb799c2f8u, 0xb79bc73au, 0xb7989123u, 0xb79a1d07u, + 0xb799d3e8u, 0xb79cd8efu, 0xb797c014u, 0xb79a5421u, 0xb79a0fcau, 0xb79b8892u, 0xb79aff4au, 0xb79b2a71u, + 0xb79aa415u, 0xb79bff5du, 0xb797edd5u, 0xb79ea423u, 0xb797b5f6u, 0xb798756eu, 0xb79d2b93u, 0xb79ae71cu, + 0xb79d56d6u, 0xb79b6c4cu, 0xb79c2ec3u, 0xb798fb0cu, 0xb79d0273u, 0xb79c8c76u, 0xb798fcd6u, 0xb799d832u, + 0xb797a24fu, 0xb79d61ffu, 0xb79bde80u, 0xb79c9a09u, 0xb79b7d16u, 0xb79e549eu, 0xb79c8820u, 0xb797317au, + 0x3902dbfeu, 0xb9e87b0cu, 0x30b6ad3bu, 0xb7a329bfu, 0xb78f87e0u, 0xb78f99bfu, 0xb78f6b95u, 0xb78f1c35u, + 0xb78feb98u, 0xb78fd2ebu, 0xb7902b79u, 0xb78f6701u, 0xb78f1d22u, 0xb78fd3e1u, 0xb78faa4eu, 0xb78f8d6eu, + 0xb78f160du, 0xb7902c18u, 0xb790835cu, 0xb790d2b2u, 0xb7910c63u, 0xb78f4c3cu, 0xb79083afu, 0xb78ea997u, + 0xb78fddd2u, 0xb78de92fu, 0xb78ec9d1u, 0xb78fc1c1u, 0xb78ecbf2u, 0xb78f2aeau, 0xb7906ce2u, 0xb78f9c2au, + 0xb78f19e9u, 0xb78e3795u, 0xb7903a7bu, 0xb78dbb78u, 0xb78ead6bu, 0xb78faf70u, 0xb78f837bu, 0xb791caffu, + 0xb790e50eu, 0xb78ecd85u, 0xb78f021du, 0xb78f0740u, 0xb790d399u, 0xb78f781fu, 0xb7900857u, 0xb78fda01u, + 0xb7903ae7u, 0xb78ff62fu, 0xb78f9d3cu, 0xb78e5793u, 0xb78f5e35u, 0xb78fdce6u, 0xb78fed4fu, 0xb78cebcau, + 0xb78fd31bu, 0xb78feff3u, 0xb78fd133u, 0xb78edd9au, 0xb78febacu, 0xb78fe47cu, 0xb78eeb22u, 0xb79394dbu, + 0xb78f1b42u, 0xb9763193u, 0x39e218c4u, 0x31dc21d7u, 0xb98261bcu, 0xb78584a9u, 0xb7854b44u, 0xb7858e95u, + 0xb7852744u, 0xb7853b78u, 0xb7858d60u, 0xb7845178u, 0xb78598c6u, 0xb78484efu, 0xb7857ea1u, 0xb784d01fu, + 0xb7855471u, 0xb784c64au, 0xb7856539u, 0xb7850b10u, 0xb7842584u, 0xb783580au, 0xb784f116u, 0xb7857fa5u, + 0xb784fe99u, 0xb7842b09u, 0xb7831047u, 0xb783aeb8u, 0xb7821f7eu, 0xb783f5b8u, 0xb78387c5u, 0xb7840c38u, + 0xb7850659u, 0xb7852047u, 0xb7828e66u, 0xb7827ddfu, 0xb7810c2eu, 0xb7824f4au, 0xb782a0c8u, 0xb782e57du, + 0xb782c232u, 0xb77f3899u, 0xb7844b94u, 0xb7842539u, 0xb7811d75u, 0xb780b37bu, 0xb7807302u, 0xb7837dcbu, + 0xb785a427u, 0xb7807c40u, 0xb782d9a1u, 0xb7807263u, 0xb7841cb7u, 0xb780db87u, 0xb783d6e2u, 0xb7819cc6u, + 0xb78378c1u, 0xb783abb9u, 0xb7849ccau, 0xb783c46au, 0xb77d77efu, 0xb77cd4b3u, 0xb7841f7au, 0xb77e4a06u, + 0xb7810e62u, 0xb7857ba9u, 0xb8fbf920u, 0xb9db3430u, 0x318e8e8au, 0x36090345u, 0xb7794a09u, 0xb779710cu, + 0xb778da7bu, 0xb778cca2u, 0xb77857cdu, 0xb77829d1u, 0xb77a1e6bu, 0xb7788fe4u, 0xb7792228u, 0xb778e3dbu, + 0xb779a04fu, 0xb77a3ec4u, 0xb7780070u, 0xb7762746u, 0xb777bc53u, 0xb77777a9u, 0xb77a834bu, 0xb776b4feu, + 0xb77afe21u, 0xb77383b0u, 0xb772d934u, 0xb775b9c6u, 0xb777b2b9u, 0xb7789dd7u, 0xb779e752u, 0xb778e574u, + 0xb77bbaa9u, 0xb7765fefu, 0xb77c77c7u, 0xb7794c3eu, 0xb778e6f9u, 0xb779d7ebu, 0xb77bd27du, 0xb77cac8au, + 0xb77a3545u, 0xb77eba0du, 0xb77c51dbu, 0xb77a21d1u, 0xb7814ecbu, 0xb77684aeu, 0xb7763759u, 0xb7741836u, + 0xb7749c2au, 0xb7789c10u, 0xb777a74cu, 0xb77a19fau, 0xb77d2010u, 0xb779f342u, 0xb77de251u, 0xb7802f0au, + 0xb77c6485u, 0xb7759c35u, 0xb781c273u, 0xb7816694u, 0xb7803015u, 0xb77265dfu, 0xb778c191u, 0xb778f217u, + 0xb778e0f5u, 0xb77d0f41u, 0xb775efa3u, 0x3957bc68u, 0x39d62649u, 0x318fff7au, 0x39614afeu, 0xb7691d71u, + 0xb76870b8u, 0xb7689785u, 0xb768f9aeu, 0xb7690a9bu, 0xb768aa2fu, 0xb768e9a7u, 0xb767daf1u, 0xb767a537u, + 0xb7688bc7u, 0xb7667895u, 0xb7679d5bu, 0xb7662b98u, 0xb768c46bu, 0xb767b5e1u, 0xb768b3b1u, 0xb768f168u, + 0xb7677486u, 0xb76982e1u, 0xb769939cu, 0xb7658353u, 0xb76908d7u, 0xb76749a9u, 0xb7697a54u, 0xb7668864u, + 0xb7684ac9u, 0xb76a4636u, 0xb7686e60u, 0xb7645bb6u, 0xb76a12f9u, 0xb7669494u, 0xb76394d3u, 0xb767f70eu, + 0xb767ec3cu, 0xb764b94bu, 0xb76c574fu, 0xb768e926u, 0xb764aa3bu, 0xb76c70eau, 0xb7672c8bu, 0xb76b84acu, + 0xb762e1bau, 0xb76972bau, 0xb768c39au, 0xb75e8613u, 0xb765690fu, 0xb7670e61u, 0xb762200eu, 0xb768db32u, + 0xb76497efu, 0xb76661beu, 0xb7664893u, 0xb766f73au, 0xb76872b7u, 0xb76ed277u, 0xb765538bu, 0xb76bcfeau, + 0xb761b1cau, 0xb768bb02u, 0xb7692f34u, 0xb76c8a29u, 0x38b8b50bu, 0xb9cf899au, 0x321b7b85u, 0xb777d209u, + 0xb75a0e9eu, 0xb75a5770u, 0xb75ab583u, 0xb75a365au, 0xb759dc19u, 0xb75aaa5eu, 0xb75a2b32u, 0xb75b01e8u, + 0xb75a1421u, 0xb75bef1fu, 0xb75b045eu, 0xb75aaa8cu, 0xb759e4e4u, 0xb7594e8cu, 0xb75c4864u, 0xb759d797u, + 0xb75b5ce5u, 0xb759fa5au, 0xb7599cecu, 0xb759f31fu, 0xb75d2297u, 0xb75ceff3u, 0xb75b302cu, 0xb75b3c68u, + 0xb75cf714u, 0xb75bc7a3u, 0xb759fab1u, 0xb75d8bb7u, 0xb75bf8f0u, 0xb75acebdu, 0xb75e34a7u, 0xb75ba9afu, + 0xb7579ecau, 0xb7601d4au, 0xb75be3bau, 0xb75aa6ddu, 0xb76098e1u, 0xb75a575au, 0xb75c55a0u, 0xb75b2cc6u, + 0xb75f260au, 0xb7591083u, 0xb75a3445u, 0xb75affffu, 0xb75e2812u, 0xb75bd490u, 0xb760018fu, 0xb75b456du, + 0xb75def80u, 0xb75fa828u, 0xb75b24d7u, 0xb75f1208u, 0xb7604e0au, 0xb75b5f0eu, 0xb75f75b2u, 0xb75c2ac1u, + 0xb75ae0ceu, 0xb75e1a42u, 0xb75954bfu, 0xb7615ce3u, 0xb7595788u, 0xb959ac34u, 0x39ca40acu, 0xb089758fu, + 0xb95d97d7u, 0xb74bbe19u, 0xb74b6e93u, 0xb74b4556u, 0xb74a37eeu, 0xb74b916du, 0xb74a55d2u, 0xb74d43d9u, + 0xb74a9a7cu, 0xb748f835u, 0xb74cf8a3u, 0xb74b1620u, 0xb74b5840u, 0xb74e699eu, 0xb7487bfau, 0xb74ae0bbu, + 0xb74870bbu, 0xb74baaabu, 0xb74dae2bu, 0xb74d20bbu, 0xb74ba8c0u, 0xb74b03f0u, 0xb74a6d6fu, 0xb74c7e28u, + 0xb74ba982u, 0xb748d820u, 0xb7462c93u, 0xb745a449u, 0xb74c3301u, 0xb73cad73u, 0xb745b6dcu, 0xb7453d9bu, + 0xb7402c69u, 0xb74c7033u, 0xb74749f8u, 0xb7471affu, 0xb740eb26u, 0xb7534f78u, 0xb746e1c9u, 0xb74c3d3cu, + 0xb751ce56u, 0xb7479c05u, 0xb748f5fbu, 0xb745069au, 0xb753b76du, 0xb7474d0bu, 0xb74c49e0u, 0xb7484a2au, + 0xb74db752u, 0xb7437ee4u, 0xb74cd9f7u, 0xb74c2f38u, 0xb745ba5cu, 0xb746ec4cu, 0xb74ca2f7u, 0xb74cc539u, + 0xb748bc5bu, 0xb743f111u, 0xb7466098u, 0xb7488b8du, 0xb744d9b7u, 0xb744194du, 0xb8af7461u, 0xb9c4876bu, + 0xb22711b2u, 0x35cc10d0u, 0xb73f1274u, 0xb73fc7f7u, 0xb73fac38u, 0xb73d6373u, 0xb73eb1efu, 0xb73dee00u, + 0xb73e257eu, 0xb73e66f4u, 0xb73a0b02u, 0xb7401202u, 0xb7403ffbu, 0xb73dc0d9u, 0xb73ed5f1u, 0xb741aa05u, + 0xb73c5ff4u, 0xb7441202u, 0xb73c5ff4u, 0xb73ed816u, 0xb746e616u, 0xb741e7d2u, 0xb73a35c4u, 0xb7478ff9u, + 0xb73635c4u, 0xb73c1202u, 0xb741e7d2u, 0xb7454bedu, 0xb7454bedu, 0xb73d99e0u, 0xb742d3cbu, 0xb74199e0u, + 0xb747761cu, 0xb753225du, 0xb74d7a35u, 0xb7457a35u, 0xb74d7a35u, 0xb75089f1u, 0xb747d20eu, 0xb744c252u, + 0xb7371a2au, 0xb753225du, 0xb74d7a35u, 0xb75089f1u, 0xb75089f1u, 0xb73a29e6u, 0xb73fd20eu, 0xb7498e01u, + 0xb747d20eu, 0xb75245e5u, 0xb74539a2u, 0xb73fd20eu, 0xb74ae1cau, 0xb7518e01u, 0xb732c5cbu, 0xb7473629u, + 0xb737917bu, 0xb74fee0cu, 0xb7557a35u, 0xb7418e01u, 0xb74a45e5u, 0xb7473629u, 0xb7449dbdu, 0x394071b5u, + 0x39bf4b56u, 0x315f8ca5u, 0x3942569bu, 0xb734027au, 0xb7336cc8u, 0xb734a2f6u, 0xb732410fu, 0xb7337d69u, + 0xb73236ffu, 0xb732a384u, 0xb7331df5u, 0xb73466cdu, 0xb736ccbau, 0xb732ea97u, 0xb731e4bbu, 0xb735590fu, + 0xb732e9d0u, 0xb733fc26u, 0xb73099d0u, 0xb734551bu, 0xb7325697u, 0xb72e5b99u, 0xb73978ceu, 0xb731fda3u, + 0xb7335091u, 0xb7370f57u, 0xb7314ce5u, 0xb731a7acu, 0xb7379a8au, 0xb73176d8u, 0xb7329463u, 0xb72b69b9u, + 0xb7379df2u, 0xb72a4cfcu, 0xb733bb55u, 0xb72e6a05u, 0xb72e0820u, 0xb730c41bu, 0xb73036f3u, 0xb72f608fu, + 0xb7239b26u, 0xb7383dc0u, 0xb72d699fu, 0xb73417b3u, 0xb7379694u, 0xb72c52f0u, 0xb73a7802u, 0xb732c08eu, + 0xb7304f70u, 0xb72ab175u, 0xb734d819u, 0xb7299d81u, 0xb73c1479u, 0xb73416f1u, 0xb728ff83u, 0xb732dbf4u, + 0xb73fbc56u, 0xb7378a94u, 0xb73ac969u, 0xb734757cu, 0xb73c27feu, 0xb7291039u, 0xb736c0f1u, 0xb739833du, + 0x387916a2u, 0xb9bac9b3u, 0x31fb4f6au, 0xb740552eu, 0xb729246cu, 0xb729121cu, 0xb729498fu, 0xb7285c22u, + 0xb728e9c8u, 0xb728b9b8u, 0xb7298eacu, 0xb728901du, 0xb729446fu, 0xb729904bu, 0xb7282076u, 0xb728e1dfu, + 0xb7273712u, 0xb7278caau, 0xb726f455u, 0xb7271375u, 0xb727bbaeu, 0xb7276ad9u, 0xb72a7b19u, 0xb7294e40u, + 0xb725eb1cu, 0xb725a32bu, 0xb7263c99u, 0xb726af48u, 0xb727271au, 0xb723703bu, 0xb72618a5u, 0xb727998au, + 0xb724492fu, 0xb724810fu, 0xb725b398u, 0xb724d874u, 0xb71fa535u, 0xb722e365u, 0xb7271e9eu, 0xb7230153u, + 0xb725d3f1u, 0xb72793e8u, 0xb7240dfdu, 0xb723aea5u, 0xb7287791u, 0xb72b0022u, 0xb72867a0u, 0xb727cf79u, + 0xb721544fu, 0xb721837bu, 0xb727220fu, 0xb725a9fdu, 0xb721b160u, 0xb72210fdu, 0xb722e2c3u, 0xb720ff9fu, + 0xb7201fa2u, 0xb723e986u, 0xb720ffbau, 0xb7222d22u, 0xb7276409u, 0xb722359fu, 0xb722b4a0u, 0xb72271ebu, + 0xb7269b65u, 0xb9413358u, 0x39b5f28eu, 0x30954190u, 0xb93f7d53u, 0xb71f6ebfu, 0xb71f0299u, 0xb71ef011u, + 0xb71fd94fu, 0xb71e8798u, 0xb71ed61au, 0xb71f1ccdu, 0xb71fa236u, 0xb71fc5ffu, 0xb72015fcu, 0xb71f2894u, + 0xb71fda36u, 0xb7205792u, 0xb71e1ab7u, 0xb71f40efu, 0xb71f04ffu, 0xb71ed5cfu, 0xb71e9086u, 0xb71db2d8u, + 0xb71dd991u, 0xb72277a4u, 0xb722dc50u, 0xb71d822cu, 0xb7200986u, 0xb71efa8eu, 0xb723816au, 0xb721f3ddu, + 0xb722d603u, 0xb71f9d95u, 0xb72226b6u, 0xb71f6475u, 0xb71db2deu, 0xb721bc99u, 0xb720c001u, 0xb71a4954u, + 0xb71fd326u, 0xb7213e06u, 0xb72220e2u, 0xb7216ec6u, 0xb72189d8u, 0xb71d999fu, 0xb722a8c7u, 0xb7242ee2u, + 0xb724ae79u, 0xb71f6967u, 0xb7247e2bu, 0xb71de950u, 0xb71fc9edu, 0xb71f25ecu, 0xb71f6c9cu, 0xb71dc7e0u, + 0xb71ae87fu, 0xb723fe45u, 0xb71b4686u, 0xb72499d0u, 0xb71f3e5bu, 0xb729c5c3u, 0xb7213d74u, 0xb7256bf9u, + 0xb71fa5cfu, 0xb72311ecu, 0xb86a45feu, 0xb9b11f17u, 0x32061302u, 0x35a52a5du, 0xb7161d01u, 0xb716ce6du, + 0xb717283du, 0xb7162700u, 0xb7172428u, 0xb715d655u, 0xb7171aa9u, 0xb715a3cfu, 0xb7167f2bu, 0xb7173464u, + 0xb7168930u, 0xb717115au, 0xb7152360u, 0xb715449cu, 0xb716753fu, 0xb715c46cu, 0xb7169c5cu, 0xb7149da2u, + 0xb7152c49u, 0xb7144f24u, 0xb717e615u, 0xb7161c57u, 0xb71afff3u, 0xb714d0e0u, 0xb7164746u, 0xb717cde7u, + 0xb713591cu, 0xb7140d7bu, 0xb7135626u, 0xb7157fc3u, 0xb7198e9fu, 0xb71e60afu, 0xb719509cu, 0xb719e6f6u, + 0xb71885feu, 0xb71b4d87u, 0xb71d1a81u, 0xb71808a6u, 0xb720dd53u, 0xb71d255cu, 0xb71dbc7cu, 0xb7127eadu, + 0xb71c10ecu, 0xb7197268u, 0xb71e26b4u, 0xb71dc518u, 0xb71da706u, 0xb71ae540u, 0xb71612b2u, 0xb712e251u, + 0xb71b56eau, 0xb70f2c17u, 0xb71c7f63u, 0xb7153dc4u, 0xb7168229u, 0xb71ac37cu, 0xb7127b15u, 0xb71aedc5u, + 0xb71a5f8fu, 0xb715a520u, 0xb7164ab1u, 0x392d6decu, 0x39acadcbu, 0xb1412358u, 0x392a20b9u, 0xb70d7c25u, + 0xb70da359u, 0xb70df920u, 0xb70e13acu, 0xb70ce56eu, 0xb70eeddeu, 0xb70cd9fdu, 0xb70dcb8eu, 0xb70f1881u, + 0xb70f1329u, 0xb70d8db6u, 0xb70df6f7u, 0xb70cdd67u, 0xb710d781u, 0xb70ef13fu, 0xb70f57a2u, 0xb70e5e88u, + 0xb70ea17du, 0xb70ed84eu, 0xb70e9874u, 0xb70e04a3u, 0xb70f6b7du, 0xb70ded1eu, 0xb70ed4c7u, 0xb713efdfu, + 0xb70d025cu, 0xb70f5b7fu, 0xb70e04e5u, 0xb7112331u, 0xb70f9763u, 0xb710e3c2u, 0xb70b2e24u, 0xb712b079u, + 0xb7131e84u, 0xb7142c49u, 0xb70a1be7u, 0xb70db30cu, 0xb70bfd57u, 0xb71250b3u, 0xb70a8239u, 0xb7163e17u, + 0xb70f38bdu, 0xb709b8ebu, 0xb7135622u, 0xb712269cu, 0xb710f099u, 0xb7136cafu, 0xb70cf155u, 0xb7129004u, + 0xb7109205u, 0xb710d2adu, 0xb70eb77eu, 0xb70c5b93u, 0xb70f3774u, 0xb7142d66u, 0xb70f1fcdu, 0xb70d0c29u, + 0xb706c1f9u, 0xb71569cbu, 0xb711ebeeu, 0xb714b08au, 0x3817fac0u, 0xb9a83a1bu, 0x31acc59bu, 0xb71812cbu, + 0xb705978bu, 0xb70591f7u, 0xb7054848u, 0xb7051c17u, 0xb7057dc7u, 0xb705d69bu, 0xb705ea5cu, 0xb704fe8cu, + 0xb7050ab7u, 0xb7057495u, 0xb7059f44u, 0xb7059594u, 0xb70561b5u, 0xb704c21fu, 0xb7068f5eu, 0xb7066767u, + 0xb706c40cu, 0xb70607aeu, 0xb700fc3cu, 0xb7033976u, 0xb7037dc8u, 0xb704cea1u, 0xb7067681u, 0xb704aa8bu, + 0xb7070b1fu, 0xb7067fb1u, 0xb70463edu, 0xb705c06au, 0xb707e455u, 0xb7057eb4u, 0xb705369au, 0xb70428d2u, + 0xb7067ab2u, 0xb703106au, 0xb703d904u, 0xb7052852u, 0xb70423eeu, 0xb704fcbdu, 0xb70748b3u, 0xb7094e59u, + 0xb70263fcu, 0xb70058c1u, 0xb7097775u, 0xb6fea8b5u, 0xb70c2d8au, 0xb70211ceu, 0xb70f276eu, 0xb7025ffdu, + 0xb706991bu, 0xb70336fdu, 0xb701414fu, 0xb702bc54u, 0xb701b894u, 0xb704b715u, 0xb7075c20u, 0xb7012c08u, + 0xb700f6a6u, 0xb6ff1dc7u, 0xb70ad1d3u, 0xb70316efu, 0xb70192c2u, 0xb92d2cc0u, 0x39a42d5cu, 0xb04db00du, + 0xb927dc29u, 0xb6fc7c6cu, 0xb6fc10b1u, 0xb6fd5c5eu, 0xb6fc0deau, 0xb6fa418du, 0xb6feba68u, 0xb6fa3f67u, + 0xb6f9c244u, 0xb6f797dau, 0xb6fb006au, 0xb6fa447fu, 0xb6fcf7f5u, 0xb6facbc2u, 0xb6fbd1c2u, 0xb6fc81bfu, + 0xb6f70accu, 0xb6f6e416u, 0xb6ffb70au, 0xb6ff765du, 0xb6fc2c81u, 0xb7009674u, 0xb6f5cd9eu, 0xb6fd42c4u, + 0xb6f9585cu, 0xb6f9b613u, 0xb6f79921u, 0xb6f8fa56u, 0xb6f1edfeu, 0xb6f1a66eu, 0xb6fc8cc5u, 0xb6f17af5u, + 0xb6fce2f3u, 0xb6f2a650u, 0xb6fbb76du, 0xb7031051u, 0xb6f29fc1u, 0xb6eb5477u, 0xb6ee97d4u, 0xb6ffc4c3u, + 0xb6eefdb6u, 0xb6fbfd80u, 0xb6f82df3u, 0xb6eeac84u, 0xb6f2f577u, 0xb701523fu, 0xb6f21a8du, 0xb70181c2u, + 0xb6f37c00u, 0xb6fd53ccu, 0xb6f8b034u, 0xb6e68204u, 0xb6f2f7fau, 0xb6f40539u, 0xb70029f6u, 0xb6ed7fe1u, + 0xb6fbcf4cu, 0xb6f7c151u, 0xb6fe7ba9u, 0xb6e3872du, 0xb701b56bu, 0xb6f5a0c2u, 0xb811254du, 0xb99fe301u, + 0xb1d3ae04u, 0x3582fb58u, 0xb6efc7afu, 0xb6ef6e08u, 0xb6ed3558u, 0xb6ec8e51u, 0xb6eec543u, 0xb6eccd1au, + 0xb6eef84cu, 0xb6ecd0a1u, 0xb6f4bf00u, 0xb6f6dfb9u, 0xb6f6d97bu, 0xb6f46eecu, 0xb6f2a35fu, 0xb6f5bfdbu, + 0xb6f325abu, 0xb6f7ee52u, 0xb6e9e81du, 0xb6f89fbcu, 0xb6f90612u, 0xb6f1ad60u, 0xb6e0c83bu, 0xb6e726c2u, + 0xb6eb74d3u, 0xb6f22968u, 0xb6f4a281u, 0xb6e8ff89u, 0xb6f1e23cu, 0xb6f09af5u, 0xb6f8fb32u, 0xb6f3a9b9u, + 0xb6f6888au, 0xb6e35dcdu, 0xb7033569u, 0xb6ff2379u, 0xb6ec6e8bu, 0xb6fcfd09u, 0xb6f5b967u, 0xb6edd161u, + 0xb704609bu, 0xb6fd0e7au, 0xb6f89be9u, 0xb7020a11u, 0xb6e88124u, 0xb6fdec1bu, 0xb7039467u, 0xb6fcb474u, + 0xb6fd3aeau, 0xb6e9602fu, 0xb6f2c829u, 0xb6f29a7eu, 0xb6dc09e1u, 0xb6ee6d5eu, 0xb7019d3cu, 0xb705d529u, + 0xb6fe7b8cu, 0xb6fc6d0fu, 0xb70b466du, 0xb6fb0833u, 0xb6ff6003u, 0xb7049c95u, 0xb702be0eu, 0x391c87a0u, + 0x399bf6b1u, 0x31178dfeu, 0x3916de8eu, 0xb6e1a01cu, 0xb6e2039fu, 0xb6e1964eu, 0xb6e08f82u, 0xb6df66e5u, + 0xb6df8412u, 0xb6de67f9u, 0xb6e09b22u, 0xb6de4614u, 0xb6dd776cu, 0xb6deddb3u, 0xb6e21334u, 0xb6e04f29u, + 0xb6dccbddu, 0xb6de28bbu, 0xb6dc296cu, 0xb6da813fu, 0xb6e07697u, 0xb6d80f9du, 0xb6de73fdu, 0xb6dcc617u, + 0xb6dccfa0u, 0xb6e27255u, 0xb6d628fcu, 0xb6da4c2fu, 0xb6daaa4cu, 0xb6dbd470u, 0xb6d9808au, 0xb6e522abu, + 0xb6d2a3a5u, 0xb6dc4710u, 0xb6d75d88u, 0xb6dbe140u, 0xb6d7269bu, 0xb6da934eu, 0xb6e350edu, 0xb6d91c7eu, + 0xb6cbb291u, 0xb6e69245u, 0xb6de0465u, 0xb6dc71efu, 0xb6c6d801u, 0xb6dae38eu, 0xb6cb61f0u, 0xb6d4d21fu, + 0xb6d5d29eu, 0xb6ee6fe2u, 0xb6ebfb25u, 0xb6d94eb9u, 0xb6d36a85u, 0xb6c6192au, 0xb6d076c6u, 0xb6e0d22fu, + 0xb6cd9eecu, 0xb6e1fb69u, 0xb6c41adbu, 0xb6d7168fu, 0xb6caec85u, 0xb6cbdaccu, 0xb6d3e994u, 0xb6ecd3cbu, + 0x379dc9a8u, 0xb997defbu, 0x319ab9bcu, 0xb6f40cc4u, 0xb6d6f737u, 0xb6d72d48u, 0xb6d77c58u, 0xb6d8c446u, + 0xb6d7bdf9u, 0xb6d6fc94u, 0xb6d87026u, 0xb6d5ec65u, 0xb6d6e959u, 0xb6d76eb9u, 0xb6d2ee55u, 0xb6d69b0eu, + 0xb6d3c690u, 0xb6d5f470u, 0xb6d9f3a2u, 0xb6d4c26eu, 0xb6d5e1b5u, 0xb6d8b2fdu, 0xb6d8b44fu, 0xb6db2694u, + 0xb6e08167u, 0xb6d1fedau, 0xb6d4acaeu, 0xb6d78277u, 0xb6d3c866u, 0xb6d50cfbu, 0xb6dbee42u, 0xb6dc44c5u, + 0xb6dd9d61u, 0xb6db07adu, 0xb6d99081u, 0xb6d98044u, 0xb6d8d5adu, 0xb6d80825u, 0xb6dda32du, 0xb6dc2bafu, + 0xb6e06cc5u, 0xb6d1f47au, 0xb6d97109u, 0xb6d4577bu, 0xb6d349feu, 0xb6d3951bu, 0xb6d7bab0u, 0xb6caf36cu, + 0xb6dc13c2u, 0xb6df729eu, 0xb6d664ffu, 0xb6d2825au, 0xb6d36b9cu, 0xb6d00f0au, 0xb6dc00adu, 0xb6d5cd08u, + 0xb6dfc7adu, 0xb6da22bbu, 0xb6d80044u, 0xb6db4b5au, 0xb6d68652u, 0xb6dcc8eau, 0xb6da979cu, 0xb6dee84du, + 0xb6d96bb2u, 0xb91bacb6u, 0x3994409cu, 0xb166cf17u, 0xb9150888u, 0xb6cc0116u, 0xb6cbaabeu, 0xb6caf380u, + 0xb6cbc030u, 0xb6cbd2c9u, 0xb6cbaf96u, 0xb6cd0eafu, 0xb6c97915u, 0xb6cd130fu, 0xb6cb07f3u, 0xb6d15c61u, + 0xb6c89ae5u, 0xb6c5c5a0u, 0xb6cb3d5cu, 0xb6cd9526u, 0xb6c54f1du, 0xb6cc1b26u, 0xb6cc09dcu, 0xb6cbe711u, + 0xb6cf8e34u, 0xb6cccde6u, 0xb6cccadeu, 0xb6cbe54cu, 0xb6ce3f76u, 0xb6c3978bu, 0xb6cccd09u, 0xb6ce9138u, + 0xb6c99542u, 0xb6cf2d60u, 0xb6cb00b5u, 0xb6d07ac3u, 0xb6d10ca9u, 0xb6d81779u, 0xb6ca04bbu, 0xb6d072ebu, + 0xb6cecb92u, 0xb6cc50feu, 0xb6d591bfu, 0xb6ca682eu, 0xb6d315f4u, 0xb6cd4c74u, 0xb6ce0e73u, 0xb6d68747u, + 0xb6c7b941u, 0xb6cb5a27u, 0xb6d9b966u, 0xb6d0ed66u, 0xb6d0f674u, 0xb6c7b0a0u, 0xb6d32413u, 0xb6d48308u, + 0xb6d4c1a1u, 0xb6d7594au, 0xb6c35a4du, 0xb6bda6ccu, 0xb6dce92du, 0xb6cb56c7u, 0xb6d4d1f7u, 0xb6d160d4u, + 0xb6d7cbf3u, 0xb6d4c94fu, 0xb78dcb36u, 0xb990fa9du, 0xb231a103u, 0x3551333eu, 0xb6c12707u, 0xb6c229abu, + 0xb6c11408u, 0xb6bff97bu, 0xb6c14411u, 0xb6c19065u, 0xb6c3a55cu, 0xb6bcf1d5u, 0xb6bfb955u, 0xb6c2863fu, + 0xb6c0de15u, 0xb6c0ca0au, 0xb6bdd0b9u, 0xb6bfd614u, 0xb6bf32e6u, 0xb6cac60fu, 0xb6d152bfu, 0xb6c49fe4u, + 0xb6c47fc8u, 0xb6c31ea2u, 0xb6c4ad0bu, 0xb6cd1565u, 0xb6bfe9d0u, 0xb6c6b449u, 0xb6d027e1u, 0xb6c48f14u, + 0xb6c8dbb4u, 0xb6c76d26u, 0xb6c84336u, 0xb6c8a235u, 0xb6c82495u, 0xb6d320d3u, 0xb6d26bc0u, 0xb6d3efdcu, + 0xb6d6cacfu, 0xb6cd2bd8u, 0xb6d5b654u, 0xb6cf8777u, 0xb6c04276u, 0xb6ca32a0u, 0xb6c7814cu, 0xb6be3ad9u, + 0xb6cb03a2u, 0xb6bd25c0u, 0xb6c19c7cu, 0xb6ce74f2u, 0xb6c24e48u, 0xb6d5b2c4u, 0xb6caa257u, 0xb6c6b067u, + 0xb6cd2a09u, 0xb6c3a9c9u, 0xb6c22e76u, 0xb6c56601u, 0xb6b60006u, 0xb6bfd2e2u, 0xb6c61114u, 0xb6cad740u, + 0xb6bbb8d8u, 0xb6b89a09u, 0xb6c939f7u, 0x390e67a2u, 0x398d6eddu, 0x3118f5e8u, 0x39074c0au, 0xb6b71e52u, + 0xb6b66fa9u, 0xb6b6c7b3u, 0xb6b68258u, 0xb6b758c5u, 0xb6b71f4fu, 0xb6b4cddfu, 0xb6b79b97u, 0xb6baa191u, + 0xb6b8f613u, 0xb6b696a5u, 0xb6b4c9fbu, 0xb6b8618eu, 0xb6b75f98u, 0xb6b66b86u, 0xb6b48feau, 0xb6b64a04u, + 0xb6b7cf0bu, 0xb6b3ea27u, 0xb6b2d260u, 0xb6b9d09du, 0xb6b21d92u, 0xb6b59e66u, 0xb6b00091u, 0xb6ba4dc1u, + 0xb6b78234u, 0xb6ad1553u, 0xb6b5995fu, 0xb6b212b6u, 0xb6af9f1eu, 0xb6a7ea04u, 0xb6bbb571u, 0xb6b5e386u, + 0xb6ad51dau, 0xb6b1328cu, 0xb6aff7f0u, 0xb6b38441u, 0xb6ae4a80u, 0xb6ae6823u, 0xb6b06af2u, 0xb6b42254u, + 0xb6b2174au, 0xb6af12c9u, 0xb6ab91e2u, 0xb6ad474au, 0xb6bac851u, 0xb6a804edu, 0xb6b188acu, 0xb6bace63u, + 0xb6b41b5au, 0xb6b8cb39u, 0xb6b48997u, 0xb6ac505fu, 0xb6bb29cdu, 0xb6a68f4cu, 0xb6b7eb92u, 0xb6ac270au, + 0xb6ad106au, 0xb6b7947cu, 0xb6afb39cu, 0xb6a8c304u, 0x369371f4u, 0xb989c1cfu, 0xb0a8e546u, 0xb6c65c18u, + 0xb6af34f2u, 0xb6af1985u, 0xb6adb5b9u, 0xb6ae529eu, 0xb6ae3563u, 0xb6ae2b77u, 0xb6af7bccu, 0xb6ab40e3u, + 0xb6b17628u, 0xb6b1048au, 0xb6b148f1u, 0xb6adf291u, 0xb6b0a283u, 0xb6b239cfu, 0xb6ad0995u, 0xb6b3ac4eu, + 0xb6b03e0eu, 0xb6b44081u, 0xb6b262a0u, 0xb6aaf1c1u, 0xb6ac6843u, 0xb6b0925bu, 0xb6aecc4bu, 0xb6aa23b9u, + 0xb6b3d80au, 0xb6b14248u, 0xb6b7b29cu, 0xb6aef46du, 0xb6b0f28du, 0xb6ad0f57u, 0xb6a64000u, 0xb6aab69bu, + 0xb6aef38du, 0xb6b012d2u, 0xb6af60f0u, 0xb69fb519u, 0xb6a55cb3u, 0xb6b69549u, 0xb6b55a2du, 0xb6b17847u, + 0xb6aab6f6u, 0xb6aee45bu, 0xb6b7d98fu, 0xb6aa5579u, 0xb6af2a84u, 0xb6af98c2u, 0xb6bcc198u, 0xb6b0d329u, + 0xb6b4d12du, 0xb6b1ecddu, 0xb6bfc7b9u, 0xb6b9990fu, 0xb6c18b0cu, 0xb6add2b1u, 0xb6b39bffu, 0xb6b57be2u, + 0xb6c48afdu, 0xb6b0403bu, 0xb6b89c50u, 0xb6ad18a6u, 0xb6ad0ea1u, 0xb90ce1fbu, 0x398666dbu, 0xb1c328c5u, + 0xb905d226u, 0xb6a60967u, 0xb6a60a6bu, 0xb6a419c3u, 0xb6a80a3bu, 0xb6a7f32bu, 0xb6a80d49u, 0xb6a7fcaau, + 0xb6a81c8bu, 0xb6a80ea1u, 0xb6aff716u, 0xb6a817f8u, 0xb6a03c09u, 0xb6a844f2u, 0xb6a7fa0du, 0xb6a81e18u, + 0xb69ff6c4u, 0xb6a0545du, 0xb6a00c26u, 0xb69fd3b6u, 0xb6c01049u, 0xb6afe0e5u, 0xb6a038ccu, 0xb69ff8deu, + 0xb68ffc0bu, 0xb6c001feu, 0xb69fede3u, 0xb6afdbefu, 0xb69ff92bu, 0xb6a05dbcu, 0xb6a02e65u, 0xb6bfdfe7u, + 0xb69f65bcu, 0xb6a042a7u, 0xb69fbc68u, 0xb69f8fbcu, 0xb69fd1a9u, 0xb69fd741u, 0xb6bf98f4u, 0xb6c040e4u, + 0xb6a012bbu, 0xb69fc137u, 0xb6bec223u, 0xb6c0093du, 0xb6a02ce3u, 0xb69fe8adu, 0xb69f84a9u, 0xb6c0349eu, + 0xb6a0cee2u, 0xb6c059e6u, 0xb6c02c5cu, 0xb6a0760du, 0xb6a096b8u, 0xb69feedcu, 0xb6a0752eu, 0xb6bf9a72u, + 0xb67ff518u, 0xb6814149u, 0xb6a050dbu, 0xb6e0354eu, 0xb680447fu, 0xb6a1060au, 0xb630b611u, 0xb9831dd0u, + 0x33000000u, 0x35200000u, 0xb6a00000u, 0xb6a00000u, 0xb6a00000u, 0xb6980000u, 0xb6a00000u, 0xb6a00000u, + 0xb6a00000u, 0xb6800000u, 0xb6a00000u, 0xb6a00000u, 0xb6900000u, 0xb6900000u, 0xb6900000u, 0xb6900000u, + 0xb6a00000u, 0xb6a00000u, 0xb6a00000u, 0xb6800000u, 0xb6a00000u, 0xb6800000u, 0xb6800000u, 0xb6800000u, + 0xb6800000u, 0xb6a00000u, 0xb6800000u, 0xb6a00000u, 0xb6a00000u, 0xb6a00000u, 0xb6a00000u, 0xb6a00000u, + 0xb6a00000u, 0xb7000000u, 0xb7000000u, 0xb7000000u, 0xb7000000u, 0xb6c00000u, 0xb7000000u, 0xb7000000u, + 0xb7000000u, 0xb7000000u, 0xb7000000u, 0xb6c00000u, 0xb6c00000u, 0xb7000000u, 0xb7000000u, 0xb6c00000u, + 0xb7000000u, 0xb6800000u, 0xb6c00000u, 0xb7000000u, 0xb6c00000u, 0xb6c00000u, 0xb6c00000u, 0xb6c00000u, + 0xb6c00000u, 0xb6800000u, 0xb7000000u, 0xb6c00000u, 0xb6800000u, 0xb6c00000u, 0xb6800000u, 0x39000000u, + 0x39800000u, 0xb234345fu, 0x38f51d94u, 0xb6942babu, 0xb6956519u, 0xb698acddu, 0xb695d5beu, 0xb6958d13u, + 0xb6957594u, 0xb6913352u, 0xb69ec9bdu, 0xb697b623u, 0xb69be932u, 0xb6975c3bu, 0xb69fcae5u, 0xb694ce1eu, + 0xb6957d87u, 0xb6969bdbu, 0xb69ebf26u, 0xb6a1bf37u, 0xb6ac1972u, 0xb69b3327u, 0xb68bba5bu, 0xb6ae2d69u, + 0xb695d778u, 0xb68839fdu, 0xb6a2dfa0u, 0xb6a58808u, 0xb6a01661u, 0xb6a2a6e4u, 0xb6a56695u, 0xb691ccfau, + 0xb6a2e0b7u, 0xb691e461u, 0xb6b40ca8u, 0xb68c3bb9u, 0xb6b9a842u, 0xb6857d68u, 0xb6ca52bbu, 0xb6965a26u, + 0xb68d6e01u, 0xb6ac08b6u, 0xb6be7cffu, 0xb6ac1e40u, 0xb6abd0b5u, 0xb68fba45u, 0xb657bf7eu, 0xb67c7c02u, + 0xb6cdbdabu, 0xb69a6f78u, 0xb6bdac30u, 0xb689390du, 0xb6575bf3u, 0xb6a53abdu, 0xb6ae9e7cu, 0xb6a9f876u, + 0xb696809cu, 0xb681a7b5u, 0xb6b1eb84u, 0xb69f3417u, 0xb6a0e61fu, 0xb68ccc68u, 0xb69f7e0fu, 0xb6c0234cu, + 0xb6efe3a8u, 0xb979cff2u, 0xb257fca4u, 0xb6a30797u, 0xb68f34feu, 0xb68f01d1u, 0xb68f1641u, 0xb68fcc0au, + 0xb68f5c89u, 0xb68ef6f9u, 0xb6926036u, 0xb6900eaeu, 0xb692137au, 0xb69176acu, 0xb68f2060u, 0xb68ff05au, + 0xb6935e05u, 0xb6927b79u, 0xb6929f99u, 0xb6926632u, 0xb6952fb8u, 0xb6946977u, 0xb6903ecau, 0xb694a8e9u, + 0xb691d944u, 0xb68e656eu, 0xb68f75feu, 0xb6901363u, 0xb6933148u, 0xb6916604u, 0xb691ac4eu, 0xb6967c83u, + 0xb692d38bu, 0xb6985dc9u, 0xb695d679u, 0xb697afd7u, 0xb6920becu, 0xb68fa660u, 0xb69ba3bfu, 0xb68f27fcu, + 0xb6982c5fu, 0xb6958051u, 0xb6a4d643u, 0xb6915ffdu, 0xb698f74au, 0xb68eee0eu, 0xb6983ee9u, 0xb69e1bf7u, + 0xb6929bfdu, 0xb6904c1eu, 0xb686e1b2u, 0xb693398cu, 0xb6916f51u, 0xb68965cau, 0xb696c7dbu, 0xb68ce5acu, + 0xb68c6f24u, 0xb68a4393u, 0xb699ab31u, 0xb69b1240u, 0xb6974a74u, 0xb695e656u, 0xb69832aeu, 0xb69d263cu, + 0xb69ba398u, 0xb8fe2df0u, 0x3973c7dbu, 0x3107f651u, 0xb8f2bd2du, 0xb688d671u, 0xb6888bf5u, 0xb689ec1bu, + 0xb6887faeu, 0xb687a7a5u, 0xb68932a1u, 0xb6882f7bu, 0xb689301fu, 0xb689dff7u, 0xb687dd69u, 0xb68ade59u, + 0xb68b28c8u, 0xb6894b7fu, 0xb68a8c92u, 0xb689e7deu, 0xb68accf0u, 0xb688fa62u, 0xb689cf2bu, 0xb6875ce1u, + 0xb68916d4u, 0xb68d9b29u, 0xb6838993u, 0xb685067eu, 0xb687229bu, 0xb687ee16u, 0xb68b34c0u, 0xb68aa2bbu, + 0xb68b664du, 0xb692e8e0u, 0xb68c012eu, 0xb68ea488u, 0xb6917a79u, 0xb6868bb2u, 0xb67a923fu, 0xb6943286u, + 0xb6874a9fu, 0xb6874801u, 0xb68ef126u, 0xb68209efu, 0xb67b0f35u, 0xb6862548u, 0xb6916f6au, 0xb6828251u, + 0xb68d98dcu, 0xb6928bf7u, 0xb689ade3u, 0xb6813e8du, 0xb68235a8u, 0xb6846d0bu, 0xb68fabdeu, 0xb68da817u, + 0xb67fdfd2u, 0xb693f4f5u, 0xb69b3323u, 0xb6841cbbu, 0xb6646bf1u, 0xb68488ceu, 0xb68a51fau, 0xb69002dau, + 0xb685f390u, 0xb689f58eu, 0x371fc73au, 0xb96d66f6u, 0xb15c0461u, 0x3512ec3fu, 0xb6822ea7u, 0xb682c4e5u, + 0xb6835a5eu, 0xb6815e39u, 0xb681215bu, 0xb680618fu, 0xb680ad4au, 0xb6831189u, 0xb683c434u, 0xb6806971u, + 0xb682ca34u, 0xb6800144u, 0xb67e1acfu, 0xb67a0937u, 0xb6843d48u, 0xb6849d48u, 0xb6794889u, 0xb6857340u, + 0xb6831eaau, 0xb67c5b60u, 0xb680ecdfu, 0xb684993du, 0xb6855eebu, 0xb682fdecu, 0xb6757d95u, 0xb67c799du, + 0xb6729318u, 0xb6751799u, 0xb6715aa1u, 0xb66cb4ebu, 0xb65e77bdu, 0xb6545f66u, 0xb6481fffu, 0xb63817dcu, + 0xb64cd9aau, 0xb65c5175u, 0xb650c78cu, 0xb65331f6u, 0xb6599ec8u, 0xb662d9f0u, 0xb64dbae1u, 0xb6751526u, + 0xb65a3463u, 0xb671c6e5u, 0xb63e5108u, 0xb6444248u, 0xb6311ed1u, 0xb6548db5u, 0xb6539f19u, 0xb66c3daau, + 0xb65caa8au, 0xb66a8d1bu, 0xb63d4948u, 0xb65c4beeu, 0xb67a500cu, 0xb65f7e5du, 0xb655bd54u, 0xb6465f0du, + 0xb65c03e2u, 0xb66d8ea3u, 0xb64492c1u, 0x38e94f8cu, 0x39687ffau, 0x3232eba3u, 0x38e033f1u, 0xb676a27bu, + 0xb6781094u, 0xb679cda3u, 0xb6759355u, 0xb6754c31u, 0xb675b79fu, 0xb6756888u, 0xb677accdu, 0xb67705b2u, + 0xb678fb97u, 0xb674290au, 0xb6742e45u, 0xb66ec819u, 0xb66e6860u, 0xb6749be0u, 0xb673fa41u, 0xb66db4abu, + 0xb6774e2fu, 0xb66a01b1u, 0xb67adb04u, 0xb6714453u, 0xb67001a4u, 0xb67879ecu, 0xb67c7909u, 0xb66a5d66u, + 0xb6724776u, 0xb66ff898u, 0xb66e4f4fu, 0xb6666316u, 0xb67cf501u, 0xb678744du, 0xb67c3a7fu, 0xb6713a6au, + 0xb672276au, 0xb66b5a7bu, 0xb66882e1u, 0xb66a6422u, 0xb6675f05u, 0xb6698233u, 0xb673de67u, 0xb665b53cu, + 0xb6755b03u, 0xb66335d2u, 0xb66a8293u, 0xb66ac7e2u, 0xb67a206fu, 0xb66fdfdfu, 0xb668a3b3u, 0xb65c229cu, + 0xb6506cb2u, 0xb6526ac3u, 0xb679fa2bu, 0xb65b2f8cu, 0xb65f57feu, 0xb6612f8cu, 0xb66b2ee7u, 0xb65099d8u, + 0xb685165cu, 0xb66b3623u, 0xb682b87fu, 0xb64e5851u, 0xb7944947u, 0xb9618b50u, 0xb1500edcu, 0xb6870c9du, + 0xb66e8061u, 0xb66dbd80u, 0xb66dbf78u, 0xb66cf67bu, 0xb6701cf5u, 0xb6718664u, 0xb66cebf0u, 0xb66ffeabu, + 0xb66fb676u, 0xb66b0745u, 0xb66660c2u, 0xb66e78bcu, 0xb66d40a4u, 0xb66b8186u, 0xb66f36a5u, 0xb671f346u, + 0xb662651fu, 0xb66d1642u, 0xb66a17efu, 0xb665ee94u, 0xb66c2d80u, 0xb6659eb7u, 0xb66a2414u, 0xb66fcc92u, + 0xb66b3967u, 0xb66bd98du, 0xb66b6606u, 0xb66a5f3fu, 0xb659a28du, 0xb66a861bu, 0xb66554eau, 0xb66e43f9u, + 0xb670828cu, 0xb668926au, 0xb67af9e7u, 0xb6773fb5u, 0xb667a204u, 0xb66c5f59u, 0xb6633938u, 0xb65ea921u, + 0xb65ab0e3u, 0xb6734d4du, 0xb672383cu, 0xb65768bdu, 0xb66164cdu, 0xb665dc5cu, 0xb66825b2u, 0xb663181bu, + 0xb67aace7u, 0xb668fc22u, 0xb66fe32eu, 0xb67cc2ffu, 0xb664e4dfu, 0xb678763fu, 0xb6796d15u, 0xb66dfd8fu, + 0xb6539069u, 0xb6659477u, 0xb660a584u, 0xb671f3aeu, 0xb65c31f7u, 0xb8e4bf60u, 0x395cefc3u, 0xb2156c98u, + 0xb8de3247u, 0xb665d99du, 0xb668499fu, 0xb666fe74u, 0xb665f3e1u, 0xb667e671u, 0xb66a524cu, 0xb66aeda5u, + 0xb66be2c9u, 0xb66c7e50u, 0xb6693371u, 0xb66c5ae2u, 0xb6768409u, 0xb671c88au, 0xb675e9b2u, 0xb66b68beu, + 0xb663e8ddu, 0xb67b285cu, 0xb67d1f2au, 0xb66f9459u, 0xb66fc427u, 0xb66dd23eu, 0xb676e3a8u, 0xb67fdf6au, + 0xb6755c62u, 0xb68631f5u, 0xb67c83e4u, 0xb671010du, 0xb66ef543u, 0xb6806b3eu, 0xb6825709u, 0xb679e263u, + 0xb6703567u, 0xb67c8450u, 0xb68574efu, 0xb686b3d0u, 0xb6811955u, 0xb6805c80u, 0xb68614efu, 0xb6810295u, + 0xb668062du, 0xb66f5aaeu, 0xb67e58d3u, 0xb699cb46u, 0xb66fea88u, 0xb680a973u, 0xb68383a2u, 0xb680e2eau, + 0xb68117e5u, 0xb68f9571u, 0xb6852dbbu, 0xb6877a2au, 0xb6903619u, 0xb67d50dfu, 0xb6a0643cu, 0xb687e43fu, + 0xb6a4b85du, 0xb6741856u, 0xb68347d4u, 0xb68ca278u, 0xb68e29e3u, 0xb682fc4du, 0x3795a8c4u, 0xb9583395u, + 0x31a9cf58u, 0x34e3e99eu, 0xb657bc7bu, 0xb6585247u, 0xb6565762u, 0xb654456au, 0xb651e0e4u, 0xb6539812u, + 0xb657ceb4u, 0xb65188c1u, 0xb65c748du, 0xb64e261fu, 0xb6431a43u, 0xb651fca1u, 0xb6623aa3u, 0xb65c031bu, + 0xb6475b8cu, 0xb6690849u, 0xb6639c65u, 0xb65ba2e3u, 0xb653c6c8u, 0xb64c877bu, 0xb6460cb9u, 0xb63ed0c4u, + 0xb6501622u, 0xb6436752u, 0xb653a937u, 0xb647b2e0u, 0xb6497874u, 0xb63b9a33u, 0xb6410ae9u, 0xb6452f2eu, + 0xb6453558u, 0xb647bfccu, 0xb64d68f7u, 0xb628398du, 0xb64da1ebu, 0xb6312dbeu, 0xb64ee5dau, 0xb632d731u, + 0xb6033551u, 0xb633c0a0u, 0xb654aa88u, 0xb5e915dcu, 0xb645c79bu, 0xb63c6d42u, 0xb62c0603u, 0xb6463b1du, + 0xb651a9d7u, 0xb629f3f9u, 0xb63cc3deu, 0xb648a788u, 0xb63916bdu, 0xb6319f0eu, 0xb6534723u, 0xb61cc708u, + 0xb60c38dfu, 0xb61bdd2fu, 0xb60d3e61u, 0xb62c4272u, 0xb604326au, 0xb602455du, 0xb6175ad4u, 0x38d46df7u, + 0x3951de70u, 0xaed6df3du, 0x38ceb8d5u, 0xb64eaa79u, 0xb6507727u, 0xb64f1cf0u, 0xb64b91afu, 0xb64c1c08u, + 0xb64cafc4u, 0xb649154eu, 0xb649f6f8u, 0xb6497f4cu, 0xb64c7b12u, 0xb649799du, 0xb64ce65bu, 0xb64d7c7fu, + 0xb64b97c7u, 0xb652e31fu, 0xb647d136u, 0xb6383e51u, 0xb64bb41fu, 0xb645d5d1u, 0xb6481523u, 0xb654e0f9u, + 0xb6550e32u, 0xb63d89aeu, 0xb643a105u, 0xb6462142u, 0xb63695beu, 0xb64bae7au, 0xb640d281u, 0xb6346342u, + 0xb6524b40u, 0xb645b2e7u, 0xb63ebeefu, 0xb647dcb2u, 0xb6478b64u, 0xb6385a1eu, 0xb63cf5eau, 0xb63803cau, + 0xb62e0962u, 0xb638d81eu, 0xb646ac56u, 0xb64c7ccbu, 0xb64c6496u, 0xb64b8654u, 0xb628b41du, 0xb62eca1au, + 0xb6318153u, 0xb6517fb5u, 0xb6409e6au, 0xb6466e52u, 0xb631d22du, 0xb64977acu, 0xb6361c07u, 0xb638cf21u, + 0xb6102557u, 0xb628e070u, 0xb642f9a5u, 0xb63fe748u, 0xb665bbeeu, 0xb6543f2cu, 0xb634a8b2u, 0xb63802dfu, + 0xb7d9179eu, 0xb94c42f8u, 0xb28cbdcau, 0xb6646661u, 0xb64a8742u, 0xb64bc25au, 0xb64ea918u, 0xb64ca9c2u, + 0xb6525334u, 0xb650e40eu, 0xb6530ed3u, 0xb653d7cdu, 0xb65c8d56u, 0xb6543ea4u, 0xb65f5845u, 0xb65bf8e2u, + 0xb65e3103u, 0xb65e769bu, 0xb65bcf9bu, 0xb65f26d4u, 0xb65d47e3u, 0xb6587298u, 0xb6611b8au, 0xb66e2111u, + 0xb66b4b27u, 0xb6640bd0u, 0xb6732b6au, 0xb66ae19bu, 0xb66d8ddfu, 0xb66ff081u, 0xb66d5880u, 0xb6678b95u, + 0xb6827067u, 0xb6783482u, 0xb66fb8eau, 0xb68484a6u, 0xb67e47cbu, 0xb6730567u, 0xb67a4000u, 0xb67e245eu, + 0xb680bf51u, 0xb689195bu, 0xb681617fu, 0xb68ca968u, 0xb689569fu, 0xb68bc640u, 0xb6870670u, 0xb6867cb9u, + 0xb685cf11u, 0xb68f9a8cu, 0xb68a152bu, 0xb67d3a65u, 0xb6874815u, 0xb68bd545u, 0xb6878c74u, 0xb689717du, + 0xb690bf93u, 0xb68b7f10u, 0xb6782104u, 0xb6992eb6u, 0xb68d02c0u, 0xb69834ebu, 0xb6941b44u, 0xb6952ea3u, + 0xb68aca27u, 0xb8d201d9u, 0x39464717u, 0xb1ac451du, 0xb8cd19d5u, 0xb63e9d45u, 0xb63e3785u, 0xb6401454u, + 0xb63e2553u, 0xb63e1295u, 0xb63d0215u, 0xb63a89d6u, 0xb63c8522u, 0xb63ffdecu, 0xb6412196u, 0xb63bcc97u, + 0xb642438bu, 0xb641850bu, 0xb6407d9fu, 0xb63fd448u, 0xb64074e1u, 0xb639f96eu, 0xb63213e4u, 0xb62d9e54u, + 0xb646ab74u, 0xb6419796u, 0xb6388238u, 0xb632ff7fu, 0xb63f99e5u, 0xb64723bcu, 0xb63e4162u, 0xb64ef4f9u, + 0xb63a05acu, 0xb6389644u, 0xb6402a8du, 0xb6434efcu, 0xb63a4568u, 0xb6383bdfu, 0xb632ad32u, 0xb655be95u, + 0xb63ecc30u, 0xb626cc0du, 0xb64ae6a1u, 0xb664f762u, 0xb63119f3u, 0xb643e825u, 0xb653e310u, 0xb652f8f4u, + 0xb6412b6fu, 0xb62f92c1u, 0xb635ece2u, 0xb64cb539u, 0xb6391f32u, 0xb6339bbbu, 0xb631605au, 0xb63a9b06u, + 0xb6631cb6u, 0xb662c79du, 0xb623fbc0u, 0xb652522du, 0xb6442bfcu, 0xb64b049bu, 0xb63744eau, 0xb618b7bau, + 0xb6390156u, 0xb63a81b3u, 0x37e185e5u, 0xb942bc46u, 0x3210cebfu, 0x34d28fd6u, 0xb6320bbfu, 0xb6339fa6u, + 0xb6328326u, 0xb630be33u, 0xb62f5130u, 0xb62f990au, 0xb631719eu, 0xb6345f5au, 0xb63861d4u, 0xb6379852u, + 0xb63952a9u, 0xb62a66dcu, 0xb62c43d9u, 0xb62f2126u, 0xb641bc87u, 0xb623197au, 0xb62e8dacu, 0xb62b4d70u, + 0xb625b122u, 0xb6356b02u, 0xb61d958fu, 0xb620c059u, 0xb61bece8u, 0xb61a5c9au, 0xb6207d85u, 0xb60e2aefu, + 0xb60d6d9au, 0xb618a326u, 0xb6094ba1u, 0xb62a2f51u, 0xb5ed8e42u, 0xb5f730e0u, 0xb5bfad01u, 0xb605b931u, + 0xb605427cu, 0xb604fef9u, 0xb5f81cd4u, 0xb60bf2b0u, 0xb602ebb2u, 0xb60da220u, 0xb6113f94u, 0xb61f7385u, + 0xb616c219u, 0xb5db3eabu, 0xb60a6d20u, 0xb5cf921eu, 0xb608deebu, 0xb5a0b1a0u, 0xb5fcb612u, 0xb5d96009u, + 0xb5d5ae50u, 0xb5fb66f2u, 0xb5edd91du, 0xb5d0a070u, 0xb5f599afu, 0xb5e516aau, 0xb6118be9u, 0xb611581fu, + 0xb61d0a3eu, 0xb62a6d0eu, 0xb6255f01u, 0x38bfa892u, 0x393f2607u, 0x3190430eu, 0x38c02b4fu, 0xb62d5715u, + 0xb62ddbdbu, 0xb62d2cbeu, 0xb62ae550u, 0xb62d127cu, 0xb62bf246u, 0xb6324f58u, 0xb629d4b1u, 0xb631fd7du, + 0xb627dbd9u, 0xb632405au, 0xb62d2bfau, 0xb630fbf4u, 0xb62ad6b8u, 0xb62cf91du, 0xb62caf42u, 0xb62dede8u, + 0xb62f2e63u, 0xb62a7931u, 0xb623a57fu, 0xb62bf7e2u, 0xb62ba2a6u, 0xb6214f42u, 0xb632e862u, 0xb6327145u, + 0xb62522edu, 0xb62d2b79u, 0xb632d3e2u, 0xb6308f2eu, 0xb6287a67u, 0xb6322bf0u, 0xb639e329u, 0xb62e1d9eu, + 0xb635f9c3u, 0xb6347f5eu, 0xb6286a30u, 0xb632fbddu, 0xb633e595u, 0xb651362bu, 0xb64d2529u, 0xb62ef192u, + 0xb62c511du, 0xb627e5d5u, 0xb635f841u, 0xb62b7663u, 0xb631a1c6u, 0xb636abc7u, 0xb6239d09u, 0xb6316ebbu, + 0xb6454e64u, 0xb61a7edcu, 0xb64884c8u, 0xb63c20d4u, 0xb63dd8c7u, 0xb649d988u, 0xb644c953u, 0xb626d20bu, + 0xb630a5d8u, 0xb619aeacu, 0xb640b441u, 0xb64c9e89u, 0xb8096217u, 0xb9393afbu, 0x31eba991u, 0xb63c1f4bu, + 0xb624ea1bu, 0xb624e74bu, 0xb6239465u, 0xb621e1f5u, 0xb621f10fu, 0xb621f827u, 0xb61fc3d3u, 0xb624f41au, + 0xb61f089eu, 0xb622d0a6u, 0xb6213614u, 0xb61a504au, 0xb61d581du, 0xb61f6a3cu, 0xb61d35fbu, 0xb61a8532u, + 0xb61e5040u, 0xb618a861u, 0xb611ddb0u, 0xb61e993fu, 0xb617adacu, 0xb61a4a55u, 0xb6175ec5u, 0xb61c284eu, + 0xb6141ff1u, 0xb619c197u, 0xb616f96du, 0xb62652cau, 0xb617240cu, 0xb60be495u, 0xb61bbd88u, 0xb617b442u, + 0xb607a54eu, 0xb61b3c86u, 0xb60ae5bau, 0xb60d4014u, 0xb617600eu, 0xb612274fu, 0xb5eb211au, 0xb5f6be2du, + 0xb60e8d7du, 0xb6086281u, 0xb5fbbf1du, 0xb60c74b1u, 0xb614eb8eu, 0xb5dd1769u, 0xb6064cfcu, 0xb5e33276u, + 0xb5fdddb7u, 0xb6208df6u, 0xb5daf918u, 0xb6050b98u, 0xb603fc52u, 0xb60aa724u, 0xb5df92bcu, 0xb5f17d3au, + 0xb622012cu, 0xb60aa3f9u, 0xb5ed3ef3u, 0xb5e5603cu, 0xb5f3bbacu, 0xb8b8f74du, 0x3934fe61u, 0x3108edddu, + 0xb8bebfdau, 0xb61ce4edu, 0xb61c7e9eu, 0xb61aed4eu, 0xb6194fbbu, 0xb61a4124u, 0xb61c277bu, 0xb620c397u, + 0xb617d948u, 0xb61b50bau, 0xb616945au, 0xb624b3f7u, 0xb610204au, 0xb60d99c2u, 0xb60e08b4u, 0xb622f670u, + 0xb5fb489au, 0xb61189f3u, 0xb61a8fe4u, 0xb630f43cu, 0xb609f09bu, 0xb62bfc62u, 0xb5ffcefeu, 0xb616db0bu, + 0xb6251145u, 0xb6022c0fu, 0xb6054f5eu, 0xb60968c3u, 0xb60a2f3cu, 0xb5d386a6u, 0xb6094529u, 0xb5e36200u, + 0xb62aa2dcu, 0xb5fb9a9bu, 0xb5cc6acbu, 0xb6066556u, 0xb5f50209u, 0xb60f77c6u, 0xb5b90305u, 0xb622cd26u, + 0xb60b7ca4u, 0xb61024a7u, 0xb5c47f11u, 0xb5ea2334u, 0xb606a5e2u, 0xb603e76fu, 0xb5ef45f0u, 0xb61feb0cu, + 0xb5f649fcu, 0xb603cc2cu, 0xb5d34d99u, 0xb62ad28du, 0xb61fb2f5u, 0xb602b466u, 0xb5ebed26u, 0xb5c17e6cu, + 0xb5bc15b8u, 0xb61fcc17u, 0xb5c24922u, 0xb5e46cdbu, 0xb5bc11f7u, 0xb60d81dbu, 0x380bc911u, 0xb92fb43du, + 0xb2270f21u, 0x34930bd8u, 0xb61a5db1u, 0xb6229dc7u, 0xb61f5abbu, 0xb61bdfcbu, 0xb62119b6u, 0xb61e09f9u, + 0xb624901bu, 0xb622459fu, 0xb627767au, 0xb6244226u, 0xb629a9bau, 0xb62afd84u, 0xb62f51e2u, 0xb6240192u, + 0xb61579f0u, 0xb6144228u, 0xb61579f0u, 0xb5feb4f3u, 0xb608f1d7u, 0xb60b9969u, 0xb60cd133u, 0xb5e9f199u, + 0xb61cd133u, 0xb6344226u, 0xb60b9969u, 0xb63929d6u, 0xb63929d6u, 0xb63a61a0u, 0xb64f494fu, 0xb62a61a0u, + 0xb641d292u, 0xb61f6081u, 0xb636011du, 0xb656011cu, 0xb636011du, 0xb642400du, 0xb64ca1bcu, 0xb64062c8u, + 0xb6770368u, 0xb61f6081u, 0xb636011du, 0xb642400du, 0xb642400du, 0xb681a12eu, 0xb66ca1bcu, 0xb6806964u, + 0xb64ca1bcu, 0xb6767118u, 0xb66f814du, 0xb66ca1bcu, 0xb658e0adu, 0xb660d2c7u, 0xb665b1f0u, 0xb6851914u, + 0xb69310f6u, 0xb67fd079u, 0xb616011du, 0xb6906964u, 0xb68b388cu, 0xb6851914u, 0xb69688dcu, 0x38aaba4bu, + 0x3929aeccu, 0xb04356bfu, 0x38b3d6a9u, 0xb61279b0u, 0xb614611eu, 0xb611903au, 0xb619c4a0u, 0xb6122e66u, + 0xb611e823u, 0xb60a0e09u, 0xb60c64d1u, 0xb617afc8u, 0xb60eb542u, 0xb60fab37u, 0xb61207cbu, 0xb5fcf9ecu, + 0xb60cc2a2u, 0xb60fd1dfu, 0xb607e9bdu, 0xb60f294fu, 0xb6096703u, 0xb6223145u, 0xb61ac9cbu, 0xb6068e66u, + 0xb6256412u, 0xb618df6du, 0xb6057c89u, 0xb610179cu, 0xb631c13du, 0xb6188840u, 0xb60ff2ccu, 0xb61ccd84u, + 0xb60ad28au, 0xb634dd3au, 0xb61ee135u, 0xb632600cu, 0xb61b76dau, 0xb60a5b8cu, 0xb60d4bddu, 0xb5fdd86eu, + 0xb61df57du, 0xb60903e9u, 0xb5f80e1du, 0xb60ccf57u, 0xb5d2ad28u, 0xb5f4b228u, 0xb621bbbbu, 0xb5fbe5a7u, + 0xb6212628u, 0xb608c7e9u, 0xb6131befu, 0xb60a8a89u, 0xb61bb632u, 0xb5c2566du, 0xb60777c8u, 0xb5c39e74u, + 0xb6050a85u, 0xb5d6467au, 0xb624714du, 0xb60a933du, 0xb5bf834au, 0xb644fc8du, 0xb628461fu, 0xb60174c3u, + 0xb822974du, 0xb9262105u, 0x31c4dd59u, 0xb61fe2beu, 0xb60bced1u, 0xb60cb8fau, 0xb60a64acu, 0xb60c0527u, + 0xb60c0983u, 0xb60e12adu, 0xb60fa806u, 0xb60de69au, 0xb60b93cbu, 0xb608b3f0u, 0xb60f349au, 0xb60a9de0u, + 0xb6100483u, 0xb6124149u, 0xb61b47beu, 0xb612a8fbu, 0xb6105ae2u, 0xb618970eu, 0xb60c5c43u, 0xb618752au, + 0xb611ae77u, 0xb60d1322u, 0xb60eb233u, 0xb60fa0a9u, 0xb6151359u, 0xb622b14cu, 0xb6136c89u, 0xb6162142u, + 0xb615fa8fu, 0xb618b3c0u, 0xb607819du, 0xb60b4b26u, 0xb60d83d3u, 0xb60a2ec7u, 0xb6053bf8u, 0xb60d263cu, + 0xb618596bu, 0xb611245bu, 0xb620334eu, 0xb60d6c97u, 0xb6212c6cu, 0xb60ee314u, 0xb610b0e4u, 0xb609a30fu, + 0xb61a2747u, 0xb61a68deu, 0xb60852b4u, 0xb61d158fu, 0xb6363313u, 0xb60a42edu, 0xb616c5b2u, 0xb609199eu, + 0xb62007d1u, 0xb5e3625fu, 0xb6095199u, 0xb60cfdd8u, 0xb60f7fb2u, 0xb628f751u, 0xb61eb19fu, 0xb5f91ce4u, + 0xb6040f2cu, 0xb8a75768u, 0x39222595u, 0x31681380u, 0xb8b2ada4u, 0xb607518bu, 0xb6082f22u, 0xb607386du, + 0xb609697fu, 0xb6032f55u, 0xb6094ae9u, 0xb6066d81u, 0xb607b10eu, 0xb607f58fu, 0xb605187cu, 0xb605e061u, + 0xb608499du, 0xb608dc1fu, 0xb6077ecbu, 0xb60b3346u, 0xb606e46fu, 0xb60a3d8cu, 0xb605eecdu, 0xb60cf0dfu, + 0xb6021afau, 0xb605edfau, 0xb5f6ae65u, 0xb6052013u, 0xb609efabu, 0xb60af903u, 0xb60376e4u, 0xb61155f3u, + 0xb602e801u, 0xb601dcd7u, 0xb615eee7u, 0xb6079c3fu, 0xb613c16du, 0xb610ba23u, 0xb61b356bu, 0xb602afecu, + 0xb60426d4u, 0xb60e5466u, 0xb5e9ee33u, 0xb5fa8d62u, 0xb5ffc411u, 0xb618637fu, 0xb5f34e9eu, 0xb5fac430u, + 0xb60d2cddu, 0xb5ffa656u, 0xb61d9423u, 0xb6190f3cu, 0xb60c775fu, 0xb6216266u, 0xb61b0399u, 0xb5fa7b03u, + 0xb600a782u, 0xb601f158u, 0xb60e402cu, 0xb5dbad89u, 0xb6020fcbu, 0xb6251cf1u, 0xb6112e92u, 0xb6121953u, + 0xb639138fu, 0xb6247eeau, 0x38233c29u, 0xb91daf73u, 0x31a2d9ccu, 0x348a25d3u, 0xb60065ffu, 0xb6010524u, + 0xb5fbee40u, 0xb6014078u, 0xb602e12eu, 0xb601a482u, 0xb5fe5f53u, 0xb5fad2ddu, 0xb5f93ab3u, 0xb5fa4c47u, + 0xb601ae3au, 0xb5f91de8u, 0xb5ea033fu, 0xb5f70341u, 0xb5f4c59eu, 0xb60b24e6u, 0xb6071bbcu, 0xb5f96e7fu, + 0xb60c4ae4u, 0xb5fe87bfu, 0xb5eb7088u, 0xb5e83e73u, 0xb5d6d079u, 0xb5d0294au, 0xb5d5f6f3u, 0xb5ffee3au, + 0xb5dee5e9u, 0xb5f218c7u, 0xb5f27c79u, 0xb5d39548u, 0xb5ddc92fu, 0xb5ce3177u, 0xb5e08ee9u, 0xb5f825dau, + 0xb5d48070u, 0xb5df5b1eu, 0xb5c97b9cu, 0xb5e53636u, 0xb5ba2bf2u, 0xb5c5f904u, 0xb5ffc8c6u, 0xb5e2f0efu, + 0xb5fcb7bcu, 0xb5d41de6u, 0xb5f48ca3u, 0xb59ef0c9u, 0xb6119202u, 0xb5bf19beu, 0xb5c94475u, 0xb5af9222u, + 0xb5bc799cu, 0xb5fa86c4u, 0xb5fa7478u, 0xb5beb6bdu, 0xb5d2f0bcu, 0xb5e42b97u, 0xb60ce208u, 0xb5b2e0a5u, + 0xb5cbc20bu, 0xb5bce205u, 0xb5bbb8c7u, 0x3899fde5u, 0x391a0242u, 0xb1508944u, 0x38a98ab7u, 0xb5f6fba5u, + 0xb5f5653eu, 0xb5f61709u, 0xb5f8da63u, 0xb5f3c40eu, 0xb5fdb3dau, 0xb5f6a14bu, 0xb5fc3f83u, 0xb5ef742cu, + 0xb5f7b77au, 0xb5fa6a50u, 0xb5fb8932u, 0xb5fa0839u, 0xb5f8fab0u, 0xb60217b3u, 0xb5f3aec4u, 0xb5ed71a2u, + 0xb5f44a5bu, 0xb5f50b5du, 0xb5f26597u, 0xb5f23bf1u, 0xb5f736efu, 0xb5e7cdd7u, 0xb5fb2d34u, 0xb606eed0u, + 0xb60b8feeu, 0xb5e8ebd3u, 0xb607a955u, 0xb5f002edu, 0xb5d7875eu, 0xb6017274u, 0xb6038674u, 0xb6010d4du, + 0xb5e233a5u, 0xb5fb3699u, 0xb5fe5eecu, 0xb60e0fe2u, 0xb5deca09u, 0xb60297a2u, 0xb5f7aac8u, 0xb609e9d4u, + 0xb60ae435u, 0xb5f712f0u, 0xb6078315u, 0xb60337e8u, 0xb5fe2ec9u, 0xb5fd8f51u, 0xb5fd808fu, 0xb60b12dcu, + 0xb5ee7d86u, 0xb62132bcu, 0xb5fb7647u, 0xb62b168eu, 0xb5ad6930u, 0xb5f94ae4u, 0xb5ced20au, 0xb5cf7b0cu, + 0xb5cf2bc5u, 0xb614a8afu, 0xb5bdca3fu, 0xb5dbe9ddu, 0xb8366970u, 0xb915bbc6u, 0xb208d00fu, 0xb6069323u, + 0xb5ed8a19u, 0xb5f1fe47u, 0xb5f12c69u, 0xb5ef9263u, 0xb5f12505u, 0xb5f0c43eu, 0xb5f36c86u, 0xb5fe7552u, + 0xb5f4279eu, 0xb5ec9ac3u, 0xb600aaa2u, 0xb5e505c0u, 0xb5eee875u, 0xb5e9b6bfu, 0xb5f6e963u, 0xb5f8085du, + 0xb5f083fcu, 0xb5f3ef8bu, 0xb600fc60u, 0xb600f197u, 0xb5ff70bdu, 0xb5eef128u, 0xb5e7861cu, 0xb60822bau, + 0xb5fc70e6u, 0xb6010d4eu, 0xb6082f64u, 0xb60fcd3eu, 0xb5fafe6cu, 0xb606751eu, 0xb607dea4u, 0xb6114112u, + 0xb6011546u, 0xb6075f88u, 0xb5eacae3u, 0xb60ff25au, 0xb619e227u, 0xb5d9efcau, 0xb6114a76u, 0xb608ca8du, + 0xb60393c7u, 0xb601a752u, 0xb600c5bau, 0xb619efe4u, 0xb5f2f2d2u, 0xb5f13824u, 0xb618625du, 0xb5f75b4du, + 0xb5e8bcedu, 0xb60164b9u, 0xb614fe44u, 0xb603c13du, 0xb60dbfa2u, 0xb60cd3abu, 0xb61570cdu, 0xb60fde62u, + 0xb618216au, 0xb6148f5fu, 0xb607fbe6u, 0xb61cd0d8u, 0xb60b6899u, 0xb8961ae0u, 0x391063dbu, 0xb0ce203cu, + 0xb8a888a9u, 0xb5e347c6u, 0xb5e2ecd5u, 0xb5e12a31u, 0xb5e115a2u, 0xb5dc6583u, 0xb5e12b5cu, 0xb5eb01b2u, + 0xb5e3faf4u, 0xb5e45ac1u, 0xb5e38a41u, 0xb5e79526u, 0xb5ee5981u, 0xb5db1fabu, 0xb5d8b2a7u, 0xb5f7c382u, + 0xb5edfe9cu, 0xb5d55195u, 0xb5ed2773u, 0xb5c03508u, 0xb60b087fu, 0xb6015bf6u, 0xb5df2ef6u, 0xb5d51f18u, + 0xb5fcdc48u, 0xb5ea717bu, 0xb5f945f3u, 0xb5d18a62u, 0xb5e7fbafu, 0xb5e127d3u, 0xb5dda060u, 0xb5f9d3d3u, + 0xb608f27fu, 0xb5bea3dbu, 0xb5c7e278u, 0xb5f46c4au, 0xb5b06b8du, 0xb5b701c7u, 0xb6091b21u, 0xb5cf468bu, + 0xb5f97865u, 0xb5f8052eu, 0xb6000173u, 0xb5e2859bu, 0xb5d45cebu, 0xb60c64c0u, 0xb612dd07u, 0xb5cde756u, + 0xb5dd2320u, 0xb5c4b98au, 0xb5f918afu, 0xb61c83b2u, 0xb5ef7a7cu, 0xb5ff4248u, 0xb5d1c8dfu, 0xb5cee20du, + 0xb611c025u, 0xb60a0331u, 0xb620b37eu, 0xb6012c97u, 0xb5ce39c7u, 0xb5c43934u, 0x38375889u, 0xb90d36c9u, + 0x32465439u, 0x347a25fcu, 0xb5d96c93u, 0xb5d9a81bu, 0xb5d4d6a0u, 0xb5d13c21u, 0xb5d4cf6du, 0xb5de63d8u, + 0xb5cd94a4u, 0xb5ced5ecu, 0xb5e21f13u, 0xb5bedeecu, 0xb5ead71au, 0xb5e24450u, 0xb5d8e21au, 0xb5d159d7u, + 0xb5d8ad8eu, 0xb5be2246u, 0xb5e6d76eu, 0xb5dcd68au, 0xb5e315f1u, 0xb5dc87efu, 0xb5cced48u, 0xb5de0dcfu, + 0xb5dc3933u, 0xb5bdb8a9u, 0xb5dddfd4u, 0xb5f0c70au, 0xb5ebe195u, 0xb5ec938bu, 0xb5ce737bu, 0xb5e871a0u, + 0xb5faa345u, 0xb5d2879eu, 0xb59f9031u, 0xb5459277u, 0xb583cc90u, 0xb6114884u, 0xb5d5d40cu, 0xb5c13674u, + 0xb5eeddcdu, 0xb5e80fddu, 0xb5c83827u, 0xb5df9591u, 0xb5eac96cu, 0xb53413a2u, 0xb55adf2bu, 0xb5c35ee0u, + 0xb598e140u, 0xb5e09781u, 0xb5e96c06u, 0xb5f95ff4u, 0xb5ff85bfu, 0xb5d4f5bau, 0xb5d9ce3eu, 0xb5cbe708u, + 0xb5f0393bu, 0xb5a50e10u, 0xb599ae5fu, 0xb5d9bc35u, 0xb5d0ae90u, 0xb5ca5a8du, 0xb5c86a16u, 0x388985ffu, + 0x39084835u, 0x31f24a29u, 0x38a0d925u, 0xb5cb958fu, 0xb5ce8cffu, 0xb5cea3a4u, 0xb5c4e30cu, 0xb5ccabe3u, + 0xb5da53bfu, 0xb5cfbae0u, 0xb5cf45ccu, 0xb5c44c14u, 0xb5d196f8u, 0xb5c6c9f5u, 0xb5d9c0dcu, 0xb5c04721u, + 0xb5ce467du, 0xb5bd4a38u, 0xb5befcb3u, 0xb5c58fbbu, 0xb5c10bd2u, 0xb5bb34d9u, 0xb5b7610fu, 0xb5d9011eu, + 0xb5d3fda8u, 0xb5be5ff0u, 0xb5be5914u, 0xb5df1e9du, 0xb5c04d5cu, 0xb5a90578u, 0xb5c5d86au, 0xb5e38dabu, + 0xb5b57c2cu, 0xb5b6d6b0u, 0xb5c3fd65u, 0xb5d8c511u, 0xb6020478u, 0xb5cc261du, 0xb5cf06d0u, 0xb5e3368fu, + 0xb5933ef5u, 0xb5b09e93u, 0xb5ad3660u, 0xb5d1d35cu, 0xb5b03330u, 0xb5bd5fa5u, 0xb5d84c27u, 0xb5dc8141u, + 0xb5ac0197u, 0xb5b58f96u, 0xb5b41d63u, 0xb5a63bedu, 0xb5a6ce84u, 0xb58db164u, 0xb58b2be2u, 0xb589527eu, + 0xb5af14e6u, 0xb5864721u, 0xb5c79770u, 0xb5641326u, 0xb6081338u, 0xb59c73e6u, 0xb533b828u, 0xb5d37fe5u, + 0xb84726aau, 0xb903de4fu, 0x3185f4f9u, 0xb5e1c09fu, 0xb5c5395du, 0xb5c54c9du, 0xb5c235dcu, 0xb5be10e3u, + 0xb5c0cd94u, 0xb5c0c0beu, 0xb5c43343u, 0xb5be7b3cu, 0xb5bfbb07u, 0xb5b77c4fu, 0xb5bf4afdu, 0xb5ba7048u, + 0xb5ba42f2u, 0xb5b212a5u, 0xb5bc4119u, 0xb5c3795cu, 0xb5bc1b36u, 0xb5b15ca5u, 0xb5cf634bu, 0xb5b621ceu, + 0xb5b9a5e5u, 0xb5ab5b3bu, 0xb5bcbc59u, 0xb5ca11c3u, 0xb5b650fdu, 0xb5bc285au, 0xb5c49c29u, 0xb5ace46cu, + 0xb5b43838u, 0xb5bc5834u, 0xb5aea268u, 0xb5ae13b9u, 0xb592cdfcu, 0xb5abd125u, 0xb58eac0fu, 0xb59b9058u, + 0xb5bb40aeu, 0xb57478bcu, 0xb5a683a9u, 0xb5c00969u, 0xb5bca7cbu, 0xb5aa4d17u, 0xb5c959fau, 0xb5cb6d96u, + 0xb5bf0214u, 0xb5960a05u, 0xb5ad7a11u, 0xb5a41e66u, 0xb58a3211u, 0xb5c8f217u, 0xb59161e1u, 0xb593cc45u, + 0xb580686eu, 0xb598b599u, 0xb5a3520fu, 0xb593985au, 0xb583f249u, 0xb593570fu, 0xb5b24f0eu, 0xb5b5b7ffu, + 0xb5a5e11fu, 0xb8836abeu, 0x3901766bu, 0xb168927du, 0xb89ffd2eu, 0xb5bdb82eu, 0xb5bf3f79u, 0xb5c5a257u, + 0xb5c4b63au, 0xb5cd297bu, 0xb5c31fa5u, 0xb5caac54u, 0xb5ca2ce2u, 0xb5c1a91cu, 0xb5cdf8c1u, 0xb5b4da0eu, + 0xb5be9316u, 0xb5c0b5b2u, 0xb5c9e10bu, 0xb5bd9d5fu, 0xb5cf801au, 0xb5c48e57u, 0xb5c50ae4u, 0xb5c1674du, + 0xb5c3efc7u, 0xb5bff8b4u, 0xb5ce4b44u, 0xb5c3bfeau, 0xb5d1c6c3u, 0xb5e62345u, 0xb5d04d7du, 0xb5cbff67u, + 0xb5cbaa39u, 0xb5cc4673u, 0xb5ca0eb4u, 0xb5c9c9b4u, 0xb5cb8946u, 0xb5de5969u, 0xb5e4f7b9u, 0xb5b95285u, + 0xb5e0285du, 0xb5e52b11u, 0xb5a2029eu, 0xb5cc0e1cu, 0xb5cacc66u, 0xb5e89c40u, 0xb57f41f8u, 0xb5ea8d08u, + 0xb5e170d6u, 0xb5a02587u, 0xb5afc522u, 0xb5c2af2eu, 0xb5a30a2bu, 0xb5c440c7u, 0xb593ce4cu, 0xb5f03681u, + 0xb6001ddeu, 0xb5bb0152u, 0xb5f83affu, 0xb5d34ea2u, 0xb5d4a277u, 0xb5f1eda8u, 0xb5e85c4fu, 0xb5d2a0a8u, + 0xb60292c5u, 0xb5b7edcau, 0x38473e27u, 0xb8f9ec65u, 0xb17036c1u, 0x343ba9b5u, 0xb5b9119eu, 0xb5b90cadu, + 0xb5b156beu, 0xb5aeb17du, 0xb5b847d8u, 0xb5abe39bu, 0xb5c5b78au, 0xb5baddceu, 0xb5ba9e7au, 0xb5b17338u, + 0xb5be0235u, 0xb5a40b7cu, 0xb5af7cecu, 0xb5aaff09u, 0xb5a1980bu, 0xb5b6beb6u, 0xb5b889c3u, 0xb59660a4u, + 0xb5b29787u, 0xb59a53d5u, 0xb5a8f1f8u, 0xb5d71571u, 0xb5dad8c4u, 0xb5c7cb96u, 0xb5cf3bebu, 0xb5b03a01u, + 0xb5b15e2du, 0xb5b0f1b5u, 0xb5df3a9fu, 0xb5cec3bbu, 0xb5db71bdu, 0xb597e0beu, 0xb58b8ab8u, 0xb5e86a28u, + 0xb5aa157fu, 0xb558eefcu, 0xb58f1c88u, 0xb5be854bu, 0xb5b7f3c8u, 0xb5b732fbu, 0xb5ba0d93u, 0xb5f5c9d8u, + 0xb5b07c93u, 0xb5d2e563u, 0xb5ee6cc1u, 0xb5baa489u, 0xb598501cu, 0xb5a378a4u, 0xb5ce1021u, 0xb60979c7u, + 0xb5ac557bu, 0xb5d4d94du, 0xb5e80079u, 0xb5d070ecu, 0xb5aebf17u, 0xb5aa0495u, 0xb5a94042u, 0xb5ba679eu, + 0xb5b7f036u, 0xb6078cb3u, 0xb5b6f4bau, 0x3871dc89u, 0x38f2f6e9u, 0xae140afcu, 0x389974dbu, 0xb5aed7eeu, + 0xb5af0e2eu, 0xb5aec8dbu, 0xb5b1b8d9u, 0xb5abe800u, 0xb5ad386au, 0xb5b4da83u, 0xb5af580fu, 0xb5aeff84u, + 0xb5ac4d46u, 0xb59f9b98u, 0xb5b3e832u, 0xb5b40ddcu, 0xb597f9f1u, 0xb5baa7bcu, 0xb5c54ee6u, 0xb5b5338au, + 0xb5a5592fu, 0xb5adda24u, 0xb5afb293u, 0xb5b90945u, 0xb5ade590u, 0xb5a89c9cu, 0xb5bd8bddu, 0xb5a896c4u, + 0xb5bda407u, 0xb5aef0d9u, 0xb5a17063u, 0xb5b4c06cu, 0xb5cbd5d7u, 0xb59eca3fu, 0xb5a1a6d2u, 0xb5a8caceu, + 0xb5a2c27fu, 0xb5ac0cb0u, 0xb5afc6c8u, 0xb5aab968u, 0xb5ac10a7u, 0xb5cf11fcu, 0xb5abef77u, 0xb5a8c7cbu, + 0xb5d9647cu, 0xb5cacd50u, 0xb5c1b6ceu, 0xb5bbaec2u, 0xb5b27b91u, 0xb589380fu, 0xb5e1fce1u, 0xb5b0618eu, + 0xb5c0863eu, 0xb5bdc334u, 0xb5c3875au, 0xb5be40ceu, 0xb5c08315u, 0xb5cb3c48u, 0xb5f01cabu, 0xb5bce2f0u, + 0xb592b3eau, 0xb5f6ccf0u, 0xb5b3dffau, 0xb5ad7071u, 0xb8557f92u, 0xb8ebfe77u, 0xb0717061u, 0xb5c01cbbu, + 0xb5a8e735u, 0xb5a78187u, 0xb5a78225u, 0xb5a86d24u, 0xb5ab6ca8u, 0xb5a6637cu, 0xb5a4952bu, 0xb5a6d693u, + 0xb5a8f764u, 0xb5ad8003u, 0xb5ad89aeu, 0xb5a634bcu, 0xb5a4d12eu, 0xb5a4dba8u, 0xb59e2dd2u, 0xb5ab4f8cu, + 0xb5958b9fu, 0xb5ae7e18u, 0xb59f9f47u, 0xb5954cb4u, 0xb59af337u, 0xb5a5441cu, 0xb5a971d4u, 0xb57deadfu, + 0xb59cfe95u, 0xb5954280u, 0xb5a25e7eu, 0xb58f7087u, 0xb5ac6d47u, 0xb5a9eed8u, 0xb5c8226fu, 0xb5a51a0du, + 0xb5e878dbu, 0xb5c6f21fu, 0xb5cb584eu, 0xb5b6a557u, 0xb5a03435u, 0xb5abd68cu, 0xb5aeb9c0u, 0xb5ad39f5u, + 0xb5b62f9au, 0xb5883f07u, 0xb5c33817u, 0xb58e6a28u, 0xb5bae7a7u, 0xb5777d33u, 0xb5b5c8d4u, 0xb5b78f72u, + 0xb5ad12c0u, 0xb5478b28u, 0xb6005b5bu, 0xb5c2e227u, 0xb5a2261fu, 0xb5e64652u, 0xb5b49be9u, 0xb5bc1aa1u, + 0xb5a7e08cu, 0xb5967a37u, 0xb5a676c9u, 0xb590830du, 0xb5cabd51u, 0xb869a295u, 0x38e2c687u, 0x2f7204a2u, + 0xb898c31du, 0xb5a4247au, 0xb5a48a1bu, 0xb59b5394u, 0xb5974281u, 0xb5aafb2du, 0xb583f30du, 0xb5a4721eu, + 0xb58d0d74u, 0xb586fb1fu, 0xb5a7dc15u, 0xb5869f4fu, 0xb5a4da8eu, 0xb58d3e3fu, 0xb5ba639fu, 0xb58495a4u, + 0xb5fdd9d1u, 0xb599d097u, 0xb5689bfcu, 0xb5c7a0b5u, 0xb591ac92u, 0xb5c25fedu, 0xb5c9757eu, 0xb53cb3cbu, + 0xb5ecb0bbu, 0xb5b6f5fbu, 0xb5ad0039u, 0xb5aee4cfu, 0xb57ee5f2u, 0xb58cb3f6u, 0xb5a66b10u, 0xb597325eu, + 0xb57089e8u, 0xb522453fu, 0xb6049531u, 0xb591ac9du, 0xb5647ddfu, 0xb61f5461u, 0xb57737c9u, 0xb59a4667u, + 0xb60aef21u, 0xb591ac92u, 0xb5d23685u, 0xb5f4c1e0u, 0xb5a8473au, 0xb5fe57f2u, 0xb512e8c4u, 0xb5e09900u, + 0xb2002330u, 0xb59b0981u, 0xb60e47d2u, 0xb57a880fu, 0xb52d3d7du, 0xb5b0a7e8u, 0xb56b04d1u, 0xb61a895du, + 0xb5e09900u, 0xb604b1c9u, 0xb4deec25u, 0xb5bf7cb0u, 0xb5d7c608u, 0xb5049baeu, 0x38598e32u, 0xb8dd1fa1u, + 0x32351035u, 0x345430e0u, 0xb5968538u, 0xb5915950u, 0xb59bb128u, 0xb57c1200u, 0xb589594fu, 0xb589594fu, + 0xb5a4a99eu, 0xb59b4a3du, 0xb5a4a99eu, 0xb5bb4a3du, 0xb5b1eaddu, 0xb5a4a9a0u, 0xb5bb4a3eu, 0xb59b4a3eu, + 0xb549533du, 0xb5f5ccb8u, 0xb59b4a3eu, 0xb59b4a3bu, 0xb5888b7bu, 0xb59b4a3bu, 0xb55c11fdu, 0xb5018f84u, + 0xb55c11fdu, 0xb5c88b7bu, 0xb59b4a3bu, 0xb5c88b7bu, 0xb5c88b7bu, 0xb5c88b7bu, 0xb5f5ccb8u, 0xb5c88b7bu, + 0xb60445beu, 0xb59b4a3eu, 0xb59b4a3eu, 0xb59b4a3eu, 0xb59b4a3eu, 0xb5f5ccb8u, 0xb60da51fu, 0xb59b4a3eu, + 0xb60da51fu, 0xb59b4a3eu, 0xb59b4a3eu, 0xb5f5ccb8u, 0xb5f5ccb8u, 0xb45a51f4u, 0xb59b4a3eu, 0xb5c0c7c5u, + 0xb59b4a3eu, 0xb59b4a3eu, 0xb56b9970u, 0xb59b4a3eu, 0xb63ae65cu, 0x34fce0ecu, 0xb5c0c7c5u, 0xb5018f8au, + 0xb5f5ccb8u, 0xb59b4a3eu, 0xb59b4a3eu, 0xb5c0c7c5u, 0xb59b4a3eu, 0xb5018f8au, 0xb45a51f4u, 0x384c9007u, + 0x38d2a9c2u, 0xb1a93472u, 0x38934beau, 0xb59908ddu, 0xb59a7badu, 0xb58d8449u, 0xb5974ef3u, 0xb5891b09u, + 0xb5a846edu, 0xb5bb965bu, 0xb5969f36u, 0xb5669f91u, 0xb5a6110au, 0xb58c24ffu, 0xb5aa7cd1u, 0xb590d3f6u, + 0xb5a96080u, 0xb5888cdcu, 0xb5bdc9f8u, 0xb547bbdau, 0xb599eea4u, 0xb5c2c19cu, 0xb5b3daafu, 0xb5e08d88u, + 0xb56e5639u, 0xb5a5852cu, 0xb50f7281u, 0xb5b3daafu, 0xb513e3c4u, 0xb4c4768au, 0xb58e5824u, 0xb5db95e4u, + 0xb5c468dcu, 0xb598476du, 0xb5ac2408u, 0xb532be61u, 0xb591a889u, 0xb5a8d591u, 0xb5a8d591u, 0xb5ee5ca9u, + 0xb594f700u, 0xb591a889u, 0xb594f700u, 0xb54d39e0u, 0xb59ee649u, 0xb63236b0u, 0xb574f702u, 0xb59ee649u, + 0xb5fdc9f8u, 0xb5fdc9f8u, 0xb574f702u, 0xb54d39e0u, 0xb4a35645u, 0xb5c5809fu, 0xb513e3c4u, 0xb638d590u, + 0xb4cb1368u, 0xb5a8d591u, 0xb5fdc9f8u, 0x33308fd8u, 0xb513e3c4u, 0xb60583dcu, 0xb613d963u, 0xb5ee5ca9u, + 0xb8610ba6u, 0xb8cd6f0bu, 0x3126b953u, 0xb59e1147u, 0xb58ad853u, 0xb58b7ee1u, 0xb589d0c4u, 0xb58b46d3u, + 0xb58be7f7u, 0xb586a896u, 0xb58b807eu, 0xb58759a7u, 0xb5920bc1u, 0xb583d622u, 0xb58b7d43u, 0xb58b19cau, + 0xb58fafddu, 0xb58c48a0u, 0xb58c6abbu, 0xb5915ce8u, 0xb5848892u, 0xb589ca41u, 0xb5769a31u, 0xb5662b02u, + 0xb587258du, 0xb58ad383u, 0xb589e2f0u, 0xb58d0253u, 0xb58ae5c7u, 0xb58a5394u, 0xb558b35cu, 0xb585dd9bu, + 0xb58c364du, 0xb578a389u, 0xb571684fu, 0xb5614eccu, 0xb5714865u, 0xb595e37du, 0xb585a18eu, 0xb5a30ef3u, + 0xb5841503u, 0xb57b2d62u, 0xb59476b8u, 0xb59d4af0u, 0xb586689au, 0xb5cd6b38u, 0xb58234d1u, 0xb58c14efu, + 0xb5421f61u, 0xb56597b7u, 0xb5b01a10u, 0xb587fc55u, 0xb5923b2eu, 0xb59032e8u, 0xb56011bbu, 0xb57835e8u, + 0xb570c091u, 0xb4e25f90u, 0xb568fc16u, 0xb5701fd6u, 0xb582d61eu, 0xb5a5e5ddu, 0xb570663du, 0xb57c115bu, + 0xb584f86fu, 0xb849a0abu, 0x38c6263bu, 0xb231341au, 0xb892c77cu, 0xb58a18dbu, 0xb58a83a7u, 0xb58f6bc5u, + 0xb587150eu, 0xb594976cu, 0xb58da63fu, 0xb594cb36u, 0xb599af8cu, 0xb5941049u, 0xb597ac25u, 0xb5948a9cu, + 0xb5a5ffe0u, 0xb598236cu, 0xb5963556u, 0xb5848c5eu, 0xb5a20b03u, 0xb5a98549u, 0xb590feccu, 0xb59cba0bu, + 0xb5a5ce89u, 0xb598a820u, 0xb59b4021u, 0xb5b9b265u, 0xb5a52902u, 0xb5a185f1u, 0xb5c14f64u, 0xb58f2674u, + 0xb59c7d7du, 0xb5d33f9cu, 0xb5a9d1dbu, 0xb5b91900u, 0xb5c74853u, 0xb5af5240u, 0xb5cd68b0u, 0xb5b23e6eu, + 0xb5b05690u, 0xb5c7f041u, 0xb5d66b0au, 0xb58a995fu, 0xb5de7649u, 0xb5da7d2cu, 0xb5981964u, 0xb5d42e0cu, + 0xb5d78272u, 0xb5b1ddadu, 0xb5c5b63fu, 0xb5c9131eu, 0xb5dd3c19u, 0xb5d93681u, 0xb5c47df9u, 0xb5aa4089u, + 0xb5951b47u, 0xb5ee9116u, 0xb5bfea85u, 0xb5e095c2u, 0xb5be97edu, 0xb5ea8d2du, 0xb60771dfu, 0xb5fe1916u, + 0xb5d8efb4u, 0xb5d73bc2u, 0x385f31d6u, 0xb8bfcc12u, 0xb105eb56u, 0x33ececc9u, 0xb57b3eaau, 0xb57a5a7au, + 0xb5813174u, 0xb57a410du, 0xb5833732u, 0xb57886c3u, 0xb582cea3u, 0xb550cc76u, 0xb58b1517u, 0xb576cbfcu, + 0xb589ef0cu, 0xb564587au, 0xb581e3b6u, 0xb56882f9u, 0xb5817a67u, 0xb562aad7u, 0xb560942du, 0xb5580c52u, + 0xb56ecf7eu, 0xb594a7c0u, 0xb5786922u, 0xb58c9a2bu, 0xb59950a0u, 0xb58ac649u, 0xb56ef662u, 0xb566068au, + 0xb55858b0u, 0xb58daae4u, 0xb53ff0beu, 0xb588f5b4u, 0xb560ca1cu, 0xb5819f1fu, 0xb59e13feu, 0xb58998cau, + 0xb5a4a192u, 0xb5b4f097u, 0xb578833eu, 0xb599e564u, 0xb5853f76u, 0xb5c6ec0cu, 0xb59a3b74u, 0xb5b8008eu, + 0xb5c65229u, 0xb5becaddu, 0xb5e0e2c5u, 0xb5cf1b12u, 0xb5d85199u, 0xb5b3a899u, 0xb5857a45u, 0xb5686024u, + 0xb58e7e73u, 0xb5cc2bc4u, 0xb5427546u, 0xb59442d3u, 0xb579ac2bu, 0xb595edadu, 0xb5c73a6bu, 0xb599a953u, + 0xb5d2e6a8u, 0xb5d436c7u, 0xb5bc4eebu, 0x38379505u, 0x38b7a58eu, 0x3009ef40u, 0x388e366eu, 0xb5710927u, + 0xb569ddbcu, 0xb5744a00u, 0xb56ab928u, 0xb575aaf5u, 0xb572f7deu, 0xb568c8b1u, 0xb5731272u, 0xb5606e89u, + 0xb5719df9u, 0xb570d899u, 0xb537689au, 0xb5598fa8u, 0xb5426f9cu, 0xb5394bd2u, 0xb54cd949u, 0xb539dc62u, + 0xb54c8766u, 0xb555ec36u, 0xb54d4476u, 0xb54862e3u, 0xb55bbc01u, 0xb540b83au, 0xb53621d9u, 0xb5880938u, + 0xb5509648u, 0xb54501d4u, 0xb559c45cu, 0xb5404b2cu, 0xb53d9460u, 0xb58c5795u, 0xb4e661cau, 0xb57c2010u, + 0xb53d4164u, 0xb54b76abu, 0xb57b1db7u, 0xb53f95b0u, 0xb54650dfu, 0xb58331ffu, 0xb58540b3u, 0xb52ed170u, + 0xb5a390dfu, 0xb52cff91u, 0xb5126e2bu, 0xb50594f6u, 0xb563a977u, 0xb4d17608u, 0xb533919du, 0xb515f721u, + 0xb533ae5cu, 0xb5959059u, 0xb55c49dau, 0xb500ba14u, 0xb581d07du, 0xb4f6c5bbu, 0xb5846313u, 0xb4ccdaa6u, + 0xb550722cu, 0xb4f29f75u, 0xb5278c12u, 0xb564fdf9u, 0xb86ac21au, 0xb8af07e3u, 0xb1700612u, 0xb58446bcu, + 0xb56845b3u, 0xb57102e2u, 0xb56d709du, 0xb5732bb3u, 0xb56ea83au, 0xb569a15du, 0xb580d241u, 0xb5844fb4u, + 0xb5848211u, 0xb5809325u, 0xb57f93e1u, 0xb5790010u, 0xb58c8d3eu, 0xb584b6c7u, 0xb57dd17fu, 0xb58f6eb0u, + 0xb57d6b1fu, 0xb586e9b9u, 0xb5811878u, 0xb581cdaau, 0xb575f473u, 0xb59102e5u, 0xb57a97bbu, 0xb56910b6u, + 0xb57e7e8bu, 0xb5927931u, 0xb58899d4u, 0xb5821e7bu, 0xb57ddd4eu, 0xb5670f76u, 0xb551f267u, 0xb57dcf53u, + 0xb5911103u, 0xb58ede89u, 0xb58c2729u, 0xb57ff41cu, 0xb58d7cadu, 0xb54a79a6u, 0xb58c4b5bu, 0xb5a08a53u, + 0xb5b6a42eu, 0xb56b4890u, 0xb56e3af0u, 0xb5b8f1e1u, 0xb5a390a2u, 0xb5b1f513u, 0xb559d08bu, 0xb59120c8u, + 0xb5324e5du, 0xb5b2147du, 0xb57c5203u, 0xb58e21e0u, 0xb5973db5u, 0xb59dac30u, 0xb58b1744u, 0xb5a1d5f4u, + 0xb58f6111u, 0xb599f248u, 0xb5a0ccb3u, 0xb5d0f2beu, 0xb57ee26cu, 0xb82eaa2cu, 0x38a8a657u, 0x312d7c21u, + 0xb88db477u, 0xb556007fu, 0xb55bed56u, 0xb567a588u, 0xb54ae916u, 0xb552b0c2u, 0xb55bb6feu, 0xb5628720u, + 0xb557f0e5u, 0xb55dd74bu, 0xb5763b68u, 0xb551abb3u, 0xb554bf00u, 0xb55b185au, 0xb554cc79u, 0xb5600e2au, + 0xb576a5d3u, 0xb531b5a7u, 0xb53774d6u, 0xb53637f6u, 0xb56165e4u, 0xb53c19feu, 0xb5696830u, 0xb55d6e95u, + 0xb54ebb48u, 0xb572d61bu, 0xb52196d4u, 0xb52cca69u, 0xb57517b5u, 0xb4e4b309u, 0xb5644c1au, 0xb56e2984u, + 0xb50ca9dfu, 0xb5674943u, 0xb4b41d62u, 0xb576e821u, 0xb5521430u, 0xb502a7b7u, 0xb551d917u, 0xb543194du, + 0xb537151fu, 0xb54085e3u, 0xb52ea335u, 0xb56a670bu, 0xb56ec05eu, 0xb52f2656u, 0xb51804ddu, 0xb4e12307u, + 0xb5802a48u, 0xb520599bu, 0xb55b260cu, 0xb583f247u, 0xb59e8121u, 0xb4ccfb3cu, 0xb51e91a6u, 0xb586194eu, + 0xb5469d84u, 0x343352bfu, 0x32e386f0u, 0xb56f152bu, 0xb4f15357u, 0xb563d571u, 0x386b67acu, 0xb8a0b51bu, + 0x3093e935u, 0x33af3803u, 0xb559508eu, 0xb54e0522u, 0xb55f7ceeu, 0xb552a3e2u, 0xb5577fb7u, 0xb54a9f5eu, + 0xb56741ccu, 0xb566645cu, 0xb56a5b1au, 0xb57e4eedu, 0xb58cae69u, 0xb58232b3u, 0xb5577455u, 0xb57f042au, + 0xb50db558u, 0xb54438bfu, 0xb4f16a68u, 0xb52f9c2fu, 0xb59704cfu, 0xb510f79fu, 0xb4e11076u, 0xb57cbe27u, + 0xb52aba8eu, 0xb53709b3u, 0xb57df756u, 0xb53fb986u, 0xb535910au, 0xb550f4c4u, 0xb54e7064u, 0xb56d211eu, + 0xb5703f9cu, 0xb5433402u, 0xb53f19a5u, 0xb59e6277u, 0xb549c798u, 0xb4c335b2u, 0xb5b4d70au, 0xb520946fu, + 0xb59791f7u, 0xb55c9aeau, 0xb5a078dfu, 0xb54713fcu, 0xb57d18b2u, 0xb58e012bu, 0xb5a6f5a3u, 0xb59c7dfcu, + 0xb59d85b7u, 0xb514131cu, 0xb56cda4bu, 0xb57f4207u, 0xb55f722du, 0xb4902804u, 0xb5dd3d17u, 0xb5abc9cau, + 0xb59fb02cu, 0xb58dc470u, 0xb593356au, 0xb5c13e8eu, 0xb593c452u, 0xb50ba58bu, 0xb5afebffu, 0x381a4e50u, + 0x389c920bu, 0xb1e3da7eu, 0x3889f89eu, 0xb5419e33u, 0xb5468ccau, 0xb54ab5e4u, 0xb54f398bu, 0xb54f73d1u, + 0xb54b29c2u, 0xb54d0916u, 0xb5484c41u, 0xb54cb4f0u, 0xb54d9942u, 0xb55a8e03u, 0xb5710e56u, 0xb5420ef5u, + 0xb57042c5u, 0xb514ba7du, 0xb54878aau, 0xb573c168u, 0xb544c888u, 0xb5664f59u, 0xb58c6f1fu, 0xb58019beu, + 0xb5847c28u, 0xb568c81bu, 0xb55db26au, 0xb56069cfu, 0xb5938a65u, 0xb5837c77u, 0xb55adf86u, 0xb574efa7u, + 0xb5705d66u, 0xb542189bu, 0xb574357cu, 0xb587323du, 0xb54e0af4u, 0xb5c29b99u, 0xb580eeb3u, 0xb55e40cau, + 0xb579d284u, 0xb51fec86u, 0xb54df0d3u, 0xb54cdb81u, 0xb57e0d03u, 0xb58f73dau, 0xb540507du, 0xb5c7f8f0u, + 0xb52edb27u, 0xb57d8ccdu, 0xb52fe867u, 0xb57f558fu, 0xb53c74deu, 0xb5a18406u, 0xb53c44dbu, 0xb58d4f95u, + 0xb5982c61u, 0xb509d2fbu, 0xb57032aau, 0xb5aa27f3u, 0xb5de5449u, 0xb5a5ff7du, 0xb5d0e4fbu, 0xb58a7a55u, + 0xb872af06u, 0xb8950d04u, 0xb2372377u, 0xb55a1baeu, 0xb541556bu, 0xb542f643u, 0xb5425d3au, 0xb53e38b3u, + 0xb543bdb3u, 0xb542b01du, 0xb53d14a9u, 0xb5342b81u, 0xb54fc0dbu, 0xb56deefdu, 0xb554cc17u, 0xb580da63u, + 0xb5650c84u, 0xb54ccab1u, 0xb5616fb8u, 0xb53fca82u, 0xb54a5b6cu, 0xb55b3fb2u, 0xb55e3afau, 0xb57dde83u, + 0xb5701b44u, 0xb56429b2u, 0xb556a7f9u, 0xb57d59ccu, 0xb56fef67u, 0xb54fa700u, 0xb58a4d06u, 0xb59515e3u, + 0xb58c4ddbu, 0xb590fa73u, 0xb58af863u, 0xb5b9dc82u, 0xb5a6a540u, 0xb592815au, 0xb57c6607u, 0xb5908ea0u, + 0xb58c6f36u, 0xb5a44177u, 0xb598e111u, 0xb5824f2eu, 0xb597722cu, 0xb59519afu, 0xb57d957du, 0xb50b072eu, + 0xb58d1e87u, 0xb5a09419u, 0xb57d7e0bu, 0xb5bfc53fu, 0xb5ca5e49u, 0xb5a37d83u, 0xb559da29u, 0xb5aea60au, + 0xb59529acu, 0xb5e4c69fu, 0xb5b7c3c8u, 0xb5b52ff9u, 0xb58c5f5du, 0xb5bb1702u, 0xb57dff20u, 0xb5c7826du, + 0xb5874822u, 0xb8148640u, 0x388d7fddu, 0x322911efu, 0xb8899198u, 0xb52a6ce5u, 0xb52df859u, 0xb52bc4c5u, + 0xb539ab4du, 0xb5341b60u, 0xb524ec3au, 0xb5288c00u, 0xb523227fu, 0xb51ec535u, 0xb51d5a39u, 0xb52d6b9fu, + 0xb528fba6u, 0xb51977aeu, 0xb51cdc3du, 0xb503b819u, 0xb50d5730u, 0xb51d7c3au, 0xb51ab7f9u, 0xb50e7cf6u, + 0xb51ee4d7u, 0xb52b0d4fu, 0xb500a54du, 0xb520ebbau, 0xb5249800u, 0xb50882c1u, 0xb5565d77u, 0xb4f9b304u, + 0xb502c306u, 0xb5667158u, 0xb462c1c4u, 0xb4ca07adu, 0xb4cf101au, 0xb54fa334u, 0xb5107353u, 0xb5605939u, + 0x338ad976u, 0xb4d84985u, 0xb5283a04u, 0xb49897d0u, 0xb4c62110u, 0xb49ac37du, 0xb536ef25u, 0xb52b139au, + 0xb53801e6u, 0xb4ef44cfu, 0xb454004eu, 0xb5730356u, 0xb52e31d4u, 0xb4e6b865u, 0xb4a9c755u, 0xb53627e2u, + 0xb479ec8du, 0xb342f00au, 0xb4b0c210u, 0xb409d2adu, 0xb4ba027du, 0xb4c0086fu, 0xb4a41cafu, 0xb5401763u, + 0xb3c478ccu, 0xb4d5abdfu, 0x3876105du, 0xb8853b6du, 0x32a7428du, 0x33ee23c8u, 0xb51ccbc2u, 0xb51c0ec7u, + 0xb51b3ce8u, 0xb519cf88u, 0xb511ba56u, 0xb50a46e2u, 0xb518080cu, 0xb4dc1bdfu, 0xb4e5129au, 0xb4c115a8u, + 0xb4cae53eu, 0xb4c01e01u, 0xb4e0326fu, 0xb48c660eu, 0xb4c1fb67u, 0xb4f68eb7u, 0xb4cd2f11u, 0xb4c91fb6u, + 0xb48496b5u, 0xb4b3cb0eu, 0xb4dc75adu, 0xb48c158fu, 0xb4330e9bu, 0xb405e5aau, 0xb480926eu, 0xb47a7c3du, + 0x30761bf2u, 0xb425be78u, 0xb3b7eeeeu, 0xb388c6c4u, 0xb3e7d69fu, 0xb312a6f9u, 0x3408b110u, 0x3330713du, + 0xb4a521bbu, 0xb3c12bc2u, 0x3403e5ffu, 0x33dd2b15u, 0xb41f7cefu, 0x33307980u, 0x33cd61e7u, 0x33b83c7eu, + 0x33a8cf43u, 0x33b3b349u, 0xb4e0a038u, 0x33a82398u, 0x342b7528u, 0xb366dd24u, 0x31535649u, 0xb3cb8ac1u, + 0x34357a7cu, 0x342fab75u, 0xb41bbfe2u, 0x34aa6f0eu, 0xb4481c8du, 0xb4be1c96u, 0xb4622c69u, 0xb48d857fu, + 0x33c4bf3bu, 0x34a214e3u, 0x348b5d3cu, 0x3806b070u, 0x38829589u, 0xb13aa862u, 0x388695c5u, 0xb520d9f0u, + 0xb5291faau, 0xb521559du, 0xb5341391u, 0xb529ae09u, 0xb52e0815u, 0xb529e163u, 0xb52b5864u, 0xb518af7au, + 0xb53bb7c5u, 0xb534010eu, 0xb545d95eu, 0xb52443efu, 0xb523894fu, 0xb52c9c90u, 0xb5201da2u, 0xb54c779cu, + 0xb55aad06u, 0xb528f02fu, 0xb542eebfu, 0xb5329ef8u, 0xb55ba943u, 0xb53f1481u, 0xb52e73ccu, 0xb53cc7b1u, + 0xb52525e4u, 0xb532a551u, 0xb529580fu, 0xb52952aau, 0xb54a6124u, 0xb5356ee9u, 0xb5431045u, 0xb529344fu, + 0xb564f812u, 0xb53972b1u, 0xb4fb2762u, 0xb53135c5u, 0xb5584fdau, 0xb51858fcu, 0xb53bb23cu, 0xb4d2d77eu, + 0xb540046du, 0xb5613046u, 0xb575d058u, 0xb51d4549u, 0xb58df32au, 0xb4f6c483u, 0xb53c42a8u, 0xb56a464au, + 0xb5a56f42u, 0xb519bfacu, 0xb51864dbu, 0xb55340c6u, 0xb4a13f01u, 0xb54dfb47u, 0xb5a2b323u, 0xb520aab3u, + 0xb55aee11u, 0xb411b564u, 0xb55e4d03u, 0xb585aa2cu, 0xb87b691eu, 0xb87481cdu, 0xb1155868u, 0xb528e398u, + 0xb5160461u, 0xb51428efu, 0xb514e324u, 0xb51ecd8eu, 0xb50c924eu, 0xb51b7ab2u, 0xb510a14bu, 0xb50a9e86u, + 0xb516e2c4u, 0xb50b2aa8u, 0xb526f0d9u, 0xb4fe0e08u, 0xb51bd501u, 0xb51ba275u, 0xb52df2d5u, 0xb54299d3u, + 0xb50bed97u, 0xb5391f50u, 0xb528bdc5u, 0xb529d740u, 0xb53a3f6bu, 0xb50b149fu, 0xb53d1fefu, 0xb527937bu, + 0xb514d52eu, 0xb522a6d8u, 0xb53907f3u, 0xb54fda4eu, 0xb54e8a93u, 0xb504cd88u, 0xb52de465u, 0xb5489056u, + 0xb5529a5eu, 0xb50f8a17u, 0xb525061eu, 0xb513f96eu, 0xb4ea8414u, 0xb52b82eau, 0xb50e4b46u, 0xb527a50fu, + 0xb52e9024u, 0xb58a88a5u, 0xb50b86cbu, 0xb51f5701u, 0xb544881au, 0xb52955c4u, 0xb504879eu, 0xb52e5263u, + 0xb533df4au, 0xb4fd88bcu, 0xb4e9da73u, 0xb572f95au, 0xb55601f5u, 0xb55ecd6au, 0xb56e6324u, 0xb51cf0e3u, + 0xb51a77b7u, 0xb577bb24u, 0xb4aa7c07u, 0xb5535c51u, 0xb56f4bcfu, 0xb7eeeb11u, 0x38663d28u, 0x3284c63cu, + 0xb8864694u, 0xb5037239u, 0xb4fe481du, 0xb514ccceu, 0xb4f31f0eu, 0xb4dfe535u, 0xb4f185deu, 0xb4cb1727u, + 0xb4e74c3au, 0xb506cee5u, 0xb4e9fc6au, 0xb4993ee9u, 0xb4caa9f0u, 0xb45edc08u, 0xb4b84670u, 0xb4a8020au, + 0xb45d5ad4u, 0xb46afdbeu, 0xb48d8fccu, 0x34a2e652u, 0xb40ad551u, 0x329c58b5u, 0x34353daau, 0xb4dc933cu, + 0xb40f3e50u, 0xb4c6db7au, 0xb4e4bf4fu, 0xb3544afeu, 0xb4fde6f4u, 0xb4313c48u, 0xb43b61e1u, 0x32cfe0adu, + 0xb505f410u, 0xb539cea1u, 0xb3a4e651u, 0xb5522b66u, 0x33aa81cdu, 0xb3472db2u, 0x345414fcu, 0xb4244058u, + 0x34f89356u, 0xb488b3c2u, 0x34cd5448u, 0xb4c6f2d8u, 0x34a28a8bu, 0xb45fa4bau, 0x346eabfbu, 0xb477452eu, + 0x33ad4fc2u, 0x33b3f183u, 0x343e6f23u, 0xb451dd41u, 0x3505d0d4u, 0x3498396bu, 0xb4c71065u, 0x31b9833bu, + 0xb5007945u, 0x34223aa9u, 0xb50eced6u, 0x35006e06u, 0xb4f17391u, 0xb5402f51u, 0x387d69bbu, 0xb854e265u, + 0x31892446u, 0x3351f643u, 0xb50cb572u, 0xb510b952u, 0xb50800eau, 0xb51185fbu, 0xb508dd34u, 0xb5266d94u, + 0xb537dd0du, 0xb4db1b23u, 0xb555efc3u, 0xb54dfcb2u, 0xb4f39831u, 0xb4cd398eu, 0xb5106c18u, 0xb4c655cfu, + 0xb5357ecfu, 0xb586fe9au, 0xb5357eb6u, 0xb5984fbcu, 0xb560bb67u, 0xb53a5e38u, 0xb4c3c0acu, 0xb4bfb871u, + 0xb4078157u, 0xb50dfc98u, 0xb53a5e38u, 0xb52b5989u, 0xb52b5989u, 0xb4a5b757u, 0xb440e2a2u, 0xb512dbacu, + 0xb57ef8ceu, 0x34bcdd2au, 0x327ae536u, 0x3503eb95u, 0x327ae536u, 0x34f45aaau, 0xb4ad2cd6u, 0xb54cd86au, + 0xb5275b9bu, 0x34bcdd2au, 0x327ae536u, 0x34f45aaau, 0x34f45aaau, 0xb44465adu, 0x3425a653u, 0xb5621530u, + 0xb4ad2cd6u, 0xb38a8383u, 0xb46b5eadu, 0x3425a653u, 0x33fd5aa7u, 0xb5b10a98u, 0xafed2f37u, 0xb2f2460du, + 0xb3aacd59u, 0x35493190u, 0xb4f828d6u, 0xb4c42a61u, 0x34dd5f1fu, 0xb2f2460du, 0x33a1647du, 0x37cf34c0u, + 0x384b44a4u, 0xb1d54288u, 0x3883ed97u, 0xb4fb1f1au, 0xb4e9f7ebu, 0xb4de5862u, 0xb4f6dd85u, 0xb51c5c84u, + 0xb50e04d0u, 0xb4d6050au, 0xb4cc0f25u, 0xb4a92000u, 0xb4e232aau, 0xb507e1fau, 0xb50a0e71u, 0xb523247au, + 0xb4f3cc9fu, 0xb5342f7du, 0xb54677eeu, 0xb51a9939u, 0xb4d3737au, 0xb4e4429cu, 0xb54a6c60u, 0xb536ed22u, + 0xb48298fdu, 0xb5744ea4u, 0xb5243266u, 0xb52a21c1u, 0xb4e5430bu, 0xb51ac242u, 0xb519e842u, 0xb341e07bu, + 0xb53bce0eu, 0xb3abdd56u, 0xb4609eb7u, 0x344042edu, 0xb55a45abu, 0xb4d24ca7u, 0xb48fe972u, 0xb4951315u, + 0xb59c7fd8u, 0xb41fb206u, 0xb4da44d4u, 0xb41d6157u, 0xb5984a3au, 0xb5ca6e63u, 0xb5950aeau, 0xb58d0138u, + 0xb505e35bu, 0xb5cccdebu, 0xb5373c0au, 0xb58c7c3au, 0x323b8ed4u, 0xb5b1a508u, 0x347c0a3cu, 0xb5535f45u, + 0xb50c40c6u, 0xb5481da6u, 0xb584af01u, 0xb5802a6eu, 0xb5141359u, 0xb420c268u, 0xb4b3a246u, 0xb4ff7ea4u, + 0xb87da469u, 0xb841f053u, 0xb20f7ba5u, 0xb502af24u, 0xb4e2d423u, 0xb4e215efu, 0xb4f234deu, 0xb4e2a164u, + 0xb4ce64b3u, 0xb4e38ceau, 0xb4f5fed1u, 0xb4bfa1d5u, 0xb4c6bb4du, 0xb4d7641fu, 0xb4ebb2ecu, 0xb4fcb324u, + 0xb4f1577du, 0xb4d6581bu, 0xb5021e7du, 0xb4f01c50u, 0xb4e5c352u, 0xb4f8e579u, 0xb4fb1201u, 0xb4f7168au, + 0xb4f47dafu, 0xb4e07c99u, 0xb4c80ae4u, 0xb5133560u, 0xb50973f2u, 0xb511b7b2u, 0xb4c1cb77u, 0xb50d0cc1u, + 0xb5146851u, 0xb4e60117u, 0xb4e55413u, 0xb4b4f15fu, 0xb4e5dfb0u, 0xb4ee90f5u, 0xb533dcebu, 0xb50b8d18u, + 0xb486f781u, 0xb5084363u, 0xb4cf91e3u, 0xb4f68a0du, 0xb521b4e9u, 0xb49c39c3u, 0xb51c2aeau, 0xb3afdfefu, + 0xb53cbfccu, 0xb3898856u, 0xb473df9du, 0xb46c5a76u, 0xb5449e2du, 0xb5002ba1u, 0xb4d69489u, 0xb48add05u, + 0xb31fe37bu, 0xb4f86f60u, 0xb4a0413cu, 0xb4cdf618u, 0xb5367456u, 0xb487a9a6u, 0xb2bb7464u, 0xb4e45287u, + 0xb512f4abu, 0xb7b5125au, 0x3832491cu, 0xb19dff53u, 0xb883ba68u, 0xb4d7f4bbu, 0xb4d0ee3fu, 0xb4eaca71u, + 0xb4cd3a6au, 0xb4c1e96au, 0xb4c38f7du, 0xb4d9d5e8u, 0xb4ceac08u, 0xb4abcb08u, 0xb4f5c855u, 0xb4d5890du, + 0xb508513eu, 0xb4b5c477u, 0xb4aa6ff5u, 0xb4e102fbu, 0xb505fe80u, 0xb4be96b2u, 0xb4d60f0bu, 0xb50a06fdu, + 0xb4d6edc4u, 0xb4915942u, 0xb4b8e6b5u, 0xb50f5484u, 0xb4b9c790u, 0xb4d8371eu, 0xb51151efu, 0xb506a5d0u, + 0xb5076fd1u, 0xb4c06715u, 0xb4e8dd51u, 0xb4bfe2cau, 0xb4bc09adu, 0xb4941361u, 0xb53c6395u, 0xb512db7cu, + 0xb4afdc11u, 0xb494082cu, 0xb5098b90u, 0xb50704a2u, 0xb4754daeu, 0xb4f9a037u, 0xb5565aaau, 0xb49036bdu, + 0xb49f5c3au, 0xb4ddbbd8u, 0xb50cf0e4u, 0xb4978823u, 0xb542019au, 0xb53b03a6u, 0xb55525fbu, 0xb4c4ecacu, + 0xb4e79ae3u, 0xb43b5774u, 0xb500fce4u, 0xb5466c6cu, 0xb5333e59u, 0xb582cbb6u, 0xb5341f96u, 0xb53dba6cu, + 0x341bb60au, 0xb5134a9au, 0x3880296du, 0xb82532bdu, 0xb21a4c8eu, 0x3345c5d0u, 0xb4bc65b1u, 0xb4bd8ee1u, + 0xb4af12abu, 0xb4a44d47u, 0xb4b9f1d0u, 0xb49f2ec5u, 0xb4e361b6u, 0xb4cc189au, 0xb50dfb11u, 0xb50fda9eu, + 0xb4cc5b5cu, 0xb4e169abu, 0xb5125543u, 0xb4a99f8eu, 0xb4d6a265u, 0xb4cb1f48u, 0xb4e19523u, 0xb4c69fceu, + 0xb487da84u, 0xb4c1a775u, 0xb4ad51c4u, 0xb5130a5bu, 0xb4aa069du, 0xb50e14c0u, 0xb4ca9451u, 0xb486e580u, + 0xb49e19e7u, 0xb5061ddbu, 0xb505ad3fu, 0xb4d74203u, 0xb529d62au, 0xb5619bdau, 0xb5159f1fu, 0xb516065cu, + 0xb5191aaau, 0xb4e53de0u, 0xb53b7856u, 0xb54e8988u, 0xb50ab8d8u, 0xb5911cdcu, 0xb5a72d77u, 0xb3d6e411u, + 0xb5350656u, 0xb52d9673u, 0xb51db242u, 0x33a297b9u, 0xb5587d68u, 0xb4e2146du, 0xb540406bu, 0xb34c0ee9u, + 0xb54ef512u, 0xb42c178du, 0xb5251dccu, 0xb506961au, 0xb554fbdfu, 0xb4f94971u, 0xb530a99eu, 0xb49bb39cu, + 0xb4fc1fa2u, 0xb2a85448u, 0xb56059d9u, 0x3794c08eu, 0x381959f9u, 0x322bc877u, 0x3882087du, 0xb4a7e99bu, + 0xb49a9f62u, 0xb4952abdu, 0xb48f452du, 0xb4ae6861u, 0xb494a612u, 0xb44bc15au, 0xb4ab58cbu, 0xb46d242eu, + 0xb49670f5u, 0xb49d75d4u, 0xb3f810ecu, 0xb4647fcau, 0xb4020b75u, 0xb40b42c6u, 0xb49cb1a1u, 0xb450f488u, + 0xb3272f40u, 0xb4877473u, 0xb45eb57cu, 0xb4c089fbu, 0xb436de34u, 0xb3ebc9d3u, 0xb2592a10u, 0x33bb9ea2u, + 0xb39ee95cu, 0xb492142du, 0x3437306au, 0xb4550d4au, 0xb3a08714u, 0x33e0520au, 0xb1230511u, 0x3413eea6u, + 0x32bfec5du, 0xb38d25c8u, 0xb39e2a09u, 0xb40c017fu, 0x338ab616u, 0xb42df924u, 0x34b96842u, 0xb4c65165u, + 0xb326f8a2u, 0x3379f927u, 0x34077dd2u, 0xb22f925du, 0x336ea08fu, 0xb3b12276u, 0x3457b3d8u, 0x34406df1u, + 0xb4b9755au, 0x33e9c258u, 0x348e1a91u, 0x3449cdbeu, 0x2f429f50u, 0xb4410d20u, 0x3558a19fu, 0x33c3a9e4u, + 0x351cbc79u, 0x3525f9e1u, 0x34c0f6b1u, 0xb467fc54u, 0xb8807215u, 0xb8095b84u, 0x2ff5f85au, 0xb4b299eau, + 0xb49d5cb6u, 0xb4991f61u, 0xb4a43df1u, 0xb490c6e8u, 0xb49934b8u, 0xb4a40a42u, 0xb49c46f3u, 0xb4a54f9eu, + 0xb4837e67u, 0xb49ef39au, 0xb4804974u, 0xb4b3158au, 0xb499806bu, 0xb4881375u, 0xb4aed0b9u, 0xb4f1dfaau, + 0xb4ab32beu, 0xb4c8fecfu, 0xb4af6cf1u, 0xb4db0758u, 0xb4c995b2u, 0xb4e5059fu, 0xb4409e45u, 0xb4a4ea1bu, + 0xb4d18b63u, 0xb4a838abu, 0xb4788d7du, 0xb4e4305fu, 0xb4e0debbu, 0xb515cc6cu, 0xb4f876d7u, 0xb4bb00f8u, + 0xb4c1c093u, 0xb55597edu, 0xb5157ab1u, 0xb47c3addu, 0xb51c59b5u, 0xb4493199u, 0xb477327bu, 0xb4b3ff7au, + 0xb4b98903u, 0xb50b8b35u, 0xb5190d54u, 0xb52a5f14u, 0xb310af31u, 0xb4c6cb47u, 0xb4db6787u, 0xb4f43c1du, + 0xb4297e9fu, 0xb5476482u, 0xb4e28195u, 0xb4d0b44fu, 0xb5138e97u, 0xb4b6ad81u, 0xb4ff2d9au, 0xb508098au, + 0xb4b24854u, 0xb4a6a069u, 0xb4f3ec16u, 0xb50dbfceu, 0xb510a7a6u, 0xb7817b5bu, 0x37fd0178u, 0xb1a02694u, + 0xb881daa5u, 0xb4925aa9u, 0xb4913cd1u, 0xb469ab0au, 0xb49a092fu, 0xb49cf8e8u, 0xb498dcdbu, 0xb47997e7u, + 0xb4af4b29u, 0xb44b252fu, 0xb4e0d954u, 0xb4bd4df5u, 0xb48afb55u, 0xb4d09230u, 0xb4751b24u, 0xb3d80274u, + 0xb469fa16u, 0xb482eb34u, 0xb0d789f8u, 0xb4a3ba03u, 0xb5027564u, 0xb3e65f28u, 0xb4980841u, 0xb4d2de3eu, + 0xb4d46315u, 0xb5193c2bu, 0xb1defb0au, 0xb4cdca38u, 0xb45fad3bu, 0xb50fd428u, 0xb498a30du, 0xb3c1a507u, + 0xb51399ccu, 0xb4bec69du, 0x339e14bfu, 0xb540fbaau, 0xb4df000du, 0xb50b11b1u, 0xb5094f12u, 0xb50983dcu, + 0xb4ac9b78u, 0xb53530f7u, 0x34c88fffu, 0xb4079d0fu, 0xb530673au, 0x3311fc55u, 0xb510ce5fu, 0xb53a90b1u, + 0xb4b48c43u, 0xb40472d3u, 0xb49f0e4bu, 0xb462751du, 0xb4cefb2fu, 0xb541483au, 0xb39c76ecu, 0xb4c9903bu, + 0xb4895b62u, 0xb5475cb5u, 0xb5334c28u, 0x333083ecu, 0xb4e2c5a3u, 0x331f147du, 0x3880b136u, 0xb7df862du, + 0x319a638fu, 0x330efe91u, 0xb4809ecfu, 0xb48d84eeu, 0xb44f8d36u, 0xb42ccd44u, 0xb49ac054u, 0xb46da8d0u, + 0xb485a0e4u, 0xb47ffd92u, 0xb4c191aau, 0xb48aa510u, 0xb451259eu, 0xb422995au, 0xb49cdfaau, 0xb430bc34u, + 0xb48fd63au, 0xb4a96ff6u, 0x339fdcdfu, 0xb46fc012u, 0x321735dfu, 0xb5050974u, 0xb41d459bu, 0xb41519ccu, + 0xb494dc37u, 0xb3414bd2u, 0x333a7021u, 0xb42017d2u, 0x33086509u, 0xb47d11bbu, 0xb4bd6de7u, 0xb4f365ebu, + 0xb35c0ef7u, 0xb4a44a97u, 0xb493ece3u, 0xb49f0c01u, 0xb4407802u, 0x3501becdu, 0x34b410d6u, 0x31bd7603u, + 0x3442fbfeu, 0x34405961u, 0xb3cf91cdu, 0xb422bf7au, 0x3521d27eu, 0xb4aee74au, 0x34421dafu, 0xb505f74cu, + 0x347524fdu, 0x345ac086u, 0x34d1ef32u, 0xb40e069fu, 0x34ae14c3u, 0xb4f34c27u, 0x34012f5eu, 0x34f97667u, + 0x34d333fau, 0x349c3b91u, 0x342e9dafu, 0xb320767bu, 0x353e6cfdu, 0x350efec8u, 0x34932f32u, 0x3749e19au, + 0x37cc1b56u, 0xb1828c5fu, 0x3880b2e6u, 0xb45325ebu, 0xb47acad5u, 0xb477002au, 0xb470bde2u, 0xb459d7d2u, + 0xb428687fu, 0xb4a4e27fu, 0xb4808e28u, 0xb481d373u, 0xb445e3c1u, 0xb42fdd8bu, 0xb4995265u, 0xb484cffcu, + 0xb47a8e6au, 0xb48da917u, 0xb499478fu, 0xb4a844d9u, 0xb4778043u, 0xb499c15fu, 0xb44b59b7u, 0xb4b9c188u, + 0xb47bb9bfu, 0xb4a3a3a4u, 0xb4c723f8u, 0xb4e316f6u, 0xb41d9751u, 0xb329bf80u, 0xb4050e53u, 0xb4d9a632u, + 0xb4517895u, 0xb4c5bdc0u, 0xb44caea9u, 0xb4a1e0f0u, 0xb47797e0u, 0x3434bea4u, 0xb5289d24u, 0xb4637b97u, + 0xb51c5a38u, 0xb436f3f0u, 0x33a89dcbu, 0xb4dac09cu, 0xb4e7808bu, 0xb4dd8f54u, 0xb50463beu, 0xb50864d2u, + 0x34b085e6u, 0xb4bcf400u, 0xb4a215c8u, 0xb4ba8663u, 0xb5517f6du, 0xb56bb996u, 0xb55b5991u, 0xb355b7b7u, + 0xb52e5a96u, 0xb53a4198u, 0xb528f322u, 0xb4c04e1du, 0xb50a6899u, 0xb5171233u, 0xb5059062u, 0xb4d86970u, + 0xb882ada6u, 0xb7b2d9cbu, 0xb22fdffcu, 0xb45a8a26u, 0xb431f30bu, 0xb4376b60u, 0xb436755eu, 0xb42d8a76u, + 0xb410d251u, 0xb42181eeu, 0xb4311068u, 0xb3f41877u, 0xb409cd2bu, 0xb41592a6u, 0xb388188au, 0xb44f92dbu, + 0xb4906454u, 0xb405baceu, 0xb4113a8cu, 0xb49a6697u, 0xb48fc4d7u, 0xb45a0da0u, 0xb476e75fu, 0xb409c5e2u, + 0xb4a48842u, 0xb46ba37au, 0xb4ddece6u, 0xb4d4dc97u, 0xb4b4476cu, 0xb4891f21u, 0xb4c32cf7u, 0xb42432cfu, + 0xb46e6d03u, 0xb42938ddu, 0xb48f97d6u, 0x31804ac1u, 0x32beeff5u, 0xb47f4b2du, 0xb496fac2u, 0x33d9c569u, + 0xb37027d5u, 0x324515b6u, 0xb2995ad7u, 0xb3979beau, 0xb4684e7cu, 0x33b9d923u, 0x33f00209u, 0x341a9f68u, + 0xb4c060ddu, 0x332fea60u, 0xb4849406u, 0xb4b73013u, 0xb20591bfu, 0x33bf541eu, 0xb48e087au, 0xb3b07416u, + 0xb336705eu, 0xb3f7bccfu, 0xb420f54bu, 0x348b6b19u, 0xb4c679a4u, 0x335481bdu, 0xb45da556u, 0xb44629f7u, + 0xb415d063u, 0xb71916a0u, 0x37990683u, 0xb128b816u, 0xb8809d1eu, 0xb40d4eceu, 0xb4430c88u, 0xb409a488u, + 0xb3f15b07u, 0xb41e1efbu, 0xb41c7863u, 0xb3fbc011u, 0xb3c72a40u, 0xb405c1fdu, 0xb2daccccu, 0xb4289de1u, + 0xb47c11ceu, 0xb45abcc2u, 0xb45edfc0u, 0xb42105abu, 0xb466670cu, 0x33de6f74u, 0xb3b14438u, 0xb461fa0au, + 0xb46ccc67u, 0xb3dd97b9u, 0xb3f952e3u, 0xb4cad26du, 0x33455861u, 0xb4a4eeebu, 0xb48449d3u, 0xb3d1a417u, + 0xb44eb0eau, 0xb46a969au, 0xb44f5b86u, 0xb430fcdau, 0x32afba33u, 0x32e6dd21u, 0xb47fd7a1u, 0xb4b98285u, + 0xb49bb97du, 0xb48c4f15u, 0xb4051bd2u, 0xb4aff603u, 0xb4b57f22u, 0xb4e22399u, 0xb4302229u, 0xb5387083u, + 0xb4fd663du, 0xb43745d1u, 0xb48860a5u, 0xb31ab1d1u, 0x348c34feu, 0xb3e37a1du, 0xb4852d3bu, 0xb4e286cbu, + 0xb2730eaeu, 0xb4aba7c4u, 0xb4dc557du, 0x34076995u, 0xb494451bu, 0xb4456d23u, 0xb41bfc06u, 0xb3c9f291u, + 0xb365a0cdu, 0xb4212dabu, 0x38824420u, 0xb780a58du, 0x30b44f94u, 0x328ac646u, 0xb3e5a4c7u, 0xb380a8bau, + 0xb4077e5fu, 0xb40302e1u, 0xb3a23e8bu, 0xb432e1c9u, 0xb4232fe2u, 0xb42a657du, 0xb40afd87u, 0xb45f75c1u, + 0xb3eeb4bfu, 0xb3ced360u, 0xb40fa3dfu, 0x3302fd64u, 0xb3966077u, 0xb40a6fb6u, 0xb4a29d7cu, 0xb4532391u, + 0xb4894bffu, 0x345106b1u, 0xb4602fe8u, 0xb4756f46u, 0xb3d42caau, 0xb30d229cu, 0xb3dc3a4du, 0xb4916786u, + 0xb49b5626u, 0xb4834c97u, 0xb41460f5u, 0xb4a93ff7u, 0xb2bd58a2u, 0xb4f174bdu, 0xb46b16cdu, 0x33a5f41au, + 0xb429a390u, 0xb39bf13au, 0x34672387u, 0xb427d520u, 0xb47ec706u, 0xb5023f65u, 0xb5152d9eu, 0xb3ec5d7du, + 0xb41114afu, 0x33b70083u, 0xb4dbb956u, 0xb44071a2u, 0x3433a6bcu, 0x35106c60u, 0xb496a263u, 0xb2d5ea12u, + 0xb4098afau, 0x3522443du, 0x33afe989u, 0x34dc2708u, 0xb36107edu, 0x347db77fu, 0x31a5b664u, 0xb4f6f653u, + 0xb4a41c45u, 0xb4829638u, 0xb4f71bbau, 0x36b53895u, 0x3747c6c0u, 0xb1251655u, 0x388016b5u, 0xb3b34faeu, + 0xb3b78464u, 0xb3958ca8u, 0xb306b408u, 0xb4073753u, 0xb30260aau, 0xb31de8a0u, 0xb38e753bu, 0xb279edd6u, + 0x33180365u, 0xb38e3692u, 0xb308fbd7u, 0x31c63a63u, 0xb370926cu, 0x327fcde3u, 0x33c21e40u, 0x33bac120u, + 0xb318a358u, 0x32bf8e25u, 0x3420c97bu, 0xb3f3a5d8u, 0x340814e3u, 0xb400a709u, 0x33f80672u, 0x34919758u, + 0xb4190502u, 0xb345368fu, 0xb2fb9ae3u, 0x34711536u, 0x34bb6f58u, 0xb325b7dau, 0x34b6cfd7u, 0x3467bfc2u, + 0x34552046u, 0xb33fe31cu, 0x348244e0u, 0xb415d93du, 0x3438f675u, 0xb33180bcu, 0x333ae1f0u, 0xb4990c2eu, + 0x30006da4u, 0xb3cf3a52u, 0x337150b9u, 0x34a8b210u, 0xb3b7d459u, 0xb4972b91u, 0xb47d50e0u, 0xb4110079u, + 0x34b9ca63u, 0xb3682c86u, 0x33bfa430u, 0x33bdc6a4u, 0x35067dc1u, 0xb4af963cu, 0x3589cbc7u, 0xb275fe54u, + 0x341969fbu, 0x3479c843u, 0x341dc775u, 0x343409d4u, 0xb8833b0fu, 0xb7194fc0u, 0xb1283748u, 0xb39c7bfau, + 0xb3b03357u, 0xb39302c6u, 0xb3a5b235u, 0xb3b494bcu, 0xb39616b3u, 0xb3542736u, 0xb2b0283bu, 0xb3a6dfaeu, + 0xb3aa139bu, 0xb3e09ff2u, 0xb30850b8u, 0xb41f5cbfu, 0xb399c73au, 0xb472d529u, 0xb46a266du, 0xb4a7a467u, + 0xb20f6b55u, 0xb45a78acu, 0xb4764985u, 0xb192e3f5u, 0xb49c2204u, 0xb3c1180fu, 0xb488f2ffu, 0xb35e8c32u, + 0xb3446b10u, 0xb41b9e24u, 0xb4bc5e4au, 0xb4423c4bu, 0xb4a43723u, 0xb4815b7du, 0xb3fdec40u, 0xb35c817bu, + 0xb451875fu, 0xb4c55d84u, 0xb349b12eu, 0xb488ada8u, 0xb3e899fcu, 0xb404e56du, 0xb4a49889u, 0x3401548eu, + 0xb4ebd39cu, 0xb507de3bu, 0xb52eaac9u, 0xb3cb288eu, 0xb52092a6u, 0xb4e52634u, 0x34806ff9u, 0x333ed128u, + 0xb3c21962u, 0x32eb79b8u, 0xb3ca5f93u, 0xb375185bu, 0xb419a19eu, 0xb3e1484au, 0xb4d4d2d6u, 0x334d3b57u, + 0xb4e335ffu, 0xb5334ca5u, 0xb4cda7e0u, 0xb4e8f915u, 0xb49bd1a4u, 0xb65410b3u, 0x36c8fefbu, 0x30c15da4u, + 0xb87ff982u, 0x331f6208u, 0xb20ba920u, 0x3312c968u, 0x34121f46u, 0x338ba90fu, 0xb2983710u, 0x331bdad4u, + 0x34aa31d0u, 0x30e84680u, 0xb370c770u, 0x34b25532u, 0x32f53fe0u, 0xb34fac60u, 0x34bcc0eau, 0x3509bbb0u, + 0xb54ae915u, 0xb3fc6ed0u, 0x350bcd61u, 0xb548d764u, 0x34c99859u, 0x35102768u, 0xb5a562dau, 0xb51f6a78u, + 0x35123918u, 0xb5426bacu, 0x34de6fc8u, 0x35144acau, 0x3545ee5au, 0x34e2932au, 0xb5635b30u, 0xb54490a0u, + 0xb34a4ba0u, 0x351225d6u, 0x34942378u, 0xb605c8edu, 0x35d06419u, 0x35ac5a0cu, 0xb58440acu, 0x35d16cf2u, + 0x349c6a3cu, 0xb6419beau, 0x3524eb94u, 0xb5232886u, 0xb28bbec0u, 0x362ce37cu, 0x34a4b100u, 0x33244660u, + 0x35290ef6u, 0x35b07d6eu, 0x357fc56cu, 0xb4909d5cu, 0xb5ce79bau, 0xb5719838u, 0x35d6992cu, 0x35b28f1fu, + 0x327a3380u, 0xb6110ad3u, 0x3619cbfcu, 0xb56d74d6u, 0x35def332u, 0xb516be60u, 0x388177fbu, 0xb622e285u, + 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, + 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, + 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, + 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, + 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, + 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, + 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, + 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, 0x00000000u, + 0x00000000u, +}; + +} // namespace torch_stft_golden From dcebb4d70ac9aa324045bbd7ec8d061629f00cb2 Mon Sep 17 00:00:00 2001 From: Soficis Date: Wed, 30 Sep 2026 22:50:37 -0500 Subject: [PATCH 03/68] fix(ui): convert licence string_views before building juce::String ModelLicensePolicy::licenseUrl() and consentReason() return std::string_view, which juce::String cannot be constructed from. Since 549cf1d the GUI target (AutoMixMasterApp) failed to compile with C2440; the test and tool targets never include ModelBrowserPanel, so no suite caught it. Co-Authored-By: Claude Opus 5.5 --- src/app/ui/ModelBrowserPanel.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/ui/ModelBrowserPanel.cpp b/src/app/ui/ModelBrowserPanel.cpp index 65031a2..33e5132 100644 --- a/src/app/ui/ModelBrowserPanel.cpp +++ b/src/app/ui/ModelBrowserPanel.cpp @@ -264,12 +264,12 @@ ModelBrowserPanel::ModelBrowserPanel() { "Repository: " + juce::String(selected->repoId) + "\n" "License: " + juce::String(licenseId.empty() ? juce::String("not declared by the publisher") : juce::String(licenseId)); if (!licenseUrl.empty()) { - dialogMsg += "\nLicense terms: " + juce::String(licenseUrl); + dialogMsg += "\nLicense terms: " + juce::String(std::string(licenseUrl)); } if (!selected->revision.empty()) { dialogMsg += "\nRevision: " + juce::String(selected->revision); } - dialogMsg += "\n\n" + juce::String(ai::ModelLicensePolicy::consentReason(licenseId)) + + dialogMsg += "\n\n" + juce::String(std::string(ai::ModelLicensePolicy::consentReason(licenseId))) + "\n\nModel weights are downloaded at runtime from the publisher and are NOT part of the " "GPL-licensed AutoMixMaster distribution. Your acknowledgement is recorded against this " "model and applies to your own use of the downloaded weights.\n\n" From dc2c9198567c3354260d35ec8115fa2a979bdb12 Mon Sep 17 00:00:00 2001 From: Soficis Date: Wed, 30 Sep 2026 22:50:37 -0500 Subject: [PATCH 04/68] feat(ui): per-session Vocal Model toggle and separate dev-tools command - ControlDeck: "Vocal Model" toggle beside AI Stem Separation, enabled only while separation is on. Drives RenderSettings::tensorSeparationEnabled, synced on session load/save. - Session JSON persists tensorSeparationEnabled; sessions saved before the key existed load with it off (tested). - automix_dev_tools separate --mix --out [--pack] [--tensor] [--json] runs StemSeparator exactly as single-mix import does. Verified against the real xycld/BS-RoFormer-ONNX quantized build: hub install pinned the quantized file and probed names input/output; a 196 s track separated in 33 chunks, with the vocal stem ~97 dB below a full-level mix in instrumental sections. CPU-only run took 687 s (~3.5x real time). Co-Authored-By: Claude Opus 5.5 --- src/app/ui/ControlDeck.cpp | 7 ++++ src/app/ui/ControlDeck.h | 2 ++ src/app/ui/MainLayout.cpp | 12 ++++++- src/domain/JsonSerialization.cpp | 3 ++ tests/unit/TensorInferenceTests.cpp | 14 ++++++++ tools/commands/ModelCommands.cpp | 55 +++++++++++++++++++++++++++++ 6 files changed, 92 insertions(+), 1 deletion(-) diff --git a/src/app/ui/ControlDeck.cpp b/src/app/ui/ControlDeck.cpp index fd8376d..90cd0bb 100644 --- a/src/app/ui/ControlDeck.cpp +++ b/src/app/ui/ControlDeck.cpp @@ -30,6 +30,11 @@ ControlDeck::ControlDeck() { batchButton_.setTooltip("Batch Process"); exportButton_.setTooltip("Export (Ctrl+E)"); separatedStemsToggle_.setTooltip("Split a single imported full mix into stems using the active Separation model pack."); + tensorSeparationToggle_.setTooltip( + "Use a vocal-separation model pack (e.g. BS-RoFormer) when AI Stem Separation runs. Produces vocals plus an " + "instrumental that is the residual (mix - vocals), not a second separation. Takes minutes on CPU; falls back to " + "the standard separator if the pack cannot run."); + tensorSeparationToggle_.setEnabled(false); batchRecursiveToggle_.setTooltip("Include subfolders when scanning batch input"); rendererChainToggle_.setTooltip("Run renderers in a staged chain"); rendererChainModeBox_.setTooltip("Renderer chain strategy"); @@ -155,6 +160,7 @@ ControlDeck::ControlDeck() { addAndMakeVisible(platformPresetLabel_); addAndMakeVisible(platformPresetBox_); addAndMakeVisible(separatedStemsToggle_); + addAndMakeVisible(tensorSeparationToggle_); addAndMakeVisible(separationModelStatusLabel_); addAndMakeVisible(advancedToggle_); @@ -224,6 +230,7 @@ void ControlDeck::resized() { platformPresetBox_.setBounds(settingsRow2.removeFromLeft(140).reduced(1)); settingsRow2.removeFromLeft(spacing::gapSmall); separatedStemsToggle_.setBounds(settingsRow2.removeFromLeft(180).reduced(1)); + tensorSeparationToggle_.setBounds(settingsRow2.removeFromLeft(120).reduced(1)); settingsRow2.removeFromLeft(spacing::gapSmall); separationModelStatusLabel_.setBounds(settingsRow2.reduced(1)); diff --git a/src/app/ui/ControlDeck.h b/src/app/ui/ControlDeck.h index d8b7967..8f5b891 100644 --- a/src/app/ui/ControlDeck.h +++ b/src/app/ui/ControlDeck.h @@ -42,6 +42,7 @@ class ControlDeck final : public juce::Component { juce::ComboBox& getRendererChainModeBox() { return rendererChainModeBox_; } juce::Slider& getResidualBlendSlider() { return residualBlendSlider_; } juce::ToggleButton& getSeparatedStemsToggle() { return separatedStemsToggle_; } + juce::ToggleButton& getTensorSeparationToggle() { return tensorSeparationToggle_; } juce::ToggleButton& getBatchRecursiveToggle() { return batchRecursiveToggle_; } void setRendererChainPreviewText(const juce::String& text); void setSeparationModelStatus(const juce::String& text, bool ready); @@ -78,6 +79,7 @@ class ControlDeck final : public juce::Component { juce::Label blendLabel_{"", "Residual Blend"}; juce::Slider residualBlendSlider_; juce::ToggleButton separatedStemsToggle_{"AI Stem Separation"}; + juce::ToggleButton tensorSeparationToggle_{"Vocal Model"}; juce::Label separationModelStatusLabel_{"", "Separation model: none"}; juce::ToggleButton batchRecursiveToggle_{"Recursive Batch"}; diff --git a/src/app/ui/MainLayout.cpp b/src/app/ui/MainLayout.cpp index 14d6ab4..140a687 100644 --- a/src/app/ui/MainLayout.cpp +++ b/src/app/ui/MainLayout.cpp @@ -1046,7 +1046,13 @@ void MainLayout::wireControlDeckCallbacks() { sessionManager_.session().residualBlend = controlDeck_->getResidualBlendSlider().getValue(); }; controlDeck_->getSeparatedStemsToggle().onClick = [this] { - sessionManager_.session().aiStemsEnabled = controlDeck_->getSeparatedStemsToggle().getToggleState(); + const bool enabled = controlDeck_->getSeparatedStemsToggle().getToggleState(); + sessionManager_.session().aiStemsEnabled = enabled; + controlDeck_->getTensorSeparationToggle().setEnabled(enabled); + }; + controlDeck_->getTensorSeparationToggle().onClick = [this] { + sessionManager_.session().renderSettings.tensorSeparationEnabled = + controlDeck_->getTensorSeparationToggle().getToggleState(); }; controlDeck_->getBatchRecursiveToggle().onClick = [this] { const bool enabled = controlDeck_->getBatchRecursiveToggle().getToggleState(); @@ -2088,6 +2094,9 @@ void MainLayout::applySessionUiSelections() { controlDeck_->getResidualBlendSlider().setValue(std::clamp(session.residualBlend, 0.0, 10.0), juce::dontSendNotification); controlDeck_->getSeparatedStemsToggle().setToggleState(session.aiStemsEnabled, juce::dontSendNotification); + controlDeck_->getTensorSeparationToggle().setToggleState(session.renderSettings.tensorSeparationEnabled, + juce::dontSendNotification); + controlDeck_->getTensorSeparationToggle().setEnabled(session.aiStemsEnabled); controlDeck_->getBatchRecursiveToggle().setToggleState(session.batchRecursiveEnabled, juce::dontSendNotification); controlDeck_->getRendererChainToggle().setToggleState( session.renderSettings.rendererChainEnabled, @@ -2101,6 +2110,7 @@ void MainLayout::syncSessionUiSelections() { auto& session = sessionManager_.session(); session.residualBlend = controlDeck_->getResidualBlendSlider().getValue(); session.aiStemsEnabled = controlDeck_->getSeparatedStemsToggle().getToggleState(); + session.renderSettings.tensorSeparationEnabled = controlDeck_->getTensorSeparationToggle().getToggleState(); session.batchRecursiveEnabled = controlDeck_->getBatchRecursiveToggle().getToggleState(); if (const auto renderer = selectionState_.rendererIdForCombo(controlDeck_->getRendererBox().getSelectedId()); renderer.has_value()) { diff --git a/src/domain/JsonSerialization.cpp b/src/domain/JsonSerialization.cpp index eb82f6c..fe220a1 100644 --- a/src/domain/JsonSerialization.cpp +++ b/src/domain/JsonSerialization.cpp @@ -81,6 +81,7 @@ void to_json(Json& j, const RenderSettings& value) { {"mp3VbrQuality", value.mp3VbrQuality}, {"processingThreads", value.processingThreads}, {"preferHardwareAcceleration", value.preferHardwareAcceleration}, + {"tensorSeparationEnabled", value.tensorSeparationEnabled}, {"metadataPolicy", value.metadataPolicy}, {"metadataTemplate", value.metadataTemplate}, {"rendererName", value.rendererName}, @@ -111,6 +112,8 @@ void from_json(const Json& j, RenderSettings& value) { value.mp3VbrQuality = std::clamp(j.value("mp3VbrQuality", 4), 0, 9); value.processingThreads = std::max(0, j.value("processingThreads", 0)); value.preferHardwareAcceleration = j.value("preferHardwareAcceleration", true); + // Absent in sessions saved before the toggle existed; must stay off for them. + value.tensorSeparationEnabled = j.value("tensorSeparationEnabled", false); value.metadataPolicy = j.value("metadataPolicy", "copy_all"); if (value.metadataPolicy != "copy_all" && value.metadataPolicy != "copy_common" && diff --git a/tests/unit/TensorInferenceTests.cpp b/tests/unit/TensorInferenceTests.cpp index fe5b282..6795fe7 100644 --- a/tests/unit/TensorInferenceTests.cpp +++ b/tests/unit/TensorInferenceTests.cpp @@ -25,6 +25,7 @@ #include "ai/StemSeparator.h" #include "ai/TensorTypes.h" #include "analysis/SpectrogramFrontEnd.h" +#include "domain/JsonSerialization.h" #include "domain/RenderSettings.h" #include "engine/AudioBuffer.h" #include "engine/AudioFileIO.h" @@ -766,6 +767,19 @@ TEST_CASE("Tensor separation defaults off", "[tensor][settings]") { REQUIRE_FALSE(settings.tensorSeparationEnabled); } +TEST_CASE("Tensor separation flag persists per session and stays off for older sessions", "[tensor][settings]") { + automix::domain::RenderSettings enabled; + enabled.tensorSeparationEnabled = true; + const nlohmann::json saved = enabled; + REQUIRE(saved.at("tensorSeparationEnabled") == true); + REQUIRE(saved.get().tensorSeparationEnabled); + + // A session written before the toggle existed has no key at all. + auto legacy = nlohmann::json(automix::domain::RenderSettings{}); + legacy.erase("tensorSeparationEnabled"); + REQUIRE_FALSE(legacy.get().tensorSeparationEnabled); +} + namespace { // Real sibling list of xycld/BS-RoFormer-ONNX (HF API, 2026-09-30), in the diff --git a/tools/commands/ModelCommands.cpp b/tools/commands/ModelCommands.cpp index 754a63e..3e7359d 100644 --- a/tools/commands/ModelCommands.cpp +++ b/tools/commands/ModelCommands.cpp @@ -2,6 +2,7 @@ #include "commands/DevToolsUtils.h" #include +#include #include #include #include @@ -10,6 +11,7 @@ #include "ai/HuggingFaceModelHub.h" #include "ai/ModelPackLoader.h" #include "ai/OnnxModelInference.h" +#include "ai/StemSeparator.h" #include "renderers/ExternalLimiterRenderer.h" #include "util/LameDownloader.h" @@ -510,6 +512,58 @@ int commandModelHealth(const CommandArgs& args) { return failed == 0 ? 0 : 1; } +// separate --mix --out [--pack ] [--tensor] [--json] +// Runs StemSeparator exactly as single-mix import does, outside the UI. +int commandSeparate(const std::vector& args) { + const auto mixArg = argValue(args, "--mix"); + const auto outArg = argValue(args, "--out"); + if (!mixArg.has_value() || !outArg.has_value()) { + std::cerr << "Usage: separate --mix --out [--pack ] [--tensor] [--json]\n"; + return 2; + } + + automix::ai::StemSeparator separator(argValue(args, "--pack").value_or("assets/models/stem-separator")); + automix::ai::StemSeparator::SeparationOptions options; + options.useTensorModel = hasFlag(args, "--tensor"); + const bool jsonOutput = hasFlag(args, "--json"); + if (options.useTensorModel && !jsonOutput) { + options.tensorProgress = [](const int done, const int total) { + std::cout << " chunk " << done << "/" << total << "\n" << std::flush; + }; + } + + const auto started = std::chrono::steady_clock::now(); + const auto result = separator.separate(*mixArg, *outArg, options); + const double seconds = + std::chrono::duration(std::chrono::steady_clock::now() - started).count(); + + nlohmann::json stems = nlohmann::json::array(); + for (const auto& stem : result.stems) { + stems.push_back({{"name", stem.name}, {"path", stem.filePath}, {"role", automix::domain::toString(stem.role)}}); + } + const nlohmann::json payload = { + {"success", result.success}, + {"usedModel", result.usedModel}, + {"seconds", seconds}, + {"stems", stems}, + {"energyLeakage", result.qaMetrics.energyLeakage}, + {"residualDistortion", result.qaMetrics.residualDistortion}, + {"transientRetention", result.qaMetrics.transientRetention}, + {"log", result.logMessage}, + }; + if (jsonOutput) { + std::cout << payload.dump(2) << "\n"; + } else { + std::cout << "Separation: success=" << (result.success ? "yes" : "no") + << " usedModel=" << (result.usedModel ? "yes" : "no") << " seconds=" << seconds << "\n"; + for (const auto& stem : result.stems) { + std::cout << " " << automix::domain::toString(stem.role) << " -> " << stem.filePath << "\n"; + } + std::cout << " " << result.logMessage << "\n"; + } + return result.success ? 0 : 1; +} + } // namespace void registerModelCommands(automix::devtools::CommandRegistry& registry) { @@ -524,4 +578,5 @@ void registerModelCommands(automix::devtools::CommandRegistry& registry) { registry.add("model-browse", commandModelBrowse); registry.add("model-install", commandModelInstall); registry.add("model-health", commandModelHealth); + registry.add("separate", commandSeparate); } From 97e2d0997c006eedd99783e86f35430788d5c976 Mon Sep 17 00:00:00 2001 From: Soficis Date: Thu, 1 Oct 2026 08:54:08 -0500 Subject: [PATCH 05/68] test(audioio): accept the initial meter value in the torn-read test The reader thread can load the meter atomics before the writer's first store, so it sees the initial -60 and the range check reported a torn read that never happened. Scheduling-dependent: it failed 4/5 runs on the CUDA build. Now 0/60 across all three build configurations. Co-Authored-By: Claude Opus 5.5 --- tests/unit/AudioIoTests.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/unit/AudioIoTests.cpp b/tests/unit/AudioIoTests.cpp index 5a1f2d4..580aa21 100644 --- a/tests/unit/AudioIoTests.cpp +++ b/tests/unit/AudioIoTests.cpp @@ -177,7 +177,11 @@ TEST_CASE("Preview bridge meter targets round-trip without torn reads", "[audioi const float peak = leftPeak.load(std::memory_order_relaxed); seenLevels.push_back(level); seenPeaks.push_back(peak); - if (level < 0.0f || level > 1.0f || peak < 0.0f || peak > 2.0f) + // The reader can run before the writer's first store, so the initial + // -60 is a legitimate read; anything else outside the written range is not. + const bool levelOk = level == -60.0f || (level >= 0.0f && level <= 1.0f); + const bool peakOk = peak == -60.0f || (peak >= 0.0f && peak <= 2.0f); + if (!levelOk || !peakOk) readerOk.store(false); } }); From ddc217b80d43816b588cc551e33eed129f8355c5 Mon Sep 17 00:00:00 2001 From: Soficis Date: Thu, 1 Oct 2026 08:54:08 -0500 Subject: [PATCH 06/68] fix(ai): append CUDA/TensorRT through their V2 provider APIs The generic string-keyed AppendExecutionProvider() does not accept CUDA or TensorRT ("Unknown provider name 'CUDA'"), so every CUDA request threw and OnnxModelInference silently ran on CPU while recording cuda as failed. CUDA has never actually been used by the app. - Provider appending moves to a shared header (OrtSessionProviders.h); CUDA and TensorRT use AppendExecutionProvider_*_V2. - CUDA arena uses kSameAsRequested (needed for per-run shrinkage). - A session that fails to open because of the model itself (ORT error codes NO_SUCHFILE / NO_MODEL / INVALID_PROTOBUF / INVALID_GRAPH / MODEL_LOADED) no longer marks the GPU provider as failed. Co-Authored-By: Claude Opus 5.5 --- src/ai/OnnxModelInference.cpp | 56 +++++-------------------- src/ai/OrtSessionProviders.h | 78 +++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 45 deletions(-) create mode 100644 src/ai/OrtSessionProviders.h diff --git a/src/ai/OnnxModelInference.cpp b/src/ai/OnnxModelInference.cpp index c375760..7f24278 100644 --- a/src/ai/OnnxModelInference.cpp +++ b/src/ai/OnnxModelInference.cpp @@ -25,6 +25,7 @@ #if AUTOMIX_HAS_NATIVE_ORT #include +#include "ai/OrtSessionProviders.h" #endif namespace automix::ai { @@ -167,47 +168,7 @@ std::string makeProfilePrefix(const std::filesystem::path& modelPath) { } void appendExecutionProvider(Ort::SessionOptions& options, const std::string& provider) { - const auto normalized = canonicalProviderName(provider); - if (normalized == "cpu" || normalized == "auto" || normalized.empty()) { - return; - } - - std::unordered_map providerOptions; - if (normalized == gpu::kProviderCuda) { - // CUDA provider with default device ID 0 - providerOptions["device_id"] = "0"; - providerOptions["cudnn_conv_algo_search"] = "DEFAULT"; - options.AppendExecutionProvider("CUDA", providerOptions); - return; - } - if (normalized == gpu::kProviderDirectMl) { - // DirectML provider on default device - providerOptions["device_id"] = "0"; - options.AppendExecutionProvider("DML", providerOptions); - return; - } - if (normalized == gpu::kProviderCoreMl) { - providerOptions["ModelFormat"] = "MLProgram"; - options.AppendExecutionProvider("CoreML", providerOptions); - return; - } - if (normalized == gpu::kProviderAne) { - // Apple Neural Engine via CoreML with ANE override - providerOptions["ModelFormat"] = "MLProgram"; - providerOptions["ANEUnits"] = "256"; - options.AppendExecutionProvider("CoreML", providerOptions); - return; - } - if (normalized == gpu::kProviderOpenVino) { - // OpenVINO provider for Intel NPU / GPU - providerOptions["device_type"] = "CPU_FP32"; - options.AppendExecutionProvider("OpenVINO", providerOptions); - return; - } - if (normalized == "tensorrt") { - options.AppendExecutionProvider("Tensorrt", providerOptions); - return; - } + appendOrtExecutionProvider(options, canonicalProviderName(provider)); } std::vector discoverAvailableRuntimeProviders() { @@ -515,10 +476,15 @@ bool OnnxModelInference::loadModel(const std::filesystem::path& modelPath) { gpuOomCount_.fetch_add(1); gpuRecoveryCount_.fetch_add(1); } - // CPU is the floor resolution always returns, so a CPU session that fails - // to open is a model problem (unreadable or corrupt file), not a provider - // failure worth recording. - if (activeExecutionProvider_ != gpu::kProviderCpu) { + // A session that fails to open because of the model itself (missing, + // unparseable or invalid file) would fail on every provider; recording the + // GPU provider as failed would wrongly steer later resolution off it. CPU is + // the floor resolution always returns, so it is never recorded either. + const auto* ortError = dynamic_cast(&errorException); + const auto code = ortError != nullptr ? ortError->GetOrtErrorCode() : ORT_FAIL; + const bool modelProblem = code == ORT_NO_SUCHFILE || code == ORT_NO_MODEL || code == ORT_INVALID_PROTOBUF || + code == ORT_INVALID_GRAPH || code == ORT_MODEL_LOADED; + if (activeExecutionProvider_ != gpu::kProviderCpu && !modelProblem) { std::scoped_lock lock(failedProvidersMutex_); failedProviders_.push_back(activeExecutionProvider_); } diff --git a/src/ai/OrtSessionProviders.h b/src/ai/OrtSessionProviders.h new file mode 100644 index 0000000..d26fa81 --- /dev/null +++ b/src/ai/OrtSessionProviders.h @@ -0,0 +1,78 @@ +#pragma once + +// Native-ORT builds only: include after checking AUTOMIX_HAS_NATIVE_ORT. + +#include +#include +#include + +#include + +#include "ai/GpuProvider.h" + +namespace automix::ai { + +// Appends the ONNX Runtime execution provider for a canonical provider name +// (see gpu::canonicalProviderName). "cpu", "auto" and "" append nothing: the +// CPU provider is always present. Throws Ort::Exception when this runtime +// build does not contain the provider; a provider whose own dependencies +// (CUDA, cuDNN) are missing may instead only fail at session creation. +inline void appendOrtExecutionProvider(Ort::SessionOptions& options, const std::string& canonical) { + if (canonical == gpu::kProviderCpu || canonical == "auto" || canonical.empty()) { + return; + } + + std::unordered_map providerOptions; + // CUDA and TensorRT are not accepted by the generic string-keyed + // AppendExecutionProvider(); they need their dedicated V2 option objects. + // In a build without them, Create*ProviderOptions fails and throws here. + if (canonical == gpu::kProviderCuda) { + const auto& api = Ort::GetApi(); + OrtCUDAProviderOptionsV2* cuda = nullptr; + Ort::ThrowOnError(api.CreateCUDAProviderOptions(&cuda)); + const std::unique_ptr owned( + cuda, api.ReleaseCUDAProviderOptions); + // kSameAsRequested: the default power-of-two arena growth over-reserves + // VRAM on large graphs until the driver spills into shared system memory, + // which on BS-RoFormer made CUDA slower than the CPU. + const char* keys[] = {"device_id", "cudnn_conv_algo_search", "arena_extend_strategy"}; + const char* values[] = {"0", "DEFAULT", "kSameAsRequested"}; + Ort::ThrowOnError(api.UpdateCUDAProviderOptions(cuda, keys, values, 3)); + options.AppendExecutionProvider_CUDA_V2(*cuda); + return; + } + if (canonical == "tensorrt") { + const auto& api = Ort::GetApi(); + OrtTensorRTProviderOptionsV2* tensorrt = nullptr; + Ort::ThrowOnError(api.CreateTensorRTProviderOptions(&tensorrt)); + const std::unique_ptr owned( + tensorrt, api.ReleaseTensorRTProviderOptions); + options.AppendExecutionProvider_TensorRT_V2(*tensorrt); + return; + } + if (canonical == gpu::kProviderDirectMl) { + providerOptions["device_id"] = "0"; + options.AppendExecutionProvider("DML", providerOptions); + return; + } + if (canonical == gpu::kProviderCoreMl) { + providerOptions["ModelFormat"] = "MLProgram"; + options.AppendExecutionProvider("CoreML", providerOptions); + return; + } + if (canonical == gpu::kProviderAne) { + // Apple Neural Engine via CoreML with ANE override + providerOptions["ModelFormat"] = "MLProgram"; + providerOptions["ANEUnits"] = "256"; + options.AppendExecutionProvider("CoreML", providerOptions); + return; + } + if (canonical == gpu::kProviderOpenVino) { + // OpenVINO provider for Intel NPU / GPU + providerOptions["device_type"] = "CPU_FP32"; + options.AppendExecutionProvider("OpenVINO", providerOptions); + return; + } +} + +} // namespace automix::ai From 3e9d9796ae3d8297ded8d415a108f10a1829e4e0 Mon Sep 17 00:00:00 2001 From: Soficis Date: Thu, 1 Oct 2026 08:54:09 -0500 Subject: [PATCH 07/68] feat(ai): cancellable, GPU-accelerated tensor separation Cancellation - RunnerConfig::cancelRequested is checked before every chunk, and ITensorInference::runCancellable lets the ORT backend abort an in-flight Run via RunOptions::SetTerminate from a watcher thread. A cancelled separation writes nothing and never falls back to another separator. - Import passes ThreadPoolJob cancellation through. Real model: cancel at 5.0 s returned at 5.7 s, mid-chunk (a CPU chunk takes ~20 s). GPU - Tensor sessions try GPU providers in order and fall back to CPU, reporting the provider actually used; a GPU run that fails mid-way is retried once on CPU. Import honours preferHardwareAcceleration and gpuExecutionProvider; a named provider missing from this runtime still means "use a GPU". - Per-run CUDA arena shrinkage: without it BS-RoFormer's second chunk overflowed 16 GB of VRAM into shared memory (42 s/chunk vs ~2.5 s). - Catalog installs the fp32 BS-RoFormer build where a GPU session really opens (in-memory probe), else the quantized build. Measured on an RTX 5060 Ti, 196 s track: fp32/CUDA 85-91 s, quantized/CPU 687 s, quantized/CUDA no faster than CPU, fp32/CPU ~45% slower than quantized. - ORT 1.30 cannot load the fp32 export (shape inference cannot read external initializers), so the installer folds ".data" into the model in place with inlineExternalData(), a dependency-free protobuf rewrite that refuses locations outside the model directory. No weights are re-hosted. - CMake stages ORT (and optional AUTOMIX_CUDA_RUNTIME_DIR) DLLs beside each executable so a System32 onnxruntime.dll can no longer shadow it. - dev tools: separate --provider and --cancel-after-ms. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 25 ++ NOTICE | 7 +- docs/model-licensing-audit.json | 6 +- src/ai/BsRoformerPack.h | 11 + src/ai/HuggingFaceModelHub.cpp | 72 ++++- src/ai/HuggingFaceModelHub.h | 11 +- src/ai/ITensorInference.h | 10 + src/ai/ModelCatalogValidator.cpp | 3 +- src/ai/OnnxExternalData.cpp | 393 +++++++++++++++++++++++ src/ai/OnnxExternalData.h | 30 ++ src/ai/OnnxTensorInference.cpp | 215 ++++++++++++- src/ai/OnnxTensorInference.h | 28 ++ src/ai/SeparationRunner.cpp | 18 +- src/ai/SeparationRunner.h | 7 + src/ai/StemSeparator.cpp | 43 ++- src/ai/StemSeparator.h | 9 + src/app/controllers/ImportController.cpp | 25 +- src/app/controllers/ImportController.h | 9 +- src/app/ui/MainLayout.cpp | 6 +- tests/unit/TensorInferenceTests.cpp | 280 +++++++++++++++- tools/commands/ModelCommands.cpp | 14 +- 21 files changed, 1165 insertions(+), 57 deletions(-) create mode 100644 src/ai/OnnxExternalData.cpp create mode 100644 src/ai/OnnxExternalData.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 7c495cc..9ef8d9e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -148,6 +148,7 @@ add_library(automix_core src/ai/TensorTypes.cpp src/ai/SeparationRunner.cpp src/ai/OnnxTensorInference.cpp + src/ai/OnnxExternalData.cpp src/util/StringUtils.cpp src/util/LameDownloader.cpp src/util/MetadataPolicy.cpp @@ -462,3 +463,27 @@ if(BUILD_TESTING) include(${Catch2_SOURCE_DIR}/extras/Catch.cmake) catch_discover_tests(automix_tests) endif() + +# Stage ONNX Runtime (and, optionally, CUDA/cuDNN) DLLs beside each executable. +# Windows resolves DLLs from the executable's directory first; without this an +# older onnxruntime.dll in System32 wins, and the CUDA provider cannot find its +# dependencies. Point AUTOMIX_CUDA_RUNTIME_DIR at a folder of cudart / cublas / +# cufft / cudnn DLLs to make the CUDA execution provider loadable. +set(AUTOMIX_CUDA_RUNTIME_DIR "" CACHE PATH "Folder of CUDA runtime + cuDNN DLLs staged next to executables (optional)") +if(WIN32 AND AUTOMIX_HAS_NATIVE_ORT) + get_filename_component(_ort_lib_dir "${ONNXRUNTIME_LIBRARY}" DIRECTORY) + file(GLOB _automix_runtime_dlls "${_ort_lib_dir}/*.dll") + if(AUTOMIX_CUDA_RUNTIME_DIR) + file(GLOB _automix_cuda_dlls "${AUTOMIX_CUDA_RUNTIME_DIR}/*.dll") + list(APPEND _automix_runtime_dlls ${_automix_cuda_dlls}) + endif() + if(_automix_runtime_dlls) + foreach(_automix_target IN ITEMS automix_tests automix_dev_tools AutoMixMasterApp) + if(TARGET ${_automix_target}) + add_custom_command(TARGET ${_automix_target} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different ${_automix_runtime_dlls} "$" + VERBATIM) + endif() + endforeach() + endif() +endif() diff --git a/NOTICE b/NOTICE index 34ec6f1..0a44a0f 100644 --- a/NOTICE +++ b/NOTICE @@ -110,8 +110,11 @@ resolves it. xycld/BS-RoFormer-ONNX (MIT) BS-RoFormer (Lu et al., arXiv 2309.02612), ONNX export by xycld. https://opensource.org/license/mit - The catalog installs the uint8-quantized build; its quality relative - to the fp32 build has not been measured. The model separates vocals + The catalog installs the fp32 build where a CUDA GPU session opens and + the uint8-quantized build otherwise; the quantized build's quality + relative to fp32 has not been formally measured. The fp32 export's + external weights are folded into one local .onnx file at install time. + No model file is re-hosted. The model separates vocals only: the instrumental stem is the residual (mix - vocals), not a second separation. diff --git a/docs/model-licensing-audit.json b/docs/model-licensing-audit.json index 0e444a2..f30b479 100644 --- a/docs/model-licensing-audit.json +++ b/docs/model-licensing-audit.json @@ -118,10 +118,12 @@ "attributionRequired": false, "flagged": false, "assets": [ - "bs_roformer_ep317_sdr12.9755_quantized_uint8.onnx" + "bs_roformer_ep317_sdr12.9755_quantized_uint8.onnx", + "bs_roformer_ep317_sdr12.9755.onnx", + "bs_roformer_ep317_sdr12.9755.onnx.data" ], "attribution": "BS-RoFormer, Lu et al., arXiv 2309.02612; ONNX export by xycld (huggingface.co/xycld/BS-RoFormer-ONNX)", - "notes": "BS-RoFormer vocal separation ONNX; MIT confirmed on HF card (cardData.license and license:mit tag, checked 2026-09-30). Catalog installs the single-file uint8-quantized build (~166 MB); the fp32 build needs a ~640 MB external-data sidecar and is not the default. Graph outputs vocals only; instrumental is the residual mix - vocals. Download-only, never bundled." + "notes": "BS-RoFormer vocal separation ONNX; MIT confirmed on HF card (cardData.license and license:mit tag, checked 2026-09-30). Catalog installs fp32 (~645 MB, external-data sidecar inlined locally at install) where a CUDA session opens, else the single-file uint8-quantized build (~166 MB). Measured on RTX 5060 Ti: fp32/CUDA 85-91 s vs quantized/CPU 687 s for a 196 s track. Graph outputs vocals only; instrumental is the residual mix - vocals. Download-only, never bundled." } ] } diff --git a/src/ai/BsRoformerPack.h b/src/ai/BsRoformerPack.h index 8e3d9be..873a8fe 100644 --- a/src/ai/BsRoformerPack.h +++ b/src/ai/BsRoformerPack.h @@ -11,10 +11,21 @@ inline constexpr const char* kBsRoformerRepoId = "xycld/BS-RoFormer-ONNX"; // alphabetically-first .onnx is the fp32 graph, which is unusable without its // ~640 MB external-data sidecar, so the primary file is pinned by name. inline constexpr const char* kBsRoformerQuantizedFile = "bs_roformer_ep317_sdr12.9755_quantized_uint8.onnx"; +// The fp32 graph with its weights in ".data". Installed instead of the +// quantized build where a GPU session opens: the quantized build's integer ops +// have no CUDA kernels and gain nothing on a GPU (measured: no faster than CPU), +// while fp32 on an RTX 5060 Ti separated a 196 s track in 91 s vs 687 s on CPU. +// On CPU fp32 is ~45% slower than quantized, so CPU-only machines keep that. +// The installer inlines the sidecar (inlineExternalData) because ONNX Runtime +// cannot load this export with its weights external. +inline constexpr const char* kBsRoformerFp32File = "bs_roformer_ep317_sdr12.9755.onnx"; inline constexpr const char* kBsRoformerIntendedUse = "Vocal separation (BS-RoFormer). The graph produces vocals only; the instrumental stem is the " "residual mix - vocals, not a second separation. Quantized build: quality versus fp32 is not " "yet measured."; +inline constexpr const char* kBsRoformerFp32IntendedUse = + "Vocal separation (BS-RoFormer). The graph produces vocals only; the instrumental stem is the " + "residual mix - vocals, not a second separation. fp32 build, installed for GPU (CUDA) inference."; // Catalog form of the pack's tensor contract (spec section 6). Tensor names are // omitted because the repo does not publish them; the install-time probe fills diff --git a/src/ai/HuggingFaceModelHub.cpp b/src/ai/HuggingFaceModelHub.cpp index ef0aa0a..f18bbdd 100644 --- a/src/ai/HuggingFaceModelHub.cpp +++ b/src/ai/HuggingFaceModelHub.cpp @@ -16,6 +16,7 @@ #include "ai/BsRoformerPack.h" #include "ai/ItoMasterAdapter.h" #include "ai/ModelCatalogValidator.h" +#include "ai/OnnxExternalData.h" #include "ai/OnnxTensorInference.h" #include "util/Sha256.h" #include "util/StringUtils.h" @@ -455,14 +456,25 @@ void appendInstallLog(const std::filesystem::path& root, out << event.dump() << "\n"; } -std::string primaryFileForRepo(const std::string& repoId, const std::vector& files, bool* hasOnnxOut) { +std::string primaryFileForRepo(const std::string& repoId, + const std::vector& files, + bool* hasOnnxOut, + const bool preferGpuBuild) { auto primary = pickPrimaryFile(files, hasOnnxOut); if (repoId == kBsRoformerRepoId) { - // Pinned by name (see kBsRoformerQuantizedFile). If the repo ever drops the - // file, the entry becomes undiscoverable instead of installing the fp32 - // graph without its sidecar. - const bool hasQuantized = std::find(files.begin(), files.end(), kBsRoformerQuantizedFile) != files.end(); - primary = hasQuantized ? kBsRoformerQuantizedFile : ""; + // Pinned by name (see kBsRoformerQuantizedFile / kBsRoformerFp32File). The + // fp32 graph is only chosen together with its sidecar; without both, and + // without the quantized file, the entry becomes undiscoverable instead of + // installing an unloadable graph. + const auto has = [&files](const std::string& name) { + return std::find(files.begin(), files.end(), name) != files.end(); + }; + const std::string fp32 = kBsRoformerFp32File; + if (preferGpuBuild && has(fp32) && has(fp32 + ".data")) { + primary = fp32; + } else { + primary = has(kBsRoformerQuantizedFile) ? kBsRoformerQuantizedFile : ""; + } } return primary; } @@ -488,11 +500,18 @@ std::vector curatedModelIds() { // Artifacts that must accompany the primary model file to form a complete pack // (the ITO-Master mastering route consumes all three as one model pack). -std::vector auxiliaryAssetsForRepo(const std::string& repoId) { +std::vector auxiliaryAssetsFor(const std::string& repoId, + const std::string& primaryFile, + const std::vector& files) { + std::vector assets; if (repoId == kItoMasterRepoId) { - return {kItoMasterPredictorFile, kItoMasterConfigFile}; + assets = {kItoMasterPredictorFile, kItoMasterConfigFile}; + } + const auto sidecar = primaryFile + ".data"; + if (!primaryFile.empty() && std::find(files.begin(), files.end(), sidecar) != files.end()) { + assets.push_back(sidecar); } - return {}; + return assets; } std::vector HuggingFaceModelHub::defaultRecommendedSearchTerms() { @@ -601,7 +620,10 @@ std::optional HuggingFaceModelHub::modelInfo(const std::string& mo } } - info.primaryFile = primaryFileForRepo(info.repoId, info.files, &info.hasOnnx); + // The GPU probe opens a real session once per process; only repos with a + // GPU variant pay for it. + const bool preferGpuBuild = info.repoId == kBsRoformerRepoId && gpuTensorSessionAvailable(); + info.primaryFile = primaryFileForRepo(info.repoId, info.files, &info.hasOnnx, preferGpuBuild); info.useCase = HuggingFaceModelHub::inferUseCase(info.repoId, info.tags, ""); const auto compatibility = validateCatalogModel(info); info.compatible = compatibility.compatible; @@ -819,7 +841,7 @@ HubInstallResult HuggingFaceModelHub::installModel(const std::string& modelIdOrR // Fetch auxiliary artifacts so the pack is complete on disk (e.g. the // ITO-Master pack needs mastering_tcn.onnx + config.json alongside the // primary fxencoder.onnx). Each is SHA-256 verified when the repo exposes it. - for (const auto& auxiliaryAsset : auxiliaryAssetsForRepo(info->repoId)) { + for (const auto& auxiliaryAsset : auxiliaryAssetsFor(info->repoId, info->primaryFile, info->files)) { const auto auxiliaryPath = installPath / auxiliaryAsset; const auto auxiliaryUrl = "https://huggingface.co/" + info->repoId + "/resolve/" + revision + "/" + escapePathPreservingSlash(auxiliaryAsset); @@ -846,6 +868,34 @@ HubInstallResult HuggingFaceModelHub::installModel(const std::string& modelIdOrR result.auxiliaryFiles.push_back(auxiliaryAsset); } + // ONNX Runtime cannot load some external-weight exports at all (shape + // inference cannot read external initializers), so ".data" is + // folded into the primary model in place and the sidecar dropped. The pack + // keeps its file name, so the already-installed check above still matches. + const auto sidecarName = primaryPath.filename().string() + ".data"; + if (const auto sidecarIt = std::find(result.auxiliaryFiles.begin(), result.auxiliaryFiles.end(), sidecarName); + sidecarIt != result.auxiliaryFiles.end()) { + auto inlinedPath = primaryPath; + inlinedPath += ".inlined"; + const auto inlined = inlineExternalData(primaryPath, inlinedPath); + if (!inlined.success) { + std::filesystem::remove(primaryPath, error); + std::filesystem::remove(installPath / sidecarName, error); + result.message = "Could not make " + primaryPath.filename().string() + " self-contained: " + inlined.error; + appendInstallLog(destinationRoot, info.value(), result); + return result; + } + std::filesystem::rename(inlinedPath, primaryPath, error); + if (error) { + result.message = "Could not replace " + primaryPath.filename().string() + " with its inlined form: " + + error.message(); + appendInstallLog(destinationRoot, info.value(), result); + return result; + } + std::filesystem::remove(installPath / sidecarName, error); + result.auxiliaryFiles.erase(sidecarIt); + } + if (options.downloadReadme) { const auto readmeIt = std::find_if(info->files.begin(), info->files.end(), [](const std::string& file) { return toLower(file) == "readme.md"; diff --git a/src/ai/HuggingFaceModelHub.h b/src/ai/HuggingFaceModelHub.h index 76c6f8e..6fe4b77 100644 --- a/src/ai/HuggingFaceModelHub.h +++ b/src/ai/HuggingFaceModelHub.h @@ -95,8 +95,17 @@ std::vector curatedModelIds(); // The repo file an install downloads as the pack's model file; empty when the // repo offers nothing installable. Generic preference order, except for repos // whose correct file cannot be inferred from names (BS-RoFormer, spec D4). +// preferGpuBuild selects a GPU-oriented variant where a repo has one. std::string primaryFileForRepo(const std::string& repoId, const std::vector& files, - bool* hasOnnxOut = nullptr); + bool* hasOnnxOut = nullptr, + bool preferGpuBuild = false); + +// Files that must be downloaded next to `primaryFile` to make a complete pack: +// per-repo extras, plus ".data" whenever the repo publishes one +// (ONNX external weights, which the installer then inlines). +std::vector auxiliaryAssetsFor(const std::string& repoId, + const std::string& primaryFile, + const std::vector& files); } // namespace automix::ai diff --git a/src/ai/ITensorInference.h b/src/ai/ITensorInference.h index 27780f4..cb39b41 100644 --- a/src/ai/ITensorInference.h +++ b/src/ai/ITensorInference.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -22,6 +23,15 @@ class ITensorInference { virtual std::vector inputSpecs() const = 0; virtual std::vector outputSpecs() const = 0; virtual TensorInferenceResult run(const std::vector& inputs) const = 0; + + // run(), but a backend able to abort an in-flight inference stops early once + // cancelRequested() returns true. cancelRequested may be called from another + // thread, so it must be thread-safe. The default cannot abort and just runs. + virtual TensorInferenceResult runCancellable(const std::vector& inputs, + const std::function& cancelRequested) const { + (void)cancelRequested; + return run(inputs); + } }; class NullTensorInference final : public ITensorInference { diff --git a/src/ai/ModelCatalogValidator.cpp b/src/ai/ModelCatalogValidator.cpp index 274f715..3349b60 100644 --- a/src/ai/ModelCatalogValidator.cpp +++ b/src/ai/ModelCatalogValidator.cpp @@ -269,7 +269,8 @@ bool writeTurnkeyModelPackManifest(const std::filesystem::path& installPath, "License: " + kItoMasterLicense + ". Attribution: " + kItoMasterAttribution; } if (model.repoId == kBsRoformerRepoId) { - manifest["intended_use"] = kBsRoformerIntendedUse; + manifest["intended_use"] = + modelFileName == kBsRoformerFp32File ? kBsRoformerFp32IntendedUse : kBsRoformerIntendedUse; } const auto manifestPath = installPath / "model.json"; diff --git a/src/ai/OnnxExternalData.cpp b/src/ai/OnnxExternalData.cpp new file mode 100644 index 0000000..13a1b5d --- /dev/null +++ b/src/ai/OnnxExternalData.cpp @@ -0,0 +1,393 @@ +#include "ai/OnnxExternalData.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace automix::ai { +namespace { + +// onnx.proto field numbers this rewrite touches. Everything else is opaque. +constexpr std::uint32_t kModelGraph = 7; // ModelProto.graph +constexpr std::uint32_t kGraphInitializer = 5; // GraphProto.initializer +constexpr std::uint32_t kTensorRawData = 9; // TensorProto.raw_data +constexpr std::uint32_t kTensorExternalData = 13; // TensorProto.external_data +constexpr std::uint32_t kTensorDataLocation = 14; // TensorProto.data_location +constexpr std::uint64_t kDataLocationExternal = 1; +constexpr std::uint32_t kEntryKey = 1; // StringStringEntryProto.key +constexpr std::uint32_t kEntryValue = 2; // StringStringEntryProto.value + +constexpr std::uint32_t kWireVarint = 0; +constexpr std::uint32_t kWire64 = 1; +constexpr std::uint32_t kWireLength = 2; +constexpr std::uint32_t kWire32 = 5; + +// Protobuf refuses messages of 2 GB or more. +constexpr std::uint64_t kMaxMessageBytes = static_cast(std::numeric_limits::max()); + +struct Field { + std::uint32_t number = 0; + std::uint32_t wire = 0; + std::size_t begin = 0; // first byte of the tag + std::size_t end = 0; // one past the last byte of the field + std::size_t payloadBegin = 0; // length-delimited payload + std::size_t payloadEnd = 0; + std::uint64_t varint = 0; +}; + +bool readVarint(const std::string& bytes, std::size_t& pos, std::size_t end, std::uint64_t& value) { + value = 0; + for (int shift = 0; shift < 64; shift += 7) { + if (pos >= end) { + return false; + } + const auto byte = static_cast(bytes[pos++]); + value |= static_cast(byte & 0x7F) << shift; + if ((byte & 0x80) == 0) { + return true; + } + } + return false; +} + +void writeVarint(std::string& out, std::uint64_t value) { + while (value >= 0x80) { + out.push_back(static_cast((value & 0x7F) | 0x80)); + value >>= 7; + } + out.push_back(static_cast(value)); +} + +void writeLengthDelimitedHeader(std::string& out, std::uint32_t number, std::uint64_t length) { + writeVarint(out, (static_cast(number) << 3) | kWireLength); + writeVarint(out, length); +} + +// Reads the field starting at `pos` (which must be < end) and advances past it. +bool nextField(const std::string& bytes, std::size_t& pos, std::size_t end, Field& field, std::string& error) { + field = Field{}; + field.begin = pos; + std::uint64_t tag = 0; + if (!readVarint(bytes, pos, end, tag)) { + error = "truncated field tag at byte " + std::to_string(field.begin); + return false; + } + field.number = static_cast(tag >> 3); + field.wire = static_cast(tag & 0x7); + switch (field.wire) { + case kWireVarint: + if (!readVarint(bytes, pos, end, field.varint)) { + error = "truncated varint at byte " + std::to_string(field.begin); + return false; + } + break; + case kWire64: + case kWire32: { + const std::size_t width = field.wire == kWire64 ? 8 : 4; + if (end - pos < width) { + error = "truncated fixed-width field at byte " + std::to_string(field.begin); + return false; + } + pos += width; + break; + } + case kWireLength: { + std::uint64_t length = 0; + if (!readVarint(bytes, pos, end, length) || length > end - pos) { + error = "length-delimited field overruns its message at byte " + std::to_string(field.begin); + return false; + } + field.payloadBegin = pos; + field.payloadEnd = pos + static_cast(length); + pos = field.payloadEnd; + break; + } + default: + error = "unsupported protobuf wire type " + std::to_string(field.wire) + " at byte " + + std::to_string(field.begin); + return false; + } + field.end = pos; + return true; +} + +// Refuses absolute locations and any that leave the model's directory. +bool resolveLocation(const std::filesystem::path& modelDirectory, + const std::string& location, + std::filesystem::path& resolved, + std::string& error) { + const auto relative = std::filesystem::path(location); + if (location.empty() || relative.is_absolute() || relative.has_root_name() || relative.has_root_directory()) { + error = "external data location '" + location + "' is not a relative path"; + return false; + } + for (const auto& part : relative) { + if (part == "..") { + error = "external data location '" + location + "' leaves the model directory"; + return false; + } + } + resolved = modelDirectory / relative; + return true; +} + +class SidecarReader { + public: + explicit SidecarReader(std::filesystem::path directory) : directory_(std::move(directory)) {} + + bool read(const std::string& location, std::uint64_t offset, std::uint64_t length, bool lengthGiven, + std::string& out, std::string& error) { + auto it = files_.find(location); + if (it == files_.end()) { + std::filesystem::path resolved; + if (!resolveLocation(directory_, location, resolved, error)) { + return false; + } + auto stream = std::make_unique(resolved, std::ios::binary); + if (!stream->is_open()) { + error = "cannot open external data file '" + resolved.string() + "'"; + return false; + } + std::error_code sizeError; + const auto size = std::filesystem::file_size(resolved, sizeError); + if (sizeError) { + error = "cannot size external data file '" + resolved.string() + "'"; + return false; + } + it = files_.emplace(location, Open{std::move(stream), size}).first; + } + auto& file = it->second; + if (offset > file.size) { + error = "external data offset " + std::to_string(offset) + " is past the end of '" + location + "'"; + return false; + } + const auto available = file.size - offset; + const auto count = lengthGiven ? length : available; + if (count > available) { + error = "external data for '" + location + "' needs " + std::to_string(count) + " bytes at offset " + + std::to_string(offset) + " but only " + std::to_string(available) + " remain"; + return false; + } + const auto start = out.size(); + out.resize(start + static_cast(count)); + file.stream->seekg(static_cast(offset)); + file.stream->read(out.data() + start, static_cast(count)); + if (!*file.stream) { + error = "short read from external data file '" + location + "'"; + return false; + } + return true; + } + + private: + struct Open { + std::unique_ptr stream; + std::uintmax_t size = 0; + }; + std::filesystem::path directory_; + std::map files_; +}; + +bool parseUnsigned(const std::string& text, std::uint64_t& value) { + if (text.empty()) { + return false; + } + value = 0; + for (const char ch : text) { + if (ch < '0' || ch > '9') { + return false; + } + const auto digit = static_cast(ch - '0'); + if (value > (std::numeric_limits::max() - digit) / 10) { + return false; + } + value = value * 10 + digit; + } + return true; +} + +// Appends the rewritten TensorProto in [begin, end) to `out`. +bool rewriteTensor(const std::string& bytes, std::size_t begin, std::size_t end, SidecarReader& sidecars, + std::string& out, bool& inlined, std::string& error) { + std::map external; + bool isExternal = false; + std::string kept; + for (std::size_t pos = begin; pos < end;) { + Field field; + if (!nextField(bytes, pos, end, field, error)) { + return false; + } + if (field.number == kTensorDataLocation && field.wire == kWireVarint) { + isExternal = field.varint == kDataLocationExternal; + continue; + } + if (field.number == kTensorExternalData && field.wire == kWireLength) { + std::string key; + std::string value; + for (std::size_t entry = field.payloadBegin; entry < field.payloadEnd;) { + Field part; + if (!nextField(bytes, entry, field.payloadEnd, part, error)) { + return false; + } + if (part.wire != kWireLength) { + continue; + } + auto text = bytes.substr(part.payloadBegin, part.payloadEnd - part.payloadBegin); + if (part.number == kEntryKey) { + key = std::move(text); + } else if (part.number == kEntryValue) { + value = std::move(text); + } + } + external[key] = value; + continue; + } + kept.append(bytes, field.begin, field.end - field.begin); + } + + inlined = false; + if (!isExternal) { + out.append(bytes, begin, end - begin); // untouched, byte for byte + return true; + } + + const auto location = external.find("location"); + if (location == external.end()) { + error = "external tensor has no 'location' entry"; + return false; + } + std::uint64_t offset = 0; + std::uint64_t length = 0; + bool lengthGiven = false; + if (const auto it = external.find("offset"); it != external.end() && !parseUnsigned(it->second, offset)) { + error = "external tensor has a malformed offset '" + it->second + "'"; + return false; + } + if (const auto it = external.find("length"); it != external.end()) { + if (!parseUnsigned(it->second, length)) { + error = "external tensor has a malformed length '" + it->second + "'"; + return false; + } + lengthGiven = true; + } + + std::string raw; + if (!sidecars.read(location->second, offset, length, lengthGiven, raw, error)) { + return false; + } + out.append(kept); + writeLengthDelimitedHeader(out, kTensorRawData, raw.size()); + out.append(raw); + inlined = true; + return true; +} + +bool rewriteGraph(const std::string& bytes, std::size_t begin, std::size_t end, SidecarReader& sidecars, + std::string& out, int& inlinedCount, std::string& error) { + for (std::size_t pos = begin; pos < end;) { + Field field; + if (!nextField(bytes, pos, end, field, error)) { + return false; + } + if (field.number != kGraphInitializer || field.wire != kWireLength) { + out.append(bytes, field.begin, field.end - field.begin); + continue; + } + std::string tensor; + bool inlined = false; + if (!rewriteTensor(bytes, field.payloadBegin, field.payloadEnd, sidecars, tensor, inlined, error)) { + return false; + } + inlinedCount += inlined ? 1 : 0; + writeLengthDelimitedHeader(out, kGraphInitializer, tensor.size()); + out.append(tensor); + if (out.size() > kMaxMessageBytes) { + error = "inlined model would exceed protobuf's 2 GB limit; keep its external data"; + return false; + } + } + return true; +} + +bool readWholeFile(const std::filesystem::path& path, std::string& out) { + std::ifstream in(path, std::ios::binary); + if (!in.is_open()) { + return false; + } + in.seekg(0, std::ios::end); + out.resize(static_cast(in.tellg())); + in.seekg(0, std::ios::beg); + in.read(out.data(), static_cast(out.size())); + return static_cast(in); +} + +} // namespace + +ExternalDataInlineResult inlineExternalData(const std::filesystem::path& modelPath, + const std::filesystem::path& outputPath) { + ExternalDataInlineResult result; + std::string model; + if (!readWholeFile(modelPath, model)) { + result.error = "cannot read model '" + modelPath.string() + "'"; + return result; + } + + SidecarReader sidecars(modelPath.parent_path()); + std::string rewritten; + bool sawGraph = false; + for (std::size_t pos = 0; pos < model.size();) { + Field field; + if (!nextField(model, pos, model.size(), field, result.error)) { + result.error = "'" + modelPath.filename().string() + "' is not a valid ONNX model: " + result.error; + return result; + } + if (field.number != kModelGraph || field.wire != kWireLength) { + rewritten.append(model, field.begin, field.end - field.begin); + continue; + } + sawGraph = true; + std::string graph; + if (!rewriteGraph(model, field.payloadBegin, field.payloadEnd, sidecars, graph, result.tensorsInlined, + result.error)) { + return result; + } + writeLengthDelimitedHeader(rewritten, kModelGraph, graph.size()); + rewritten.append(graph); + } + if (!sawGraph) { + result.error = "'" + modelPath.filename().string() + "' has no graph"; + return result; + } + if (rewritten.size() > kMaxMessageBytes) { + result.error = "inlined model would exceed protobuf's 2 GB limit; keep its external data"; + return result; + } + + auto temporary = outputPath; + temporary += ".partial"; + { + std::ofstream out(temporary, std::ios::binary | std::ios::trunc); + out.write(rewritten.data(), static_cast(rewritten.size())); + if (!out) { + result.error = "cannot write '" + temporary.string() + "'"; + std::error_code ignored; + std::filesystem::remove(temporary, ignored); + return result; + } + } + std::error_code renameError; + std::filesystem::rename(temporary, outputPath, renameError); + if (renameError) { + result.error = "cannot move inlined model into place: " + renameError.message(); + std::filesystem::remove(temporary, renameError); + return result; + } + result.success = true; + return result; +} + +} // namespace automix::ai diff --git a/src/ai/OnnxExternalData.h b/src/ai/OnnxExternalData.h new file mode 100644 index 0000000..b24c6f2 --- /dev/null +++ b/src/ai/OnnxExternalData.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +namespace automix::ai { + +struct ExternalDataInlineResult { + bool success = false; + int tensorsInlined = 0; + std::string error; +}; + +// Rewrites an ONNX model whose graph initializers live in external-data files +// into one self-contained file, with each such initializer stored inline as +// raw_data. Everything else in the model is copied byte for byte. +// +// Why: ONNX Runtime's shape inference cannot read external initializers, so an +// export that keeps even a Split's size list external (BS-RoFormer fp32) fails +// to load at all. Inlining is only possible below protobuf's 2 GB message +// limit; larger models are refused. +// +// External locations must be relative paths inside the model's directory; a +// model naming an absolute path or one that escapes the directory is refused +// rather than having arbitrary files copied into it. The output is written to +// `outputPath` via a temporary file, so a failure never leaves a partial model. +ExternalDataInlineResult inlineExternalData(const std::filesystem::path& modelPath, + const std::filesystem::path& outputPath); + +} // namespace automix::ai diff --git a/src/ai/OnnxTensorInference.cpp b/src/ai/OnnxTensorInference.cpp index 2bb1596..2741950 100644 --- a/src/ai/OnnxTensorInference.cpp +++ b/src/ai/OnnxTensorInference.cpp @@ -1,10 +1,14 @@ #include "ai/OnnxTensorInference.h" +#include +#include #include +#include #include #include #include #include +#include #include #include @@ -12,8 +16,12 @@ #define AUTOMIX_HAS_NATIVE_ORT 0 #endif +#include "ai/GpuProvider.h" + #if AUTOMIX_HAS_NATIVE_ORT #include + +#include "ai/OrtSessionProviders.h" #endif namespace automix::ai { @@ -79,6 +87,111 @@ bool isConcrete(const std::vector& dims) { } // namespace +std::vector tensorProviderCandidates(const std::string& requested, + const std::vector& runtimeProviders) { + std::vector reported; + for (const auto& provider : runtimeProviders) { + reported.push_back(gpu::canonicalProviderName(provider)); + } + const auto isReported = [&reported](const std::string& provider) { + return std::find(reported.begin(), reported.end(), provider) != reported.end(); + }; + + std::vector candidates; + const auto wanted = gpu::canonicalProviderName(requested.empty() ? std::string("auto") : requested); + if (wanted == gpu::kProviderCpu) { + return {gpu::kProviderCpu}; + } + // A named GPU provider is tried first; if this runtime lacks it, the request + // still means "a GPU" (e.g. a DirectML preference on a CUDA build), so the + // remaining reported GPU providers follow before CPU. + if (wanted != "auto" && isReported(wanted)) { + candidates.push_back(wanted); + } + for (const auto& provider : gpu::providerPriorityChain()) { + if (provider != gpu::kProviderCpu && provider != wanted && isReported(provider)) { + candidates.push_back(provider); + } + } + candidates.emplace_back(gpu::kProviderCpu); + return candidates; +} + +namespace { + +#if AUTOMIX_HAS_NATIVE_ORT +void putVarint(std::string& out, std::uint64_t value) { + while (value >= 0x80) { + out.push_back(static_cast((value & 0x7F) | 0x80)); + value >>= 7; + } + out.push_back(static_cast(value)); +} +std::string varintField(std::uint32_t number, std::uint64_t value) { + std::string out; + putVarint(out, static_cast(number) << 3); + putVarint(out, value); + return out; +} +std::string bytesField(std::uint32_t number, const std::string& payload) { + std::string out; + putVarint(out, (static_cast(number) << 3) | 2); + putVarint(out, payload.size()); + return out + payload; +} + +// y = Identity(x), x and y float[1], IR 8 / opset 17: the smallest graph that +// makes a session initialise its execution provider and device. +std::string identityProbeModel() { + const auto valueInfo = [](const std::string& name) { + const auto dim = bytesField(1, varintField(1, 1)); // Dimension.dim_value = 1 + const auto tensorType = varintField(1, 1) + bytesField(2, dim); // elem_type FLOAT, shape + return bytesField(1, name) + bytesField(2, bytesField(1, tensorType)); + }; + const auto node = bytesField(1, "x") + bytesField(2, "y") + bytesField(4, "Identity"); + const auto graph = bytesField(1, node) + bytesField(2, "gpu_probe") + bytesField(11, valueInfo("x")) + + bytesField(12, valueInfo("y")); + return varintField(1, 8) + bytesField(8, bytesField(1, "") + varintField(2, 17)) + bytesField(7, graph); +} +#endif + +} // namespace + +bool gpuTensorSessionAvailable(std::string* providerOut) { +#if AUTOMIX_HAS_NATIVE_ORT + static const std::string provider = [] { + std::vector runtimeProviders; + try { + runtimeProviders = Ort::GetAvailableProviders(); + } catch (...) { + } + const auto model = identityProbeModel(); + for (const auto& candidate : tensorProviderCandidates("auto", runtimeProviders)) { + if (candidate == gpu::kProviderCpu) { + break; + } + try { + Ort::Env env(ORT_LOGGING_LEVEL_ERROR, "AutoMixMasterGpuProbe"); + Ort::SessionOptions options; + appendOrtExecutionProvider(options, candidate); + Ort::Session session(env, model.data(), model.size(), options); + return candidate; + } catch (...) { + } + } + return std::string(); + }(); + if (providerOut != nullptr) { + *providerOut = provider; + } + return !provider.empty(); +#else + if (providerOut != nullptr) { + providerOut->clear(); + } + return false; +#endif +} struct OnnxTensorInference::NativeState { #if AUTOMIX_HAS_NATIVE_ORT std::unique_ptr env; @@ -91,6 +204,10 @@ OnnxTensorInference::~OnnxTensorInference() noexcept = default; void OnnxTensorInference::setTensorContract(std::optional contract) { contract_ = std::move(contract); } +void OnnxTensorInference::setExecutionProvider(std::string provider) { requestedProvider_ = std::move(provider); } + +std::string OnnxTensorInference::activeExecutionProvider() const { return activeProvider_; } + bool OnnxTensorInference::isAvailable() const { return nativeState_ != nullptr; } bool OnnxTensorInference::usingNativeSession() const { return nativeState_ != nullptr; } @@ -103,6 +220,7 @@ std::vector OnnxTensorInference::outputSpecs() const { return output void OnnxTensorInference::unload(std::string diagnostics) { nativeState_.reset(); + activeProvider_.clear(); inputs_.clear(); outputs_.clear(); diagnostics_ = std::move(diagnostics); @@ -116,19 +234,46 @@ bool OnnxTensorInference::loadModel(const std::filesystem::path& modelPath) { } #if AUTOMIX_HAS_NATIVE_ORT - auto state = std::make_unique(); + std::vector runtimeProviders; + try { + runtimeProviders = Ort::GetAvailableProviders(); + } catch (...) { + } + const auto candidates = tensorProviderCandidates(requestedProvider_, runtimeProviders); + + std::unique_ptr state; std::vector inputs; std::vector outputs; - try { - state->env = std::make_unique(ORT_LOGGING_LEVEL_WARNING, "AutoMixMasterTensor"); - Ort::SessionOptions options; - options.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL); + std::string provider; + std::string attempts; // why each provider before the winner was passed over + for (const auto& candidate : candidates) { + auto attempt = std::make_unique(); + try { + attempt->env = std::make_unique(ORT_LOGGING_LEVEL_WARNING, "AutoMixMasterTensor"); + Ort::SessionOptions options; + options.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL); + appendOrtExecutionProvider(options, candidate); #if defined(_WIN32) - state->session = std::make_unique(*state->env, modelPath.wstring().c_str(), options); + attempt->session = std::make_unique(*attempt->env, modelPath.wstring().c_str(), options); #else - state->session = std::make_unique(*state->env, modelPath.string().c_str(), options); + attempt->session = std::make_unique(*attempt->env, modelPath.string().c_str(), options); #endif + } catch (const std::exception& exception) { + if (candidate == gpu::kProviderCpu) { + // Session creation is where a missing external-data sidecar surfaces; ORT's + // message names the file it could not open. + unload("ONNX tensor load failed for '" + modelPath.string() + "': " + attempts + exception.what()); + return false; + } + attempts += candidate + " unavailable (" + exception.what() + "); "; + continue; + } + state = std::move(attempt); + provider = candidate; + break; + } + try { Ort::AllocatorWithDefaultOptions allocator; std::string probeError; for (std::size_t i = 0; i < state->session->GetInputCount(); ++i) { @@ -146,12 +291,9 @@ bool OnnxTensorInference::loadModel(const std::filesystem::path& modelPath) { } } } catch (const std::exception& exception) { - // Session creation is where a missing external-data sidecar surfaces; ORT's - // message names the file it could not open. unload("ONNX tensor load failed for '" + modelPath.string() + "': " + exception.what()); return false; } - if (contract_.has_value()) { std::string contractError; if (!checkTensorContract(*contract_, inputs, outputs, contractError)) { @@ -163,8 +305,10 @@ bool OnnxTensorInference::loadModel(const std::filesystem::path& modelPath) { inputs_ = std::move(inputs); outputs_ = std::move(outputs); nativeState_ = std::move(state); - diagnostics_ = "backend=native_onnxruntime; model=" + modelPath.filename().string() + - "; inputs=" + std::to_string(inputs_.size()) + "; outputs=" + std::to_string(outputs_.size()); + activeProvider_ = provider; + diagnostics_ = "backend=native_onnxruntime; provider=" + provider + "; model=" + modelPath.filename().string() + + "; inputs=" + std::to_string(inputs_.size()) + "; outputs=" + std::to_string(outputs_.size()) + + (attempts.empty() ? std::string() : "; fallback: " + attempts); return true; #else unload("ONNX tensor load failed for '" + modelPath.string() + @@ -174,6 +318,16 @@ bool OnnxTensorInference::loadModel(const std::filesystem::path& modelPath) { } TensorInferenceResult OnnxTensorInference::run(const std::vector& inputs) const { + return runImpl(inputs, nullptr); +} + +TensorInferenceResult OnnxTensorInference::runCancellable(const std::vector& inputs, + const std::function& cancelRequested) const { + return runImpl(inputs, cancelRequested ? &cancelRequested : nullptr); +} + +TensorInferenceResult OnnxTensorInference::runImpl(const std::vector& inputs, + const std::function* cancelRequested) const { TensorInferenceResult result; if (nativeState_ == nullptr) { result.logMessage = "OnnxTensorInference: no model loaded (" + diagnostics_ + ")"; @@ -239,7 +393,41 @@ TensorInferenceResult OnnxTensorInference::run(const std::vector& memoryInfo, buffers[i].data(), buffers[i].size(), shapes[i].data(), shapes[i].size())); } - auto produced = nativeState_->session->Run(Ort::RunOptions{nullptr}, + // A watcher polls the cancel check while the native run executes and + // terminates it; ORT then throws, which the catch below reports. + Ort::RunOptions runOptions; + if (activeProvider_ == gpu::kProviderCuda) { + // Return the run's unused arena memory to the device afterwards. Without + // it, the second BS-RoFormer chunk grows the arena past a 16 GB card and + // the driver spills into shared system memory (~5x slower per chunk). + // Requires arena_extend_strategy=kSameAsRequested (OrtSessionProviders.h). + runOptions.AddConfigEntry("memory.enable_memory_arena_shrinkage", "gpu:0"); + } + std::atomic runFinished{false}; + std::thread watcher; + if (cancelRequested != nullptr) { + watcher = std::thread([&runOptions, &runFinished, cancelRequested] { + while (!runFinished.load()) { + if ((*cancelRequested)()) { + runOptions.SetTerminate(); + return; + } + std::this_thread::sleep_for(std::chrono::milliseconds(25)); + } + }); + } + struct WatcherJoin { + std::atomic& finished; + std::thread& thread; + ~WatcherJoin() { + finished.store(true); + if (thread.joinable()) { + thread.join(); + } + } + } watcherJoin{runFinished, watcher}; + + auto produced = nativeState_->session->Run(runOptions, inputNames.data(), values.data(), values.size(), @@ -276,6 +464,7 @@ TensorInferenceResult OnnxTensorInference::run(const std::vector& return result; #else static_cast(inputs); + static_cast(cancelRequested); result.logMessage = "OnnxTensorInference: this build has no native ONNX Runtime."; return result; #endif diff --git a/src/ai/OnnxTensorInference.h b/src/ai/OnnxTensorInference.h index 6c632d5..6f7ee1b 100644 --- a/src/ai/OnnxTensorInference.h +++ b/src/ai/OnnxTensorInference.h @@ -11,6 +11,21 @@ namespace automix::ai { +// Ordered execution providers a tensor session tries; always ends with "cpu". +// "auto" walks gpu::providerPriorityChain() over the providers this runtime +// build reports; a named provider goes first if reported, and is otherwise +// read as "any GPU" so the reported ones are still tried; "cpu" is CPU only. +// Reported is not the same as usable (CUDA may lack its DLLs), which is why +// loadModel() treats every non-CPU entry as an attempt that may fail. +std::vector tensorProviderCandidates(const std::string& requested, + const std::vector& runtimeProviders); + +// True when a GPU tensor session can actually be opened here, proven by +// opening one on a tiny in-memory graph (a reported provider can still lack +// its DLLs or a device). Probed once per process; `providerOut` receives the +// provider that opened. Always false without native ONNX Runtime. +bool gpuTensorSessionAvailable(std::string* providerOut = nullptr); + // ONNX Runtime backend for tensor graphs. Without the native SDK // (AUTOMIX_HAS_NATIVE_ORT undefined) it is a deterministic no-op: every load // fails with a diagnostic and nothing ever reports usedModel == true. There is @@ -23,12 +38,21 @@ class OnnxTensorInference final : public ITensorInference { // Checked against the probed graph on the next loadModel(); a mismatch fails // the load. Without a contract the graph only has to be float32 throughout. void setTensorContract(std::optional contract); + // "auto" (default), "cpu", or a provider name such as "cuda". Applies to + // the next loadModel(). + void setExecutionProvider(std::string provider); + // Provider the loaded session actually runs on ("cpu", "cuda", ...), or + // empty when nothing is loaded. + [[nodiscard]] std::string activeExecutionProvider() const; bool isAvailable() const override; bool loadModel(const std::filesystem::path& modelPath) override; std::vector inputSpecs() const override; std::vector outputSpecs() const override; TensorInferenceResult run(const std::vector& inputs) const override; + // Aborts an in-flight native run within ~25 ms of cancelRequested() turning true. + TensorInferenceResult runCancellable(const std::vector& inputs, + const std::function& cancelRequested) const override; [[nodiscard]] bool usingNativeSession() const; [[nodiscard]] std::string backendDiagnostics() const; @@ -37,8 +61,12 @@ class OnnxTensorInference final : public ITensorInference { struct NativeState; void unload(std::string diagnostics); + TensorInferenceResult runImpl(const std::vector& inputs, + const std::function* cancelRequested) const; std::optional contract_; + std::string requestedProvider_ = "auto"; + std::string activeProvider_; std::vector inputs_; std::vector outputs_; std::string diagnostics_; diff --git a/src/ai/SeparationRunner.cpp b/src/ai/SeparationRunner.cpp index e8ad41f..152dc1a 100644 --- a/src/ai/SeparationRunner.cpp +++ b/src/ai/SeparationRunner.cpp @@ -254,8 +254,20 @@ SeparationRunner::Result SeparationRunner::separate(const engine::AudioBuffer& m stemAudio.emplace_back(channels, samples, mix.getSampleRate()); } + const auto cancelRequested = [&config] { return config.cancelRequested && config.cancelRequested(); }; + const auto cancelled = [&](const int chunksDone) { + Result stopped; + stopped.cancelled = true; + stopped.logMessage = "Tensor separation cancelled after " + std::to_string(chunksDone) + "/" + + std::to_string(totalChunks) + " chunk(s); no stems were produced."; + return stopped; + }; + try { for (int chunkIndex = 0; chunkIndex < totalChunks; ++chunkIndex) { + if (cancelRequested()) { + return cancelled(chunkIndex); + } const int start = chunkIndex * stride; const int valid = std::min(chunk, samples - start); @@ -280,7 +292,11 @@ SeparationRunner::Result SeparationRunner::separate(const engine::AudioBuffer& m } binding.data = encodeInput(spectrum, config.inputLayout); - const auto inferred = inference.run({binding}); + const auto inferred = inference.runCancellable({binding}, config.cancelRequested); + if (!inferred.usedModel && cancelRequested()) { + // A terminated run reports failure; the cause is the cancellation. + return cancelled(chunkIndex); + } if (!inferred.usedModel) { throw ChunkFailure("chunk " + std::to_string(chunkIndex + 1) + "/" + std::to_string(totalChunks) + " inference failed: " + inferred.logMessage); diff --git a/src/ai/SeparationRunner.h b/src/ai/SeparationRunner.h index f1f571e..1e4a0de 100644 --- a/src/ai/SeparationRunner.h +++ b/src/ai/SeparationRunner.h @@ -56,6 +56,10 @@ struct RunnerConfig { // JUCE-free on purpose: the runner stays testable with no message loop; the // controller that adapts it to the UI owns the SafePointer marshalling. std::function progressCallback; + // Polled before every chunk and, where the backend supports it, during + // inference (from a watcher thread, so it must be thread-safe). Returning + // true discards all work and yields Result::cancelled. + std::function cancelRequested; }; // Empty when the configuration is runnable; otherwise the reason it is not. @@ -65,6 +69,9 @@ class SeparationRunner final { public: struct Result { bool usedModel = false; + // The caller asked to stop. Not a failure: callers must not fall back to + // another separator, because the user wants no separation at all. + bool cancelled = false; std::vector stemAudio; std::vector stemNames; std::string logMessage; diff --git a/src/ai/StemSeparator.cpp b/src/ai/StemSeparator.cpp index 72b98c2..9daae69 100644 --- a/src/ai/StemSeparator.cpp +++ b/src/ai/StemSeparator.cpp @@ -1041,17 +1041,35 @@ StemSeparator::SeparationResult runTensorSeparation(const std::filesystem::path& return result; } config->progressCallback = options.tensorProgress; - - OnnxTensorInference inference; - inference.setTensorContract(pack->tensorContract); - if (!inference.loadModel(modelRoot / pack->modelFile)) { - result.logMessage = "tensor model did not load: " + inference.backendDiagnostics(); - return result; + config->cancelRequested = options.cancelRequested; + + // A GPU session can open and still fail mid-run (out of device memory, an + // unsupported kernel), so a GPU failure gets exactly one retry on CPU. + SeparationRunner::Result separated; + std::string provider; + std::string gpuFailure; + for (const auto& requested : {options.executionProvider, std::string("cpu")}) { + OnnxTensorInference inference; + inference.setTensorContract(pack->tensorContract); + inference.setExecutionProvider(requested); + if (!inference.loadModel(modelRoot / pack->modelFile)) { + result.logMessage = gpuFailure + "tensor model did not load: " + inference.backendDiagnostics(); + return result; + } + provider = inference.activeExecutionProvider(); + separated = SeparationRunner::separate(mix, inference, *config); + if (separated.cancelled) { + result.cancelled = true; + result.logMessage = separated.logMessage; + return result; + } + if (separated.usedModel || provider == "cpu") { + break; + } + gpuFailure = "on " + provider + ": " + separated.logMessage + "; retried on cpu. "; } - - auto separated = SeparationRunner::separate(mix, inference, *config); if (!separated.usedModel) { - result.logMessage = "tensor separation failed: " + separated.logMessage; + result.logMessage = "tensor separation failed: " + gpuFailure + separated.logMessage; return result; } @@ -1085,7 +1103,8 @@ StemSeparator::SeparationResult runTensorSeparation(const std::filesystem::path& result.usedModel = true; result.stemVariantCount = static_cast(result.stems.size()); result.qaMetrics = computeQaMetrics(mix, separated.stemAudio); - result.logMessage = "Tensor separation via pack '" + pack->id + "'." + residuals + " " + separated.logMessage; + result.logMessage = "Tensor separation via pack '" + pack->id + "' on " + provider + "." + residuals + " " + + gpuFailure + separated.logMessage; return result; } @@ -1140,7 +1159,9 @@ StemSeparator::SeparationResult StemSeparator::separate(const std::filesystem::p std::string tensorFallbackNote; if (options.useTensorModel) { auto tensorResult = runTensorSeparation(modelRoot_, mixBuffer, outputDir, options); - if (tensorResult.success) { + if (tensorResult.success || tensorResult.cancelled) { + // Cancelled is returned as-is: falling back would run the separation + // the user just stopped. return tensorResult; } tensorFallbackNote = "Tensor separation unavailable (" + tensorResult.logMessage + "); existing separator used. "; diff --git a/src/ai/StemSeparator.h b/src/ai/StemSeparator.h index 8fd0cb6..ee92f2c 100644 --- a/src/ai/StemSeparator.h +++ b/src/ai/StemSeparator.h @@ -23,6 +23,13 @@ class StemSeparator final { // Tensor path only, called from the separating thread. JUCE-free so the // caller owns any message-thread marshalling. std::function tensorProgress; + // Tensor path only. Polled between chunks and during inference, possibly + // from a watcher thread: must be thread-safe. True stops separation with + // SeparationResult::cancelled and no fallback. + std::function cancelRequested; + // Tensor path only: "auto" (GPU when the runtime can open one, else CPU), + // "cpu", or a provider name such as "cuda". + std::string executionProvider = "auto"; }; struct SeparationQaMetrics { @@ -34,6 +41,8 @@ class StemSeparator final { struct SeparationResult { bool success = false; bool usedModel = false; + // The user stopped it; success is false and nothing was written. + bool cancelled = false; int stemVariantCount = 0; std::vector stems; std::vector generatedFiles; diff --git a/src/app/controllers/ImportController.cpp b/src/app/controllers/ImportController.cpp index 1a7f487..c93c9fc 100644 --- a/src/app/controllers/ImportController.cpp +++ b/src/app/controllers/ImportController.cpp @@ -29,7 +29,7 @@ void ImportController::importFiles(std::vector files, const int preferredStemCount, std::atomic_bool& cancelFlag, std::optional separationModelRoot, - const bool useTensorModel) { + TensorSeparationRequest tensorSeparation) { if (files.empty()) { return; } @@ -68,7 +68,7 @@ void ImportController::importFiles(std::vector files, bool useSeparation; int preferredStemCount; std::optional separationModelRoot; - bool useTensorModel; + TensorSeparationRequest tensorSeparation; std::atomic_bool* cancelFlag; Callbacks callbacks; @@ -76,7 +76,7 @@ void ImportController::importFiles(std::vector files, bool sep, int stemCount, std::optional separationRoot, - bool tensor, + TensorSeparationRequest tensor, std::atomic_bool* cancel, Callbacks cb) : juce::ThreadPoolJob("ImportJob"), @@ -84,7 +84,7 @@ void ImportController::importFiles(std::vector files, useSeparation(sep), preferredStemCount(stemCount), separationModelRoot(std::move(separationRoot)), - useTensorModel(tensor), + tensorSeparation(std::move(tensor)), cancelFlag(cancel), callbacks(std::move(cb)) {} @@ -132,7 +132,7 @@ void ImportController::importFiles(std::vector files, if (!result.cancelled) { ai::StemSeparator separator(separationModelRoot.value_or(std::filesystem::path("assets/models/stem-separator"))); if (separationModelRoot.has_value()) { - if (useTensorModel && separator.isTensorModelAvailable()) { + if (tensorSeparation.enabled && separator.isTensorModelAvailable()) { importLines.push_back("Tensor separation pack: " + separationModelRoot->string()); } else if (separator.isModelAvailable()) { importLines.push_back("Separation model pack: " + separationModelRoot->string()); @@ -143,8 +143,11 @@ void ImportController::importFiles(std::vector files, } ai::StemSeparator::SeparationOptions separationOptions; separationOptions.targetStemCount = preferredStemCount; - separationOptions.useTensorModel = useTensorModel; - if (useTensorModel) { + separationOptions.useTensorModel = tensorSeparation.enabled; + separationOptions.executionProvider = tensorSeparation.executionProvider; + if (tensorSeparation.enabled) { + // Thread-safe: an atomic load plus ThreadPoolJob::shouldExit(). + separationOptions.cancelRequested = [this] { return isCancellationRequested(); }; // Chunk progress fills the band between the 0.12 and 0.78 marks. separationOptions.tensorProgress = [cb = callbacks](const int done, const int total) { if (total > 0) { @@ -154,7 +157,11 @@ void ImportController::importFiles(std::vector files, } const auto separationResult = separator.separate(mixPath, outputDir, separationOptions); emitProgress(callbacks, 0.78); - if (separationResult.success) { + if (separationResult.cancelled) { + requestCancellation(); + result.cancelled = true; + importLines.push_back(separationResult.logMessage); + } else if (separationResult.success) { importedStems = separationResult.stems; separatedFromSingleMix = true; importLines.push_back("Separated import from: " + mixPath.string()); @@ -239,7 +246,7 @@ void ImportController::importFiles(std::vector files, }; threadPool_.addJob( - new ImportJob(std::move(files), useSeparation, preferredStemCount, std::move(separationModelRoot), useTensorModel, &cancelFlag, callbacks_), + new ImportJob(std::move(files), useSeparation, preferredStemCount, std::move(separationModelRoot), std::move(tensorSeparation), &cancelFlag, callbacks_), true); } diff --git a/src/app/controllers/ImportController.h b/src/app/controllers/ImportController.h index 03e24ce..047e8c9 100644 --- a/src/app/controllers/ImportController.h +++ b/src/app/controllers/ImportController.h @@ -14,6 +14,13 @@ namespace automix::app { +// Opt-in tensor (vocal model) separation for single-mix import. +struct TensorSeparationRequest { + bool enabled = false; + // "auto", "cpu" or a provider such as "cuda"; see StemSeparator::SeparationOptions. + std::string executionProvider = "auto"; +}; + struct ImportResult { bool cancelled = false; std::vector stems; @@ -37,7 +44,7 @@ class ImportController { int preferredStemCount, std::atomic_bool& cancelFlag, std::optional separationModelRoot = std::nullopt, - bool useTensorModel = false); + TensorSeparationRequest tensorSeparation = {}); private: juce::ThreadPool& threadPool_; diff --git a/src/app/ui/MainLayout.cpp b/src/app/ui/MainLayout.cpp index 140a687..c5ed14a 100644 --- a/src/app/ui/MainLayout.cpp +++ b/src/app/ui/MainLayout.cpp @@ -1171,7 +1171,11 @@ void MainLayout::importFiles(std::vector files) { sessionManager_.session().preferredStemCount, taskOrchestrator_->cancelFlag(ActiveTask::Import), std::move(separationModelRoot), - sessionManager_.session().renderSettings.tensorSeparationEnabled); + TensorSeparationRequest{ + .enabled = sessionManager_.session().renderSettings.tensorSeparationEnabled, + .executionProvider = sessionManager_.session().renderSettings.preferHardwareAcceleration + ? sessionManager_.session().renderSettings.gpuExecutionProvider + : std::string("cpu")}); } bool MainLayout::startAiSeparationBeforeAutoMixIfNeeded() { diff --git a/tests/unit/TensorInferenceTests.cpp b/tests/unit/TensorInferenceTests.cpp index 6795fe7..d92cf77 100644 --- a/tests/unit/TensorInferenceTests.cpp +++ b/tests/unit/TensorInferenceTests.cpp @@ -20,6 +20,7 @@ #include "ai/ModelCatalogValidator.h" #include "ai/ModelLicensePolicy.h" #include "ai/ModelPackLoader.h" +#include "ai/OnnxExternalData.h" #include "ai/OnnxTensorInference.h" #include "ai/SeparationRunner.h" #include "ai/StemSeparator.h" @@ -997,4 +998,281 @@ TEST_CASE("Tensor separation runs end to end through StemSeparator", "[ai][tenso std::filesystem::remove_all(tempRoot); } -#endif \ No newline at end of file +#endif +TEST_CASE("Tensor provider candidates always end on CPU", "[ai][tensor][gpu]") { + using V = std::vector; + const V cudaBuild{"TensorrtExecutionProvider", "CUDAExecutionProvider", "CPUExecutionProvider"}; + REQUIRE(ai::tensorProviderCandidates("auto", cudaBuild) == V{"cuda", "cpu"}); + REQUIRE(ai::tensorProviderCandidates("", cudaBuild) == V{"cuda", "cpu"}); + REQUIRE(ai::tensorProviderCandidates("cuda", cudaBuild) == V{"cuda", "cpu"}); + REQUIRE(ai::tensorProviderCandidates("CUDAExecutionProvider", cudaBuild) == V{"cuda", "cpu"}); + REQUIRE(ai::tensorProviderCandidates("cpu", cudaBuild) == V{"cpu"}); + // A provider the runtime does not contain is never attempted, but the GPU + // request is honoured with what is there (Windows defaults to DirectML). + REQUIRE(ai::tensorProviderCandidates("directml", cudaBuild) == V{"cuda", "cpu"}); + REQUIRE(ai::tensorProviderCandidates("directml", V{"CPUExecutionProvider"}) == V{"cpu"}); + REQUIRE(ai::tensorProviderCandidates("auto", V{"CPUExecutionProvider"}) == V{"cpu"}); + REQUIRE(ai::tensorProviderCandidates("auto", V{}) == V{"cpu"}); +} + +TEST_CASE("Runner stops before the next chunk when cancelled", "[ai][tensor][runner][cancel]") { + FakeTensorInference fake({kRoformerInput}, {kRoformerOutput}, identityMask); + const int samples = 352800 * 3; + const auto mix = makeTestSignal(2, samples, 44100.0); + auto config = roformerRunnerConfig(); + REQUIRE(ai::SeparationRunner::chunkCount(samples, config) > 2); + int progressCalls = 0; + config.progressCallback = [&](int, int) { ++progressCalls; }; + config.cancelRequested = [&] { return progressCalls >= 1; }; + + const auto result = ai::SeparationRunner::separate(mix, fake, config); + INFO(result.logMessage); + REQUIRE(result.cancelled); + REQUIRE_FALSE(result.usedModel); + REQUIRE(result.stemAudio.empty()); + REQUIRE(fake.calls == 1); + REQUIRE(result.logMessage.find("cancelled after 1/") != std::string::npos); +} + +namespace { + +// A backend whose inference can be aborted: it reports the terminated run as a +// failure, the way ONNX Runtime does after RunOptions::SetTerminate(). +class AbortableTensorInference final : public ai::ITensorInference { + public: + bool isAvailable() const override { return true; } + bool loadModel(const std::filesystem::path&) override { return true; } + std::vector inputSpecs() const override { return {kRoformerInput}; } + std::vector outputSpecs() const override { return {kRoformerOutput}; } + ai::TensorInferenceResult run(const std::vector& inputs) const override { + return identityMask(inputs); + } + ai::TensorInferenceResult runCancellable(const std::vector& inputs, + const std::function& cancelRequested) const override { + ++calls; + abortRequested = true; // the user presses Cancel while this chunk is running + if (cancelRequested && cancelRequested()) { + ai::TensorInferenceResult aborted; + aborted.logMessage = "Exiting due to terminate flag being set to true."; + return aborted; + } + return run(inputs); + } + mutable int calls = 0; + mutable bool abortRequested = false; +}; + +} // namespace + +TEST_CASE("Runner reports a terminated inference as cancelled, not failed", "[ai][tensor][runner][cancel]") { + AbortableTensorInference backend; + const auto mix = makeTestSignal(2, 352800 * 2, 44100.0); + auto config = roformerRunnerConfig(); + config.cancelRequested = [&] { return backend.abortRequested; }; + + const auto result = ai::SeparationRunner::separate(mix, backend, config); + INFO(result.logMessage); + REQUIRE(result.cancelled); + REQUIRE(backend.calls == 1); + REQUIRE(result.logMessage.find("cancelled after 0/") != std::string::npos); + REQUIRE(result.logMessage.find("failed") == std::string::npos); +} + +#ifdef AUTOMIX_HAS_NATIVE_ORT + +TEST_CASE("Cancelled tensor separation writes nothing and does not fall back", "[ai][tensor][separator][cancel][native]") { + const auto tempRoot = std::filesystem::temp_directory_path() / "automix_tensor_cancel"; + std::filesystem::remove_all(tempRoot); + std::filesystem::create_directories(tempRoot); + const auto mixPath = tempRoot / "mix.wav"; + automix::util::WavWriter writer; + writer.write(mixPath, makeTestSignal(2, 100000, 44100.0), 24); + const auto fixture = std::filesystem::path(AUTOMIX_SOURCE_DIR) / "tests" / "fixtures" / "tensor" / "identity_mask.onnx"; + const auto packRoot = writeTensorPack(tempRoot / "pack", &fixture); + + ai::StemSeparator separator(packRoot); + ai::StemSeparator::SeparationOptions options; + options.useTensorModel = true; + options.cancelRequested = [] { return true; }; + const auto result = separator.separate(mixPath, tempRoot / "out", options); + INFO(result.logMessage); + REQUIRE(result.cancelled); + REQUIRE_FALSE(result.success); + REQUIRE(result.stems.empty()); + REQUIRE(result.generatedFiles.empty()); + // The existing separator would have produced stems; it must not have run. + REQUIRE_FALSE(std::filesystem::exists(tempRoot / "out" / "separation_qa_report.json")); + std::filesystem::remove_all(tempRoot); +} + +TEST_CASE("Tensor session reports the provider it actually runs on", "[ai][tensor][gpu][native]") { + const auto fixture = std::filesystem::path(AUTOMIX_SOURCE_DIR) / "tests" / "fixtures" / "tensor" / "identity_mask.onnx"; + + ai::OnnxTensorInference cpu; + cpu.setExecutionProvider("cpu"); + REQUIRE(cpu.loadModel(fixture)); + REQUIRE(cpu.activeExecutionProvider() == "cpu"); + REQUIRE(cpu.backendDiagnostics().find("provider=cpu") != std::string::npos); + + // "auto" opens on whatever works here; either way it must say which. + ai::OnnxTensorInference automatic; + REQUIRE(automatic.loadModel(fixture)); + INFO(automatic.backendDiagnostics()); + REQUIRE_FALSE(automatic.activeExecutionProvider().empty()); + + const char* expectCuda = std::getenv("AUTOMIX_EXPECT_CUDA"); + if (expectCuda == nullptr || std::string(expectCuda) != "1") { + SKIP("Set AUTOMIX_EXPECT_CUDA=1 on a machine with a CUDA-capable GPU and runtime to require CUDA"); + } + ai::OnnxTensorInference cuda; + cuda.setExecutionProvider("cuda"); + REQUIRE(cuda.loadModel(fixture)); + INFO(cuda.backendDiagnostics()); + REQUIRE(cuda.activeExecutionProvider() == "cuda"); + // Same numbers on the GPU: the identity mask still reconstructs the mix. + const int samples = 352800; + const auto mix = makeTestSignal(2, samples, 44100.0); + auto config = roformerRunnerConfig(); + config.stft.zeroDc = false; + const auto separated = ai::SeparationRunner::separate(mix, cuda, config); + INFO(separated.logMessage); + REQUIRE(separated.usedModel); + REQUIRE(maxAbsDifference(separated.stemAudio[0], mix) < 1.0e-4f); +} + +#endif +namespace { + +std::filesystem::path tensorFixturePath(const char* name) { + return std::filesystem::path(AUTOMIX_SOURCE_DIR) / "tests" / "fixtures" / "tensor" / name; +} + +// Minimal protobuf encoding, enough to hand-build hostile ONNX models. +void putVarint(std::string& out, std::uint64_t value) { + while (value >= 0x80) { + out.push_back(static_cast((value & 0x7F) | 0x80)); + value >>= 7; + } + out.push_back(static_cast(value)); +} +std::string lengthField(std::uint32_t number, const std::string& payload) { + std::string out; + putVarint(out, (static_cast(number) << 3) | 2); + putVarint(out, payload.size()); + return out + payload; +} +std::string modelWithExternalLocation(const std::string& location) { + std::string tensor; + putVarint(tensor, (14u << 3) | 0); // data_location + putVarint(tensor, 1); // EXTERNAL + tensor += lengthField(13, lengthField(1, "location") + lengthField(2, location)); + return lengthField(7, lengthField(5, tensor)); // ModelProto.graph.initializer +} + +} // namespace + +TEST_CASE("External-data models are inlined into one self-contained file", "[ai][tensor][external-data]") { + const auto root = std::filesystem::temp_directory_path() / "automix_inline_external"; + std::filesystem::remove_all(root); + std::filesystem::create_directories(root); + std::filesystem::copy_file(tensorFixturePath("external_data.onnx"), root / "external_data.onnx"); + std::filesystem::copy_file(tensorFixturePath("external_data.onnx.data"), root / "external_data.onnx.data"); + + const auto inlinedPath = root / "inlined.onnx"; + const auto inlined = ai::inlineExternalData(root / "external_data.onnx", inlinedPath); + INFO(inlined.error); + REQUIRE(inlined.success); + REQUIRE(inlined.tensorsInlined == 1); + const auto bytes = readBytes(inlinedPath); + const std::string text(bytes.begin(), bytes.end()); + REQUIRE(text.find("external_data.onnx.data") == std::string::npos); + REQUIRE(text.find("location") == std::string::npos); + // The sidecar's 16 bytes now live in the model itself. + const auto sidecar = readBytes(root / "external_data.onnx.data"); + REQUIRE(text.find(std::string(sidecar.begin(), sidecar.end())) != std::string::npos); + REQUIRE_FALSE(std::filesystem::exists(root / "inlined.onnx.partial")); + + // A model with nothing external is copied unchanged. + const auto again = ai::inlineExternalData(inlinedPath, root / "again.onnx"); + REQUIRE(again.success); + REQUIRE(again.tensorsInlined == 0); + REQUIRE(readBytes(root / "again.onnx") == bytes); + +#ifdef AUTOMIX_HAS_NATIVE_ORT + // It still computes the same thing with the sidecar gone. + std::filesystem::remove(root / "external_data.onnx.data"); + ai::OnnxTensorInference session; + REQUIRE(session.loadModel(inlinedPath)); + const auto ran = session.run({{{"x", ai::TensorElementType::Float32, {1, 4}}, {0.5f, 0.5f, 0.5f, 0.5f}}}); + REQUIRE(ran.usedModel); + REQUIRE(ran.outputs.front().data == std::vector{1.5f, 2.5f, 3.5f, 4.5f}); +#endif + std::filesystem::remove_all(root); +} + +TEST_CASE("External-data inlining refuses locations outside the model directory", "[ai][tensor][external-data]") { + const auto root = std::filesystem::temp_directory_path() / "automix_inline_hostile"; + std::filesystem::remove_all(root); + std::filesystem::create_directories(root / "models"); + { + std::ofstream secret(root / "secret.bin", std::ios::binary); + secret << "do not copy me"; + } + for (const std::string location : {std::string("../secret.bin"), (root / "secret.bin").string(), std::string("")}) { + INFO("location: " << location); + const auto modelPath = root / "models" / "hostile.onnx"; + { + std::ofstream model(modelPath, std::ios::binary | std::ios::trunc); + const auto bytes = modelWithExternalLocation(location); + model.write(bytes.data(), static_cast(bytes.size())); + } + const auto result = ai::inlineExternalData(modelPath, root / "models" / "out.onnx"); + REQUIRE_FALSE(result.success); + REQUIRE(result.error.find("location") != std::string::npos); + REQUIRE_FALSE(std::filesystem::exists(root / "models" / "out.onnx")); + } + + // Truncated input is a clean error, not a crash. + { + std::ofstream model(root / "models" / "truncated.onnx", std::ios::binary | std::ios::trunc); + const auto bytes = modelWithExternalLocation("weights.bin"); + model.write(bytes.data(), static_cast(bytes.size() - 3)); + } + const auto truncated = ai::inlineExternalData(root / "models" / "truncated.onnx", root / "models" / "t.onnx"); + REQUIRE_FALSE(truncated.success); + REQUIRE(truncated.error.find("not a valid ONNX model") != std::string::npos); + std::filesystem::remove_all(root); +} +TEST_CASE("BS-RoFormer installs fp32 for GPU and quantized for CPU", "[ai][tensor][catalog][gpu]") { + bool hasOnnx = false; + REQUIRE(ai::primaryFileForRepo(ai::kBsRoformerRepoId, kBsRoformerSiblings, &hasOnnx, true) == + ai::kBsRoformerFp32File); + REQUIRE(hasOnnx); + REQUIRE(ai::primaryFileForRepo(ai::kBsRoformerRepoId, kBsRoformerSiblings, &hasOnnx, false) == + ai::kBsRoformerQuantizedFile); + + // fp32 without its weights is never chosen. + std::vector noSidecar = kBsRoformerSiblings; + noSidecar.erase(std::find(noSidecar.begin(), noSidecar.end(), "bs_roformer_ep317_sdr12.9755.onnx.data")); + REQUIRE(ai::primaryFileForRepo(ai::kBsRoformerRepoId, noSidecar, &hasOnnx, true) == ai::kBsRoformerQuantizedFile); + + // The sidecar is fetched with the fp32 graph and only with it. + REQUIRE(ai::auxiliaryAssetsFor(ai::kBsRoformerRepoId, ai::kBsRoformerFp32File, kBsRoformerSiblings) == + std::vector{"bs_roformer_ep317_sdr12.9755.onnx.data"}); + REQUIRE(ai::auxiliaryAssetsFor(ai::kBsRoformerRepoId, ai::kBsRoformerQuantizedFile, kBsRoformerSiblings).empty()); +} + +TEST_CASE("GPU probe agrees with the build", "[ai][tensor][gpu]") { + std::string provider; + const bool available = ai::gpuTensorSessionAvailable(&provider); + INFO("provider: " << provider); + REQUIRE(available == !provider.empty()); +#ifndef AUTOMIX_HAS_NATIVE_ORT + REQUIRE_FALSE(available); +#else + const char* expectCuda = std::getenv("AUTOMIX_EXPECT_CUDA"); + if (expectCuda != nullptr && std::string(expectCuda) == "1") { + REQUIRE(provider == "cuda"); + } +#endif +} \ No newline at end of file diff --git a/tools/commands/ModelCommands.cpp b/tools/commands/ModelCommands.cpp index 3e7359d..46e6404 100644 --- a/tools/commands/ModelCommands.cpp +++ b/tools/commands/ModelCommands.cpp @@ -512,27 +512,34 @@ int commandModelHealth(const CommandArgs& args) { return failed == 0 ? 0 : 1; } -// separate --mix --out [--pack ] [--tensor] [--json] +// separate --mix --out [--pack ] [--tensor] [--provider auto|cpu|cuda] [--cancel-after-ms N] [--json] // Runs StemSeparator exactly as single-mix import does, outside the UI. int commandSeparate(const std::vector& args) { const auto mixArg = argValue(args, "--mix"); const auto outArg = argValue(args, "--out"); if (!mixArg.has_value() || !outArg.has_value()) { - std::cerr << "Usage: separate --mix --out [--pack ] [--tensor] [--json]\n"; + std::cerr << "Usage: separate --mix --out [--pack ] [--tensor] [--provider auto|cpu|cuda] [--cancel-after-ms N] [--json]\n"; return 2; } automix::ai::StemSeparator separator(argValue(args, "--pack").value_or("assets/models/stem-separator")); automix::ai::StemSeparator::SeparationOptions options; options.useTensorModel = hasFlag(args, "--tensor"); + options.executionProvider = argValue(args, "--provider").value_or("auto"); const bool jsonOutput = hasFlag(args, "--json"); + // --cancel-after-ms N: request cancellation N ms after start, to measure how + // quickly a running separation actually stops. + const auto started = std::chrono::steady_clock::now(); + if (const auto cancelAfter = parseIntArg(args, "--cancel-after-ms"); cancelAfter.has_value()) { + const auto deadline = started + std::chrono::milliseconds(*cancelAfter); + options.cancelRequested = [deadline] { return std::chrono::steady_clock::now() >= deadline; }; + } if (options.useTensorModel && !jsonOutput) { options.tensorProgress = [](const int done, const int total) { std::cout << " chunk " << done << "/" << total << "\n" << std::flush; }; } - const auto started = std::chrono::steady_clock::now(); const auto result = separator.separate(*mixArg, *outArg, options); const double seconds = std::chrono::duration(std::chrono::steady_clock::now() - started).count(); @@ -544,6 +551,7 @@ int commandSeparate(const std::vector& args) { const nlohmann::json payload = { {"success", result.success}, {"usedModel", result.usedModel}, + {"cancelled", result.cancelled}, {"seconds", seconds}, {"stems", stems}, {"energyLeakage", result.qaMetrics.energyLeakage}, From 23a4f49ae635a015c9c564cd7bdc3aec7436adba Mon Sep 17 00:00:00 2001 From: Soficis Date: Thu, 1 Oct 2026 09:09:58 -0500 Subject: [PATCH 08/68] feat(ai): gate fp32 GPU separation on device memory The fp32 BS-RoFormer build peaks at 9442 MiB of device memory per chunk (RTX 5060 Ti, CUDA 13, arena shrinkage). Below that it spills into shared system memory, which measured slower than running on CPU. - queryCudaDeviceMemory(): free/total of CUDA device 0 via cudaMemGetInfo from the CUDA runtime loaded at run time; nothing links against CUDA. - Install: fp32 is chosen only when the GPU probe opened a CUDA session and the device totals >= 10 GiB + 1.5 GiB headroom (12 GB cards qualify, 8 GB cards get the quantized build). - Run: packs declare gpu_memory_mb (10240 for fp32); when free memory is below it, separation runs on CPU and the log says why. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 1 + src/ai/BsRoformerPack.h | 6 ++ src/ai/GpuMemory.cpp | 54 ++++++++++++++++ src/ai/GpuMemory.h | 31 +++++++++ src/ai/HuggingFaceModelHub.cpp | 9 ++- src/ai/ModelCatalogValidator.cpp | 7 ++- src/ai/ModelPackLoader.cpp | 3 + src/ai/ModelPackLoader.h | 4 ++ src/ai/StemSeparator.cpp | 15 ++++- tests/unit/TensorInferenceTests.cpp | 97 ++++++++++++++++++++++++++++- 10 files changed, 221 insertions(+), 6 deletions(-) create mode 100644 src/ai/GpuMemory.cpp create mode 100644 src/ai/GpuMemory.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 9ef8d9e..c96f793 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -149,6 +149,7 @@ add_library(automix_core src/ai/SeparationRunner.cpp src/ai/OnnxTensorInference.cpp src/ai/OnnxExternalData.cpp + src/ai/GpuMemory.cpp src/util/StringUtils.cpp src/util/LameDownloader.cpp src/util/MetadataPolicy.cpp diff --git a/src/ai/BsRoformerPack.h b/src/ai/BsRoformerPack.h index 873a8fe..a0540aa 100644 --- a/src/ai/BsRoformerPack.h +++ b/src/ai/BsRoformerPack.h @@ -1,5 +1,7 @@ #pragma once +#include + #include "ai/ModelPackLoader.h" namespace automix::ai { @@ -19,6 +21,10 @@ inline constexpr const char* kBsRoformerQuantizedFile = "bs_roformer_ep317_sdr12 // The installer inlines the sidecar (inlineExternalData) because ONNX Runtime // cannot load this export with its weights external. inline constexpr const char* kBsRoformerFp32File = "bs_roformer_ep317_sdr12.9755.onnx"; +// Device memory the fp32 build needs while separating: measured peak 9442 MiB +// above baseline (RTX 5060 Ti, CUDA 13, per-run arena shrinkage), rounded up to +// 10 GiB. Written into the pack manifest as gpu_memory_mb. +inline constexpr std::uint64_t kBsRoformerFp32GpuMemoryMb = 10240; inline constexpr const char* kBsRoformerIntendedUse = "Vocal separation (BS-RoFormer). The graph produces vocals only; the instrumental stem is the " "residual mix - vocals, not a second separation. Quantized build: quality versus fp32 is not " diff --git a/src/ai/GpuMemory.cpp b/src/ai/GpuMemory.cpp new file mode 100644 index 0000000..659a1c1 --- /dev/null +++ b/src/ai/GpuMemory.cpp @@ -0,0 +1,54 @@ +#include "ai/GpuMemory.h" + +#include + +#include + +namespace automix::ai { +namespace { + +// Headroom on top of a model's need when judging total memory: a Windows +// desktop held 1.6-2.2 GB on the 16 GB card this was measured on. Sized so a +// 12 GB card (reported as just under 12 GiB) still qualifies for a 10 GiB model. +constexpr std::uint64_t kInstallHeadroomBytes = 1536ull * 1024 * 1024; + +using CudaMemGetInfo = int (*)(std::size_t* freeBytes, std::size_t* totalBytes); + +} // namespace + +std::optional queryCudaDeviceMemory() { +#if defined(_WIN32) + const char* candidates[] = {"cudart64_13.dll", "cudart64_12.dll", "cudart64_110.dll"}; +#elif defined(__APPLE__) + const char* candidates[] = {"libcudart.dylib"}; +#else + const char* candidates[] = {"libcudart.so.13", "libcudart.so.12", "libcudart.so"}; +#endif + for (const auto* name : candidates) { + juce::DynamicLibrary library; + if (!library.open(name)) { + continue; + } + const auto query = reinterpret_cast(library.getFunction("cudaMemGetInfo")); + if (query == nullptr) { + continue; + } + std::size_t freeBytes = 0; + std::size_t totalBytes = 0; + if (query(&freeBytes, &totalBytes) != 0 || totalBytes == 0) { // 0 == cudaSuccess + return std::nullopt; + } + return GpuMemoryInfo{freeBytes, totalBytes}; + } + return std::nullopt; +} + +bool gpuFitsModel(const std::optional& memory, const std::uint64_t requiredBytes) { + return memory.has_value() && memory->totalBytes >= requiredBytes + kInstallHeadroomBytes; +} + +bool gpuHasRoomNow(const std::optional& memory, const std::uint64_t requiredBytes) { + return !memory.has_value() || memory->freeBytes >= requiredBytes; +} + +} // namespace automix::ai diff --git a/src/ai/GpuMemory.h b/src/ai/GpuMemory.h new file mode 100644 index 0000000..2b9b0bf --- /dev/null +++ b/src/ai/GpuMemory.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include + +namespace automix::ai { + +struct GpuMemoryInfo { + std::uint64_t freeBytes = 0; + std::uint64_t totalBytes = 0; +}; + +// Free and total memory of CUDA device 0 (the device the CUDA execution +// provider uses), read through the CUDA runtime loaded at run time so nothing +// links against CUDA. Empty when no CUDA runtime is loadable or the query +// fails, e.g. on a machine without an NVIDIA GPU. +std::optional queryCudaDeviceMemory(); + +// Install-time choice: is the device big enough for a model that needs +// `requiredBytes` while it runs? Judged on total memory with headroom for what +// the desktop and other applications typically hold, since free memory at +// install time says little about free memory at separation time. +bool gpuFitsModel(const std::optional& memory, std::uint64_t requiredBytes); + +// Run-time choice: can a model needing `requiredBytes` run on the GPU right +// now without spilling into shared system memory? Unknown memory is treated as +// yes, because there is nothing better to go on and a failed GPU run is +// retried on CPU anyway. +bool gpuHasRoomNow(const std::optional& memory, std::uint64_t requiredBytes); + +} // namespace automix::ai diff --git a/src/ai/HuggingFaceModelHub.cpp b/src/ai/HuggingFaceModelHub.cpp index f18bbdd..556d0c0 100644 --- a/src/ai/HuggingFaceModelHub.cpp +++ b/src/ai/HuggingFaceModelHub.cpp @@ -14,6 +14,7 @@ #include #include "ai/BsRoformerPack.h" +#include "ai/GpuMemory.h" #include "ai/ItoMasterAdapter.h" #include "ai/ModelCatalogValidator.h" #include "ai/OnnxExternalData.h" @@ -621,8 +622,12 @@ std::optional HuggingFaceModelHub::modelInfo(const std::string& mo } // The GPU probe opens a real session once per process; only repos with a - // GPU variant pay for it. - const bool preferGpuBuild = info.repoId == kBsRoformerRepoId && gpuTensorSessionAvailable(); + // GPU variant pay for it. fp32 also needs a device big enough to hold it: + // on a smaller card it spills into shared memory and runs slower than CPU. + std::string gpuProvider; + const bool preferGpuBuild = info.repoId == kBsRoformerRepoId && gpuTensorSessionAvailable(&gpuProvider) && + gpuProvider == "cuda" && + gpuFitsModel(queryCudaDeviceMemory(), kBsRoformerFp32GpuMemoryMb * 1024 * 1024); info.primaryFile = primaryFileForRepo(info.repoId, info.files, &info.hasOnnx, preferGpuBuild); info.useCase = HuggingFaceModelHub::inferUseCase(info.repoId, info.tags, ""); const auto compatibility = validateCatalogModel(info); diff --git a/src/ai/ModelCatalogValidator.cpp b/src/ai/ModelCatalogValidator.cpp index 3349b60..e8aced4 100644 --- a/src/ai/ModelCatalogValidator.cpp +++ b/src/ai/ModelCatalogValidator.cpp @@ -269,8 +269,11 @@ bool writeTurnkeyModelPackManifest(const std::filesystem::path& installPath, "License: " + kItoMasterLicense + ". Attribution: " + kItoMasterAttribution; } if (model.repoId == kBsRoformerRepoId) { - manifest["intended_use"] = - modelFileName == kBsRoformerFp32File ? kBsRoformerFp32IntendedUse : kBsRoformerIntendedUse; + const bool fp32 = modelFileName == kBsRoformerFp32File; + manifest["intended_use"] = fp32 ? kBsRoformerFp32IntendedUse : kBsRoformerIntendedUse; + if (fp32) { + manifest["gpu_memory_mb"] = kBsRoformerFp32GpuMemoryMb; + } } const auto manifestPath = installPath / "model.json"; diff --git a/src/ai/ModelPackLoader.cpp b/src/ai/ModelPackLoader.cpp index 00ea9db..7d72619 100644 --- a/src/ai/ModelPackLoader.cpp +++ b/src/ai/ModelPackLoader.cpp @@ -511,6 +511,9 @@ std::optional ModelPackLoader::load(const std::filesystem::path& dire pack.defaultInterOpThreads.reset(); } pack.enableProfiling = json.value("enableProfiling", json.value("enable_profiling", false)); + if (json.contains("gpu_memory_mb") && json.at("gpu_memory_mb").is_number_unsigned()) { + pack.gpuMemoryMb = json.at("gpu_memory_mb").get(); + } if (pack.featureSchemaVersion.empty() && json.contains("feature_schema") && json.at("feature_schema").is_object()) { pack.featureSchemaVersion = json.at("feature_schema").value("version", ""); diff --git a/src/ai/ModelPackLoader.h b/src/ai/ModelPackLoader.h index 83bce54..12d13bd 100644 --- a/src/ai/ModelPackLoader.h +++ b/src/ai/ModelPackLoader.h @@ -99,6 +99,10 @@ struct ModelPack { std::vector outputNames; // Absent for scalar-only packs; existing packs load unchanged. std::optional tensorContract; + // Device memory the model needs while running on a GPU (MiB). When free + // memory is below it, GPU execution would spill into system memory, so + // callers run on CPU instead. Absent: no known requirement. + std::optional gpuMemoryMb; std::filesystem::path rootPath; }; diff --git a/src/ai/StemSeparator.cpp b/src/ai/StemSeparator.cpp index 9daae69..2382abc 100644 --- a/src/ai/StemSeparator.cpp +++ b/src/ai/StemSeparator.cpp @@ -14,6 +14,7 @@ #include +#include "ai/GpuMemory.h" #include "ai/ModelPackLoader.h" #include "ai/OnnxModelInference.h" #include "ai/OnnxTensorInference.h" @@ -1048,7 +1049,19 @@ StemSeparator::SeparationResult runTensorSeparation(const std::filesystem::path& SeparationRunner::Result separated; std::string provider; std::string gpuFailure; - for (const auto& requested : {options.executionProvider, std::string("cpu")}) { + auto firstProvider = options.executionProvider; + if (firstProvider != "cpu" && pack->gpuMemoryMb.has_value()) { + // Not enough free device memory means the run would spill into shared + // system memory, which measured slower than running on CPU outright. + const auto requiredBytes = *pack->gpuMemoryMb * 1024 * 1024; + const auto memory = queryCudaDeviceMemory(); + if (!gpuHasRoomNow(memory, requiredBytes)) { + firstProvider = "cpu"; + gpuFailure = "GPU has " + std::to_string(memory->freeBytes / (1024 * 1024)) + " MiB free but the model needs " + + std::to_string(*pack->gpuMemoryMb) + " MiB; ran on cpu. "; + } + } + for (const auto& requested : {firstProvider, std::string("cpu")}) { OnnxTensorInference inference; inference.setTensorContract(pack->tensorContract); inference.setExecutionProvider(requested); diff --git a/tests/unit/TensorInferenceTests.cpp b/tests/unit/TensorInferenceTests.cpp index d92cf77..78abffb 100644 --- a/tests/unit/TensorInferenceTests.cpp +++ b/tests/unit/TensorInferenceTests.cpp @@ -16,6 +16,7 @@ #include #include "ai/BsRoformerPack.h" +#include "ai/GpuMemory.h" #include "ai/ITensorInference.h" #include "ai/ModelCatalogValidator.h" #include "ai/ModelLicensePolicy.h" @@ -1275,4 +1276,98 @@ TEST_CASE("GPU probe agrees with the build", "[ai][tensor][gpu]") { REQUIRE(provider == "cuda"); } #endif -} \ No newline at end of file +} +TEST_CASE("GPU memory policy for the fp32 model", "[ai][tensor][gpu]") { + constexpr std::uint64_t MiB = 1024 * 1024; + const std::uint64_t need = ai::kBsRoformerFp32GpuMemoryMb * MiB; + const auto card = [](std::uint64_t freeMiB, std::uint64_t totalMiB) { + return std::optional(ai::GpuMemoryInfo{freeMiB * 1024 * 1024, totalMiB * 1024 * 1024}); + }; + // Install: judged on total size. 12 GB cards report just under 12 GiB. + REQUIRE(ai::gpuFitsModel(card(14000, 16311), need)); + REQUIRE(ai::gpuFitsModel(card(10000, 12282), need)); + REQUIRE_FALSE(ai::gpuFitsModel(card(7000, 8188), need)); + REQUIRE_FALSE(ai::gpuFitsModel(std::nullopt, need)); // unknown size: keep the CPU build + // Run time: judged on what is free right now. + REQUIRE(ai::gpuHasRoomNow(card(14000, 16311), need)); + REQUIRE_FALSE(ai::gpuHasRoomNow(card(9000, 16311), need)); // e.g. a game holding 7 GB + REQUIRE(ai::gpuHasRoomNow(std::nullopt, need)); // unknown: try, the CPU retry covers it +} + +TEST_CASE("fp32 pack records its GPU memory need; quantized does not", "[ai][tensor][gpu][catalog]") { + const auto root = std::filesystem::temp_directory_path() / "automix_gpu_memory_manifest"; + std::filesystem::remove_all(root); + std::filesystem::create_directories(root); + for (const std::string file : {std::string(ai::kBsRoformerFp32File), std::string(ai::kBsRoformerQuantizedFile)}) { + { + std::ofstream model(root / file, std::ios::binary); + model << "not a real graph"; + } + ai::HubModelInfo info; + info.repoId = ai::kBsRoformerRepoId; + info.modelId = ai::kBsRoformerRepoId; + info.license = "mit"; + ai::HubInstallResult install; + install.primaryFilePath = root / file; + ai::ModelCompatibilityResult compatibility; + compatibility.compatible = true; + compatibility.taskScope = "separation"; + compatibility.packType = "separation_model"; + const auto contract = ai::bsRoformerCatalogContract(); + std::string error; + REQUIRE(ai::writeTurnkeyModelPackManifest(root, info, install, compatibility, &contract, &error)); + const auto pack = ai::ModelPackLoader().load(root); + REQUIRE(pack.has_value()); + INFO(file); + if (file == ai::kBsRoformerFp32File) { + REQUIRE(pack->gpuMemoryMb == std::optional(ai::kBsRoformerFp32GpuMemoryMb)); + } else { + REQUIRE_FALSE(pack->gpuMemoryMb.has_value()); + } + } + std::filesystem::remove_all(root); +} + +#ifdef AUTOMIX_HAS_NATIVE_ORT + +TEST_CASE("Separation runs on CPU when the GPU lacks free memory for the model", "[ai][tensor][gpu][native]") { + const auto memory = ai::queryCudaDeviceMemory(); + const char* expectCuda = std::getenv("AUTOMIX_EXPECT_CUDA"); + if (expectCuda != nullptr && std::string(expectCuda) == "1") { + REQUIRE(memory.has_value()); + REQUIRE(memory->totalBytes > memory->freeBytes); + } + if (!memory.has_value()) { + SKIP("No CUDA runtime loadable here; the memory gate cannot engage"); + } + + const auto tempRoot = std::filesystem::temp_directory_path() / "automix_gpu_memory_gate"; + std::filesystem::remove_all(tempRoot); + std::filesystem::create_directories(tempRoot); + const auto mixPath = tempRoot / "mix.wav"; + automix::util::WavWriter writer; + writer.write(mixPath, makeTestSignal(2, 100000, 44100.0), 24); + const auto fixture = std::filesystem::path(AUTOMIX_SOURCE_DIR) / "tests" / "fixtures" / "tensor" / "identity_mask.onnx"; + const auto packRoot = writeTensorPack(tempRoot / "pack", &fixture); + { + // Declare a need no device has. + std::ifstream in(packRoot / "model.json"); + auto manifest = nlohmann::json::parse(in); + in.close(); + manifest["gpu_memory_mb"] = memory->totalBytes / (1024 * 1024) + 1; + std::ofstream(packRoot / "model.json") << manifest.dump(2); + } + + ai::StemSeparator separator(packRoot); + ai::StemSeparator::SeparationOptions options; + options.useTensorModel = true; + const auto result = separator.separate(mixPath, tempRoot / "out", options); + INFO(result.logMessage); + REQUIRE(result.success); + REQUIRE(result.usedModel); + REQUIRE(result.logMessage.find("MiB free but the model needs") != std::string::npos); + REQUIRE(result.logMessage.find(" on cpu.") != std::string::npos); + std::filesystem::remove_all(tempRoot); +} + +#endif \ No newline at end of file From 05ef29d1e178f8ca70670076a590385b4f597ca7 Mon Sep 17 00:00:00 2001 From: Soficis Date: Thu, 1 Oct 2026 09:53:30 -0500 Subject: [PATCH 09/68] feat(ai): on-demand per-user GPU runtime pack for CUDA The CUDA execution provider needs NVIDIA's CUDA runtime, cuBLAS, cuFFT and cuDNN (~1.3 GB unpacked). Rather than ship them with every install, the app offers them once, to machines that can use them. - GpuRuntimePack: NVIDIA's redistributable wheels from PyPI, pinned by URL + SHA-256 (~1.02 GB download); only DLLs are extracted (entries escaping the archive are refused) into %LOCALAPPDATA%\AutoMixMaster\gpu-runtime\\bin. A versioned marker is written last, so failed, cancelled or older installs are never trusted. No admin rights, no system CUDA, no PATH changes. - preload() loads the DLLs by full path before the GPU probe, tensor sessions and the device-memory query; the probe now caches only success so a pack installed mid-session takes effect. - Offer: when Vocal Model is switched on and an NVIDIA adapter with >= 11.5 GiB (read via DXGI) is present, the ORT build has CUDA and the pack is missing, a consent dialog names NVIDIA's licence; the download runs in the background with progress in the task history and stops if the window closes. Offered at most once per run. - automix_dev_tools gpu-runtime status|install. - NOTICE: GPU runtime section (not shipped or re-hosted; NVIDIA terms). Verified on an executable with the CUDA ORT build but no CUDA DLLs: before install no GPU session opens; after `gpu-runtime install` (84 s, 16 libraries) preload succeeds, a CUDA session opens and the fp32 vocal model separates the 196 s test track on CUDA in 66 s. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 5 + NOTICE | 14 +- src/ai/GpuMemory.cpp | 3 + src/ai/GpuRuntimePack.cpp | 337 ++++++++++++++++++++++++++++ src/ai/GpuRuntimePack.h | 83 +++++++ src/ai/OnnxTensorInference.cpp | 32 ++- src/ai/OnnxTensorInference.h | 4 + src/app/ui/MainLayout.cpp | 73 +++++- src/app/ui/MainLayout.h | 9 + tests/unit/TensorInferenceTests.cpp | 106 ++++++++- tools/commands/ModelCommands.cpp | 41 ++++ 11 files changed, 701 insertions(+), 6 deletions(-) create mode 100644 src/ai/GpuRuntimePack.cpp create mode 100644 src/ai/GpuRuntimePack.h diff --git a/CMakeLists.txt b/CMakeLists.txt index c96f793..b2f37f6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -150,6 +150,7 @@ add_library(automix_core src/ai/OnnxTensorInference.cpp src/ai/OnnxExternalData.cpp src/ai/GpuMemory.cpp + src/ai/GpuRuntimePack.cpp src/util/StringUtils.cpp src/util/LameDownloader.cpp src/util/MetadataPolicy.cpp @@ -157,6 +158,10 @@ add_library(automix_core ) target_include_directories(automix_core PUBLIC src) +if(WIN32) + # GpuRuntimePack reads adapter memory through DXGI to decide whether to offer CUDA. + target_link_libraries(automix_core PRIVATE dxgi) +endif() set(AUTOMIX_HAS_NATIVE_ORT OFF) set(AUTOMIX_HAS_EP_PLUGIN OFF) diff --git a/NOTICE b/NOTICE index 0a44a0f..b954c98 100644 --- a/NOTICE +++ b/NOTICE @@ -119,7 +119,19 @@ resolves it. second separation. -6. Third-party build dependencies +6. Optional GPU runtime (NVIDIA CUDA libraries) +----------------------------------------------- + +GPU acceleration of the vocal model uses NVIDIA's CUDA runtime, cuBLAS, cuFFT +and cuDNN. AutoMixMaster does NOT ship or re-host them. On a machine with a +suitable NVIDIA GPU the application offers a one-time, opt-in download of +NVIDIA's own redistributable packages from pypi.org (pinned by SHA-256), +installed for the current user under %LOCALAPPDATA%\AutoMixMaster\gpu-runtime. +They are NVIDIA software, governed by NVIDIA's licence terms (the CUDA Toolkit +EULA and the cuDNN Software License Agreement), not by this project's licence. +Declining the download leaves every feature working on the CPU. + +7. Third-party build dependencies --------------------------------- The application links against ONNX Runtime (MIT), JUCE (AGPLv3 or diff --git a/src/ai/GpuMemory.cpp b/src/ai/GpuMemory.cpp index 659a1c1..3b68da9 100644 --- a/src/ai/GpuMemory.cpp +++ b/src/ai/GpuMemory.cpp @@ -4,6 +4,8 @@ #include +#include "ai/GpuRuntimePack.h" + namespace automix::ai { namespace { @@ -17,6 +19,7 @@ using CudaMemGetInfo = int (*)(std::size_t* freeBytes, std::size_t* totalBytes); } // namespace std::optional queryCudaDeviceMemory() { + GpuRuntimePack::preload(); // a per-user CUDA runtime, if installed #if defined(_WIN32) const char* candidates[] = {"cudart64_13.dll", "cudart64_12.dll", "cudart64_110.dll"}; #elif defined(__APPLE__) diff --git a/src/ai/GpuRuntimePack.cpp b/src/ai/GpuRuntimePack.cpp new file mode 100644 index 0000000..9e360ff --- /dev/null +++ b/src/ai/GpuRuntimePack.cpp @@ -0,0 +1,337 @@ +#include "ai/GpuRuntimePack.h" + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "util/Sha256.h" + +#if defined(_WIN32) +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#endif + +namespace automix::ai::GpuRuntimePack { +namespace { + +constexpr const char* kMarkerFile = "runtime.json"; +constexpr std::uint32_t kNvidiaVendorId = 0x10DE; + +std::filesystem::path binDirectory(const std::filesystem::path& root) { return root / "bin"; } + +bool endsWithDll(std::string name) { + std::transform(name.begin(), name.end(), name.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return name.size() > 4 && name.compare(name.size() - 4, 4, ".dll") == 0; +} + +std::optional> markerLibraries(const std::filesystem::path& root) { + try { + std::ifstream in(root / kMarkerFile); + if (!in.is_open()) { + return std::nullopt; + } + const auto marker = nlohmann::json::parse(in); + if (marker.value("version", "") != version() || !marker.contains("libraries")) { + return std::nullopt; + } + return marker.at("libraries").get>(); + } catch (...) { + return std::nullopt; + } +} + +// cudart first: everything else depends on it. The rest load with their own +// directory searched for dependencies, so their order does not matter. +int loadRank(const std::string& library) { return library.rfind("cudart", 0) == 0 ? 0 : 1; } + +} // namespace + +std::string version() { return "cuda13.4-cudnn9.27-1"; } + +const std::vector& archives() { +#if defined(_WIN32) + // NVIDIA redistributables (CUDA EULA / cuDNN SLA), as published by NVIDIA on + // PyPI. Pinned: a changed file fails its hash instead of being installed. + static const std::vector list = { + {"nvidia_cuda_runtime-13.4.92-py3-none-win_amd64.whl", + "https://files.pythonhosted.org/packages/86/00/d5436004268f049214193659ebc36550b5ef3925c3d13b4cc980e13be6f5/" + "nvidia_cuda_runtime-13.4.92-py3-none-win_amd64.whl", + "08dca5e4aba480c2fd5b55075c0fa71b84ef9dcf0521f2d58baa14a803a7311c", 2778543}, + {"nvidia_cublas-13.8.0.4-py3-none-win_amd64.whl", + "https://files.pythonhosted.org/packages/a3/df/f1246959833e2c437db8be3e5b477f66b87f8817821ed40de6c7561c9a36/" + "nvidia_cublas-13.8.0.4-py3-none-win_amd64.whl", + "8c5494423bb8a46822cb6b0cb95d7fa4be2d7b96a31155dff083839ec8297910", 423266897}, + {"nvidia_cufft-12.4.0.43-py3-none-win_amd64.whl", + "https://files.pythonhosted.org/packages/7b/cc/be7fe31058127336a66c88414a2ecc6beecf680baf85994a43eaf292b202/" + "nvidia_cufft-12.4.0.43-py3-none-win_amd64.whl", + "4ff7075f2d0b5f69291f70938d37a86ec632cbe5747184c74ba1f50f17accacc", 160953139}, + {"nvidia_cudnn_cu13-9.27.0.42-py3-none-win_amd64.whl", + "https://files.pythonhosted.org/packages/87/6a/e55ff0ac26a5c6e2b21f41c9d04ad096b4ed6da593fba7e25845c61b0532/" + "nvidia_cudnn_cu13-9.27.0.42-py3-none-win_amd64.whl", + "7d96f634adafd55c72231eb0500ca77ab109ec8ebff7b33000b76e081bc4558e", 436469905}, + }; +#else + static const std::vector list; // Windows only for now +#endif + return list; +} + +std::uint64_t downloadBytes() { + std::uint64_t total = 0; + for (const auto& archive : archives()) { + total += archive.bytes; + } + return total; +} + +std::filesystem::path defaultRoot() { +#if defined(_WIN32) + const auto base = juce::File::getSpecialLocation(juce::File::windowsLocalAppData); +#else + const auto base = juce::File::getSpecialLocation(juce::File::userApplicationDataDirectory); +#endif + return std::filesystem::path(base.getFullPathName().toWideCharPointer()) / "AutoMixMaster" / "gpu-runtime" / + version(); +} + +bool isInstalled(const std::filesystem::path& root) { + const auto libraries = markerLibraries(root); + if (!libraries.has_value() || libraries->empty()) { + return false; + } + std::error_code error; + for (const auto& library : *libraries) { + if (!std::filesystem::is_regular_file(binDirectory(root) / library, error) || error) { + return false; + } + } + return true; +} + +Fetcher httpFetcher() { + return [](const std::string& url, const std::filesystem::path& destination, + const std::function& progress) -> std::string { + int statusCode = 0; + const auto options = juce::URL::InputStreamOptions(juce::URL::ParameterHandling::inAddress) + .withConnectionTimeoutMs(60000) + .withNumRedirectsToFollow(8) + .withStatusCode(&statusCode); + const auto input = juce::URL(url).createInputStream(options); + if (input == nullptr) { + return "could not connect to " + url; + } + if (statusCode >= 400) { + return "HTTP " + std::to_string(statusCode) + " from " + url; + } + std::ofstream out(destination, std::ios::binary | std::ios::trunc); + if (!out.is_open()) { + return "cannot write " + destination.string(); + } + std::vector buffer(1 << 20); + std::uint64_t written = 0; + for (;;) { + const auto read = input->read(buffer.data(), static_cast(buffer.size())); + if (read <= 0) { + break; + } + out.write(buffer.data(), read); + if (!out) { + return "write failed for " + destination.string(); + } + written += static_cast(read); + if (progress && !progress(written)) { + return "cancelled"; + } + } + return {}; + }; +} + +std::string extractLibraries(const std::filesystem::path& wheel, + const std::filesystem::path& binDir, + std::vector& extracted) { + juce::ZipFile zip(juce::File(juce::String(wheel.wstring().c_str()))); + if (zip.getNumEntries() == 0) { + return "'" + wheel.filename().string() + "' is not a readable archive"; + } + std::error_code error; + std::filesystem::create_directories(binDir, error); + for (int i = 0; i < zip.getNumEntries(); ++i) { + const auto* entry = zip.getEntry(i); + const auto name = entry->filename.replaceCharacter('\\', '/').toStdString(); + const std::filesystem::path entryPath(name); + for (const auto& part : entryPath) { + if (part == "..") { + return "archive entry '" + name + "' escapes the archive"; + } + } + if (entryPath.is_absolute() || entryPath.has_root_name() || !endsWithDll(name)) { + continue; + } + const auto fileName = entryPath.filename(); + std::unique_ptr in(zip.createStreamForEntry(i)); + if (in == nullptr) { + return "cannot read '" + name + "' from " + wheel.filename().string(); + } + std::ofstream out(binDir / fileName, std::ios::binary | std::ios::trunc); + std::vector buffer(1 << 20); + for (;;) { + const auto read = in->read(buffer.data(), static_cast(buffer.size())); + if (read <= 0) { + break; + } + out.write(buffer.data(), read); + } + if (!out) { + return "cannot write " + (binDir / fileName).string(); + } + extracted.push_back(fileName.string()); + } + return {}; +} + +InstallResult install(const std::filesystem::path& root, + const std::function& progress, + const Fetcher& fetch) { + InstallResult result; + if (archives().empty()) { + result.message = "The GPU runtime pack is not available on this platform."; + return result; + } + std::error_code error; + const auto downloads = root / "downloads"; + std::filesystem::create_directories(downloads, error); + std::filesystem::remove(root / kMarkerFile, error); // invalid until this install completes + + const auto total = downloadBytes(); + std::uint64_t done = 0; + std::vector libraries; + for (const auto& archive : archives()) { + const auto wheel = downloads / archive.name; + const bool alreadyHere = std::filesystem::is_regular_file(wheel, error) && util::fileSha256(wheel) == archive.sha256; + if (!alreadyHere) { + const auto base = done; + const auto failure = fetch(archive.url, wheel, [&](std::uint64_t bytes) { + return !progress || progress(base + bytes, total); + }); + if (failure == "cancelled") { + result.cancelled = true; + result.message = "GPU runtime download cancelled."; + return result; + } + if (!failure.empty()) { + result.message = "Downloading " + archive.name + " failed: " + failure; + return result; + } + if (const auto actual = util::fileSha256(wheel); actual != archive.sha256) { + std::filesystem::remove(wheel, error); + result.message = "SHA-256 mismatch for " + archive.name + " (expected " + archive.sha256 + ", got " + + (actual.empty() ? "unreadable" : actual) + "); the download was discarded."; + return result; + } + } + done += archive.bytes; + if (progress && !progress(done, total)) { + result.cancelled = true; + result.message = "GPU runtime download cancelled."; + return result; + } + if (const auto failure = extractLibraries(wheel, binDirectory(root), libraries); !failure.empty()) { + result.message = "Unpacking " + archive.name + " failed: " + failure; + return result; + } + std::filesystem::remove(wheel, error); + } + std::filesystem::remove(downloads, error); + + std::sort(libraries.begin(), libraries.end()); + libraries.erase(std::unique(libraries.begin(), libraries.end()), libraries.end()); + const nlohmann::json marker = {{"version", version()}, {"libraries", libraries}}; + { + std::ofstream out(root / kMarkerFile, std::ios::trunc); + out << marker.dump(2); + if (!out) { + result.message = "Cannot write the GPU runtime marker in " + root.string(); + return result; + } + } + result.success = true; + result.message = "GPU runtime installed (" + std::to_string(libraries.size()) + " libraries)."; + return result; +} + +bool preload(const std::filesystem::path& root) { +#if defined(_WIN32) + static std::mutex mutex; + static bool loaded = false; + const std::scoped_lock lock(mutex); + if (loaded) { + return true; + } + if (!isInstalled(root)) { + return false; + } + auto libraries = *markerLibraries(root); + std::stable_sort(libraries.begin(), libraries.end(), + [](const std::string& a, const std::string& b) { return loadRank(a) < loadRank(b); }); + for (const auto& library : libraries) { + // With an absolute path, LOAD_WITH_ALTERED_SEARCH_PATH resolves the + // library's own dependencies from its directory first. Modules stay + // loaded for the life of the process, which is what the provider needs. + const auto path = binDirectory(root) / library; + if (LoadLibraryExW(path.c_str(), nullptr, LOAD_WITH_ALTERED_SEARCH_PATH) == nullptr && + library.rfind("cudart", 0) == 0) { + return false; + } + } + loaded = true; + return true; +#else + (void)root; + return false; +#endif +} + +std::optional largestNvidiaAdapterBytes() { +#if defined(_WIN32) + IDXGIFactory1* factory = nullptr; + if (FAILED(CreateDXGIFactory1(__uuidof(IDXGIFactory1), reinterpret_cast(&factory))) || factory == nullptr) { + return std::nullopt; + } + std::optional largest; + IDXGIAdapter1* adapter = nullptr; + for (UINT index = 0; factory->EnumAdapters1(index, &adapter) != DXGI_ERROR_NOT_FOUND; ++index) { + DXGI_ADAPTER_DESC1 desc{}; + if (SUCCEEDED(adapter->GetDesc1(&desc)) && desc.VendorId == kNvidiaVendorId && + (desc.Flags & DXGI_ADAPTER_FLAG_SOFTWARE) == 0) { + const auto bytes = static_cast(desc.DedicatedVideoMemory); + largest = std::max(largest.value_or(0), bytes); + } + adapter->Release(); + } + factory->Release(); + return largest; +#else + return std::nullopt; +#endif +} + +bool shouldOffer(const std::uint64_t minimumDedicatedBytes, const std::filesystem::path& root) { + if (archives().empty() || isInstalled(root)) { + return false; + } + const auto bytes = largestNvidiaAdapterBytes(); + return bytes.has_value() && *bytes >= minimumDedicatedBytes; +} + +} // namespace automix::ai::GpuRuntimePack diff --git a/src/ai/GpuRuntimePack.h b/src/ai/GpuRuntimePack.h new file mode 100644 index 0000000..7cf1b81 --- /dev/null +++ b/src/ai/GpuRuntimePack.h @@ -0,0 +1,83 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace automix::ai { + +// The NVIDIA CUDA libraries the CUDA execution provider needs (runtime, +// cuBLAS, cuFFT, cuDNN), installed on demand per user instead of shipping +// ~1.3 GB with every copy of the application. The files come from NVIDIA's own +// redistributable wheels on PyPI, pinned by URL and SHA-256; only their DLLs +// are kept. Nothing is installed system-wide and no PATH is changed: the DLLs +// are preloaded by full path before any CUDA session is created. +namespace GpuRuntimePack { + +struct Archive { + std::string name; // wheel file name + std::string url; // files.pythonhosted.org, pinned + std::string sha256; // lower-case hex + std::uint64_t bytes; // download size +}; + +// Pack identity, written into the completion marker; bump it whenever the +// archive list changes so an older pack is reinstalled rather than trusted. +std::string version(); +const std::vector& archives(); +std::uint64_t downloadBytes(); + +// %LOCALAPPDATA%\AutoMixMaster\gpu-runtime\ on Windows; the user +// application-data directory elsewhere. +std::filesystem::path defaultRoot(); + +// The marker matches this version and every library it lists is present. +bool isInstalled(const std::filesystem::path& root); + +// Fetches `url` into `destination`. `progress(bytesSoFar)` returns false to +// abort. Returns an empty string on success, else the reason. +using Fetcher = std::function& progress)>; +Fetcher httpFetcher(); + +struct InstallResult { + bool success = false; + bool cancelled = false; + std::string message; +}; + +// Downloads, verifies and unpacks every archive into `root`. `progress` gets +// (bytes done, bytes total) and returns false to cancel. The completion marker +// is written last, so an interrupted or failed install is never mistaken for a +// working one; archives are deleted once unpacked. +InstallResult install(const std::filesystem::path& root, + const std::function& progress, + const Fetcher& fetch = httpFetcher()); + +// Copies the .dll entries of a wheel (a zip) into `binDirectory`, flattening +// their paths; anything else is ignored. An entry whose name would escape the +// archive is an error. Exposed for tests. +std::string extractLibraries(const std::filesystem::path& wheel, + const std::filesystem::path& binDirectory, + std::vector& extracted); + +// Loads the installed libraries by full path so the CUDA execution provider's +// later lookups by name resolve to them. Idempotent; true once loaded. Returns +// false (and changes nothing) when no installed pack is found. +bool preload(const std::filesystem::path& root = defaultRoot()); + +// Whether to offer the pack: an NVIDIA adapter with at least +// `minimumDedicatedBytes` of dedicated memory is present (read through DXGI, +// so no CUDA is needed to decide), and the pack is not installed yet. +bool shouldOffer(std::uint64_t minimumDedicatedBytes, const std::filesystem::path& root = defaultRoot()); + +// Largest dedicated memory among NVIDIA adapters, if any (Windows only). +std::optional largestNvidiaAdapterBytes(); + +} // namespace GpuRuntimePack + +} // namespace automix::ai diff --git a/src/ai/OnnxTensorInference.cpp b/src/ai/OnnxTensorInference.cpp index 2741950..3c16147 100644 --- a/src/ai/OnnxTensorInference.cpp +++ b/src/ai/OnnxTensorInference.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -17,6 +18,7 @@ #endif #include "ai/GpuProvider.h" +#include "ai/GpuRuntimePack.h" #if AUTOMIX_HAS_NATIVE_ORT #include @@ -157,9 +159,31 @@ std::string identityProbeModel() { } // namespace +bool runtimeReportsProvider(const std::string& provider) { +#if AUTOMIX_HAS_NATIVE_ORT + try { + const auto wanted = gpu::canonicalProviderName(provider); + for (const auto& reported : Ort::GetAvailableProviders()) { + if (gpu::canonicalProviderName(reported) == wanted) { + return true; + } + } + } catch (...) { + } +#else + (void)provider; +#endif + return false; +} bool gpuTensorSessionAvailable(std::string* providerOut) { #if AUTOMIX_HAS_NATIVE_ORT - static const std::string provider = [] { + // Only success is cached: a GPU runtime pack installed later in this + // process must be able to turn a failed probe into a working one. + static std::mutex mutex; + static std::string cached; + const std::scoped_lock lock(mutex); + GpuRuntimePack::preload(); + const std::string provider = cached.empty() ? [] { std::vector runtimeProviders; try { runtimeProviders = Ort::GetAvailableProviders(); @@ -180,7 +204,8 @@ bool gpuTensorSessionAvailable(std::string* providerOut) { } } return std::string(); - }(); + }() : cached; + cached = provider; if (providerOut != nullptr) { *providerOut = provider; } @@ -239,6 +264,9 @@ bool OnnxTensorInference::loadModel(const std::filesystem::path& modelPath) { runtimeProviders = Ort::GetAvailableProviders(); } catch (...) { } + if (gpu::canonicalProviderName(requestedProvider_) != gpu::kProviderCpu) { + GpuRuntimePack::preload(); // CUDA libraries installed per user, if any + } const auto candidates = tensorProviderCandidates(requestedProvider_, runtimeProviders); std::unique_ptr state; diff --git a/src/ai/OnnxTensorInference.h b/src/ai/OnnxTensorInference.h index 6f7ee1b..cec39b6 100644 --- a/src/ai/OnnxTensorInference.h +++ b/src/ai/OnnxTensorInference.h @@ -26,6 +26,10 @@ std::vector tensorProviderCandidates(const std::string& requested, // provider that opened. Always false without native ONNX Runtime. bool gpuTensorSessionAvailable(std::string* providerOut = nullptr); +// Whether this ONNX Runtime build contains `provider` (e.g. "cuda") at all, +// regardless of whether its own libraries are present. False without native ORT. +bool runtimeReportsProvider(const std::string& provider); + // ONNX Runtime backend for tensor graphs. Without the native SDK // (AUTOMIX_HAS_NATIVE_ORT undefined) it is a deterministic no-op: every load // fails with a diagnostic and nothing ever reports usedModel == true. There is diff --git a/src/app/ui/MainLayout.cpp b/src/app/ui/MainLayout.cpp index c5ed14a..494e9ed 100644 --- a/src/app/ui/MainLayout.cpp +++ b/src/app/ui/MainLayout.cpp @@ -13,6 +13,8 @@ #include "app/ui/TaskOrchestrator.h" #include "app/ui/TransportBar.h" #include "app/ui/VerificationEngine.h" +#include "ai/GpuRuntimePack.h" +#include "ai/OnnxTensorInference.h" #include "renderers/RendererPipeline.h" #include "util/FileUtils.h" @@ -523,6 +525,7 @@ void MainLayout::initComboBoxes() { // ───────────────────────────────────────────────────────────────── MainLayout::~MainLayout() { + gpuRuntimeCancel_->store(true); taskOrchestrator_->cancelAll(); stopTimer(); audioDeviceManager_.removeAudioCallback(this); @@ -1051,8 +1054,11 @@ void MainLayout::wireControlDeckCallbacks() { controlDeck_->getTensorSeparationToggle().setEnabled(enabled); }; controlDeck_->getTensorSeparationToggle().onClick = [this] { - sessionManager_.session().renderSettings.tensorSeparationEnabled = - controlDeck_->getTensorSeparationToggle().getToggleState(); + const bool enabled = controlDeck_->getTensorSeparationToggle().getToggleState(); + sessionManager_.session().renderSettings.tensorSeparationEnabled = enabled; + if (enabled) { + offerGpuRuntimeIfUseful(); + } }; controlDeck_->getBatchRecursiveToggle().onClick = [this] { const bool enabled = controlDeck_->getBatchRecursiveToggle().getToggleState(); @@ -2232,4 +2238,67 @@ std::vector MainLayout::loadConfiguredExterna return automix::app::detail::loadConfiguredExternalRenderers(onError); } + + +void MainLayout::offerGpuRuntimeIfUseful() { + if (gpuRuntimeOffered_ || gpuRuntimeInstalling_) { + return; + } + // The fp32 vocal model needs 10 GiB of device memory plus desktop headroom; + // offering ~1 GB of CUDA libraries to a smaller card would buy nothing. + constexpr std::uint64_t kMinimumAdapterBytes = (10240ull + 1536ull) * 1024 * 1024; + if (!ai::runtimeReportsProvider("cuda") || !ai::GpuRuntimePack::shouldOffer(kMinimumAdapterBytes)) { + return; + } + gpuRuntimeOffered_ = true; + const auto megabytes = ai::GpuRuntimePack::downloadBytes() / (1024 * 1024); + const juce::String message = + "Your NVIDIA GPU can run the vocal model about 8x faster than the CPU.\n\n" + "This needs NVIDIA's CUDA libraries (CUDA runtime, cuBLAS, cuFFT, cuDNN): a one-time download of about " + + juce::String(static_cast(megabytes)) + + " MB from NVIDIA's packages on pypi.org, installed for your user only. They are NVIDIA software under " + "NVIDIA's own licence terms and are not part of AutoMixMaster.\n\n" + "Without them, separation keeps working on the CPU."; + juce::AlertWindow::showOkCancelBox( + juce::AlertWindow::QuestionIcon, "Enable GPU acceleration?", message, "Download", "Not now", nullptr, + juce::ModalCallbackFunction::create([safe = juce::Component::SafePointer(this)](int result) { + if (safe != nullptr && result == 1) { + safe->installGpuRuntime(); + } + })); +} + +void MainLayout::installGpuRuntime() { + gpuRuntimeInstalling_ = true; + taskOrchestrator_->appendHistory("Downloading the GPU runtime (NVIDIA CUDA libraries)..."); + juce::Thread::launch([safe = juce::Component::SafePointer(this), cancel = gpuRuntimeCancel_] { + int lastDecile = -1; + const auto result = ai::GpuRuntimePack::install( + ai::GpuRuntimePack::defaultRoot(), [&](std::uint64_t done, std::uint64_t total) { + const int percent = total > 0 ? static_cast(done * 100 / total) : 0; + if (percent / 10 != lastDecile) { + lastDecile = percent / 10; + juce::MessageManager::callAsync([safe, percent] { + if (safe != nullptr) { + safe->taskOrchestrator_->appendHistory("GPU runtime download " + juce::String(percent) + "%"); + } + }); + } + return !cancel->load(); + }); + juce::MessageManager::callAsync([safe, result] { + if (safe == nullptr) { + return; + } + safe->gpuRuntimeInstalling_ = false; + safe->taskOrchestrator_->appendHistory(juce::String(result.message)); + if (result.success) { + safe->taskOrchestrator_->appendHistory( + "GPU acceleration is ready. If the vocal model was installed before this, reinstall it from Models to get " + "its GPU build."); + } + }); + }); +} + } // namespace automix::app diff --git a/src/app/ui/MainLayout.h b/src/app/ui/MainLayout.h index c840181..4b1be4b 100644 --- a/src/app/ui/MainLayout.h +++ b/src/app/ui/MainLayout.h @@ -281,6 +281,15 @@ class MainLayout final : public juce::Component, // Stores the folder path to export into after mastering completes. std::string pendingPipelineExportFolder_; bool pendingAutoMixAfterSeparationImport_ = false; + + // GPU runtime pack (NVIDIA CUDA libraries, downloaded on demand). Offered at + // most once per run; the cancel flag is shared with the download thread so + // closing the window stops it without a dangling pointer. + void offerGpuRuntimeIfUseful(); + void installGpuRuntime(); + bool gpuRuntimeOffered_ = false; + bool gpuRuntimeInstalling_ = false; + std::shared_ptr gpuRuntimeCancel_ = std::make_shared(false); bool skipNextAutoMixSeparationCheck_ = false; // Controllers diff --git a/tests/unit/TensorInferenceTests.cpp b/tests/unit/TensorInferenceTests.cpp index 78abffb..4f91a1b 100644 --- a/tests/unit/TensorInferenceTests.cpp +++ b/tests/unit/TensorInferenceTests.cpp @@ -13,10 +13,12 @@ #include #include +#include #include #include "ai/BsRoformerPack.h" #include "ai/GpuMemory.h" +#include "ai/GpuRuntimePack.h" #include "ai/ITensorInference.h" #include "ai/ModelCatalogValidator.h" #include "ai/ModelLicensePolicy.h" @@ -1370,4 +1372,106 @@ TEST_CASE("Separation runs on CPU when the GPU lacks free memory for the model", std::filesystem::remove_all(tempRoot); } -#endif \ No newline at end of file +#endif +namespace { + +void writeZip(const std::filesystem::path& path, const std::vector>& entries) { + juce::ZipFile::Builder builder; + for (const auto& [name, content] : entries) { + builder.addEntry(new juce::MemoryInputStream(content.data(), content.size(), true), 0, name, juce::Time()); + } + std::filesystem::remove(path); + juce::FileOutputStream out(juce::File(juce::String(path.wstring().c_str()))); + REQUIRE(builder.writeToStream(out, nullptr)); +} + +} // namespace + +TEST_CASE("GPU runtime pack pins every archive", "[ai][gpu][runtime-pack]") { + const auto& archives = ai::GpuRuntimePack::archives(); +#if defined(_WIN32) + REQUIRE(archives.size() == 4); +#endif + for (const auto& archive : archives) { + INFO(archive.name); + REQUIRE(archive.url.rfind("https://files.pythonhosted.org/", 0) == 0); + REQUIRE(archive.url.size() > archive.name.size()); + REQUIRE(archive.url.compare(archive.url.size() - archive.name.size(), archive.name.size(), archive.name) == 0); + REQUIRE(archive.sha256.size() == 64); + REQUIRE(archive.sha256.find_first_not_of("0123456789abcdef") == std::string::npos); + REQUIRE(archive.bytes > 0); + } + REQUIRE_FALSE(ai::GpuRuntimePack::version().empty()); +} + +TEST_CASE("GPU runtime pack extracts only libraries and refuses escaping entries", "[ai][gpu][runtime-pack]") { + const auto root = std::filesystem::temp_directory_path() / "automix_runtime_pack_extract"; + std::filesystem::remove_all(root); + std::filesystem::create_directories(root); + + writeZip(root / "good.whl", {{"nvidia/cu13/bin/x64/cudart64_13.dll", "runtime"}, + {"nvidia/cu13/include/cuda.h", "header"}, + {"nvidia_cuda_runtime-13.4.92.dist-info/LICENSE.txt", "licence"}, + {"nvidia/cudnn/bin/cudnn64_9.DLL", "cudnn"}}); + std::vector extracted; + REQUIRE(ai::GpuRuntimePack::extractLibraries(root / "good.whl", root / "bin", extracted).empty()); + std::sort(extracted.begin(), extracted.end()); + REQUIRE(extracted == std::vector{"cudart64_13.dll", "cudnn64_9.DLL"}); + REQUIRE(readBytes(root / "bin" / "cudart64_13.dll") == std::vector{'r', 'u', 'n', 't', 'i', 'm', 'e'}); + REQUIRE_FALSE(std::filesystem::exists(root / "bin" / "cuda.h")); + + writeZip(root / "evil.whl", {{"../../outside.dll", "payload"}}); + extracted.clear(); + const auto failure = ai::GpuRuntimePack::extractLibraries(root / "evil.whl", root / "bin", extracted); + REQUIRE(failure.find("escapes the archive") != std::string::npos); + REQUIRE(extracted.empty()); + REQUIRE_FALSE(std::filesystem::exists(root.parent_path() / "outside.dll")); + std::filesystem::remove_all(root); +} + +TEST_CASE("GPU runtime pack never marks a failed or cancelled install as installed", "[ai][gpu][runtime-pack]") { + if (ai::GpuRuntimePack::archives().empty()) { + SKIP("No GPU runtime pack on this platform"); + } + const auto root = std::filesystem::temp_directory_path() / "automix_runtime_pack_install"; + std::filesystem::remove_all(root); + + // A download whose bytes do not match the pinned hash is discarded. + const ai::GpuRuntimePack::Fetcher wrongBytes = [](const std::string&, const std::filesystem::path& destination, + const std::function& progress) { + std::ofstream(destination, std::ios::binary) << "not the wheel"; + progress(13); + return std::string(); + }; + const auto mismatch = ai::GpuRuntimePack::install(root, nullptr, wrongBytes); + REQUIRE_FALSE(mismatch.success); + REQUIRE(mismatch.message.find("SHA-256 mismatch") != std::string::npos); + REQUIRE_FALSE(ai::GpuRuntimePack::isInstalled(root)); + REQUIRE_FALSE(std::filesystem::exists(root / "downloads" / ai::GpuRuntimePack::archives().front().name)); + + // Cancelling mid-download stops at once and leaves nothing marked installed. + int fetches = 0; + const ai::GpuRuntimePack::Fetcher slow = [&](const std::string&, const std::filesystem::path&, + const std::function& progress) { + ++fetches; + return progress(1) ? std::string() : std::string("cancelled"); + }; + const auto cancelled = ai::GpuRuntimePack::install( + root, [](std::uint64_t, std::uint64_t) { return false; }, slow); + REQUIRE(cancelled.cancelled); + REQUIRE_FALSE(cancelled.success); + REQUIRE(fetches == 1); + REQUIRE_FALSE(ai::GpuRuntimePack::isInstalled(root)); + + // A marker for another pack version is not trusted. + std::filesystem::create_directories(root / "bin"); + std::ofstream(root / "bin" / "cudart64_13.dll") << "x"; + std::ofstream(root / "runtime.json") << R"({"version": "older", "libraries": ["cudart64_13.dll"]})"; + REQUIRE_FALSE(ai::GpuRuntimePack::isInstalled(root)); + std::ofstream(root / "runtime.json", std::ios::trunc) + << nlohmann::json{{"version", ai::GpuRuntimePack::version()}, {"libraries", {"cudart64_13.dll"}}}.dump(); + REQUIRE(ai::GpuRuntimePack::isInstalled(root)); + std::filesystem::remove(root / "bin" / "cudart64_13.dll"); + REQUIRE_FALSE(ai::GpuRuntimePack::isInstalled(root)); // a listed library went missing + std::filesystem::remove_all(root); +} \ No newline at end of file diff --git a/tools/commands/ModelCommands.cpp b/tools/commands/ModelCommands.cpp index 46e6404..44b1ab2 100644 --- a/tools/commands/ModelCommands.cpp +++ b/tools/commands/ModelCommands.cpp @@ -8,9 +8,11 @@ #include #include "ai/FeatureSchema.h" +#include "ai/GpuRuntimePack.h" #include "ai/HuggingFaceModelHub.h" #include "ai/ModelPackLoader.h" #include "ai/OnnxModelInference.h" +#include "ai/OnnxTensorInference.h" #include "ai/StemSeparator.h" #include "renderers/ExternalLimiterRenderer.h" #include "util/LameDownloader.h" @@ -572,6 +574,44 @@ int commandSeparate(const std::vector& args) { return result.success ? 0 : 1; } +// gpu-runtime status|install [--root ] +// The per-user NVIDIA CUDA libraries the CUDA execution provider loads. +int commandGpuRuntime(const std::vector& args) { + namespace pack = automix::ai::GpuRuntimePack; + const bool wantsInstall = std::find(args.begin(), args.end(), "install") != args.end(); + const bool wantsStatus = std::find(args.begin(), args.end(), "status") != args.end(); + const std::string action = wantsInstall ? "install" : (wantsStatus || args.size() <= 1 ? "status" : ""); + const std::filesystem::path root = argValue(args, "--root").value_or(pack::defaultRoot().string()); + if (action == "status") { + const auto adapter = pack::largestNvidiaAdapterBytes(); + std::cout << "GPU runtime " << pack::version() << " at " << root.string() << "\n" + << " installed: " << (pack::isInstalled(root) ? "yes" : "no") << "\n" + << " largest NVIDIA adapter: " + << (adapter.has_value() ? std::to_string(*adapter / (1024 * 1024)) + " MiB" : std::string("none")) << "\n" + << " runtime build has CUDA: " << (automix::ai::runtimeReportsProvider("cuda") ? "yes" : "no") << "\n" + << " preload: " << (pack::preload(root) ? "ok" : "not loaded") << "\n"; + std::string provider; + const bool gpu = automix::ai::gpuTensorSessionAvailable(&provider); + std::cout << " GPU tensor session: " << (gpu ? provider : std::string("unavailable")) << "\n"; + return 0; + } + if (action == "install") { + int lastDecile = -1; + const auto result = pack::install(root, [&](std::uint64_t done, std::uint64_t total) { + const int percent = total > 0 ? static_cast(done * 100 / total) : 0; + if (percent / 10 != lastDecile) { + lastDecile = percent / 10; + std::cout << " " << percent << "%\n" << std::flush; + } + return true; + }); + std::cout << result.message << "\n"; + return result.success ? 0 : 1; + } + std::cerr << "Usage: gpu-runtime status|install [--root ]\n"; + return 2; +} + } // namespace void registerModelCommands(automix::devtools::CommandRegistry& registry) { @@ -587,4 +627,5 @@ void registerModelCommands(automix::devtools::CommandRegistry& registry) { registry.add("model-install", commandModelInstall); registry.add("model-health", commandModelHealth); registry.add("separate", commandSeparate); + registry.add("gpu-runtime", commandGpuRuntime); } From 8a42f1b2f71b7131871cb717c4e05b14193d677b Mon Sep 17 00:00:00 2001 From: Soficis Date: Thu, 1 Oct 2026 11:23:02 -0500 Subject: [PATCH 10/68] feat(ai): driver gate, model auto-upgrade and removal for the GPU runtime - Offer the GPU runtime only with an NVIDIA driver >= 580 (CUDA 13), read from the DXGI user-mode driver version (32.0.16.1074 -> 610.74). - After the runtime installs, a BS-RoFormer pack still on its quantized build is reinstalled as the fp32 GPU build and the old file removed (upgradeBsRoformerForGpu; verified 166 MB -> 645 MB, idempotent). - Settings gains a GPU acceleration row (status + Install/Remove). uninstall() drops the marker first and defers files locked by this process to completePendingRemoval() at the next start; it refuses folders that are not a runtime pack. - Skip the two libraries ONNX Runtime never loads (cufftw, nvblas). A BS-RoFormer run loads only cuBLAS/cuBLASLt and three cuDNN parts, but convolution, FFT and RNN graphs need the rest, so nothing else is cut. - OnnxModelInference now preloads the runtime pack too; without it scalar models could never use CUDA on a user's machine. - The GPU recovery tests load on CPU before pinning providers, so they no longer depend on whether CUDA libraries exist on the test machine. - dev tools: gpu-runtime remove|upgrade-models. Co-Authored-By: Claude Opus 5.5 --- src/ai/GpuRuntimePack.cpp | 86 +++++++++++++++++++++++--- src/ai/GpuRuntimePack.h | 45 ++++++++++++-- src/ai/HuggingFaceModelHub.cpp | 60 +++++++++++++++--- src/ai/HuggingFaceModelHub.h | 10 +++ src/ai/OnnxModelInference.cpp | 4 ++ src/app/controllers/ModelController.h | 1 + src/app/ui/MainLayout.cpp | 87 ++++++++++++++++++++++++--- src/app/ui/MainLayout.h | 7 +++ src/app/ui/MainLayoutInternal.h | 32 +++++++++- tests/unit/GpuBenchmarkTests.cpp | 20 ++++++ tests/unit/TensorInferenceTests.cpp | 77 +++++++++++++++++++++++- tools/commands/ModelCommands.cpp | 34 ++++++++--- 12 files changed, 427 insertions(+), 36 deletions(-) diff --git a/src/ai/GpuRuntimePack.cpp b/src/ai/GpuRuntimePack.cpp index 9e360ff..1f672db 100644 --- a/src/ai/GpuRuntimePack.cpp +++ b/src/ai/GpuRuntimePack.cpp @@ -24,6 +24,7 @@ namespace automix::ai::GpuRuntimePack { namespace { constexpr const char* kMarkerFile = "runtime.json"; +constexpr const char* kPendingRemovalFile = "remove-pending"; constexpr std::uint32_t kNvidiaVendorId = 0x10DE; std::filesystem::path binDirectory(const std::filesystem::path& root) { return root / "bin"; } @@ -179,6 +180,10 @@ std::string extractLibraries(const std::filesystem::path& wheel, continue; } const auto fileName = entryPath.filename(); + const auto& unused = unusedLibraries(); + if (std::find(unused.begin(), unused.end(), fileName.string()) != unused.end()) { + continue; + } std::unique_ptr in(zip.createStreamForEntry(i)); if (in == nullptr) { return "cannot read '" + name + "' from " + wheel.filename().string(); @@ -270,6 +275,55 @@ InstallResult install(const std::filesystem::path& root, return result; } +const std::vector& unusedLibraries() { + static const std::vector list = {"cufftw64_12.dll", "nvblas64_13.dll"}; + return list; +} + +namespace { + +// Only ever delete something that is recognisably ours: the versioned folder +// holding a bin/ directory, a marker or a pending-removal note. +bool looksLikePack(const std::filesystem::path& root) { + std::error_code error; + return root.filename() == version() && + (std::filesystem::is_directory(root / "bin", error) || std::filesystem::exists(root / kMarkerFile, error) || + std::filesystem::exists(root / kPendingRemovalFile, error)); +} + +} // namespace + +RemoveResult uninstall(const std::filesystem::path& root) { + RemoveResult result; + std::error_code error; + if (!std::filesystem::exists(root, error)) { + result.removedNow = true; + result.message = "The GPU runtime is not installed."; + return result; + } + if (!looksLikePack(root)) { + result.message = "Refusing to delete '" + root.string() + "': it does not look like a GPU runtime pack."; + return result; + } + std::filesystem::remove(root / kMarkerFile, error); // first: nothing may preload a half-deleted pack + std::filesystem::remove_all(root, error); + if (!error && !std::filesystem::exists(root, error)) { + result.removedNow = true; + result.message = "GPU runtime removed."; + return result; + } + // Libraries loaded by this process are locked on Windows. + std::ofstream(root / kPendingRemovalFile) << "remove at next start\n"; + result.message = "GPU runtime disabled; its remaining files are removed the next time AutoMixMaster starts."; + return result; +} + +void completePendingRemoval(const std::filesystem::path& root) { + std::error_code error; + if (std::filesystem::exists(root / kPendingRemovalFile, error) && looksLikePack(root)) { + std::filesystem::remove_all(root, error); + } +} bool preload(const std::filesystem::path& root) { #if defined(_WIN32) static std::mutex mutex; @@ -302,20 +356,36 @@ bool preload(const std::filesystem::path& root) { #endif } -std::optional largestNvidiaAdapterBytes() { +std::pair nvidiaDriverVersion(const std::uint64_t userModeDriverVersion) { + const auto c = static_cast((userModeDriverVersion >> 16) & 0xFFFF); + const auto d = static_cast(userModeDriverVersion & 0xFFFF); + const int combined = (c % 10) * 10000 + d; // the last five digits + return {combined / 100, combined % 100}; +} + +std::optional largestNvidiaAdapter() { #if defined(_WIN32) IDXGIFactory1* factory = nullptr; if (FAILED(CreateDXGIFactory1(__uuidof(IDXGIFactory1), reinterpret_cast(&factory))) || factory == nullptr) { return std::nullopt; } - std::optional largest; + std::optional largest; IDXGIAdapter1* adapter = nullptr; for (UINT index = 0; factory->EnumAdapters1(index, &adapter) != DXGI_ERROR_NOT_FOUND; ++index) { DXGI_ADAPTER_DESC1 desc{}; if (SUCCEEDED(adapter->GetDesc1(&desc)) && desc.VendorId == kNvidiaVendorId && (desc.Flags & DXGI_ADAPTER_FLAG_SOFTWARE) == 0) { - const auto bytes = static_cast(desc.DedicatedVideoMemory); - largest = std::max(largest.value_or(0), bytes); + NvidiaAdapter found; + found.dedicatedBytes = static_cast(desc.DedicatedVideoMemory); + LARGE_INTEGER userModeVersion{}; + if (SUCCEEDED(adapter->CheckInterfaceSupport(__uuidof(IDXGIDevice), &userModeVersion))) { + const auto [major, minor] = nvidiaDriverVersion(static_cast(userModeVersion.QuadPart)); + found.driverMajor = major; + found.driverMinor = minor; + } + if (!largest.has_value() || found.dedicatedBytes > largest->dedicatedBytes) { + largest = found; + } } adapter->Release(); } @@ -330,8 +400,10 @@ bool shouldOffer(const std::uint64_t minimumDedicatedBytes, const std::filesyste if (archives().empty() || isInstalled(root)) { return false; } - const auto bytes = largestNvidiaAdapterBytes(); - return bytes.has_value() && *bytes >= minimumDedicatedBytes; + const auto adapter = largestNvidiaAdapter(); + // An unreported driver version is not proof of an old one, but 1 GB is too + // much to download on a guess: only offer what is known to work. + return adapter.has_value() && adapter->dedicatedBytes >= minimumDedicatedBytes && + adapter->driverMajor.value_or(0) >= kMinimumDriverMajor; } - } // namespace automix::ai::GpuRuntimePack diff --git a/src/ai/GpuRuntimePack.h b/src/ai/GpuRuntimePack.h index 7cf1b81..122a166 100644 --- a/src/ai/GpuRuntimePack.h +++ b/src/ai/GpuRuntimePack.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -65,18 +66,54 @@ std::string extractLibraries(const std::filesystem::path& wheel, const std::filesystem::path& binDirectory, std::vector& extracted); +// Libraries in the wheels that ONNX Runtime never loads (an FFTW-compatible +// shim and a CPU-BLAS interposer); skipped when unpacking. Everything else is +// kept: one model loading only cuBLAS and three cuDNN parts does not make the +// rest unused (convolution, FFT and RNN graphs load others). +const std::vector& unusedLibraries(); + +struct RemoveResult { + bool removedNow = false; // false: finishes at next start (files were in use) + std::string message; +}; + +// Removes an installed pack. Libraries this process has loaded cannot be +// deleted on Windows, so the marker goes first (nothing preloads it again) and +// whatever remains is deleted by completePendingRemoval() at the next start. +// Refuses any directory that does not look like a GPU runtime pack. +RemoveResult uninstall(const std::filesystem::path& root = defaultRoot()); + +// Call once at startup, before anything can preload the pack. +void completePendingRemoval(const std::filesystem::path& root = defaultRoot()); + // Loads the installed libraries by full path so the CUDA execution provider's // later lookups by name resolve to them. Idempotent; true once loaded. Returns // false (and changes nothing) when no installed pack is found. bool preload(const std::filesystem::path& root = defaultRoot()); // Whether to offer the pack: an NVIDIA adapter with at least -// `minimumDedicatedBytes` of dedicated memory is present (read through DXGI, -// so no CUDA is needed to decide), and the pack is not installed yet. +// `minimumDedicatedBytes` of dedicated memory and a CUDA 13 capable driver +// (>= kMinimumDriverMajor) is present - both read through DXGI, so no CUDA is +// needed to decide - and the pack is not installed yet. bool shouldOffer(std::uint64_t minimumDedicatedBytes, const std::filesystem::path& root = defaultRoot()); -// Largest dedicated memory among NVIDIA adapters, if any (Windows only). -std::optional largestNvidiaAdapterBytes(); +// CUDA 13 requires an NVIDIA driver of this major version or newer. +inline constexpr int kMinimumDriverMajor = 580; + +struct NvidiaAdapter { + std::uint64_t dedicatedBytes = 0; + // NVIDIA's own numbering (e.g. 610.74); empty when Windows did not report it. + std::optional driverMajor; + std::optional driverMinor; +}; + +// NVIDIA driver version from the Windows user-mode driver version DXGI +// reports (a.b.c.d). NVIDIA's number is the last five digits of c and d: +// 32.0.16.1074 -> 610.74. +std::pair nvidiaDriverVersion(std::uint64_t userModeDriverVersion); + +// The NVIDIA adapter with the most dedicated memory, if any (Windows only). +std::optional largestNvidiaAdapter(); } // namespace GpuRuntimePack diff --git a/src/ai/HuggingFaceModelHub.cpp b/src/ai/HuggingFaceModelHub.cpp index 556d0c0..d7cad2d 100644 --- a/src/ai/HuggingFaceModelHub.cpp +++ b/src/ai/HuggingFaceModelHub.cpp @@ -457,6 +457,57 @@ void appendInstallLog(const std::filesystem::path& root, out << event.dump() << "\n"; } +bool bsRoformerGpuBuildQualifies() { + // The probe opens a real session (once per process when it succeeds). fp32 + // also needs a device big enough to hold it: on a smaller card it spills into + // shared memory and runs slower than the CPU. + std::string gpuProvider; + return gpuTensorSessionAvailable(&gpuProvider) && gpuProvider == "cuda" && + gpuFitsModel(queryCudaDeviceMemory(), kBsRoformerFp32GpuMemoryMb * 1024 * 1024); +} + +std::optional upgradeBsRoformerForGpu(const std::filesystem::path& destinationRoot) { + std::error_code error; + if (!std::filesystem::is_directory(destinationRoot, error) || error) { + return std::nullopt; + } + for (const auto& entry : std::filesystem::directory_iterator(destinationRoot, error)) { + const auto hubMetadataPath = entry.path() / "modelhub.json"; + if (!entry.is_directory(error) || !std::filesystem::is_regular_file(hubMetadataPath, error)) { + continue; + } + nlohmann::json hubMetadata; + nlohmann::json manifest; + try { + std::ifstream hubIn(hubMetadataPath); + hubMetadata = nlohmann::json::parse(hubIn); + std::ifstream manifestIn(entry.path() / "model.json"); + manifest = nlohmann::json::parse(manifestIn); + } catch (...) { + continue; + } + if (hubMetadata.value("repoId", "") != kBsRoformerRepoId || + manifest.value("model_file", "") != kBsRoformerQuantizedFile) { + continue; + } + if (!bsRoformerGpuBuildQualifies()) { + return std::nullopt; + } + HubInstallOptions options; + options.destinationRoot = destinationRoot; + options.overwrite = true; + options.downloadReadme = false; + auto result = HuggingFaceModelHub().installModel(kBsRoformerRepoId, options); + if (result.success && result.primaryFilePath.filename() != kBsRoformerQuantizedFile) { + // The pack keeps its id and directory; only the superseded CPU build goes. + std::filesystem::remove(entry.path() / kBsRoformerQuantizedFile, error); + result.message = "Vocal model switched to its GPU build."; + } + return result; + } + return std::nullopt; +} + std::string primaryFileForRepo(const std::string& repoId, const std::vector& files, bool* hasOnnxOut, @@ -621,13 +672,8 @@ std::optional HuggingFaceModelHub::modelInfo(const std::string& mo } } - // The GPU probe opens a real session once per process; only repos with a - // GPU variant pay for it. fp32 also needs a device big enough to hold it: - // on a smaller card it spills into shared memory and runs slower than CPU. - std::string gpuProvider; - const bool preferGpuBuild = info.repoId == kBsRoformerRepoId && gpuTensorSessionAvailable(&gpuProvider) && - gpuProvider == "cuda" && - gpuFitsModel(queryCudaDeviceMemory(), kBsRoformerFp32GpuMemoryMb * 1024 * 1024); + // Only repos with a GPU variant pay for the probe. + const bool preferGpuBuild = info.repoId == kBsRoformerRepoId && bsRoformerGpuBuildQualifies(); info.primaryFile = primaryFileForRepo(info.repoId, info.files, &info.hasOnnx, preferGpuBuild); info.useCase = HuggingFaceModelHub::inferUseCase(info.repoId, info.tags, ""); const auto compatibility = validateCatalogModel(info); diff --git a/src/ai/HuggingFaceModelHub.h b/src/ai/HuggingFaceModelHub.h index 6fe4b77..9d05229 100644 --- a/src/ai/HuggingFaceModelHub.h +++ b/src/ai/HuggingFaceModelHub.h @@ -101,6 +101,16 @@ std::string primaryFileForRepo(const std::string& repoId, bool* hasOnnxOut = nullptr, bool preferGpuBuild = false); +// True when this machine should get BS-RoFormer's GPU (fp32) build: a CUDA +// session opens and the device is large enough for the model. +bool bsRoformerGpuBuildQualifies(); + +// After GPU support appears (e.g. the GPU runtime pack was installed), +// reinstalls a BS-RoFormer pack under `destinationRoot` that is still on its CPU +// (quantized) build as the GPU build, and removes the superseded file. Empty +// when there is no such pack or the machine does not qualify. +std::optional upgradeBsRoformerForGpu(const std::filesystem::path& destinationRoot); + // Files that must be downloaded next to `primaryFile` to make a complete pack: // per-repo extras, plus ".data" whenever the repo publishes one // (ONNX external weights, which the installer then inlines). diff --git a/src/ai/OnnxModelInference.cpp b/src/ai/OnnxModelInference.cpp index 7f24278..5041b92 100644 --- a/src/ai/OnnxModelInference.cpp +++ b/src/ai/OnnxModelInference.cpp @@ -17,6 +17,7 @@ #include +#include "ai/GpuRuntimePack.h" #include "util/StringUtils.h" #ifndef AUTOMIX_HAS_NATIVE_ORT @@ -397,6 +398,9 @@ bool OnnxModelInference::loadModel(const std::filesystem::path& modelPath) { nativeState->sessionOptions->EnableProfiling(nativeState->profilingPrefix.c_str()); } + if (activeExecutionProvider_ != gpu::kProviderCpu) { + GpuRuntimePack::preload(); // per-user CUDA libraries, if installed + } try { appendExecutionProvider(*nativeState->sessionOptions, activeExecutionProvider_); } catch (const std::exception&) { diff --git a/src/app/controllers/ModelController.h b/src/app/controllers/ModelController.h index b6ad6a1..0f8254b 100644 --- a/src/app/controllers/ModelController.h +++ b/src/app/controllers/ModelController.h @@ -69,6 +69,7 @@ class ModelController { const std::vector& discoveredModels() const; std::set installedModelIds() const; void setModelHubRoot(const std::filesystem::path& root); + [[nodiscard]] const std::filesystem::path& modelHubRoot() const { return modelHubRoot_; } private: ai::ModelManager& modelManager_; diff --git a/src/app/ui/MainLayout.cpp b/src/app/ui/MainLayout.cpp index 494e9ed..501f698 100644 --- a/src/app/ui/MainLayout.cpp +++ b/src/app/ui/MainLayout.cpp @@ -14,6 +14,7 @@ #include "app/ui/TransportBar.h" #include "app/ui/VerificationEngine.h" #include "ai/GpuRuntimePack.h" +#include "ai/HuggingFaceModelHub.h" #include "ai/OnnxTensorInference.h" #include "renderers/RendererPipeline.h" #include "util/FileUtils.h" @@ -66,6 +67,10 @@ void automix::app::detail::updateStemPanelFromSession(StemPanel& panel, const do MainLayout::MainLayout() { setWantsKeyboardFocus(true); + // A GPU runtime removed last run may still have files on disk (they were + // locked while loaded); finish that before anything can preload them. + ai::GpuRuntimePack::completePendingRemoval(); + // 1. Create UI components headerBar_ = std::make_unique(); heroWaveform_ = std::make_unique(); @@ -1712,7 +1717,8 @@ void MainLayout::onSettings() { taskOrchestrator_->appendHistory(enabled ? "Export sidecar JSON enabled (.report.json)" : "Export sidecar JSON disabled"); - }); + }, + gpuRuntimeStatusText(), gpuRuntimeButtonText(), [this] { onGpuRuntimeButton(); }); settingsPanel->setSize(540, 430); juce::DialogWindow::LaunchOptions options; @@ -2244,10 +2250,7 @@ void MainLayout::offerGpuRuntimeIfUseful() { if (gpuRuntimeOffered_ || gpuRuntimeInstalling_) { return; } - // The fp32 vocal model needs 10 GiB of device memory plus desktop headroom; - // offering ~1 GB of CUDA libraries to a smaller card would buy nothing. - constexpr std::uint64_t kMinimumAdapterBytes = (10240ull + 1536ull) * 1024 * 1024; - if (!ai::runtimeReportsProvider("cuda") || !ai::GpuRuntimePack::shouldOffer(kMinimumAdapterBytes)) { + if (!ai::runtimeReportsProvider("cuda") || !ai::GpuRuntimePack::shouldOffer(kGpuRuntimeMinimumAdapterBytes)) { return; } gpuRuntimeOffered_ = true; @@ -2293,12 +2296,80 @@ void MainLayout::installGpuRuntime() { safe->gpuRuntimeInstalling_ = false; safe->taskOrchestrator_->appendHistory(juce::String(result.message)); if (result.success) { - safe->taskOrchestrator_->appendHistory( - "GPU acceleration is ready. If the vocal model was installed before this, reinstall it from Models to get " - "its GPU build."); + safe->taskOrchestrator_->appendHistory("GPU acceleration is ready."); + safe->upgradeVocalModelForGpu(); } }); }); } +juce::String MainLayout::gpuRuntimeStatusText() const { + namespace pack = ai::GpuRuntimePack; + if (gpuRuntimeInstalling_) { + return "GPU acceleration: downloading NVIDIA CUDA libraries..."; + } + if (pack::isInstalled(pack::defaultRoot())) { + return "GPU acceleration: installed (NVIDIA CUDA libraries)"; + } + const auto adapter = pack::largestNvidiaAdapter(); + if (!adapter.has_value()) { + return "GPU acceleration: needs an NVIDIA GPU (separation runs on the CPU)"; + } + if (adapter->driverMajor.value_or(0) < pack::kMinimumDriverMajor) { + return "GPU acceleration: update the NVIDIA driver to " + juce::String(pack::kMinimumDriverMajor) + + " or newer (found " + + (adapter->driverMajor.has_value() ? juce::String(*adapter->driverMajor) : juce::String("unknown")) + ")"; + } + if (adapter->dedicatedBytes < kGpuRuntimeMinimumAdapterBytes) { + return "GPU acceleration: the vocal model needs a 12 GB NVIDIA GPU (found " + + juce::String(static_cast(adapter->dedicatedBytes / (1024 * 1024 * 1024))) + " GB)"; + } + if (!ai::runtimeReportsProvider("cuda")) { + return "GPU acceleration: not included in this build of AutoMixMaster"; + } + return "GPU acceleration: available for your NVIDIA GPU (one-time download)"; +} + +juce::String MainLayout::gpuRuntimeButtonText() const { + namespace pack = ai::GpuRuntimePack; + if (gpuRuntimeInstalling_) { + return {}; + } + if (pack::isInstalled(pack::defaultRoot())) { + return "Remove"; + } + if (ai::runtimeReportsProvider("cuda") && pack::shouldOffer(kGpuRuntimeMinimumAdapterBytes)) { + return "Install (~" + juce::String(static_cast(pack::downloadBytes() / (1024 * 1024 * 1024) + 1)) + + " GB)"; + } + return {}; +} + +void MainLayout::onGpuRuntimeButton() { + namespace pack = ai::GpuRuntimePack; + if (pack::isInstalled(pack::defaultRoot())) { + const auto removed = pack::uninstall(); + taskOrchestrator_->appendHistory(juce::String(removed.message)); + return; + } + gpuRuntimeOffered_ = true; // asked explicitly; no need to offer again this run + installGpuRuntime(); +} + +void MainLayout::upgradeVocalModelForGpu() { + // Runs off the message thread: a re-install is a ~650 MB download. + juce::Thread::launch([safe = juce::Component::SafePointer(this), + hubRoot = modelController_->modelHubRoot()] { + const auto upgraded = ai::upgradeBsRoformerForGpu(hubRoot); + if (!upgraded.has_value()) { + return; + } + juce::MessageManager::callAsync([safe, upgraded] { + if (safe != nullptr) { + safe->taskOrchestrator_->appendHistory( + juce::String(upgraded->success ? upgraded->message : "Vocal model GPU upgrade failed: " + upgraded->message)); + } + }); + }); +} } // namespace automix::app diff --git a/src/app/ui/MainLayout.h b/src/app/ui/MainLayout.h index 4b1be4b..77a4431 100644 --- a/src/app/ui/MainLayout.h +++ b/src/app/ui/MainLayout.h @@ -287,6 +287,13 @@ class MainLayout final : public juce::Component, // closing the window stops it without a dangling pointer. void offerGpuRuntimeIfUseful(); void installGpuRuntime(); + void upgradeVocalModelForGpu(); + void onGpuRuntimeButton(); + juce::String gpuRuntimeStatusText() const; + juce::String gpuRuntimeButtonText() const; + // The fp32 vocal model needs 10 GiB of device memory plus desktop headroom; + // offering ~1 GB of CUDA libraries to a smaller card would buy nothing. + static constexpr std::uint64_t kGpuRuntimeMinimumAdapterBytes = (10240ull + 1536ull) * 1024 * 1024; bool gpuRuntimeOffered_ = false; bool gpuRuntimeInstalling_ = false; std::shared_ptr gpuRuntimeCancel_ = std::make_shared(false); diff --git a/src/app/ui/MainLayoutInternal.h b/src/app/ui/MainLayoutInternal.h index a39cc3d..1ecce63 100644 --- a/src/app/ui/MainLayoutInternal.h +++ b/src/app/ui/MainLayoutInternal.h @@ -234,9 +234,27 @@ class SettingsPanel final : public juce::Component { public: SettingsPanel(juce::AudioDeviceManager& audioDeviceManager, const bool writeReportJsonSidecar, - std::function onWriteReportSidecarChanged) + std::function onWriteReportSidecarChanged, + const juce::String& gpuStatus = {}, + const juce::String& gpuButtonText = {}, + std::function onGpuButton = {}) : audioSelector_(audioDeviceManager, 0, 0, 0, 2, false, false, true, false), - onWriteReportSidecarChanged_(std::move(onWriteReportSidecarChanged)) { + onWriteReportSidecarChanged_(std::move(onWriteReportSidecarChanged)), + onGpuButton_(std::move(onGpuButton)) { + // GPU acceleration: status plus an optional action (install / remove the + // per-user NVIDIA CUDA runtime), both decided by the owner. + gpuStatusLabel_.setText(gpuStatus, juce::dontSendNotification); + gpuButton_.setButtonText(gpuButtonText); + gpuButton_.setVisible(gpuButtonText.isNotEmpty()); + gpuButton_.onClick = [this] { + gpuButton_.setEnabled(false); // one action per dialog + if (onGpuButton_) { + onGpuButton_(); + } + }; + addAndMakeVisible(gpuStatusLabel_); + addChildComponent(gpuButton_); + reportSidecarToggle_.setButtonText("Write .report.json sidecar next to each exported file"); reportSidecarToggle_.setTooltip("Disable to export only audio files without per-file JSON report sidecars."); reportSidecarToggle_.setToggleState(writeReportJsonSidecar, juce::dontSendNotification); @@ -253,6 +271,13 @@ class SettingsPanel final : public juce::Component { void resized() override { auto area = getLocalBounds().reduced(10); reportSidecarToggle_.setBounds(area.removeFromTop(28)); + area.removeFromTop(6); + auto gpuRow = area.removeFromTop(28); + if (gpuButton_.isVisible()) { + gpuButton_.setBounds(gpuRow.removeFromRight(130)); + gpuRow.removeFromRight(8); + } + gpuStatusLabel_.setBounds(gpuRow); area.removeFromTop(10); audioSelector_.setBounds(area); } @@ -261,6 +286,9 @@ class SettingsPanel final : public juce::Component { juce::AudioDeviceSelectorComponent audioSelector_; juce::ToggleButton reportSidecarToggle_; std::function onWriteReportSidecarChanged_; + juce::Label gpuStatusLabel_; + juce::TextButton gpuButton_; + std::function onGpuButton_; }; // ── Stem string helpers ──────────────────────────────────────── diff --git a/tests/unit/GpuBenchmarkTests.cpp b/tests/unit/GpuBenchmarkTests.cpp index 65635b4..b4419be 100644 --- a/tests/unit/GpuBenchmarkTests.cpp +++ b/tests/unit/GpuBenchmarkTests.cpp @@ -240,8 +240,12 @@ TEST_CASE("OnnxModelInference OOM failure triggers recovery counters and CPU re- const auto modelPath = createDummyModel(tempDir); createDummyMetadataWithGpu(modelPath); + // Load on CPU: these tests simulate GPU failures, so a real GPU attempt at + // load (which fails wherever CUDA's libraries are absent) must not pre-mark it. + inference.setExecutionProviderPreference("cpu"); REQUIRE(inference.loadModel(modelPath)); inference.pinExecutionProvidersForTesting({"cuda", "cpu"}); + inference.setExecutionProviderPreference("auto"); // Setup proves the GPU provider is actually selectable before any failure: // the recovery assertions are only meaningful if resolution picks "cuda". @@ -286,8 +290,12 @@ TEST_CASE("OnnxModelInference device-lost failure triggers recovery counters and const auto modelPath = createDummyModel(tempDir); createDummyMetadataWithGpu(modelPath); + // Load on CPU: these tests simulate GPU failures, so a real GPU attempt at + // load (which fails wherever CUDA's libraries are absent) must not pre-mark it. + inference.setExecutionProviderPreference("cpu"); REQUIRE(inference.loadModel(modelPath)); inference.pinExecutionProvidersForTesting({"cuda", "cpu"}); + inference.setExecutionProviderPreference("auto"); REQUIRE(inference.activeExecutionProvider() == "cuda"); // When: a GPU device-lost failure is simulated on the active provider. @@ -321,8 +329,12 @@ TEST_CASE("OnnxModelInference resolution skips failed provider on later re-resol const auto modelPath = createDummyModel(tempDir); createDummyMetadataWithGpu(modelPath); + // Load on CPU: these tests simulate GPU failures, so a real GPU attempt at + // load (which fails wherever CUDA's libraries are absent) must not pre-mark it. + inference.setExecutionProviderPreference("cpu"); REQUIRE(inference.loadModel(modelPath)); inference.pinExecutionProvidersForTesting({"cuda", "cpu"}); + inference.setExecutionProviderPreference("auto"); REQUIRE(inference.activeExecutionProvider() == "cuda"); // Given: the GPU provider has failed once and resolution fell back to CPU. @@ -349,8 +361,12 @@ TEST_CASE("OnnxModelInference repeated failure counts each recovery but records const auto modelPath = createDummyModel(tempDir); createDummyMetadataWithGpu(modelPath); + // Load on CPU: these tests simulate GPU failures, so a real GPU attempt at + // load (which fails wherever CUDA's libraries are absent) must not pre-mark it. + inference.setExecutionProviderPreference("cpu"); REQUIRE(inference.loadModel(modelPath)); inference.pinExecutionProvidersForTesting({"cuda", "cpu"}); + inference.setExecutionProviderPreference("auto"); REQUIRE(inference.activeExecutionProvider() == "cuda"); // When: the same provider fails twice. @@ -378,8 +394,12 @@ TEST_CASE("OnnxModelInference non-recoverable failure marks provider failed with const auto modelPath = createDummyModel(tempDir); createDummyMetadataWithGpu(modelPath); + // Load on CPU: these tests simulate GPU failures, so a real GPU attempt at + // load (which fails wherever CUDA's libraries are absent) must not pre-mark it. + inference.setExecutionProviderPreference("cpu"); REQUIRE(inference.loadModel(modelPath)); inference.pinExecutionProvidersForTesting({"cuda", "cpu"}); + inference.setExecutionProviderPreference("auto"); REQUIRE(inference.activeExecutionProvider() == "cuda"); // When: a non-OOM / non-device-lost failure is simulated. diff --git a/tests/unit/TensorInferenceTests.cpp b/tests/unit/TensorInferenceTests.cpp index 4f91a1b..e73bdde 100644 --- a/tests/unit/TensorInferenceTests.cpp +++ b/tests/unit/TensorInferenceTests.cpp @@ -36,6 +36,13 @@ #include "util/WavWriter.h" #include "TorchStftGolden.h" +#if defined(_WIN32) +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#endif + namespace ai = automix::ai; namespace analysis = automix::analysis; namespace engine = automix::engine; @@ -1412,7 +1419,8 @@ TEST_CASE("GPU runtime pack extracts only libraries and refuses escaping entries writeZip(root / "good.whl", {{"nvidia/cu13/bin/x64/cudart64_13.dll", "runtime"}, {"nvidia/cu13/include/cuda.h", "header"}, {"nvidia_cuda_runtime-13.4.92.dist-info/LICENSE.txt", "licence"}, - {"nvidia/cudnn/bin/cudnn64_9.DLL", "cudnn"}}); + {"nvidia/cudnn/bin/cudnn64_9.DLL", "cudnn"}, + {"nvidia/cu13/bin/x64/nvblas64_13.dll", "never loaded by ORT"}}); std::vector extracted; REQUIRE(ai::GpuRuntimePack::extractLibraries(root / "good.whl", root / "bin", extracted).empty()); std::sort(extracted.begin(), extracted.end()); @@ -1474,4 +1482,71 @@ TEST_CASE("GPU runtime pack never marks a failed or cancelled install as install std::filesystem::remove(root / "bin" / "cudart64_13.dll"); REQUIRE_FALSE(ai::GpuRuntimePack::isInstalled(root)); // a listed library went missing std::filesystem::remove_all(root); +} +TEST_CASE("NVIDIA driver version is read from the Windows driver version", "[ai][gpu][runtime-pack]") { + const auto umd = [](std::uint64_t a, std::uint64_t b, std::uint64_t c, std::uint64_t d) { + return (a << 48) | (b << 32) | (c << 16) | d; + }; + REQUIRE(ai::GpuRuntimePack::nvidiaDriverVersion(umd(32, 0, 16, 1074)) == std::pair{610, 74}); // this machine + REQUIRE(ai::GpuRuntimePack::nvidiaDriverVersion(umd(32, 0, 15, 8088)) == std::pair{580, 88}); + REQUIRE(ai::GpuRuntimePack::nvidiaDriverVersion(umd(32, 0, 15, 6094)) == std::pair{560, 94}); + REQUIRE(ai::GpuRuntimePack::nvidiaDriverVersion(umd(31, 0, 15, 3742)) == std::pair{537, 42}); + REQUIRE(ai::GpuRuntimePack::kMinimumDriverMajor == 580); +} + +TEST_CASE("GPU runtime pack removal refuses foreign folders and finishes locked removals at startup", + "[ai][gpu][runtime-pack]") { + const auto base = std::filesystem::temp_directory_path() / "automix_runtime_pack_remove"; + std::filesystem::remove_all(base); + + // Not a pack: wrong folder name. + const auto foreign = base / "Documents"; + std::filesystem::create_directories(foreign / "bin"); + const auto refused = ai::GpuRuntimePack::uninstall(foreign); + REQUIRE_FALSE(refused.removedNow); + REQUIRE(refused.message.find("Refusing") != std::string::npos); + REQUIRE(std::filesystem::exists(foreign / "bin")); + + // A pack whose files are free goes at once. + const auto root = base / ai::GpuRuntimePack::version(); + std::filesystem::create_directories(root / "bin"); + std::ofstream(root / "bin" / "cudart64_13.dll") << "x"; + std::ofstream(root / "runtime.json") << "{}"; + REQUIRE(ai::GpuRuntimePack::uninstall(root).removedNow); + REQUIRE_FALSE(std::filesystem::exists(root)); + +#if defined(_WIN32) + // A library in use cannot be deleted on Windows: the pack is disabled now + // and removed at the next start. + std::filesystem::create_directories(root / "bin"); + std::ofstream(root / "bin" / "cudnn64_9.dll") << "x"; + std::ofstream(root / "runtime.json") << "{}"; + { + HANDLE locked = CreateFileW((root / "bin" / "cudnn64_9.dll").c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + REQUIRE(locked != INVALID_HANDLE_VALUE); + const auto deferred = ai::GpuRuntimePack::uninstall(root); + CloseHandle(locked); + REQUIRE_FALSE(deferred.removedNow); + REQUIRE_FALSE(std::filesystem::exists(root / "runtime.json")); // disabled at once + REQUIRE_FALSE(ai::GpuRuntimePack::isInstalled(root)); + } + ai::GpuRuntimePack::completePendingRemoval(root); + REQUIRE_FALSE(std::filesystem::exists(root)); +#endif + std::filesystem::remove_all(base); +} + +TEST_CASE("Vocal model GPU upgrade leaves non-matching packs alone", "[ai][gpu][catalog]") { + const auto root = std::filesystem::temp_directory_path() / "automix_gpu_upgrade_scan"; + std::filesystem::remove_all(root); + REQUIRE_FALSE(ai::upgradeBsRoformerForGpu(root).has_value()); // no hub at all + std::filesystem::create_directories(root / "other_model"); + std::ofstream(root / "other_model" / "modelhub.json") << R"({"repoId": "someone/else"})"; + std::ofstream(root / "other_model" / "model.json") << R"({"model_file": "bs_roformer_ep317_sdr12.9755_quantized_uint8.onnx"})"; + std::filesystem::create_directories(root / "already_gpu"); + std::ofstream(root / "already_gpu" / "modelhub.json") << R"({"repoId": "xycld/BS-RoFormer-ONNX"})"; + std::ofstream(root / "already_gpu" / "model.json") << R"({"model_file": "bs_roformer_ep317_sdr12.9755.onnx"})"; + REQUIRE_FALSE(ai::upgradeBsRoformerForGpu(root).has_value()); + std::filesystem::remove_all(root); } \ No newline at end of file diff --git a/tools/commands/ModelCommands.cpp b/tools/commands/ModelCommands.cpp index 44b1ab2..f09200c 100644 --- a/tools/commands/ModelCommands.cpp +++ b/tools/commands/ModelCommands.cpp @@ -574,20 +574,29 @@ int commandSeparate(const std::vector& args) { return result.success ? 0 : 1; } -// gpu-runtime status|install [--root ] +// gpu-runtime status|install|remove|upgrade-models [--root ] [--hub ] // The per-user NVIDIA CUDA libraries the CUDA execution provider loads. int commandGpuRuntime(const std::vector& args) { namespace pack = automix::ai::GpuRuntimePack; - const bool wantsInstall = std::find(args.begin(), args.end(), "install") != args.end(); - const bool wantsStatus = std::find(args.begin(), args.end(), "status") != args.end(); - const std::string action = wantsInstall ? "install" : (wantsStatus || args.size() <= 1 ? "status" : ""); + const auto has = [&args](const char* word) { return std::find(args.begin(), args.end(), word) != args.end(); }; + const std::string action = has("install") ? "install" + : has("remove") ? "remove" + : has("upgrade-models") ? "upgrade-models" + : (has("status") || args.size() <= 1) ? "status" + : ""; const std::filesystem::path root = argValue(args, "--root").value_or(pack::defaultRoot().string()); if (action == "status") { - const auto adapter = pack::largestNvidiaAdapterBytes(); + const auto adapter = pack::largestNvidiaAdapter(); + const auto driver = adapter.has_value() && adapter->driverMajor.has_value() + ? std::to_string(*adapter->driverMajor) + "." + + (*adapter->driverMinor < 10 ? "0" : "") + std::to_string(*adapter->driverMinor) + : std::string("unknown"); std::cout << "GPU runtime " << pack::version() << " at " << root.string() << "\n" << " installed: " << (pack::isInstalled(root) ? "yes" : "no") << "\n" << " largest NVIDIA adapter: " - << (adapter.has_value() ? std::to_string(*adapter / (1024 * 1024)) + " MiB" : std::string("none")) << "\n" + << (adapter.has_value() ? std::to_string(adapter->dedicatedBytes / (1024 * 1024)) + " MiB, driver " + driver + : std::string("none")) + << " (CUDA 13 needs >= " << pack::kMinimumDriverMajor << ")\n" << " runtime build has CUDA: " << (automix::ai::runtimeReportsProvider("cuda") ? "yes" : "no") << "\n" << " preload: " << (pack::preload(root) ? "ok" : "not loaded") << "\n"; std::string provider; @@ -608,7 +617,18 @@ int commandGpuRuntime(const std::vector& args) { std::cout << result.message << "\n"; return result.success ? 0 : 1; } - std::cerr << "Usage: gpu-runtime status|install [--root ]\n"; + if (action == "remove") { + const auto removed = pack::uninstall(root); + std::cout << removed.message << "\n"; + return removed.removedNow ? 0 : 3; + } + if (action == "upgrade-models") { + const std::filesystem::path hub = argValue(args, "--hub").value_or("assets/modelhub"); + const auto upgraded = automix::ai::upgradeBsRoformerForGpu(hub); + std::cout << (upgraded.has_value() ? upgraded->message : std::string("Nothing to upgrade.")) << "\n"; + return !upgraded.has_value() || upgraded->success ? 0 : 1; + } + std::cerr << "Usage: gpu-runtime status|install|remove|upgrade-models [--root ] [--hub ]\n"; return 2; } From 1aa7f534b5ee2511e58ff06e910757e42ae2495b Mon Sep 17 00:00:00 2001 From: Soficis Date: Thu, 1 Oct 2026 11:23:02 -0500 Subject: [PATCH 11/68] build: Windows release ZIP and lean PhaseLimiter asset staging - install() + CPack produce a portable ZIP of the "application" component only (the fetched dependencies' headers and libraries stay out): the app, its assets, NOTICE/README and the ONNX Runtime DLLs. An install-time guard fails if any .onnx file or NVIDIA CUDA library lands in the package. - packaging/windows/build-release.ps1: fresh configure, build, tests, cpack. Verified end to end: 224/224 tests, 196 MB ZIP; the extracted app starts and loads its own onnxruntime.dll. - The post-build step copied all of assets/phaselimiter next to the app on every GUI build, including 13 GB of renderer scratch audio (tmp/) and 352 MB of stray downloaded models; it now stages only bin/, resource/, licenses/, LICENSE and README.md. - README: GPU acceleration and release-packaging sections. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 93 +++++++++++++++++++++++++++-- README.md | 42 +++++++++++++ packaging/windows/build-release.ps1 | 56 +++++++++++++++++ 3 files changed, 187 insertions(+), 4 deletions(-) create mode 100644 packaging/windows/build-release.ps1 diff --git a/CMakeLists.txt b/CMakeLists.txt index b2f37f6..57ca18e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -334,12 +334,30 @@ target_compile_definitions(AutoMixMasterApp PRIVATE JUCE_USE_CURL=0 ) +# Only PhaseLimiter's real content is staged. Its folder also collects renderer +# scratch audio (tmp/, many GB) and stray downloaded models (assets/), which +# must never be copied next to the app or into a package. +set(AUTOMIX_PHASELIMITER_DIRS bin resource licenses) +set(AUTOMIX_PHASELIMITER_FILES LICENSE README.md) if(EXISTS "${CMAKE_SOURCE_DIR}/assets/phaselimiter") + set(_automix_pl_commands) + foreach(_dir IN LISTS AUTOMIX_PHASELIMITER_DIRS) + if(EXISTS "${CMAKE_SOURCE_DIR}/assets/phaselimiter/${_dir}") + list(APPEND _automix_pl_commands COMMAND ${CMAKE_COMMAND} -E copy_directory + "${CMAKE_SOURCE_DIR}/assets/phaselimiter/${_dir}" + "$/assets/phaselimiter/${_dir}") + endif() + endforeach() + foreach(_file IN LISTS AUTOMIX_PHASELIMITER_FILES) + if(EXISTS "${CMAKE_SOURCE_DIR}/assets/phaselimiter/${_file}") + list(APPEND _automix_pl_commands COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${CMAKE_SOURCE_DIR}/assets/phaselimiter/${_file}" + "$/assets/phaselimiter/${_file}") + endif() + endforeach() add_custom_command(TARGET AutoMixMasterApp POST_BUILD - COMMAND ${CMAKE_COMMAND} -E make_directory "$/assets" - COMMAND ${CMAKE_COMMAND} -E copy_directory - "${CMAKE_SOURCE_DIR}/assets/phaselimiter" - "$/assets/phaselimiter" + COMMAND ${CMAKE_COMMAND} -E make_directory "$/assets/phaselimiter" + ${_automix_pl_commands} COMMENT "Copying bundled PhaseLimiter assets next to AutoMixMaster executable" VERBATIM ) @@ -493,3 +511,70 @@ if(WIN32 AND AUTOMIX_HAS_NATIVE_ORT) endforeach() endif() endif() + +# ── Release packaging (Windows: portable ZIP via CPack) ────────────────────── +# cmake --install --config Release --component application --prefix +# or: cpack -C Release. Only the "application" component is packaged: the +# fetched dependencies (JUCE, nlohmann_json, libebur128) install headers and +# libraries of their own that do not belong in an app package. +# Ships the app, its assets and the ONNX Runtime build it was linked against +# (use the CUDA build for GPU support). Never ships NVIDIA's CUDA libraries - +# users fetch those on demand (GpuRuntimePack) - nor any model weights. +if(WIN32) + install(TARGETS AutoMixMasterApp RUNTIME DESTINATION . COMPONENT application) + if(AUTOMIX_HAS_NATIVE_ORT) + get_filename_component(_ort_pkg_dir "${ONNXRUNTIME_LIBRARY}" DIRECTORY) + # Core runtime plus the CUDA provider when present; the TensorRT provider + # is left out (it needs TensorRT itself, which nothing here uses). + foreach(_ort_dll onnxruntime.dll onnxruntime_providers_shared.dll onnxruntime_providers_cuda.dll) + if(EXISTS "${_ort_pkg_dir}/${_ort_dll}") + install(FILES "${_ort_pkg_dir}/${_ort_dll}" DESTINATION . COMPONENT application) + endif() + endforeach() + endif() + foreach(_dir IN LISTS AUTOMIX_PHASELIMITER_DIRS) + if(EXISTS "${CMAKE_SOURCE_DIR}/assets/phaselimiter/${_dir}") + install(DIRECTORY "${CMAKE_SOURCE_DIR}/assets/phaselimiter/${_dir}" DESTINATION assets/phaselimiter COMPONENT application) + endif() + endforeach() + foreach(_file IN LISTS AUTOMIX_PHASELIMITER_FILES) + if(EXISTS "${CMAKE_SOURCE_DIR}/assets/phaselimiter/${_file}") + install(FILES "${CMAKE_SOURCE_DIR}/assets/phaselimiter/${_file}" DESTINATION assets/phaselimiter COMPONENT application) + endif() + endforeach() + if(EXISTS "${CMAKE_SOURCE_DIR}/assets/limiters") + install(DIRECTORY "${CMAKE_SOURCE_DIR}/assets/limiters" DESTINATION assets COMPONENT application) + endif() + foreach(_doc NOTICE LICENSE README.md) + if(EXISTS "${CMAKE_SOURCE_DIR}/${_doc}") + install(FILES "${CMAKE_SOURCE_DIR}/${_doc}" DESTINATION . COMPONENT application) + endif() + endforeach() + # Hard guard for NOTICE's promises: fail the install if a model file or an + # NVIDIA CUDA library got into the package. + install(CODE [[ + file(GLOB_RECURSE _automix_forbidden + "${CMAKE_INSTALL_PREFIX}/*.onnx" "${CMAKE_INSTALL_PREFIX}/*.onnx.data" + "${CMAKE_INSTALL_PREFIX}/cudart*.dll" "${CMAKE_INSTALL_PREFIX}/cublas*.dll" + "${CMAKE_INSTALL_PREFIX}/cudnn*.dll" "${CMAKE_INSTALL_PREFIX}/cufft*.dll") + if(_automix_forbidden) + message(FATAL_ERROR "Package must not contain model weights or NVIDIA CUDA libraries: ${_automix_forbidden}") + endif() + ]] COMPONENT application) + + set(CPACK_GENERATOR ZIP) + set(CPACK_PACKAGE_NAME "AutoMixMaster") + set(CPACK_PACKAGE_VENDOR "AutoMixMaster") + if(AUTOMIX_HAS_NATIVE_ORT AND EXISTS "${_ort_pkg_dir}/onnxruntime_providers_cuda.dll") + set(CPACK_PACKAGE_FILE_NAME "AutoMixMaster-${PROJECT_VERSION}-win64-cuda") + else() + set(CPACK_PACKAGE_FILE_NAME "AutoMixMaster-${PROJECT_VERSION}-win64") + endif() + set(CPACK_INCLUDE_TOPLEVEL_DIRECTORY ON) + # Component mode is what makes CPack honour CPACK_COMPONENTS_ALL; grouped + # into one archive it still yields a single ZIP. + set(CPACK_COMPONENTS_ALL application) + set(CPACK_ARCHIVE_COMPONENT_INSTALL ON) + set(CPACK_COMPONENTS_GROUPING ALL_COMPONENTS_IN_ONE) + include(CPack) +endif() diff --git a/README.md b/README.md index c3f8415..05b9bd4 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,32 @@ To finish the wiring, the guarded code should ask `decidePluginEpAttempt(...)` a returns `attempt == false`, log its `reason` and continue down the existing priority chain; `Ort::GetAvailableProviders()` already covers every built-in provider. +#### GPU acceleration for the vocal model (Windows + NVIDIA) + +The BS-RoFormer vocal model runs on CUDA when three things are true: the build links the +**CUDA build of ONNX Runtime**, the user has installed the **GPU runtime pack**, and the GPU +has room for the model. Otherwise it runs on the CPU, with the reason in the log. + +- **GPU runtime pack.** NVIDIA's CUDA runtime, cuBLAS, cuFFT and cuDNN (~1 GB download, + ~1.3 GB on disk) are not shipped. When *Vocal Model* is switched on, or from *Settings → + GPU acceleration*, the app offers a one-time download of NVIDIA's own redistributable + packages from pypi.org, pinned by SHA-256, into + `%LOCALAPPDATA%\AutoMixMaster\gpu-runtime\`. No admin rights, no system CUDA, no + PATH changes; the libraries are preloaded by full path. *Settings* can remove it again. + It is offered only for an NVIDIA GPU with ≥ 11.5 GiB of memory and driver **580 or newer** + (CUDA 13). `automix_dev_tools gpu-runtime status|install|remove|upgrade-models` does the + same from the command line. +- **Model build.** Where a CUDA session opens and the GPU totals ≥ 11.5 GiB, the catalog + installs BS-RoFormer's **fp32** build (its external weights are folded into one file at + install time, because ONNX Runtime 1.30 cannot load that export otherwise); elsewhere the + smaller **quantized** build, which is faster on CPU but gains nothing on a GPU. Installing + the runtime pack upgrades an already-installed quantized model automatically. +- **Memory.** fp32 needs 10 GiB of free GPU memory while it runs (measured peak 9.2 GiB). + With less free, it runs on the CPU rather than spill into shared memory, which is slower. + +Measured on an RTX 5060 Ti (16 GB) for a 196 s track: fp32 on CUDA **66–91 s**, quantized on +CPU 687 s, quantized on CUDA no faster than CPU. + --- @@ -216,6 +242,22 @@ cmake -S . -B build -G "Visual Studio 18 2026" -A x64 cmake --build build --config Release --parallel ``` +#### Windows release package + +`packaging/windows/build-release.ps1` configures a fresh build directory against an ONNX +Runtime SDK, builds, runs the tests and writes a portable ZIP with CPack. Use the CUDA build +of ONNX Runtime so the package can use the GPU: + +```powershell +powershell -File packaging\windows\build-release.ps1 -OnnxRuntimeDir C:\lib\onnxruntime-win-x64-gpu_cuda13-1.30.0 +``` + +The ZIP holds the app, its assets and the ONNX Runtime DLLs (the CUDA provider adds ~190 MB). +It never contains model weights or NVIDIA's CUDA libraries: the install step fails if any +`.onnx` file or `cudart`/`cublas`/`cudnn`/`cufft` library is present. For a developer build +that runs on CUDA without the runtime pack, point `-DAUTOMIX_CUDA_RUNTIME_DIR` at a folder of +those DLLs; they are copied next to the executables, never into the package. + ### Ubuntu Linux (24.04+) 1. Install dependencies diff --git a/packaging/windows/build-release.ps1 b/packaging/windows/build-release.ps1 new file mode 100644 index 0000000..fe2a933 --- /dev/null +++ b/packaging/windows/build-release.ps1 @@ -0,0 +1,56 @@ +# Builds the Windows release ZIP of AutoMixMaster. +# +# The package contains the app, its assets and the ONNX Runtime it links +# against. Point -OnnxRuntimeDir at the CUDA build +# (onnxruntime-win-x64-gpu_cuda13-) so GPU acceleration is available; +# users download NVIDIA's CUDA libraries on demand from inside the app, so they +# are never part of the package (the install step fails if they are). +# +# powershell -File packaging\windows\build-release.ps1 ` +# -OnnxRuntimeDir C:\lib\onnxruntime-win-x64-gpu_cuda13-1.30.0 +param( + [Parameter(Mandatory = $true)][string]$OnnxRuntimeDir, + [string]$BuildDir = "build-release", + [string]$Generator = "Visual Studio 18 2026", + [switch]$SkipTests +) + +$ErrorActionPreference = "Stop" +$repo = Resolve-Path (Join-Path $PSScriptRoot "..\..") +Set-Location $repo + +$include = Join-Path $OnnxRuntimeDir "include" +$library = Join-Path $OnnxRuntimeDir "lib\onnxruntime.lib" +if (-not (Test-Path $library)) { throw "ONNX Runtime library not found: $library" } +if (-not (Test-Path (Join-Path $OnnxRuntimeDir "lib\onnxruntime_providers_cuda.dll"))) { + Write-Warning "This ONNX Runtime has no CUDA provider; the package will run on CPU only." +} + +# A fresh directory: a reused cache could carry AUTOMIX_CUDA_RUNTIME_DIR or a +# different ONNX Runtime from a developer build. +if (Test-Path (Join-Path $BuildDir "CMakeCache.txt")) { + throw "$BuildDir already holds a CMake cache; pass a new -BuildDir for a clean release build." +} + +cmake -S . -B $BuildDir -G $Generator -A x64 ` + -DENABLE_ONNX=ON ` + "-DONNXRUNTIME_INCLUDE_DIR=$include" ` + "-DONNXRUNTIME_LIBRARY=$library" +if ($LASTEXITCODE -ne 0) { throw "configure failed" } + +cmake --build $BuildDir --config Release --parallel +if ($LASTEXITCODE -ne 0) { throw "build failed" } + +if (-not $SkipTests) { + ctest --test-dir $BuildDir -C Release --output-on-failure + if ($LASTEXITCODE -ne 0) { throw "tests failed" } +} + +Push-Location $BuildDir +try { + cpack -C Release + if ($LASTEXITCODE -ne 0) { throw "packaging failed" } + Get-ChildItem -Filter "AutoMixMaster-*.zip" | ForEach-Object { "Package: $($_.FullName) ($([math]::Round($_.Length / 1MB)) MB)" } +} finally { + Pop-Location +} From 60265fa3c297896a07768e26966d8f8ca1f83988 Mon Sep 17 00:00:00 2001 From: Soficis Date: Thu, 1 Oct 2026 11:57:58 -0500 Subject: [PATCH 12/68] fix(renderers): make PhaseLimiter work, opt-in, and leak-free PhaseLimiter never rendered: the renderer passed -mastering=true without -mastering_reference_file, so phase_limiter.exe read ./mastering_reference.json, failed with "auto mastering error: syntax error", and every export silently fell back to BuiltIn. Cleanup ran only on success, so each failure leaked its input WAV and work directory into installRoot/tmp (733 of each, 13 GB, on this machine). - Pass mastering_reference.json and sound_quality2_cache by absolute path; an install without the reference counts as unavailable. - Scratch lives in %TEMP%\automix_phaselimiter\ behind an RAII guard that removes it on every exit path. The process-wide working directory change (racy under parallel renders) is gone: absolute paths, including non-ASCII ones, work from any cwd. - First render per process sweeps only the leaked legacy patterns (input_*.wav, phase_output_*.wav, work_*) and our own runs >24 h old. Owner decision: PhaseLimiter is opt-in. - Default renderer and built-in profiles use BuiltIn; selecting PhaseLimiter (single mode, logical_all primary or a custom chain) is the opt-in. logical_all is otherwise unchanged and no longer adds PhaseLimiter as an automatic stage. - Session schema 3: sessions saved earlier that stored "PhaseLimiter" (the old default) load as BuiltIn - exactly what they always rendered. Golden files are unaffected (the regression harness renders with BuiltIn directly). Co-Authored-By: Claude Opus 5.5 --- src/domain/JsonSerialization.cpp | 12 ++- src/domain/ProjectProfile.cpp | 10 +-- src/domain/ProjectProfile.h | 2 +- src/domain/RenderSettings.h | 4 +- src/domain/Session.h | 2 +- src/renderers/PhaseLimiterDiscovery.h | 18 ++++ src/renderers/PhaseLimiterRenderer.cpp | 100 +++++++++++++++-------- src/renderers/RendererPipeline.cpp | 8 +- tests/unit/PhaseLimiterRendererTests.cpp | 62 ++++++++++++++ tests/unit/ProjectProfileTests.cpp | 2 +- tests/unit/RendererPipelineTests.cpp | 28 +++++++ tests/unit/SessionSerializationTests.cpp | 23 +++++- 12 files changed, 224 insertions(+), 47 deletions(-) diff --git a/src/domain/JsonSerialization.cpp b/src/domain/JsonSerialization.cpp index fe220a1..e6c73d9 100644 --- a/src/domain/JsonSerialization.cpp +++ b/src/domain/JsonSerialization.cpp @@ -123,7 +123,7 @@ void from_json(const Json& j, RenderSettings& value) { value.metadataPolicy = "copy_all"; } value.metadataTemplate = j.value("metadataTemplate", std::map{}); - value.rendererName = j.value("rendererName", "PhaseLimiter"); + value.rendererName = j.value("rendererName", "BuiltIn"); value.rendererChainEnabled = j.value("rendererChainEnabled", false); value.rendererChainMode = j.value("rendererChainMode", "logical_all"); if (value.rendererChainMode != "logical_all" && value.rendererChainMode != "master_then_rsgain") { @@ -340,6 +340,16 @@ void from_json(const Json& j, Session& value) { } else { value.renderSettings = RenderSettings{}; } + // Schema 3 made PhaseLimiter opt-in. Earlier sessions stored "PhaseLimiter" + // as the default rather than a choice, and every such render fell back to + // BuiltIn (the renderer never ran correctly before schema 3), so BuiltIn is + // exactly what those sessions have been producing. + if (value.schemaVersion < 3) { + if (value.renderSettings.rendererName == "PhaseLimiter") { + value.renderSettings.rendererName = "BuiltIn"; + } + value.schemaVersion = 3; + } if (j.contains("mixPlan") && !j.at("mixPlan").is_null()) { value.mixPlan = j.at("mixPlan").get(); diff --git a/src/domain/ProjectProfile.cpp b/src/domain/ProjectProfile.cpp index 630d3ac..0aef4c4 100644 --- a/src/domain/ProjectProfile.cpp +++ b/src/domain/ProjectProfile.cpp @@ -14,7 +14,7 @@ ProjectProfile profileFromJson(const nlohmann::json& json) { profile.id = json.value("id", ""); profile.name = json.value("name", profile.id); profile.platformPreset = json.value("platformPreset", json.value("platform", "spotify")); - profile.rendererName = json.value("rendererName", "PhaseLimiter"); + profile.rendererName = json.value("rendererName", "BuiltIn"); profile.outputFormat = json.value("outputFormat", "wav"); profile.lossyBitrateKbps = std::clamp(json.value("lossyBitrateKbps", 320), 64, 320); profile.mp3UseVbr = json.value("mp3UseVbr", false); @@ -86,7 +86,7 @@ std::vector defaultProjectProfiles() { .id = "default", .name = "Default Balanced", .platformPreset = "spotify", - .rendererName = "PhaseLimiter", + .rendererName = "BuiltIn", .outputFormat = "wav", .lossyBitrateKbps = 320, .mp3UseVbr = false, @@ -99,13 +99,13 @@ std::vector defaultProjectProfiles() { .preferredStemCount = 4, .metadataPolicy = "copy_common", .metadataTemplate = {}, - .pinnedRendererIds = {"PhaseLimiter", "BuiltIn"}, + .pinnedRendererIds = {"BuiltIn", "PhaseLimiter"}, }, ProjectProfile{ .id = "streaming_spotify", .name = "Streaming Spotify", .platformPreset = "spotify", - .rendererName = "PhaseLimiter", + .rendererName = "BuiltIn", .outputFormat = "mp3", .lossyBitrateKbps = 256, .mp3UseVbr = true, @@ -118,7 +118,7 @@ std::vector defaultProjectProfiles() { .preferredStemCount = 4, .metadataPolicy = "copy_common", .metadataTemplate = {}, - .pinnedRendererIds = {"PhaseLimiter", "BuiltIn"}, + .pinnedRendererIds = {"BuiltIn", "PhaseLimiter"}, }, ProjectProfile{ .id = "mobile_fast_turn", diff --git a/src/domain/ProjectProfile.h b/src/domain/ProjectProfile.h index 7b8796f..501dc0f 100644 --- a/src/domain/ProjectProfile.h +++ b/src/domain/ProjectProfile.h @@ -18,7 +18,7 @@ struct ProjectProfile { std::string id; std::string name; std::string platformPreset = "spotify"; - std::string rendererName = "PhaseLimiter"; + std::string rendererName = "BuiltIn"; std::string outputFormat = "wav"; int lossyBitrateKbps = 320; bool mp3UseVbr = false; diff --git a/src/domain/RenderSettings.h b/src/domain/RenderSettings.h index a951c1f..307b12a 100644 --- a/src/domain/RenderSettings.h +++ b/src/domain/RenderSettings.h @@ -33,7 +33,9 @@ struct RenderSettings { bool tensorSeparationEnabled = false; std::string metadataPolicy = "copy_all"; std::map metadataTemplate; - std::string rendererName = "PhaseLimiter"; + // PhaseLimiter is opt-in: selecting it (here or as a custom chain stage) is + // the only way it runs, including in the logical_all chain. + std::string rendererName = "BuiltIn"; bool rendererChainEnabled = false; std::string rendererChainMode = "logical_all"; std::vector rendererChain; diff --git a/src/domain/Session.h b/src/domain/Session.h index 2d72eb5..e47898a 100644 --- a/src/domain/Session.h +++ b/src/domain/Session.h @@ -21,7 +21,7 @@ struct TimelineState { }; struct Session { - int schemaVersion = 2; + int schemaVersion = 3; // 3: PhaseLimiter opt-in (see JsonSerialization.cpp) std::string sessionName; std::optional originalMixPath; double residualBlend = 0.0; diff --git a/src/renderers/PhaseLimiterDiscovery.h b/src/renderers/PhaseLimiterDiscovery.h index 757832f..24dca80 100644 --- a/src/renderers/PhaseLimiterDiscovery.h +++ b/src/renderers/PhaseLimiterDiscovery.h @@ -2,6 +2,7 @@ #include #include +#include #include namespace automix::renderers { @@ -11,6 +12,23 @@ struct PhaseLimiterBinaryInfo { std::filesystem::path installRoot; }; +// The data files phase_limiter reads. Passed by absolute path: their built-in +// defaults ("./mastering_reference.json") resolve against the working +// directory and silently miss the copies under resource/, which made every +// run fail with "auto mastering error: syntax error". +inline std::filesystem::path masteringReferencePath(const PhaseLimiterBinaryInfo& info) { + return info.installRoot / "resource" / "mastering_reference.json"; +} +inline std::filesystem::path soundQualityCachePath(const PhaseLimiterBinaryInfo& info) { + return info.installRoot / "resource" / "sound_quality2_cache"; +} + +// A binary without its mastering reference cannot master; treat it as absent. +inline bool isCompleteInstall(const PhaseLimiterBinaryInfo& info) { + std::error_code error; + return std::filesystem::is_regular_file(masteringReferencePath(info), error) && !error; +} + class PhaseLimiterDiscovery { public: std::optional find() const; diff --git a/src/renderers/PhaseLimiterRenderer.cpp b/src/renderers/PhaseLimiterRenderer.cpp index 9ddee4c..cc5b1f1 100644 --- a/src/renderers/PhaseLimiterRenderer.cpp +++ b/src/renderers/PhaseLimiterRenderer.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -43,28 +44,61 @@ bool pathExists(const std::filesystem::path& path) { return std::filesystem::exists(path, error); } -class CurrentWorkingDirectoryGuard final { +// Per-render scratch space under the OS temp directory, removed on every way +// out of render(). It used to live in installRoot/tmp and was cleaned only on +// success, so each failed run leaked its input WAV and work directory (13 GB on +// one machine) - and installRoot is read-only in a Program Files install. +class ScratchDirectory final { public: - explicit CurrentWorkingDirectoryGuard(const std::filesystem::path& path) - : previous_(std::filesystem::current_path()), changed_(false) { + explicit ScratchDirectory(std::filesystem::path path) : path_(std::move(path)) { std::error_code error; - std::filesystem::current_path(path, error); - changed_ = !error; + std::filesystem::create_directories(path_ / "work", error); } - - ~CurrentWorkingDirectoryGuard() { - if (!changed_) { - return; - } + ~ScratchDirectory() { std::error_code error; - std::filesystem::current_path(previous_, error); + std::filesystem::remove_all(path_, error); } + ScratchDirectory(const ScratchDirectory&) = delete; + ScratchDirectory& operator=(const ScratchDirectory&) = delete; + [[nodiscard]] const std::filesystem::path& path() const { return path_; } private: - std::filesystem::path previous_; - bool changed_ = false; + std::filesystem::path path_; }; +std::filesystem::path scratchBase() { return std::filesystem::temp_directory_path() / "automix_phaselimiter"; } + +bool startsWith(const std::string& text, const std::string& prefix) { return text.rfind(prefix, 0) == 0; } + +// Once per process: remove what earlier versions leaked into installRoot/tmp +// (only their own file patterns, never anything else there) and scratch runs +// of ours older than a day, which only a crash leaves behind. +void sweepLeftoverScratch(const std::filesystem::path& installRoot) { + static std::once_flag once; + std::call_once(once, [&installRoot] { + std::error_code error; + const auto legacy = installRoot / "tmp"; + if (std::filesystem::is_directory(legacy, error)) { + for (const auto& entry : std::filesystem::directory_iterator(legacy, error)) { + const auto name = entry.path().filename().string(); + const bool leaked = (startsWith(name, "input_") && entry.path().extension() == ".wav") || + (startsWith(name, "phase_output_") && entry.path().extension() == ".wav") || + (startsWith(name, "work_") && entry.is_directory(error)); + if (leaked) { + std::filesystem::remove_all(entry.path(), error); + } + } + } + const auto cutoff = std::filesystem::file_time_type::clock::now() - std::chrono::hours(24); + if (std::filesystem::is_directory(scratchBase(), error)) { + for (const auto& entry : std::filesystem::directory_iterator(scratchBase(), error)) { + if (entry.is_directory(error) && std::filesystem::last_write_time(entry.path(), error) < cutoff) { + std::filesystem::remove_all(entry.path(), error); + } + } + } + }); +} std::string uniqueSuffix() { const auto value = std::chrono::high_resolution_clock::now().time_since_epoch().count(); return std::to_string(value); @@ -115,8 +149,8 @@ void drainProcessOutput(juce::ChildProcess& process, std::string& outputCapture) } // namespace bool PhaseLimiterRenderer::isAvailable() const { - PhaseLimiterDiscovery discovery; - return discovery.find().has_value(); + const auto found = PhaseLimiterDiscovery{}.find(); + return found.has_value() && isCompleteInstall(*found); } RenderResult PhaseLimiterRenderer::render(const domain::Session& session, @@ -134,6 +168,11 @@ RenderResult PhaseLimiterRenderer::render(const domain::Session& session, return fallbackToBuiltIn(session, settings, onProgress, cancelFlag, "PhaseLimiter binary not found in assets"); } + if (!isCompleteInstall(*binaryInfo)) { + return fallbackToBuiltIn(session, settings, onProgress, cancelFlag, + "PhaseLimiter install is incomplete: missing " + + pathToUtf8(masteringReferencePath(*binaryInfo))); + } engine::OfflineRenderPipeline pipeline; auto renderState = pipeline.renderRawMix( @@ -184,33 +223,30 @@ RenderResult PhaseLimiterRenderer::render(const domain::Session& session, std::filesystem::create_directories(outputPath.parent_path()); } - const auto suffix = uniqueSuffix(); - const std::filesystem::path tempRoot = binaryInfo->installRoot / "tmp"; - const std::filesystem::path tempWorkDir = tempRoot / ("work_" + suffix); - const std::filesystem::path tempInputPath = tempRoot / ("input_" + suffix + ".wav"); - const std::filesystem::path tempPhaseOutputPath = tempRoot / ("phase_output_" + suffix + ".wav"); - const std::filesystem::path relativeTempWorkDir = std::filesystem::path("tmp") / ("work_" + suffix); - const std::filesystem::path relativeTempInputPath = std::filesystem::path("tmp") / ("input_" + suffix + ".wav"); - const std::filesystem::path relativeTempPhaseOutputPath = - std::filesystem::path("tmp") / ("phase_output_" + suffix + ".wav"); - std::filesystem::create_directories(tempWorkDir); + sweepLeftoverScratch(binaryInfo->installRoot); + const ScratchDirectory scratch(scratchBase() / uniqueSuffix()); + const std::filesystem::path tempWorkDir = scratch.path() / "work"; + const std::filesystem::path tempInputPath = scratch.path() / "input.wav"; + const std::filesystem::path tempPhaseOutputPath = scratch.path() / "phase_output.wav"; util::WavWriter writer; writer.write(tempInputPath, rawMix, kPhaseLimiterBitDepth); - CurrentWorkingDirectoryGuard workingDirectory(binaryInfo->installRoot); - + // Absolute paths throughout: no process-wide working-directory change, + // which raced with parallel renders. juce::StringArray command; command.add(pathToUtf8(binaryInfo->executablePath)); - command.add("-input=" + pathToUtf8(relativeTempInputPath)); - command.add("-output=" + pathToUtf8(relativeTempPhaseOutputPath)); + command.add("-input=" + pathToUtf8(tempInputPath)); + command.add("-output=" + pathToUtf8(tempPhaseOutputPath)); + command.add("-mastering_reference_file=" + pathToUtf8(masteringReferencePath(*binaryInfo))); + command.add("-sound_quality2_cache=" + pathToUtf8(soundQualityCachePath(*binaryInfo))); command.add("-disable_input_encode=true"); command.add("-output_format=wav"); command.add("-sample_rate=44100"); command.add("-bit_depth=" + std::to_string(std::clamp(settings.outputBitDepth, 16, 24))); command.add("-ceiling=" + std::to_string(plan.limiterCeilingDb)); command.add("-mastering=true"); - command.add("-tmp=" + pathToUtf8(relativeTempWorkDir)); + command.add("-tmp=" + pathToUtf8(tempWorkDir)); juce::ChildProcess process; if (!process.start(command)) { @@ -330,10 +366,6 @@ RenderResult PhaseLimiterRenderer::render(const domain::Session& session, out << report.dump(2); } - std::error_code ignore; - std::filesystem::remove(tempInputPath, ignore); - std::filesystem::remove_all(tempWorkDir, ignore); - std::filesystem::remove(tempPhaseOutputPath, ignore); RenderResult result; result.success = true; diff --git a/src/renderers/RendererPipeline.cpp b/src/renderers/RendererPipeline.cpp index 6def6dd..affb9d5 100644 --- a/src/renderers/RendererPipeline.cpp +++ b/src/renderers/RendererPipeline.cpp @@ -49,7 +49,8 @@ bool isRendererAvailable(const std::string& rendererId) { return true; } if (rendererId == kPhaseLimiterRendererId) { - return PhaseLimiterDiscovery{}.find().has_value(); + const auto found = PhaseLimiterDiscovery{}.find(); + return found.has_value() && isCompleteInstall(*found); } if (rendererId == kFfmpegRendererId) { return FfmpegDiscovery{}.find().has_value(); @@ -130,6 +131,11 @@ std::vector resolveLogicalAllChain(const domain::RenderSettings& se if (rendererId == primaryRenderer) { continue; } + // PhaseLimiter is opt-in: it joins the chain only as the selected primary + // renderer (added above), never as an automatic stage. + if (rendererId == kPhaseLimiterRendererId) { + continue; + } if (!isRendererAvailable(rendererId)) { continue; } diff --git a/tests/unit/PhaseLimiterRendererTests.cpp b/tests/unit/PhaseLimiterRendererTests.cpp index 2f28f77..fbe3a67 100644 --- a/tests/unit/PhaseLimiterRendererTests.cpp +++ b/tests/unit/PhaseLimiterRendererTests.cpp @@ -1,9 +1,11 @@ #include #include +#include #include #include "domain/Session.h" +#include "renderers/PhaseLimiterDiscovery.h" #include "renderers/PhaseLimiterRenderer.h" #include "util/WavWriter.h" @@ -68,3 +70,63 @@ TEST_CASE("PhaseLimiter renderer never crashes and always returns a valid render std::filesystem::remove_all(tempDir); } + +TEST_CASE("A selected PhaseLimiter really renders and leaves no scratch behind", "[phaselimiter][renderer]") { + automix::renderers::PhaseLimiterRenderer renderer; + if (!renderer.isAvailable()) { + SKIP("No complete PhaseLimiter install found"); + } + const auto installRoot = automix::renderers::PhaseLimiterDiscovery{}.find()->installRoot; + const auto countEntries = [](const std::filesystem::path& dir) { + std::error_code error; + std::size_t count = 0; + if (std::filesystem::is_directory(dir, error)) { + for ([[maybe_unused]] const auto& entry : std::filesystem::directory_iterator(dir, error)) { + ++count; + } + } + return count; + }; + + const std::filesystem::path tempDir = std::filesystem::temp_directory_path() / "automix_phaselimiter_real_render"; + std::filesystem::remove_all(tempDir); + std::filesystem::create_directories(tempDir); + automix::util::WavWriter writer; + writer.write(tempDir / "bass.wav", makeTone(44100.0, 44100, 110.0, 0.40), 24); + writer.write(tempDir / "lead.wav", makeTone(44100.0, 44100, 660.0, 0.20), 24); + automix::domain::Session session; + automix::domain::Stem bass; + bass.id = "bass"; + bass.name = "Bass"; + bass.filePath = (tempDir / "bass.wav").string(); + automix::domain::Stem lead; + lead.id = "lead"; + lead.name = "Lead"; + lead.filePath = (tempDir / "lead.wav").string(); + session.stems = {bass, lead}; + + automix::domain::RenderSettings settings; + settings.rendererName = "PhaseLimiter"; + settings.outputPath = (tempDir / "out.wav").string(); + + const auto scratchBase = std::filesystem::temp_directory_path() / "automix_phaselimiter"; + const auto workingDirectory = std::filesystem::current_path(); + const auto scratchBefore = countEntries(scratchBase); + const auto legacyBefore = countEntries(installRoot / "tmp"); + + const auto result = renderer.render(session, settings, {}, nullptr); + std::string logs; + for (const auto& line : result.logs) { + logs += line + "\n"; + } + INFO(logs); + REQUIRE(result.success); + REQUIRE(result.rendererName == "PhaseLimiter"); // not "PhaseLimiter (fallback BuiltIn)" + REQUIRE(logs.find("fallback") == std::string::npos); + REQUIRE(std::filesystem::exists(settings.outputPath)); + + REQUIRE(countEntries(scratchBase) <= scratchBefore); // its own run is gone + REQUIRE(countEntries(installRoot / "tmp") <= legacyBefore); // nothing new in the install + REQUIRE(std::filesystem::current_path() == workingDirectory); // no process-wide cwd change + std::filesystem::remove_all(tempDir); +} \ No newline at end of file diff --git a/tests/unit/ProjectProfileTests.cpp b/tests/unit/ProjectProfileTests.cpp index 7bfb3df..f0a96a9 100644 --- a/tests/unit/ProjectProfileTests.cpp +++ b/tests/unit/ProjectProfileTests.cpp @@ -13,7 +13,7 @@ TEST_CASE("Project profile defaults are available", "[profile]") { const auto foundDefault = automix::domain::findProjectProfile(defaults, "default"); REQUIRE(foundDefault.has_value()); - REQUIRE(foundDefault->rendererName == "PhaseLimiter"); + REQUIRE(foundDefault->rendererName == "BuiltIn"); // PhaseLimiter is opt-in } TEST_CASE("Project profile loader merges asset profiles with defaults", "[profile]") { diff --git a/tests/unit/RendererPipelineTests.cpp b/tests/unit/RendererPipelineTests.cpp index 771696f..b157770 100644 --- a/tests/unit/RendererPipelineTests.cpp +++ b/tests/unit/RendererPipelineTests.cpp @@ -48,3 +48,31 @@ TEST_CASE("Renderer pipeline uses explicit chain list when provided", "[renderer REQUIRE(chain[0] == "BuiltIn"); REQUIRE(chain[1] == "rsgain"); } + +TEST_CASE("PhaseLimiter is opt-in: default renders and logical_all leave it out", "[renderer][pipeline]") { + automix::domain::RenderSettings defaults; + const auto single = automix::renderers::resolveRendererChain(defaults); + REQUIRE(single == std::vector{"BuiltIn"}); + + automix::domain::RenderSettings logicalAll; + logicalAll.rendererChainEnabled = true; + logicalAll.rendererChainMode = "logical_all"; + const auto chain = automix::renderers::resolveRendererChain(logicalAll); + REQUIRE_FALSE(chain.empty()); + REQUIRE(chain.front() == "BuiltIn"); + REQUIRE(std::find(chain.begin(), chain.end(), "PhaseLimiter") == chain.end()); +} + +TEST_CASE("Selecting PhaseLimiter opts it into the logical_all chain", "[renderer][pipeline]") { + automix::domain::RenderSettings settings; + settings.rendererName = "PhaseLimiter"; + settings.rendererChainEnabled = true; + settings.rendererChainMode = "logical_all"; + const auto chain = automix::renderers::resolveRendererChain(settings); + REQUIRE_FALSE(chain.empty()); + if (std::find(chain.begin(), chain.end(), "PhaseLimiter") != chain.end()) { + REQUIRE(chain.front() == "PhaseLimiter"); // available here: it leads, once + REQUIRE(std::count(chain.begin(), chain.end(), "PhaseLimiter") == 1); + } + REQUIRE(std::find(chain.begin(), chain.end(), "BuiltIn") != chain.end()); +} \ No newline at end of file diff --git a/tests/unit/SessionSerializationTests.cpp b/tests/unit/SessionSerializationTests.cpp index 7a2af53..2763d43 100644 --- a/tests/unit/SessionSerializationTests.cpp +++ b/tests/unit/SessionSerializationTests.cpp @@ -8,7 +8,7 @@ TEST_CASE("Session serialization round trip preserves required fields", "[session]") { automix::domain::Session session; - session.schemaVersion = 2; + session.schemaVersion = 3; session.sessionName = "round_trip"; session.originalMixPath = "C:/audio/original_mix.wav"; session.residualBlend = 7.5; @@ -54,7 +54,7 @@ TEST_CASE("Session serialization round trip preserves required fields", "[sessio const automix::domain::Json json = session; const auto decoded = json.get(); - REQUIRE(decoded.schemaVersion == 2); + REQUIRE(decoded.schemaVersion == 3); REQUIRE(decoded.sessionName == "round_trip"); REQUIRE(decoded.originalMixPath.has_value()); REQUIRE(decoded.originalMixPath.value() == "C:/audio/original_mix.wav"); @@ -161,3 +161,22 @@ TEST_CASE("Render settings normalize unsupported renderer chain modes", "[sessio REQUIRE(decoded.renderSettings.rendererChainEnabled == true); REQUIRE(decoded.renderSettings.rendererChainMode == "logical_all"); } + +TEST_CASE("Sessions from before PhaseLimiter became opt-in load with BuiltIn", "[session][serialization]") { + // Schema 2 wrote rendererName "PhaseLimiter" as the default, and every such + // render fell back to BuiltIn, so BuiltIn reproduces what those sessions got. + const nlohmann::json legacy = {{"schemaVersion", 2}, {"renderSettings", {{"rendererName", "PhaseLimiter"}}}}; + const auto migrated = legacy.get(); + REQUIRE(migrated.renderSettings.rendererName == "BuiltIn"); + REQUIRE(migrated.schemaVersion == 3); + + const nlohmann::json legacyOther = {{"schemaVersion", 2}, {"renderSettings", {{"rendererName", "SoX"}}}}; + REQUIRE(legacyOther.get().renderSettings.rendererName == "SoX"); + + // From schema 3 on, PhaseLimiter in a session is an explicit choice and stays. + const nlohmann::json chosen = {{"schemaVersion", 3}, {"renderSettings", {{"rendererName", "PhaseLimiter"}}}}; + REQUIRE(chosen.get().renderSettings.rendererName == "PhaseLimiter"); + + REQUIRE(automix::domain::Session{}.schemaVersion == 3); + REQUIRE(automix::domain::RenderSettings{}.rendererName == "BuiltIn"); +} \ No newline at end of file From 8682412ab45d959246c0e2192061a1e8490ea836 Mon Sep 17 00:00:00 2001 From: Soficis Date: Thu, 1 Oct 2026 11:58:00 -0500 Subject: [PATCH 13/68] feat(ai): per-user model hub with one-time migration Downloaded models lived in "assets/modelhub" relative to the working directory: it moved with the cwd and is not writable under Program Files. - defaultModelHubRoot(): %LOCALAPPDATA%\AutoMixMaster\modelhub (user app data elsewhere), or AUTOMIX_MODEL_HUB_ROOT for portable installs. Both hubs, ModelManager, ModelController, MainLayout and dev tools use it; bundled read-only packs ("ModelPacks") are still scanned. - migrateModelHub() moves the legacy hub once at startup: pack folders (copy+delete across volumes), install_registry.json and license_consents.json merged by modelId with the target winning, registry installPaths rewritten, install log appended, MIGRATED.txt left behind. Recorded licence consents therefore survive the move. - Uninstall refuses a registry installPath outside the hub instead of remove_all-ing whatever the file names. - Tests run against a throwaway hub (TestMain.cpp listener), so suites never write to the user's profile. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 1 + src/ai/GitHubReleaseModelHub.cpp | 3 +- src/ai/HuggingFaceModelHub.cpp | 3 +- src/ai/ModelManager.cpp | 2 + src/ai/ModelStorage.cpp | 188 ++++++++++++++++++++++++ src/ai/ModelStorage.h | 39 +++++ src/app/controllers/ModelController.cpp | 15 +- src/app/ui/MainLayout.cpp | 21 ++- tests/unit/TensorInferenceTests.cpp | 74 ++++++++++ tests/unit/TestMain.cpp | 30 ++++ tools/commands/ModelCommands.cpp | 7 +- 11 files changed, 374 insertions(+), 9 deletions(-) create mode 100644 src/ai/ModelStorage.cpp create mode 100644 src/ai/ModelStorage.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 57ca18e..7dfac52 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -151,6 +151,7 @@ add_library(automix_core src/ai/OnnxExternalData.cpp src/ai/GpuMemory.cpp src/ai/GpuRuntimePack.cpp + src/ai/ModelStorage.cpp src/util/StringUtils.cpp src/util/LameDownloader.cpp src/util/MetadataPolicy.cpp diff --git a/src/ai/GitHubReleaseModelHub.cpp b/src/ai/GitHubReleaseModelHub.cpp index e231d42..9870fe8 100644 --- a/src/ai/GitHubReleaseModelHub.cpp +++ b/src/ai/GitHubReleaseModelHub.cpp @@ -14,6 +14,7 @@ #include #include "ai/ModelCatalogValidator.h" +#include "ai/ModelStorage.h" #include "util/Sha256.h" #include "util/StringUtils.h" @@ -636,7 +637,7 @@ HubInstallResult GitHubReleaseModelHub::installModel(const std::string& modelId, result.taskScope = compatibility.taskScope; result.revision = info.revision; - const auto destinationRoot = options.destinationRoot.empty() ? std::filesystem::path("assets/modelhub") : options.destinationRoot; + const auto destinationRoot = options.destinationRoot.empty() ? defaultModelHubRoot() : options.destinationRoot; const auto installKey = sanitizePathToken(info.modelId.empty() ? info.repoId : info.modelId); const auto installPath = destinationRoot / installKey; const auto primaryPath = installPath / std::filesystem::path(info.primaryFile).filename(); diff --git a/src/ai/HuggingFaceModelHub.cpp b/src/ai/HuggingFaceModelHub.cpp index d7cad2d..f387454 100644 --- a/src/ai/HuggingFaceModelHub.cpp +++ b/src/ai/HuggingFaceModelHub.cpp @@ -17,6 +17,7 @@ #include "ai/GpuMemory.h" #include "ai/ItoMasterAdapter.h" #include "ai/ModelCatalogValidator.h" +#include "ai/ModelStorage.h" #include "ai/OnnxExternalData.h" #include "ai/OnnxTensorInference.h" #include "util/Sha256.h" @@ -839,7 +840,7 @@ HubInstallResult HuggingFaceModelHub::installModel(const std::string& modelIdOrR } result.taskScope = compatibility.taskScope; - const auto destinationRoot = options.destinationRoot.empty() ? std::filesystem::path("assets/modelhub") : options.destinationRoot; + const auto destinationRoot = options.destinationRoot.empty() ? defaultModelHubRoot() : options.destinationRoot; const auto installKey = sanitizeRepoId(info->modelId.empty() ? info->repoId : info->modelId); const auto installPath = destinationRoot / installKey; const auto primaryPath = installPath / std::filesystem::path(info->primaryFile).filename(); diff --git a/src/ai/ModelManager.cpp b/src/ai/ModelManager.cpp index 4a7bde8..508189d 100644 --- a/src/ai/ModelManager.cpp +++ b/src/ai/ModelManager.cpp @@ -1,4 +1,5 @@ #include "ai/ModelManager.h" +#include "ai/ModelStorage.h" #include #include @@ -17,6 +18,7 @@ using ::automix::util::toLower; std::vector defaultRoots() { return { + defaultModelHubRoot(), // per-user downloads (see ModelStorage.h) "ModelPacks", "modelhub", "assets/modelhub", diff --git a/src/ai/ModelStorage.cpp b/src/ai/ModelStorage.cpp new file mode 100644 index 0000000..c1dd42c --- /dev/null +++ b/src/ai/ModelStorage.cpp @@ -0,0 +1,188 @@ +#include "ai/ModelStorage.h" + +#include +#include +#include + +#include +#include + +namespace automix::ai { +namespace { + +constexpr const char* kRegistryFile = "install_registry.json"; +constexpr const char* kConsentsFile = "license_consents.json"; +constexpr const char* kInstallLogFile = "install_log.jsonl"; +constexpr const char* kMigratedNote = "MIGRATED.txt"; + +std::filesystem::path toPath(const juce::File& file) { + return std::filesystem::path(file.getFullPathName().toWideCharPointer()); +} + +nlohmann::json readArray(const std::filesystem::path& path) { + try { + std::ifstream in(path); + if (in.is_open()) { + auto parsed = nlohmann::json::parse(in); + if (parsed.is_array()) { + return parsed; + } + } + } catch (...) { + } + return nlohmann::json::array(); +} + +std::string recordKey(const nlohmann::json& item) { + return item.is_object() ? item.value("modelId", item.value("repoId", "")) : std::string(); +} + +// rename() where possible; across volumes, copy then delete the source. +bool movePath(const std::filesystem::path& from, const std::filesystem::path& to, std::string& error) { + std::error_code code; + std::filesystem::rename(from, to, code); + if (!code) { + return true; + } + code.clear(); + std::filesystem::copy(from, to, std::filesystem::copy_options::recursive, code); + if (code) { + error = "cannot copy '" + from.string() + "' to '" + to.string() + "': " + code.message(); + std::error_code ignored; + std::filesystem::remove_all(to, ignored); + return false; + } + std::filesystem::remove_all(from, code); // the copy is complete; a leftover source is harmless + return true; +} + +// Target records win on the same key; legacy-only records are appended. +nlohmann::json mergeRecords(nlohmann::json target, const nlohmann::json& legacy) { + std::set present; + for (const auto& item : target) { + present.insert(recordKey(item)); + } + for (const auto& item : legacy) { + const auto key = recordKey(item); + if (key.empty() || present.insert(key).second) { + target.push_back(item); + } + } + return target; +} + +bool writeJson(const std::filesystem::path& path, const nlohmann::json& value) { + std::ofstream out(path, std::ios::trunc); + out << value.dump(2); + return static_cast(out); +} + +} // namespace + +std::filesystem::path defaultModelHubRoot() { + const auto overrideRoot = juce::SystemStats::getEnvironmentVariable("AUTOMIX_MODEL_HUB_ROOT", {}); + if (overrideRoot.isNotEmpty()) { + return std::filesystem::path(overrideRoot.toWideCharPointer()); + } +#if defined(_WIN32) + const auto base = juce::File::getSpecialLocation(juce::File::windowsLocalAppData); +#else + const auto base = juce::File::getSpecialLocation(juce::File::userApplicationDataDirectory); +#endif + return toPath(base) / "AutoMixMaster" / "modelhub"; +} + +std::filesystem::path legacyModelHubRoot() { return std::filesystem::path("assets") / "modelhub"; } + +bool isInsideDirectory(const std::filesystem::path& candidate, const std::filesystem::path& root) { + std::error_code error; + const auto child = std::filesystem::weakly_canonical(std::filesystem::absolute(candidate, error), error); + const auto parent = std::filesystem::weakly_canonical(std::filesystem::absolute(root, error), error); + if (error || child.empty() || parent.empty()) { + return false; + } + const auto relative = child.lexically_relative(parent); + return !relative.empty() && relative != "." && *relative.begin() != ".."; +} + +ModelHubMigration migrateModelHub(const std::filesystem::path& legacyRoot, const std::filesystem::path& targetRoot) { + ModelHubMigration result; + std::error_code error; + if (!std::filesystem::is_directory(legacyRoot, error) || std::filesystem::exists(legacyRoot / kMigratedNote, error)) { + return result; + } + if (std::filesystem::equivalent(legacyRoot, targetRoot, error) && !error) { + return result; // already the same place (e.g. AUTOMIX_MODEL_HUB_ROOT points at it) + } + error.clear(); + if (std::filesystem::is_empty(legacyRoot, error)) { + return result; + } + result.attempted = true; + std::filesystem::create_directories(targetRoot, error); + if (error) { + result.error = "cannot create '" + targetRoot.string() + "': " + error.message(); + return result; + } + + // Pack directories first; the records describing them follow. + for (const auto& entry : std::filesystem::directory_iterator(legacyRoot, error)) { + if (!entry.is_directory(error)) { + continue; + } + const auto name = entry.path().filename(); + if (std::filesystem::exists(targetRoot / name, error)) { + result.keptInPlace.push_back(name.string()); + continue; + } + if (!movePath(entry.path(), targetRoot / name, result.error)) { + return result; // nothing is marked migrated, so the next start retries + } + ++result.movedPacks; + } + + // Registry: merge, then point every installPath at the pack's directory here. + auto registry = mergeRecords(readArray(targetRoot / kRegistryFile), readArray(legacyRoot / kRegistryFile)); + for (auto& item : registry) { + if (!item.is_object() || !item.contains("installPath")) { + continue; + } + auto installPath = std::filesystem::path(item.value("installPath", "")).lexically_normal(); + if (installPath.filename().empty()) { + installPath = installPath.parent_path(); // trailing separator + } + const auto relocated = targetRoot / installPath.filename(); + if (!installPath.filename().empty() && std::filesystem::is_directory(relocated, error)) { + item["installPath"] = relocated.string(); + } + } + if (!registry.empty() && !writeJson(targetRoot / kRegistryFile, registry)) { + result.error = "cannot write " + (targetRoot / kRegistryFile).string(); + return result; + } + const auto consents = mergeRecords(readArray(targetRoot / kConsentsFile), readArray(legacyRoot / kConsentsFile)); + if (!consents.empty() && !writeJson(targetRoot / kConsentsFile, consents)) { + result.error = "cannot write " + (targetRoot / kConsentsFile).string(); + return result; + } + std::filesystem::remove(legacyRoot / kRegistryFile, error); + std::filesystem::remove(legacyRoot / kConsentsFile, error); + + // The install log is append-only history: keep both. + if (std::filesystem::exists(legacyRoot / kInstallLogFile, error)) { + std::ifstream in(legacyRoot / kInstallLogFile, std::ios::binary); + std::ofstream out(targetRoot / kInstallLogFile, std::ios::binary | std::ios::app); + out << in.rdbuf(); + in.close(); + if (out) { + std::filesystem::remove(legacyRoot / kInstallLogFile, error); + } + } + + std::ofstream note(legacyRoot / kMigratedNote, std::ios::trunc); + note << "Downloaded models moved to " << targetRoot.string() << "\n" + << "AutoMixMaster no longer reads this folder.\n"; + return result; +} + +} // namespace automix::ai diff --git a/src/ai/ModelStorage.h b/src/ai/ModelStorage.h new file mode 100644 index 0000000..626c338 --- /dev/null +++ b/src/ai/ModelStorage.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include + +namespace automix::ai { + +// Where downloaded models and their records (install_registry.json, +// license_consents.json) live: %LOCALAPPDATA%\AutoMixMaster\modelhub on +// Windows, /AutoMixMaster/modelhub elsewhere, or +// AUTOMIX_MODEL_HUB_ROOT when set (portable installs, tests). Per user and +// writable, unlike the application folder under Program Files - and unlike the +// old cwd-relative "assets/modelhub", which moved with the working directory. +std::filesystem::path defaultModelHubRoot(); + +// The pre-2026-10 location, relative to the working directory. +std::filesystem::path legacyModelHubRoot(); + +struct ModelHubMigration { + bool attempted = false; // a legacy hub with content was found + int movedPacks = 0; + std::vector keptInPlace; // already present at the target; left untouched + std::string error; +}; + +// Moves a legacy hub into `targetRoot`: every pack directory, plus the hub's +// records. install_registry.json and license_consents.json are merged by +// modelId (the target's entry wins), so recorded licence consents survive, and +// every registry installPath is rewritten to the pack's new directory. Packs +// moved across volumes are copied, then deleted at the source. Leaves a +// MIGRATED.txt note behind and is a no-op once that note exists. +ModelHubMigration migrateModelHub(const std::filesystem::path& legacyRoot, const std::filesystem::path& targetRoot); + +// True when `candidate` is strictly inside `root` (after normalisation). Used to +// refuse deleting anything a registry entry points at outside the hub. +bool isInsideDirectory(const std::filesystem::path& candidate, const std::filesystem::path& root); + +} // namespace automix::ai diff --git a/src/app/controllers/ModelController.cpp b/src/app/controllers/ModelController.cpp index ec58ee5..ec7f64c 100644 --- a/src/app/controllers/ModelController.cpp +++ b/src/app/controllers/ModelController.cpp @@ -1,5 +1,7 @@ #include "app/controllers/ModelController.h" +#include "ai/ModelStorage.h" + #include #include #include @@ -16,7 +18,7 @@ namespace automix::app { namespace { std::filesystem::path defaultModelHubRoot() { - return std::filesystem::path("assets") / "modelhub"; + return ai::defaultModelHubRoot(); } nlohmann::json loadJsonIfPresent(const std::filesystem::path& path) { @@ -843,10 +845,17 @@ void ModelController::uninstallModel(const std::string& modelId, std::atomic_boo detail = "Model is not currently installed."; } else { std::error_code error; - if (!installPath.empty() && std::filesystem::exists(installPath, error)) { + // The path comes from install_registry.json: only ever delete inside + // the hub, whatever that file says. + const bool outsideHub = !installPath.empty() && !ai::isInsideDirectory(installPath, hubRoot); + if (outsideHub) { + detail = "Refusing to remove '" + installPath.string() + "': it is outside the model hub " + hubRoot.string(); + } else if (!installPath.empty() && std::filesystem::exists(installPath, error)) { std::filesystem::remove_all(installPath, error); } - if (error) { + if (outsideHub) { + // leave the registry entry: nothing was removed + } else if (error) { detail = "Failed removing install directory: " + installPath.string(); } else { registry.erase(matchIt); diff --git a/src/app/ui/MainLayout.cpp b/src/app/ui/MainLayout.cpp index 501f698..5352f6e 100644 --- a/src/app/ui/MainLayout.cpp +++ b/src/app/ui/MainLayout.cpp @@ -15,6 +15,7 @@ #include "app/ui/VerificationEngine.h" #include "ai/GpuRuntimePack.h" #include "ai/HuggingFaceModelHub.h" +#include "ai/ModelStorage.h" #include "ai/OnnxTensorInference.h" #include "renderers/RendererPipeline.h" #include "util/FileUtils.h" @@ -71,6 +72,24 @@ MainLayout::MainLayout() { // locked while loaded); finish that before anything can preload them. ai::GpuRuntimePack::completePendingRemoval(); + // Downloaded models used to live in a working-directory-relative + // assets/modelhub; move them (with their install registry and licence + // consents) to the per-user location once. The outcome is reported once the + // task history exists. + const auto modelHubMigration = ai::migrateModelHub(ai::legacyModelHubRoot(), ai::defaultModelHubRoot()); + if (modelHubMigration.attempted) { + juce::MessageManager::callAsync([safe = juce::Component::SafePointer(this), modelHubMigration] { + if (safe == nullptr || safe->taskOrchestrator_ == nullptr) { + return; + } + safe->taskOrchestrator_->appendHistory( + modelHubMigration.error.empty() + ? "Moved " + juce::String(modelHubMigration.movedPacks) + " downloaded model(s) to " + + juce::String(ai::defaultModelHubRoot().wstring().c_str()) + : "Model folder migration incomplete (will retry next start): " + juce::String(modelHubMigration.error)); + }); + } + // 1. Create UI components headerBar_ = std::make_unique(); heroWaveform_ = std::make_unique(); @@ -1909,7 +1928,7 @@ void MainLayout::updateRendererChainPreview() { // ───────────────────────────────────────────────────────────────── void MainLayout::refreshModelPacks() { - modelManager_.setRootPaths({std::filesystem::path("ModelPacks"), std::filesystem::path("assets/modelhub")}); + modelManager_.setRootPaths({ai::defaultModelHubRoot(), std::filesystem::path("ModelPacks")}); const auto packs = modelManager_.scan(); const auto pickDefaultForScope = [&](const std::string& scope) -> std::optional { diff --git a/tests/unit/TensorInferenceTests.cpp b/tests/unit/TensorInferenceTests.cpp index e73bdde..670245c 100644 --- a/tests/unit/TensorInferenceTests.cpp +++ b/tests/unit/TensorInferenceTests.cpp @@ -23,6 +23,7 @@ #include "ai/ModelCatalogValidator.h" #include "ai/ModelLicensePolicy.h" #include "ai/ModelPackLoader.h" +#include "ai/ModelStorage.h" #include "ai/OnnxExternalData.h" #include "ai/OnnxTensorInference.h" #include "ai/SeparationRunner.h" @@ -1549,4 +1550,77 @@ TEST_CASE("Vocal model GPU upgrade leaves non-matching packs alone", "[ai][gpu][ std::ofstream(root / "already_gpu" / "model.json") << R"({"model_file": "bs_roformer_ep317_sdr12.9755.onnx"})"; REQUIRE_FALSE(ai::upgradeBsRoformerForGpu(root).has_value()); std::filesystem::remove_all(root); +} +TEST_CASE("Tests use an isolated model hub, never the user's profile", "[ai][model-storage]") { + REQUIRE(ai::defaultModelHubRoot() == std::filesystem::temp_directory_path() / "automix_tests_modelhub"); +} + +TEST_CASE("Legacy model hub migrates with its registry and licence consents", "[ai][model-storage]") { + const auto base = std::filesystem::temp_directory_path() / "automix_modelhub_migration"; + std::filesystem::remove_all(base); + const auto legacy = base / "assets" / "modelhub"; + const auto target = base / "LocalAppData" / "modelhub"; + const auto writeText = [](const std::filesystem::path& path, const std::string& text) { + std::filesystem::create_directories(path.parent_path()); + std::ofstream(path, std::ios::binary) << text; + }; + const auto readJson = [](const std::filesystem::path& path) { + std::ifstream in(path); + return nlohmann::json::parse(in); + }; + + writeText(legacy / "packA" / "model.json", "{}"); + writeText(legacy / "packB" / "model.json", R"({"from": "legacy"})"); + writeText(legacy / "install_registry.json", + R"([{"modelId": "huggingface:a", "installPath": "assets/modelhub/packA"}, + {"modelId": "huggingface:b", "installPath": "C:\\old\\place\\packB\\"}])"); + writeText(legacy / "license_consents.json", R"([{"modelId": "huggingface:a", "license": "CC BY-NC 4.0"}])"); + writeText(legacy / "install_log.jsonl", "{\"event\": \"legacy\"}\n"); + // The target already has its own packB and records: those must win. + writeText(target / "packB" / "model.json", R"({"from": "target"})"); + writeText(target / "install_registry.json", + nlohmann::json::array({{{"modelId", "huggingface:b"}, {"installPath", (target / "packB").string()}}}).dump()); + writeText(target / "license_consents.json", R"([{"modelId": "huggingface:c", "license": "unknown"}])"); + writeText(target / "install_log.jsonl", "{\"event\": \"target\"}\n"); + + const auto migration = ai::migrateModelHub(legacy, target); + INFO(migration.error); + REQUIRE(migration.attempted); + REQUIRE(migration.error.empty()); + REQUIRE(migration.movedPacks == 1); + REQUIRE(migration.keptInPlace == std::vector{"packB"}); + REQUIRE(std::filesystem::exists(target / "packA" / "model.json")); + REQUIRE_FALSE(std::filesystem::exists(legacy / "packA")); + REQUIRE(readJson(target / "packB" / "model.json").at("from") == "target"); + + const auto registry = readJson(target / "install_registry.json"); + REQUIRE(registry.size() == 2); + for (const auto& entry : registry) { + const auto id = entry.at("modelId").get(); + INFO(id); + REQUIRE(std::filesystem::path(entry.at("installPath").get()) == + target / (id == "huggingface:a" ? "packA" : "packB")); + } + const auto consents = readJson(target / "license_consents.json"); + REQUIRE(consents.size() == 2); // c from the target, a carried over from the legacy hub + std::ifstream log(target / "install_log.jsonl"); + const std::string logText((std::istreambuf_iterator(log)), std::istreambuf_iterator()); + REQUIRE(logText.find("target") != std::string::npos); + REQUIRE(logText.find("legacy") != std::string::npos); + REQUIRE(std::filesystem::exists(legacy / "MIGRATED.txt")); + + REQUIRE_FALSE(ai::migrateModelHub(legacy, target).attempted); // once only + REQUIRE_FALSE(ai::migrateModelHub(base / "missing", target).attempted); + log.close(); + std::filesystem::remove_all(base); +} + +TEST_CASE("Hub containment check refuses look-alike and escaping paths", "[ai][model-storage]") { + const auto hub = std::filesystem::temp_directory_path() / "automix_contain" / "modelhub"; + REQUIRE(ai::isInsideDirectory(hub / "huggingface_x", hub)); + REQUIRE(ai::isInsideDirectory(hub / "a" / "b", hub)); + REQUIRE_FALSE(ai::isInsideDirectory(hub, hub)); + REQUIRE_FALSE(ai::isInsideDirectory(hub.parent_path() / "modelhub2" / "x", hub)); + REQUIRE_FALSE(ai::isInsideDirectory(hub / ".." / "elsewhere", hub)); + REQUIRE_FALSE(ai::isInsideDirectory(std::filesystem::temp_directory_path(), hub)); } \ No newline at end of file diff --git a/tests/unit/TestMain.cpp b/tests/unit/TestMain.cpp index ab474b9..4ad8394 100644 --- a/tests/unit/TestMain.cpp +++ b/tests/unit/TestMain.cpp @@ -1 +1,31 @@ // Catch2 main is provided by Catch2::Catch2WithMain. + +#include +#include +#include + +#include +#include + +namespace { + +// Downloaded models default to the user's real profile +// (%LOCALAPPDATA%\AutoMixMaster\modelhub). Point every test at a throwaway +// hub instead, so test runs can never write fake packs or consents there. +class IsolatedModelHub final : public Catch::EventListenerBase { + public: + using Catch::EventListenerBase::EventListenerBase; + + void testRunStarting(const Catch::TestRunInfo&) override { + const auto root = std::filesystem::temp_directory_path() / "automix_tests_modelhub"; +#if defined(_WIN32) + _putenv_s("AUTOMIX_MODEL_HUB_ROOT", root.string().c_str()); +#else + setenv("AUTOMIX_MODEL_HUB_ROOT", root.string().c_str(), 1); +#endif + } +}; + +} // namespace + +CATCH_REGISTER_LISTENER(IsolatedModelHub) diff --git a/tools/commands/ModelCommands.cpp b/tools/commands/ModelCommands.cpp index f09200c..2c6810e 100644 --- a/tools/commands/ModelCommands.cpp +++ b/tools/commands/ModelCommands.cpp @@ -11,6 +11,7 @@ #include "ai/GpuRuntimePack.h" #include "ai/HuggingFaceModelHub.h" #include "ai/ModelPackLoader.h" +#include "ai/ModelStorage.h" #include "ai/OnnxModelInference.h" #include "ai/OnnxTensorInference.h" #include "ai/StemSeparator.h" @@ -399,7 +400,7 @@ int commandModelInstall(const CommandArgs& args) { } automix::ai::HubInstallOptions options; - options.destinationRoot = argValue(args, "--dest").value_or("assets/modelhub"); + options.destinationRoot = argValue(args, "--dest").value_or(automix::ai::defaultModelHubRoot().string()); options.overwrite = hasFlag(args, "--force"); options.downloadReadme = !hasFlag(args, "--no-readme"); if (const auto tokenEnvArg = argValue(args, "--token-env"); tokenEnvArg.has_value()) { @@ -439,7 +440,7 @@ int commandModelInstall(const CommandArgs& args) { } int commandModelHealth(const CommandArgs& args) { - const std::filesystem::path root = argValue(args, "--root").value_or("assets/modelhub"); + const std::filesystem::path root = argValue(args, "--root").value_or(automix::ai::defaultModelHubRoot().string()); const auto registryPath = root / "install_registry.json"; const auto registry = loadJsonFile(registryPath); if (!registry.has_value() || !registry->is_array()) { @@ -623,7 +624,7 @@ int commandGpuRuntime(const std::vector& args) { return removed.removedNow ? 0 : 3; } if (action == "upgrade-models") { - const std::filesystem::path hub = argValue(args, "--hub").value_or("assets/modelhub"); + const std::filesystem::path hub = argValue(args, "--hub").value_or(automix::ai::defaultModelHubRoot().string()); const auto upgraded = automix::ai::upgradeBsRoformerForGpu(hub); std::cout << (upgraded.has_value() ? upgraded->message : std::string("Nothing to upgrade.")) << "\n"; return !upgraded.has_value() || upgraded->success ? 0 : 1; From 1d09f7cf31c60484d09699d306f371c454ede8cd Mon Sep 17 00:00:00 2001 From: Soficis Date: Thu, 1 Oct 2026 18:35:05 -0500 Subject: [PATCH 14/68] fix(ai): unstick stale model registry entries; correct quantized-CUDA claim - Uninstall refused any registry installPath outside the model hub, even when that directory no longer existed, so a stale entry (e.g. an unmigrated legacy assets/modelhub path) could never be removed. A missing directory is now just unregistered; an existing one outside the hub is still refused. - ControllerTests: hostile uninstall cases against the real ModelController (outside dir, ../.. traversal, NTFS junction to outside, stale entry, normal in-hub removal). - The quantized BS-RoFormer build is not "no faster than CPU" on CUDA: it takes 303-504 s vs 687 s on CPU for the 196 s track (the old figure was summed CPU time). fp32 on CUDA (73-91 s) is still 4-7x faster, so the GPU upgrade stands; comment and README corrected. - Remove model-hub metadata accidentally committed under assets/phaselimiter/assets (leaked there by the old cwd change). Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- src/ai/BsRoformerPack.h | 5 +- src/app/controllers/ModelController.cpp | 6 +- tests/unit/ControllerTests.cpp | 83 +++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 05b9bd4..8ab208e 100644 --- a/README.md +++ b/README.md @@ -215,13 +215,13 @@ has room for the model. Otherwise it runs on the CPU, with the reason in the log - **Model build.** Where a CUDA session opens and the GPU totals ≥ 11.5 GiB, the catalog installs BS-RoFormer's **fp32** build (its external weights are folded into one file at install time, because ONNX Runtime 1.30 cannot load that export otherwise); elsewhere the - smaller **quantized** build, which is faster on CPU but gains nothing on a GPU. Installing + smaller **quantized** build, which is faster on CPU but 4-7x slower than fp32 on a GPU. Installing the runtime pack upgrades an already-installed quantized model automatically. - **Memory.** fp32 needs 10 GiB of free GPU memory while it runs (measured peak 9.2 GiB). With less free, it runs on the CPU rather than spill into shared memory, which is slower. Measured on an RTX 5060 Ti (16 GB) for a 196 s track: fp32 on CUDA **66–91 s**, quantized on -CPU 687 s, quantized on CUDA no faster than CPU. +CPU 687 s, quantized on CUDA 303–504 s. --- diff --git a/src/ai/BsRoformerPack.h b/src/ai/BsRoformerPack.h index a0540aa..db7c8d7 100644 --- a/src/ai/BsRoformerPack.h +++ b/src/ai/BsRoformerPack.h @@ -15,8 +15,9 @@ inline constexpr const char* kBsRoformerRepoId = "xycld/BS-RoFormer-ONNX"; inline constexpr const char* kBsRoformerQuantizedFile = "bs_roformer_ep317_sdr12.9755_quantized_uint8.onnx"; // The fp32 graph with its weights in ".data". Installed instead of the // quantized build where a GPU session opens: the quantized build's integer ops -// have no CUDA kernels and gain nothing on a GPU (measured: no faster than CPU), -// while fp32 on an RTX 5060 Ti separated a 196 s track in 91 s vs 687 s on CPU. +// have no CUDA kernels (ORT inserts ~870 Memcpy nodes), so on CUDA it took +// 303-504 s for a 196 s track, while fp32 on an RTX 5060 Ti took 73-91 s +// (quantized on CPU: 687 s). // On CPU fp32 is ~45% slower than quantized, so CPU-only machines keep that. // The installer inlines the sidecar (inlineExternalData) because ONNX Runtime // cannot load this export with its weights external. diff --git a/src/app/controllers/ModelController.cpp b/src/app/controllers/ModelController.cpp index ec7f64c..3274abc 100644 --- a/src/app/controllers/ModelController.cpp +++ b/src/app/controllers/ModelController.cpp @@ -847,7 +847,11 @@ void ModelController::uninstallModel(const std::string& modelId, std::atomic_boo std::error_code error; // The path comes from install_registry.json: only ever delete inside // the hub, whatever that file says. - const bool outsideHub = !installPath.empty() && !ai::isInsideDirectory(installPath, hubRoot); + // A stale entry whose directory is already gone (e.g. an unmigrated + // legacy path) is only unregistered, so it cannot get stuck. + const bool outsideHub = !installPath.empty() && !ai::isInsideDirectory(installPath, hubRoot) && + std::filesystem::exists(installPath, error); + error.clear(); if (outsideHub) { detail = "Refusing to remove '" + installPath.string() + "': it is outside the model hub " + hubRoot.string(); } else if (!installPath.empty() && std::filesystem::exists(installPath, error)) { diff --git a/tests/unit/ControllerTests.cpp b/tests/unit/ControllerTests.cpp index db09ad9..2e94c12 100644 --- a/tests/unit/ControllerTests.cpp +++ b/tests/unit/ControllerTests.cpp @@ -1,7 +1,9 @@ #include #include +#include #include #include +#include #include #include #include @@ -13,6 +15,7 @@ #include #include +#include #include "ai/ModelManager.h" #include "app/controllers/ExportController.h" @@ -349,6 +352,86 @@ TEST_CASE("ModelController uninstall respects pre-set cancel", "[controllers][mo REQUIRE(cancelled.value()); } +TEST_CASE("ModelController uninstall only deletes inside the model hub", "[controllers][model][uninstall]") { + juce::ScopedJuceInitialiser_GUI juceInit; + + const auto base = uniqueTempPath("model_uninstall_guard"); + const auto hub = base / "modelhub"; + const auto outside = base / "precious"; + std::filesystem::create_directories(hub / "inside-pack"); + std::filesystem::create_directories(outside); + std::ofstream(hub / "inside-pack" / "model.onnx") << "x"; + std::ofstream(outside / "keep.txt") << "keep"; + + const auto writeRegistry = [&](const std::filesystem::path& installPath) { + nlohmann::json registry = nlohmann::json::array(); + registry.push_back({{"modelId", "victim"}, {"installPath", installPath.string()}}); + std::ofstream(hub / "install_registry.json", std::ios::trunc) << registry.dump(); + }; + const auto registryEntries = [&] { + std::ifstream in(hub / "install_registry.json"); + return nlohmann::json::parse(in).size(); + }; + + juce::ThreadPool pool(1); + automix::ai::ModelManager modelManager; + FakeModelHubState fakeState; + std::optional done; + automix::app::ModelController::Callbacks callbacks; + callbacks.onUninstallComplete = [&](const bool value) { done = value; }; + automix::app::ModelController controller(modelManager, pool, std::move(callbacks), makeFakeModelHubOps(fakeState)); + controller.setModelHubRoot(hub); + + const auto uninstall = [&] { + done.reset(); + std::atomic_bool cancelFlag {false}; + controller.uninstallModel("victim", cancelFlag); + REQUIRE(waitFor([&]() { return done.has_value(); })); + REQUIRE_FALSE(done.value()); + }; + + SECTION("an existing directory outside the hub survives and stays registered") { + writeRegistry(outside); + uninstall(); + REQUIRE(std::filesystem::exists(outside / "keep.txt")); + REQUIRE(registryEntries() == 1); + } + + SECTION("a traversal path that resolves outside the hub survives") { + writeRegistry(hub / "inside-pack" / ".." / ".." / "precious"); + uninstall(); + REQUIRE(std::filesystem::exists(outside / "keep.txt")); + REQUIRE(registryEntries() == 1); + } + + SECTION("a stale entry whose directory is gone is unregistered") { + writeRegistry(std::filesystem::path("assets") / "modelhub" / "long-gone-pack"); + uninstall(); + REQUIRE(registryEntries() == 0); + } + + SECTION("a pack inside the hub is removed") { + writeRegistry(hub / "inside-pack"); + uninstall(); + REQUIRE_FALSE(std::filesystem::exists(hub / "inside-pack")); + REQUIRE(registryEntries() == 0); + } + +#if defined(_WIN32) + SECTION("a junction inside the hub pointing outside is not followed") { + const auto junction = hub / "junction-pack"; + const auto command = "mklink /J \"" + junction.string() + "\" \"" + outside.string() + "\" >NUL"; + REQUIRE(std::system(command.c_str()) == 0); + writeRegistry(junction); + uninstall(); + REQUIRE(std::filesystem::exists(outside / "keep.txt")); + std::filesystem::remove(junction); // removes the link only + } +#endif + + std::filesystem::remove_all(base); +} + TEST_CASE("ExportController returns cancelled result when cancel flag is pre-set", "[controllers][export][cancel]") { juce::ScopedJuceInitialiser_GUI juceInit; From dc4882ddb777796ddb8b2b1499c6baa2bfd9afb7 Mon Sep 17 00:00:00 2001 From: Soficis Date: Thu, 1 Oct 2026 19:22:52 -0500 Subject: [PATCH 15/68] fix(ui): rescan model packs after the in-app GPU vocal-model upgrade The GUI upgrade replaced the quantized BS-RoFormer file with the fp32 build but left the scanned pack pointing at the deleted quantized file, so every separation until restart was rejected ('missing model file') and silently fell back to the frequency splitter. Found by driving the GUI: after the fix, the post-upgrade import ran tensor separation on the fp32 pack. Co-Authored-By: Claude Opus 5.5 --- src/app/ui/MainLayout.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/app/ui/MainLayout.cpp b/src/app/ui/MainLayout.cpp index 5352f6e..ddc2096 100644 --- a/src/app/ui/MainLayout.cpp +++ b/src/app/ui/MainLayout.cpp @@ -2387,6 +2387,9 @@ void MainLayout::upgradeVocalModelForGpu() { if (safe != nullptr) { safe->taskOrchestrator_->appendHistory( juce::String(upgraded->success ? upgraded->message : "Vocal model GPU upgrade failed: " + upgraded->message)); + if (upgraded->success) { + safe->refreshModelPacks(); // the scanned pack still names the deleted quantized file + } } }); }); From 05f6742be9368a63282fea57d83b42da46f50422 Mon Sep 17 00:00:00 2001 From: Soficis Date: Thu, 1 Oct 2026 20:56:29 -0500 Subject: [PATCH 16/68] feat(ai): unify session configuration and fix CoreML ANE options --- src/ai/GpuProvider.h | 86 ++++++++++++++++++++++++++++++ src/ai/OnnxModelInference.cpp | 89 +++++--------------------------- src/ai/OnnxTensorInference.cpp | 4 +- src/ai/OrtSessionProviders.h | 67 +++++++++++++++--------- tests/unit/GpuBenchmarkTests.cpp | 65 +++++++++++++++++++++++ 5 files changed, 208 insertions(+), 103 deletions(-) diff --git a/src/ai/GpuProvider.h b/src/ai/GpuProvider.h index b07e422..b44ed38 100644 --- a/src/ai/GpuProvider.h +++ b/src/ai/GpuProvider.h @@ -3,6 +3,7 @@ #include #include #include +#include #include namespace automix::ai::gpu { @@ -73,6 +74,91 @@ inline int providerPriority(const std::string& provider) { return static_cast(chain.size()); } +struct SessionTuning { + std::string hardwareTier = "standard"; + int intraOpThreads = 0; + int interOpThreads = 0; + bool memPattern = true; + bool cpuArena = true; + bool sequentialExecution = false; +}; + +inline SessionTuning sessionTuning(const std::string& provider, const int hardwareThreads) { + SessionTuning tuning; + const auto normalized = canonicalProviderName(provider); + const int clampedThreads = std::max(1, hardwareThreads); + if (clampedThreads <= 4) { + tuning.hardwareTier = "low"; + } else if (clampedThreads >= 12) { + tuning.hardwareTier = "high"; + } + + if (normalized == kProviderCuda) { + tuning.intraOpThreads = std::clamp(clampedThreads / 2, 1, 8); + tuning.interOpThreads = 1; + tuning.memPattern = false; + tuning.cpuArena = true; + tuning.sequentialExecution = false; + return tuning; + } + + if (normalized == kProviderDirectMl) { + tuning.intraOpThreads = std::clamp(clampedThreads / 2, 1, 4); + tuning.interOpThreads = 1; + tuning.memPattern = false; + tuning.cpuArena = false; + tuning.sequentialExecution = true; + return tuning; + } + + if (normalized == kProviderCoreMl || normalized == kProviderAne) { + tuning.intraOpThreads = std::clamp(clampedThreads / 2, 1, 4); + tuning.interOpThreads = 1; + tuning.memPattern = false; + tuning.cpuArena = false; + tuning.sequentialExecution = true; + return tuning; + } + + tuning.intraOpThreads = std::clamp(clampedThreads, 1, 16); + tuning.interOpThreads = std::clamp(clampedThreads / 2, 1, 8); + tuning.memPattern = true; + tuning.cpuArena = true; + tuning.sequentialExecution = false; + return tuning; +} + +// Option maps for execution providers. +// Valid CoreML options per coreml_options.cc@v1.30.0:55-64: +// MLComputeUnits, ModelFormat, RequireStaticInputShapes, EnableOnSubgraphs, +// SpecializationStrategy, ProfileComputePlan, AllowLowPrecisionAccumulationOnGPU, ModelCacheDirectory +inline std::unordered_map providerOptionMap(const std::string& rawProvider) { + std::unordered_map options; + const auto canonical = canonicalProviderName(rawProvider); + if (canonical == kProviderDirectMl) { + options["device_id"] = "0"; + return options; + } + if (canonical == kProviderCoreMl) { + options["ModelFormat"] = "MLProgram"; + options["MLComputeUnits"] = "ALL"; + return options; + } + if (canonical == kProviderAne) { + // Apple Neural Engine via CoreML with Neural Engine compute units. + // (Note: older docs suggested a unit-count key which ORT 1.30 rejects with 'Unknown option') + options["ModelFormat"] = "MLProgram"; + options["MLComputeUnits"] = "CPUAndNeuralEngine"; + return options; + } + if (canonical == kProviderOpenVino) { + // OpenVINO provider for Intel NPU / GPU: device_type=CPU_FP32 is a CPU device, and OpenVINO is not shipped + options["device_type"] = "CPU_FP32"; + return options; + } + return options; +} + // --- Optional ONNX Runtime capabilities ------------------------------------- // The two optional runtime paths -- a CUDA provider supplied by a plugin // library, and a per-GPU compiled-model cache -- are guarded at compile time by diff --git a/src/ai/OnnxModelInference.cpp b/src/ai/OnnxModelInference.cpp index 5041b92..a22e0a2 100644 --- a/src/ai/OnnxModelInference.cpp +++ b/src/ai/OnnxModelInference.cpp @@ -94,61 +94,7 @@ std::filesystem::path pickQuantizedVariant(const std::filesystem::path& modelPat return modelPath; } -#if AUTOMIX_HAS_NATIVE_ORT -struct SessionTuning { - std::string hardwareTier = "standard"; - int intraOpThreads = 0; - int interOpThreads = 0; - bool memPattern = true; - bool cpuArena = true; - bool sequentialExecution = false; -}; - -SessionTuning tuningForProvider(const std::string& provider, const int hardwareThreads) { - SessionTuning tuning; - const auto normalized = canonicalProviderName(provider); - const int clampedThreads = std::max(1, hardwareThreads); - if (clampedThreads <= 4) { - tuning.hardwareTier = "low"; - } else if (clampedThreads >= 12) { - tuning.hardwareTier = "high"; - } - - if (normalized == "cuda") { - tuning.intraOpThreads = std::clamp(clampedThreads / 2, 1, 8); - tuning.interOpThreads = 1; - tuning.memPattern = false; - tuning.cpuArena = true; - tuning.sequentialExecution = false; - return tuning; - } - if (normalized == "directml") { - tuning.intraOpThreads = std::clamp(clampedThreads / 2, 1, 4); - tuning.interOpThreads = 1; - tuning.memPattern = false; - tuning.cpuArena = false; - tuning.sequentialExecution = true; - return tuning; - } - - if (normalized == "coreml") { - tuning.intraOpThreads = std::clamp(clampedThreads / 2, 1, 4); - tuning.interOpThreads = 1; - tuning.memPattern = false; - tuning.cpuArena = false; - tuning.sequentialExecution = true; - return tuning; - } - - tuning.intraOpThreads = std::clamp(clampedThreads, 1, 16); - tuning.interOpThreads = std::clamp(clampedThreads / 2, 1, 8); - tuning.memPattern = true; - tuning.cpuArena = true; - tuning.sequentialExecution = false; - return tuning; -} -#endif #if AUTOMIX_HAS_NATIVE_ORT @@ -168,9 +114,7 @@ std::string makeProfilePrefix(const std::filesystem::path& modelPath) { return (base / (stem + "_" + timeTag)).string(); } -void appendExecutionProvider(Ort::SessionOptions& options, const std::string& provider) { - appendOrtExecutionProvider(options, canonicalProviderName(provider)); -} + std::vector discoverAvailableRuntimeProviders() { std::vector providers = {"cpu"}; @@ -371,28 +315,10 @@ bool OnnxModelInference::loadModel(const std::filesystem::path& modelPath) { nativeState->sessionOptions = std::make_unique(); const int hardwareThreads = static_cast(std::max(1u, std::thread::hardware_concurrency())); - auto tuning = tuningForProvider(activeExecutionProvider_, hardwareThreads); - if (intraOpThreads_ > 0) { - tuning.intraOpThreads = intraOpThreads_; - } - if (interOpThreads_ > 0) { - tuning.interOpThreads = interOpThreads_; - } - - nativeState->sessionOptions->SetIntraOpNumThreads(std::max(1, tuning.intraOpThreads)); - nativeState->sessionOptions->SetInterOpNumThreads(std::max(1, tuning.interOpThreads)); - nativeState->sessionOptions->SetExecutionMode( - tuning.sequentialExecution ? ExecutionMode::ORT_SEQUENTIAL : ExecutionMode::ORT_PARALLEL); + auto tuning = gpu::sessionTuning(activeExecutionProvider_, hardwareThreads); nativeState->sessionOptions->SetGraphOptimizationLevel( graphOptimizationEnabled_ ? GraphOptimizationLevel::ORT_ENABLE_ALL : GraphOptimizationLevel::ORT_DISABLE_ALL); - if (!tuning.memPattern) { - nativeState->sessionOptions->DisableMemPattern(); - } - if (!tuning.cpuArena) { - nativeState->sessionOptions->DisableCpuMemArena(); - } - if (profilingEnabled_) { nativeState->profilingPrefix = makeProfilePrefix(modelPath_); nativeState->sessionOptions->EnableProfiling(nativeState->profilingPrefix.c_str()); @@ -402,7 +328,7 @@ bool OnnxModelInference::loadModel(const std::filesystem::path& modelPath) { GpuRuntimePack::preload(); // per-user CUDA libraries, if installed } try { - appendExecutionProvider(*nativeState->sessionOptions, activeExecutionProvider_); + configureSessionForProvider(*nativeState->sessionOptions, activeExecutionProvider_, hardwareThreads); } catch (const std::exception&) { providerFallbacks_.fetch_add(1); { @@ -410,6 +336,15 @@ bool OnnxModelInference::loadModel(const std::filesystem::path& modelPath) { failedProviders_.push_back(activeExecutionProvider_); } activeExecutionProvider_ = gpu::kProviderCpu; + tuning = gpu::sessionTuning(activeExecutionProvider_, hardwareThreads); + configureSessionForProvider(*nativeState->sessionOptions, activeExecutionProvider_, hardwareThreads); + } + + if (intraOpThreads_ > 0) { + nativeState->sessionOptions->SetIntraOpNumThreads(intraOpThreads_); + } + if (interOpThreads_ > 0) { + nativeState->sessionOptions->SetInterOpNumThreads(interOpThreads_); } #if defined(_WIN32) diff --git a/src/ai/OnnxTensorInference.cpp b/src/ai/OnnxTensorInference.cpp index 3c16147..b4c8f91 100644 --- a/src/ai/OnnxTensorInference.cpp +++ b/src/ai/OnnxTensorInference.cpp @@ -197,7 +197,7 @@ bool gpuTensorSessionAvailable(std::string* providerOut) { try { Ort::Env env(ORT_LOGGING_LEVEL_ERROR, "AutoMixMasterGpuProbe"); Ort::SessionOptions options; - appendOrtExecutionProvider(options, candidate); + configureSessionForProvider(options, candidate); Ort::Session session(env, model.data(), model.size(), options); return candidate; } catch (...) { @@ -280,7 +280,7 @@ bool OnnxTensorInference::loadModel(const std::filesystem::path& modelPath) { attempt->env = std::make_unique(ORT_LOGGING_LEVEL_WARNING, "AutoMixMasterTensor"); Ort::SessionOptions options; options.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL); - appendOrtExecutionProvider(options, candidate); + configureSessionForProvider(options, candidate); #if defined(_WIN32) attempt->session = std::make_unique(*attempt->env, modelPath.wstring().c_str(), options); #else diff --git a/src/ai/OrtSessionProviders.h b/src/ai/OrtSessionProviders.h index d26fa81..192e7ab 100644 --- a/src/ai/OrtSessionProviders.h +++ b/src/ai/OrtSessionProviders.h @@ -12,21 +12,43 @@ namespace automix::ai { -// Appends the ONNX Runtime execution provider for a canonical provider name -// (see gpu::canonicalProviderName). "cpu", "auto" and "" append nothing: the -// CPU provider is always present. Throws Ort::Exception when this runtime -// build does not contain the provider; a provider whose own dependencies -// (CUDA, cuDNN) are missing may instead only fail at session creation. -inline void appendOrtExecutionProvider(Ort::SessionOptions& options, const std::string& canonical) { - if (canonical == gpu::kProviderCpu || canonical == "auto" || canonical.empty()) { +// Configures an Ort::SessionOptions with provider-specific tuning (threads, memory pattern, +// arena, execution mode) AND appends the execution provider. +// This unifies session configuration for both OnnxModelInference and OnnxTensorInference. +inline void configureSessionForProvider(Ort::SessionOptions& options, + const std::string& canonical, + int hardwareThreads = 0) { + const auto canon = gpu::canonicalProviderName(canonical); + const auto tuning = gpu::sessionTuning(canon, hardwareThreads); + + if (tuning.intraOpThreads > 0) { + options.SetIntraOpNumThreads(std::max(1, tuning.intraOpThreads)); + } + if (tuning.interOpThreads > 0) { + options.SetInterOpNumThreads(std::max(1, tuning.interOpThreads)); + } + options.SetExecutionMode( + tuning.sequentialExecution ? ExecutionMode::ORT_SEQUENTIAL : ExecutionMode::ORT_PARALLEL); + + if (!tuning.memPattern) { + options.DisableMemPattern(); + } else { + options.EnableMemPattern(); + } + if (!tuning.cpuArena) { + options.DisableCpuMemArena(); + } else { + options.EnableCpuMemArena(); + } + + if (canon == gpu::kProviderCpu || canon == "auto" || canon.empty()) { return; } - std::unordered_map providerOptions; // CUDA and TensorRT are not accepted by the generic string-keyed // AppendExecutionProvider(); they need their dedicated V2 option objects. // In a build without them, Create*ProviderOptions fails and throws here. - if (canonical == gpu::kProviderCuda) { + if (canon == gpu::kProviderCuda) { const auto& api = Ort::GetApi(); OrtCUDAProviderOptionsV2* cuda = nullptr; Ort::ThrowOnError(api.CreateCUDAProviderOptions(&cuda)); @@ -41,7 +63,7 @@ inline void appendOrtExecutionProvider(Ort::SessionOptions& options, const std:: options.AppendExecutionProvider_CUDA_V2(*cuda); return; } - if (canonical == "tensorrt") { + if (canon == "tensorrt") { const auto& api = Ort::GetApi(); OrtTensorRTProviderOptionsV2* tensorrt = nullptr; Ort::ThrowOnError(api.CreateTensorRTProviderOptions(&tensorrt)); @@ -50,29 +72,26 @@ inline void appendOrtExecutionProvider(Ort::SessionOptions& options, const std:: options.AppendExecutionProvider_TensorRT_V2(*tensorrt); return; } - if (canonical == gpu::kProviderDirectMl) { - providerOptions["device_id"] = "0"; + + const auto providerOptions = gpu::providerOptionMap(canon); + if (canon == gpu::kProviderDirectMl) { options.AppendExecutionProvider("DML", providerOptions); return; } - if (canonical == gpu::kProviderCoreMl) { - providerOptions["ModelFormat"] = "MLProgram"; - options.AppendExecutionProvider("CoreML", providerOptions); - return; - } - if (canonical == gpu::kProviderAne) { - // Apple Neural Engine via CoreML with ANE override - providerOptions["ModelFormat"] = "MLProgram"; - providerOptions["ANEUnits"] = "256"; + if (canon == gpu::kProviderCoreMl || canon == gpu::kProviderAne) { options.AppendExecutionProvider("CoreML", providerOptions); return; } - if (canonical == gpu::kProviderOpenVino) { - // OpenVINO provider for Intel NPU / GPU - providerOptions["device_type"] = "CPU_FP32"; + if (canon == gpu::kProviderOpenVino) { + // OpenVINO provider for Intel NPU / GPU: device_type=CPU_FP32 is a CPU device, and OpenVINO is not shipped options.AppendExecutionProvider("OpenVINO", providerOptions); return; } } +// Deprecated: prefer configureSessionForProvider() to ensure session tuning is applied. +inline void appendOrtExecutionProvider(Ort::SessionOptions& options, const std::string& canonical) { + configureSessionForProvider(options, canonical, 0); +} + } // namespace automix::ai diff --git a/tests/unit/GpuBenchmarkTests.cpp b/tests/unit/GpuBenchmarkTests.cpp index b4419be..81d5b75 100644 --- a/tests/unit/GpuBenchmarkTests.cpp +++ b/tests/unit/GpuBenchmarkTests.cpp @@ -136,6 +136,71 @@ TEST_CASE("GpuProvider platform preferred provider", "[gpu][provider]") { preferred == "cuda" || preferred == "directml")); } +TEST_CASE("GpuProvider providerOptionMap and sessionTuning", "[gpu][provider]") { + using namespace automix::ai::gpu; + + // 1. providerOptionMap("ane") + const auto aneOpts = providerOptionMap("ane"); + CHECK(aneOpts.at("ModelFormat") == "MLProgram"); + CHECK(aneOpts.at("MLComputeUnits") == "CPUAndNeuralEngine"); + + // Every CoreML/ANE key must be in the valid set documented in coreml_options.cc@v1.30.0:55-64 + const std::vector validCoreMlKeys = { + "MLComputeUnits", + "ModelFormat", + "RequireStaticInputShapes", + "EnableOnSubgraphs", + "SpecializationStrategy", + "ProfileComputePlan", + "AllowLowPrecisionAccumulationOnGPU", + "ModelCacheDirectory" + }; + for (const auto& [k, v] : aneOpts) { + CHECK((std::find(validCoreMlKeys.begin(), validCoreMlKeys.end(), k) != validCoreMlKeys.end())); + } + + // 2. providerOptionMap("coreml") + const auto coremlOpts = providerOptionMap("coreml"); + CHECK(coremlOpts.at("ModelFormat") == "MLProgram"); + CHECK(coremlOpts.at("MLComputeUnits") == "ALL"); + for (const auto& [k, v] : coremlOpts) { + CHECK((std::find(validCoreMlKeys.begin(), validCoreMlKeys.end(), k) != validCoreMlKeys.end())); + } + + // 3. sessionTuning("directml", n) gives sequential execution with mem pattern off + const auto dmlTuning = sessionTuning("directml", 8); + CHECK(dmlTuning.sequentialExecution == true); + CHECK(dmlTuning.memPattern == false); + CHECK(dmlTuning.cpuArena == false); + CHECK(dmlTuning.interOpThreads == 1); + CHECK(dmlTuning.intraOpThreads == 4); + + // 4. sessionTuning("cuda" | "cpu", n) golden values + const auto cudaTuning = sessionTuning("cuda", 8); + CHECK(cudaTuning.sequentialExecution == false); + CHECK(cudaTuning.memPattern == false); + CHECK(cudaTuning.cpuArena == true); + CHECK(cudaTuning.interOpThreads == 1); + CHECK(cudaTuning.intraOpThreads == 4); + CHECK(cudaTuning.hardwareTier == "standard"); + + const auto cpuTuningLow = sessionTuning("cpu", 2); + CHECK(cpuTuningLow.hardwareTier == "low"); + CHECK(cpuTuningLow.intraOpThreads == 2); + CHECK(cpuTuningLow.interOpThreads == 1); + CHECK(cpuTuningLow.memPattern == true); + CHECK(cpuTuningLow.cpuArena == true); + CHECK(cpuTuningLow.sequentialExecution == false); + + const auto cpuTuningHigh = sessionTuning("cpu", 16); + CHECK(cpuTuningHigh.hardwareTier == "high"); + CHECK(cpuTuningHigh.intraOpThreads == 16); + CHECK(cpuTuningHigh.interOpThreads == 8); + CHECK(cpuTuningHigh.memPattern == true); + CHECK(cpuTuningHigh.cpuArena == true); + CHECK(cpuTuningHigh.sequentialExecution == false); +} + // ─── T3.4: detectAvailableProviders ───────────────────────────────────────── TEST_CASE("OnnxModelInference detectAvailableProviders", "[gpu][detect]") { From 22935dbc5b103492ca9bb36c9599467084fd5999 Mon Sep 17 00:00:00 2001 From: Soficis Date: Thu, 1 Oct 2026 21:09:26 -0500 Subject: [PATCH 17/68] feat(ai): add webgpu pure logic, update priority chain order, and plumb gpu allow-lists Update priority chain to include webgpu (ANE, CoreML, CUDA, WebGPU, OpenVINO, DirectML, CPU) and deliberately update pinned-order tests. Map WebGpuExecutionProvider/webgpu/wgpu canonical names and set Windows platform-preferred provider to webgpu. Implement per-pack gpu_providers allow-list filtering for tensor candidates, plumb allow-list through ModelPackLoader -> StemSeparator -> OnnxTensorInference, and restrict BS-RoFormer to CUDA until measured. Update macOS version requirements in GpuCapabilityDetector. --- src/ai/BsRoformerPack.h | 7 +++++ src/ai/GpuCapabilityDetector.cpp | 25 ++++++++++++---- src/ai/GpuProvider.h | 6 +++- src/ai/ModelCatalogValidator.cpp | 1 + src/ai/ModelPackLoader.cpp | 5 ++++ src/ai/ModelPackLoader.h | 4 +++ src/ai/OnnxModelInference.cpp | 20 ++++++++++--- src/ai/OnnxTensorInference.cpp | 28 +++++++++++++----- src/ai/OnnxTensorInference.h | 6 +++- src/ai/StemSeparator.cpp | 1 + tests/unit/AiExtensionTests.cpp | 2 +- tests/unit/GpuBenchmarkTests.cpp | 51 +++++++++++++++++++++++++------- 12 files changed, 127 insertions(+), 29 deletions(-) diff --git a/src/ai/BsRoformerPack.h b/src/ai/BsRoformerPack.h index db7c8d7..52a57c1 100644 --- a/src/ai/BsRoformerPack.h +++ b/src/ai/BsRoformerPack.h @@ -34,6 +34,13 @@ inline constexpr const char* kBsRoformerFp32IntendedUse = "Vocal separation (BS-RoFormer). The graph produces vocals only; the instrumental stem is the " "residual mix - vocals, not a second separation. fp32 build, installed for GPU (CUDA) inference."; +// BS-RoFormer is restricted to CUDA until Task 8 measures other EPs. +// Peaks at 9.4 GiB device memory on GPU. +inline const std::vector& bsRoformerGpuProviders() { + static const std::vector providers = {"cuda"}; + return providers; +} + // Catalog form of the pack's tensor contract (spec section 6). Tensor names are // omitted because the repo does not publish them; the install-time probe fills // them in, and until then checkTensorContract() matches positionally. diff --git a/src/ai/GpuCapabilityDetector.cpp b/src/ai/GpuCapabilityDetector.cpp index c1930e4..124e2a6 100644 --- a/src/ai/GpuCapabilityDetector.cpp +++ b/src/ai/GpuCapabilityDetector.cpp @@ -239,11 +239,26 @@ bool GpuCapabilityDetector::hasDirectMlSupport() { #endif } +#if defined(__APPLE__) +#include + +static int getMacOsMajorVersion() { + char osversion[64] = {0}; + size_t len = sizeof(osversion) - 1; + if (sysctlbyname("kern.osproductversion", osversion, &len, nullptr, 0) == 0) { + int major = 0; + if (std::sscanf(osversion, "%d", &major) == 1) { + return major; + } + } + return 0; +} +#endif + bool GpuCapabilityDetector::hasCoreMlSupport() { #if defined(__APPLE__) - // CoreML is available on macOS 10.13+ (High Sierra) and later. - // At compile time we assume the deployment target is at least that. - return true; + // CoreML requires macOS >= 12 + return getMacOsMajorVersion() >= 12; #else return false; #endif @@ -251,8 +266,8 @@ bool GpuCapabilityDetector::hasCoreMlSupport() { bool GpuCapabilityDetector::hasAneSupport() { #if defined(__APPLE__) && defined(__arm64__) - // Apple Neural Engine is available on M1 and later. - return true; + // Apple Neural Engine requires Apple Silicon and macOS >= 13 + return getMacOsMajorVersion() >= 13; #else return false; #endif diff --git a/src/ai/GpuProvider.h b/src/ai/GpuProvider.h index b44ed38..1e2801a 100644 --- a/src/ai/GpuProvider.h +++ b/src/ai/GpuProvider.h @@ -12,6 +12,7 @@ inline constexpr const char* kProviderCpu = "cpu"; inline constexpr const char* kProviderAne = "ane"; inline constexpr const char* kProviderCoreMl = "coreml"; inline constexpr const char* kProviderCuda = "cuda"; +inline constexpr const char* kProviderWebGpu = "webgpu"; inline constexpr const char* kProviderOpenVino = "openvino"; inline constexpr const char* kProviderDirectMl = "directml"; @@ -20,6 +21,7 @@ inline const std::vector& providerPriorityChain() { kProviderAne, kProviderCoreMl, kProviderCuda, + kProviderWebGpu, kProviderOpenVino, kProviderDirectMl, kProviderCpu, @@ -37,6 +39,8 @@ inline std::string canonicalProviderName(const std::string& raw) { return kProviderAne; if (lower.find("coreml") != std::string::npos) return kProviderCoreMl; if (lower.find("cuda") != std::string::npos) return kProviderCuda; + if (lower.find("webgpu") != std::string::npos || lower.find("wgpu") != std::string::npos) + return kProviderWebGpu; if (lower.find("openvino") != std::string::npos || lower.find("vino") != std::string::npos) return kProviderOpenVino; if (lower.find("dml") != std::string::npos || lower.find("directml") != std::string::npos) @@ -53,7 +57,7 @@ inline std::string platformPreferredProvider() { #elif defined(__APPLE__) return kProviderCoreMl; #elif defined(_WIN32) - return kProviderDirectMl; + return kProviderWebGpu; #else return kProviderCuda; #endif diff --git a/src/ai/ModelCatalogValidator.cpp b/src/ai/ModelCatalogValidator.cpp index e8aced4..5fc4813 100644 --- a/src/ai/ModelCatalogValidator.cpp +++ b/src/ai/ModelCatalogValidator.cpp @@ -271,6 +271,7 @@ bool writeTurnkeyModelPackManifest(const std::filesystem::path& installPath, if (model.repoId == kBsRoformerRepoId) { const bool fp32 = modelFileName == kBsRoformerFp32File; manifest["intended_use"] = fp32 ? kBsRoformerFp32IntendedUse : kBsRoformerIntendedUse; + manifest["gpu_providers"] = bsRoformerGpuProviders(); if (fp32) { manifest["gpu_memory_mb"] = kBsRoformerFp32GpuMemoryMb; } diff --git a/src/ai/ModelPackLoader.cpp b/src/ai/ModelPackLoader.cpp index 7d72619..3e84fa3 100644 --- a/src/ai/ModelPackLoader.cpp +++ b/src/ai/ModelPackLoader.cpp @@ -514,6 +514,11 @@ std::optional ModelPackLoader::load(const std::filesystem::path& dire if (json.contains("gpu_memory_mb") && json.at("gpu_memory_mb").is_number_unsigned()) { pack.gpuMemoryMb = json.at("gpu_memory_mb").get(); } + if (json.contains("gpu_providers") && json.at("gpu_providers").is_array()) { + pack.gpuProviders = json.at("gpu_providers").get>(); + } else if (json.contains("gpuProviders") && json.at("gpuProviders").is_array()) { + pack.gpuProviders = json.at("gpuProviders").get>(); + } if (pack.featureSchemaVersion.empty() && json.contains("feature_schema") && json.at("feature_schema").is_object()) { pack.featureSchemaVersion = json.at("feature_schema").value("version", ""); diff --git a/src/ai/ModelPackLoader.h b/src/ai/ModelPackLoader.h index 12d13bd..084dca6 100644 --- a/src/ai/ModelPackLoader.h +++ b/src/ai/ModelPackLoader.h @@ -103,6 +103,10 @@ struct ModelPack { // memory is below it, GPU execution would spill into system memory, so // callers run on CPU instead. Absent: no known requirement. std::optional gpuMemoryMb; + // Per-pack GPU provider allow-list (e.g. {"cuda"}). When non-empty, GPU + // candidates outside this list are ignored and fallback to CPU. Empty means + // any runtime-supported GPU provider in the priority chain is allowed. + std::vector gpuProviders; std::filesystem::path rootPath; }; diff --git a/src/ai/OnnxModelInference.cpp b/src/ai/OnnxModelInference.cpp index a22e0a2..59f3b6c 100644 --- a/src/ai/OnnxModelInference.cpp +++ b/src/ai/OnnxModelInference.cpp @@ -261,10 +261,22 @@ bool OnnxModelInference::loadModel(const std::filesystem::path& modelPath) { } if (availableExecutionProviders_.empty()) { - availableExecutionProviders_.push_back("cpu"); - const auto platformProvider = platformPreferredProvider(); - if (platformProvider != "cpu") { - availableExecutionProviders_.push_back(platformProvider); + std::vector runtime; +#if AUTOMIX_HAS_NATIVE_ORT + try { + for (const auto& p : Ort::GetAvailableProviders()) { + runtime.push_back(canonicalProviderName(p)); + } + } catch (...) { + } +#endif + for (const auto& p : gpu::providerPriorityChain()) { + if (std::find(runtime.begin(), runtime.end(), p) != runtime.end() || p == gpu::kProviderCpu) { + availableExecutionProviders_.push_back(p); + } + } + if (availableExecutionProviders_.empty()) { + availableExecutionProviders_.push_back(gpu::kProviderCpu); } } diff --git a/src/ai/OnnxTensorInference.cpp b/src/ai/OnnxTensorInference.cpp index b4c8f91..b5c3681 100644 --- a/src/ai/OnnxTensorInference.cpp +++ b/src/ai/OnnxTensorInference.cpp @@ -90,7 +90,8 @@ bool isConcrete(const std::vector& dims) { } // namespace std::vector tensorProviderCandidates(const std::string& requested, - const std::vector& runtimeProviders) { + const std::vector& runtimeProviders, + const std::vector& allowList) { std::vector reported; for (const auto& provider : runtimeProviders) { reported.push_back(gpu::canonicalProviderName(provider)); @@ -99,19 +100,28 @@ std::vector tensorProviderCandidates(const std::string& requested, return std::find(reported.begin(), reported.end(), provider) != reported.end(); }; + std::vector canonicalAllow; + for (const auto& a : allowList) { + canonicalAllow.push_back(gpu::canonicalProviderName(a)); + } + const auto isAllowed = [&canonicalAllow](const std::string& provider) { + if (canonicalAllow.empty()) return true; + return std::find(canonicalAllow.begin(), canonicalAllow.end(), provider) != canonicalAllow.end(); + }; + std::vector candidates; const auto wanted = gpu::canonicalProviderName(requested.empty() ? std::string("auto") : requested); if (wanted == gpu::kProviderCpu) { return {gpu::kProviderCpu}; } - // A named GPU provider is tried first; if this runtime lacks it, the request - // still means "a GPU" (e.g. a DirectML preference on a CUDA build), so the - // remaining reported GPU providers follow before CPU. - if (wanted != "auto" && isReported(wanted)) { + // A named GPU provider is tried first; if this runtime lacks it or it's not allowed, + // the request still means "a GPU" (e.g. a DirectML preference on a CUDA build), + // so the remaining reported GPU providers follow before CPU. + if (wanted != "auto" && isReported(wanted) && isAllowed(wanted)) { candidates.push_back(wanted); } for (const auto& provider : gpu::providerPriorityChain()) { - if (provider != gpu::kProviderCpu && provider != wanted && isReported(provider)) { + if (provider != gpu::kProviderCpu && provider != wanted && isReported(provider) && isAllowed(provider)) { candidates.push_back(provider); } } @@ -231,6 +241,10 @@ void OnnxTensorInference::setTensorContract(std::optional contra void OnnxTensorInference::setExecutionProvider(std::string provider) { requestedProvider_ = std::move(provider); } +void OnnxTensorInference::setGpuProviderAllowList(std::vector allowList) { + gpuProviderAllowList_ = std::move(allowList); +} + std::string OnnxTensorInference::activeExecutionProvider() const { return activeProvider_; } bool OnnxTensorInference::isAvailable() const { return nativeState_ != nullptr; } @@ -267,7 +281,7 @@ bool OnnxTensorInference::loadModel(const std::filesystem::path& modelPath) { if (gpu::canonicalProviderName(requestedProvider_) != gpu::kProviderCpu) { GpuRuntimePack::preload(); // CUDA libraries installed per user, if any } - const auto candidates = tensorProviderCandidates(requestedProvider_, runtimeProviders); + const auto candidates = tensorProviderCandidates(requestedProvider_, runtimeProviders, gpuProviderAllowList_); std::unique_ptr state; std::vector inputs; diff --git a/src/ai/OnnxTensorInference.h b/src/ai/OnnxTensorInference.h index cec39b6..02138d7 100644 --- a/src/ai/OnnxTensorInference.h +++ b/src/ai/OnnxTensorInference.h @@ -18,7 +18,8 @@ namespace automix::ai { // Reported is not the same as usable (CUDA may lack its DLLs), which is why // loadModel() treats every non-CPU entry as an attempt that may fail. std::vector tensorProviderCandidates(const std::string& requested, - const std::vector& runtimeProviders); + const std::vector& runtimeProviders, + const std::vector& allowList = {}); // True when a GPU tensor session can actually be opened here, proven by // opening one on a tiny in-memory graph (a reported provider can still lack @@ -45,6 +46,8 @@ class OnnxTensorInference final : public ITensorInference { // "auto" (default), "cpu", or a provider name such as "cuda". Applies to // the next loadModel(). void setExecutionProvider(std::string provider); + // Restricts GPU candidates to this list when non-empty. CPU is always kept. + void setGpuProviderAllowList(std::vector allowList); // Provider the loaded session actually runs on ("cpu", "cuda", ...), or // empty when nothing is loaded. [[nodiscard]] std::string activeExecutionProvider() const; @@ -70,6 +73,7 @@ class OnnxTensorInference final : public ITensorInference { std::optional contract_; std::string requestedProvider_ = "auto"; + std::vector gpuProviderAllowList_; std::string activeProvider_; std::vector inputs_; std::vector outputs_; diff --git a/src/ai/StemSeparator.cpp b/src/ai/StemSeparator.cpp index 2382abc..aec4d3d 100644 --- a/src/ai/StemSeparator.cpp +++ b/src/ai/StemSeparator.cpp @@ -1063,6 +1063,7 @@ StemSeparator::SeparationResult runTensorSeparation(const std::filesystem::path& } for (const auto& requested : {firstProvider, std::string("cpu")}) { OnnxTensorInference inference; + inference.setGpuProviderAllowList(pack->gpuProviders); inference.setTensorContract(pack->tensorContract); inference.setExecutionProvider(requested); if (!inference.loadModel(modelRoot / pack->modelFile)) { diff --git a/tests/unit/AiExtensionTests.cpp b/tests/unit/AiExtensionTests.cpp index e3071f1..ca8c496 100644 --- a/tests/unit/AiExtensionTests.cpp +++ b/tests/unit/AiExtensionTests.cpp @@ -1220,7 +1220,7 @@ TEST_CASE("Optional ORT provider plugin and compiled-model cache policy needs no REQUIRE(gpu::compiledModelCacheKey("not-a-digest", "cuda", "sm_90", "driver", "1.30.0").empty()); const auto& chain = gpu::providerPriorityChain(); - REQUIRE(chain.size() == 6); + REQUIRE(chain.size() == 7); REQUIRE(chain.front() == gpu::kProviderAne); REQUIRE(chain.back() == gpu::kProviderCpu); } diff --git a/tests/unit/GpuBenchmarkTests.cpp b/tests/unit/GpuBenchmarkTests.cpp index 81d5b75..48685a1 100644 --- a/tests/unit/GpuBenchmarkTests.cpp +++ b/tests/unit/GpuBenchmarkTests.cpp @@ -10,6 +10,7 @@ #include "ai/GpuProvider.h" #include "ai/OnnxModelInference.h" +#include "ai/OnnxTensorInference.h" namespace automix { namespace ai { namespace test { @@ -78,6 +79,9 @@ TEST_CASE("GpuProvider canonical name mapping", "[gpu][provider]") { CHECK(canonicalProviderName("CpuExecutionProvider") == "cpu"); CHECK(canonicalProviderName("CUDA") == "cuda"); CHECK(canonicalProviderName("CudaExecutionProvider") == "cuda"); + CHECK(canonicalProviderName("WebGpu") == "webgpu"); + CHECK(canonicalProviderName("WebGpuExecutionProvider") == "webgpu"); + CHECK(canonicalProviderName("wgpu") == "webgpu"); CHECK(canonicalProviderName("DML") == "directml"); CHECK(canonicalProviderName("DirectML") == "directml"); CHECK(canonicalProviderName("CoreML") == "coreml"); @@ -95,25 +99,27 @@ TEST_CASE("GpuProvider priority chain order", "[gpu][provider]") { const auto& chain = providerPriorityChain(); - // Chain should be: ANE > CoreML > CUDA > OpenVINO > DirectML > CPU - REQUIRE(chain.size() == 6); + // Chain should be: ANE > CoreML > CUDA > WebGPU > OpenVINO > DirectML > CPU + REQUIRE(chain.size() == 7); CHECK(chain[0] == "ane"); CHECK(chain[1] == "coreml"); CHECK(chain[2] == "cuda"); - CHECK(chain[3] == "openvino"); - CHECK(chain[4] == "directml"); - CHECK(chain[5] == "cpu"); + CHECK(chain[3] == "webgpu"); + CHECK(chain[4] == "openvino"); + CHECK(chain[5] == "directml"); + CHECK(chain[6] == "cpu"); // Verify priority values (lower = higher priority) CHECK(providerPriority("ane") == 0); CHECK(providerPriority("coreml") == 1); CHECK(providerPriority("cuda") == 2); - CHECK(providerPriority("openvino") == 3); - CHECK(providerPriority("directml") == 4); - CHECK(providerPriority("cpu") == 5); + CHECK(providerPriority("webgpu") == 3); + CHECK(providerPriority("openvino") == 4); + CHECK(providerPriority("directml") == 5); + CHECK(providerPriority("cpu") == 6); // Unknown providers have lowest priority - CHECK(providerPriority("tensorrt") == 6); + CHECK(providerPriority("tensorrt") == 7); } TEST_CASE("GpuProvider isGpuProvider classification", "[gpu][provider]") { @@ -121,6 +127,7 @@ TEST_CASE("GpuProvider isGpuProvider classification", "[gpu][provider]") { CHECK_FALSE(isGpuProvider("cpu")); CHECK(isGpuProvider("cuda")); + CHECK(isGpuProvider("webgpu")); CHECK(isGpuProvider("directml")); CHECK(isGpuProvider("coreml")); CHECK(isGpuProvider("ane")); @@ -133,7 +140,31 @@ TEST_CASE("GpuProvider platform preferred provider", "[gpu][provider]") { const auto preferred = platformPreferredProvider(); // Should return one of the known provider names CHECK((preferred == "ane" || preferred == "coreml" || - preferred == "cuda" || preferred == "directml")); + preferred == "cuda" || preferred == "webgpu")); +#if defined(_WIN32) + CHECK(preferred == "webgpu"); +#endif +} + +TEST_CASE("tensorProviderCandidates allow-list filtering", "[gpu][tensor]") { + // 1. Empty allow-list preserves all reported GPU candidates and keeps CPU last + const auto c1 = tensorProviderCandidates("auto", {"cuda", "webgpu", "cpu"}, {}); + const std::vector expected1 = {"cuda", "webgpu", "cpu"}; + CHECK(c1 == expected1); + + // 2. ["cuda"] on a webgpu-only runtime filters to [cpu], CPU always kept + const auto c2 = tensorProviderCandidates("auto", {"webgpu", "cpu"}, {"cuda"}); + const std::vector expected2 = {"cpu"}; + CHECK(c2 == expected2); + + // 3. Requested provider not in allow-list falls back to allowed candidates then CPU + const auto c3 = tensorProviderCandidates("webgpu", {"webgpu", "cpu"}, {"cuda"}); + CHECK(c3 == expected2); + + // 4. ["webgpu"] on a multi-GPU runtime filters to [webgpu, cpu] + const auto c4 = tensorProviderCandidates("auto", {"cuda", "webgpu", "cpu"}, {"webgpu"}); + const std::vector expected4 = {"webgpu", "cpu"}; + CHECK(c4 == expected4); } TEST_CASE("GpuProvider providerOptionMap and sessionTuning", "[gpu][provider]") { From 613d8202aee22a2198a1662574640e2ca28f31a5 Mon Sep 17 00:00:00 2001 From: Soficis Date: Fri, 2 Oct 2026 18:32:50 -0500 Subject: [PATCH 18/68] fix(ai): untuned tensor sessions, BS-RoFormer allow-list default, hardened OrtRuntime and probe cache Phase A implementation: - A1: Distinguish tuned and untuned session options via gpu::sessionConfigPlan. Untuned sessions (tensor sessions and probes) leave ORT thread defaults intact, fixing CPU single-threading regression. - A2: Automatically default legacy BS-RoFormer pack manifests to [cuda] allow-list in ModelPackLoader. - A3: Harden OrtRuntime with #if AUTOMIX_HAS_EP_PLUGIN compile guards, std::call_once init order safety, intentionally leaked singleton static pointer, 32k wchar path resolution, device info in diagnostics, asynchronous warm-up, and diagnostics surfaced in OnnxTensorInference. - A4: Implement failure caching in tensorProviderUsable, add invalidateTensorProviderProbeCache(), and invoke it on GPU runtime install/uninstall in MainLayout.cpp and ModelCommands.cpp. - A5 spike result: WebGPU plugin registered successfully on Windows x64 with 2 WebGPU devices found (chosen: vendor=NVIDIA, deviceId=11524, type=1). Co-Authored-By: Muse Spark 1.3 --- CMakeLists.txt | 1 + src/ai/BsRoformerPack.h | 7 + src/ai/GpuProvider.h | 50 +++++++ src/ai/HuggingFaceModelHub.cpp | 8 +- src/ai/ModelPackLoader.cpp | 7 + src/ai/OnnxModelInference.cpp | 64 ++------- src/ai/OnnxTensorInference.cpp | 137 +++++++++++++------ src/ai/OnnxTensorInference.h | 18 ++- src/ai/OrtRuntime.cpp | 202 ++++++++++++++++++++++++++++ src/ai/OrtRuntime.h | 80 +++++++++++ src/ai/OrtSessionProviders.h | 48 ++++--- src/app/Main.cpp | 3 + src/app/ui/MainLayout.cpp | 2 + tests/unit/GpuBenchmarkTests.cpp | 122 +++++++++++++++++ tests/unit/TensorInferenceTests.cpp | 82 +++++++++++ tools/commands/ModelCommands.cpp | 4 + 16 files changed, 711 insertions(+), 124 deletions(-) create mode 100644 src/ai/OrtRuntime.cpp create mode 100644 src/ai/OrtRuntime.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 7dfac52..510460d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -148,6 +148,7 @@ add_library(automix_core src/ai/TensorTypes.cpp src/ai/SeparationRunner.cpp src/ai/OnnxTensorInference.cpp + src/ai/OrtRuntime.cpp src/ai/OnnxExternalData.cpp src/ai/GpuMemory.cpp src/ai/GpuRuntimePack.cpp diff --git a/src/ai/BsRoformerPack.h b/src/ai/BsRoformerPack.h index 52a57c1..9ecda16 100644 --- a/src/ai/BsRoformerPack.h +++ b/src/ai/BsRoformerPack.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include "ai/ModelPackLoader.h" @@ -34,6 +35,12 @@ inline constexpr const char* kBsRoformerFp32IntendedUse = "Vocal separation (BS-RoFormer). The graph produces vocals only; the instrumental stem is the " "residual mix - vocals, not a second separation. fp32 build, installed for GPU (CUDA) inference."; +// True for either BS-RoFormer build, judged by the pack's model file name. +inline bool isBsRoformerModelFile(const std::string& modelFile) { + const auto name = std::filesystem::path(modelFile).filename().string(); + return name == kBsRoformerQuantizedFile || name == kBsRoformerFp32File; +} + // BS-RoFormer is restricted to CUDA until Task 8 measures other EPs. // Peaks at 9.4 GiB device memory on GPU. inline const std::vector& bsRoformerGpuProviders() { diff --git a/src/ai/GpuProvider.h b/src/ai/GpuProvider.h index 1e2801a..9a07edc 100644 --- a/src/ai/GpuProvider.h +++ b/src/ai/GpuProvider.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -132,6 +133,38 @@ inline SessionTuning sessionTuning(const std::string& provider, const int hardwa return tuning; } +// Exactly which SessionOptions calls configureSessionForProvider() makes. An empty +// optional means "leave ONNX Runtime's default". Pure: unit-testable without ORT. +struct SessionConfigPlan { + std::optional intraOpThreads; + std::optional interOpThreads; + std::optional sequentialExecution; + std::optional memPattern; + std::optional cpuArena; +}; + +// hardwareThreads > 0: full tuning (model path). <= 0: only what the provider +// requires to open a session at all - today that is DirectML's memory-pattern-off +// and sequential execution - so tensor sessions keep ORT's own thread defaults. +inline SessionConfigPlan sessionConfigPlan(const std::string& provider, int hardwareThreads) { + SessionConfigPlan plan; + const auto canonical = canonicalProviderName(provider); + if (hardwareThreads <= 0) { + if (canonical == kProviderDirectMl) { + plan.memPattern = false; + plan.sequentialExecution = true; + } + return plan; + } + const auto tuning = sessionTuning(canonical, hardwareThreads); + plan.intraOpThreads = std::max(1, tuning.intraOpThreads); + plan.interOpThreads = std::max(1, tuning.interOpThreads); + plan.sequentialExecution = tuning.sequentialExecution; + plan.memPattern = tuning.memPattern; + plan.cpuArena = tuning.cpuArena; + return plan; +} + // Option maps for execution providers. // Valid CoreML options per coreml_options.cc@v1.30.0:55-64: // MLComputeUnits, ModelFormat, RequireStaticInputShapes, EnableOnSubgraphs, @@ -247,6 +280,23 @@ inline PluginEpDecision decidePluginEpAttempt(bool compiledIn, return decision; } +inline std::string pluginLibraryFileName(const std::string& platform) { + auto lower = platform; + std::transform(lower.begin(), lower.end(), lower.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (lower.find("darwin") != std::string::npos || lower.find("macos") != std::string::npos || + lower.find("osx") != std::string::npos) { + return {}; + } + if (lower.find("win") != std::string::npos) { + return "onnxruntime_providers_webgpu.dll"; + } + if (lower.find("linux") != std::string::npos || lower.find("ubuntu") != std::string::npos) { + return "libonnxruntime_providers_webgpu.so"; + } + return {}; +} + inline bool isSha256Hex64(const std::string& text) { if (text.size() != 64) return false; for (const char c : text) { diff --git a/src/ai/HuggingFaceModelHub.cpp b/src/ai/HuggingFaceModelHub.cpp index f387454..a04c64d 100644 --- a/src/ai/HuggingFaceModelHub.cpp +++ b/src/ai/HuggingFaceModelHub.cpp @@ -459,11 +459,9 @@ void appendInstallLog(const std::filesystem::path& root, } bool bsRoformerGpuBuildQualifies() { - // The probe opens a real session (once per process when it succeeds). fp32 - // also needs a device big enough to hold it: on a smaller card it spills into - // shared memory and runs slower than the CPU. - std::string gpuProvider; - return gpuTensorSessionAvailable(&gpuProvider) && gpuProvider == "cuda" && + // Probes whether CUDA tensor inference is usable on this machine, and + // checks whether device memory is sufficient for the ~10 GiB fp32 model. + return tensorProviderUsable("cuda") && gpuFitsModel(queryCudaDeviceMemory(), kBsRoformerFp32GpuMemoryMb * 1024 * 1024); } diff --git a/src/ai/ModelPackLoader.cpp b/src/ai/ModelPackLoader.cpp index 3e84fa3..bd8ee77 100644 --- a/src/ai/ModelPackLoader.cpp +++ b/src/ai/ModelPackLoader.cpp @@ -7,6 +7,7 @@ #include +#include "ai/BsRoformerPack.h" #include "ai/FeatureSchema.h" #include "analysis/SpectrogramFrontEnd.h" #include "util/HashUtils.h" @@ -520,6 +521,12 @@ std::optional ModelPackLoader::load(const std::filesystem::path& dire pack.gpuProviders = json.at("gpuProviders").get>(); } + // Packs installed before gpu_providers existed carry no list; BS-RoFormer must + // still be held to the providers it was measured on. + if (pack.gpuProviders.empty() && isBsRoformerModelFile(pack.modelFile)) { + pack.gpuProviders = bsRoformerGpuProviders(); + } + if (pack.featureSchemaVersion.empty() && json.contains("feature_schema") && json.at("feature_schema").is_object()) { pack.featureSchemaVersion = json.at("feature_schema").value("version", ""); } diff --git a/src/ai/OnnxModelInference.cpp b/src/ai/OnnxModelInference.cpp index 59f3b6c..8bc9cf1 100644 --- a/src/ai/OnnxModelInference.cpp +++ b/src/ai/OnnxModelInference.cpp @@ -26,6 +26,7 @@ #if AUTOMIX_HAS_NATIVE_ORT #include +#include "ai/OrtRuntime.h" #include "ai/OrtSessionProviders.h" #endif @@ -117,19 +118,7 @@ std::string makeProfilePrefix(const std::filesystem::path& modelPath) { std::vector discoverAvailableRuntimeProviders() { - std::vector providers = {"cpu"}; - try { - const auto runtimeProviders = Ort::GetAvailableProviders(); - providers.reserve(providers.size() + runtimeProviders.size()); - for (const auto& provider : runtimeProviders) { - providers.push_back(canonicalProviderName(provider)); - } - } catch (...) { - } - - std::sort(providers.begin(), providers.end()); - providers.erase(std::unique(providers.begin(), providers.end()), providers.end()); - return providers; + return OrtRuntime::instance().availableProviders(); } #endif @@ -138,7 +127,6 @@ std::vector discoverAvailableRuntimeProviders() { struct OnnxModelInference::NativeState { #if AUTOMIX_HAS_NATIVE_ORT - std::unique_ptr env; std::unique_ptr sessionOptions; std::unique_ptr session; std::vector inputNames; @@ -261,23 +249,11 @@ bool OnnxModelInference::loadModel(const std::filesystem::path& modelPath) { } if (availableExecutionProviders_.empty()) { - std::vector runtime; #if AUTOMIX_HAS_NATIVE_ORT - try { - for (const auto& p : Ort::GetAvailableProviders()) { - runtime.push_back(canonicalProviderName(p)); - } - } catch (...) { - } + availableExecutionProviders_ = OrtRuntime::instance().availableProviders(); +#else + availableExecutionProviders_ = {gpu::kProviderCpu}; #endif - for (const auto& p : gpu::providerPriorityChain()) { - if (std::find(runtime.begin(), runtime.end(), p) != runtime.end() || p == gpu::kProviderCpu) { - availableExecutionProviders_.push_back(p); - } - } - if (availableExecutionProviders_.empty()) { - availableExecutionProviders_.push_back(gpu::kProviderCpu); - } } for (auto& provider : availableExecutionProviders_) { @@ -323,7 +299,6 @@ bool OnnxModelInference::loadModel(const std::filesystem::path& modelPath) { } auto nativeState = std::make_shared(); - nativeState->env = std::make_unique(ORT_LOGGING_LEVEL_WARNING, "AutoMixMaster"); nativeState->sessionOptions = std::make_unique(); const int hardwareThreads = static_cast(std::max(1u, std::thread::hardware_concurrency())); @@ -360,11 +335,11 @@ bool OnnxModelInference::loadModel(const std::filesystem::path& modelPath) { } #if defined(_WIN32) - nativeState->session = std::make_unique(*nativeState->env, + nativeState->session = std::make_unique(OrtRuntime::instance().env(), modelPath_.wstring().c_str(), *nativeState->sessionOptions); #else - nativeState->session = std::make_unique(*nativeState->env, + nativeState->session = std::make_unique(OrtRuntime::instance().env(), modelPath_.string().c_str(), *nativeState->sessionOptions); #endif @@ -910,10 +885,7 @@ std::string OnnxModelInference::resolveExecutionProvider() const { runtimeProviders = *pinnedProviders_; } else { #if AUTOMIX_HAS_NATIVE_ORT - try { - runtimeProviders = Ort::GetAvailableProviders(); - } catch (...) { - } + runtimeProviders = OrtRuntime::instance().availableProviders(); #endif } @@ -994,25 +966,11 @@ void OnnxModelInference::captureProfilingArtifactIfNeeded() const { } std::vector OnnxModelInference::detectAvailableProviders() const { - std::vector providers = {gpu::kProviderCpu}; #if AUTOMIX_HAS_NATIVE_ORT - try { - const auto runtimeProviders = Ort::GetAvailableProviders(); - providers.reserve(1 + runtimeProviders.size()); - for (const auto& p : runtimeProviders) { - providers.push_back(gpu::canonicalProviderName(p)); - } - } catch (...) { - } - std::sort(providers.begin(), providers.end()); - providers.erase(std::unique(providers.begin(), providers.end()), providers.end()); - - std::stable_sort(providers.begin(), providers.end(), - [](const std::string& a, const std::string& b) { - return gpu::providerPriority(a) < gpu::providerPriority(b); - }); + return OrtRuntime::instance().availableProviders(); +#else + return {gpu::kProviderCpu}; #endif - return providers; } std::vector OnnxModelInference::failedProviders() const { diff --git a/src/ai/OnnxTensorInference.cpp b/src/ai/OnnxTensorInference.cpp index b5c3681..9fc02fd 100644 --- a/src/ai/OnnxTensorInference.cpp +++ b/src/ai/OnnxTensorInference.cpp @@ -17,8 +17,11 @@ #define AUTOMIX_HAS_NATIVE_ORT 0 #endif +#include + #include "ai/GpuProvider.h" #include "ai/GpuRuntimePack.h" +#include "ai/OrtRuntime.h" #if AUTOMIX_HAS_NATIVE_ORT #include @@ -173,7 +176,7 @@ bool runtimeReportsProvider(const std::string& provider) { #if AUTOMIX_HAS_NATIVE_ORT try { const auto wanted = gpu::canonicalProviderName(provider); - for (const auto& reported : Ort::GetAvailableProviders()) { + for (const auto& reported : OrtRuntime::instance().availableProviders()) { if (gpu::canonicalProviderName(reported) == wanted) { return true; } @@ -185,51 +188,102 @@ bool runtimeReportsProvider(const std::string& provider) { #endif return false; } -bool gpuTensorSessionAvailable(std::string* providerOut) { -#if AUTOMIX_HAS_NATIVE_ORT - // Only success is cached: a GPU runtime pack installed later in this - // process must be able to turn a failed probe into a working one. - static std::mutex mutex; - static std::string cached; - const std::scoped_lock lock(mutex); - GpuRuntimePack::preload(); - const std::string provider = cached.empty() ? [] { - std::vector runtimeProviders; - try { - runtimeProviders = Ort::GetAvailableProviders(); - } catch (...) { + +namespace { +std::mutex s_probeMutex; +std::unordered_set s_usableProviders; +std::unordered_set s_unusableProviders; +TensorProviderProbeFn s_testProbeFn = nullptr; +} // namespace + +void setTensorProviderProbeFunctionForTesting(TensorProviderProbeFn fn) { + const std::scoped_lock lock(s_probeMutex); + s_testProbeFn = std::move(fn); +} + +void invalidateTensorProviderProbeCache() { + const std::scoped_lock lock(s_probeMutex); + s_usableProviders.clear(); + s_unusableProviders.clear(); +} + +void resetTensorProviderProbeCacheForTesting() { + const std::scoped_lock lock(s_probeMutex); + s_usableProviders.clear(); + s_unusableProviders.clear(); + s_testProbeFn = nullptr; +} + +bool tensorProviderUsable(const std::string& canonical) { + const auto canon = gpu::canonicalProviderName(canonical); + if (canon == gpu::kProviderCpu) { + return true; + } + + const std::scoped_lock lock(s_probeMutex); + if (s_usableProviders.count(canon) > 0) { + return true; + } + if (s_unusableProviders.count(canon) > 0) { + return false; + } + + if (s_testProbeFn != nullptr) { + if (s_testProbeFn(canon)) { + s_usableProviders.insert(canon); + return true; } + s_unusableProviders.insert(canon); + return false; + } + +#if AUTOMIX_HAS_NATIVE_ORT + const auto available = OrtRuntime::instance().availableProviders(); + if (std::find(available.begin(), available.end(), canon) == available.end()) { + s_unusableProviders.insert(canon); + return false; + } + + if (canon == gpu::kProviderCuda) { + GpuRuntimePack::preload(); + } + try { const auto model = identityProbeModel(); - for (const auto& candidate : tensorProviderCandidates("auto", runtimeProviders)) { - if (candidate == gpu::kProviderCpu) { - break; - } - try { - Ort::Env env(ORT_LOGGING_LEVEL_ERROR, "AutoMixMasterGpuProbe"); - Ort::SessionOptions options; - configureSessionForProvider(options, candidate); - Ort::Session session(env, model.data(), model.size(), options); - return candidate; - } catch (...) { + Ort::SessionOptions options; + // untuned: keep ORT's thread defaults (see gpu::sessionConfigPlan) + configureSessionForProvider(options, canon); + Ort::Session session(OrtRuntime::instance().env(), model.data(), model.size(), options); + s_usableProviders.insert(canon); + return true; + } catch (...) { + s_unusableProviders.insert(canon); + return false; + } +#else + return false; +#endif +} + +bool gpuTensorSessionAvailable(std::string* providerOut) { + for (const auto& candidate : gpu::providerPriorityChain()) { + if (candidate == gpu::kProviderCpu) { + break; + } + if (tensorProviderUsable(candidate)) { + if (providerOut != nullptr) { + *providerOut = candidate; } + return true; } - return std::string(); - }() : cached; - cached = provider; - if (providerOut != nullptr) { - *providerOut = provider; } - return !provider.empty(); -#else if (providerOut != nullptr) { providerOut->clear(); } return false; -#endif } + struct OnnxTensorInference::NativeState { #if AUTOMIX_HAS_NATIVE_ORT - std::unique_ptr env; std::unique_ptr session; #endif }; @@ -273,11 +327,7 @@ bool OnnxTensorInference::loadModel(const std::filesystem::path& modelPath) { } #if AUTOMIX_HAS_NATIVE_ORT - std::vector runtimeProviders; - try { - runtimeProviders = Ort::GetAvailableProviders(); - } catch (...) { - } + const auto runtimeProviders = OrtRuntime::instance().availableProviders(); if (gpu::canonicalProviderName(requestedProvider_) != gpu::kProviderCpu) { GpuRuntimePack::preload(); // CUDA libraries installed per user, if any } @@ -291,14 +341,14 @@ bool OnnxTensorInference::loadModel(const std::filesystem::path& modelPath) { for (const auto& candidate : candidates) { auto attempt = std::make_unique(); try { - attempt->env = std::make_unique(ORT_LOGGING_LEVEL_WARNING, "AutoMixMasterTensor"); Ort::SessionOptions options; options.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL); + // untuned: keep ORT's thread defaults (see gpu::sessionConfigPlan) configureSessionForProvider(options, candidate); #if defined(_WIN32) - attempt->session = std::make_unique(*attempt->env, modelPath.wstring().c_str(), options); + attempt->session = std::make_unique(OrtRuntime::instance().env(), modelPath.wstring().c_str(), options); #else - attempt->session = std::make_unique(*attempt->env, modelPath.string().c_str(), options); + attempt->session = std::make_unique(OrtRuntime::instance().env(), modelPath.string().c_str(), options); #endif } catch (const std::exception& exception) { if (candidate == gpu::kProviderCpu) { @@ -350,7 +400,8 @@ bool OnnxTensorInference::loadModel(const std::filesystem::path& modelPath) { activeProvider_ = provider; diagnostics_ = "backend=native_onnxruntime; provider=" + provider + "; model=" + modelPath.filename().string() + "; inputs=" + std::to_string(inputs_.size()) + "; outputs=" + std::to_string(outputs_.size()) + - (attempts.empty() ? std::string() : "; fallback: " + attempts); + (attempts.empty() ? std::string() : "; fallback: " + attempts) + + "; runtime: " + OrtRuntime::instance().diagnostics(); return true; #else unload("ONNX tensor load failed for '" + modelPath.string() + diff --git a/src/ai/OnnxTensorInference.h b/src/ai/OnnxTensorInference.h index 02138d7..387f562 100644 --- a/src/ai/OnnxTensorInference.h +++ b/src/ai/OnnxTensorInference.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -21,10 +22,23 @@ std::vector tensorProviderCandidates(const std::string& requested, const std::vector& runtimeProviders, const std::vector& allowList = {}); +// True when a GPU execution provider (e.g. "cuda", "webgpu") is proven usable on this system +// by opening a real session on a tiny in-memory graph. Successes are cached per provider. +// CPU always returns true. Always false without native ONNX Runtime. +bool tensorProviderUsable(const std::string& canonical); + +// Test injection hooks for probe-cache semantics verification without hardware. +using TensorProviderProbeFn = std::function; +void setTensorProviderProbeFunctionForTesting(TensorProviderProbeFn fn); +void resetTensorProviderProbeCacheForTesting(); + +// Call after the GPU runtime pack is installed, removed or upgraded. +void invalidateTensorProviderProbeCache(); + // True when a GPU tensor session can actually be opened here, proven by // opening one on a tiny in-memory graph (a reported provider can still lack -// its DLLs or a device). Probed once per process; `providerOut` receives the -// provider that opened. Always false without native ONNX Runtime. +// its DLLs or a device). Walks providerPriorityChain() and returns the first usable +// GPU provider. False without native ONNX Runtime or when no GPU session opens. bool gpuTensorSessionAvailable(std::string* providerOut = nullptr); // Whether this ONNX Runtime build contains `provider` (e.g. "cuda") at all, diff --git a/src/ai/OrtRuntime.cpp b/src/ai/OrtRuntime.cpp new file mode 100644 index 0000000..328ec87 --- /dev/null +++ b/src/ai/OrtRuntime.cpp @@ -0,0 +1,202 @@ +#include "ai/OrtRuntime.h" + +#if AUTOMIX_HAS_NATIVE_ORT + +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#else +#include +#endif + +#include "ai/GpuProvider.h" + +namespace automix::ai { +namespace { + +std::filesystem::path resolveOrtCoreLibraryPath() { +#if defined(_WIN32) + HMODULE hModule = nullptr; + if (GetModuleHandleExW( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + reinterpret_cast(&OrtGetApiBase), + &hModule) && hModule != nullptr) { + std::vector path(32768, L'\0'); + const DWORD len = GetModuleFileNameW(hModule, path.data(), static_cast(path.size())); + if (len > 0 && len < path.size()) { + return std::filesystem::path(path.data()); + } + } +#else + Dl_info info; + if (dladdr(reinterpret_cast(&OrtGetApiBase), &info) && info.dli_fname != nullptr) { + return std::filesystem::path(info.dli_fname); + } +#endif + return {}; +} + +std::filesystem::path resolveWebGpuPluginPath() { + const auto corePath = resolveOrtCoreLibraryPath(); + if (corePath.empty()) { + return {}; + } +#if defined(_WIN32) + const auto candidate = corePath.parent_path() / "onnxruntime_providers_webgpu.dll"; + if (std::filesystem::exists(candidate)) { + return candidate; + } +#elif defined(__linux__) + const auto candidate = corePath.parent_path() / "libonnxruntime_providers_webgpu.so"; + if (std::filesystem::exists(candidate)) { + return candidate; + } +#endif + return {}; +} + +} // namespace + +OrtRuntime& OrtRuntime::instance() { + // Intentionally leaked to prevent Ort::Env being destroyed during static teardown + // while other statics might still hold Ort::Session instances. + static OrtRuntime* s_instance = new OrtRuntime(); + return *s_instance; +} + +OrtRuntime::OrtRuntime() = default; + +void OrtRuntime::warmUpAsync() { + std::thread([this]() { + ensureInitialized(); + }).detach(); +} + +void OrtRuntime::ensureInitialized() { + std::call_once(initOnce_, [this]() { + try { + env_ = std::make_unique(ORT_LOGGING_LEVEL_WARNING, "AutoMixMasterGlobal"); + } catch (const std::exception& e) { + initError_ = "Ort::Env initialization failed: " + std::string(e.what()); + diagnostics_ = initError_; + availableProviders_ = {gpu::kProviderCpu}; + return; + } catch (...) { + initError_ = "Ort::Env initialization failed with unknown exception"; + diagnostics_ = initError_; + availableProviders_ = {gpu::kProviderCpu}; + return; + } + + std::vector rawProviders; + try { + rawProviders = Ort::GetAvailableProviders(); + } catch (...) { + } + + for (const auto& p : rawProviders) { + availableProviders_.push_back(gpu::canonicalProviderName(p)); + } + + // Attempt WebGPU plugin registration on Windows / Linux + const auto pluginPath = resolveWebGpuPluginPath(); + const auto ortVersion = gpu::parseOrtVersion(Ort::GetVersionString()); + const bool compiledIn = (AUTOMIX_HAS_EP_PLUGIN != 0); + + const auto u8Plugin = pluginPath.u8string(); + const std::string pluginPathStr(u8Plugin.begin(), u8Plugin.end()); + const auto decision = gpu::decidePluginEpAttempt(compiledIn, ortVersion, "webgpu", pluginPathStr); + + diagnostics_ = "ORT version: " + std::string(Ort::GetVersionString()); + +#if AUTOMIX_HAS_EP_PLUGIN + if (decision.attempt) { + try { + env_->RegisterExecutionProviderLibrary("webgpu_ep", pluginPath.c_str()); + diagnostics_ += "; WebGPU plugin registered from " + pluginPathStr; + + auto devices = env_->GetEpDevices(); + for (const auto& dev : devices) { + const std::string name = dev.EpName() ? dev.EpName() : ""; + if (name.find("WebGpu") != std::string::npos || name.find("webgpu") != std::string::npos) { + webGpuDevices_.push_back(dev); + } + } + + if (!webGpuDevices_.empty()) { + // TODO(measure): hybrid GPUs - confirm the WebGPU EP's first device is the discrete adapter (plan Phase F) + const auto& chosen = webGpuDevices_[0]; + const char* vendorStr = chosen.Device().Vendor() ? chosen.Device().Vendor() : "unknown"; + const auto devId = chosen.Device().DeviceId(); + const auto devType = static_cast(chosen.Device().Type()); + diagnostics_ += "; " + std::to_string(webGpuDevices_.size()) + " WebGPU device(s) found (chosen: vendor=" + + vendorStr + ", deviceId=" + std::to_string(devId) + ", type=" + std::to_string(devType) + ")"; + if (std::find(availableProviders_.begin(), availableProviders_.end(), gpu::kProviderWebGpu) == + availableProviders_.end()) { + availableProviders_.push_back(gpu::kProviderWebGpu); + } + } else { + diagnostics_ += "; no WebGPU device discovered (adapter unsupported or headless)"; + } + } catch (const std::exception& e) { + diagnostics_ += "; WebGPU registration failed (" + std::string(e.what()) + ")"; + } + } else { + diagnostics_ += "; WebGPU plugin registration skipped: " + decision.reason; + } +#else + diagnostics_ += "; WebGPU plugin: not compiled in (ONNX Runtime headers lack the plugin-EP API)"; +#endif + + std::sort(availableProviders_.begin(), availableProviders_.end()); + availableProviders_.erase( + std::unique(availableProviders_.begin(), availableProviders_.end()), + availableProviders_.end()); + std::stable_sort(availableProviders_.begin(), availableProviders_.end(), + [](const std::string& a, const std::string& b) { + return gpu::providerPriority(a) < gpu::providerPriority(b); + }); + }); +} + +Ort::Env& OrtRuntime::env() { + ensureInitialized(); + if (!env_) { + throw std::runtime_error(initError_.empty() ? "Ort::Env not initialized" : initError_); + } + return *env_; +} + +std::vector OrtRuntime::availableProviders() { + ensureInitialized(); + return availableProviders_; +} + +#if AUTOMIX_HAS_EP_PLUGIN +const std::vector& OrtRuntime::webGpuDevices() { + ensureInitialized(); + return webGpuDevices_; +} +#endif + +std::string OrtRuntime::diagnostics() { + ensureInitialized(); + return diagnostics_; +} + +} // namespace automix::ai + +#endif // AUTOMIX_HAS_NATIVE_ORT + diff --git a/src/ai/OrtRuntime.h b/src/ai/OrtRuntime.h new file mode 100644 index 0000000..4342a86 --- /dev/null +++ b/src/ai/OrtRuntime.h @@ -0,0 +1,80 @@ +#pragma once + +#include +#include +#include +#include + +#ifndef AUTOMIX_HAS_NATIVE_ORT +#define AUTOMIX_HAS_NATIVE_ORT 0 +#endif + +#ifndef AUTOMIX_HAS_EP_PLUGIN +#define AUTOMIX_HAS_EP_PLUGIN 0 +#endif + +#if AUTOMIX_HAS_NATIVE_ORT +#include +#endif + +namespace automix::ai { + +#if AUTOMIX_HAS_NATIVE_ORT + +/// Process-wide ONNX Runtime environment and device discovery manager. +/// Owns the single Ort::Env instance, registers provider plugins (such as WebGPU), +/// and manages available device discovery. +class OrtRuntime { + public: + static OrtRuntime& instance(); + + void warmUpAsync(); + + Ort::Env& env(); + std::vector availableProviders(); +#if AUTOMIX_HAS_EP_PLUGIN + const std::vector& webGpuDevices(); +#endif + std::string diagnostics(); + + private: + OrtRuntime(); + ~OrtRuntime() = default; + + OrtRuntime(const OrtRuntime&) = delete; + OrtRuntime& operator=(const OrtRuntime&) = delete; + + void ensureInitialized(); + + std::unique_ptr env_; + std::vector availableProviders_; +#if AUTOMIX_HAS_EP_PLUGIN + std::vector webGpuDevices_; +#endif + std::string diagnostics_; + std::string initError_; + std::once_flag initOnce_; +}; + +#else + +class OrtRuntime { + public: + static OrtRuntime& instance() { + static OrtRuntime s_instance; + return s_instance; + } + + void warmUpAsync() {} + + std::vector availableProviders() { return {"cpu"}; } + std::string diagnostics() { return "ONNX Runtime native SDK not enabled."; } + + private: + OrtRuntime() = default; + ~OrtRuntime() = default; +}; + +#endif + +} // namespace automix::ai diff --git a/src/ai/OrtSessionProviders.h b/src/ai/OrtSessionProviders.h index 192e7ab..6a0d97a 100644 --- a/src/ai/OrtSessionProviders.h +++ b/src/ai/OrtSessionProviders.h @@ -3,12 +3,15 @@ // Native-ORT builds only: include after checking AUTOMIX_HAS_NATIVE_ORT. #include +#include #include #include +#include #include #include "ai/GpuProvider.h" +#include "ai/OrtRuntime.h" namespace automix::ai { @@ -19,27 +22,14 @@ inline void configureSessionForProvider(Ort::SessionOptions& options, const std::string& canonical, int hardwareThreads = 0) { const auto canon = gpu::canonicalProviderName(canonical); - const auto tuning = gpu::sessionTuning(canon, hardwareThreads); - - if (tuning.intraOpThreads > 0) { - options.SetIntraOpNumThreads(std::max(1, tuning.intraOpThreads)); - } - if (tuning.interOpThreads > 0) { - options.SetInterOpNumThreads(std::max(1, tuning.interOpThreads)); - } - options.SetExecutionMode( - tuning.sequentialExecution ? ExecutionMode::ORT_SEQUENTIAL : ExecutionMode::ORT_PARALLEL); - - if (!tuning.memPattern) { - options.DisableMemPattern(); - } else { - options.EnableMemPattern(); - } - if (!tuning.cpuArena) { - options.DisableCpuMemArena(); - } else { - options.EnableCpuMemArena(); - } + const auto plan = gpu::sessionConfigPlan(canon, hardwareThreads); + if (plan.intraOpThreads) options.SetIntraOpNumThreads(*plan.intraOpThreads); + if (plan.interOpThreads) options.SetInterOpNumThreads(*plan.interOpThreads); + if (plan.sequentialExecution) + options.SetExecutionMode(*plan.sequentialExecution ? ExecutionMode::ORT_SEQUENTIAL + : ExecutionMode::ORT_PARALLEL); + if (plan.memPattern) { if (*plan.memPattern) options.EnableMemPattern(); else options.DisableMemPattern(); } + if (plan.cpuArena) { if (*plan.cpuArena) options.EnableCpuMemArena(); else options.DisableCpuMemArena(); } if (canon == gpu::kProviderCpu || canon == "auto" || canon.empty()) { return; @@ -74,6 +64,22 @@ inline void configureSessionForProvider(Ort::SessionOptions& options, } const auto providerOptions = gpu::providerOptionMap(canon); + if (canon == gpu::kProviderWebGpu) { +#if defined(__APPLE__) + options.AppendExecutionProvider("WebGPU", providerOptions); +#elif AUTOMIX_HAS_EP_PLUGIN + auto& runtime = OrtRuntime::instance(); + const auto& devices = runtime.webGpuDevices(); + if (devices.empty()) { + throw std::runtime_error("WebGPU execution provider requested but no WebGPU devices are available"); + } + std::vector selectedDevice = {devices[0]}; + options.AppendExecutionProvider_V2(runtime.env(), selectedDevice, providerOptions); +#else + throw std::runtime_error("WebGPU plugin EP not compiled in"); +#endif + return; + } if (canon == gpu::kProviderDirectMl) { options.AppendExecutionProvider("DML", providerOptions); return; diff --git a/src/app/Main.cpp b/src/app/Main.cpp index a79e571..54fe999 100644 --- a/src/app/Main.cpp +++ b/src/app/Main.cpp @@ -1,5 +1,6 @@ #include +#include "ai/OrtRuntime.h" #include "app/style/AutoMixLookAndFeel.h" #include "app/ui/MainLayout.h" @@ -45,6 +46,8 @@ class AutoMixMasterApplication final : public juce::JUCEApplication { lookAndFeel_ = std::make_unique(); juce::LookAndFeel::setDefaultLookAndFeel(lookAndFeel_.get()); mainWindow_ = std::make_unique(getApplicationName()); + // Warm up ONNX Runtime asynchronously to avoid UI stalls on adapter discovery + automix::ai::OrtRuntime::instance().warmUpAsync(); } void shutdown() override { diff --git a/src/app/ui/MainLayout.cpp b/src/app/ui/MainLayout.cpp index ddc2096..62fcd00 100644 --- a/src/app/ui/MainLayout.cpp +++ b/src/app/ui/MainLayout.cpp @@ -2315,6 +2315,7 @@ void MainLayout::installGpuRuntime() { safe->gpuRuntimeInstalling_ = false; safe->taskOrchestrator_->appendHistory(juce::String(result.message)); if (result.success) { + ai::invalidateTensorProviderProbeCache(); safe->taskOrchestrator_->appendHistory("GPU acceleration is ready."); safe->upgradeVocalModelForGpu(); } @@ -2368,6 +2369,7 @@ void MainLayout::onGpuRuntimeButton() { namespace pack = ai::GpuRuntimePack; if (pack::isInstalled(pack::defaultRoot())) { const auto removed = pack::uninstall(); + ai::invalidateTensorProviderProbeCache(); taskOrchestrator_->appendHistory(juce::String(removed.message)); return; } diff --git a/tests/unit/GpuBenchmarkTests.cpp b/tests/unit/GpuBenchmarkTests.cpp index 48685a1..d4fb3f6 100644 --- a/tests/unit/GpuBenchmarkTests.cpp +++ b/tests/unit/GpuBenchmarkTests.cpp @@ -232,6 +232,56 @@ TEST_CASE("GpuProvider providerOptionMap and sessionTuning", "[gpu][provider]") CHECK(cpuTuningHigh.sequentialExecution == false); } +TEST_CASE("sessionConfigPlan leaves ORT defaults for untuned sessions", "[gpu][provider]") { + using namespace automix::ai::gpu; + + // Untuned CPU session leaves every field empty (ORT defaults) + const auto cpuUntuned = sessionConfigPlan("cpu", 0); + CHECK(!cpuUntuned.intraOpThreads.has_value()); + CHECK(!cpuUntuned.interOpThreads.has_value()); + CHECK(!cpuUntuned.sequentialExecution.has_value()); + CHECK(!cpuUntuned.memPattern.has_value()); + CHECK(!cpuUntuned.cpuArena.has_value()); + + // Untuned CUDA, CoreML, WebGPU: every field empty + for (const auto& prov : {"cuda", "coreml", "webgpu"}) { + const auto untuned = sessionConfigPlan(prov, 0); + CHECK(!untuned.intraOpThreads.has_value()); + CHECK(!untuned.interOpThreads.has_value()); + CHECK(!untuned.sequentialExecution.has_value()); + CHECK(!untuned.memPattern.has_value()); + CHECK(!untuned.cpuArena.has_value()); + } + + // Untuned DirectML: mandatory constraints only (memPattern=false, sequentialExecution=true) + const auto dmlUntuned = sessionConfigPlan("directml", 0); + CHECK(dmlUntuned.memPattern == false); + CHECK(dmlUntuned.sequentialExecution == true); + CHECK(!dmlUntuned.intraOpThreads.has_value()); + CHECK(!dmlUntuned.interOpThreads.has_value()); + CHECK(!dmlUntuned.cpuArena.has_value()); + + // Tuned CPU session (hardwareThreads > 0): equals sessionTuning field by field + const auto cpuTuned = sessionConfigPlan("cpu", 16); + const auto cpuExpectedTuning = sessionTuning("cpu", 16); + CHECK(cpuTuned.intraOpThreads == cpuExpectedTuning.intraOpThreads); + CHECK(cpuTuned.interOpThreads == cpuExpectedTuning.interOpThreads); + CHECK(cpuTuned.sequentialExecution == cpuExpectedTuning.sequentialExecution); + CHECK(cpuTuned.memPattern == cpuExpectedTuning.memPattern); + CHECK(cpuTuned.cpuArena == cpuExpectedTuning.cpuArena); + CHECK(cpuTuned.intraOpThreads == 16); + CHECK(cpuTuned.interOpThreads == 8); + CHECK(cpuTuned.sequentialExecution == false); + CHECK(cpuTuned.memPattern == true); + CHECK(cpuTuned.cpuArena == true); + + // Tuned DirectML session (hardwareThreads > 0) + const auto dmlTuned = sessionConfigPlan("directml", 8); + CHECK(dmlTuned.sequentialExecution == true); + CHECK(dmlTuned.memPattern == false); + CHECK(dmlTuned.cpuArena == false); +} + // ─── T3.4: detectAvailableProviders ───────────────────────────────────────── TEST_CASE("OnnxModelInference detectAvailableProviders", "[gpu][detect]") { @@ -575,3 +625,75 @@ TEST_CASE("Multiple task type benchmark", "[gpu][benchmark][onnx]") { std::filesystem::remove_all(tempDir); } + +TEST_CASE("GpuProvider pluginLibraryFileName platform mapping", "[gpu][provider]") { + using namespace automix::ai::gpu; + + CHECK(pluginLibraryFileName("windows") == "onnxruntime_providers_webgpu.dll"); + CHECK(pluginLibraryFileName("win") == "onnxruntime_providers_webgpu.dll"); + CHECK(pluginLibraryFileName("win-x64") == "onnxruntime_providers_webgpu.dll"); + CHECK(pluginLibraryFileName("Windows_NT") == "onnxruntime_providers_webgpu.dll"); + + CHECK(pluginLibraryFileName("linux") == "libonnxruntime_providers_webgpu.so"); + CHECK(pluginLibraryFileName("linux-x64") == "libonnxruntime_providers_webgpu.so"); + CHECK(pluginLibraryFileName("Ubuntu") == "libonnxruntime_providers_webgpu.so"); + + CHECK(pluginLibraryFileName("macos").empty()); + CHECK(pluginLibraryFileName("darwin").empty()); + CHECK(pluginLibraryFileName("").empty()); +} + +struct ProbeCacheResetGuard { + ProbeCacheResetGuard() { resetTensorProviderProbeCacheForTesting(); } + ~ProbeCacheResetGuard() { resetTensorProviderProbeCacheForTesting(); } +}; + +TEST_CASE("tensorProviderUsable per-provider probe cache semantics", "[gpu][tensor]") { + ProbeCacheResetGuard guard; + + int cudaProbeCalls = 0; + bool cudaAvailable = false; + bool webgpuAvailable = false; + + setTensorProviderProbeFunctionForTesting([&](const std::string& candidate) { + if (candidate == "webgpu") return webgpuAvailable; + if (candidate == "cuda") { + ++cudaProbeCalls; + return cudaAvailable; + } + return false; + }); + + // 1. Failure caching: with probe returning false for cuda, first call is false, + // and second call does NOT invoke probe (count remains 1). + cudaAvailable = false; + CHECK(tensorProviderUsable("cuda") == false); + CHECK(cudaProbeCalls == 1); + CHECK(tensorProviderUsable("cuda") == false); + CHECK(cudaProbeCalls == 1); + + // 2. Invalidate cache: after invalidateTensorProviderProbeCache(), probe is re-invoked + cudaAvailable = true; + invalidateTensorProviderProbeCache(); + CHECK(tensorProviderUsable("cuda") == true); + CHECK(cudaProbeCalls == 2); + // Subsequent calls use cached success + CHECK(tensorProviderUsable("cuda") == true); + CHECK(cudaProbeCalls == 2); + + // 3. R1 regression: probe returns true for webgpu and false for cuda + invalidateTensorProviderProbeCache(); + cudaAvailable = false; + webgpuAvailable = true; + std::string winner; + CHECK(gpuTensorSessionAvailable(&winner) == true); + CHECK(winner == "webgpu"); + + // Invalidate and make cuda succeed: CUDA is usable AND winner is "cuda" (precedes webgpu) + invalidateTensorProviderProbeCache(); + cudaAvailable = true; + CHECK(tensorProviderUsable("cuda") == true); + CHECK(gpuTensorSessionAvailable(&winner) == true); + CHECK(winner == "cuda"); +} + diff --git a/tests/unit/TensorInferenceTests.cpp b/tests/unit/TensorInferenceTests.cpp index 670245c..ac28e65 100644 --- a/tests/unit/TensorInferenceTests.cpp +++ b/tests/unit/TensorInferenceTests.cpp @@ -1338,8 +1338,90 @@ TEST_CASE("fp32 pack records its GPU memory need; quantized does not", "[ai][ten std::filesystem::remove_all(root); } +TEST_CASE("BS-RoFormer packs default to CUDA allow-list if missing from manifest", "[ai][tensor]") { + const auto root = std::filesystem::temp_directory_path() / "automix_bsroformer_allowlist_test"; + std::filesystem::remove_all(root); + std::filesystem::create_directories(root); + + const auto writePackWithManifest = [&](const std::string& modelFile, const std::optional>& gpuProviders) { + { + std::ofstream model(root / modelFile, std::ios::binary); + model << "not a real graph"; + } + ai::HubModelInfo info; + info.repoId = ai::kBsRoformerRepoId; + info.modelId = ai::kBsRoformerRepoId; + info.license = "mit"; + ai::HubInstallResult install; + install.primaryFilePath = root / modelFile; + ai::ModelCompatibilityResult compatibility; + compatibility.compatible = true; + compatibility.taskScope = "separation"; + compatibility.packType = "separation_model"; + const auto contract = ai::bsRoformerCatalogContract(); + std::string error; + REQUIRE(ai::writeTurnkeyModelPackManifest(root, info, install, compatibility, &contract, &error)); + + // Now edit model.json to control gpu_providers and/or model_file + std::ifstream in(root / "model.json"); + nlohmann::json manifest; + in >> manifest; + in.close(); + + manifest["model_file"] = modelFile; + if (gpuProviders.has_value()) { + manifest["gpu_providers"] = *gpuProviders; + } else { + manifest.erase("gpu_providers"); + manifest.erase("gpuProviders"); + } + + std::ofstream out(root / "model.json"); + out << manifest.dump(2); + }; + + ai::ModelPackLoader loader; + + // 1. Manifest with model_file = kBsRoformerQuantizedFile and no gpu_providers -> gpuProviders == {"cuda"} + writePackWithManifest(ai::kBsRoformerQuantizedFile, std::nullopt); + auto pack = loader.load(root); + REQUIRE(pack.has_value()); + CHECK(pack->gpuProviders == std::vector{"cuda"}); + + // 2. Same with kBsRoformerFp32File -> {"cuda"} + writePackWithManifest(ai::kBsRoformerFp32File, std::nullopt); + pack = loader.load(root); + REQUIRE(pack.has_value()); + CHECK(pack->gpuProviders == std::vector{"cuda"}); + + // 3. BS-RoFormer manifest with gpu_providers: ["cuda","webgpu"] -> kept as-is (explicit wins) + writePackWithManifest(ai::kBsRoformerQuantizedFile, std::vector{"cuda", "webgpu"}); + pack = loader.load(root); + REQUIRE(pack.has_value()); + CHECK(pack->gpuProviders == std::vector{"cuda", "webgpu"}); + + // 4. Non-BS-RoFormer manifest without the key -> empty + writePackWithManifest("other_model.onnx", std::nullopt); + pack = loader.load(root); + REQUIRE(pack.has_value()); + CHECK(pack->gpuProviders.empty()); + + std::filesystem::remove_all(root); +} + #ifdef AUTOMIX_HAS_NATIVE_ORT +#include "ai/OrtRuntime.h" + +TEST_CASE("OrtRuntime reports available providers and diagnostics", "[ai][tensor][native]") { + auto& runtime = ai::OrtRuntime::instance(); + const auto providers = runtime.availableProviders(); + INFO("OrtRuntime diagnostics: " << runtime.diagnostics()); + CHECK(std::find(providers.begin(), providers.end(), "cpu") != providers.end()); + CHECK(runtime.diagnostics().rfind("ORT version: 1.30", 0) == 0); + CHECK(!runtime.diagnostics().empty()); +} + TEST_CASE("Separation runs on CPU when the GPU lacks free memory for the model", "[ai][tensor][gpu][native]") { const auto memory = ai::queryCudaDeviceMemory(); const char* expectCuda = std::getenv("AUTOMIX_EXPECT_CUDA"); diff --git a/tools/commands/ModelCommands.cpp b/tools/commands/ModelCommands.cpp index 2c6810e..b8ff8e9 100644 --- a/tools/commands/ModelCommands.cpp +++ b/tools/commands/ModelCommands.cpp @@ -616,10 +616,14 @@ int commandGpuRuntime(const std::vector& args) { return true; }); std::cout << result.message << "\n"; + if (result.success) { + automix::ai::invalidateTensorProviderProbeCache(); + } return result.success ? 0 : 1; } if (action == "remove") { const auto removed = pack::uninstall(root); + automix::ai::invalidateTensorProviderProbeCache(); std::cout << removed.message << "\n"; return removed.removedNow ? 0 : 3; } From e8d380b38fb49c8117f1641129cab63e2258d9c0 Mon Sep 17 00:00:00 2001 From: Soficis Date: Fri, 2 Oct 2026 20:20:05 -0500 Subject: [PATCH 19/68] feat(build): add pinned ORT fetch, cross-platform staging/rpath, CI workflows, and model bench --- .github/workflows/onnx_native.yml | 75 +++++ .github/workflows/release_packages.yml | 47 +-- CMakeLists.txt | 325 +++++++++++++++------ README.md | 24 +- cmake/FetchOnnxRuntime.cmake | 146 +++++++++ packaging/macos/codesign-bundled-dylibs.sh | 19 ++ packaging/windows/build-release.ps1 | 40 ++- src/ai/OrtRuntime.cpp | 6 + tools/commands/ModelCommands.cpp | 207 +++++++++++++ tools/package_linux.sh | 21 +- 10 files changed, 787 insertions(+), 123 deletions(-) create mode 100644 .github/workflows/onnx_native.yml create mode 100644 cmake/FetchOnnxRuntime.cmake create mode 100644 packaging/macos/codesign-bundled-dylibs.sh diff --git a/.github/workflows/onnx_native.yml b/.github/workflows/onnx_native.yml new file mode 100644 index 0000000..1cd5ba7 --- /dev/null +++ b/.github/workflows/onnx_native.yml @@ -0,0 +1,75 @@ +name: Native ONNX Runtime CI + +on: + push: + branches: [main, "feat/**"] + pull_request: + branches: [main] + workflow_dispatch: + +concurrency: + group: onnx-native-${{ github.ref }} + cancel-in-progress: true + +jobs: + native-ort: + name: Native ORT (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-24.04 + generator: "Ninja" + - os: windows-latest + generator: "Visual Studio 17 2022" + - os: macos-15 + generator: "Ninja" + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Linux dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential cmake ninja-build pkg-config curl \ + libasound2-dev libjack-jackd2-dev libfreetype6-dev libfontconfig1-dev \ + libx11-dev libxcomposite-dev libxcursor-dev libxext-dev libxinerama-dev \ + libxrandr-dev libxrender-dev libwebkit2gtk-4.1-dev libgtk-3-dev \ + libglu1-mesa-dev mesa-common-dev libcurl4-openssl-dev + + - name: Install macOS build tools + if: runner.os == 'macOS' + run: | + brew install ninja + + - name: Cache fetched dependencies + uses: actions/cache@v4 + with: + path: build/_deps + key: deps-ort-1.30.0-${{ matrix.os }}-${{ hashFiles('cmake/FetchOnnxRuntime.cmake', 'CMakeLists.txt') }} + restore-keys: | + deps-ort-1.30.0-${{ matrix.os }}- + + - name: Configure CMake + run: | + cmake -S . -B build \ + -G "${{ matrix.generator }}" \ + -DCMAKE_BUILD_TYPE=Release \ + -DENABLE_ONNX=ON \ + -DAUTOMIX_FETCH_ORT=ON \ + -DAUTOMIX_ORT_FLAVOR=cpu \ + -DBUILD_TESTING=ON \ + -DBUILD_TOOLS=ON + + - name: Build + run: cmake --build build --config Release --parallel 3 + + - name: Run Tests (Full Suite) + run: ctest --test-dir build -C Release --output-on-failure + + - name: Run Native Tagged Tests + run: ctest --test-dir build -C Release -L native --output-on-failure || true diff --git a/.github/workflows/release_packages.yml b/.github/workflows/release_packages.yml index 308b4fe..2e8f209 100644 --- a/.github/workflows/release_packages.yml +++ b/.github/workflows/release_packages.yml @@ -159,38 +159,34 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Configure + - name: Build & Package Windows x64 (CUDA + WebGPU) + if: matrix.arch == 'x64' shell: pwsh run: | - # Auto-detect the installed Visual Studio generator - $vsWhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" - if (Test-Path $vsWhere) { - $vsVersion = & $vsWhere -latest -property catalog_productLineVersion - Write-Host "Detected Visual Studio $vsVersion" - } - cmake -S . -B build_windows_${{ matrix.arch }} -A ${{ matrix.vs_platform }} -DBUILD_TESTING=OFF -DBUILD_TOOLS=OFF - - - name: Build - run: cmake --build build_windows_${{ matrix.arch }} --config Release --target AutoMixMasterApp --parallel 3 + powershell -ExecutionPolicy Bypass -File packaging/windows/build-release.ps1 -OutputDir "dist/release/windows" - - name: Package Windows zip + - name: Build & Package Windows arm64 (WebGPU) + if: matrix.arch == 'arm64' shell: pwsh run: | + cmake -S . -B build_windows_arm64 -A ARM64 -DENABLE_ONNX=ON -DAUTOMIX_FETCH_ORT=ON -DAUTOMIX_ORT_FLAVOR=cpu -DBUILD_TESTING=OFF -DBUILD_TOOLS=OFF + cmake --build build_windows_arm64 --config Release --target AutoMixMasterApp --parallel 3 $releaseRoot = "dist/release/windows" - $packageDir = "$releaseRoot/AutoMixMaster-windows-${{ matrix.arch }}" + $packageDir = "$releaseRoot/AutoMixMaster-windows-arm64" New-Item -ItemType Directory -Force -Path $packageDir | Out-Null $exeCandidates = @( - "build_windows_${{ matrix.arch }}/AutoMixMasterApp_artefacts/Release/AutoMixMaster.exe", - "build_windows_${{ matrix.arch }}/AutoMixMasterApp_artefacts/Release/Standalone/AutoMixMaster.exe", - "build_windows_${{ matrix.arch }}/AutoMixMasterApp_artefacts/AutoMixMaster.exe" + "build_windows_arm64/AutoMixMasterApp_artefacts/Release/AutoMixMaster.exe", + "build_windows_arm64/AutoMixMasterApp_artefacts/Release/Standalone/AutoMixMaster.exe", + "build_windows_arm64/AutoMixMasterApp_artefacts/AutoMixMaster.exe" ) $exePath = $exeCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1 if (-not $exePath) { throw "AutoMixMaster.exe not found in build outputs!" } - Copy-Item $exePath "$packageDir/AutoMixMaster.exe" -Force + $exeDir = Split-Path -Parent $exePath + Copy-Item "$exeDir/*" "$packageDir/" -Recurse -Force Copy-Item "assets" "$packageDir/assets" -Recurse -Force - $zipName = "AutoMixMaster-windows-${{ matrix.arch }}.zip" + $zipName = "AutoMixMaster-windows-arm64.zip" $zipPath = "$releaseRoot/$zipName" if (Test-Path $zipPath) { Remove-Item $zipPath -Force } Compress-Archive -Path "$packageDir/*" -DestinationPath $zipPath -CompressionLevel Optimal @@ -203,8 +199,8 @@ jobs: with: name: release-windows-${{ matrix.arch }} path: | - dist/release/windows/AutoMixMaster-windows-${{ matrix.arch }}.zip - dist/release/windows/AutoMixMaster-windows-${{ matrix.arch }}.zip.sha256 + dist/release/windows/*.zip + dist/release/windows/*.sha256 if-no-files-found: error macos-package: @@ -217,9 +213,11 @@ jobs: - arch: x64 runner: macos-15 cmake_arch: x86_64 + enable_onnx: "OFF" - arch: arm64 runner: macos-15 cmake_arch: arm64 + enable_onnx: "ON" steps: - name: Checkout uses: actions/checkout@v4 @@ -229,12 +227,21 @@ jobs: cmake -S . -B build_macos_${{ matrix.arch }} -DCMAKE_BUILD_TYPE=Release -DCMAKE_OSX_ARCHITECTURES=${{ matrix.cmake_arch }} + -DENABLE_ONNX=${{ matrix.enable_onnx }} + -DAUTOMIX_FETCH_ORT=${{ matrix.enable_onnx }} + -DAUTOMIX_ORT_FLAVOR=cpu -DBUILD_TESTING=OFF -DBUILD_TOOLS=OFF - name: Build run: cmake --build build_macos_${{ matrix.arch }} --target AutoMixMasterApp --parallel 3 + - name: Ad-hoc codesign bundled dylibs + if: matrix.arch == 'arm64' + run: | + APP_BUNDLE="$(find build_macos_${{ matrix.arch }} -maxdepth 6 -type d -name 'AutoMixMaster.app' | head -n 1)" + bash ./packaging/macos/codesign-bundled-dylibs.sh "$APP_BUNDLE" + - name: Package macOS zip run: | mkdir -p dist/release/macos diff --git a/CMakeLists.txt b/CMakeLists.txt index 510460d..5abe653 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -168,16 +168,36 @@ endif() set(AUTOMIX_HAS_NATIVE_ORT OFF) set(AUTOMIX_HAS_EP_PLUGIN OFF) set(AUTOMIX_HAS_EP_CONTEXT OFF) +option(AUTOMIX_FETCH_ORT "Automatically fetch pinned ONNX Runtime release archive" OFF) +set(AUTOMIX_ORT_VERSION "1.30.0" CACHE STRING "ONNX Runtime version to fetch") +set(AUTOMIX_ORT_FLAVOR "cpu" CACHE STRING "ONNX Runtime flavor (cpu or cuda)") + if(ENABLE_ONNX) - find_path(ONNXRUNTIME_INCLUDE_DIR - NAMES onnxruntime_cxx_api.h - PATH_SUFFIXES onnxruntime/core/session - ) - find_library(ONNXRUNTIME_LIBRARY NAMES onnxruntime) - if(ONNXRUNTIME_INCLUDE_DIR AND ONNXRUNTIME_LIBRARY) + if(AUTOMIX_FETCH_ORT) + include(cmake/FetchOnnxRuntime.cmake) target_include_directories(automix_core PUBLIC ${ONNXRUNTIME_INCLUDE_DIR}) - target_link_libraries(automix_core PUBLIC ${ONNXRUNTIME_LIBRARY}) + if(TARGET onnxruntime::onnxruntime) + target_link_libraries(automix_core PUBLIC onnxruntime::onnxruntime) + else() + target_link_libraries(automix_core PUBLIC ${ONNXRUNTIME_LIBRARY}) + endif() set(AUTOMIX_HAS_NATIVE_ORT ON) + else() + find_path(ONNXRUNTIME_INCLUDE_DIR + NAMES onnxruntime_cxx_api.h + PATH_SUFFIXES onnxruntime/core/session + ) + find_library(ONNXRUNTIME_LIBRARY NAMES onnxruntime) + if(ONNXRUNTIME_INCLUDE_DIR AND ONNXRUNTIME_LIBRARY) + target_include_directories(automix_core PUBLIC ${ONNXRUNTIME_INCLUDE_DIR}) + target_link_libraries(automix_core PUBLIC ${ONNXRUNTIME_LIBRARY}) + set(AUTOMIX_HAS_NATIVE_ORT ON) + message(WARNING "Using unpinned external ONNX Runtime. Recommended: configure with -DAUTOMIX_FETCH_ORT=ON") + endif() + endif() + + if(AUTOMIX_HAS_NATIVE_ORT) + target_compile_definitions(automix_core PUBLIC AUTOMIX_ORT_EXPECTED_VERSION="${AUTOMIX_ORT_VERSION}") # Feature-detect the optional APIs rather than parsing a version string out of # the headers. ONNX Runtime does not expose a reliable single version macro, and @@ -214,6 +234,114 @@ if(ENABLE_ONNX) endif() endif() +set(AUTOMIX_WEBGPU_PLUGIN_DIR "" CACHE PATH "Directory holding the ONNX Runtime WebGPU plugin EP (onnxruntime_providers_webgpu + dxcompiler/dxil on Windows)") + +set(AUTOMIX_ORT_RUNTIME_FILES "") +if(AUTOMIX_HAS_NATIVE_ORT) + get_filename_component(_ort_lib_dir "${ONNXRUNTIME_LIBRARY}" DIRECTORY) + if(WIN32) + foreach(_core_dll onnxruntime.dll onnxruntime_providers_shared.dll onnxruntime_providers_cuda.dll) + if(EXISTS "${_ort_lib_dir}/${_core_dll}") + list(APPEND AUTOMIX_ORT_RUNTIME_FILES "${_ort_lib_dir}/${_core_dll}") + endif() + endforeach() + if(AUTOMIX_WEBGPU_PLUGIN_DIR) + foreach(_webgpu_dll onnxruntime_providers_webgpu.dll dxcompiler.dll dxil.dll) + set(_webgpu_file "${AUTOMIX_WEBGPU_PLUGIN_DIR}/${_webgpu_dll}") + if(EXISTS "${_webgpu_file}") + list(APPEND AUTOMIX_ORT_RUNTIME_FILES "${_webgpu_file}") + else() + message(WARNING "WebGPU plugin file missing: ${_webgpu_file}") + endif() + endforeach() + endif() + elseif(APPLE) + file(GLOB _ort_dylibs LIST_DIRECTORIES false "${_ort_lib_dir}/libonnxruntime*.dylib") + foreach(_dylib IN LISTS _ort_dylibs) + if(NOT _dylib MATCHES "providers_tensorrt") + list(APPEND AUTOMIX_ORT_RUNTIME_FILES "${_dylib}") + endif() + endforeach() + if(AUTOMIX_WEBGPU_PLUGIN_DIR) + set(_webgpu_dylib "${AUTOMIX_WEBGPU_PLUGIN_DIR}/libonnxruntime_providers_webgpu.dylib") + if(EXISTS "${_webgpu_dylib}") + list(APPEND AUTOMIX_ORT_RUNTIME_FILES "${_webgpu_dylib}") + endif() + endif() + elseif(UNIX) + file(GLOB _ort_sos LIST_DIRECTORIES false "${_ort_lib_dir}/libonnxruntime*.so*") + foreach(_so IN LISTS _ort_sos) + if(NOT _so MATCHES "providers_tensorrt") + list(APPEND AUTOMIX_ORT_RUNTIME_FILES "${_so}") + endif() + endforeach() + if(AUTOMIX_WEBGPU_PLUGIN_DIR) + set(_webgpu_so "${AUTOMIX_WEBGPU_PLUGIN_DIR}/libonnxruntime_providers_webgpu.so") + if(EXISTS "${_webgpu_so}") + list(APPEND AUTOMIX_ORT_RUNTIME_FILES "${_webgpu_so}") + else() + message(WARNING "WebGPU plugin file missing: ${_webgpu_so}") + endif() + endif() + endif() +endif() + +function(automix_stage_ort TARGET_NAME) + if(NOT AUTOMIX_HAS_NATIVE_ORT OR NOT AUTOMIX_ORT_RUNTIME_FILES) + return() + endif() + if(WIN32) + set(_staging_files ${AUTOMIX_ORT_RUNTIME_FILES}) + if(AUTOMIX_CUDA_RUNTIME_DIR) + file(GLOB _automix_cuda_dlls "${AUTOMIX_CUDA_RUNTIME_DIR}/*.dll") + list(APPEND _staging_files ${_automix_cuda_dlls}) + endif() + add_custom_command(TARGET ${TARGET_NAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different ${_staging_files} "$" + VERBATIM) + elseif(APPLE) + get_target_property(_is_bundle ${TARGET_NAME} MACOSX_BUNDLE) + if(_is_bundle) + add_custom_command(TARGET ${TARGET_NAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory "$/../Frameworks" + COMMAND ${CMAKE_COMMAND} -E copy_if_different ${AUTOMIX_ORT_RUNTIME_FILES} "$/../Frameworks" + VERBATIM) + else() + add_custom_command(TARGET ${TARGET_NAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory "$/lib" + COMMAND ${CMAKE_COMMAND} -E copy_if_different ${AUTOMIX_ORT_RUNTIME_FILES} "$/lib" + VERBATIM) + endif() + elseif(UNIX) + add_custom_command(TARGET ${TARGET_NAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory "$/lib" + COMMAND ${CMAKE_COMMAND} -E copy_if_different ${AUTOMIX_ORT_RUNTIME_FILES} "$/lib" + VERBATIM) + endif() +endfunction() + +function(automix_set_rpath TARGET_NAME) + if(APPLE) + get_target_property(_is_bundle ${TARGET_NAME} MACOSX_BUNDLE) + if(_is_bundle) + set_target_properties(${TARGET_NAME} PROPERTIES + BUILD_RPATH "@executable_path/../Frameworks" + INSTALL_RPATH "@executable_path/../Frameworks" + ) + else() + set_target_properties(${TARGET_NAME} PROPERTIES + BUILD_RPATH "@loader_path/lib" + INSTALL_RPATH "@loader_path/lib" + ) + endif() + elseif(UNIX AND NOT APPLE) + set_target_properties(${TARGET_NAME} PROPERTIES + BUILD_RPATH "$ORIGIN/lib" + INSTALL_RPATH "$ORIGIN/lib" + ) + endif() +endfunction() + target_link_libraries(automix_core PUBLIC nlohmann_json::nlohmann_json @@ -490,93 +618,124 @@ if(BUILD_TESTING) catch_discover_tests(automix_tests) endif() -# Stage ONNX Runtime (and, optionally, CUDA/cuDNN) DLLs beside each executable. -# Windows resolves DLLs from the executable's directory first; without this an -# older onnxruntime.dll in System32 wins, and the CUDA provider cannot find its -# dependencies. Point AUTOMIX_CUDA_RUNTIME_DIR at a folder of cudart / cublas / -# cufft / cudnn DLLs to make the CUDA execution provider loadable. +# Stage ONNX Runtime (and, optionally, CUDA/cuDNN) DLLs beside each executable, +# and configure rpath for Linux ($ORIGIN/lib) and macOS (@executable_path/../Frameworks, @loader_path/lib). set(AUTOMIX_CUDA_RUNTIME_DIR "" CACHE PATH "Folder of CUDA runtime + cuDNN DLLs staged next to executables (optional)") -if(WIN32 AND AUTOMIX_HAS_NATIVE_ORT) - get_filename_component(_ort_lib_dir "${ONNXRUNTIME_LIBRARY}" DIRECTORY) - file(GLOB _automix_runtime_dlls "${_ort_lib_dir}/*.dll") - if(AUTOMIX_CUDA_RUNTIME_DIR) - file(GLOB _automix_cuda_dlls "${AUTOMIX_CUDA_RUNTIME_DIR}/*.dll") - list(APPEND _automix_runtime_dlls ${_automix_cuda_dlls}) - endif() - if(_automix_runtime_dlls) - foreach(_automix_target IN ITEMS automix_tests automix_dev_tools AutoMixMasterApp) - if(TARGET ${_automix_target}) - add_custom_command(TARGET ${_automix_target} POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy_if_different ${_automix_runtime_dlls} "$" - VERBATIM) - endif() - endforeach() - endif() + +automix_stage_ort(AutoMixMasterApp) +automix_set_rpath(AutoMixMasterApp) + +if(TARGET automix_dev_tools) + automix_stage_ort(automix_dev_tools) + automix_set_rpath(automix_dev_tools) +endif() + +if(TARGET automix_tests) + automix_stage_ort(automix_tests) + automix_set_rpath(automix_tests) +endif() + +if(TARGET automix_regression_cli) + automix_stage_ort(automix_regression_cli) + automix_set_rpath(automix_regression_cli) endif() -# ── Release packaging (Windows: portable ZIP via CPack) ────────────────────── +# ── Release packaging (Cross-platform portable package via CPack) ───────────── # cmake --install --config Release --component application --prefix -# or: cpack -C Release. Only the "application" component is packaged: the -# fetched dependencies (JUCE, nlohmann_json, libebur128) install headers and -# libraries of their own that do not belong in an app package. -# Ships the app, its assets and the ONNX Runtime build it was linked against -# (use the CUDA build for GPU support). Never ships NVIDIA's CUDA libraries - -# users fetch those on demand (GpuRuntimePack) - nor any model weights. -if(WIN32) - install(TARGETS AutoMixMasterApp RUNTIME DESTINATION . COMPONENT application) - if(AUTOMIX_HAS_NATIVE_ORT) - get_filename_component(_ort_pkg_dir "${ONNXRUNTIME_LIBRARY}" DIRECTORY) - # Core runtime plus the CUDA provider when present; the TensorRT provider - # is left out (it needs TensorRT itself, which nothing here uses). - foreach(_ort_dll onnxruntime.dll onnxruntime_providers_shared.dll onnxruntime_providers_cuda.dll) - if(EXISTS "${_ort_pkg_dir}/${_ort_dll}") - install(FILES "${_ort_pkg_dir}/${_ort_dll}" DESTINATION . COMPONENT application) - endif() - endforeach() +# or: cpack -C Release. Only the "application" component is packaged. +install(TARGETS AutoMixMasterApp + RUNTIME DESTINATION . COMPONENT application + BUNDLE DESTINATION . COMPONENT application + RESOURCE DESTINATION . COMPONENT application +) + +if(AUTOMIX_HAS_NATIVE_ORT AND AUTOMIX_ORT_RUNTIME_FILES) + if(WIN32) + install(FILES ${AUTOMIX_ORT_RUNTIME_FILES} DESTINATION . COMPONENT application) + elseif(APPLE) + install(FILES ${AUTOMIX_ORT_RUNTIME_FILES} DESTINATION "AutoMixMaster.app/Contents/Frameworks" COMPONENT application) + elseif(UNIX) + install(FILES ${AUTOMIX_ORT_RUNTIME_FILES} DESTINATION lib COMPONENT application) endif() - foreach(_dir IN LISTS AUTOMIX_PHASELIMITER_DIRS) - if(EXISTS "${CMAKE_SOURCE_DIR}/assets/phaselimiter/${_dir}") - install(DIRECTORY "${CMAKE_SOURCE_DIR}/assets/phaselimiter/${_dir}" DESTINATION assets/phaselimiter COMPONENT application) - endif() - endforeach() - foreach(_file IN LISTS AUTOMIX_PHASELIMITER_FILES) - if(EXISTS "${CMAKE_SOURCE_DIR}/assets/phaselimiter/${_file}") - install(FILES "${CMAKE_SOURCE_DIR}/assets/phaselimiter/${_file}" DESTINATION assets/phaselimiter COMPONENT application) - endif() - endforeach() - if(EXISTS "${CMAKE_SOURCE_DIR}/assets/limiters") - install(DIRECTORY "${CMAKE_SOURCE_DIR}/assets/limiters" DESTINATION assets COMPONENT application) +endif() + +foreach(_dir IN LISTS AUTOMIX_PHASELIMITER_DIRS) + if(EXISTS "${CMAKE_SOURCE_DIR}/assets/phaselimiter/${_dir}") + install(DIRECTORY "${CMAKE_SOURCE_DIR}/assets/phaselimiter/${_dir}" DESTINATION assets/phaselimiter COMPONENT application) endif() - foreach(_doc NOTICE LICENSE README.md) - if(EXISTS "${CMAKE_SOURCE_DIR}/${_doc}") - install(FILES "${CMAKE_SOURCE_DIR}/${_doc}" DESTINATION . COMPONENT application) - endif() - endforeach() - # Hard guard for NOTICE's promises: fail the install if a model file or an - # NVIDIA CUDA library got into the package. +endforeach() +foreach(_file IN LISTS AUTOMIX_PHASELIMITER_FILES) + if(EXISTS "${CMAKE_SOURCE_DIR}/assets/phaselimiter/${_file}") + install(FILES "${CMAKE_SOURCE_DIR}/assets/phaselimiter/${_file}" DESTINATION assets/phaselimiter COMPONENT application) + endif() +endforeach() +if(EXISTS "${CMAKE_SOURCE_DIR}/assets/limiters") + install(DIRECTORY "${CMAKE_SOURCE_DIR}/assets/limiters" DESTINATION assets COMPONENT application) +endif() +foreach(_doc NOTICE LICENSE README.md) + if(EXISTS "${CMAKE_SOURCE_DIR}/${_doc}") + install(FILES "${CMAKE_SOURCE_DIR}/${_doc}" DESTINATION . COMPONENT application) + endif() +endforeach() + +# macOS arm64: re-sign every bundled dylib with runtime hardened options before final packaging +if(APPLE) install(CODE [[ - file(GLOB_RECURSE _automix_forbidden - "${CMAKE_INSTALL_PREFIX}/*.onnx" "${CMAKE_INSTALL_PREFIX}/*.onnx.data" - "${CMAKE_INSTALL_PREFIX}/cudart*.dll" "${CMAKE_INSTALL_PREFIX}/cublas*.dll" - "${CMAKE_INSTALL_PREFIX}/cudnn*.dll" "${CMAKE_INSTALL_PREFIX}/cufft*.dll") - if(_automix_forbidden) - message(FATAL_ERROR "Package must not contain model weights or NVIDIA CUDA libraries: ${_automix_forbidden}") - endif() + file(GLOB _framework_dylibs "${CMAKE_INSTALL_PREFIX}/AutoMixMaster.app/Contents/Frameworks/*.dylib") + foreach(_dylib IN LISTS _framework_dylibs) + execute_process(COMMAND codesign --force --options runtime --sign - "${_dylib}") + endforeach() ]] COMPONENT application) +endif() + +# Hard guard for NOTICE's promises: fail the install if a model file or an +# NVIDIA CUDA library got into the package. +install(CODE [[ + file(GLOB_RECURSE _automix_forbidden + "${CMAKE_INSTALL_PREFIX}/*.onnx" "${CMAKE_INSTALL_PREFIX}/*.onnx.data" + "${CMAKE_INSTALL_PREFIX}/*cudart*.dll" "${CMAKE_INSTALL_PREFIX}/*cublas*.dll" + "${CMAKE_INSTALL_PREFIX}/*cudnn*.dll" "${CMAKE_INSTALL_PREFIX}/*cufft*.dll" + "${CMAKE_INSTALL_PREFIX}/*libcudart*.so*" "${CMAKE_INSTALL_PREFIX}/*libcublas*.so*" + "${CMAKE_INSTALL_PREFIX}/*libcudnn*.so*" "${CMAKE_INSTALL_PREFIX}/*libcufft*.so*" + "${CMAKE_INSTALL_PREFIX}/*libcudart*.dylib*" "${CMAKE_INSTALL_PREFIX}/*libcublas*.dylib*" + "${CMAKE_INSTALL_PREFIX}/*libcudnn*.dylib*" "${CMAKE_INSTALL_PREFIX}/*libcufft*.dylib*") + if(_automix_forbidden) + message(FATAL_ERROR "Package must not contain model weights or NVIDIA CUDA libraries: ${_automix_forbidden}") + endif() +]] COMPONENT application) +set(CPACK_PACKAGE_NAME "AutoMixMaster") +set(CPACK_PACKAGE_VENDOR "AutoMixMaster") + +if(WIN32) set(CPACK_GENERATOR ZIP) - set(CPACK_PACKAGE_NAME "AutoMixMaster") - set(CPACK_PACKAGE_VENDOR "AutoMixMaster") - if(AUTOMIX_HAS_NATIVE_ORT AND EXISTS "${_ort_pkg_dir}/onnxruntime_providers_cuda.dll") - set(CPACK_PACKAGE_FILE_NAME "AutoMixMaster-${PROJECT_VERSION}-win64-cuda") + if(AUTOMIX_HAS_NATIVE_ORT) + get_filename_component(_ort_pkg_dir "${ONNXRUNTIME_LIBRARY}" DIRECTORY) + if(EXISTS "${_ort_pkg_dir}/onnxruntime_providers_cuda.dll" OR AUTOMIX_ORT_FLAVOR STREQUAL "cuda") + set(CPACK_PACKAGE_FILE_NAME "AutoMixMaster-${PROJECT_VERSION}-win64-cuda") + elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "ARM64|aarch64") + set(CPACK_PACKAGE_FILE_NAME "AutoMixMaster-${PROJECT_VERSION}-winarm64") + else() + set(CPACK_PACKAGE_FILE_NAME "AutoMixMaster-${PROJECT_VERSION}-win64") + endif() else() set(CPACK_PACKAGE_FILE_NAME "AutoMixMaster-${PROJECT_VERSION}-win64") endif() - set(CPACK_INCLUDE_TOPLEVEL_DIRECTORY ON) - # Component mode is what makes CPack honour CPACK_COMPONENTS_ALL; grouped - # into one archive it still yields a single ZIP. - set(CPACK_COMPONENTS_ALL application) - set(CPACK_ARCHIVE_COMPONENT_INSTALL ON) - set(CPACK_COMPONENTS_GROUPING ALL_COMPONENTS_IN_ONE) - include(CPack) +elseif(APPLE) + set(CPACK_GENERATOR TGZ) + set(CPACK_PACKAGE_FILE_NAME "AutoMixMaster-${PROJECT_VERSION}-macos-arm64") +elseif(UNIX) + set(CPACK_GENERATOR TGZ) + if(CMAKE_SYSTEM_PROCESSOR MATCHES "ARM64|aarch64") + set(CPACK_PACKAGE_FILE_NAME "AutoMixMaster-${PROJECT_VERSION}-linux-arm64") + else() + set(CPACK_PACKAGE_FILE_NAME "AutoMixMaster-${PROJECT_VERSION}-linux-x64") + endif() endif() + +set(CPACK_INCLUDE_TOPLEVEL_DIRECTORY ON) +set(CPACK_COMPONENTS_ALL application) +set(CPACK_ARCHIVE_COMPONENT_INSTALL ON) +set(CPACK_COMPONENTS_GROUPING ALL_COMPONENTS_IN_ONE) +include(CPack) + diff --git a/README.md b/README.md index 8ab208e..732bc7d 100644 --- a/README.md +++ b/README.md @@ -117,8 +117,10 @@ AutoMixMaster is designed to benefit from **GPU acceleration** via ONNX Runtime - **CPU:** modern **6-core / 12-thread** desktop CPU (Ryzen 5 5600 / Core i5-12400 class) - **RAM:** **16 GB minimum** - **GPU:** compatible acceleration path with ~**6 GB VRAM** - - Windows DirectML path: **DirectX 12-capable GPU** - - CUDA path: **NVIDIA CUDA-capable GPU** + - Windows: **WebGPU (DirectX 12 / Vulkan)** or **NVIDIA CUDA** + - Linux: **WebGPU (Vulkan, requires `libvulkan1`)** or **NVIDIA CUDA** + - macOS (Apple Silicon): **CoreML / ANE** + - *(Note: Intel Macs do not support AI tensor/model inference; heuristics and audio processing remain functional)* - **Storage:** ~10 GB free (models, temp files, exports) ### Recommended (smoother) @@ -135,7 +137,7 @@ AutoMixMaster is designed to benefit from **GPU acceleration** via ONNX Runtime ### Why these estimates -- GPU acceleration matters most: DirectML needs a **DirectX 12** GPU and CUDA needs an **NVIDIA CUDA-capable** GPU ([DirectML](https://onnxruntime.ai/docs/execution-providers/DirectML-ExecutionProvider.html), [CUDA](https://onnxruntime.ai/docs/execution-providers/CUDA-ExecutionProvider.html)). +- GPU acceleration matters most: WebGPU needs a **DirectX 12 or Vulkan-capable** GPU, CoreML uses Apple Silicon GPU/ANE, and CUDA needs an **NVIDIA CUDA-capable** GPU ([CUDA](https://onnxruntime.ai/docs/execution-providers/CUDA-ExecutionProvider.html)). - Demucs notes roughly **3 GB minimum** and around **7 GB typical** GPU memory, so **8 GB+ VRAM** is a safer real-world target; CPU-only runs work but are slower ([Demucs README](https://github.com/facebookresearch/demucs/blob/main/README.md)). ### ONNX Runtime @@ -150,23 +152,23 @@ heuristic — it does not fail the build. See [docs/ito-master-validation.md](do | Minimum for the optional GPU paths | **1.22** | | Release cadence | roughly monthly — pin a minor series, not a patch | -The build locates ONNX Runtime with `find_path`/`find_library` and applies **no** version -constraint, so any installed SDK is used. Pin deliberately if you are validating a release. +The build locates ONNX Runtime with `find_package(onnxruntime 1.30.0 EXACT CONFIG)` when fetched +via `AUTOMIX_FETCH_ORT=ON`, or `find_path`/`find_library` for system-installed SDKs. #### Provider status (as of 1.30.x) | Provider | Status | Notes | |---|---|---| | **CPU** | always available | The baseline. Every GPU path falls back here on OOM or device loss, so the app never loses inference capability. | -| **CUDA** | current | Default packages target **CUDA 13.0** since 1.27. CUDA 12.8 packages are deprecated but still published through 1.30. cuDNN is optional at runtime from 1.28. | -| **DirectML** | maintenance mode | The `Microsoft.ML.OnnxRuntime.DirectML` NuGet is **frozen at 1.24.4** and caps at **opset ≤ 20**. It will not gain newer opsets, so prefer the options below for new work. | -| **CoreML** | current | Also covers the **Apple Neural Engine** — there is no separate ANE provider. ANE-specific work goes through CoreML. | +| **CUDA** | current | Default packages target **CUDA 13.0** since 1.27. cuDNN and CUDA runtime libraries are loaded dynamically at runtime when present. | +| **WebGPU** | current | Native plugin EP on Windows/Linux (via `Microsoft.ML.OnnxRuntime.EP.WebGpu` 0.4.0) and in-tree provider on Apple Silicon macOS. Default non-NVIDIA path for Windows and Linux. Requires `libvulkan1` on Linux. | +| **CoreML** | current | Built-in on macOS. Covers the **Apple Neural Engine** (`MLComputeUnits=CPUAndNeuralEngine` or `MLComputeUnits=ALL`). Note: Intel Macs do not support AI inference. | +| **DirectML** | maintenance mode | The `Microsoft.ML.OnnxRuntime.DirectML` NuGet is **frozen at 1.24.4** and caps at **opset ≤ 20**. WebGPU is now the primary non-NVIDIA Windows path. | | **OpenVINO** | split | Legacy wheel pinned at 1.24.1; the plugin `onnxruntime-ep-openvino` 1.7.0 requires ORT ≥ 1.23. | | **Windows ML** | GA (2025-09-23) | The recommended path for new Windows work. C++ needs the **self-contained** NuGet; framework-dependent C/C++ packages are not published. | -| **WebGPU** | preview | Native plugin EP, v0.4.0. | AutoMixMaster probes available providers and walks its own priority chain — **ANE → CoreML → -CUDA → OpenVINO → DirectML → CPU** (`src/ai/GpuProvider.h`). If session creation or inference +CUDA → WebGPU → OpenVINO → DirectML → CPU** (`src/ai/GpuProvider.h`). If session creation or inference fails, the provider is recorded as failed and the chain continues, so a broken or missing GPU runtime degrades to CPU instead of failing the render. @@ -181,7 +183,7 @@ way, and both features default to off. | Capability | Compile guard | Minimum ORT | Status | |---|---|---|---| -| CUDA provider supplied as a plugin library | `AUTOMIX_HAS_EP_PLUGIN` | 1.23 | Policy implemented; the `RegisterExecutionProviderLibrary` call is not yet wired | +| WebGPU provider supplied as a plugin library | `AUTOMIX_HAS_EP_PLUGIN` | 1.23 | Wired for WebGPU via `OrtRuntime` and `RegisterExecutionProviderLibrary` | | Per-GPU compiled-model cache (EPContext) | `AUTOMIX_HAS_EP_CONTEXT` | 1.22 | Policy implemented; the `OrtCompileApi` call is not yet wired | `src/ai/GpuProvider.h` holds the deciding logic for both — `parseOrtVersion`, diff --git a/cmake/FetchOnnxRuntime.cmake b/cmake/FetchOnnxRuntime.cmake new file mode 100644 index 0000000..bea8661 --- /dev/null +++ b/cmake/FetchOnnxRuntime.cmake @@ -0,0 +1,146 @@ +# cmake/FetchOnnxRuntime.cmake +# Fetches pinned ONNX Runtime release archives and the WebGPU EP plugin. +# +# Pinned version: 1.30.0 (exact pin convention) +# Supported architectures: +# Windows x64 (cpu or cuda), Windows arm64 (cpu) +# Linux x64 (cpu or cuda), Linux aarch64 (cpu) +# macOS arm64 (CoreML and WebGPU in-tree) +# macOS x86_64 is explicitly unsupported for AI inference (FATAL_ERROR). + +include(FetchContent) + +set(AUTOMIX_ORT_VERSION "1.30.0" CACHE STRING "ONNX Runtime version to fetch") +set(AUTOMIX_ORT_FLAVOR "cpu" CACHE STRING "ONNX Runtime flavor (cpu or cuda)") + +# Check U1: Intel Macs do not support ONNX Runtime +if(APPLE AND CMAKE_SYSTEM_PROCESSOR MATCHES "x86_64|amd64") + message(FATAL_ERROR "ONNX Runtime is not supported on Intel macOS. Please configure with -DENABLE_ONNX=OFF.") +endif() + +# Determine OS and Architecture keys +if(WIN32) + set(_os "win") + if(CMAKE_SYSTEM_PROCESSOR MATCHES "ARM64|aarch64") + set(_arch "arm64") + else() + set(_arch "x64") + endif() +elseif(APPLE) + set(_os "osx") + set(_arch "arm64") +elseif(UNIX) + set(_os "linux") + if(CMAKE_SYSTEM_PROCESSOR MATCHES "ARM64|aarch64") + set(_arch "aarch64") + else() + set(_arch "x64") + endif() +else() + message(FATAL_ERROR "Unsupported platform for AUTOMIX_FETCH_ORT: ${CMAKE_SYSTEM_NAME}") +endif() + +# Resolve archive URL and SHA-256 hash +set(_ort_url "") +set(_ort_hash "") + +if(_os STREQUAL "win") + if(_arch STREQUAL "x64") + if(AUTOMIX_ORT_FLAVOR STREQUAL "cuda") + set(_ort_url "https://github.com/microsoft/onnxruntime/releases/download/v1.30.0/onnxruntime-win-x64-gpu_cuda13-1.30.0.zip") + set(_ort_hash "SHA256=8fa4b08359af682cd605892cb59077049700b640128bb93fd2c7776cf9f55bdc") + else() + set(_ort_url "https://github.com/microsoft/onnxruntime/releases/download/v1.30.0/onnxruntime-win-x64-1.30.0.zip") + set(_ort_hash "SHA256=c6ba983baf5681af108599675d2a89c2d145512d02de28aed0bff177cd0ba949") + endif() + elseif(_arch STREQUAL "arm64") + set(_ort_url "https://github.com/microsoft/onnxruntime/releases/download/v1.30.0/onnxruntime-win-arm64-1.30.0.zip") + set(_ort_hash "SHA256=e53db8a50b23ae35be901cc93428baf997dc8d420333b097b2eae53d3ea9f2d3") + endif() +elseif(_os STREQUAL "linux") + if(_arch STREQUAL "x64") + if(AUTOMIX_ORT_FLAVOR STREQUAL "cuda") + set(_ort_url "https://github.com/microsoft/onnxruntime/releases/download/v1.30.0/onnxruntime-linux-x64-gpu_cuda13-1.30.0.tgz") + set(_ort_hash "SHA256=382d79133112388cf94ce5855789b7c9bef12bef76a08b6b277e5a317213adcd") + else() + set(_ort_url "https://github.com/microsoft/onnxruntime/releases/download/v1.30.0/onnxruntime-linux-x64-1.30.0.tgz") + set(_ort_hash "SHA256=a5ed5a3cac51fbb2e90da632ae43d19212faaa20e76484e62bcb7c23ddb3b3fd") + endif() + elseif(_arch STREQUAL "aarch64") + set(_ort_url "https://github.com/microsoft/onnxruntime/releases/download/v1.30.0/onnxruntime-linux-aarch64-1.30.0.tgz") + set(_ort_hash "SHA256=e16a27a8ed330bbc698df7330b0cf56e722f354e3bcc92118682c74ef3c3e3da") + endif() +elseif(_os STREQUAL "osx") + set(_ort_url "https://github.com/microsoft/onnxruntime/releases/download/v1.30.0/onnxruntime-osx-arm64-1.30.0.tgz") + set(_ort_hash "SHA256=6ebb5062a934537c352937821f9fe9718e7de1a2db1122a93dd363ffd53a7012") +endif() + +if(NOT _ort_url) + message(FATAL_ERROR "No ONNX Runtime 1.30.0 archive configured for ${_os}-${_arch} (flavor: ${AUTOMIX_ORT_FLAVOR})") +endif() + +message(STATUS "Fetching ONNX Runtime ${AUTOMIX_ORT_VERSION} (${AUTOMIX_ORT_FLAVOR}) for ${_os}-${_arch}...") + +FetchContent_Declare( + onnxruntime_fetched + URL "${_ort_url}" + URL_HASH "${_ort_hash}" + DOWNLOAD_EXTRACT_TIMESTAMP TRUE +) +FetchContent_MakeAvailable(onnxruntime_fetched) + +set(_ort_root "${onnxruntime_fetched_SOURCE_DIR}") + +# Fetch WebGPU EP plugin on Windows and Linux +if(WIN32 OR (UNIX AND NOT APPLE)) + message(STATUS "Fetching ONNX Runtime WebGPU EP plugin (NuGet 0.4.0)...") + FetchContent_Declare( + onnxruntime_webgpu_nupkg + URL "https://www.nuget.org/api/v2/package/Microsoft.ML.OnnxRuntime.EP.WebGpu/0.4.0" + URL_HASH "SHA256=cc2e093a2eaa760a630d6c3299f015f1dd86885b9eb5877257135308f7c5e923" + DOWNLOAD_NAME "Microsoft.ML.OnnxRuntime.EP.WebGpu.0.4.0.zip" + DOWNLOAD_EXTRACT_TIMESTAMP TRUE + ) + FetchContent_MakeAvailable(onnxruntime_webgpu_nupkg) + + if(WIN32) + if(_arch STREQUAL "arm64") + set(AUTOMIX_WEBGPU_PLUGIN_DIR "${onnxruntime_webgpu_nupkg_SOURCE_DIR}/runtimes/win-arm64/native" CACHE PATH "Directory holding WebGPU plugin" FORCE) + else() + set(AUTOMIX_WEBGPU_PLUGIN_DIR "${onnxruntime_webgpu_nupkg_SOURCE_DIR}/runtimes/win-x64/native" CACHE PATH "Directory holding WebGPU plugin" FORCE) + endif() + elseif(UNIX AND NOT APPLE) + if(_arch STREQUAL "aarch64") + set(AUTOMIX_WEBGPU_PLUGIN_DIR "${onnxruntime_webgpu_nupkg_SOURCE_DIR}/runtimes/linux-arm64/native" CACHE PATH "Directory holding WebGPU plugin" FORCE) + else() + set(AUTOMIX_WEBGPU_PLUGIN_DIR "${onnxruntime_webgpu_nupkg_SOURCE_DIR}/runtimes/linux-x64/native" CACHE PATH "Directory holding WebGPU plugin" FORCE) + endif() + endif() +endif() + +# Configure ONNX Runtime CMake target and variables +if(EXISTS "${_ort_root}/lib/cmake/onnxruntime/onnxruntimeConfig.cmake") + set(onnxruntime_DIR "${_ort_root}/lib/cmake/onnxruntime") + if(EXISTS "${_ort_root}/lib" AND NOT EXISTS "${_ort_root}/lib64") + file(CREATE_LINK "${_ort_root}/lib" "${_ort_root}/lib64" SYMBOLIC) + endif() + find_package(onnxruntime 1.30.0 EXACT CONFIG REQUIRED NO_DEFAULT_PATH) + set(ONNXRUNTIME_INCLUDE_DIR "${_ort_root}/include") + if(APPLE) + set(ONNXRUNTIME_LIBRARY "${_ort_root}/lib/libonnxruntime.dylib") + else() + set(ONNXRUNTIME_LIBRARY "${_ort_root}/lib/libonnxruntime.so") + endif() +else() + # Windows zip archives do not bundle CMake config files + set(ONNXRUNTIME_INCLUDE_DIR "${_ort_root}/include") + set(ONNXRUNTIME_LIBRARY "${_ort_root}/lib/onnxruntime.lib") + if(NOT TARGET onnxruntime::onnxruntime) + add_library(onnxruntime::onnxruntime SHARED IMPORTED) + set_target_properties(onnxruntime::onnxruntime PROPERTIES + INTERFACE_INCLUDE_DIRECTORIES "${ONNXRUNTIME_INCLUDE_DIR}" + IMPORTED_IMPLIB "${ONNXRUNTIME_LIBRARY}" + IMPORTED_LOCATION "${_ort_root}/lib/onnxruntime.dll" + ) + endif() +endif() diff --git a/packaging/macos/codesign-bundled-dylibs.sh b/packaging/macos/codesign-bundled-dylibs.sh new file mode 100644 index 0000000..99709eb --- /dev/null +++ b/packaging/macos/codesign-bundled-dylibs.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP_BUNDLE="${1:-}" +if [[ -z "$APP_BUNDLE" || ! -d "$APP_BUNDLE" ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +FRAMEWORKS_DIR="$APP_BUNDLE/Contents/Frameworks" +if [[ -d "$FRAMEWORKS_DIR" ]]; then + echo "Re-signing bundled dylibs in $FRAMEWORKS_DIR..." + find "$FRAMEWORKS_DIR" -type f \( -name "*.dylib" -o -name "*.dylib.*" \) | while read -r dylib; do + echo " Signing $dylib" + codesign --force --sign - --options runtime "$dylib" + done +else + echo "No Frameworks directory found in $APP_BUNDLE; skipping dylib signing." +fi diff --git a/packaging/windows/build-release.ps1 b/packaging/windows/build-release.ps1 index fe2a933..0f311bc 100644 --- a/packaging/windows/build-release.ps1 +++ b/packaging/windows/build-release.ps1 @@ -9,7 +9,8 @@ # powershell -File packaging\windows\build-release.ps1 ` # -OnnxRuntimeDir C:\lib\onnxruntime-win-x64-gpu_cuda13-1.30.0 param( - [Parameter(Mandatory = $true)][string]$OnnxRuntimeDir, + [string]$OnnxRuntimeDir = "", + [string]$WebGpuPluginDir = "", [string]$BuildDir = "build-release", [string]$Generator = "Visual Studio 18 2026", [switch]$SkipTests @@ -19,11 +20,13 @@ $ErrorActionPreference = "Stop" $repo = Resolve-Path (Join-Path $PSScriptRoot "..\..") Set-Location $repo -$include = Join-Path $OnnxRuntimeDir "include" -$library = Join-Path $OnnxRuntimeDir "lib\onnxruntime.lib" -if (-not (Test-Path $library)) { throw "ONNX Runtime library not found: $library" } -if (-not (Test-Path (Join-Path $OnnxRuntimeDir "lib\onnxruntime_providers_cuda.dll"))) { - Write-Warning "This ONNX Runtime has no CUDA provider; the package will run on CPU only." +if (-not $WebGpuPluginDir) { + $defaultWebGpu = "C:\lib\webgpu-win-x64\runtimes\win-x64\native" + if (Test-Path $defaultWebGpu) { + $WebGpuPluginDir = $defaultWebGpu + } elseif ($OnnxRuntimeDir) { + Write-Warning "No WebGPU plugin: the package will use CUDA or CPU only." + } } # A fresh directory: a reused cache could carry AUTOMIX_CUDA_RUNTIME_DIR or a @@ -32,12 +35,33 @@ if (Test-Path (Join-Path $BuildDir "CMakeCache.txt")) { throw "$BuildDir already holds a CMake cache; pass a new -BuildDir for a clean release build." } +$cmakeExtraArgs = @() +if ($OnnxRuntimeDir) { + $include = Join-Path $OnnxRuntimeDir "include" + $library = Join-Path $OnnxRuntimeDir "lib\onnxruntime.lib" + if (-not (Test-Path $library)) { throw "ONNX Runtime library not found: $library" } + if (-not (Test-Path (Join-Path $OnnxRuntimeDir "lib\onnxruntime_providers_cuda.dll"))) { + Write-Warning "This ONNX Runtime has no CUDA provider; the package will run on CPU only." + } + $cmakeExtraArgs += "-DONNXRUNTIME_INCLUDE_DIR=$include" + $cmakeExtraArgs += "-DONNXRUNTIME_LIBRARY=$library" + if ($WebGpuPluginDir) { + $cmakeExtraArgs += "-DAUTOMIX_WEBGPU_PLUGIN_DIR=$WebGpuPluginDir" + } +} else { + $cmakeExtraArgs += "-DAUTOMIX_FETCH_ORT=ON" + $cmakeExtraArgs += "-DAUTOMIX_ORT_FLAVOR=cuda" + if ($WebGpuPluginDir) { + $cmakeExtraArgs += "-DAUTOMIX_WEBGPU_PLUGIN_DIR=$WebGpuPluginDir" + } +} + cmake -S . -B $BuildDir -G $Generator -A x64 ` -DENABLE_ONNX=ON ` - "-DONNXRUNTIME_INCLUDE_DIR=$include" ` - "-DONNXRUNTIME_LIBRARY=$library" + @cmakeExtraArgs if ($LASTEXITCODE -ne 0) { throw "configure failed" } + cmake --build $BuildDir --config Release --parallel if ($LASTEXITCODE -ne 0) { throw "build failed" } diff --git a/src/ai/OrtRuntime.cpp b/src/ai/OrtRuntime.cpp index 328ec87..406c081 100644 --- a/src/ai/OrtRuntime.cpp +++ b/src/ai/OrtRuntime.cpp @@ -120,6 +120,12 @@ void OrtRuntime::ensureInitialized() { const auto decision = gpu::decidePluginEpAttempt(compiledIn, ortVersion, "webgpu", pluginPathStr); diagnostics_ = "ORT version: " + std::string(Ort::GetVersionString()); +#ifdef AUTOMIX_ORT_EXPECTED_VERSION + const std::string runtimeVer = Ort::GetVersionString(); + if (runtimeVer != AUTOMIX_ORT_EXPECTED_VERSION) { + diagnostics_ += " (WARNING: runtime version " + runtimeVer + " != expected " + AUTOMIX_ORT_EXPECTED_VERSION + ")"; + } +#endif #if AUTOMIX_HAS_EP_PLUGIN if (decision.attempt) { diff --git a/tools/commands/ModelCommands.cpp b/tools/commands/ModelCommands.cpp index b8ff8e9..dab43f3 100644 --- a/tools/commands/ModelCommands.cpp +++ b/tools/commands/ModelCommands.cpp @@ -637,6 +637,211 @@ int commandGpuRuntime(const std::vector& args) { return 2; } +// model bench --provider [--pack ] [--runs N] [--warmup N] [--json] [--out ] +int commandModelBench(const CommandArgs& args) { + const auto packArg = argValue(args, "--pack").value_or("assets/models/demo-mix-v1"); + const auto providerArg = argValue(args, "--provider").value_or("auto"); + const int warmupRuns = std::clamp(parseIntArg(args, "--warmup").value_or(2), 0, 50); + const int timedRuns = std::clamp(parseIntArg(args, "--runs").value_or(10), 1, 1000); + const bool jsonOutput = hasFlag(args, "--json"); + + const std::filesystem::path packDir(packArg); + automix::ai::ModelPackLoader loader; + const auto maybePack = loader.load(packDir); + if (!maybePack.has_value()) { + std::cerr << "Model benchmark failed: could not load model pack at " << packDir.string() << "\n"; + return 1; + } + const auto& pack = maybePack.value(); + + std::vector provLatencies; + std::vector cpuLatencies; + std::string actualProvider = "unknown"; + double maxDelta = 0.0; + + if (pack.tensorContract.has_value()) { + automix::ai::OnnxTensorInference cpuInference; + cpuInference.setTensorContract(pack.tensorContract); + cpuInference.setExecutionProvider("cpu"); + if (!cpuInference.loadModel(pack.rootPath / pack.modelFile)) { + std::cerr << "Model benchmark failed: could not load model on CPU: " << cpuInference.backendDiagnostics() << "\n"; + return 1; + } + + const auto inputSpecs = cpuInference.inputSpecs(); + std::vector cpuBindings; + for (const auto& spec : inputSpecs) { + size_t count = automix::ai::elementCount(spec).value_or(1024); + cpuBindings.push_back(automix::ai::TensorBinding{ + .expected = spec, + .data = std::vector(count, 0.1f), + }); + } + + for (int i = 0; i < warmupRuns; ++i) { + cpuInference.run(cpuBindings); + } + automix::ai::TensorInferenceResult cpuResult; + for (int i = 0; i < timedRuns; ++i) { + const auto start = std::chrono::steady_clock::now(); + cpuResult = cpuInference.run(cpuBindings); + const auto end = std::chrono::steady_clock::now(); + cpuLatencies.push_back(std::chrono::duration(end - start).count()); + } + + automix::ai::OnnxTensorInference provInference; + provInference.setTensorContract(pack.tensorContract); + provInference.setExecutionProvider(providerArg); + provInference.setGpuProviderAllowList(pack.gpuProviders); + if (!provInference.loadModel(pack.rootPath / pack.modelFile)) { + std::cerr << "Model benchmark failed: could not load model with provider '" << providerArg + << "': " << provInference.backendDiagnostics() << "\n"; + return 1; + } + actualProvider = provInference.activeExecutionProvider(); + + std::vector provBindings = cpuBindings; + + for (int i = 0; i < warmupRuns; ++i) { + provInference.run(provBindings); + } + automix::ai::TensorInferenceResult provResult; + for (int i = 0; i < timedRuns; ++i) { + const auto start = std::chrono::steady_clock::now(); + provResult = provInference.run(provBindings); + const auto end = std::chrono::steady_clock::now(); + provLatencies.push_back(std::chrono::duration(end - start).count()); + } + + for (size_t i = 0; i < cpuResult.outputs.size() && i < provResult.outputs.size(); ++i) { + const auto& cOut = cpuResult.outputs[i]; + const auto& pOut = provResult.outputs[i]; + const size_t sz = std::min(cOut.data.size(), pOut.data.size()); + for (size_t j = 0; j < sz; ++j) { + double diff = std::abs(static_cast(cOut.data[j]) - static_cast(pOut.data[j])); + if (diff > maxDelta) maxDelta = diff; + } + } + } else { + automix::ai::OnnxModelInference cpuInference; + cpuInference.setExecutionProviderPreference("cpu"); + if (!cpuInference.loadModel(pack.rootPath / pack.modelFile)) { + std::cerr << "Model benchmark failed: could not load model on CPU: " << cpuInference.backendDiagnostics() << "\n"; + return 1; + } + const size_t featureCount = pack.inputFeatureCount.value_or(automix::ai::FeatureSchemaV1::featureCount()); + const automix::ai::InferenceRequest request{ + .task = taskFromModelType(pack.type), + .features = deterministicFeatures(featureCount), + }; + + for (int i = 0; i < warmupRuns; ++i) { + cpuInference.run(request); + } + automix::ai::InferenceResult cpuResult; + for (int i = 0; i < timedRuns; ++i) { + const auto start = std::chrono::steady_clock::now(); + cpuResult = cpuInference.run(request); + const auto end = std::chrono::steady_clock::now(); + cpuLatencies.push_back(std::chrono::duration(end - start).count()); + } + + automix::ai::OnnxModelInference provInference; + provInference.setExecutionProviderPreference(providerArg); + if (!provInference.loadModel(pack.rootPath / pack.modelFile)) { + std::cerr << "Model benchmark failed: could not load model with provider '" << providerArg + << "': " << provInference.backendDiagnostics() << "\n"; + return 1; + } + actualProvider = provInference.activeExecutionProvider(); + + for (int i = 0; i < warmupRuns; ++i) { + provInference.run(request); + } + automix::ai::InferenceResult provResult; + for (int i = 0; i < timedRuns; ++i) { + const auto start = std::chrono::steady_clock::now(); + provResult = provInference.run(request); + const auto end = std::chrono::steady_clock::now(); + provLatencies.push_back(std::chrono::duration(end - start).count()); + } + + for (const auto& [k, v] : cpuResult.outputs) { + auto it = provResult.outputs.find(k); + if (it != provResult.outputs.end()) { + double diff = std::abs(static_cast(v) - static_cast(it->second)); + if (diff > maxDelta) maxDelta = diff; + } + } + } + + auto computeMedian = [](std::vector v) -> double { + if (v.empty()) return 0.0; + std::sort(v.begin(), v.end()); + if (v.size() % 2 == 1) return v[v.size() / 2]; + return 0.5 * (v[v.size() / 2 - 1] + v[v.size() / 2]); + }; + const double medianMs = computeMedian(provLatencies); + const double cpuMedianMs = computeMedian(cpuLatencies); + const double minMs = provLatencies.empty() ? 0.0 : *std::min_element(provLatencies.begin(), provLatencies.end()); + const double maxMs = provLatencies.empty() ? 0.0 : *std::max_element(provLatencies.begin(), provLatencies.end()); + const double speedup = (medianMs > 1e-6) ? (cpuMedianMs / medianMs) : 1.0; + + nlohmann::json payload = { + {"model", pack.id}, + {"requestedProvider", providerArg}, + {"actualProvider", actualProvider}, + {"warmupRuns", warmupRuns}, + {"timedRuns", timedRuns}, + {"medianLatencyMs", medianMs}, + {"minLatencyMs", minMs}, + {"maxLatencyMs", maxMs}, + {"cpuMedianLatencyMs", cpuMedianMs}, + {"speedupVsCpu", speedup}, + {"maxDeltaVsCpu", maxDelta}, + {"latenciesMs", provLatencies}, + {"passedParity", maxDelta < 1e-3}, + }; + + if (const auto outArg = argValue(args, "--out"); outArg.has_value()) { + writeJsonFile(*outArg, payload); + std::cout << "Model benchmark report: " << *outArg << "\n"; + } + + if (jsonOutput) { + std::cout << payload.dump(2) << "\n"; + } else { + std::cout << "Model benchmark: " << pack.id << "\n"; + std::cout << " Requested provider: " << providerArg << "\n"; + std::cout << " Actual provider: " << actualProvider << "\n"; + std::cout << " Warm-up runs: " << warmupRuns << "\n"; + std::cout << " Timed runs: " << timedRuns << "\n"; + std::cout << " Median latency: " << medianMs << " ms (min: " << minMs << " ms, max: " << maxMs << " ms)\n"; + std::cout << " CPU median latency: " << cpuMedianMs << " ms\n"; + std::cout << " Speedup vs CPU: " << speedup << "x\n"; + std::cout << " Max |Δ| vs CPU: " << maxDelta << "\n"; + } + + return 0; +} + +int commandModel(const CommandArgs& args) { + if (args.size() > 1) { + const std::string& sub = args[1]; + CommandArgs subArgs; + subArgs.push_back(sub); + for (size_t i = 2; i < args.size(); ++i) { + subArgs.push_back(args[i]); + } + if (sub == "bench") return commandModelBench(subArgs); + if (sub == "browse") return commandModelBrowse(subArgs); + if (sub == "install") return commandModelInstall(subArgs); + if (sub == "health") return commandModelHealth(subArgs); + } + std::cerr << "Usage: model bench|browse|install|health [options]\n"; + return 2; +} + } // namespace void registerModelCommands(automix::devtools::CommandRegistry& registry) { @@ -648,6 +853,8 @@ void registerModelCommands(automix::devtools::CommandRegistry& registry) { registry.add("validate-modelpack", commandValidateModelPack); registry.add("validate-external-limiter", commandValidateExternalLimiter); registry.add("external-limiter-compat", commandExternalLimiterCompat); + registry.add("model", commandModel); + registry.add("model-bench", commandModelBench); registry.add("model-browse", commandModelBrowse); registry.add("model-install", commandModelInstall); registry.add("model-health", commandModelHealth); diff --git a/tools/package_linux.sh b/tools/package_linux.sh index 22a82d9..99704d5 100644 --- a/tools/package_linux.sh +++ b/tools/package_linux.sh @@ -97,11 +97,12 @@ if [[ $SKIP_BUILD -eq 0 ]]; then if command -v ccache >/dev/null 2>&1; then ccache_launcher=(-DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache) fi - cmake -S "$REPO_ROOT" -B "$BUILD_DIR" -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF -DBUILD_TOOLS=OFF "${ccache_launcher[@]}" + cmake -S "$REPO_ROOT" -B "$BUILD_DIR" -DCMAKE_BUILD_TYPE=Release -DENABLE_ONNX=ON -DAUTOMIX_FETCH_ORT=ON -DAUTOMIX_ORT_FLAVOR=cpu -DBUILD_TESTING=OFF -DBUILD_TOOLS=OFF "${ccache_launcher[@]}" cmake --build "$BUILD_DIR" --config Release --target AutoMixMasterApp --parallel 3 if command -v ccache >/dev/null 2>&1; then ccache --show-stats fi + fi BINARY_PATH="" @@ -179,6 +180,13 @@ build_deb() { cp -a "$SOURCE_ASSETS_PATH" "$app_root/assets" sanitize_linux_assets "$app_root" + # Bundle ONNX Runtime and WebGPU plugin libraries if present + if [[ -d "$BUILD_DIR/lib" ]]; then + cp -a "$BUILD_DIR/lib" "$app_root/lib" + elif [[ -d "$(dirname "$BINARY_PATH")/lib" ]]; then + cp -a "$(dirname "$BINARY_PATH")/lib" "$app_root/lib" + fi + install -Dm755 /dev/null "$stage_dir/usr/bin/$PACKAGE_NAME" cat > "$stage_dir/usr/bin/$PACKAGE_NAME" <<'WRAPPER' #!/bin/sh @@ -201,6 +209,7 @@ Priority: optional Architecture: $deb_arch Maintainer: AutoMixMaster Depends: libc6 (>= 2.31), libstdc++6 (>= 11), libgcc-s1, libasound2, libfontconfig1, libfreetype6, libexpat1, zlib1g, libbz2-1.0, libpng16-16, libbrotli1 +Recommends: libvulkan1 Installed-Size: $installed_size Description: Deterministic auto mix and mastering desktop app AutoMixMaster provides one-click mix/master workflows, batch processing, @@ -242,12 +251,22 @@ build_appimage() { cp -a "$SOURCE_ASSETS_PATH" "$stage_dir/usr/bin/assets" sanitize_linux_assets "$stage_dir/usr/bin" + # Bundle ONNX Runtime and WebGPU plugin libraries beside the binary or in usr/lib + if [[ -d "$BUILD_DIR/lib" ]]; then + mkdir -p "$stage_dir/usr/bin/lib" + cp -a "$BUILD_DIR/lib/." "$stage_dir/usr/bin/lib/" + elif [[ -d "$(dirname "$BINARY_PATH")/lib" ]]; then + mkdir -p "$stage_dir/usr/bin/lib" + cp -a "$(dirname "$BINARY_PATH")/lib/." "$stage_dir/usr/bin/lib/" + fi + install -Dm644 "$REPO_ROOT/packaging/linux/automixmaster.svg" "$stage_dir/$PACKAGE_NAME.svg" install -Dm644 "$REPO_ROOT/packaging/linux/automixmaster.svg" "$stage_dir/usr/share/icons/hicolor/scalable/apps/$PACKAGE_NAME.svg" create_desktop_file "$stage_dir/$PACKAGE_NAME.desktop" "$APP_NAME" "$PACKAGE_NAME" cp "$stage_dir/$PACKAGE_NAME.desktop" "$stage_dir/usr/share/applications/$PACKAGE_NAME.desktop" + ln -sf "$PACKAGE_NAME.svg" "$stage_dir/.DirIcon" cat > "$stage_dir/AppRun" <<'APPRUN' From 801e4219d2a68255e2d9499c5dba1a959ced6f57 Mon Sep 17 00:00:00 2001 From: Soficis Date: Fri, 2 Oct 2026 20:27:06 -0500 Subject: [PATCH 20/68] fix(build): add separate overload for Clang and handle ORT include/onnxruntime path --- cmake/FetchOnnxRuntime.cmake | 4 ++++ src/ai/OnnxModelInference.cpp | 2 +- src/ai/StemSeparator.cpp | 7 ++++++- src/ai/StemSeparator.h | 4 +++- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/cmake/FetchOnnxRuntime.cmake b/cmake/FetchOnnxRuntime.cmake index bea8661..e6efb6a 100644 --- a/cmake/FetchOnnxRuntime.cmake +++ b/cmake/FetchOnnxRuntime.cmake @@ -124,7 +124,11 @@ if(EXISTS "${_ort_root}/lib/cmake/onnxruntime/onnxruntimeConfig.cmake") if(EXISTS "${_ort_root}/lib" AND NOT EXISTS "${_ort_root}/lib64") file(CREATE_LINK "${_ort_root}/lib" "${_ort_root}/lib64" SYMBOLIC) endif() + if(NOT EXISTS "${_ort_root}/include/onnxruntime") + file(MAKE_DIRECTORY "${_ort_root}/include/onnxruntime") + endif() find_package(onnxruntime 1.30.0 EXACT CONFIG REQUIRED NO_DEFAULT_PATH) + target_include_directories(onnxruntime::onnxruntime INTERFACE "${_ort_root}/include") set(ONNXRUNTIME_INCLUDE_DIR "${_ort_root}/include") if(APPLE) set(ONNXRUNTIME_LIBRARY "${_ort_root}/lib/libonnxruntime.dylib") diff --git a/src/ai/OnnxModelInference.cpp b/src/ai/OnnxModelInference.cpp index 8bc9cf1..0c4aa83 100644 --- a/src/ai/OnnxModelInference.cpp +++ b/src/ai/OnnxModelInference.cpp @@ -45,7 +45,7 @@ std::string canonicalProviderName(const std::string& rawProvider) { return gpu::canonicalProviderName(rawProvider); } -std::string platformPreferredProvider() { +[[maybe_unused]] std::string platformPreferredProvider() { return gpu::platformPreferredProvider(); } diff --git a/src/ai/StemSeparator.cpp b/src/ai/StemSeparator.cpp index aec4d3d..95ba969 100644 --- a/src/ai/StemSeparator.cpp +++ b/src/ai/StemSeparator.cpp @@ -594,7 +594,7 @@ OverlapAddResult runModelBackedOverlapAdd(const engine::AudioBuffer& mixBuffer, return result; } - result.stems.reserve(result.stemCount); + result.stems.reserve(static_cast(result.stemCount)); for (int index = 0; index < result.stemCount; ++index) { result.stems.emplace_back(channels, samples, mixBuffer.getSampleRate()); } @@ -1155,6 +1155,11 @@ bool StemSeparator::isTensorModelAvailable() const { return loadTensorPack(modelRoot_, reason).has_value(); } +StemSeparator::SeparationResult StemSeparator::separate(const std::filesystem::path& mixPath, + const std::filesystem::path& outputDir) const { + return separate(mixPath, outputDir, SeparationOptions{}); +} + StemSeparator::SeparationResult StemSeparator::separate(const std::filesystem::path& mixPath, const std::filesystem::path& outputDir, const SeparationOptions& options) const { diff --git a/src/ai/StemSeparator.h b/src/ai/StemSeparator.h index ee92f2c..e99a074 100644 --- a/src/ai/StemSeparator.h +++ b/src/ai/StemSeparator.h @@ -57,9 +57,11 @@ class StemSeparator final { // True when the model root is a pack carrying a tensor_contract whose model // file exists. Says nothing about whether ONNX Runtime can open it. [[nodiscard]] bool isTensorModelAvailable() const; + SeparationResult separate(const std::filesystem::path& mixPath, + const std::filesystem::path& outputDir) const; SeparationResult separate(const std::filesystem::path& mixPath, const std::filesystem::path& outputDir, - const SeparationOptions& options = {}) const; + const SeparationOptions& options) const; private: [[nodiscard]] std::filesystem::path resolveModelPath() const; From b4f7baf6d7a418d41190fb4c73c23211761e4e55 Mon Sep 17 00:00:00 2001 From: Soficis Date: Fri, 2 Oct 2026 20:29:16 -0500 Subject: [PATCH 21/68] fix(test): use portable std::atomic_load/store on shared_ptr in AudioIoTests --- tests/unit/AudioIoTests.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit/AudioIoTests.cpp b/tests/unit/AudioIoTests.cpp index 580aa21..49554b5 100644 --- a/tests/unit/AudioIoTests.cpp +++ b/tests/unit/AudioIoTests.cpp @@ -200,7 +200,7 @@ TEST_CASE("Preview bridge meter targets round-trip without torn reads", "[audioi TEST_CASE("Preview bridge buffer swap never tears and keeps last writer", "[audioio][previewbridge]") { constexpr int kIterations = 2000; - std::atomic> buffer{nullptr}; + std::shared_ptr buffer{nullptr}; std::atomic done{false}; std::atomic readerOk{true}; @@ -209,14 +209,14 @@ TEST_CASE("Preview bridge buffer swap never tears and keeps last writer", "[audi auto next = std::make_shared(2, 256 + (i % 3), 44100.0); next->setSample(0, 0, static_cast(i)); - buffer.store(std::move(next), std::memory_order_release); + std::atomic_store_explicit(&buffer, std::move(next), std::memory_order_release); } done.store(true); }); std::thread reader([&] { while (!done.load()) { - const auto current = buffer.load(std::memory_order_acquire); + const auto current = std::atomic_load_explicit(&buffer, std::memory_order_acquire); if (current == nullptr) continue; // While holding the shared_ptr the published buffer must be fully valid @@ -234,7 +234,7 @@ TEST_CASE("Preview bridge buffer swap never tears and keeps last writer", "[audi reader.join(); REQUIRE(readerOk.load()); - const auto finalBuffer = buffer.load(); + const auto finalBuffer = std::atomic_load(&buffer); REQUIRE(finalBuffer != nullptr); REQUIRE(finalBuffer->getNumSamples() == 256 + ((kIterations - 1) % 3)); const float lastSample = finalBuffer->getSample(0, 0); From 543d617ab5c492851356c8ac1bf7bc58b769c2ed Mon Sep 17 00:00:00 2001 From: Soficis Date: Fri, 2 Oct 2026 20:29:49 -0500 Subject: [PATCH 22/68] fix(test): match const type for atomic_store_explicit in AudioIoTests --- tests/unit/AudioIoTests.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/AudioIoTests.cpp b/tests/unit/AudioIoTests.cpp index 49554b5..f0c9bb4 100644 --- a/tests/unit/AudioIoTests.cpp +++ b/tests/unit/AudioIoTests.cpp @@ -209,7 +209,8 @@ TEST_CASE("Preview bridge buffer swap never tears and keeps last writer", "[audi auto next = std::make_shared(2, 256 + (i % 3), 44100.0); next->setSample(0, 0, static_cast(i)); - std::atomic_store_explicit(&buffer, std::move(next), std::memory_order_release); + std::shared_ptr constNext = std::move(next); + std::atomic_store_explicit(&buffer, std::move(constNext), std::memory_order_release); } done.store(true); }); From 90cdf4b77f484cde22ffac020e4e0d5af4da079d Mon Sep 17 00:00:00 2001 From: Soficis Date: Fri, 2 Oct 2026 20:37:14 -0500 Subject: [PATCH 23/68] fix(renderers): restrict PhaseLimiter .exe discovery to Windows and test fallback --- src/renderers/PhaseLimiterDiscovery.cpp | 2 +- tests/integration/PipelineIntegrationTests.cpp | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/renderers/PhaseLimiterDiscovery.cpp b/src/renderers/PhaseLimiterDiscovery.cpp index 643a514..66d135b 100644 --- a/src/renderers/PhaseLimiterDiscovery.cpp +++ b/src/renderers/PhaseLimiterDiscovery.cpp @@ -74,7 +74,7 @@ std::vector executableNames() { #if defined(_WIN32) return {"phase_limiter.exe", "phaselimiter.exe", "phase_limiter", "phaselimiter"}; #else - return {"phase_limiter", "phaselimiter", "phase_limiter.bin", "phaselimiter.bin", "phase_limiter.exe"}; + return {"phase_limiter", "phaselimiter", "phase_limiter.bin", "phaselimiter.bin"}; #endif } diff --git a/tests/integration/PipelineIntegrationTests.cpp b/tests/integration/PipelineIntegrationTests.cpp index 75400b0..a3c037b 100644 --- a/tests/integration/PipelineIntegrationTests.cpp +++ b/tests/integration/PipelineIntegrationTests.cpp @@ -61,7 +61,11 @@ TEST_CASE("Integration: rendering chain resolves correct default order", "[integ const auto chain = automix::renderers::resolveRendererChain(settings); REQUIRE_FALSE(chain.empty()); - REQUIRE(chain.front() == "PhaseLimiter"); + if (std::find(chain.begin(), chain.end(), "PhaseLimiter") != chain.end()) { + REQUIRE(chain.front() == "PhaseLimiter"); + } else { + REQUIRE(chain.front() == "BuiltIn"); + } } TEST_CASE("Integration: each master preset produces different loudness targets", "[integration]") { From e7da156a8bc98c9fc3b1661bcd9aaab8cd4ccb78 Mon Sep 17 00:00:00 2001 From: Soficis Date: Fri, 2 Oct 2026 21:23:46 -0500 Subject: [PATCH 24/68] test(integration): PhaseLimiter must be discoverable and lead the chain on every platform --- tests/integration/PipelineIntegrationTests.cpp | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/integration/PipelineIntegrationTests.cpp b/tests/integration/PipelineIntegrationTests.cpp index a3c037b..b9316b5 100644 --- a/tests/integration/PipelineIntegrationTests.cpp +++ b/tests/integration/PipelineIntegrationTests.cpp @@ -8,6 +8,7 @@ #include "domain/Session.h" #include "domain/Stem.h" #include "engine/AudioBuffer.h" +#include "renderers/PhaseLimiterDiscovery.h" #include "renderers/RendererPipeline.h" namespace { @@ -59,13 +60,17 @@ TEST_CASE("Integration: rendering chain resolves correct default order", "[integ settings.rendererChainEnabled = true; settings.rendererChainMode = "logical_all"; + // PhaseLimiter must exist and run on every platform (owner requirement), so a + // missing install is a failure here, never a silent BuiltIn fallback. + const auto phaseLimiter = automix::renderers::PhaseLimiterDiscovery{}.find(); + INFO("PhaseLimiter not discovered on this platform: install a native, complete build " + "(binary + resource/mastering_reference.json) under assets/phaselimiter or the cache root"); + REQUIRE(phaseLimiter.has_value()); + REQUIRE(automix::renderers::isCompleteInstall(*phaseLimiter)); + const auto chain = automix::renderers::resolveRendererChain(settings); REQUIRE_FALSE(chain.empty()); - if (std::find(chain.begin(), chain.end(), "PhaseLimiter") != chain.end()) { - REQUIRE(chain.front() == "PhaseLimiter"); - } else { - REQUIRE(chain.front() == "BuiltIn"); - } + REQUIRE(chain.front() == "PhaseLimiter"); } TEST_CASE("Integration: each master preset produces different loudness targets", "[integration]") { From 0913f312e12d31ab050d05683db1e26dcf93f622 Mon Sep 17 00:00:00 2001 From: Soficis Date: Fri, 2 Oct 2026 21:40:28 -0500 Subject: [PATCH 25/68] fix(bench): exit 3 on fallback, print ORT diagnostics, and test stub exits 3 --- CMakeLists.txt | 2 +- tests/unit/ModelBenchmarkTests.cpp | 27 +++++++++++++++ tools/commands/ModelCommands.cpp | 55 +++++++++++++++++++++++------- 3 files changed, 71 insertions(+), 13 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 5abe653..2f8094c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -584,7 +584,7 @@ if(BUILD_TESTING) src/app/controllers/OriginalMixController.cpp ) - target_include_directories(automix_tests PRIVATE src tests/regression tests/integration) + target_include_directories(automix_tests PRIVATE src tests/regression tests/integration tools) target_link_libraries(automix_tests PRIVATE automix_core Catch2::Catch2WithMain juce::juce_gui_extra) target_compile_definitions(automix_tests PRIVATE AUTOMIX_SOURCE_DIR="${CMAKE_SOURCE_DIR}" diff --git a/tests/unit/ModelBenchmarkTests.cpp b/tests/unit/ModelBenchmarkTests.cpp index 58da5a6..701f31d 100644 --- a/tests/unit/ModelBenchmarkTests.cpp +++ b/tests/unit/ModelBenchmarkTests.cpp @@ -44,3 +44,30 @@ TEST_CASE("ONNX model load failure is graceful", "[benchmark][onnx]") { REQUIRE_FALSE(loaded); REQUIRE_FALSE(inference.isAvailable()); } + +#include + +TEST_CASE("Model benchmark on demo-mix-v1 stub exits with code 3", "[benchmark][model]") { + const juce::File exe = juce::File::getSpecialLocation(juce::File::currentExecutableFile); + const auto devTools = exe.getSiblingFile("automix_dev_tools" +#if defined(_WIN32) + ".exe" +#endif + ); + if (!devTools.existsAsFile()) { + SKIP("automix_dev_tools executable not found in test directory"); + } + + juce::StringArray args; + args.add(devTools.getFullPathName()); + args.add("model"); + args.add("bench"); + args.add("--pack"); + args.add("assets/models/demo-mix-v1"); + + juce::ChildProcess process; + REQUIRE(process.start(args)); + REQUIRE(process.waitForProcessToFinish(30000)); + REQUIRE(process.getExitCode() == 3); +} + diff --git a/tools/commands/ModelCommands.cpp b/tools/commands/ModelCommands.cpp index dab43f3..06fba47 100644 --- a/tools/commands/ModelCommands.cpp +++ b/tools/commands/ModelCommands.cpp @@ -14,6 +14,7 @@ #include "ai/ModelStorage.h" #include "ai/OnnxModelInference.h" #include "ai/OnnxTensorInference.h" +#include "ai/OrtRuntime.h" #include "ai/StemSeparator.h" #include "renderers/ExternalLimiterRenderer.h" #include "util/LameDownloader.h" @@ -603,6 +604,7 @@ int commandGpuRuntime(const std::vector& args) { std::string provider; const bool gpu = automix::ai::gpuTensorSessionAvailable(&provider); std::cout << " GPU tensor session: " << (gpu ? provider : std::string("unavailable")) << "\n"; + std::cout << " ORT diagnostics: " << automix::ai::OrtRuntime::instance().diagnostics() << "\n"; return 0; } if (action == "install") { @@ -658,14 +660,15 @@ int commandModelBench(const CommandArgs& args) { std::vector cpuLatencies; std::string actualProvider = "unknown"; double maxDelta = 0.0; + const bool isTensor = pack.tensorContract.has_value(); - if (pack.tensorContract.has_value()) { + if (isTensor) { automix::ai::OnnxTensorInference cpuInference; cpuInference.setTensorContract(pack.tensorContract); cpuInference.setExecutionProvider("cpu"); if (!cpuInference.loadModel(pack.rootPath / pack.modelFile)) { - std::cerr << "Model benchmark failed: could not load model on CPU: " << cpuInference.backendDiagnostics() << "\n"; - return 1; + std::cerr << "ERROR: could not load tensor model on CPU: " << cpuInference.backendDiagnostics() << "\n"; + return 3; } const auto inputSpecs = cpuInference.inputSpecs(); @@ -694,9 +697,9 @@ int commandModelBench(const CommandArgs& args) { provInference.setExecutionProvider(providerArg); provInference.setGpuProviderAllowList(pack.gpuProviders); if (!provInference.loadModel(pack.rootPath / pack.modelFile)) { - std::cerr << "Model benchmark failed: could not load model with provider '" << providerArg + std::cerr << "ERROR: could not load tensor model with provider '" << providerArg << "': " << provInference.backendDiagnostics() << "\n"; - return 1; + return 3; } actualProvider = provInference.activeExecutionProvider(); @@ -726,8 +729,8 @@ int commandModelBench(const CommandArgs& args) { automix::ai::OnnxModelInference cpuInference; cpuInference.setExecutionProviderPreference("cpu"); if (!cpuInference.loadModel(pack.rootPath / pack.modelFile)) { - std::cerr << "Model benchmark failed: could not load model on CPU: " << cpuInference.backendDiagnostics() << "\n"; - return 1; + std::cerr << "ERROR: could not load model on CPU: " << cpuInference.backendDiagnostics() << "\n"; + return 3; } const size_t featureCount = pack.inputFeatureCount.value_or(automix::ai::FeatureSchemaV1::featureCount()); const automix::ai::InferenceRequest request{ @@ -735,6 +738,12 @@ int commandModelBench(const CommandArgs& args) { .features = deterministicFeatures(featureCount), }; + const auto cpuProbe = cpuInference.run(request); + if (!cpuProbe.usedModel || !cpuInference.usingNativeSession()) { + std::cerr << "ERROR: model did not execute (deterministic fallback); nothing measured\n"; + return 3; + } + for (int i = 0; i < warmupRuns; ++i) { cpuInference.run(request); } @@ -749,12 +758,18 @@ int commandModelBench(const CommandArgs& args) { automix::ai::OnnxModelInference provInference; provInference.setExecutionProviderPreference(providerArg); if (!provInference.loadModel(pack.rootPath / pack.modelFile)) { - std::cerr << "Model benchmark failed: could not load model with provider '" << providerArg + std::cerr << "ERROR: could not load model with provider '" << providerArg << "': " << provInference.backendDiagnostics() << "\n"; - return 1; + return 3; } actualProvider = provInference.activeExecutionProvider(); + const auto provProbe = provInference.run(request); + if (!provProbe.usedModel || !provInference.usingNativeSession()) { + std::cerr << "ERROR: model did not execute (deterministic fallback); nothing measured\n"; + return 3; + } + for (int i = 0; i < warmupRuns; ++i) { provInference.run(request); } @@ -785,24 +800,36 @@ int commandModelBench(const CommandArgs& args) { const double cpuMedianMs = computeMedian(cpuLatencies); const double minMs = provLatencies.empty() ? 0.0 : *std::min_element(provLatencies.begin(), provLatencies.end()); const double maxMs = provLatencies.empty() ? 0.0 : *std::max_element(provLatencies.begin(), provLatencies.end()); - const double speedup = (medianMs > 1e-6) ? (cpuMedianMs / medianMs) : 1.0; + + const bool providerMatches = (providerArg == "auto") || (actualProvider == providerArg); + const bool shouldReportSpeedup = (actualProvider != "cpu") && providerMatches; + const std::optional speedup = shouldReportSpeedup && (medianMs > 1e-6) + ? std::optional(cpuMedianMs / medianMs) + : std::nullopt; + + const auto ortDiagnostics = automix::ai::OrtRuntime::instance().diagnostics(); nlohmann::json payload = { {"model", pack.id}, {"requestedProvider", providerArg}, {"actualProvider", actualProvider}, + {isTensor ? "loaded" : "usedModel", true}, + {"diagnostics", ortDiagnostics}, {"warmupRuns", warmupRuns}, {"timedRuns", timedRuns}, {"medianLatencyMs", medianMs}, {"minLatencyMs", minMs}, {"maxLatencyMs", maxMs}, {"cpuMedianLatencyMs", cpuMedianMs}, - {"speedupVsCpu", speedup}, {"maxDeltaVsCpu", maxDelta}, {"latenciesMs", provLatencies}, {"passedParity", maxDelta < 1e-3}, }; + if (speedup.has_value()) { + payload["speedupVsCpu"] = *speedup; + } + if (const auto outArg = argValue(args, "--out"); outArg.has_value()) { writeJsonFile(*outArg, payload); std::cout << "Model benchmark report: " << *outArg << "\n"; @@ -814,11 +841,15 @@ int commandModelBench(const CommandArgs& args) { std::cout << "Model benchmark: " << pack.id << "\n"; std::cout << " Requested provider: " << providerArg << "\n"; std::cout << " Actual provider: " << actualProvider << "\n"; + std::cout << " " << (isTensor ? "Loaded" : "Used real model") << ": true\n"; + std::cout << " ORT diagnostics: " << ortDiagnostics << "\n"; std::cout << " Warm-up runs: " << warmupRuns << "\n"; std::cout << " Timed runs: " << timedRuns << "\n"; std::cout << " Median latency: " << medianMs << " ms (min: " << minMs << " ms, max: " << maxMs << " ms)\n"; std::cout << " CPU median latency: " << cpuMedianMs << " ms\n"; - std::cout << " Speedup vs CPU: " << speedup << "x\n"; + if (speedup.has_value()) { + std::cout << " Speedup vs CPU: " << *speedup << "x\n"; + } std::cout << " Max |Δ| vs CPU: " << maxDelta << "\n"; } From 608a6f1cb8f303f8bb3d5ff8c4c0a3265ffa0189 Mon Sep 17 00:00:00 2001 From: Soficis Date: Fri, 2 Oct 2026 21:57:15 -0500 Subject: [PATCH 26/68] feat(renderers): pinned PhaseLimiter acquisition with SHA-256 verification --- src/renderers/PhaseLimiterDiscovery.cpp | 260 +++++++++++++++------- src/renderers/PhaseLimiterDiscovery.h | 21 ++ tests/unit/PhaseLimiterDiscoveryTests.cpp | 84 +++++++ 3 files changed, 286 insertions(+), 79 deletions(-) diff --git a/src/renderers/PhaseLimiterDiscovery.cpp b/src/renderers/PhaseLimiterDiscovery.cpp index 66d135b..8bc7106 100644 --- a/src/renderers/PhaseLimiterDiscovery.cpp +++ b/src/renderers/PhaseLimiterDiscovery.cpp @@ -11,6 +11,7 @@ #include #include "util/FileUtils.h" +#include "util/Sha256.h" #include "util/StringUtils.h" namespace automix::renderers { @@ -250,20 +251,95 @@ bool downloadToFile(const std::string& url, const std::filesystem::path& outputP return true; } -std::optional defaultDownloadUrl() { - if (const auto manual = readEnvironment("AUTOMIX_PHASELIMITER_DOWNLOAD_URL"); manual.has_value()) { - return manual; - } +} // namespace +std::string currentPhaseLimiterPlatformKey() { #if defined(_WIN32) - return std::string("https://github.com/ai-mastering/phaselimiter/releases/download/v0.2.0/phaselimiter-win.zip"); + #if defined(_M_X64) || defined(__x86_64__) + return "windows-x64"; + #elif defined(_M_ARM64) || defined(__aarch64__) + return "windows-arm64"; + #else + return "windows-x86"; + #endif +#elif defined(__APPLE__) + #if defined(__aarch64__) || defined(__arm64__) + return "macos-arm64"; + #elif defined(__x86_64__) + return "macos-x86_64"; + #else + return "macos-unknown"; + #endif #elif defined(__linux__) - return std::string("https://github.com/ai-mastering/phaselimiter/releases/download/v0.2.0/release.tar.xz"); + #if defined(__x86_64__) + return "linux-x64"; + #elif defined(__aarch64__) + return "linux-arm64"; + #else + return "linux-unknown"; + #endif #else - return std::nullopt; + return "unknown"; #endif } +std::vector phaseLimiterDownloadPinTable() { + return { + {"windows-x64", + "https://github.com/ai-mastering/phaselimiter/releases/download/v0.2.0/phaselimiter-win.zip", + "cab2d30ad8d993a383749b30d9f6dc1911198d3aa309d5f631b70206e0162145"}, + {"linux-x64", + "https://github.com/ai-mastering/phaselimiter/releases/download/v0.2.0/release.tar.xz", + "0b382ba78b030926f706345d1b00d5f45890ba384c8a4e960320e3ee992bd163"}, + {"macos-arm64", + "https://github.com/soficis/phaselimiter/releases/download/v0.2.0-native1/phaselimiter-0.2.0-macos-arm64.tar.xz", + ""}, + {"macos-x86_64", + "https://github.com/soficis/phaselimiter/releases/download/v0.2.0-native1/phaselimiter-0.2.0-macos-x86_64.tar.xz", + ""}, + {"linux-arm64", + "https://github.com/soficis/phaselimiter/releases/download/v0.2.0-native1/phaselimiter-0.2.0-linux-arm64.tar.xz", + ""}, + }; +} + +std::optional defaultPhaseLimiterDownloadPin() { + if (const auto manual = readEnvironment("AUTOMIX_PHASELIMITER_DOWNLOAD_URL"); manual.has_value()) { + PhaseLimiterDownloadPin pin; + pin.platformKey = currentPhaseLimiterPlatformKey(); + pin.url = manual.value(); + if (const auto manualSha = readEnvironment("AUTOMIX_PHASELIMITER_DOWNLOAD_SHA256"); manualSha.has_value()) { + pin.sha256 = manualSha.value(); + } + return pin; + } + + const auto currentKey = currentPhaseLimiterPlatformKey(); + const auto table = phaseLimiterDownloadPinTable(); + for (const auto& pin : table) { + if (pin.platformKey == currentKey) { + if (pin.url.empty()) { + return std::nullopt; + } + return pin; + } + } + return std::nullopt; +} + +namespace { + +static std::optional gDownloadFetcher; +static bool gAttemptedDownload = false; +static std::mutex gDownloadMutex; + +bool fetchArchive(const std::string& url, const std::filesystem::path& destination) { + if (gDownloadFetcher.has_value()) { + return (*gDownloadFetcher)(url, destination); + } + return downloadToFile(url, destination); +} + bool isSafeArchivePath(const std::filesystem::path& relativePath) { if (relativePath.empty() || relativePath.is_absolute()) { return false; @@ -353,13 +429,55 @@ std::optional resolveFromCacheInstall() { return scanDirectoryRecursive(cacheInstallRoot(), 6); } -std::optional resolveFromAutoDownload() { +// Ordered by priority: AUTOMIX_ASSET_ROOT is an override, so it is searched +// first. (A std::set here once sorted roots lexicographically, which let the +// executable's own tree win whenever its path sorted before the override.) +std::vector defaultRoots() { + std::vector candidates; + if (const auto assetRoot = readEnvironment("AUTOMIX_ASSET_ROOT"); assetRoot.has_value()) { + candidates.emplace_back(assetRoot.value()); + } + candidates.push_back(std::filesystem::current_path()); + + const juce::File executable = juce::File::getSpecialLocation(juce::File::currentExecutableFile); + const std::filesystem::path executableDir(executable.getParentDirectory().getFullPathName().toStdString()); + candidates.push_back(executableDir); + candidates.push_back(executableDir / ".." / "Resources"); + + std::set seen; + std::vector output; + output.reserve(candidates.size()); + for (const auto& root : candidates) { + if (seen.insert(root).second) { + output.push_back(root); + } + } + return output; +} + +} // namespace + +void PhaseLimiterDiscovery::setDownloadFetcherForTesting(DownloadFetcher fetcher) { + gDownloadFetcher = std::move(fetcher); +} + +void PhaseLimiterDiscovery::resetDownloadFetcherForTesting() { + gDownloadFetcher.reset(); +} + +void PhaseLimiterDiscovery::resetAttemptedDownloadForTesting() { + std::scoped_lock lock(gDownloadMutex); + gAttemptedDownload = false; +} + +std::optional PhaseLimiterDiscovery::downloadAndInstall( + const std::optional& customPin) { if (flagEnabled("AUTOMIX_PHASELIMITER_SKIP_DOWNLOAD")) { return std::nullopt; } - const auto url = defaultDownloadUrl(); - if (!url.has_value()) { + const auto pin = customPin.has_value() ? customPin : defaultPhaseLimiterDownloadPin(); + if (!pin.has_value() || pin->url.empty()) { return std::nullopt; } @@ -367,17 +485,14 @@ std::optional resolveFromAutoDownload() { return cachedInfo; } - static std::mutex downloadMutex; - static bool attemptedDownload = false; - std::scoped_lock lock(downloadMutex); - + std::scoped_lock lock(gDownloadMutex); if (const auto cachedInfo = resolveFromCacheInstall(); cachedInfo.has_value()) { return cachedInfo; } - if (attemptedDownload) { + if (gAttemptedDownload) { return std::nullopt; } - attemptedDownload = true; + gAttemptedDownload = true; std::error_code error; std::filesystem::create_directories(cacheToolsRoot(), error); @@ -385,88 +500,75 @@ std::optional resolveFromAutoDownload() { return std::nullopt; } - const auto lowerUrl = toLower(url.value()); + const auto lowerUrl = toLower(pin->url); const std::filesystem::path archivePath = cacheToolsRoot() / "phaselimiter_download"; + std::filesystem::path destinationFile; + bool isArchive = false; + bool isZip = false; if (lowerUrl.ends_with(".zip")) { - const auto zipPath = archivePath.string() + ".zip"; - if (!downloadToFile(url.value(), zipPath)) { - return std::nullopt; - } - std::filesystem::remove_all(cacheInstallRoot(), error); - if (!extractZipArchive(zipPath, cacheToolsRoot())) { - return std::nullopt; - } + destinationFile = archivePath.string() + ".zip"; + isArchive = true; + isZip = true; } else if (lowerUrl.ends_with(".tar.xz") || lowerUrl.ends_with(".txz")) { - const auto tarPath = archivePath.string() + ".tar.xz"; - if (!downloadToFile(url.value(), tarPath)) { - return std::nullopt; - } - std::filesystem::remove_all(cacheInstallRoot(), error); - if (!extractTarXzArchive(tarPath, cacheToolsRoot())) { - return std::nullopt; - } - } else if (lowerUrl.ends_with(".sh")) { - if (!flagEnabled("AUTOMIX_PHASELIMITER_ALLOW_LINUX_INSTALL_SCRIPT")) { - return std::nullopt; - } -#if defined(__linux__) - const auto scriptPath = archivePath.string() + ".sh"; - if (!downloadToFile(url.value(), scriptPath)) { + destinationFile = archivePath.string() + ".tar.xz"; + isArchive = true; + isZip = false; + } else { + std::filesystem::create_directories(cacheInstallRoot() / "bin", error); + if (error) { return std::nullopt; } + destinationFile = cacheBinaryPath(); + isArchive = false; + } - juce::StringArray command; - command.add("bash"); - command.add(scriptPath); - juce::ChildProcess process; - if (!process.start(command) || !process.waitForProcessToFinish(180000) || process.getExitCode() != 0) { + if (!fetchArchive(pin->url, destinationFile)) { + std::filesystem::remove(destinationFile, error); + return std::nullopt; + } + + // SHA-256 verification before extraction + if (!pin->sha256.empty()) { + const auto actualSha = toLower(automix::util::fileSha256(destinationFile)); + const auto expectedSha = toLower(pin->sha256); + if (actualSha != expectedSha) { + std::filesystem::remove(destinationFile, error); + juce::Logger::writeToLog("PhaseLimiter download SHA-256 mismatch for " + + destinationFile.string() + " (expected " + expectedSha + + ", got " + (actualSha.empty() ? "unreadable" : actualSha) + + "); download discarded."); return std::nullopt; } -#else - return std::nullopt; -#endif } else { - std::filesystem::create_directories(cacheInstallRoot() / "bin", error); - if (error || !downloadToFile(url.value(), cacheBinaryPath())) { + juce::Logger::writeToLog("WARNING: PhaseLimiter downloaded without SHA-256 verification (unpinned URL)."); + } + + if (isArchive) { + std::filesystem::remove_all(cacheInstallRoot(), error); + const bool extracted = isZip ? extractZipArchive(destinationFile, cacheToolsRoot()) + : extractTarXzArchive(destinationFile, cacheToolsRoot()); + std::filesystem::remove(destinationFile, error); + if (!extracted) { return std::nullopt; } } if (const auto downloadedInfo = resolveFromCacheInstall(); downloadedInfo.has_value()) { +#if !defined(_WIN32) + const auto currentPermissions = std::filesystem::status(downloadedInfo->executablePath, error).permissions(); + if (!error) { + constexpr auto executeFlags = std::filesystem::perms::owner_exec | + std::filesystem::perms::group_exec | + std::filesystem::perms::others_exec; + std::filesystem::permissions(downloadedInfo->executablePath, currentPermissions | executeFlags, error); + } +#endif return downloadedInfo; } return std::nullopt; } -// Ordered by priority: AUTOMIX_ASSET_ROOT is an override, so it is searched -// first. (A std::set here once sorted roots lexicographically, which let the -// executable's own tree win whenever its path sorted before the override.) -std::vector defaultRoots() { - std::vector candidates; - if (const auto assetRoot = readEnvironment("AUTOMIX_ASSET_ROOT"); assetRoot.has_value()) { - candidates.emplace_back(assetRoot.value()); - } - candidates.push_back(std::filesystem::current_path()); - - const juce::File executable = juce::File::getSpecialLocation(juce::File::currentExecutableFile); - const std::filesystem::path executableDir(executable.getParentDirectory().getFullPathName().toStdString()); - candidates.push_back(executableDir); - candidates.push_back(executableDir / ".." / "Resources"); - - std::set seen; - std::vector output; - output.reserve(candidates.size()); - for (const auto& root : candidates) { - if (seen.insert(root).second) { - output.push_back(root); - } - } - return output; -} - -} // namespace - std::optional PhaseLimiterDiscovery::find() const { if (const auto fromEnv = resolveFromEnvironment(); fromEnv.has_value()) { return fromEnv; @@ -477,7 +579,7 @@ std::optional PhaseLimiterDiscovery::find() const { if (const auto fromLocal = findInRoots(defaultRoots()); fromLocal.has_value()) { return fromLocal; } - return resolveFromAutoDownload(); + return downloadAndInstall(); } std::optional PhaseLimiterDiscovery::findInRoots( diff --git a/src/renderers/PhaseLimiterDiscovery.h b/src/renderers/PhaseLimiterDiscovery.h index 24dca80..575526c 100644 --- a/src/renderers/PhaseLimiterDiscovery.h +++ b/src/renderers/PhaseLimiterDiscovery.h @@ -1,7 +1,9 @@ #pragma once #include +#include #include +#include #include #include @@ -29,10 +31,29 @@ inline bool isCompleteInstall(const PhaseLimiterBinaryInfo& info) { return std::filesystem::is_regular_file(masteringReferencePath(info), error) && !error; } +struct PhaseLimiterDownloadPin { + std::string platformKey; + std::string url; + std::string sha256; +}; + +std::vector phaseLimiterDownloadPinTable(); +std::optional defaultPhaseLimiterDownloadPin(); +std::string currentPhaseLimiterPlatformKey(); + class PhaseLimiterDiscovery { public: + using DownloadFetcher = std::function; + std::optional find() const; std::optional findInRoots(const std::vector& roots) const; + + static std::optional downloadAndInstall( + const std::optional& pin = std::nullopt); + + static void setDownloadFetcherForTesting(DownloadFetcher fetcher); + static void resetDownloadFetcherForTesting(); + static void resetAttemptedDownloadForTesting(); }; } // namespace automix::renderers diff --git a/tests/unit/PhaseLimiterDiscoveryTests.cpp b/tests/unit/PhaseLimiterDiscoveryTests.cpp index 4bb358e..2bfacad 100644 --- a/tests/unit/PhaseLimiterDiscoveryTests.cpp +++ b/tests/unit/PhaseLimiterDiscoveryTests.cpp @@ -136,3 +136,87 @@ TEST_CASE("PhaseLimiter discovery supports AUTOMIX_ASSET_ROOT override", "[phase setEnvValue("AUTOMIX_ASSET_ROOT", previousEnv); std::filesystem::remove_all(root); } + +TEST_CASE("PhaseLimiter download pin table contains target platforms and valid sha256 digests", "[phaselimiter][discovery]") { + const auto table = automix::renderers::phaseLimiterDownloadPinTable(); + REQUIRE_FALSE(table.empty()); + + const std::vector requiredPlatforms = { + "windows-x64", + "linux-x64", + "macos-arm64", + "macos-x86_64", + "linux-arm64", + }; + + for (const auto& required : requiredPlatforms) { + const auto it = std::find_if(table.begin(), table.end(), + [&](const automix::renderers::PhaseLimiterDownloadPin& pin) { + return pin.platformKey == required; + }); + INFO("Checking presence of required platform: " << required); + REQUIRE(it != table.end()); + REQUIRE(!it->url.empty()); + if (!it->sha256.empty()) { + REQUIRE(it->sha256.size() == 64); + for (char c : it->sha256) { + REQUIRE(((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))); + } + } + } + + const auto currentKey = automix::renderers::currentPhaseLimiterPlatformKey(); + REQUIRE_FALSE(currentKey.empty()); + REQUIRE(currentKey != "unknown"); +} + +TEST_CASE("PhaseLimiter auto-download rejects corrupted archive before extraction and discards file", "[phaselimiter][discovery]") { + automix::renderers::PhaseLimiterDiscovery::resetAttemptedDownloadForTesting(); + + std::filesystem::path interceptedDestination; + automix::renderers::PhaseLimiterDiscovery::setDownloadFetcherForTesting( + [&](const std::string& /*url*/, const std::filesystem::path& destination) { + interceptedDestination = destination; + std::ofstream out(destination, std::ios::binary); + out << "corrupted-file-data-that-will-not-match-expected-hash"; + return true; + }); + + automix::renderers::PhaseLimiterDownloadPin testPin{ + "test-platform", + "https://example.com/test_corrupted.zip", + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + }; + + const auto result = automix::renderers::PhaseLimiterDiscovery::downloadAndInstall(testPin); + REQUIRE(!result.has_value()); + REQUIRE(!interceptedDestination.empty()); + REQUIRE(!std::filesystem::exists(interceptedDestination)); + + automix::renderers::PhaseLimiterDiscovery::resetDownloadFetcherForTesting(); + automix::renderers::PhaseLimiterDiscovery::resetAttemptedDownloadForTesting(); +} + +TEST_CASE("PhaseLimiter defaultPhaseLimiterDownloadPin supports AUTOMIX_PHASELIMITER_DOWNLOAD_URL override", "[phaselimiter][discovery]") { + std::string previousUrl; +#if defined(_WIN32) + char* oldValue = nullptr; + size_t oldLength = 0; + if (_dupenv_s(&oldValue, &oldLength, "AUTOMIX_PHASELIMITER_DOWNLOAD_URL") == 0 && oldValue != nullptr) { + previousUrl.assign(oldValue, oldLength > 0 ? oldLength - 1 : 0); + free(oldValue); + } +#else + if (const char* existing = std::getenv("AUTOMIX_PHASELIMITER_DOWNLOAD_URL"); existing != nullptr) { + previousUrl = existing; + } +#endif + + setEnvValue("AUTOMIX_PHASELIMITER_DOWNLOAD_URL", "https://example.com/custom_phaselimiter.zip"); + + const auto pin = automix::renderers::defaultPhaseLimiterDownloadPin(); + REQUIRE(pin.has_value()); + REQUIRE(pin->url == "https://example.com/custom_phaselimiter.zip"); + + setEnvValue("AUTOMIX_PHASELIMITER_DOWNLOAD_URL", previousUrl); +} From 9853d6dbab56d4ce59e37ed50144a9c229723429 Mon Sep 17 00:00:00 2001 From: Soficis Date: Sat, 3 Oct 2026 00:04:44 -0500 Subject: [PATCH 27/68] feat(renderers): update PhaseLimiter download pin table with native release v0.2.0-native1 --- src/renderers/PhaseLimiterDiscovery.cpp | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/renderers/PhaseLimiterDiscovery.cpp b/src/renderers/PhaseLimiterDiscovery.cpp index 8bc7106..91bc198 100644 --- a/src/renderers/PhaseLimiterDiscovery.cpp +++ b/src/renderers/PhaseLimiterDiscovery.cpp @@ -289,11 +289,11 @@ std::vector phaseLimiterDownloadPinTable() { "https://github.com/ai-mastering/phaselimiter/releases/download/v0.2.0/phaselimiter-win.zip", "cab2d30ad8d993a383749b30d9f6dc1911198d3aa309d5f631b70206e0162145"}, {"linux-x64", - "https://github.com/ai-mastering/phaselimiter/releases/download/v0.2.0/release.tar.xz", - "0b382ba78b030926f706345d1b00d5f45890ba384c8a4e960320e3ee992bd163"}, + "https://github.com/soficis/phaselimiter/releases/download/v0.2.0-native1/phaselimiter-0.2.0-linux-x86_64.tar.xz", + "208ad1bdc3811b001d0f1f96801cb73c0b6a2ee749c7568170a068493458df15"}, {"macos-arm64", "https://github.com/soficis/phaselimiter/releases/download/v0.2.0-native1/phaselimiter-0.2.0-macos-arm64.tar.xz", - ""}, + "bffd93614efc9f7d74b3ac148eef3731339dabbdaffaf9ab8912fc562473b722"}, {"macos-x86_64", "https://github.com/soficis/phaselimiter/releases/download/v0.2.0-native1/phaselimiter-0.2.0-macos-x86_64.tar.xz", ""}, @@ -481,13 +481,17 @@ std::optional PhaseLimiterDiscovery::downloadAndInstall( return std::nullopt; } - if (const auto cachedInfo = resolveFromCacheInstall(); cachedInfo.has_value()) { - return cachedInfo; + if (!customPin.has_value()) { + if (const auto cachedInfo = resolveFromCacheInstall(); cachedInfo.has_value()) { + return cachedInfo; + } } std::scoped_lock lock(gDownloadMutex); - if (const auto cachedInfo = resolveFromCacheInstall(); cachedInfo.has_value()) { - return cachedInfo; + if (!customPin.has_value()) { + if (const auto cachedInfo = resolveFromCacheInstall(); cachedInfo.has_value()) { + return cachedInfo; + } } if (gAttemptedDownload) { return std::nullopt; @@ -573,12 +577,12 @@ std::optional PhaseLimiterDiscovery::find() const { if (const auto fromEnv = resolveFromEnvironment(); fromEnv.has_value()) { return fromEnv; } - if (const auto cached = toBinaryInfo(cacheBinaryPath()); cached.has_value()) { - return cached; - } if (const auto fromLocal = findInRoots(defaultRoots()); fromLocal.has_value()) { return fromLocal; } + if (const auto cached = resolveFromCacheInstall(); cached.has_value()) { + return cached; + } return downloadAndInstall(); } From 745159c3696f20d00d4db8b58e9a278953e225b1 Mon Sep 17 00:00:00 2001 From: Soficis Date: Sat, 3 Oct 2026 10:18:51 -0500 Subject: [PATCH 28/68] feat(renderers): restore static linux PhaseLimiter pin, enforce strict hashes, and stage assets offline Round 2 follow-up addressing R2-2, R2-3, R2-5, R2-6/P3, and R2-8: - Point linux-x64 back at upstream static release.tar.xz (0b382ba7...) to eliminate distro glibc/boost regressions - Remove unverified empty-hash pins from download table and reject empty-hash downloads before network fetch - Add cmake/FetchPhaseLimiter.cmake to fetch and stage bin/ and resource/ assets beside executables (AUTOMIX_FETCH_PHASELIMITER) - Add macOS Gatekeeper first-launch approval instructions in README.md - Add strict 64-hex SHA-256 assertions and empty-hash rejection test in PhaseLimiterDiscoveryTests --- CMakeLists.txt | 79 ++++++++++--------- README.md | 3 + cmake/FetchPhaseLimiter.cmake | 94 +++++++++++++++++++++++ src/renderers/PhaseLimiterDiscovery.cpp | 18 ++--- tests/unit/PhaseLimiterDiscoveryTests.cpp | 45 +++++++++-- 5 files changed, 186 insertions(+), 53 deletions(-) create mode 100644 cmake/FetchPhaseLimiter.cmake diff --git a/CMakeLists.txt b/CMakeLists.txt index 2f8094c..bf6391a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -464,35 +464,19 @@ target_compile_definitions(AutoMixMasterApp PRIVATE JUCE_USE_CURL=0 ) -# Only PhaseLimiter's real content is staged. Its folder also collects renderer -# scratch audio (tmp/, many GB) and stray downloaded models (assets/), which -# must never be copied next to the app or into a package. -set(AUTOMIX_PHASELIMITER_DIRS bin resource licenses) -set(AUTOMIX_PHASELIMITER_FILES LICENSE README.md) -if(EXISTS "${CMAKE_SOURCE_DIR}/assets/phaselimiter") - set(_automix_pl_commands) - foreach(_dir IN LISTS AUTOMIX_PHASELIMITER_DIRS) - if(EXISTS "${CMAKE_SOURCE_DIR}/assets/phaselimiter/${_dir}") - list(APPEND _automix_pl_commands COMMAND ${CMAKE_COMMAND} -E copy_directory - "${CMAKE_SOURCE_DIR}/assets/phaselimiter/${_dir}" - "$/assets/phaselimiter/${_dir}") - endif() - endforeach() - foreach(_file IN LISTS AUTOMIX_PHASELIMITER_FILES) - if(EXISTS "${CMAKE_SOURCE_DIR}/assets/phaselimiter/${_file}") - list(APPEND _automix_pl_commands COMMAND ${CMAKE_COMMAND} -E copy_if_different - "${CMAKE_SOURCE_DIR}/assets/phaselimiter/${_file}" - "$/assets/phaselimiter/${_file}") - endif() - endforeach() - add_custom_command(TARGET AutoMixMasterApp POST_BUILD - COMMAND ${CMAKE_COMMAND} -E make_directory "$/assets/phaselimiter" - ${_automix_pl_commands} - COMMENT "Copying bundled PhaseLimiter assets next to AutoMixMaster executable" - VERBATIM - ) +# Pinned PhaseLimiter fetching and staging (default ON when BUILD_TESTING=ON) +if(BUILD_TESTING) + set(AUTOMIX_FETCH_PHASELIMITER_DEFAULT ON) +else() + set(AUTOMIX_FETCH_PHASELIMITER_DEFAULT OFF) +endif() +option(AUTOMIX_FETCH_PHASELIMITER "Fetch and stage pinned PhaseLimiter release archive" ${AUTOMIX_FETCH_PHASELIMITER_DEFAULT}) + +if(AUTOMIX_FETCH_PHASELIMITER) + include(cmake/FetchPhaseLimiter.cmake) endif() + if(EXISTS "${CMAKE_SOURCE_DIR}/assets/limiters") add_custom_command(TARGET AutoMixMasterApp POST_BUILD COMMAND ${CMAKE_COMMAND} -E make_directory "$/assets" @@ -623,20 +607,32 @@ endif() set(AUTOMIX_CUDA_RUNTIME_DIR "" CACHE PATH "Folder of CUDA runtime + cuDNN DLLs staged next to executables (optional)") automix_stage_ort(AutoMixMasterApp) +if(COMMAND automix_stage_phaselimiter) + automix_stage_phaselimiter(AutoMixMasterApp) +endif() automix_set_rpath(AutoMixMasterApp) if(TARGET automix_dev_tools) automix_stage_ort(automix_dev_tools) + if(COMMAND automix_stage_phaselimiter) + automix_stage_phaselimiter(automix_dev_tools) + endif() automix_set_rpath(automix_dev_tools) endif() if(TARGET automix_tests) automix_stage_ort(automix_tests) + if(COMMAND automix_stage_phaselimiter) + automix_stage_phaselimiter(automix_tests) + endif() automix_set_rpath(automix_tests) endif() if(TARGET automix_regression_cli) automix_stage_ort(automix_regression_cli) + if(COMMAND automix_stage_phaselimiter) + automix_stage_phaselimiter(automix_regression_cli) + endif() automix_set_rpath(automix_regression_cli) endif() @@ -659,16 +655,25 @@ if(AUTOMIX_HAS_NATIVE_ORT AND AUTOMIX_ORT_RUNTIME_FILES) endif() endif() -foreach(_dir IN LISTS AUTOMIX_PHASELIMITER_DIRS) - if(EXISTS "${CMAKE_SOURCE_DIR}/assets/phaselimiter/${_dir}") - install(DIRECTORY "${CMAKE_SOURCE_DIR}/assets/phaselimiter/${_dir}" DESTINATION assets/phaselimiter COMPONENT application) - endif() -endforeach() -foreach(_file IN LISTS AUTOMIX_PHASELIMITER_FILES) - if(EXISTS "${CMAKE_SOURCE_DIR}/assets/phaselimiter/${_file}") - install(FILES "${CMAKE_SOURCE_DIR}/assets/phaselimiter/${_file}" DESTINATION assets/phaselimiter COMPONENT application) - endif() -endforeach() +set(_automix_pl_install_root "") +if(AUTOMIX_PHASELIMITER_ROOT) + set(_automix_pl_install_root "${AUTOMIX_PHASELIMITER_ROOT}") +elseif(EXISTS "${CMAKE_SOURCE_DIR}/assets/phaselimiter") + set(_automix_pl_install_root "${CMAKE_SOURCE_DIR}/assets/phaselimiter") +endif() + +if(_automix_pl_install_root) + foreach(_dir bin resource licenses) + if(EXISTS "${_automix_pl_install_root}/${_dir}") + install(DIRECTORY "${_automix_pl_install_root}/${_dir}" DESTINATION assets/phaselimiter COMPONENT application) + endif() + endforeach() + foreach(_file LICENSE README.md) + if(EXISTS "${_automix_pl_install_root}/${_file}") + install(FILES "${_automix_pl_install_root}/${_file}" DESTINATION assets/phaselimiter COMPONENT application) + endif() + endforeach() +endif() if(EXISTS "${CMAKE_SOURCE_DIR}/assets/limiters") install(DIRECTORY "${CMAKE_SOURCE_DIR}/assets/limiters" DESTINATION assets COMPONENT application) endif() diff --git a/README.md b/README.md index 732bc7d..da01de0 100644 --- a/README.md +++ b/README.md @@ -326,6 +326,9 @@ sudo cp -R "$APP_BUNDLE" /Applications/ open /Applications/AutoMixMaster.app ``` +> **macOS Note:** On first launch of the unsigned application, macOS Gatekeeper may prompt that the developer cannot be verified. To allow the application to open, go to **System Settings → Privacy & Security**, scroll down to the Security section, and click **Open Anyway**. + + ### Linux Package Builds (.deb + AppImage) After building, create distributable Linux packages with: diff --git a/cmake/FetchPhaseLimiter.cmake b/cmake/FetchPhaseLimiter.cmake new file mode 100644 index 0000000..b616d82 --- /dev/null +++ b/cmake/FetchPhaseLimiter.cmake @@ -0,0 +1,94 @@ +# cmake/FetchPhaseLimiter.cmake +# Fetches pinned PhaseLimiter release archives and stages assets beside executables. + +include(FetchContent) + +set(AUTOMIX_PHASELIMITER_ROOT "") + +if(WIN32) + if(EXISTS "${CMAKE_SOURCE_DIR}/assets/phaselimiter/bin/phase_limiter.exe") + set(AUTOMIX_PHASELIMITER_ROOT "${CMAKE_SOURCE_DIR}/assets/phaselimiter") + else() + set(_pl_url "https://github.com/ai-mastering/phaselimiter/releases/download/v0.2.0/phaselimiter-win.zip") + set(_pl_hash "SHA256=cab2d30ad8d993a383749b30d9f6dc1911198d3aa309d5f631b70206e0162145") + endif() +elseif(APPLE) + if(CMAKE_SYSTEM_PROCESSOR MATCHES "ARM64|aarch64") + set(_pl_url "https://github.com/soficis/phaselimiter/releases/download/v0.2.0-native1/phaselimiter-0.2.0-macos-arm64.tar.xz") + set(_pl_hash "SHA256=bffd93614efc9f7d74b3ac148eef3731339dabbdaffaf9ab8912fc562473b722") + endif() +elseif(UNIX) + if(CMAKE_SYSTEM_PROCESSOR MATCHES "x86_64|amd64") + set(_pl_url "https://github.com/ai-mastering/phaselimiter/releases/download/v0.2.0/release.tar.xz") + set(_pl_hash "SHA256=0b382ba78b030926f706345d1b00d5f45890ba384c8a4e960320e3ee992bd163") + endif() +endif() + +if(NOT AUTOMIX_PHASELIMITER_ROOT AND _pl_url) + message(STATUS "Fetching PhaseLimiter archive: ${_pl_url}") + FetchContent_Declare( + phaselimiter_fetched + URL "${_pl_url}" + URL_HASH "${_pl_hash}" + DOWNLOAD_EXTRACT_TIMESTAMP TRUE + ) + FetchContent_MakeAvailable(phaselimiter_fetched) + + if(EXISTS "${phaselimiter_fetched_SOURCE_DIR}/bin") + set(AUTOMIX_PHASELIMITER_ROOT "${phaselimiter_fetched_SOURCE_DIR}") + elseif(EXISTS "${phaselimiter_fetched_SOURCE_DIR}/phaselimiter/bin") + set(AUTOMIX_PHASELIMITER_ROOT "${phaselimiter_fetched_SOURCE_DIR}/phaselimiter") + else() + file(GLOB _pl_subdirs LIST_DIRECTORIES true "${phaselimiter_fetched_SOURCE_DIR}/*") + foreach(_sub IN LISTS _pl_subdirs) + if(IS_DIRECTORY "${_sub}" AND EXISTS "${_sub}/bin") + set(AUTOMIX_PHASELIMITER_ROOT "${_sub}") + break() + endif() + endforeach() + endif() +endif() + +if(UNIX AND AUTOMIX_PHASELIMITER_ROOT) + file(GLOB _pl_bins "${AUTOMIX_PHASELIMITER_ROOT}/bin/*") + foreach(_bin IN LISTS _pl_bins) + if(NOT IS_DIRECTORY "${_bin}") + file(CHMOD "${_bin}" + PERMISSIONS + OWNER_READ OWNER_WRITE OWNER_EXECUTE + GROUP_READ GROUP_EXECUTE + WORLD_READ WORLD_EXECUTE) + endif() + endforeach() +endif() + +function(automix_stage_phaselimiter TARGET_NAME) + if(NOT AUTOMIX_PHASELIMITER_ROOT) + return() + endif() + + set(_staging_commands) + foreach(_dir bin resource licenses) + if(EXISTS "${AUTOMIX_PHASELIMITER_ROOT}/${_dir}") + list(APPEND _staging_commands COMMAND ${CMAKE_COMMAND} -E copy_directory + "${AUTOMIX_PHASELIMITER_ROOT}/${_dir}" + "$/assets/phaselimiter/${_dir}") + endif() + endforeach() + foreach(_file LICENSE README.md) + if(EXISTS "${AUTOMIX_PHASELIMITER_ROOT}/${_file}") + list(APPEND _staging_commands COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${AUTOMIX_PHASELIMITER_ROOT}/${_file}" + "$/assets/phaselimiter/${_file}") + endif() + endforeach() + + if(_staging_commands) + add_custom_command(TARGET ${TARGET_NAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory "$/assets/phaselimiter" + ${_staging_commands} + COMMENT "Staging PhaseLimiter assets next to ${TARGET_NAME}" + VERBATIM + ) + endif() +endfunction() diff --git a/src/renderers/PhaseLimiterDiscovery.cpp b/src/renderers/PhaseLimiterDiscovery.cpp index 91bc198..c35b8d8 100644 --- a/src/renderers/PhaseLimiterDiscovery.cpp +++ b/src/renderers/PhaseLimiterDiscovery.cpp @@ -289,17 +289,11 @@ std::vector phaseLimiterDownloadPinTable() { "https://github.com/ai-mastering/phaselimiter/releases/download/v0.2.0/phaselimiter-win.zip", "cab2d30ad8d993a383749b30d9f6dc1911198d3aa309d5f631b70206e0162145"}, {"linux-x64", - "https://github.com/soficis/phaselimiter/releases/download/v0.2.0-native1/phaselimiter-0.2.0-linux-x86_64.tar.xz", - "208ad1bdc3811b001d0f1f96801cb73c0b6a2ee749c7568170a068493458df15"}, + "https://github.com/ai-mastering/phaselimiter/releases/download/v0.2.0/release.tar.xz", + "0b382ba78b030926f706345d1b00d5f45890ba384c8a4e960320e3ee992bd163"}, {"macos-arm64", "https://github.com/soficis/phaselimiter/releases/download/v0.2.0-native1/phaselimiter-0.2.0-macos-arm64.tar.xz", "bffd93614efc9f7d74b3ac148eef3731339dabbdaffaf9ab8912fc562473b722"}, - {"macos-x86_64", - "https://github.com/soficis/phaselimiter/releases/download/v0.2.0-native1/phaselimiter-0.2.0-macos-x86_64.tar.xz", - ""}, - {"linux-arm64", - "https://github.com/soficis/phaselimiter/releases/download/v0.2.0-native1/phaselimiter-0.2.0-linux-arm64.tar.xz", - ""}, }; } @@ -318,7 +312,7 @@ std::optional defaultPhaseLimiterDownloadPin() { const auto table = phaseLimiterDownloadPinTable(); for (const auto& pin : table) { if (pin.platformKey == currentKey) { - if (pin.url.empty()) { + if (pin.url.empty() || pin.sha256.empty()) { return std::nullopt; } return pin; @@ -481,6 +475,12 @@ std::optional PhaseLimiterDiscovery::downloadAndInstall( return std::nullopt; } + const bool isManualUrlOverride = readEnvironment("AUTOMIX_PHASELIMITER_DOWNLOAD_URL").has_value(); + if (pin->sha256.empty() && !isManualUrlOverride) { + juce::Logger::writeToLog("ERROR: PhaseLimiter download pin has empty SHA-256 hash. Rejected unverified download."); + return std::nullopt; + } + if (!customPin.has_value()) { if (const auto cachedInfo = resolveFromCacheInstall(); cachedInfo.has_value()) { return cachedInfo; diff --git a/tests/unit/PhaseLimiterDiscoveryTests.cpp b/tests/unit/PhaseLimiterDiscoveryTests.cpp index 2bfacad..7c9b5f9 100644 --- a/tests/unit/PhaseLimiterDiscoveryTests.cpp +++ b/tests/unit/PhaseLimiterDiscoveryTests.cpp @@ -145,8 +145,6 @@ TEST_CASE("PhaseLimiter download pin table contains target platforms and valid s "windows-x64", "linux-x64", "macos-arm64", - "macos-x86_64", - "linux-arm64", }; for (const auto& required : requiredPlatforms) { @@ -157,12 +155,18 @@ TEST_CASE("PhaseLimiter download pin table contains target platforms and valid s INFO("Checking presence of required platform: " << required); REQUIRE(it != table.end()); REQUIRE(!it->url.empty()); - if (!it->sha256.empty()) { - REQUIRE(it->sha256.size() == 64); - for (char c : it->sha256) { - REQUIRE(((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))); - } + REQUIRE(it->sha256.size() == 64); + for (char c : it->sha256) { + REQUIRE(((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))); } + if (required == "linux-x64") { + REQUIRE(it->sha256 == "0b382ba78b030926f706345d1b00d5f45890ba384c8a4e960320e3ee992bd163"); + } + } + + for (const auto& pin : table) { + REQUIRE(!pin.url.empty()); + REQUIRE(pin.sha256.size() == 64); } const auto currentKey = automix::renderers::currentPhaseLimiterPlatformKey(); @@ -170,6 +174,32 @@ TEST_CASE("PhaseLimiter download pin table contains target platforms and valid s REQUIRE(currentKey != "unknown"); } +TEST_CASE("PhaseLimiter auto-download rejects unverified table pin with empty hash", "[phaselimiter][discovery]") { + automix::renderers::PhaseLimiterDiscovery::resetAttemptedDownloadForTesting(); + + bool downloadAttempted = false; + automix::renderers::PhaseLimiterDiscovery::setDownloadFetcherForTesting( + [&](const std::string& /*url*/, const std::filesystem::path& destination) { + downloadAttempted = true; + std::ofstream out(destination, std::ios::binary); + out << "test-data"; + return true; + }); + + automix::renderers::PhaseLimiterDownloadPin emptyHashPin{ + "test-empty-hash", + "https://example.com/test_empty.zip", + "" + }; + + const auto result = automix::renderers::PhaseLimiterDiscovery::downloadAndInstall(emptyHashPin); + REQUIRE(!result.has_value()); + REQUIRE(!downloadAttempted); + + automix::renderers::PhaseLimiterDiscovery::resetDownloadFetcherForTesting(); + automix::renderers::PhaseLimiterDiscovery::resetAttemptedDownloadForTesting(); +} + TEST_CASE("PhaseLimiter auto-download rejects corrupted archive before extraction and discards file", "[phaselimiter][discovery]") { automix::renderers::PhaseLimiterDiscovery::resetAttemptedDownloadForTesting(); @@ -220,3 +250,4 @@ TEST_CASE("PhaseLimiter defaultPhaseLimiterDownloadPin supports AUTOMIX_PHASELIM setEnvValue("AUTOMIX_PHASELIMITER_DOWNLOAD_URL", previousUrl); } + From ac85fdc606a8b3449ad10bf2407eff2798a4f511 Mon Sep 17 00:00:00 2001 From: Soficis Date: Sat, 3 Oct 2026 10:43:44 -0500 Subject: [PATCH 29/68] docs: sanitize internal doc links in README prior to history purge --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index da01de0..fbb7f0c 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ AutoMixMaster is designed to benefit from **GPU acceleration** via ONNX Runtime ONNX Runtime is an **optional** dependency. When it is not found at configure time the build falls back to a deterministic adapter and every model-dependent feature degrades to a -heuristic — it does not fail the build. See [docs/ito-master-validation.md](docs/ito-master-validation.md). +heuristic — it does not fail the build. | | | |---|---| @@ -415,7 +415,7 @@ Model weights are **not bundled** into the installer or executable binaries. Use > **Non-Commercial Notice**: Models licensed under **CC-BY-NC 4.0** (such as Meta Demucs, Denoiser, and ITO-Master weights) are restricted to personal, educational, and non-commercial evaluation use. Commercial workflows can use open-source MIT-licensed models (e.g. Whisper, CLAP) or the built-in deterministic heuristic DSP engines. User consent gating is enforced prior to model download and execution. -> **Model Licensing Audit**: For complete machine-checkable model license metadata and audit reports, see [docs/model-licensing-audit.md](docs/model-licensing-audit.md) and [docs/model-licensing-audit.json](docs/model-licensing-audit.json). +> **Model Licensing Audit**: For complete machine-checkable model license metadata and audit specifications, see [docs/model-licensing-audit.json](docs/model-licensing-audit.json). #### Mix-Scope Model Contract From 65810210de3bb5746ffb21590fe8f2f4961db6ca Mon Sep 17 00:00:00 2001 From: Soficis Date: Sat, 3 Oct 2026 11:13:47 -0500 Subject: [PATCH 30/68] chore(hygiene): add repository hygiene checker, gitleaks CI workflow, and recurrence guards --- .github/workflows/repo_hygiene.yml | 36 ++++++++ .gitignore | 13 ++- tools/check_repo_hygiene.py | 130 +++++++++++++++++++++++++++++ 3 files changed, 175 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/repo_hygiene.yml create mode 100644 tools/check_repo_hygiene.py diff --git a/.github/workflows/repo_hygiene.yml b/.github/workflows/repo_hygiene.yml new file mode 100644 index 0000000..71caaf1 --- /dev/null +++ b/.github/workflows/repo_hygiene.yml @@ -0,0 +1,36 @@ +name: Repository Hygiene & Security + +on: + push: + branches: [master, main, "feat/**"] + pull_request: + branches: [master, main] + workflow_dispatch: + +concurrency: + group: hygiene-${{ github.ref }} + cancel-in-progress: true + +jobs: + hygiene: + name: Repository Hygiene & Secret Scan + runs-on: ubuntu-24.04 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Check repository hygiene & forbidden paths + run: | + python tools/check_repo_hygiene.py + + - name: Run Gitleaks secret detection + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 70089da..ba61e7f 100644 --- a/.gitignore +++ b/.gitignore @@ -108,6 +108,10 @@ personal/ config.ini .github/codex_agents.md CLAUDE.md +**/CLAUDE.md +AGENTS.md +**/AGENTS.md +diff_output.txt # === Documentation (all docs except README) === docs/* @@ -122,20 +126,21 @@ docs/* ccache/ .omo/ .omo/** -!/.omo/ -# === Model Hub Downloaded Weights === +# === Model Hub Downloaded Weights & Hub Assets === +assets/**/modelhub/ +assets/**/modelhub/** assets/**/modelhub/**/*.onnx assets/**/modelhub/**/*.pt assets/**/modelhub/**/*.bin assets/**/modelhub/**/*.safetensors +assets/phaselimiter/assets/ # === Tracked licensing audit === # Must stay last: git honours the LAST matching pattern, so the docs/* line # re-ignores the whole directory (undoing the case-insensitive !README*.md -# match above) and the two negations then re-include only these files. +# match above) and the negation then re-includes only this file. docs/* -!docs/model-licensing-audit.md !docs/model-licensing-audit.json diff --git a/tools/check_repo_hygiene.py b/tools/check_repo_hygiene.py new file mode 100644 index 0000000..48ec7de --- /dev/null +++ b/tools/check_repo_hygiene.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +""" +tools/check_repo_hygiene.py + +Repository hygiene verification script. +Ensures no forbidden files, sensitive IP patterns, private tokens, +or developer notes are committed or staged in the repository. +""" + +import argparse +import os +import re +import subprocess +import sys + +# Paths/patterns that must NEVER exist in the repo +FORBIDDEN_PATH_PATTERNS = [ + re.compile(r"^diff_output\.txt$", re.IGNORECASE), + re.compile(r"CLAUDE\.md$", re.IGNORECASE), + re.compile(r"AGENTS\.md$", re.IGNORECASE), + re.compile(r"^\.omo(/|$)", re.IGNORECASE), + re.compile(r"^assets/phaselimiter/assets(/|$)", re.IGNORECASE), + # Under docs/, ONLY model-licensing-audit.json is permitted + re.compile(r"^docs/(?!model-licensing-audit\.json$).+", re.IGNORECASE), +] + +# Sensitive content regex patterns +SENSITIVE_CONTENT_PATTERNS = [ + ("Private Key", re.compile(r"-----BEGIN (RSA|OPENSSH|EC|DSA) PRIVATE KEY-----")), + ("GitHub Personal Access Token (classic)", re.compile(r"\bghp_[A-Za-z0-9]{36}\b")), + ("GitHub Fine-Grained PAT", re.compile(r"\bgithub_pat_[A-Za-z0-9_]{82}\b")), + ("Slack Token", re.compile(r"\bxox[baprs]-[A-Za-z0-9-]+\b")), + ("Local Network IP", re.compile(r"\b192\.168\.0\.\d{1,3}\b")), + ("Windows User Path", re.compile(r"[C-Zc-z]:[/\\]Users[/\\](?!runneradmin|Default|Public)[A-Za-z0-9_]+")), +] + +# Files/extensions exempt from binary or content inspection +EXEMPT_EXTENSIONS = { + ".onnx", ".png", ".jpg", ".jpeg", ".ico", ".wav", ".mp3", ".zip", ".tar", + ".gz", ".xz", ".exe", ".dll", ".lib", ".so", ".dylib", ".bin", ".dat" +} + +EXEMPT_PATHS = { + "tools/check_repo_hygiene.py", # self-exempt for pattern literals +} + + +def get_git_files(staged_only=False): + """Retrieve list of files from git.""" + cmd = ["git", "diff", "--cached", "--name-only"] if staged_only else ["git", "ls-files"] + result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True) + return [line.strip() for line in result.stdout.splitlines() if line.strip()] + + +def check_path_hygiene(file_path): + """Check if a path violates repository hygiene rules.""" + normalized = file_path.replace("\\", "/").lstrip("/") + for pat in FORBIDDEN_PATH_PATTERNS: + if pat.search(normalized): + return f"Forbidden path pattern '{pat.pattern}' matched: {file_path}" + return None + + +def check_file_content(file_path): + """Scan a text file for forbidden content patterns.""" + normalized = file_path.replace("\\", "/") + if normalized in EXEMPT_PATHS: + return [] + + _, ext = os.path.splitext(file_path) + if ext.lower() in EXEMPT_EXTENSIONS: + return [] + + if not os.path.isfile(file_path): + return [] + + findings = [] + try: + with open(file_path, "r", encoding="utf-8", errors="replace") as f: + for line_no, line in enumerate(f, start=1): + for name, pat in SENSITIVE_CONTENT_PATTERNS: + if pat.search(line): + findings.append(f"{file_path}:{line_no}: detected {name}") + except Exception as e: + findings.append(f"{file_path}: unable to read file: {e}") + + return findings + + +def main(): + parser = argparse.ArgumentParser(description="Check repository hygiene and scan for sensitive content.") + parser.add_argument("--staged", action="store_true", help="Scan only git staged files.") + parser.add_argument("--path", nargs="*", help="Specific file paths to scan.") + args = parser.parse_args() + + files = args.path if args.path else get_git_files(staged_only=args.staged) + + path_errors = [] + content_errors = [] + + for file_path in files: + err = check_path_hygiene(file_path) + if err: + path_errors.append(err) + + content_errs = check_file_content(file_path) + content_errors.extend(content_errs) + + has_errors = False + if path_errors: + has_errors = True + print("[ERROR] Hygiene: Forbidden paths found:", file=sys.stderr) + for err in path_errors: + print(f" - {err}", file=sys.stderr) + + if content_errors: + has_errors = True + print("[ERROR] Hygiene: Sensitive patterns found in file contents:", file=sys.stderr) + for err in content_errors: + print(f" - {err}", file=sys.stderr) + + if has_errors: + sys.exit(1) + + print(f"[OK] Repository hygiene check passed: {len(files)} files scanned, 0 violations.") + sys.exit(0) + + +if __name__ == "__main__": + main() From 904a97a1dc20e87c3c50d6f832f3ee9c2167775e Mon Sep 17 00:00:00 2001 From: Soficis Date: Sat, 3 Oct 2026 11:17:36 -0500 Subject: [PATCH 31/68] fix(build): case-insensitive PhaseLimiter platform detection; reject universal macOS --- CMakeLists.txt | 1 + cmake/FetchPhaseLimiter.cmake | 60 ++++++++++++++++ tests/cmake/phaselimiter_arch_test.cmake | 88 ++++++++++++++++++++++++ 3 files changed, 149 insertions(+) create mode 100644 tests/cmake/phaselimiter_arch_test.cmake diff --git a/CMakeLists.txt b/CMakeLists.txt index bf6391a..3f9fc2f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -600,6 +600,7 @@ if(BUILD_TESTING) include(CTest) include(${Catch2_SOURCE_DIR}/extras/Catch.cmake) catch_discover_tests(automix_tests) + add_test(NAME cmake_phaselimiter_arch COMMAND ${CMAKE_COMMAND} -P ${CMAKE_SOURCE_DIR}/tests/cmake/phaselimiter_arch_test.cmake) endif() # Stage ONNX Runtime (and, optionally, CUDA/cuDNN) DLLs beside each executable, diff --git a/cmake/FetchPhaseLimiter.cmake b/cmake/FetchPhaseLimiter.cmake index b616d82..f3ffbd3 100644 --- a/cmake/FetchPhaseLimiter.cmake +++ b/cmake/FetchPhaseLimiter.cmake @@ -3,6 +3,66 @@ include(FetchContent) +function(automix_phaselimiter_platform_key OUT_VAR) + set(_os "${CMAKE_SYSTEM_NAME}") + string(TOLOWER "${CMAKE_SYSTEM_PROCESSOR}" _proc) + + if(_os STREQUAL "Darwin" OR APPLE) + if(CMAKE_OSX_ARCHITECTURES) + list(LENGTH CMAKE_OSX_ARCHITECTURES _num_archs) + if(_num_archs GREATER 1) + message(FATAL_ERROR "PhaseLimiter does not support universal macOS architectures: ${CMAKE_OSX_ARCHITECTURES}") + endif() + string(TOLOWER "${CMAKE_OSX_ARCHITECTURES}" _osx_arch) + if(_osx_arch STREQUAL "arm64" OR _osx_arch STREQUAL "aarch64") + set(${OUT_VAR} "macos-arm64" PARENT_SCOPE) + return() + elseif(_osx_arch STREQUAL "x86_64" OR _osx_arch STREQUAL "amd64") + set(${OUT_VAR} "macos-x86_64" PARENT_SCOPE) + return() + else() + message(FATAL_ERROR "Unsupported macOS architecture in CMAKE_OSX_ARCHITECTURES: ${CMAKE_OSX_ARCHITECTURES}") + endif() + endif() + + if(_proc MATCHES "arm64|aarch64") + set(${OUT_VAR} "macos-arm64" PARENT_SCOPE) + return() + elseif(_proc MATCHES "x86_64|amd64") + set(${OUT_VAR} "macos-x86_64" PARENT_SCOPE) + return() + else() + message(FATAL_ERROR "Unsupported macOS processor: ${CMAKE_SYSTEM_PROCESSOR}") + endif() + + elseif(_os STREQUAL "Linux") + if(_proc MATCHES "arm64|aarch64") + set(${OUT_VAR} "linux-arm64" PARENT_SCOPE) + return() + elseif(_proc MATCHES "x86_64|amd64") + set(${OUT_VAR} "linux-x64" PARENT_SCOPE) + return() + else() + message(FATAL_ERROR "Unsupported Linux processor: ${CMAKE_SYSTEM_PROCESSOR}") + endif() + + elseif(_os STREQUAL "Windows" OR WIN32) + if(_proc MATCHES "x86_64|amd64") + set(${OUT_VAR} "windows-x64" PARENT_SCOPE) + return() + else() + message(FATAL_ERROR "Unsupported Windows processor: ${CMAKE_SYSTEM_PROCESSOR}") + endif() + + else() + message(FATAL_ERROR "Unsupported platform for PhaseLimiter: ${CMAKE_SYSTEM_NAME}") + endif() +endfunction() + +if(CMAKE_SCRIPT_MODE_FILE) + return() +endif() + set(AUTOMIX_PHASELIMITER_ROOT "") if(WIN32) diff --git a/tests/cmake/phaselimiter_arch_test.cmake b/tests/cmake/phaselimiter_arch_test.cmake new file mode 100644 index 0000000..cfa1230 --- /dev/null +++ b/tests/cmake/phaselimiter_arch_test.cmake @@ -0,0 +1,88 @@ +# tests/cmake/phaselimiter_arch_test.cmake + +cmake_minimum_required(VERSION 3.20) + +get_filename_component(_root_dir "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE) +include("${_root_dir}/cmake/FetchPhaseLimiter.cmake") + +if(TEST_SUBCASE STREQUAL "universal") + set(CMAKE_SYSTEM_NAME "Darwin") + set(CMAKE_OSX_ARCHITECTURES "arm64;x86_64") + set(CMAKE_SYSTEM_PROCESSOR "arm64") + automix_phaselimiter_platform_key(_key) + message(FATAL_ERROR "Should not reach here: universal arch did not trigger error") +endif() + +# Test 1: Darwin, no CMAKE_OSX_ARCHITECTURES, arm64 processor +set(CMAKE_SYSTEM_NAME "Darwin") +set(CMAKE_OSX_ARCHITECTURES "") +set(CMAKE_SYSTEM_PROCESSOR "arm64") +automix_phaselimiter_platform_key(_key) +if(NOT _key STREQUAL "macos-arm64") + message(FATAL_ERROR "Test 1 failed: expected macos-arm64, got '${_key}'") +endif() + +# Test 2: Darwin, CMAKE_OSX_ARCHITECTURES=x86_64 +set(CMAKE_SYSTEM_NAME "Darwin") +set(CMAKE_OSX_ARCHITECTURES "x86_64") +set(CMAKE_SYSTEM_PROCESSOR "arm64") +automix_phaselimiter_platform_key(_key) +if(NOT _key STREQUAL "macos-x86_64") + message(FATAL_ERROR "Test 2 failed: expected macos-x86_64, got '${_key}'") +endif() + +# Test 3: Darwin, CMAKE_OSX_ARCHITECTURES=arm64 with processor x86_64 (explicit arch wins) +set(CMAKE_SYSTEM_NAME "Darwin") +set(CMAKE_OSX_ARCHITECTURES "arm64") +set(CMAKE_SYSTEM_PROCESSOR "x86_64") +automix_phaselimiter_platform_key(_key) +if(NOT _key STREQUAL "macos-arm64") + message(FATAL_ERROR "Test 3 failed: expected macos-arm64, got '${_key}'") +endif() + +# Test 4: Linux, processor aarch64 +set(CMAKE_SYSTEM_NAME "Linux") +set(CMAKE_OSX_ARCHITECTURES "") +set(CMAKE_SYSTEM_PROCESSOR "aarch64") +automix_phaselimiter_platform_key(_key) +if(NOT _key STREQUAL "linux-arm64") + message(FATAL_ERROR "Test 4 failed: expected linux-arm64, got '${_key}'") +endif() + +# Test 5: Linux, processor AMD64 (case-insensitive) +set(CMAKE_SYSTEM_NAME "Linux") +set(CMAKE_OSX_ARCHITECTURES "") +set(CMAKE_SYSTEM_PROCESSOR "AMD64") +automix_phaselimiter_platform_key(_key) +if(NOT _key STREQUAL "linux-x64") + message(FATAL_ERROR "Test 5 failed: expected linux-x64, got '${_key}'") +endif() + +# Test 6: Windows, processor AMD64 +set(CMAKE_SYSTEM_NAME "Windows") +set(CMAKE_OSX_ARCHITECTURES "") +set(CMAKE_SYSTEM_PROCESSOR "AMD64") +automix_phaselimiter_platform_key(_key) +if(NOT _key STREQUAL "windows-x64") + message(FATAL_ERROR "Test 6 failed: expected windows-x64, got '${_key}'") +endif() + +# Test 7: Darwin, CMAKE_OSX_ARCHITECTURES="arm64;x86_64" -> FATAL_ERROR containing "universal" +execute_process( + COMMAND "${CMAKE_COMMAND}" -DTEST_SUBCASE=universal -P "${CMAKE_CURRENT_LIST_FILE}" + RESULT_VARIABLE _proc_res + OUTPUT_VARIABLE _proc_out + ERROR_VARIABLE _proc_err +) + +if(_proc_res EQUAL 0) + message(FATAL_ERROR "Test 7 failed: expected universal architecture to trigger non-zero exit code") +endif() + +set(_all_proc_output "${_proc_out}${_proc_err}") +string(TOLOWER "${_all_proc_output}" _lower_err) +if(NOT _lower_err MATCHES "universal") + message(FATAL_ERROR "Test 7 failed: expected 'universal' in error output, got:\n${_all_proc_output}") +endif() + +message(STATUS "All automix_phaselimiter_platform_key tests passed successfully.") From 3be9fc0b3e85bd1bcd0371f8627d67125d8de303 Mon Sep 17 00:00:00 2001 From: Soficis Date: Sat, 3 Oct 2026 11:29:19 -0500 Subject: [PATCH 32/68] refactor(build): single source of truth for PhaseLimiter pins --- CMakeLists.txt | 6 +++- cmake/FetchPhaseLimiter.cmake | 40 +++++++++++++++------ cmake/PhaseLimiterPins.cmake | 44 +++++++++++++++++++++++ src/renderers/PhaseLimiterDiscovery.cpp | 18 ++++------ src/renderers/PhaseLimiterPins.h.in | 20 +++++++++++ tests/unit/PhaseLimiterDiscoveryTests.cpp | 23 ++++++++++++ 6 files changed, 128 insertions(+), 23 deletions(-) create mode 100644 cmake/PhaseLimiterPins.cmake create mode 100644 src/renderers/PhaseLimiterPins.h.in diff --git a/CMakeLists.txt b/CMakeLists.txt index 3f9fc2f..60888f7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -159,7 +159,11 @@ add_library(automix_core src/util/WavWriter.cpp ) -target_include_directories(automix_core PUBLIC src) +target_include_directories(automix_core PUBLIC src "${CMAKE_BINARY_DIR}/generated") + +include(cmake/PhaseLimiterPins.cmake) +automix_generate_phaselimiter_pins_header() + if(WIN32) # GpuRuntimePack reads adapter memory through DXGI to decide whether to offer CUDA. target_link_libraries(automix_core PRIVATE dxgi) diff --git a/cmake/FetchPhaseLimiter.cmake b/cmake/FetchPhaseLimiter.cmake index f3ffbd3..2cdb27d 100644 --- a/cmake/FetchPhaseLimiter.cmake +++ b/cmake/FetchPhaseLimiter.cmake @@ -65,22 +65,40 @@ endif() set(AUTOMIX_PHASELIMITER_ROOT "") -if(WIN32) +include("${CMAKE_CURRENT_LIST_DIR}/PhaseLimiterPins.cmake") +automix_phaselimiter_platform_key(_platform_key) + +set(_pl_url "") +set(_pl_hash "") + +if(_platform_key STREQUAL "windows-x64") if(EXISTS "${CMAKE_SOURCE_DIR}/assets/phaselimiter/bin/phase_limiter.exe") set(AUTOMIX_PHASELIMITER_ROOT "${CMAKE_SOURCE_DIR}/assets/phaselimiter") else() - set(_pl_url "https://github.com/ai-mastering/phaselimiter/releases/download/v0.2.0/phaselimiter-win.zip") - set(_pl_hash "SHA256=cab2d30ad8d993a383749b30d9f6dc1911198d3aa309d5f631b70206e0162145") + set(_pl_url "${AUTOMIX_PL_PIN_WINDOWS_X64_URL}") + if(AUTOMIX_PL_PIN_WINDOWS_X64_SHA256) + set(_pl_hash "SHA256=${AUTOMIX_PL_PIN_WINDOWS_X64_SHA256}") + endif() + endif() +elseif(_platform_key STREQUAL "linux-x64") + set(_pl_url "${AUTOMIX_PL_PIN_LINUX_X64_URL}") + if(AUTOMIX_PL_PIN_LINUX_X64_SHA256) + set(_pl_hash "SHA256=${AUTOMIX_PL_PIN_LINUX_X64_SHA256}") + endif() +elseif(_platform_key STREQUAL "linux-arm64") + set(_pl_url "${AUTOMIX_PL_PIN_LINUX_ARM64_URL}") + if(AUTOMIX_PL_PIN_LINUX_ARM64_SHA256) + set(_pl_hash "SHA256=${AUTOMIX_PL_PIN_LINUX_ARM64_SHA256}") endif() -elseif(APPLE) - if(CMAKE_SYSTEM_PROCESSOR MATCHES "ARM64|aarch64") - set(_pl_url "https://github.com/soficis/phaselimiter/releases/download/v0.2.0-native1/phaselimiter-0.2.0-macos-arm64.tar.xz") - set(_pl_hash "SHA256=bffd93614efc9f7d74b3ac148eef3731339dabbdaffaf9ab8912fc562473b722") +elseif(_platform_key STREQUAL "macos-arm64") + set(_pl_url "${AUTOMIX_PL_PIN_MACOS_ARM64_URL}") + if(AUTOMIX_PL_PIN_MACOS_ARM64_SHA256) + set(_pl_hash "SHA256=${AUTOMIX_PL_PIN_MACOS_ARM64_SHA256}") endif() -elseif(UNIX) - if(CMAKE_SYSTEM_PROCESSOR MATCHES "x86_64|amd64") - set(_pl_url "https://github.com/ai-mastering/phaselimiter/releases/download/v0.2.0/release.tar.xz") - set(_pl_hash "SHA256=0b382ba78b030926f706345d1b00d5f45890ba384c8a4e960320e3ee992bd163") +elseif(_platform_key STREQUAL "macos-x86_64") + set(_pl_url "${AUTOMIX_PL_PIN_MACOS_X86_64_URL}") + if(AUTOMIX_PL_PIN_MACOS_X86_64_SHA256) + set(_pl_hash "SHA256=${AUTOMIX_PL_PIN_MACOS_X86_64_SHA256}") endif() endif() diff --git a/cmake/PhaseLimiterPins.cmake b/cmake/PhaseLimiterPins.cmake new file mode 100644 index 0000000..a305431 --- /dev/null +++ b/cmake/PhaseLimiterPins.cmake @@ -0,0 +1,44 @@ +# cmake/PhaseLimiterPins.cmake +# Single source of truth for PhaseLimiter release downloads and SHA-256 integrity pins. + +set(AUTOMIX_PL_PIN_WINDOWS_X64_URL "https://github.com/ai-mastering/phaselimiter/releases/download/v0.2.0/phaselimiter-win.zip") +set(AUTOMIX_PL_PIN_WINDOWS_X64_SHA256 "cab2d30ad8d993a383749b30d9f6dc1911198d3aa309d5f631b70206e0162145") + +set(AUTOMIX_PL_PIN_LINUX_X64_URL "https://github.com/ai-mastering/phaselimiter/releases/download/v0.2.0/release.tar.xz") +set(AUTOMIX_PL_PIN_LINUX_X64_SHA256 "0b382ba78b030926f706345d1b00d5f45890ba384c8a4e960320e3ee992bd163") + +set(AUTOMIX_PL_PIN_MACOS_ARM64_URL "https://github.com/soficis/phaselimiter/releases/download/v0.2.0-native1/phaselimiter-0.2.0-macos-arm64.tar.xz") +set(AUTOMIX_PL_PIN_MACOS_ARM64_SHA256 "bffd93614efc9f7d74b3ac148eef3731339dabbdaffaf9ab8912fc562473b722") + +set(AUTOMIX_PL_PIN_MACOS_X86_64_URL "") +set(AUTOMIX_PL_PIN_MACOS_X86_64_SHA256 "") + +set(AUTOMIX_PL_PIN_LINUX_ARM64_URL "") +set(AUTOMIX_PL_PIN_LINUX_ARM64_SHA256 "") + +function(automix_generate_phaselimiter_pins_header) + set(_entries "") + set(_platforms windows-x64 linux-x64 linux-arm64 macos-arm64 macos-x86_64) + set(_var_windows-x64 WINDOWS_X64) + set(_var_linux-x64 LINUX_X64) + set(_var_linux-arm64 LINUX_ARM64) + set(_var_macos-arm64 MACOS_ARM64) + set(_var_macos-x86_64 MACOS_X86_64) + + foreach(_key IN LISTS _platforms) + set(_var_key "${_var_${_key}}") + set(_url "${AUTOMIX_PL_PIN_${_var_key}_URL}") + set(_sha "${AUTOMIX_PL_PIN_${_var_key}_SHA256}") + if(_url AND _sha) + string(APPEND _entries " {\"${_key}\", \"${_url}\", \"${_sha}\"},\n") + endif() + endforeach() + + string(REGEX REPLACE "\n$" "" AUTOMIX_PHASELIMITER_PIN_ROWS "${_entries}") + + configure_file( + "${CMAKE_SOURCE_DIR}/src/renderers/PhaseLimiterPins.h.in" + "${CMAKE_BINARY_DIR}/generated/renderers/PhaseLimiterPins.h" + @ONLY + ) +endfunction() diff --git a/src/renderers/PhaseLimiterDiscovery.cpp b/src/renderers/PhaseLimiterDiscovery.cpp index c35b8d8..6815590 100644 --- a/src/renderers/PhaseLimiterDiscovery.cpp +++ b/src/renderers/PhaseLimiterDiscovery.cpp @@ -1,4 +1,5 @@ #include "renderers/PhaseLimiterDiscovery.h" +#include "renderers/PhaseLimiterPins.h" #include #include @@ -284,17 +285,12 @@ std::string currentPhaseLimiterPlatformKey() { } std::vector phaseLimiterDownloadPinTable() { - return { - {"windows-x64", - "https://github.com/ai-mastering/phaselimiter/releases/download/v0.2.0/phaselimiter-win.zip", - "cab2d30ad8d993a383749b30d9f6dc1911198d3aa309d5f631b70206e0162145"}, - {"linux-x64", - "https://github.com/ai-mastering/phaselimiter/releases/download/v0.2.0/release.tar.xz", - "0b382ba78b030926f706345d1b00d5f45890ba384c8a4e960320e3ee992bd163"}, - {"macos-arm64", - "https://github.com/soficis/phaselimiter/releases/download/v0.2.0-native1/phaselimiter-0.2.0-macos-arm64.tar.xz", - "bffd93614efc9f7d74b3ac148eef3731339dabbdaffaf9ab8912fc562473b722"}, - }; + std::vector table; + table.reserve(std::size(kPhaseLimiterPins)); + for (const auto& pin : kPhaseLimiterPins) { + table.push_back({pin.platformKey, pin.url, pin.sha256}); + } + return table; } std::optional defaultPhaseLimiterDownloadPin() { diff --git a/src/renderers/PhaseLimiterPins.h.in b/src/renderers/PhaseLimiterPins.h.in new file mode 100644 index 0000000..70a4a8b --- /dev/null +++ b/src/renderers/PhaseLimiterPins.h.in @@ -0,0 +1,20 @@ +// generated/renderers/PhaseLimiterPins.h +// Generated from src/renderers/PhaseLimiterPins.h.in by CMake. Do not edit. + +#pragma once + +#include + +namespace automix::renderers { + +struct PinRow { + const char* platformKey; + const char* url; + const char* sha256; +}; + +inline constexpr PinRow kPhaseLimiterPins[] = { +@AUTOMIX_PHASELIMITER_PIN_ROWS@ +}; + +} // namespace automix::renderers diff --git a/tests/unit/PhaseLimiterDiscoveryTests.cpp b/tests/unit/PhaseLimiterDiscoveryTests.cpp index 7c9b5f9..442a4db 100644 --- a/tests/unit/PhaseLimiterDiscoveryTests.cpp +++ b/tests/unit/PhaseLimiterDiscoveryTests.cpp @@ -6,6 +6,9 @@ #include #include "renderers/PhaseLimiterDiscovery.h" +#include "renderers/PhaseLimiterPins.h" +#include +#include namespace { @@ -251,3 +254,23 @@ TEST_CASE("PhaseLimiter defaultPhaseLimiterDownloadPin supports AUTOMIX_PHASELIM setEnvValue("AUTOMIX_PHASELIMITER_DOWNLOAD_URL", previousUrl); } +TEST_CASE("PhaseLimiter pin table comes from the generated header", "[phaselimiter]") { + const auto table = automix::renderers::phaseLimiterDownloadPinTable(); + REQUIRE(table.size() == std::size(automix::renderers::kPhaseLimiterPins)); + + const std::set validKeys = { + "windows-x64", "macos-arm64", "macos-x86_64", "linux-x64", "linux-arm64" + }; + const std::regex shaRegex("^[0-9a-f]{64}$"); + + std::set seenKeys; + for (const auto& pin : table) { + REQUIRE(validKeys.find(pin.platformKey) != validKeys.end()); + REQUIRE(pin.url.rfind("https://github.com/", 0) == 0); + REQUIRE(std::regex_match(pin.sha256, shaRegex)); + + REQUIRE(seenKeys.find(pin.platformKey) == seenKeys.end()); + seenKeys.insert(pin.platformKey); + } +} + From e8d79b031142645f92884e65b547ba57dc15e3fe Mon Sep 17 00:00:00 2001 From: Soficis Date: Sat, 3 Oct 2026 11:56:31 -0500 Subject: [PATCH 33/68] fix(build): always package a native PhaseLimiter; fail install without one --- CMakeLists.txt | 38 +++++++++-- cmake/FetchPhaseLimiter.cmake | 21 +++++- src/renderers/PhaseLimiterDiscovery.cpp | 1 + .../phaselimiter_install_guard_test.cmake | 67 +++++++++++++++++++ tests/unit/PhaseLimiterDiscoveryTests.cpp | 18 +++++ 5 files changed, 138 insertions(+), 7 deletions(-) create mode 100644 tests/cmake/phaselimiter_install_guard_test.cmake diff --git a/CMakeLists.txt b/CMakeLists.txt index 60888f7..583962e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -468,11 +468,20 @@ target_compile_definitions(AutoMixMasterApp PRIVATE JUCE_USE_CURL=0 ) -# Pinned PhaseLimiter fetching and staging (default ON when BUILD_TESTING=ON) -if(BUILD_TESTING) - set(AUTOMIX_FETCH_PHASELIMITER_DEFAULT ON) +# Pinned PhaseLimiter fetching and staging +# Defaults ON whenever assets/phaselimiter/bin has no native binary for the platform key. +include(cmake/FetchPhaseLimiter.cmake) +automix_phaselimiter_platform_key(_automix_host_pl_key) +if(_automix_host_pl_key STREQUAL "windows-x64") + set(_automix_native_bin_name "phase_limiter.exe") else() + set(_automix_native_bin_name "phase_limiter") +endif() + +if(EXISTS "${CMAKE_SOURCE_DIR}/assets/phaselimiter/bin/${_automix_native_bin_name}") set(AUTOMIX_FETCH_PHASELIMITER_DEFAULT OFF) +else() + set(AUTOMIX_FETCH_PHASELIMITER_DEFAULT ON) endif() option(AUTOMIX_FETCH_PHASELIMITER "Fetch and stage pinned PhaseLimiter release archive" ${AUTOMIX_FETCH_PHASELIMITER_DEFAULT}) @@ -605,6 +614,8 @@ if(BUILD_TESTING) include(${Catch2_SOURCE_DIR}/extras/Catch.cmake) catch_discover_tests(automix_tests) add_test(NAME cmake_phaselimiter_arch COMMAND ${CMAKE_COMMAND} -P ${CMAKE_SOURCE_DIR}/tests/cmake/phaselimiter_arch_test.cmake) + add_test(NAME cmake_phaselimiter_install_guard COMMAND ${CMAKE_COMMAND} -P ${CMAKE_SOURCE_DIR}/tests/cmake/phaselimiter_install_guard_test.cmake) + set_tests_properties(cmake_phaselimiter_install_guard PROPERTIES LABELS "slow") endif() # Stage ONNX Runtime (and, optionally, CUDA/cuDNN) DLLs beside each executable, @@ -660,6 +671,12 @@ if(AUTOMIX_HAS_NATIVE_ORT AND AUTOMIX_ORT_RUNTIME_FILES) endif() endif() +if(APPLE) + set(_automix_pl_dest "AutoMixMaster.app/Contents/MacOS/assets/phaselimiter") +else() + set(_automix_pl_dest "assets/phaselimiter") +endif() + set(_automix_pl_install_root "") if(AUTOMIX_PHASELIMITER_ROOT) set(_automix_pl_install_root "${AUTOMIX_PHASELIMITER_ROOT}") @@ -670,15 +687,26 @@ endif() if(_automix_pl_install_root) foreach(_dir bin resource licenses) if(EXISTS "${_automix_pl_install_root}/${_dir}") - install(DIRECTORY "${_automix_pl_install_root}/${_dir}" DESTINATION assets/phaselimiter COMPONENT application) + install(DIRECTORY "${_automix_pl_install_root}/${_dir}" DESTINATION "${_automix_pl_dest}" COMPONENT application) endif() endforeach() foreach(_file LICENSE README.md) if(EXISTS "${_automix_pl_install_root}/${_file}") - install(FILES "${_automix_pl_install_root}/${_file}" DESTINATION assets/phaselimiter COMPONENT application) + install(FILES "${_automix_pl_install_root}/${_file}" DESTINATION "${_automix_pl_dest}" COMPONENT application) endif() endforeach() endif() + +# Hard guard for PhaseLimiter: fail the install if no native PhaseLimiter binary or reference file is packaged +if(ENABLE_PHASELIMITER) + install(CODE " + set(_pl_bin_check \"\${CMAKE_INSTALL_PREFIX}/${_automix_pl_dest}/bin/${_automix_native_bin_name}\") + set(_pl_res_check \"\${CMAKE_INSTALL_PREFIX}/${_automix_pl_dest}/resource/mastering_reference.json\") + if(NOT EXISTS \"\${_pl_bin_check}\" OR NOT EXISTS \"\${_pl_res_check}\") + message(FATAL_ERROR \"Package has no native PhaseLimiter for ${_automix_host_pl_key}\") + endif() + " COMPONENT application) +endif() if(EXISTS "${CMAKE_SOURCE_DIR}/assets/limiters") install(DIRECTORY "${CMAKE_SOURCE_DIR}/assets/limiters" DESTINATION assets COMPONENT application) endif() diff --git a/cmake/FetchPhaseLimiter.cmake b/cmake/FetchPhaseLimiter.cmake index 2cdb27d..3fc1fbc 100644 --- a/cmake/FetchPhaseLimiter.cmake +++ b/cmake/FetchPhaseLimiter.cmake @@ -5,7 +5,24 @@ include(FetchContent) function(automix_phaselimiter_platform_key OUT_VAR) set(_os "${CMAKE_SYSTEM_NAME}") - string(TOLOWER "${CMAKE_SYSTEM_PROCESSOR}" _proc) + if(NOT _os) + if(APPLE) + set(_os "Darwin") + elseif(WIN32) + set(_os "Windows") + else() + set(_os "${CMAKE_HOST_SYSTEM_NAME}") + endif() + endif() + + set(_proc_raw "${CMAKE_SYSTEM_PROCESSOR}") + if(NOT _proc_raw) + set(_proc_raw "${CMAKE_HOST_SYSTEM_PROCESSOR}") + endif() + if(NOT _proc_raw AND WIN32) + set(_proc_raw "$ENV{PROCESSOR_ARCHITECTURE}") + endif() + string(TOLOWER "${_proc_raw}" _proc) if(_os STREQUAL "Darwin" OR APPLE) if(CMAKE_OSX_ARCHITECTURES) @@ -59,7 +76,7 @@ function(automix_phaselimiter_platform_key OUT_VAR) endif() endfunction() -if(CMAKE_SCRIPT_MODE_FILE) +if(CMAKE_SCRIPT_MODE_FILE OR NOT AUTOMIX_FETCH_PHASELIMITER) return() endif() diff --git a/src/renderers/PhaseLimiterDiscovery.cpp b/src/renderers/PhaseLimiterDiscovery.cpp index 6815590..f10fe8d 100644 --- a/src/renderers/PhaseLimiterDiscovery.cpp +++ b/src/renderers/PhaseLimiterDiscovery.cpp @@ -165,6 +165,7 @@ std::vector baseAssetDirectories(const std::filesystem::p root / "resources" / "assets", root / "Resources" / "assets", root / "Contents" / "Resources" / "assets", + root / "Contents" / "MacOS" / "assets", }; } diff --git a/tests/cmake/phaselimiter_install_guard_test.cmake b/tests/cmake/phaselimiter_install_guard_test.cmake new file mode 100644 index 0000000..8d048fc --- /dev/null +++ b/tests/cmake/phaselimiter_install_guard_test.cmake @@ -0,0 +1,67 @@ +# tests/cmake/phaselimiter_install_guard_test.cmake + +cmake_minimum_required(VERSION 3.20) + +get_filename_component(_root_dir "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE) +include("${_root_dir}/cmake/FetchPhaseLimiter.cmake") +automix_phaselimiter_platform_key(_host_key) + +set(_temp_dir "${CMAKE_CURRENT_BINARY_DIR}/install_guard_scratch") +file(REMOVE_RECURSE "${_temp_dir}") +file(MAKE_DIRECTORY "${_temp_dir}/install_prefix") + +# Verify that if binary or resource is missing, the guard fails with non-zero exit and expected error +set(_dummy_cmake_install "${_temp_dir}/test_guard.cmake") +file(WRITE "${_dummy_cmake_install}" " +set(CMAKE_INSTALL_PREFIX \"${_temp_dir}/install_prefix\") +set(_automix_pl_dest \"assets/phaselimiter\") +set(_automix_native_bin_name \"phase_limiter\") +if(\"${_host_key}\" STREQUAL \"windows-x64\") + set(_automix_native_bin_name \"phase_limiter.exe\") +endif() +set(_pl_bin_check \"\${CMAKE_INSTALL_PREFIX}/\${_automix_pl_dest}/bin/\${_automix_native_bin_name}\") +set(_pl_res_check \"\${CMAKE_INSTALL_PREFIX}/\${_automix_pl_dest}/resource/mastering_reference.json\") +if(NOT EXISTS \"\${_pl_bin_check}\" OR NOT EXISTS \"\${_pl_res_check}\") + message(FATAL_ERROR \"Package has no native PhaseLimiter for ${_host_key}\") +endif() +") + +execute_process( + COMMAND "${CMAKE_COMMAND}" -P "${_dummy_cmake_install}" + RESULT_VARIABLE _res + ERROR_VARIABLE _err + OUTPUT_VARIABLE _out +) + +if(_res EQUAL 0) + message(FATAL_ERROR "Expected guard script to fail on missing binary, but it succeeded!") +endif() + +set(_full_err "${_out}${_err}") +if(NOT _full_err MATCHES "Package has no native PhaseLimiter for ${_host_key}") + message(FATAL_ERROR "Expected error message to contain 'Package has no native PhaseLimiter for ${_host_key}', got:\n${_full_err}") +endif() + +# Verify that when binary and resource exist, the guard succeeds +file(MAKE_DIRECTORY "${_temp_dir}/install_prefix/assets/phaselimiter/bin") +file(MAKE_DIRECTORY "${_temp_dir}/install_prefix/assets/phaselimiter/resource") +set(_bin_ext "") +if("${_host_key}" STREQUAL "windows-x64") + set(_bin_ext ".exe") +endif() +file(WRITE "${_temp_dir}/install_prefix/assets/phaselimiter/bin/phase_limiter${_bin_ext}" "dummy binary") +file(WRITE "${_temp_dir}/install_prefix/assets/phaselimiter/resource/mastering_reference.json" "{}") + +execute_process( + COMMAND "${CMAKE_COMMAND}" -P "${_dummy_cmake_install}" + RESULT_VARIABLE _res2 + ERROR_VARIABLE _err2 + OUTPUT_VARIABLE _out2 +) + +if(NOT _res2 EQUAL 0) + message(FATAL_ERROR "Guard script failed when files were present:\n${_out2}${_err2}") +endif() + +file(REMOVE_RECURSE "${_temp_dir}") +message(STATUS "cmake_phaselimiter_install_guard test passed.") diff --git a/tests/unit/PhaseLimiterDiscoveryTests.cpp b/tests/unit/PhaseLimiterDiscoveryTests.cpp index 442a4db..10bebff 100644 --- a/tests/unit/PhaseLimiterDiscoveryTests.cpp +++ b/tests/unit/PhaseLimiterDiscoveryTests.cpp @@ -72,6 +72,24 @@ TEST_CASE("PhaseLimiter discovery finds binary inside assets folder", "[phaselim std::filesystem::remove_all(root); } +TEST_CASE("PhaseLimiter discovery finds binary inside macOS bundle layout", "[phaselimiter][discovery]") { + const std::filesystem::path appBundle = std::filesystem::temp_directory_path() / "AutoMixMaster.app"; + const std::filesystem::path binDir = appBundle / "Contents" / "MacOS" / "assets" / "phaselimiter" / "bin"; + const std::filesystem::path binary = binDir / binaryNameForPlatform(); + + std::filesystem::remove_all(appBundle); + std::filesystem::create_directories(binDir); + std::ofstream(binary).put('\n'); + + automix::renderers::PhaseLimiterDiscovery discovery; + const auto result = discovery.findInRoots({appBundle}); + REQUIRE(result.has_value()); + REQUIRE(lowerPath(result->executablePath) == lowerPath(binary)); + REQUIRE(lowerPath(result->installRoot) == lowerPath(appBundle / "Contents" / "MacOS" / "assets" / "phaselimiter")); + + std::filesystem::remove_all(appBundle); +} + TEST_CASE("PhaseLimiter discovery supports PHASELIMITER_BIN override", "[phaselimiter][discovery]") { const std::filesystem::path root = std::filesystem::temp_directory_path() / "automix_phaselimiter_discovery_env"; const std::filesystem::path binDir = root / "custom_bin"; From 1ea2b4097b60530de524a7a140dd882a9d47db57 Mon Sep 17 00:00:00 2001 From: Soficis Date: Sat, 3 Oct 2026 12:14:44 -0500 Subject: [PATCH 34/68] fix(tests): resolve demo-mix-v1 model pack relative to repo root in benchmark test --- tests/unit/ModelBenchmarkTests.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/unit/ModelBenchmarkTests.cpp b/tests/unit/ModelBenchmarkTests.cpp index 701f31d..f7dcc51 100644 --- a/tests/unit/ModelBenchmarkTests.cpp +++ b/tests/unit/ModelBenchmarkTests.cpp @@ -63,7 +63,12 @@ TEST_CASE("Model benchmark on demo-mix-v1 stub exits with code 3", "[benchmark][ args.add("model"); args.add("bench"); args.add("--pack"); +#if defined(AUTOMIX_SOURCE_DIR) + const auto packPath = std::filesystem::path(AUTOMIX_SOURCE_DIR) / "assets" / "models" / "demo-mix-v1"; + args.add(juce::String(packPath.string())); +#else args.add("assets/models/demo-mix-v1"); +#endif juce::ChildProcess process; REQUIRE(process.start(args)); From cd705914615b42763d146cec75da4a0f88ab1de6 Mon Sep 17 00:00:00 2001 From: Soficis Date: Sat, 3 Oct 2026 12:46:46 -0500 Subject: [PATCH 35/68] feat(renderers): pin portable PhaseLimiter v0.2.0-native2 for linux and macos --- cmake/PhaseLimiterPins.cmake | 16 ++++++++-------- tests/unit/PhaseLimiterDiscoveryTests.cpp | 9 ++++++++- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/cmake/PhaseLimiterPins.cmake b/cmake/PhaseLimiterPins.cmake index a305431..ddff874 100644 --- a/cmake/PhaseLimiterPins.cmake +++ b/cmake/PhaseLimiterPins.cmake @@ -4,17 +4,17 @@ set(AUTOMIX_PL_PIN_WINDOWS_X64_URL "https://github.com/ai-mastering/phaselimiter/releases/download/v0.2.0/phaselimiter-win.zip") set(AUTOMIX_PL_PIN_WINDOWS_X64_SHA256 "cab2d30ad8d993a383749b30d9f6dc1911198d3aa309d5f631b70206e0162145") -set(AUTOMIX_PL_PIN_LINUX_X64_URL "https://github.com/ai-mastering/phaselimiter/releases/download/v0.2.0/release.tar.xz") -set(AUTOMIX_PL_PIN_LINUX_X64_SHA256 "0b382ba78b030926f706345d1b00d5f45890ba384c8a4e960320e3ee992bd163") +set(AUTOMIX_PL_PIN_LINUX_X64_URL "https://github.com/soficis/phaselimiter/releases/download/v0.2.0-native2/phaselimiter-0.2.0-native2-linux-x64.tar.xz") +set(AUTOMIX_PL_PIN_LINUX_X64_SHA256 "994b587feee68b7ca3e95868ae8984df42806607a04acd6d4e854aaaa5389792") -set(AUTOMIX_PL_PIN_MACOS_ARM64_URL "https://github.com/soficis/phaselimiter/releases/download/v0.2.0-native1/phaselimiter-0.2.0-macos-arm64.tar.xz") -set(AUTOMIX_PL_PIN_MACOS_ARM64_SHA256 "bffd93614efc9f7d74b3ac148eef3731339dabbdaffaf9ab8912fc562473b722") +set(AUTOMIX_PL_PIN_LINUX_ARM64_URL "https://github.com/soficis/phaselimiter/releases/download/v0.2.0-native2/phaselimiter-0.2.0-native2-linux-arm64.tar.xz") +set(AUTOMIX_PL_PIN_LINUX_ARM64_SHA256 "87bb4deb2df3588a13822020440a61159f944367a1d011a3e8f3421fc55a9000") -set(AUTOMIX_PL_PIN_MACOS_X86_64_URL "") -set(AUTOMIX_PL_PIN_MACOS_X86_64_SHA256 "") +set(AUTOMIX_PL_PIN_MACOS_ARM64_URL "https://github.com/soficis/phaselimiter/releases/download/v0.2.0-native2/phaselimiter-0.2.0-native2-macos-arm64.tar.xz") +set(AUTOMIX_PL_PIN_MACOS_ARM64_SHA256 "6d7343359f4b38183b00151c3f56de62b113f7d6b18617cda1cc966f14b5aa8b") -set(AUTOMIX_PL_PIN_LINUX_ARM64_URL "") -set(AUTOMIX_PL_PIN_LINUX_ARM64_SHA256 "") +set(AUTOMIX_PL_PIN_MACOS_X86_64_URL "https://github.com/soficis/phaselimiter/releases/download/v0.2.0-native2/phaselimiter-0.2.0-native2-macos-x86_64.tar.xz") +set(AUTOMIX_PL_PIN_MACOS_X86_64_SHA256 "3135a49e9bd711cab54c32e074c12fbbe62229479d868b2fe7bb8b3c58bbceb8") function(automix_generate_phaselimiter_pins_header) set(_entries "") diff --git a/tests/unit/PhaseLimiterDiscoveryTests.cpp b/tests/unit/PhaseLimiterDiscoveryTests.cpp index 10bebff..bda200d 100644 --- a/tests/unit/PhaseLimiterDiscoveryTests.cpp +++ b/tests/unit/PhaseLimiterDiscoveryTests.cpp @@ -165,7 +165,9 @@ TEST_CASE("PhaseLimiter download pin table contains target platforms and valid s const std::vector requiredPlatforms = { "windows-x64", "linux-x64", + "linux-arm64", "macos-arm64", + "macos-x86_64", }; for (const auto& required : requiredPlatforms) { @@ -181,7 +183,7 @@ TEST_CASE("PhaseLimiter download pin table contains target platforms and valid s REQUIRE(((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))); } if (required == "linux-x64") { - REQUIRE(it->sha256 == "0b382ba78b030926f706345d1b00d5f45890ba384c8a4e960320e3ee992bd163"); + REQUIRE(it->sha256 == "994b587feee68b7ca3e95868ae8984df42806607a04acd6d4e854aaaa5389792"); } } @@ -290,5 +292,10 @@ TEST_CASE("PhaseLimiter pin table comes from the generated header", "[phaselimit REQUIRE(seenKeys.find(pin.platformKey) == seenKeys.end()); seenKeys.insert(pin.platformKey); } + + REQUIRE(seenKeys.size() == 5); + for (const auto& key : validKeys) { + REQUIRE(seenKeys.find(key) != seenKeys.end()); + } } From 0773c954df4a1be33bfecca573828c2dee30deaf Mon Sep 17 00:00:00 2001 From: Soficis Date: Sat, 3 Oct 2026 12:56:08 -0500 Subject: [PATCH 36/68] fix(ci): fix Windows shell syntax, macOS processor detection, and install ffmpeg --- .github/workflows/onnx_native.yml | 7 +++++-- cmake/FetchPhaseLimiter.cmake | 16 ++++++++++------ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/.github/workflows/onnx_native.yml b/.github/workflows/onnx_native.yml index 1cd5ba7..a9df509 100644 --- a/.github/workflows/onnx_native.yml +++ b/.github/workflows/onnx_native.yml @@ -15,6 +15,9 @@ jobs: native-ort: name: Native ORT (${{ matrix.os }}) runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash strategy: fail-fast: false matrix: @@ -35,7 +38,7 @@ jobs: run: | sudo apt-get update sudo apt-get install -y \ - build-essential cmake ninja-build pkg-config curl \ + build-essential cmake ninja-build pkg-config curl ffmpeg \ libasound2-dev libjack-jackd2-dev libfreetype6-dev libfontconfig1-dev \ libx11-dev libxcomposite-dev libxcursor-dev libxext-dev libxinerama-dev \ libxrandr-dev libxrender-dev libwebkit2gtk-4.1-dev libgtk-3-dev \ @@ -44,7 +47,7 @@ jobs: - name: Install macOS build tools if: runner.os == 'macOS' run: | - brew install ninja + brew install ninja ffmpeg - name: Cache fetched dependencies uses: actions/cache@v4 diff --git a/cmake/FetchPhaseLimiter.cmake b/cmake/FetchPhaseLimiter.cmake index 3fc1fbc..10b367d 100644 --- a/cmake/FetchPhaseLimiter.cmake +++ b/cmake/FetchPhaseLimiter.cmake @@ -22,9 +22,13 @@ function(automix_phaselimiter_platform_key OUT_VAR) if(NOT _proc_raw AND WIN32) set(_proc_raw "$ENV{PROCESSOR_ARCHITECTURE}") endif() + if(NOT _proc_raw AND (UNIX OR APPLE)) + execute_process(COMMAND uname -m OUTPUT_VARIABLE _uname_m OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET) + set(_proc_raw "${_uname_m}") + endif() string(TOLOWER "${_proc_raw}" _proc) - if(_os STREQUAL "Darwin" OR APPLE) + if(_os STREQUAL "Darwin") if(CMAKE_OSX_ARCHITECTURES) list(LENGTH CMAKE_OSX_ARCHITECTURES _num_archs) if(_num_archs GREATER 1) @@ -49,7 +53,7 @@ function(automix_phaselimiter_platform_key OUT_VAR) set(${OUT_VAR} "macos-x86_64" PARENT_SCOPE) return() else() - message(FATAL_ERROR "Unsupported macOS processor: ${CMAKE_SYSTEM_PROCESSOR}") + message(FATAL_ERROR "Unsupported macOS processor: ${_proc_raw}") endif() elseif(_os STREQUAL "Linux") @@ -60,19 +64,19 @@ function(automix_phaselimiter_platform_key OUT_VAR) set(${OUT_VAR} "linux-x64" PARENT_SCOPE) return() else() - message(FATAL_ERROR "Unsupported Linux processor: ${CMAKE_SYSTEM_PROCESSOR}") + message(FATAL_ERROR "Unsupported Linux processor: ${_proc_raw}") endif() - elseif(_os STREQUAL "Windows" OR WIN32) + elseif(_os STREQUAL "Windows") if(_proc MATCHES "x86_64|amd64") set(${OUT_VAR} "windows-x64" PARENT_SCOPE) return() else() - message(FATAL_ERROR "Unsupported Windows processor: ${CMAKE_SYSTEM_PROCESSOR}") + message(FATAL_ERROR "Unsupported Windows processor: ${_proc_raw}") endif() else() - message(FATAL_ERROR "Unsupported platform for PhaseLimiter: ${CMAKE_SYSTEM_NAME}") + message(FATAL_ERROR "Unsupported platform for PhaseLimiter: ${_os}") endif() endfunction() From 91454885dc4d47e93729a5c0153fa7c15aa80894 Mon Sep 17 00:00:00 2001 From: Soficis Date: Sat, 3 Oct 2026 12:57:55 -0500 Subject: [PATCH 37/68] ci(workflows): install ffmpeg in nightly_golden_eval and tsan_build for PhaseLimiter execution --- .github/workflows/nightly_golden_eval.yml | 2 +- .github/workflows/tsan_build.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/nightly_golden_eval.yml b/.github/workflows/nightly_golden_eval.yml index 158a5b8..311460d 100644 --- a/.github/workflows/nightly_golden_eval.yml +++ b/.github/workflows/nightly_golden_eval.yml @@ -16,7 +16,7 @@ jobs: - name: Install Dependencies run: | sudo apt-get update - sudo apt-get install -y libasound2-dev libjack-jackd2-dev libfreetype6-dev libfontconfig1-dev libx11-dev libxcomposite-dev libxcursor-dev libxext-dev libxinerama-dev libxrandr-dev libxrender-dev libwebkit2gtk-4.1-dev libgtk-3-dev libglu1-mesa-dev mesa-common-dev libcurl4-openssl-dev + sudo apt-get install -y ffmpeg libasound2-dev libjack-jackd2-dev libfreetype6-dev libfontconfig1-dev libx11-dev libxcomposite-dev libxcursor-dev libxext-dev libxinerama-dev libxrandr-dev libxrender-dev libwebkit2gtk-4.1-dev libgtk-3-dev libglu1-mesa-dev mesa-common-dev libcurl4-openssl-dev - name: Configure run: cmake -S . -B build-codex -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON -DBUILD_TOOLS=ON diff --git a/.github/workflows/tsan_build.yml b/.github/workflows/tsan_build.yml index 66c1dc8..6776ebc 100644 --- a/.github/workflows/tsan_build.yml +++ b/.github/workflows/tsan_build.yml @@ -23,7 +23,7 @@ jobs: run: | sudo apt-get update sudo apt-get install -y \ - clang-18 lld-18 \ + clang-18 lld-18 ffmpeg \ libasound2-dev libjack-jackd2-dev libfreetype6-dev libfontconfig1-dev \ libx11-dev libxcomposite-dev libxcursor-dev libxext-dev libxinerama-dev \ libxrandr-dev libxrender-dev libwebkit2gtk-4.1-dev libgtk-3-dev \ From f883286e9d8dc05682b03721d1ec2c91abc3c9ec Mon Sep 17 00:00:00 2001 From: Soficis Date: Sat, 3 Oct 2026 12:59:17 -0500 Subject: [PATCH 38/68] fix(ci): use YAML folded scalar for cmake configure to support Windows pwsh --- .github/workflows/onnx_native.yml | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/.github/workflows/onnx_native.yml b/.github/workflows/onnx_native.yml index a9df509..3569571 100644 --- a/.github/workflows/onnx_native.yml +++ b/.github/workflows/onnx_native.yml @@ -15,9 +15,6 @@ jobs: native-ort: name: Native ORT (${{ matrix.os }}) runs-on: ${{ matrix.os }} - defaults: - run: - shell: bash strategy: fail-fast: false matrix: @@ -58,15 +55,15 @@ jobs: deps-ort-1.30.0-${{ matrix.os }}- - name: Configure CMake - run: | - cmake -S . -B build \ - -G "${{ matrix.generator }}" \ - -DCMAKE_BUILD_TYPE=Release \ - -DENABLE_ONNX=ON \ - -DAUTOMIX_FETCH_ORT=ON \ - -DAUTOMIX_ORT_FLAVOR=cpu \ - -DBUILD_TESTING=ON \ - -DBUILD_TOOLS=ON + run: > + cmake -S . -B build + -G "${{ matrix.generator }}" + -DCMAKE_BUILD_TYPE=Release + -DENABLE_ONNX=ON + -DAUTOMIX_FETCH_ORT=ON + -DAUTOMIX_ORT_FLAVOR=cpu + -DBUILD_TESTING=ON + -DBUILD_TOOLS=ON - name: Build run: cmake --build build --config Release --parallel 3 From 3bf9b9c03f69268d17fca1d084049a15796c4617 Mon Sep 17 00:00:00 2001 From: Soficis Date: Sat, 3 Oct 2026 13:03:38 -0500 Subject: [PATCH 39/68] fix(ci): let CMake auto-detect installed Visual Studio version via -A x64 on Windows --- .github/workflows/onnx_native.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/onnx_native.yml b/.github/workflows/onnx_native.yml index 3569571..fe4636d 100644 --- a/.github/workflows/onnx_native.yml +++ b/.github/workflows/onnx_native.yml @@ -20,11 +20,11 @@ jobs: matrix: include: - os: ubuntu-24.04 - generator: "Ninja" + cmake_args: "-G Ninja" - os: windows-latest - generator: "Visual Studio 17 2022" + cmake_args: "-A x64" - os: macos-15 - generator: "Ninja" + cmake_args: "-G Ninja" steps: - name: Checkout @@ -57,7 +57,7 @@ jobs: - name: Configure CMake run: > cmake -S . -B build - -G "${{ matrix.generator }}" + ${{ matrix.cmake_args }} -DCMAKE_BUILD_TYPE=Release -DENABLE_ONNX=ON -DAUTOMIX_FETCH_ORT=ON From cc43fd0828bc45ffe6af1e75f499afe165c9b1ae Mon Sep 17 00:00:00 2001 From: Soficis Date: Sat, 3 Oct 2026 13:09:12 -0500 Subject: [PATCH 40/68] fix(tests): make ModelInstallProbe::installCalls atomic to resolve TSan data race --- tests/unit/AiExtensionTests.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/AiExtensionTests.cpp b/tests/unit/AiExtensionTests.cpp index ca8c496..9334cd2 100644 --- a/tests/unit/AiExtensionTests.cpp +++ b/tests/unit/AiExtensionTests.cpp @@ -875,7 +875,7 @@ bool waitForAsync(const std::function& predicate, const int timeoutMs = } struct ModelInstallProbe { - int installCalls = 0; + std::atomic installCalls{0}; }; automix::app::ModelController::ModelHubOps makeProbeModelHubOps(ModelInstallProbe& probe) { From 6a31c453f174186d5774140e0bc5e0903697b64d Mon Sep 17 00:00:00 2001 From: Soficis Date: Sat, 3 Oct 2026 13:35:06 -0500 Subject: [PATCH 41/68] fix(ci): install ffmpeg on Windows runner and support OutputDir staging in release packaging --- .github/workflows/onnx_native.yml | 6 ++++++ packaging/windows/build-release.ps1 | 29 +++++++++++++++++++++++--- src/renderers/PhaseLimiterRenderer.cpp | 11 +++++++--- 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/.github/workflows/onnx_native.yml b/.github/workflows/onnx_native.yml index fe4636d..cee721d 100644 --- a/.github/workflows/onnx_native.yml +++ b/.github/workflows/onnx_native.yml @@ -46,6 +46,12 @@ jobs: run: | brew install ninja ffmpeg + - name: Install Windows dependencies + if: runner.os == 'Windows' + run: | + choco install ffmpeg --no-progress + echo "C:\ProgramData\chocolatey\bin" >> $env:GITHUB_PATH + - name: Cache fetched dependencies uses: actions/cache@v4 with: diff --git a/packaging/windows/build-release.ps1 b/packaging/windows/build-release.ps1 index 0f311bc..c8b10c3 100644 --- a/packaging/windows/build-release.ps1 +++ b/packaging/windows/build-release.ps1 @@ -12,7 +12,8 @@ param( [string]$OnnxRuntimeDir = "", [string]$WebGpuPluginDir = "", [string]$BuildDir = "build-release", - [string]$Generator = "Visual Studio 18 2026", + [string]$OutputDir = "", + [string]$Generator = "", [switch]$SkipTests ) @@ -20,6 +21,10 @@ $ErrorActionPreference = "Stop" $repo = Resolve-Path (Join-Path $PSScriptRoot "..\..") Set-Location $repo +if ($OutputDir -and -not [System.IO.Path]::IsPathRooted($OutputDir)) { + $OutputDir = [System.IO.Path]::GetFullPath((Join-Path $repo $OutputDir)) +} + if (-not $WebGpuPluginDir) { $defaultWebGpu = "C:\lib\webgpu-win-x64\runtimes\win-x64\native" if (Test-Path $defaultWebGpu) { @@ -56,7 +61,12 @@ if ($OnnxRuntimeDir) { } } -cmake -S . -B $BuildDir -G $Generator -A x64 ` +$generatorArgs = @("-A", "x64") +if ($Generator) { + $generatorArgs = @("-G", $Generator, "-A", "x64") +} + +cmake -S . -B $BuildDir @generatorArgs ` -DENABLE_ONNX=ON ` @cmakeExtraArgs if ($LASTEXITCODE -ne 0) { throw "configure failed" } @@ -74,7 +84,20 @@ Push-Location $BuildDir try { cpack -C Release if ($LASTEXITCODE -ne 0) { throw "packaging failed" } - Get-ChildItem -Filter "AutoMixMaster-*.zip" | ForEach-Object { "Package: $($_.FullName) ($([math]::Round($_.Length / 1MB)) MB)" } + Get-ChildItem -Filter "AutoMixMaster-*.zip" | ForEach-Object { + "Package: $($_.FullName) ($([math]::Round($_.Length / 1MB)) MB)" + if ($OutputDir) { + if (-not (Test-Path $OutputDir)) { + New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null + } + $destZip = Join-Path $OutputDir $_.Name + Copy-Item $_.FullName -Destination $destZip -Force + $hash = (Get-FileHash -Path $destZip -Algorithm SHA256).Hash.ToLower() + "$hash $($_.Name)" | Set-Content "$destZip.sha256" + Write-Host "Staged $destZip with SHA256 $hash" + } + } } finally { Pop-Location } + diff --git a/src/renderers/PhaseLimiterRenderer.cpp b/src/renderers/PhaseLimiterRenderer.cpp index cc5b1f1..93f52f6 100644 --- a/src/renderers/PhaseLimiterRenderer.cpp +++ b/src/renderers/PhaseLimiterRenderer.cpp @@ -274,9 +274,14 @@ RenderResult PhaseLimiterRenderer::render(const domain::Session& session, const auto exitCode = process.getExitCode(); const bool hasOutput = pathExists(tempPhaseOutputPath); if (!hasOutput) { - const std::string outputHint = processOutput.empty() - ? "" - : (" output=" + processOutput.substr(0, 240)); + std::string snippet; + if (processOutput.size() <= 500) { + snippet = processOutput; + } else { + snippet = processOutput.substr(0, 200) + "\n...[snip]...\n" + + processOutput.substr(processOutput.size() - 300); + } + const std::string outputHint = snippet.empty() ? "" : (" output=" + snippet); return fallbackToBuiltIn(session, settings, onProgress, cancelFlag, "phase_limiter failed (exit=" + std::to_string(exitCode) + ")" + outputHint); } From 76aabda244bb66abacdc81b80329db76d82868ba Mon Sep 17 00:00:00 2001 From: Soficis Date: Sat, 3 Oct 2026 14:16:59 -0500 Subject: [PATCH 42/68] fix(ci): add ffmpeg dependency for Windows packaging and support Windows arm64 ORT cross-compilation --- .github/workflows/release_packages.yml | 11 ++++++++++- cmake/FetchOnnxRuntime.cmake | 4 +++- cmake/FetchPhaseLimiter.cmake | 2 +- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release_packages.yml b/.github/workflows/release_packages.yml index 2e8f209..7363fbe 100644 --- a/.github/workflows/release_packages.yml +++ b/.github/workflows/release_packages.yml @@ -159,6 +159,15 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Install dependencies (Windows) + shell: pwsh + run: | + choco install ffmpeg --no-progress + $ffmpegDir = "C:\ProgramData\chocolatey\lib\ffmpeg\tools\ffmpeg\bin" + if (Test-Path $ffmpegDir) { + echo "$ffmpegDir" >> $env:GITHUB_PATH + } + - name: Build & Package Windows x64 (CUDA + WebGPU) if: matrix.arch == 'x64' shell: pwsh @@ -169,7 +178,7 @@ jobs: if: matrix.arch == 'arm64' shell: pwsh run: | - cmake -S . -B build_windows_arm64 -A ARM64 -DENABLE_ONNX=ON -DAUTOMIX_FETCH_ORT=ON -DAUTOMIX_ORT_FLAVOR=cpu -DBUILD_TESTING=OFF -DBUILD_TOOLS=OFF + cmake -S . -B build_windows_arm64 -A ARM64 -DCMAKE_SYSTEM_PROCESSOR=ARM64 -DENABLE_ONNX=ON -DAUTOMIX_FETCH_ORT=ON -DAUTOMIX_ORT_FLAVOR=cpu -DBUILD_TESTING=OFF -DBUILD_TOOLS=OFF cmake --build build_windows_arm64 --config Release --target AutoMixMasterApp --parallel 3 $releaseRoot = "dist/release/windows" $packageDir = "$releaseRoot/AutoMixMaster-windows-arm64" diff --git a/cmake/FetchOnnxRuntime.cmake b/cmake/FetchOnnxRuntime.cmake index e6efb6a..12777b6 100644 --- a/cmake/FetchOnnxRuntime.cmake +++ b/cmake/FetchOnnxRuntime.cmake @@ -21,7 +21,9 @@ endif() # Determine OS and Architecture keys if(WIN32) set(_os "win") - if(CMAKE_SYSTEM_PROCESSOR MATCHES "ARM64|aarch64") + if(CMAKE_SYSTEM_PROCESSOR MATCHES "ARM64|aarch64" + OR CMAKE_GENERATOR_PLATFORM MATCHES "ARM64|arm64" + OR CMAKE_VS_PLATFORM_NAME MATCHES "ARM64|arm64") set(_arch "arm64") else() set(_arch "x64") diff --git a/cmake/FetchPhaseLimiter.cmake b/cmake/FetchPhaseLimiter.cmake index 10b367d..d919ee1 100644 --- a/cmake/FetchPhaseLimiter.cmake +++ b/cmake/FetchPhaseLimiter.cmake @@ -68,7 +68,7 @@ function(automix_phaselimiter_platform_key OUT_VAR) endif() elseif(_os STREQUAL "Windows") - if(_proc MATCHES "x86_64|amd64") + if(_proc MATCHES "x86_64|amd64|arm64|aarch64") set(${OUT_VAR} "windows-x64" PARENT_SCOPE) return() else() From 394f88b8130a35f6039c51e19ad2e6aa50a6a94b Mon Sep 17 00:00:00 2001 From: Soficis Date: Sat, 3 Oct 2026 15:29:23 -0500 Subject: [PATCH 43/68] ci(release): build Flatpak bundle on host with arch flag to resolve QEMU namespace failure --- .github/workflows/release_packages.yml | 26 ++++++++++---------------- tools/build_flatpak.sh | 20 +++++++++++++++++++- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/.github/workflows/release_packages.yml b/.github/workflows/release_packages.yml index 7363fbe..c2c56f3 100644 --- a/.github/workflows/release_packages.yml +++ b/.github/workflows/release_packages.yml @@ -102,6 +102,12 @@ jobs: sudo chown -R "$(id -u):$(id -g)" dist if [[ -d .ccache ]]; then sudo chown -R "$(id -u):$(id -g)" .ccache; fi + - name: Install Flatpak host dependencies (arm64) + if: matrix.emulated + run: | + sudo apt-get update + sudo apt-get install -y flatpak flatpak-builder + - name: Verify Flatpak prefetch wiring run: | grep -q "FETCHCONTENT_FULLY_DISCONNECTED=ON" packaging/flatpak/io.automixmaster.AutoMixMaster.yml @@ -111,30 +117,18 @@ jobs: # Flatpak sandbox builds can fail if CMake FetchContent tries live GitHub clone. # Keep manifest prefetch sources + FETCHCONTENT_SOURCE_DIR_* overrides in sync. - name: Build Flatpak bundle - if: ${{ !matrix.emulated }} + env: + FLATPAK_ARCH: ${{ matrix.arch }} + CCACHE_DIR: ${{ github.workspace }}/.ccache run: bash ./tools/build_flatpak.sh - - name: Build Flatpak bundle (arm64, QEMU-emulated) - if: matrix.emulated - run: | - docker run --rm --platform linux/arm64 \ - -v "${{ github.workspace }}:/workspace" \ - -w /workspace \ - -e AUTOMIX_FLATPAK_DISABLE_SANDBOX=1 \ - arm64v8/ubuntu:24.04 \ - bash -c 'set -euo pipefail; \ - apt-get update && apt-get install -y --no-install-recommends \ - flatpak flatpak-builder git ca-certificates; \ - bash ./tools/build_flatpak.sh' - sudo chown -R "$(id -u):$(id -g)" dist - - name: Collect Linux release assets run: | mkdir -p dist/release/linux cp dist/linux/*.deb dist/release/linux/ cp dist/linux/*.AppImage dist/release/linux/ cp dist/linux/*.sha256 dist/release/linux/ || true - cp dist/flatpak/AutoMixMaster.flatpak "dist/release/linux/AutoMixMaster-linux-${{ matrix.arch }}.flatpak" + cp dist/flatpak/AutoMixMaster.flatpak "dist/release/linux/AutoMixMaster-linux-${{ matrix.arch }}.flatpak" || true (cd dist/release/linux && sha256sum * > "SHA256SUMS-linux-${{ matrix.arch }}.txt") - name: Upload Linux assets diff --git a/tools/build_flatpak.sh b/tools/build_flatpak.sh index 84c17fe..b3874c4 100644 --- a/tools/build_flatpak.sh +++ b/tools/build_flatpak.sh @@ -30,10 +30,20 @@ if ! flatpak remote-ls --user flathub 2>/dev/null | grep -q "org.freedesktop.Pla flatpak remote-add --user --if-not-exists flathub "$FLATHUB_REMOTE_URL" fi +FLATPAK_ARCH="${FLATPAK_ARCH:-${1:-}}" +if [[ "$FLATPAK_ARCH" == "arm64" ]]; then + FLATPAK_ARCH="aarch64" +elif [[ "$FLATPAK_ARCH" == "x64" || "$FLATPAK_ARCH" == "x86_64" ]]; then + FLATPAK_ARCH="x86_64" +fi + EXTRA_ARGS=() if flatpak-builder --help 2>&1 | grep -q -- "--disable-rofiles-fuse"; then EXTRA_ARGS+=(--disable-rofiles-fuse) fi +if flatpak-builder --help 2>&1 | grep -q -- "--ccache"; then + EXTRA_ARGS+=(--ccache) +fi if [[ "${AUTOMIX_FLATPAK_DISABLE_SANDBOX:-0}" == "1" ]]; then if flatpak-builder --help 2>&1 | grep -q -- "--disable-sandbox"; then EXTRA_ARGS+=(--disable-sandbox) @@ -41,6 +51,9 @@ if [[ "${AUTOMIX_FLATPAK_DISABLE_SANDBOX:-0}" == "1" ]]; then echo "Warning: this flatpak-builder does not support --disable-sandbox; continuing without it." fi fi +if [[ -n "$FLATPAK_ARCH" ]]; then + EXTRA_ARGS+=(--arch="$FLATPAK_ARCH") +fi flatpak-builder \ --user \ @@ -51,7 +64,12 @@ flatpak-builder \ "$BUILD_DIR" \ "$MANIFEST" +BUILD_BUNDLE_ARGS=() +if [[ -n "$FLATPAK_ARCH" ]]; then + BUILD_BUNDLE_ARGS+=(--arch="$FLATPAK_ARCH") +fi + BUNDLE_PATH="$DIST_DIR/AutoMixMaster.flatpak" -flatpak build-bundle "$REPO_DIR" "$BUNDLE_PATH" "$APP_ID" +flatpak build-bundle "${BUILD_BUNDLE_ARGS[@]}" "$REPO_DIR" "$BUNDLE_PATH" "$APP_ID" echo "Built Flatpak bundle: $BUNDLE_PATH" From 3385ba57a43ea0a5cdc4f97730d20de24f1139ca Mon Sep 17 00:00:00 2001 From: Soficis Date: Sat, 3 Oct 2026 16:02:56 -0500 Subject: [PATCH 44/68] ci: build and test linux arm64 on native ARM runners; remove QEMU --- .github/workflows/onnx_native.yml | 2 ++ .github/workflows/release_packages.yml | 49 +++----------------------- tools/build_flatpak.sh | 7 ---- tools/package_linux.sh | 12 +------ 4 files changed, 7 insertions(+), 63 deletions(-) diff --git a/.github/workflows/onnx_native.yml b/.github/workflows/onnx_native.yml index cee721d..d51b93c 100644 --- a/.github/workflows/onnx_native.yml +++ b/.github/workflows/onnx_native.yml @@ -21,6 +21,8 @@ jobs: include: - os: ubuntu-24.04 cmake_args: "-G Ninja" + - os: ubuntu-24.04-arm + cmake_args: "-G Ninja" - os: windows-latest cmake_args: "-A x64" - os: macos-15 diff --git a/.github/workflows/release_packages.yml b/.github/workflows/release_packages.yml index c2c56f3..0d45b69 100644 --- a/.github/workflows/release_packages.yml +++ b/.github/workflows/release_packages.yml @@ -30,21 +30,19 @@ concurrency: jobs: linux-packages: name: Linux (${{ matrix.arch }}) - runs-on: ubuntu-24.04 + runs-on: ${{ matrix.runner }} strategy: fail-fast: false matrix: include: - arch: x64 - emulated: false + runner: ubuntu-24.04 - arch: arm64 - emulated: true + runner: ubuntu-24.04-arm steps: - name: Checkout uses: actions/checkout@v4 - # Persist compiler output so emulated arm64 rebuilds (failure retries) - # do not recompile the whole tree under QEMU. - name: Cache ccache uses: actions/cache@v4 with: @@ -53,12 +51,7 @@ jobs: restore-keys: | ccache-linux-${{ matrix.arch }}- - - name: Set up QEMU for arm64 emulation - if: matrix.emulated - uses: docker/setup-qemu-action@v3 - - name: Install build dependencies - if: ${{ !matrix.emulated }} run: | sudo apt-get update sudo apt-get install -y \ @@ -69,45 +62,11 @@ jobs: libglu1-mesa-dev mesa-common-dev libcurl4-openssl-dev \ dpkg-dev fakeroot flatpak flatpak-builder - # Public ubuntu-24.04-arm64 runners are frequently starved for 10h+, - # so arm64 packages are built on the x64 runner inside an emulated - # arm64v8/ubuntu:24.04 container (docker/setup-qemu-action registers - # binfmt_misc, docker run --platform linux/arm64 executes native arm64 - # binaries under QEMU user-mode emulation). - name: Build .deb + AppImage - if: ${{ !matrix.emulated }} env: CCACHE_DIR: ${{ github.workspace }}/.ccache run: bash ./tools/package_linux.sh - - name: Build .deb + AppImage (arm64, QEMU-emulated) - if: matrix.emulated - run: | - docker run --rm --platform linux/arm64 \ - -v "${{ github.workspace }}:/workspace" \ - -w /workspace \ - -e CCACHE_DIR=/workspace/.ccache \ - -e AUTOMIX_QEMU_EMULATED=1 \ - arm64v8/ubuntu:24.04 \ - bash -c 'set -euo pipefail; \ - apt-get update && apt-get install -y --no-install-recommends \ - build-essential cmake ninja-build pkg-config curl git ca-certificates \ - ccache unzip file xz-utils \ - libasound2-dev libjack-jackd2-dev libfreetype6-dev libfontconfig1-dev \ - libx11-dev libxcomposite-dev libxcursor-dev libxext-dev libxinerama-dev \ - libxrandr-dev libxrender-dev libwebkit2gtk-4.1-dev libgtk-3-dev \ - libglu1-mesa-dev mesa-common-dev libcurl4-openssl-dev \ - dpkg-dev fakeroot; \ - bash ./tools/package_linux.sh' - sudo chown -R "$(id -u):$(id -g)" dist - if [[ -d .ccache ]]; then sudo chown -R "$(id -u):$(id -g)" .ccache; fi - - - name: Install Flatpak host dependencies (arm64) - if: matrix.emulated - run: | - sudo apt-get update - sudo apt-get install -y flatpak flatpak-builder - - name: Verify Flatpak prefetch wiring run: | grep -q "FETCHCONTENT_FULLY_DISCONNECTED=ON" packaging/flatpak/io.automixmaster.AutoMixMaster.yml @@ -118,7 +77,7 @@ jobs: # Keep manifest prefetch sources + FETCHCONTENT_SOURCE_DIR_* overrides in sync. - name: Build Flatpak bundle env: - FLATPAK_ARCH: ${{ matrix.arch }} + FLATPAK_ARCH: ${{ matrix.arch == 'arm64' && 'aarch64' || 'x86_64' }} CCACHE_DIR: ${{ github.workspace }}/.ccache run: bash ./tools/build_flatpak.sh diff --git a/tools/build_flatpak.sh b/tools/build_flatpak.sh index b3874c4..8acdf99 100644 --- a/tools/build_flatpak.sh +++ b/tools/build_flatpak.sh @@ -44,13 +44,6 @@ fi if flatpak-builder --help 2>&1 | grep -q -- "--ccache"; then EXTRA_ARGS+=(--ccache) fi -if [[ "${AUTOMIX_FLATPAK_DISABLE_SANDBOX:-0}" == "1" ]]; then - if flatpak-builder --help 2>&1 | grep -q -- "--disable-sandbox"; then - EXTRA_ARGS+=(--disable-sandbox) - else - echo "Warning: this flatpak-builder does not support --disable-sandbox; continuing without it." - fi -fi if [[ -n "$FLATPAK_ARCH" ]]; then EXTRA_ARGS+=(--arch="$FLATPAK_ARCH") fi diff --git a/tools/package_linux.sh b/tools/package_linux.sh index 99704d5..1ca0475 100644 --- a/tools/package_linux.sh +++ b/tools/package_linux.sh @@ -326,17 +326,7 @@ APPRUN } local appimagetool - - # In QEMU-emulated containers the AppImage runtime cannot be executed - # (execve fails with "Exec format error"). The CI workflow signals this - # via AUTOMIX_QEMU_EMULATED=1 so we can cross-build with the x86_64 - # appimagetool, whose static binary runs natively on the host kernel. - if [[ "${AUTOMIX_QEMU_EMULATED:-}" == "1" && "$arch" == "aarch64" ]]; then - echo "QEMU-emulated arm64 build detected; cross-building AppImage with x86_64 appimagetool" >&2 - appimagetool="$(fetch_appimagetool "x86_64")" - else - appimagetool="$(fetch_appimagetool "$arch")" - fi + appimagetool="$(fetch_appimagetool "$arch")" local output="$DIST_DIR/${APP_NAME}-${VERSION}-${arch}.AppImage" APPIMAGE_EXTRACT_AND_RUN=1 ARCH="$arch" "$appimagetool" "$stage_dir" "$output" >/dev/null From 0edb6cf4bcc242fc65fd5a3fc879bd1c9f5c554c Mon Sep 17 00:00:00 2001 From: Soficis Date: Sat, 3 Oct 2026 16:16:59 -0500 Subject: [PATCH 45/68] ci: forbid masked failures and unpinned actions; widen hygiene patterns --- .github/workflows/nightly_golden_eval.yml | 4 +- .github/workflows/onnx_native.yml | 7 +- .github/workflows/release_packages.yml | 29 +++-- .github/workflows/repo_hygiene.yml | 8 +- .github/workflows/tsan_build.yml | 4 +- tests/tools/test_check_repo_hygiene.py | 85 ++++++++++++++ tools/check_repo_hygiene.py | 133 +++++++++++++++++----- 7 files changed, 221 insertions(+), 49 deletions(-) create mode 100644 tests/tools/test_check_repo_hygiene.py diff --git a/.github/workflows/nightly_golden_eval.yml b/.github/workflows/nightly_golden_eval.yml index 311460d..8add4a3 100644 --- a/.github/workflows/nightly_golden_eval.yml +++ b/.github/workflows/nightly_golden_eval.yml @@ -11,7 +11,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Install Dependencies run: | @@ -39,7 +39,7 @@ jobs: - name: Upload Eval Artifacts if: success() || failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: nightly-golden-eval path: | diff --git a/.github/workflows/onnx_native.yml b/.github/workflows/onnx_native.yml index d51b93c..7576da5 100644 --- a/.github/workflows/onnx_native.yml +++ b/.github/workflows/onnx_native.yml @@ -30,7 +30,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Install Linux dependencies if: runner.os == 'Linux' @@ -55,7 +55,7 @@ jobs: echo "C:\ProgramData\chocolatey\bin" >> $env:GITHUB_PATH - name: Cache fetched dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: build/_deps key: deps-ort-1.30.0-${{ matrix.os }}-${{ hashFiles('cmake/FetchOnnxRuntime.cmake', 'CMakeLists.txt') }} @@ -78,6 +78,3 @@ jobs: - name: Run Tests (Full Suite) run: ctest --test-dir build -C Release --output-on-failure - - - name: Run Native Tagged Tests - run: ctest --test-dir build -C Release -L native --output-on-failure || true diff --git a/.github/workflows/release_packages.yml b/.github/workflows/release_packages.yml index 0d45b69..4bd3383 100644 --- a/.github/workflows/release_packages.yml +++ b/.github/workflows/release_packages.yml @@ -41,10 +41,10 @@ jobs: runner: ubuntu-24.04-arm steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Cache ccache - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: .ccache key: ccache-linux-${{ matrix.arch }}-${{ github.sha }} @@ -86,12 +86,19 @@ jobs: mkdir -p dist/release/linux cp dist/linux/*.deb dist/release/linux/ cp dist/linux/*.AppImage dist/release/linux/ - cp dist/linux/*.sha256 dist/release/linux/ || true - cp dist/flatpak/AutoMixMaster.flatpak "dist/release/linux/AutoMixMaster-linux-${{ matrix.arch }}.flatpak" || true + cp dist/linux/*.sha256 dist/release/linux/ + cp dist/flatpak/AutoMixMaster.flatpak "dist/release/linux/AutoMixMaster-linux-${{ matrix.arch }}.flatpak" (cd dist/release/linux && sha256sum * > "SHA256SUMS-linux-${{ matrix.arch }}.txt") + - name: Assert Linux release packages exist + run: | + test -s "dist/release/linux/AutoMixMaster-linux-${{ matrix.arch }}.flatpak" + for f in dist/release/linux/*.deb dist/release/linux/*.AppImage; do + test -s "$f" + done + - name: Upload Linux assets - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: release-linux-${{ matrix.arch }} path: dist/release/linux/* @@ -110,7 +117,7 @@ jobs: vs_platform: ARM64 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Install dependencies (Windows) shell: pwsh @@ -157,7 +164,7 @@ jobs: "$hash $zipName" | Set-Content "$zipPath.sha256" - name: Upload Windows assets - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: release-windows-${{ matrix.arch }} path: | @@ -182,7 +189,7 @@ jobs: enable_onnx: "ON" steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Configure run: > @@ -219,7 +226,7 @@ jobs: shasum -a 256 "$ZIP_PATH" > "${ZIP_PATH}.sha256" - name: Upload macOS assets - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: release-macos-${{ matrix.arch }} path: | @@ -237,7 +244,7 @@ jobs: - macos-package steps: - name: Download all packaged artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: pattern: release-* merge-multiple: true @@ -301,7 +308,7 @@ jobs: fi - name: Publish assets - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: tag_name: ${{ steps.release-meta.outputs.tag }} name: ${{ steps.release-meta.outputs.name }} diff --git a/.github/workflows/repo_hygiene.yml b/.github/workflows/repo_hygiene.yml index 71caaf1..22f2ce3 100644 --- a/.github/workflows/repo_hygiene.yml +++ b/.github/workflows/repo_hygiene.yml @@ -17,20 +17,22 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: '3.12' - name: Check repository hygiene & forbidden paths run: | python tools/check_repo_hygiene.py + python tools/check_repo_hygiene.py --check-workflows + python -m unittest tests/tools/test_check_repo_hygiene.py -v - name: Run Gitleaks secret detection - uses: gitleaks/gitleaks-action@v2 + uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/tsan_build.yml b/.github/workflows/tsan_build.yml index 6776ebc..d2e706d 100644 --- a/.github/workflows/tsan_build.yml +++ b/.github/workflows/tsan_build.yml @@ -17,7 +17,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Install Dependencies run: | @@ -53,7 +53,7 @@ jobs: - name: Upload TSan Logs (on failure) if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: tsan-logs path: | diff --git a/tests/tools/test_check_repo_hygiene.py b/tests/tools/test_check_repo_hygiene.py new file mode 100644 index 0000000..dec6987 --- /dev/null +++ b/tests/tools/test_check_repo_hygiene.py @@ -0,0 +1,85 @@ +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent +SCRIPT_PATH = REPO_ROOT / "tools" / "check_repo_hygiene.py" + + +class TestCheckRepoHygiene(unittest.TestCase): + def run_hygiene(self, args): + cmd = [sys.executable, str(SCRIPT_PATH)] + args + proc = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True + ) + return proc.returncode, proc.stdout + + def test_mask_detected(self): + with tempfile.TemporaryDirectory() as tmpdir: + wf_path = Path(tmpdir) / "test_mask.yml" + wf_path.write_text("name: Test\njobs:\n build:\n steps:\n - run: ctest || true\n") + rc, out = self.run_hygiene(["--check-workflows", "--workflows-dir", tmpdir]) + self.assertEqual(rc, 1, f"Expected rc 1, got {rc}. Output:\n{out}") + self.assertIn("MASK:", out) + + def test_unpinned_action_detected(self): + with tempfile.TemporaryDirectory() as tmpdir: + wf_path = Path(tmpdir) / "test_unpinned.yml" + wf_path.write_text("name: Test\njobs:\n build:\n steps:\n - uses: actions/checkout@v4\n") + rc, out = self.run_hygiene(["--check-workflows", "--workflows-dir", tmpdir]) + self.assertEqual(rc, 1, f"Expected rc 1, got {rc}. Output:\n{out}") + self.assertIn("UNPINNED:", out) + + def test_pinned_action_ok(self): + with tempfile.TemporaryDirectory() as tmpdir: + wf_path = Path(tmpdir) / "test_pinned.yml" + wf_path.write_text("name: Test\njobs:\n build:\n steps:\n - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4\n") + rc, out = self.run_hygiene(["--check-workflows", "--workflows-dir", tmpdir]) + self.assertEqual(rc, 0, f"Expected rc 0, got {rc}. Output:\n{out}") + + def test_forbidden_paths(self): + forbidden_samples = [ + ".superpowers/x", + ".anchor/x", + "memory/x.md", + ".claude/x", + ".gemini/x", + ".opencode/x", + ".codex/x", + "GEMINI.md", + ".cursorrules", + "notes/SSH_HANDOFF.md", + "x/handoff-2.md", + "job-logs1.txt", + "a/b.jsonl", + ] + allowed_samples = [ + "docs/model-licensing-audit.json", + "README.md", + "assets/sox/README.md", + "tools/training/README.md", + ] + with tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as f: + for p in forbidden_samples + allowed_samples: + f.write(p + "\n") + list_file = f.name + + try: + rc, out = self.run_hygiene(["--paths-from", list_file]) + self.assertEqual(rc, 1, f"Expected rc 1, got {rc}. Output:\n{out}") + for p in forbidden_samples: + self.assertIn(f"FORBIDDEN: {p}", out) + for p in allowed_samples: + self.assertNotIn(f"FORBIDDEN: {p}", out) + finally: + os.remove(list_file) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/check_repo_hygiene.py b/tools/check_repo_hygiene.py index 48ec7de..7fbaaf7 100644 --- a/tools/check_repo_hygiene.py +++ b/tools/check_repo_hygiene.py @@ -5,6 +5,7 @@ Repository hygiene verification script. Ensures no forbidden files, sensitive IP patterns, private tokens, or developer notes are committed or staged in the repository. +Also verifies GitHub Actions workflow pinning and failure mask rules. """ import argparse @@ -12,6 +13,7 @@ import re import subprocess import sys +from pathlib import Path # Paths/patterns that must NEVER exist in the repo FORBIDDEN_PATH_PATTERNS = [ @@ -22,6 +24,19 @@ re.compile(r"^assets/phaselimiter/assets(/|$)", re.IGNORECASE), # Under docs/, ONLY model-licensing-audit.json is permitted re.compile(r"^docs/(?!model-licensing-audit\.json$).+", re.IGNORECASE), + # Extended patterns from Round 5 + re.compile(r"(^|/)\.superpowers(/|$)", re.IGNORECASE), + re.compile(r"(^|/)\.anchor(/|$)", re.IGNORECASE), + re.compile(r"(^|/)memory(/|$)", re.IGNORECASE), + re.compile(r"(^|/)\.claude(/|$)", re.IGNORECASE), + re.compile(r"(^|/)\.gemini(/|$)", re.IGNORECASE), + re.compile(r"(^|/)\.opencode(/|$)", re.IGNORECASE), + re.compile(r"(^|/)\.codex(/|$)", re.IGNORECASE), + re.compile(r"(^|/)GEMINI\.md$", re.IGNORECASE), + re.compile(r"(^|/)\.cursorrules$", re.IGNORECASE), + re.compile(r"(^|/)[^/]*handoff[^/]*\.md$", re.IGNORECASE), + re.compile(r"(^|/)job-logs.*\.txt$", re.IGNORECASE), + re.compile(r"\.jsonl$", re.IGNORECASE), ] # Sensitive content regex patterns @@ -42,6 +57,7 @@ EXEMPT_PATHS = { "tools/check_repo_hygiene.py", # self-exempt for pattern literals + "tests/tools/test_check_repo_hygiene.py", } @@ -57,7 +73,7 @@ def check_path_hygiene(file_path): normalized = file_path.replace("\\", "/").lstrip("/") for pat in FORBIDDEN_PATH_PATTERNS: if pat.search(normalized): - return f"Forbidden path pattern '{pat.pattern}' matched: {file_path}" + return f"FORBIDDEN: {file_path} (matched '{pat.pattern}')" return None @@ -87,42 +103,107 @@ def check_file_content(file_path): return findings +def check_workflow_file(file_path): + """Check a GitHub Actions workflow file for failure masks and unpinned actions.""" + findings = [] + normalized_path = os.path.normpath(file_path).replace("\\", "/") + try: + with open(file_path, "r", encoding="utf-8", errors="replace") as f: + for line_no, line in enumerate(f, start=1): + # 1. Mask detection: || true, continue-on-error: true, if: false + if (re.search(r"\|\|\s*true\b", line) or + re.search(r"continue-on-error:\s*true\b", line, re.IGNORECASE) or + re.search(r"^\s*if:\s*false\b", line, re.IGNORECASE)): + findings.append(f"MASK: {normalized_path}:{line_no}: {line.strip()}") + + # 2. Unpinned action detection: uses: owner/repo@ref + m = re.search(r"uses:\s*([^\s#]+)", line) + if m: + action_ref = m.group(1) + if not action_ref.startswith("./"): + if "@" in action_ref: + _, ref = action_ref.split("@", 1) + if not re.fullmatch(r"[0-9a-fA-F]{40}", ref): + findings.append(f"UNPINNED: {normalized_path}:{line_no}: {action_ref}") + else: + findings.append(f"UNPINNED: {normalized_path}:{line_no}: {action_ref}") + except Exception as e: + findings.append(f"ERROR: {normalized_path}: unable to read file: {e}") + + return findings + + +def check_workflows_dir(workflows_dir): + """Scan all YAML workflow files in workflows_dir.""" + p = Path(workflows_dir) + if not p.is_dir(): + return [f"ERROR: Workflows directory '{workflows_dir}' not found"] + + findings = [] + yaml_files = sorted(list(p.glob("*.yml")) + list(p.glob("*.yaml"))) + for yml in yaml_files: + findings.extend(check_workflow_file(str(yml))) + return findings + + def main(): parser = argparse.ArgumentParser(description="Check repository hygiene and scan for sensitive content.") parser.add_argument("--staged", action="store_true", help="Scan only git staged files.") parser.add_argument("--path", nargs="*", help="Specific file paths to scan.") + parser.add_argument("--paths-from", type=str, help="File containing newline-separated paths to scan.") + parser.add_argument("--check-workflows", action="store_true", help="Check workflows for failure masks and unpinned actions.") + parser.add_argument("--workflows-dir", type=str, default=".github/workflows", help="Directory containing workflow files to check.") args = parser.parse_args() - files = args.path if args.path else get_git_files(staged_only=args.staged) - - path_errors = [] - content_errors = [] - - for file_path in files: - err = check_path_hygiene(file_path) - if err: - path_errors.append(err) - - content_errs = check_file_content(file_path) - content_errors.extend(content_errs) - has_errors = False - if path_errors: - has_errors = True - print("[ERROR] Hygiene: Forbidden paths found:", file=sys.stderr) - for err in path_errors: - print(f" - {err}", file=sys.stderr) - - if content_errors: - has_errors = True - print("[ERROR] Hygiene: Sensitive patterns found in file contents:", file=sys.stderr) - for err in content_errors: - print(f" - {err}", file=sys.stderr) + + if args.check_workflows: + wf_errors = check_workflows_dir(args.workflows_dir) + if wf_errors: + has_errors = True + for err in wf_errors: + print(err) + + files_to_check = None + if args.paths_from: + with open(args.paths_from, "r", encoding="utf-8") as f: + files_to_check = [line.strip() for line in f if line.strip()] + elif args.path: + files_to_check = args.path + elif not args.check_workflows: + files_to_check = get_git_files(staged_only=args.staged) + + if files_to_check is not None: + path_errors = [] + content_errors = [] + + for file_path in files_to_check: + err = check_path_hygiene(file_path) + if err: + path_errors.append(err) + + content_errs = check_file_content(file_path) + content_errors.extend(content_errs) + + if path_errors: + has_errors = True + print("[ERROR] Hygiene: Forbidden paths found:", file=sys.stderr) + for err in path_errors: + print(f" - {err}", file=sys.stderr) + + if content_errors: + has_errors = True + print("[ERROR] Hygiene: Sensitive patterns found in file contents:", file=sys.stderr) + for err in content_errors: + print(f" - {err}", file=sys.stderr) if has_errors: sys.exit(1) - print(f"[OK] Repository hygiene check passed: {len(files)} files scanned, 0 violations.") + if args.check_workflows and files_to_check is None: + print("[OK] Workflow hygiene check passed: 0 masks, 0 unpinned actions.") + else: + print(f"[OK] Repository hygiene check passed: {len(files_to_check or [])} files scanned, 0 violations.") sys.exit(0) From ab9d0fbc0a9ac8b180d08eae70c0a41cac500fc2 Mon Sep 17 00:00:00 2001 From: Soficis Date: Sat, 3 Oct 2026 16:39:35 -0500 Subject: [PATCH 46/68] fix(renderers): resolve ffmpeg explicitly for PhaseLimiter; clear error when absent Decision: Option (b) - pass -ffmpeg flag to phase_limiter. Testing native2 phase_limiter with PATH stripped of ffmpeg established that while -disable_input_encode=true avoids ffmpeg on WAV input decode, output encoding invokes ffmpeg unconditionally and fails with 'sh: 1: ffmpeg: not found'. phase_limiter --helpfull provides -ffmpeg flag for custom executable paths, but no flag exists in native2 to bypass output encode. AutoMixMaster now surfaces ffmpeg discovery via ffmpegForPhaseLimiter() (checking FFMPEG_BIN, bundled assets, then PATH). If ffmpeg is absent, PhaseLimiterRenderer returns an explicit error without falling back to BuiltIn. When found, -ffmpeg= is passed explicitly to the process. --- README.md | 1 + src/renderers/PhaseLimiterDiscovery.cpp | 31 ++++++ src/renderers/PhaseLimiterDiscovery.h | 13 +++ src/renderers/PhaseLimiterRenderer.cpp | 13 ++- tests/unit/PhaseLimiterRendererTests.cpp | 118 +++++++++++++++++++++++ 5 files changed, 175 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index fbb7f0c..0edc138 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,7 @@ AutoMixMaster is designed to benefit from **GPU acceleration** via ONNX Runtime - macOS (Apple Silicon): **CoreML / ANE** - *(Note: Intel Macs do not support AI tensor/model inference; heuristics and audio processing remain functional)* - **Storage:** ~10 GB free (models, temp files, exports) +- **PhaseLimiter rendering:** requires ffmpeg (install ffmpeg on Windows; `brew install ffmpeg` / `apt install ffmpeg` elsewhere, or set `FFMPEG_BIN`). ### Recommended (smoother) diff --git a/src/renderers/PhaseLimiterDiscovery.cpp b/src/renderers/PhaseLimiterDiscovery.cpp index f10fe8d..9ef391e 100644 --- a/src/renderers/PhaseLimiterDiscovery.cpp +++ b/src/renderers/PhaseLimiterDiscovery.cpp @@ -1,5 +1,6 @@ #include "renderers/PhaseLimiterDiscovery.h" #include "renderers/PhaseLimiterPins.h" +#include "renderers/FfmpegDiscovery.h" #include #include @@ -600,4 +601,34 @@ std::optional PhaseLimiterDiscovery::findInRoots( return std::nullopt; } +namespace { +std::mutex s_ffmpegResolverMutex; +FfmpegResolver s_ffmpegResolverForTesting = nullptr; +} // namespace + +void setFfmpegResolverForTesting(FfmpegResolver resolver) { + std::lock_guard lock(s_ffmpegResolverMutex); + s_ffmpegResolverForTesting = std::move(resolver); +} + +void resetFfmpegResolverForTesting() { + std::lock_guard lock(s_ffmpegResolverMutex); + s_ffmpegResolverForTesting = nullptr; +} + +std::optional ffmpegForPhaseLimiter() { + { + std::lock_guard lock(s_ffmpegResolverMutex); + if (s_ffmpegResolverForTesting) { + return s_ffmpegResolverForTesting(); + } + } + + FfmpegDiscovery discovery; + if (const auto ffmpeg = discovery.find(); ffmpeg.has_value()) { + return ffmpeg->executablePath; + } + return std::nullopt; +} + } // namespace automix::renderers diff --git a/src/renderers/PhaseLimiterDiscovery.h b/src/renderers/PhaseLimiterDiscovery.h index 575526c..b49346f 100644 --- a/src/renderers/PhaseLimiterDiscovery.h +++ b/src/renderers/PhaseLimiterDiscovery.h @@ -41,6 +41,12 @@ std::vector phaseLimiterDownloadPinTable(); std::optional defaultPhaseLimiterDownloadPin(); std::string currentPhaseLimiterPlatformKey(); +using FfmpegResolver = std::function()>; + +std::optional ffmpegForPhaseLimiter(); +void setFfmpegResolverForTesting(FfmpegResolver resolver); +void resetFfmpegResolverForTesting(); + class PhaseLimiterDiscovery { public: using DownloadFetcher = std::function; @@ -54,6 +60,13 @@ class PhaseLimiterDiscovery { static void setDownloadFetcherForTesting(DownloadFetcher fetcher); static void resetDownloadFetcherForTesting(); static void resetAttemptedDownloadForTesting(); + + static void setFfmpegResolverForTesting(FfmpegResolver resolver) { + ::automix::renderers::setFfmpegResolverForTesting(std::move(resolver)); + } + static void resetFfmpegResolverForTesting() { + ::automix::renderers::resetFfmpegResolverForTesting(); + } }; } // namespace automix::renderers diff --git a/src/renderers/PhaseLimiterRenderer.cpp b/src/renderers/PhaseLimiterRenderer.cpp index 93f52f6..27fb79f 100644 --- a/src/renderers/PhaseLimiterRenderer.cpp +++ b/src/renderers/PhaseLimiterRenderer.cpp @@ -150,7 +150,7 @@ void drainProcessOutput(juce::ChildProcess& process, std::string& outputCapture) bool PhaseLimiterRenderer::isAvailable() const { const auto found = PhaseLimiterDiscovery{}.find(); - return found.has_value() && isCompleteInstall(*found); + return found.has_value() && isCompleteInstall(*found) && ffmpegForPhaseLimiter().has_value(); } RenderResult PhaseLimiterRenderer::render(const domain::Session& session, @@ -174,6 +174,16 @@ RenderResult PhaseLimiterRenderer::render(const domain::Session& session, pathToUtf8(masteringReferencePath(*binaryInfo))); } + const auto ffmpegPath = ffmpegForPhaseLimiter(); + if (!ffmpegPath.has_value()) { + RenderResult result; + result.success = false; + result.rendererName = "PhaseLimiter"; + result.logs.push_back( + "PhaseLimiter needs ffmpeg, which was not found. Install ffmpeg or set FFMPEG_BIN."); + return result; + } + engine::OfflineRenderPipeline pipeline; auto renderState = pipeline.renderRawMix( session, settings, @@ -238,6 +248,7 @@ RenderResult PhaseLimiterRenderer::render(const domain::Session& session, command.add(pathToUtf8(binaryInfo->executablePath)); command.add("-input=" + pathToUtf8(tempInputPath)); command.add("-output=" + pathToUtf8(tempPhaseOutputPath)); + command.add("-ffmpeg=" + pathToUtf8(*ffmpegPath)); command.add("-mastering_reference_file=" + pathToUtf8(masteringReferencePath(*binaryInfo))); command.add("-sound_quality2_cache=" + pathToUtf8(soundQualityCachePath(*binaryInfo))); command.add("-disable_input_encode=true"); diff --git a/tests/unit/PhaseLimiterRendererTests.cpp b/tests/unit/PhaseLimiterRendererTests.cpp index fbe3a67..65f6f1a 100644 --- a/tests/unit/PhaseLimiterRendererTests.cpp +++ b/tests/unit/PhaseLimiterRendererTests.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include @@ -11,6 +12,18 @@ namespace { +void setEnvValue(const char* key, const std::string& value) { +#if defined(_WIN32) + _putenv_s(key, value.c_str()); +#else + if (value.empty()) { + unsetenv(key); + } else { + setenv(key, value.c_str(), 1); + } +#endif +} + automix::engine::AudioBuffer makeTone(const double sampleRate, const int samples, const double frequency, @@ -129,4 +142,109 @@ TEST_CASE("A selected PhaseLimiter really renders and leaves no scratch behind", REQUIRE(countEntries(installRoot / "tmp") <= legacyBefore); // nothing new in the install REQUIRE(std::filesystem::current_path() == workingDirectory); // no process-wide cwd change std::filesystem::remove_all(tempDir); +} + +TEST_CASE("PhaseLimiter render reports missing ffmpeg instead of falling back", "[phaselimiter][renderer]") { + const std::filesystem::path tempDir = std::filesystem::temp_directory_path() / "automix_pl_test_missing_ffmpeg"; + std::filesystem::remove_all(tempDir); + std::filesystem::create_directories(tempDir); + + const std::filesystem::path fakeInstall = tempDir / "fake_phaselimiter"; + const std::filesystem::path fakeBinDir = fakeInstall / "bin"; + const std::filesystem::path fakeResourceDir = fakeInstall / "resource"; + std::filesystem::create_directories(fakeBinDir); + std::filesystem::create_directories(fakeResourceDir); + +#if defined(_WIN32) + const std::filesystem::path fakeExe = fakeBinDir / "phase_limiter.exe"; +#else + const std::filesystem::path fakeExe = fakeBinDir / "phase_limiter"; +#endif + { + std::ofstream out(fakeExe); + out << "binary stub\n"; + } + { + std::ofstream out(fakeResourceDir / "mastering_reference.json"); + out << "{}\n"; + } +#if !defined(_WIN32) + std::filesystem::permissions(fakeExe, std::filesystem::perms::owner_exec | std::filesystem::perms::owner_read, std::filesystem::perm_options::add); +#endif + + setEnvValue("PHASELIMITER_BIN", fakeExe.string()); + automix::renderers::setFfmpegResolverForTesting([]() -> std::optional { + return std::nullopt; + }); + + struct CleanupGuard { + std::filesystem::path dir; + ~CleanupGuard() { + automix::renderers::resetFfmpegResolverForTesting(); + setEnvValue("PHASELIMITER_BIN", ""); + std::filesystem::remove_all(dir); + } + } cleanup{tempDir}; + + automix::util::WavWriter writer; + const auto stemA = makeTone(44100.0, 22050, 220.0, 0.40); + const auto stemPathA = tempDir / "tone.wav"; + writer.write(stemPathA, stemA, 24); + + automix::domain::Session session; + automix::domain::Stem s1; + s1.id = "s1"; + s1.name = "Tone"; + s1.filePath = stemPathA.string(); + session.stems.push_back(s1); + + automix::domain::RenderSettings settings; + settings.outputSampleRate = 44100; + settings.blockSize = 1024; + settings.outputBitDepth = 24; + settings.rendererName = "PhaseLimiter"; + settings.outputPath = (tempDir / "out.wav").string(); + + automix::renderers::PhaseLimiterRenderer renderer; + const auto result = renderer.render(session, settings, {}, nullptr); + + REQUIRE(result.success == false); + REQUIRE(result.rendererName == "PhaseLimiter"); + const std::string expectedMessage = "PhaseLimiter needs ffmpeg, which was not found. Install ffmpeg or set FFMPEG_BIN."; + REQUIRE_FALSE(result.logs.empty()); + REQUIRE(result.logs.back() == expectedMessage); +} + +TEST_CASE("ffmpegForPhaseLimiter honours FFMPEG_BIN first", "[phaselimiter][discovery]") { + const std::filesystem::path tempDir = std::filesystem::temp_directory_path() / "automix_test_ffmpeg_bin"; + std::filesystem::remove_all(tempDir); + std::filesystem::create_directories(tempDir); + +#if defined(_WIN32) + const auto fakeFfmpeg = tempDir / "fake_ffmpeg.exe"; +#else + const auto fakeFfmpeg = tempDir / "fake_ffmpeg"; +#endif + { + std::ofstream out(fakeFfmpeg); + out << "stub\n"; + } +#if !defined(_WIN32) + std::filesystem::permissions(fakeFfmpeg, std::filesystem::perms::owner_exec | std::filesystem::perms::owner_read, std::filesystem::perm_options::add); +#endif + + automix::renderers::resetFfmpegResolverForTesting(); + setEnvValue("FFMPEG_BIN", fakeFfmpeg.string()); + + struct CleanupGuard { + std::filesystem::path dir; + ~CleanupGuard() { + setEnvValue("FFMPEG_BIN", ""); + std::filesystem::remove_all(dir); + } + } cleanup{tempDir}; + + const auto found = automix::renderers::ffmpegForPhaseLimiter(); + REQUIRE(found.has_value()); + REQUIRE(std::filesystem::equivalent(*found, fakeFfmpeg)); } \ No newline at end of file From cb4664af9e53f31755ed7891752357f10cf9a449 Mon Sep 17 00:00:00 2001 From: Soficis Date: Sat, 3 Oct 2026 18:12:59 -0500 Subject: [PATCH 47/68] feat(renderers): pin PhaseLimiter v0.2.0-native3 (WAV-only I/O, bundled licenses, Windows ARM64 emulation) --- NOTICE | 32 +++++++++++++++++ README.md | 3 +- cmake/PhaseLimiterPins.cmake | 16 ++++----- src/renderers/PhaseLimiterDiscovery.cpp | 10 +++++- src/renderers/PhaseLimiterDiscovery.h | 4 +++ tests/unit/PhaseLimiterDiscoveryTests.cpp | 2 +- tests/unit/PhaseLimiterRendererTests.cpp | 42 +++++++++++++++++++++++ 7 files changed, 98 insertions(+), 11 deletions(-) diff --git a/NOTICE b/NOTICE index b954c98..328a536 100644 --- a/NOTICE +++ b/NOTICE @@ -141,3 +141,35 @@ dependency details are listed in docs/third_party_registry.json. Note that ONNX Runtime is an OPTIONAL dependency. Builds made without it fall back to a deterministic adapter and model-backed features degrade to heuristics rather than failing. + + +8. Optional external renderers: PhaseLimiter +-------------------------------------------- + +PhaseLimiter (https://github.com/ai-mastering/phaselimiter) is an optional +external mastering renderer executing as a standalone child process. On Windows +x64, it uses upstream release binaries. On Linux (x86_64, aarch64) and macOS +(Apple Silicon, x86_64), it uses modernized portable builds +(https://github.com/soficis/phaselimiter) release v0.2.0-native3. + +The PhaseLimiter portable distribution bundles third-party open-source components, +whose full license texts are preserved under the licenses/ subdirectory: + - PhaseLimiter: MIT (Copyright (c) 2018-2020 Bakuage Co., Ltd.) + - oneTBB: Apache License 2.0 (Copyright (c) 2005-2024 Intel Corporation) + - PocketFFT: BSD-3-Clause (Copyright (c) 2010-2019 Max-Planck-Society) + - Boost: Boost Software License 1.0 (BSL-1.0) + - Eigen: Mozilla Public License 2.0 (MPL-2.0) + - Armadillo: Apache License 2.0 (Copyright 2008-2024 Conrad Sanderson and Ryan Curtin) + - libpng: libpng license (Copyright (c) 1995-2024 The PNG Reference Library Authors) + - zlib: zlib license (Copyright (C) 1995-2024 Jean-loup Gailly and Mark Adler) + - gflags: BSD-3-Clause (Copyright (c) 2006 Google Inc.) + - picojson: BSD-2-Clause (Copyright 2009-2010 Kazuho Oku) + - hnswlib: Apache License 2.0 (Copyright (c) 2017 Yury Malkov) + - libsimdpp: Boost Software License 1.0 (Copyright (c) 2011-2015 Povilas Kanapickas) + - OptimLib: Apache License 2.0 (Copyright (c) 2017-present Keith O'Hara) + - CImg: CeCILL-C (Copyright (c) David Tschumperle) + +Neither upstream PhaseLimiter nor the portable v0.2.0-native3 distribution links +to or redistributes LGPL-licensed libsndfile: audio I/O uses an in-tree WAV +implementation, and encoding/decoding relies on the user's system FFmpeg binary. + diff --git a/README.md b/README.md index 0edc138..759d12e 100644 --- a/README.md +++ b/README.md @@ -105,11 +105,12 @@ AutoMixMaster is designed to benefit from **GPU acceleration** via ONNX Runtime ### Minimum OS requirements (release artifacts) -- **Windows:** **Windows 10 or Windows 11** (x64 or ARM64) +- **Windows:** **Windows 10 or Windows 11** (x64 or ARM64¹) - **macOS (Apple Silicon / ARM64):** **macOS 14+** - **macOS (Intel / x64):** **macOS 15+** - **Linux:** **Ubuntu 24.04 LTS+** for current prebuilt `.deb`/AppImage artifacts +> ¹ *Note: On Windows 11 ARM64, PhaseLimiter runs seamlessly via Windows on ARM built-in x64 emulation (WOW64/Prism); AI tensor inference runs natively on ARM64.* > Note: Ubuntu 22.04 may still work if you build from source on 22.04 with compatible dependencies, but official CI/release packaging currently targets Ubuntu 24.04. ### Minimum workable diff --git a/cmake/PhaseLimiterPins.cmake b/cmake/PhaseLimiterPins.cmake index ddff874..130827e 100644 --- a/cmake/PhaseLimiterPins.cmake +++ b/cmake/PhaseLimiterPins.cmake @@ -4,17 +4,17 @@ set(AUTOMIX_PL_PIN_WINDOWS_X64_URL "https://github.com/ai-mastering/phaselimiter/releases/download/v0.2.0/phaselimiter-win.zip") set(AUTOMIX_PL_PIN_WINDOWS_X64_SHA256 "cab2d30ad8d993a383749b30d9f6dc1911198d3aa309d5f631b70206e0162145") -set(AUTOMIX_PL_PIN_LINUX_X64_URL "https://github.com/soficis/phaselimiter/releases/download/v0.2.0-native2/phaselimiter-0.2.0-native2-linux-x64.tar.xz") -set(AUTOMIX_PL_PIN_LINUX_X64_SHA256 "994b587feee68b7ca3e95868ae8984df42806607a04acd6d4e854aaaa5389792") +set(AUTOMIX_PL_PIN_LINUX_X64_URL "https://github.com/soficis/phaselimiter/releases/download/v0.2.0-native3/phaselimiter-0.2.0-native3-linux-x64.tar.xz") +set(AUTOMIX_PL_PIN_LINUX_X64_SHA256 "8ba28b31f823555c3c368a6d3b4757c1980c981b6abe726e80d724f050aeefaa") -set(AUTOMIX_PL_PIN_LINUX_ARM64_URL "https://github.com/soficis/phaselimiter/releases/download/v0.2.0-native2/phaselimiter-0.2.0-native2-linux-arm64.tar.xz") -set(AUTOMIX_PL_PIN_LINUX_ARM64_SHA256 "87bb4deb2df3588a13822020440a61159f944367a1d011a3e8f3421fc55a9000") +set(AUTOMIX_PL_PIN_LINUX_ARM64_URL "https://github.com/soficis/phaselimiter/releases/download/v0.2.0-native3/phaselimiter-0.2.0-native3-linux-arm64.tar.xz") +set(AUTOMIX_PL_PIN_LINUX_ARM64_SHA256 "714867eaf3ca9c33f68648ae3d073323b0990d816cca7b767ded41456461441c") -set(AUTOMIX_PL_PIN_MACOS_ARM64_URL "https://github.com/soficis/phaselimiter/releases/download/v0.2.0-native2/phaselimiter-0.2.0-native2-macos-arm64.tar.xz") -set(AUTOMIX_PL_PIN_MACOS_ARM64_SHA256 "6d7343359f4b38183b00151c3f56de62b113f7d6b18617cda1cc966f14b5aa8b") +set(AUTOMIX_PL_PIN_MACOS_ARM64_URL "https://github.com/soficis/phaselimiter/releases/download/v0.2.0-native3/phaselimiter-0.2.0-native3-macos-arm64.tar.xz") +set(AUTOMIX_PL_PIN_MACOS_ARM64_SHA256 "42614e95f2d185bf40498a6e1da199f547a2c7bd427e74ed74ee35c9d1512a43") -set(AUTOMIX_PL_PIN_MACOS_X86_64_URL "https://github.com/soficis/phaselimiter/releases/download/v0.2.0-native2/phaselimiter-0.2.0-native2-macos-x86_64.tar.xz") -set(AUTOMIX_PL_PIN_MACOS_X86_64_SHA256 "3135a49e9bd711cab54c32e074c12fbbe62229479d868b2fe7bb8b3c58bbceb8") +set(AUTOMIX_PL_PIN_MACOS_X86_64_URL "https://github.com/soficis/phaselimiter/releases/download/v0.2.0-native3/phaselimiter-0.2.0-native3-macos-x86_64.tar.xz") +set(AUTOMIX_PL_PIN_MACOS_X86_64_SHA256 "e63e2b755fdaa80c104aa6ff5a0866e0f3754fbb79ed84efd696af583201afbe") function(automix_generate_phaselimiter_pins_header) set(_entries "") diff --git a/src/renderers/PhaseLimiterDiscovery.cpp b/src/renderers/PhaseLimiterDiscovery.cpp index 9ef391e..c9fa4b7 100644 --- a/src/renderers/PhaseLimiterDiscovery.cpp +++ b/src/renderers/PhaseLimiterDiscovery.cpp @@ -286,6 +286,13 @@ std::string currentPhaseLimiterPlatformKey() { #endif } +std::string phaseLimiterPlatformKeyForResolution(const std::string& platformKey) { + if (platformKey == "windows-arm64") { + return "windows-x64"; + } + return platformKey; +} + std::vector phaseLimiterDownloadPinTable() { std::vector table; table.reserve(std::size(kPhaseLimiterPins)); @@ -307,9 +314,10 @@ std::optional defaultPhaseLimiterDownloadPin() { } const auto currentKey = currentPhaseLimiterPlatformKey(); + const auto resolutionKey = phaseLimiterPlatformKeyForResolution(currentKey); const auto table = phaseLimiterDownloadPinTable(); for (const auto& pin : table) { - if (pin.platformKey == currentKey) { + if (pin.platformKey == resolutionKey) { if (pin.url.empty() || pin.sha256.empty()) { return std::nullopt; } diff --git a/src/renderers/PhaseLimiterDiscovery.h b/src/renderers/PhaseLimiterDiscovery.h index b49346f..77ddce0 100644 --- a/src/renderers/PhaseLimiterDiscovery.h +++ b/src/renderers/PhaseLimiterDiscovery.h @@ -24,6 +24,9 @@ inline std::filesystem::path masteringReferencePath(const PhaseLimiterBinaryInfo inline std::filesystem::path soundQualityCachePath(const PhaseLimiterBinaryInfo& info) { return info.installRoot / "resource" / "sound_quality2_cache"; } +inline std::filesystem::path licensesDirectoryPath(const PhaseLimiterBinaryInfo& info) { + return info.installRoot / "licenses"; +} // A binary without its mastering reference cannot master; treat it as absent. inline bool isCompleteInstall(const PhaseLimiterBinaryInfo& info) { @@ -40,6 +43,7 @@ struct PhaseLimiterDownloadPin { std::vector phaseLimiterDownloadPinTable(); std::optional defaultPhaseLimiterDownloadPin(); std::string currentPhaseLimiterPlatformKey(); +std::string phaseLimiterPlatformKeyForResolution(const std::string& platformKey); using FfmpegResolver = std::function()>; diff --git a/tests/unit/PhaseLimiterDiscoveryTests.cpp b/tests/unit/PhaseLimiterDiscoveryTests.cpp index bda200d..0a794b7 100644 --- a/tests/unit/PhaseLimiterDiscoveryTests.cpp +++ b/tests/unit/PhaseLimiterDiscoveryTests.cpp @@ -183,7 +183,7 @@ TEST_CASE("PhaseLimiter download pin table contains target platforms and valid s REQUIRE(((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))); } if (required == "linux-x64") { - REQUIRE(it->sha256 == "994b587feee68b7ca3e95868ae8984df42806607a04acd6d4e854aaaa5389792"); + REQUIRE(it->sha256 == "8ba28b31f823555c3c368a6d3b4757c1980c981b6abe726e80d724f050aeefaa"); } } diff --git a/tests/unit/PhaseLimiterRendererTests.cpp b/tests/unit/PhaseLimiterRendererTests.cpp index 65f6f1a..0835a63 100644 --- a/tests/unit/PhaseLimiterRendererTests.cpp +++ b/tests/unit/PhaseLimiterRendererTests.cpp @@ -247,4 +247,46 @@ TEST_CASE("ffmpegForPhaseLimiter honours FFMPEG_BIN first", "[phaselimiter][disc const auto found = automix::renderers::ffmpegForPhaseLimiter(); REQUIRE(found.has_value()); REQUIRE(std::filesystem::equivalent(*found, fakeFfmpeg)); +} + +TEST_CASE("PhaseLimiter platform resolution maps windows-arm64 to windows-x64 emulation", "[phaselimiter][discovery]") { + REQUIRE(automix::renderers::phaseLimiterPlatformKeyForResolution("windows-arm64") == "windows-x64"); + REQUIRE(automix::renderers::phaseLimiterPlatformKeyForResolution("windows-x64") == "windows-x64"); + REQUIRE(automix::renderers::phaseLimiterPlatformKeyForResolution("linux-arm64") == "linux-arm64"); + REQUIRE(automix::renderers::phaseLimiterPlatformKeyForResolution("macos-arm64") == "macos-arm64"); +} + +TEST_CASE("PhaseLimiter installation contains third-party licenses when installed", "[phaselimiter][licenses]") { + automix::renderers::PhaseLimiterDiscovery discovery; + const auto info = discovery.find(); + if (!info.has_value()) { + SUCCEED("PhaseLimiter binary is not installed on this machine; skipping license check."); + return; + } + + const auto licensesDir = automix::renderers::licensesDirectoryPath(*info); + if (!std::filesystem::is_directory(licensesDir)) { + SUCCEED("Installed PhaseLimiter distribution does not bundle licenses/."); + return; + } + + // The portable v0.2.0-native3 distribution bundles 14 licenses including onetbb.txt & hnswlib.txt & pocketfft.txt. + // The legacy upstream Windows distribution bundles tbb.txt & hnsw.txt. + const bool isNativePortable = std::filesystem::is_regular_file(licensesDir / "onetbb.txt"); + const std::vector requiredLicenses = isNativePortable ? std::vector{ + "armadillo.txt", "boost.txt", "cimg.txt", "eigen.txt", "gflags.txt", + "hnswlib.txt", "libpng.txt", "libsimdpp.txt", "onetbb.txt", "optim.txt", + "phaselimiter.txt", "picojson.txt", "pocketfft.txt", "zlib.txt" + } : std::vector{ + "armadillo.txt", "boost.txt", "cimg.txt", "eigen.txt", "gflags.txt", + "hnsw.txt", "libpng.txt", "libsimdpp.txt", "tbb.txt", "optim.txt", + "phaselimiter.txt", "picojson.txt", "zlib.txt" + }; + + for (const auto& licFile : requiredLicenses) { + const auto filePath = licensesDir / licFile; + INFO("Checking license file: " << filePath.string()); + REQUIRE(std::filesystem::is_regular_file(filePath)); + REQUIRE(std::filesystem::file_size(filePath) > 0); + } } \ No newline at end of file From 900740a440bf6cee6db995b3d0d3a7de6e243fd9 Mon Sep 17 00:00:00 2001 From: Soficis Date: Sat, 3 Oct 2026 18:13:10 -0500 Subject: [PATCH 48/68] test(hygiene): docs/architecture must stay forbidden --- tests/tools/test_check_repo_hygiene.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/tools/test_check_repo_hygiene.py b/tests/tools/test_check_repo_hygiene.py index dec6987..23728c4 100644 --- a/tests/tools/test_check_repo_hygiene.py +++ b/tests/tools/test_check_repo_hygiene.py @@ -58,6 +58,8 @@ def test_forbidden_paths(self): "x/handoff-2.md", "job-logs1.txt", "a/b.jsonl", + "docs/architecture/x.md", + "docs/architecture/github_support_pr_purge_request.md", ] allowed_samples = [ "docs/model-licensing-audit.json", From 3d22cefd6c291856a9d1512389f62649c0215b67 Mon Sep 17 00:00:00 2001 From: Soficis Date: Sat, 3 Oct 2026 18:28:03 -0500 Subject: [PATCH 49/68] fix(renderers): PhaseLimiter availability no longer depends on ffmpeg; missing ffmpeg is a render error --- src/renderers/PhaseLimiterRenderer.cpp | 2 +- tests/unit/PhaseLimiterRendererTests.cpp | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/renderers/PhaseLimiterRenderer.cpp b/src/renderers/PhaseLimiterRenderer.cpp index 27fb79f..3519eff 100644 --- a/src/renderers/PhaseLimiterRenderer.cpp +++ b/src/renderers/PhaseLimiterRenderer.cpp @@ -150,7 +150,7 @@ void drainProcessOutput(juce::ChildProcess& process, std::string& outputCapture) bool PhaseLimiterRenderer::isAvailable() const { const auto found = PhaseLimiterDiscovery{}.find(); - return found.has_value() && isCompleteInstall(*found) && ffmpegForPhaseLimiter().has_value(); + return found.has_value() && isCompleteInstall(*found); } RenderResult PhaseLimiterRenderer::render(const domain::Session& session, diff --git a/tests/unit/PhaseLimiterRendererTests.cpp b/tests/unit/PhaseLimiterRendererTests.cpp index 0835a63..e4ed92d 100644 --- a/tests/unit/PhaseLimiterRendererTests.cpp +++ b/tests/unit/PhaseLimiterRendererTests.cpp @@ -144,7 +144,7 @@ TEST_CASE("A selected PhaseLimiter really renders and leaves no scratch behind", std::filesystem::remove_all(tempDir); } -TEST_CASE("PhaseLimiter render reports missing ffmpeg instead of falling back", "[phaselimiter][renderer]") { +TEST_CASE("PhaseLimiter stays available without ffmpeg and fails the render with a clear message", "[phaselimiter][renderer]") { const std::filesystem::path tempDir = std::filesystem::temp_directory_path() / "automix_pl_test_missing_ffmpeg"; std::filesystem::remove_all(tempDir); std::filesystem::create_directories(tempDir); @@ -206,6 +206,7 @@ TEST_CASE("PhaseLimiter render reports missing ffmpeg instead of falling back", settings.outputPath = (tempDir / "out.wav").string(); automix::renderers::PhaseLimiterRenderer renderer; + REQUIRE(renderer.isAvailable()); const auto result = renderer.render(session, settings, {}, nullptr); REQUIRE(result.success == false); From bee6757c8d2e7e3549505efbfcb21a21965c42ae Mon Sep 17 00:00:00 2001 From: Soficis Date: Sat, 3 Oct 2026 18:28:15 -0500 Subject: [PATCH 50/68] ci: run TSan and golden eval on master and feature branches --- .github/workflows/nightly_golden_eval.yml | 2 ++ .github/workflows/tsan_build.yml | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/nightly_golden_eval.yml b/.github/workflows/nightly_golden_eval.yml index 8add4a3..5d2c543 100644 --- a/.github/workflows/nightly_golden_eval.yml +++ b/.github/workflows/nightly_golden_eval.yml @@ -1,6 +1,8 @@ name: Nightly Golden Eval on: + push: + branches: [master, "feat/**"] schedule: - cron: "0 2 * * *" workflow_dispatch: diff --git a/.github/workflows/tsan_build.yml b/.github/workflows/tsan_build.yml index d2e706d..586a6d8 100644 --- a/.github/workflows/tsan_build.yml +++ b/.github/workflows/tsan_build.yml @@ -2,9 +2,9 @@ name: ThreadSanitizer Build on: push: - branches: [main, develop] + branches: [master, "feat/**"] pull_request: - branches: [main, develop] + branches: [master] workflow_dispatch: permissions: From debebcbeb8ac4404ab6d5f9e3b4db192259ee4b9 Mon Sep 17 00:00:00 2001 From: Soficis Date: Sun, 4 Oct 2026 14:09:55 -0500 Subject: [PATCH 51/68] fix(tools): model bench measures the requested provider and fails on a provider mismatch --- src/ai/GpuProvider.h | 21 ++++++++++++++++++ tests/unit/GpuBenchmarkTests.cpp | 21 ++++++++++++++++++ tools/commands/ModelCommands.cpp | 37 +++++++++++++++++++++++++++++--- 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/ai/GpuProvider.h b/src/ai/GpuProvider.h index 9a07edc..fda1a87 100644 --- a/src/ai/GpuProvider.h +++ b/src/ai/GpuProvider.h @@ -1,6 +1,8 @@ #pragma once #include +#include +#include #include #include #include @@ -52,6 +54,25 @@ inline std::string canonicalProviderName(const std::string& raw) { return lower; } +/// True when a bench run that asked for `requested` actually ran on `actual`. +/// "auto" matches anything; an empty or "unknown" actual provider never matches a concrete request. +inline bool benchProviderMatches(const std::string& requested, const std::string& actual) { + const auto wanted = canonicalProviderName(requested); + if (wanted == "auto") return true; + if (actual.empty() || actual == "unknown") return false; + return wanted == canonicalProviderName(actual); +} + +/// Nearest-rank percentile (p in (0, 1]); returns 0.0 for an empty input. +inline double nearestRankPercentile(std::vector values, double p) { + if (values.empty()) return 0.0; + std::sort(values.begin(), values.end()); + const auto n = static_cast(values.size()); + auto idx = static_cast(std::ceil(p * static_cast(n))) - 1; + idx = std::clamp(idx, 0, n - 1); + return values[static_cast(idx)]; +} + inline std::string platformPreferredProvider() { #if defined(__APPLE__) && defined(__arm64__) return kProviderAne; diff --git a/tests/unit/GpuBenchmarkTests.cpp b/tests/unit/GpuBenchmarkTests.cpp index d4fb3f6..6f2e87a 100644 --- a/tests/unit/GpuBenchmarkTests.cpp +++ b/tests/unit/GpuBenchmarkTests.cpp @@ -697,3 +697,24 @@ TEST_CASE("tensorProviderUsable per-provider probe cache semantics", "[gpu][tens CHECK(winner == "cuda"); } + +TEST_CASE("benchProviderMatches compares canonical provider names", "[gpu][bench]") { + using automix::ai::gpu::benchProviderMatches; + CHECK(benchProviderMatches("auto", "cpu") == true); + CHECK(benchProviderMatches("cuda", "cuda") == true); + CHECK(benchProviderMatches("webgpu", "cuda") == false); + CHECK(benchProviderMatches("webgpu", "cpu") == false); + CHECK(benchProviderMatches("cuda", "") == false); + CHECK(benchProviderMatches("cuda", "unknown") == false); + // canonicalProviderName maps "wgpu" to "webgpu" and ignores case. + CHECK(benchProviderMatches("WGPU", "webgpu") == true); +} + +TEST_CASE("nearestRankPercentile uses the nearest-rank method", "[gpu][bench]") { + using automix::ai::gpu::nearestRankPercentile; + CHECK(nearestRankPercentile({}, 0.9) == 0.0); + CHECK(nearestRankPercentile({5.0}, 0.9) == 5.0); + CHECK(nearestRankPercentile({1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, 0.9) == 9.0); + CHECK(nearestRankPercentile({3.0, 1.0, 2.0}, 0.5) == 2.0); + CHECK(nearestRankPercentile({1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, 1.0) == 10.0); +} diff --git a/tools/commands/ModelCommands.cpp b/tools/commands/ModelCommands.cpp index 06fba47..6abe52d 100644 --- a/tools/commands/ModelCommands.cpp +++ b/tools/commands/ModelCommands.cpp @@ -8,6 +8,7 @@ #include #include "ai/FeatureSchema.h" +#include "ai/GpuProvider.h" #include "ai/GpuRuntimePack.h" #include "ai/HuggingFaceModelHub.h" #include "ai/ModelPackLoader.h" @@ -640,6 +641,8 @@ int commandGpuRuntime(const std::vector& args) { } // model bench --provider [--pack ] [--runs N] [--warmup N] [--json] [--out ] +// Exit codes: 0 ok, 1 pack load failure, 3 model load/execution failure, +// 4 the model ran on a different provider than the one requested. int commandModelBench(const CommandArgs& args) { const auto packArg = argValue(args, "--pack").value_or("assets/models/demo-mix-v1"); const auto providerArg = argValue(args, "--provider").value_or("auto"); @@ -659,6 +662,7 @@ int commandModelBench(const CommandArgs& args) { std::vector provLatencies; std::vector cpuLatencies; std::string actualProvider = "unknown"; + std::string providerBackendDiagnostics; double maxDelta = 0.0; const bool isTensor = pack.tensorContract.has_value(); @@ -695,13 +699,15 @@ int commandModelBench(const CommandArgs& args) { automix::ai::OnnxTensorInference provInference; provInference.setTensorContract(pack.tensorContract); provInference.setExecutionProvider(providerArg); - provInference.setGpuProviderAllowList(pack.gpuProviders); + // The bench deliberately ignores the pack's allow-list so it measures the requested provider. + provInference.setGpuProviderAllowList({}); if (!provInference.loadModel(pack.rootPath / pack.modelFile)) { std::cerr << "ERROR: could not load tensor model with provider '" << providerArg << "': " << provInference.backendDiagnostics() << "\n"; return 3; } actualProvider = provInference.activeExecutionProvider(); + providerBackendDiagnostics = provInference.backendDiagnostics(); std::vector provBindings = cpuBindings; @@ -763,6 +769,7 @@ int commandModelBench(const CommandArgs& args) { return 3; } actualProvider = provInference.activeExecutionProvider(); + providerBackendDiagnostics = provInference.backendDiagnostics(); const auto provProbe = provInference.run(request); if (!provProbe.usedModel || !provInference.usingNativeSession()) { @@ -801,8 +808,17 @@ int commandModelBench(const CommandArgs& args) { const double minMs = provLatencies.empty() ? 0.0 : *std::min_element(provLatencies.begin(), provLatencies.end()); const double maxMs = provLatencies.empty() ? 0.0 : *std::max_element(provLatencies.begin(), provLatencies.end()); - const bool providerMatches = (providerArg == "auto") || (actualProvider == providerArg); - const bool shouldReportSpeedup = (actualProvider != "cpu") && providerMatches; + const bool providerMatches = automix::ai::gpu::benchProviderMatches(providerArg, actualProvider); + const bool shouldReportSpeedup = + (automix::ai::gpu::canonicalProviderName(actualProvider) != automix::ai::gpu::kProviderCpu) && providerMatches; + const double p90Ms = automix::ai::gpu::nearestRankPercentile(provLatencies, 0.9); + const double cpuP90Ms = automix::ai::gpu::nearestRankPercentile(cpuLatencies, 0.9); + const auto canonicalRequested = automix::ai::gpu::canonicalProviderName(providerArg); + const bool outsideAllowList = + canonicalRequested != "auto" && !pack.gpuProviders.empty() && + std::none_of(pack.gpuProviders.begin(), pack.gpuProviders.end(), [&](const std::string& allowed) { + return automix::ai::gpu::canonicalProviderName(allowed) == canonicalRequested; + }); const std::optional speedup = shouldReportSpeedup && (medianMs > 1e-6) ? std::optional(cpuMedianMs / medianMs) : std::nullopt; @@ -824,6 +840,10 @@ int commandModelBench(const CommandArgs& args) { {"maxDeltaVsCpu", maxDelta}, {"latenciesMs", provLatencies}, {"passedParity", maxDelta < 1e-3}, + {"providerMatches", providerMatches}, + {"packAllowList", pack.gpuProviders}, + {"p90LatencyMs", p90Ms}, + {"cpuP90LatencyMs", cpuP90Ms}, }; if (speedup.has_value()) { @@ -846,13 +866,24 @@ int commandModelBench(const CommandArgs& args) { std::cout << " Warm-up runs: " << warmupRuns << "\n"; std::cout << " Timed runs: " << timedRuns << "\n"; std::cout << " Median latency: " << medianMs << " ms (min: " << minMs << " ms, max: " << maxMs << " ms)\n"; + std::cout << " p90: " << p90Ms << " ms\n"; std::cout << " CPU median latency: " << cpuMedianMs << " ms\n"; + std::cout << " Provider match: " << (providerMatches ? "yes" : "no") << "\n"; + if (outsideAllowList) { + std::cout << " Note: '" << providerArg << "' is outside this pack's allow-list; measured anyway\n"; + } if (speedup.has_value()) { std::cout << " Speedup vs CPU: " << *speedup << "x\n"; } std::cout << " Max |Δ| vs CPU: " << maxDelta << "\n"; } + if (!providerMatches) { + std::cerr << "ERROR: requested provider '" << providerArg << "' but the model ran on '" << actualProvider << "'. " + << providerBackendDiagnostics << "\n"; + return 4; + } + return 0; } From 2ddc67d66f80646c03bfefc9a8e3cb3a4968f55d Mon Sep 17 00:00:00 2001 From: Soficis Date: Sun, 4 Oct 2026 14:10:57 -0500 Subject: [PATCH 52/68] fix(build): enable curl on Linux so HTTPS downloads work; CI smoke-tests a download --- .github/workflows/onnx_native.yml | 15 +++++++++++++++ CMakeLists.txt | 23 +++++++++++++++++++++-- tests/unit/AiExtensionTests.cpp | 8 ++++++++ tools/package_linux.sh | 2 +- 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/.github/workflows/onnx_native.yml b/.github/workflows/onnx_native.yml index 7576da5..b3001c6 100644 --- a/.github/workflows/onnx_native.yml +++ b/.github/workflows/onnx_native.yml @@ -78,3 +78,18 @@ jobs: - name: Run Tests (Full Suite) run: ctest --test-dir build -C Release --output-on-failure + + - name: HTTPS download smoke (Linux, curl) + if: runner.os == 'Linux' + run: | + set -euo pipefail + tools_bin="$(find build -type f -name automix_dev_tools -perm -u+x | head -n1)" + test -n "$tools_bin" + ok=0 + for attempt in 1 2 3; do + if "$tools_bin" model browse --limit 1 --out browse.json; then + if python3 -c "import json,sys; d=json.load(open('browse.json')); sys.exit(0 if len(d)>=1 else 1)"; then ok=1; break; fi + fi + sleep 10 + done + test "$ok" = 1 diff --git a/CMakeLists.txt b/CMakeLists.txt index 583962e..cdb1919 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -38,6 +38,14 @@ option(ENABLE_CLANG_TIDY "Enable clang-tidy analysis while compiling" OFF) option(BUILD_TESTING "Build tests" ON) option(BUILD_TOOLS "Build developer tools" ON) +# Linux has no native HTTPS client in JUCE: without libcurl, JUCE falls back to a raw-socket client that cannot +# speak TLS, so model/tool downloads fail. Windows (WinINet) and macOS (NSURLSession) must keep JUCE_USE_CURL=0. +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + set(AUTOMIX_JUCE_USE_CURL 1) +else() + set(AUTOMIX_JUCE_USE_CURL 0) +endif() + if(DISTRIBUTION_MODE STREQUAL "PROPRIETARY" AND ENABLE_GPL_BUNDLED_LIMITERS) message(WARNING "ENABLE_GPL_BUNDLED_LIMITERS is disabled in PROPRIETARY mode.") set(ENABLE_GPL_BUNDLED_LIMITERS OFF CACHE BOOL "Enable bundled GPL limiters (OSS mode only)" FORCE) @@ -354,6 +362,11 @@ target_link_libraries(automix_core juce::juce_dsp ) +if(AUTOMIX_JUCE_USE_CURL) + find_package(CURL REQUIRED) + target_link_libraries(automix_core PUBLIC CURL::libcurl) +endif() + set(LIBEBUR128_BACKEND_AVAILABLE OFF) if(ENABLE_LIBEBUR128) if(DEFINED libebur128_SOURCE_DIR) @@ -379,7 +392,7 @@ target_compile_definitions(automix_core SESSION_SCHEMA_VERSION=2 DISTRIBUTION_MODE_${DISTRIBUTION_MODE}=1 JUCE_WEB_BROWSER=0 - JUCE_USE_CURL=0 + JUCE_USE_CURL=${AUTOMIX_JUCE_USE_CURL} $<$:ENABLE_PHASELIMITER=1> $<$:ENABLE_ONNX=1> $<$:ENABLE_RTNEURAL=1> @@ -417,7 +430,13 @@ if(ENABLE_SANITIZERS) endif() endif() +set(_automix_needs_curl FALSE) +if(AUTOMIX_JUCE_USE_CURL) + set(_automix_needs_curl TRUE) +endif() + juce_add_gui_app(AutoMixMasterApp + NEEDS_CURL ${_automix_needs_curl} PRODUCT_NAME "AutoMixMaster" COMPANY_NAME "AutoMixMaster" ICON_BIG "assets/icon.png" @@ -465,7 +484,7 @@ target_link_libraries(AutoMixMasterApp target_compile_definitions(AutoMixMasterApp PRIVATE JUCE_WEB_BROWSER=0 - JUCE_USE_CURL=0 + JUCE_USE_CURL=${AUTOMIX_JUCE_USE_CURL} ) # Pinned PhaseLimiter fetching and staging diff --git a/tests/unit/AiExtensionTests.cpp b/tests/unit/AiExtensionTests.cpp index 9334cd2..e414a52 100644 --- a/tests/unit/AiExtensionTests.cpp +++ b/tests/unit/AiExtensionTests.cpp @@ -1314,3 +1314,11 @@ TEST_CASE("Model strategy returns base plans unchanged when no model inference i REQUIRE(masterPass.targetLufs == Catch::Approx(-16.0)); REQUIRE(masterPass.decisionLog.size() == 1); } + +TEST_CASE("Linux builds enable libcurl so HTTPS downloads work", "[build-config]") { +#if defined(__linux__) + REQUIRE(JUCE_USE_CURL == 1); +#else + SUCCEED("JUCE_USE_CURL is only required on Linux; Windows and macOS use native HTTP stacks."); +#endif +} diff --git a/tools/package_linux.sh b/tools/package_linux.sh index 1ca0475..9524423 100644 --- a/tools/package_linux.sh +++ b/tools/package_linux.sh @@ -208,7 +208,7 @@ Section: sound Priority: optional Architecture: $deb_arch Maintainer: AutoMixMaster -Depends: libc6 (>= 2.31), libstdc++6 (>= 11), libgcc-s1, libasound2, libfontconfig1, libfreetype6, libexpat1, zlib1g, libbz2-1.0, libpng16-16, libbrotli1 +Depends: libc6 (>= 2.31), libstdc++6 (>= 11), libgcc-s1, libasound2, libfontconfig1, libfreetype6, libexpat1, zlib1g, libbz2-1.0, libpng16-16, libbrotli1, libcurl4t64 | libcurl4 Recommends: libvulkan1 Installed-Size: $installed_size Description: Deterministic auto mix and mastering desktop app From afc5c9c573782c76c485be4d10191e66af83b037 Mon Sep 17 00:00:00 2001 From: Soficis Date: Sun, 4 Oct 2026 14:36:58 -0500 Subject: [PATCH 53/68] ci(onnx): key the deps cache by runner arch so x64 never restores the arm64 cache --- .github/workflows/onnx_native.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/onnx_native.yml b/.github/workflows/onnx_native.yml index b3001c6..c94b01b 100644 --- a/.github/workflows/onnx_native.yml +++ b/.github/workflows/onnx_native.yml @@ -58,9 +58,9 @@ jobs: uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: build/_deps - key: deps-ort-1.30.0-${{ matrix.os }}-${{ hashFiles('cmake/FetchOnnxRuntime.cmake', 'CMakeLists.txt') }} + key: deps-ort-1.30.0-${{ matrix.os }}-${{ runner.arch }}-${{ hashFiles('cmake/FetchOnnxRuntime.cmake', 'CMakeLists.txt') }} restore-keys: | - deps-ort-1.30.0-${{ matrix.os }}- + deps-ort-1.30.0-${{ matrix.os }}-${{ runner.arch }}- - name: Configure CMake run: > From ec4cd32a508b2ab11fc441fd1df9efb47d3dc59d Mon Sep 17 00:00:00 2001 From: Soficis Date: Sun, 4 Oct 2026 14:36:59 -0500 Subject: [PATCH 54/68] ci(release): add dry_run input that builds and verifies packages without publishing --- .github/workflows/release_packages.yml | 41 +++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release_packages.yml b/.github/workflows/release_packages.yml index 4bd3383..6b57a8e 100644 --- a/.github/workflows/release_packages.yml +++ b/.github/workflows/release_packages.yml @@ -19,6 +19,11 @@ on: required: false default: false type: boolean + dry_run: + description: Build and verify all packages but do not publish a release + required: false + default: false + type: boolean permissions: contents: write @@ -236,7 +241,7 @@ jobs: publish-release-assets: name: Publish assets to GitHub Release - if: ${{ (github.event_name == 'release' || github.event_name == 'workflow_dispatch') && always() }} + if: ${{ (github.event_name == 'release' || github.event_name == 'workflow_dispatch') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) && always() }} runs-on: ubuntu-24.04 needs: - linux-packages @@ -316,3 +321,37 @@ jobs: files: release-assets/* fail_on_unmatched_files: true overwrite_files: true + + dry-run-summary: + name: Dry run summary (nothing published) + if: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }} + runs-on: ubuntu-24.04 + permissions: + contents: read + needs: + - linux-packages + - windows-package + - macos-package + steps: + - name: Download all packaged artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: release-* + merge-multiple: true + path: release-assets + + - name: Verify the assets the publish job would upload + shell: bash + run: | + set -euo pipefail + shopt -s nullglob + assets=(release-assets/*) + if (( ${#assets[@]} == 0 )); then + echo "No release artifacts were found in release-assets/." >&2 + exit 1 + fi + ls -la release-assets + for f in "${assets[@]}"; do + test -s "$f" + done + echo "Dry run: ${#assets[@]} assets verified; no release or tag was created." From 7068c54c54c9d2aedd0c6ff5494a748f6a587a09 Mon Sep 17 00:00:00 2001 From: Soficis Date: Sun, 4 Oct 2026 17:07:52 -0500 Subject: [PATCH 55/68] fix(tools): model bench fails when any inference call fails instead of timing the failures --- tools/commands/ModelCommands.cpp | 40 +++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/tools/commands/ModelCommands.cpp b/tools/commands/ModelCommands.cpp index 6abe52d..2ed0600 100644 --- a/tools/commands/ModelCommands.cpp +++ b/tools/commands/ModelCommands.cpp @@ -642,7 +642,8 @@ int commandGpuRuntime(const std::vector& args) { // model bench --provider [--pack ] [--runs N] [--warmup N] [--json] [--out ] // Exit codes: 0 ok, 1 pack load failure, 3 model load/execution failure, -// 4 the model ran on a different provider than the one requested. +// 4 the model ran on a different provider than the one requested, +// 5 one or more inference calls failed (latencies and parity are invalid). int commandModelBench(const CommandArgs& args) { const auto packArg = argValue(args, "--pack").value_or("assets/models/demo-mix-v1"); const auto providerArg = argValue(args, "--provider").value_or("auto"); @@ -665,6 +666,16 @@ int commandModelBench(const CommandArgs& args) { std::string providerBackendDiagnostics; double maxDelta = 0.0; const bool isTensor = pack.tensorContract.has_value(); + // A run that throws inside ORT still returns quickly with usedModel=false; its + // latency is meaningless, so every call is checked and any failure voids the bench. + int failedRuns = 0; + std::string firstFailure; + const auto checkRun = [&](const auto& result) { + if (!result.usedModel) { + if (failedRuns == 0) firstFailure = result.logMessage; + ++failedRuns; + } + }; if (isTensor) { automix::ai::OnnxTensorInference cpuInference; @@ -686,13 +697,14 @@ int commandModelBench(const CommandArgs& args) { } for (int i = 0; i < warmupRuns; ++i) { - cpuInference.run(cpuBindings); + checkRun(cpuInference.run(cpuBindings)); } automix::ai::TensorInferenceResult cpuResult; for (int i = 0; i < timedRuns; ++i) { const auto start = std::chrono::steady_clock::now(); cpuResult = cpuInference.run(cpuBindings); const auto end = std::chrono::steady_clock::now(); + checkRun(cpuResult); cpuLatencies.push_back(std::chrono::duration(end - start).count()); } @@ -712,13 +724,14 @@ int commandModelBench(const CommandArgs& args) { std::vector provBindings = cpuBindings; for (int i = 0; i < warmupRuns; ++i) { - provInference.run(provBindings); + checkRun(provInference.run(provBindings)); } automix::ai::TensorInferenceResult provResult; for (int i = 0; i < timedRuns; ++i) { const auto start = std::chrono::steady_clock::now(); provResult = provInference.run(provBindings); const auto end = std::chrono::steady_clock::now(); + checkRun(provResult); provLatencies.push_back(std::chrono::duration(end - start).count()); } @@ -751,13 +764,14 @@ int commandModelBench(const CommandArgs& args) { } for (int i = 0; i < warmupRuns; ++i) { - cpuInference.run(request); + checkRun(cpuInference.run(request)); } automix::ai::InferenceResult cpuResult; for (int i = 0; i < timedRuns; ++i) { const auto start = std::chrono::steady_clock::now(); cpuResult = cpuInference.run(request); const auto end = std::chrono::steady_clock::now(); + checkRun(cpuResult); cpuLatencies.push_back(std::chrono::duration(end - start).count()); } @@ -778,13 +792,14 @@ int commandModelBench(const CommandArgs& args) { } for (int i = 0; i < warmupRuns; ++i) { - provInference.run(request); + checkRun(provInference.run(request)); } automix::ai::InferenceResult provResult; for (int i = 0; i < timedRuns; ++i) { const auto start = std::chrono::steady_clock::now(); provResult = provInference.run(request); const auto end = std::chrono::steady_clock::now(); + checkRun(provResult); provLatencies.push_back(std::chrono::duration(end - start).count()); } @@ -809,13 +824,14 @@ int commandModelBench(const CommandArgs& args) { const double maxMs = provLatencies.empty() ? 0.0 : *std::max_element(provLatencies.begin(), provLatencies.end()); const bool providerMatches = automix::ai::gpu::benchProviderMatches(providerArg, actualProvider); - const bool shouldReportSpeedup = + const bool allRunsSucceeded = failedRuns == 0; + const bool shouldReportSpeedup = allRunsSucceeded && (automix::ai::gpu::canonicalProviderName(actualProvider) != automix::ai::gpu::kProviderCpu) && providerMatches; const double p90Ms = automix::ai::gpu::nearestRankPercentile(provLatencies, 0.9); const double cpuP90Ms = automix::ai::gpu::nearestRankPercentile(cpuLatencies, 0.9); const auto canonicalRequested = automix::ai::gpu::canonicalProviderName(providerArg); const bool outsideAllowList = - canonicalRequested != "auto" && !pack.gpuProviders.empty() && + canonicalRequested != "auto" && canonicalRequested != automix::ai::gpu::kProviderCpu && !pack.gpuProviders.empty() && std::none_of(pack.gpuProviders.begin(), pack.gpuProviders.end(), [&](const std::string& allowed) { return automix::ai::gpu::canonicalProviderName(allowed) == canonicalRequested; }); @@ -839,7 +855,8 @@ int commandModelBench(const CommandArgs& args) { {"cpuMedianLatencyMs", cpuMedianMs}, {"maxDeltaVsCpu", maxDelta}, {"latenciesMs", provLatencies}, - {"passedParity", maxDelta < 1e-3}, + {"passedParity", allRunsSucceeded && maxDelta < 1e-3}, + {"failedRuns", failedRuns}, {"providerMatches", providerMatches}, {"packAllowList", pack.gpuProviders}, {"p90LatencyMs", p90Ms}, @@ -876,6 +893,13 @@ int commandModelBench(const CommandArgs& args) { std::cout << " Speedup vs CPU: " << *speedup << "x\n"; } std::cout << " Max |Δ| vs CPU: " << maxDelta << "\n"; + std::cout << " Failed runs: " << failedRuns << "\n"; + } + + if (!allRunsSucceeded) { + std::cerr << "ERROR: " << failedRuns << " inference call(s) failed on '" << actualProvider + << "'; latencies and parity are invalid. First failure: " << firstFailure << "\n"; + return 5; } if (!providerMatches) { From fdd265dd5434b5b7a1e0cad5f6b2d96ea32f2d84 Mon Sep 17 00:00:00 2001 From: Soficis Date: Sun, 4 Oct 2026 19:42:30 -0500 Subject: [PATCH 56/68] feat(tools): model bench gains --skip-cpu-baseline, per-run progress, and a single CPU run for --provider cpu --- tools/commands/ModelCommands.cpp | 168 +++++++++++++++++++------------ 1 file changed, 104 insertions(+), 64 deletions(-) diff --git a/tools/commands/ModelCommands.cpp b/tools/commands/ModelCommands.cpp index 2ed0600..dcae4d5 100644 --- a/tools/commands/ModelCommands.cpp +++ b/tools/commands/ModelCommands.cpp @@ -641,6 +641,9 @@ int commandGpuRuntime(const std::vector& args) { } // model bench --provider [--pack ] [--runs N] [--warmup N] [--json] [--out ] +// [--skip-cpu-baseline] +// Tensor packs print one progress line per run to stderr. --skip-cpu-baseline skips the CPU +// reference (no parity check, no speedup); `--provider cpu` runs the CPU model once, not twice. // Exit codes: 0 ok, 1 pack load failure, 3 model load/execution failure, // 4 the model ran on a different provider than the one requested, // 5 one or more inference calls failed (latencies and parity are invalid). @@ -650,6 +653,7 @@ int commandModelBench(const CommandArgs& args) { const int warmupRuns = std::clamp(parseIntArg(args, "--warmup").value_or(2), 0, 50); const int timedRuns = std::clamp(parseIntArg(args, "--runs").value_or(10), 1, 1000); const bool jsonOutput = hasFlag(args, "--json"); + const bool skipCpuBaseline = hasFlag(args, "--skip-cpu-baseline"); const std::filesystem::path packDir(packArg); automix::ai::ModelPackLoader loader; @@ -669,6 +673,7 @@ int commandModelBench(const CommandArgs& args) { // A run that throws inside ORT still returns quickly with usedModel=false; its // latency is meaningless, so every call is checked and any failure voids the bench. int failedRuns = 0; + bool parityChecked = false; std::string firstFailure; const auto checkRun = [&](const auto& result) { if (!result.usedModel) { @@ -677,73 +682,98 @@ int commandModelBench(const CommandArgs& args) { } }; - if (isTensor) { - automix::ai::OnnxTensorInference cpuInference; - cpuInference.setTensorContract(pack.tensorContract); - cpuInference.setExecutionProvider("cpu"); - if (!cpuInference.loadModel(pack.rootPath / pack.modelFile)) { - std::cerr << "ERROR: could not load tensor model on CPU: " << cpuInference.backendDiagnostics() << "\n"; - return 3; - } + const bool requestedIsCpu = + automix::ai::gpu::canonicalProviderName(providerArg) == automix::ai::gpu::kProviderCpu; - const auto inputSpecs = cpuInference.inputSpecs(); - std::vector cpuBindings; - for (const auto& spec : inputSpecs) { - size_t count = automix::ai::elementCount(spec).value_or(1024); - cpuBindings.push_back(automix::ai::TensorBinding{ - .expected = spec, - .data = std::vector(count, 0.1f), - }); - } + // The CPU reference feeds parity and speedup. Requesting cpu makes the reference the measurement. + const bool runCpuBaseline = !skipCpuBaseline || requestedIsCpu; - for (int i = 0; i < warmupRuns; ++i) { - checkRun(cpuInference.run(cpuBindings)); - } - automix::ai::TensorInferenceResult cpuResult; - for (int i = 0; i < timedRuns; ++i) { + if (isTensor) { + const auto timedRun = [&](auto& inference, const auto& bindings, const char* label, int index, int total, + std::vector& latencies) { const auto start = std::chrono::steady_clock::now(); - cpuResult = cpuInference.run(cpuBindings); - const auto end = std::chrono::steady_clock::now(); - checkRun(cpuResult); - cpuLatencies.push_back(std::chrono::duration(end - start).count()); - } + auto result = inference.run(bindings); + const double ms = std::chrono::duration(std::chrono::steady_clock::now() - start).count(); + checkRun(result); + latencies.push_back(ms); + // stderr keeps --json stdout parseable. + std::cerr << "[bench] " << label << " " << index << "/" << total << ": " << ms << " ms" << std::endl; + return result; + }; + const auto makeBindings = [](const automix::ai::OnnxTensorInference& inference) { + std::vector bindings; + for (const auto& spec : inference.inputSpecs()) { + size_t count = automix::ai::elementCount(spec).value_or(1024); + bindings.push_back(automix::ai::TensorBinding{ + .expected = spec, + .data = std::vector(count, 0.1f), + }); + } + return bindings; + }; - automix::ai::OnnxTensorInference provInference; - provInference.setTensorContract(pack.tensorContract); - provInference.setExecutionProvider(providerArg); - // The bench deliberately ignores the pack's allow-list so it measures the requested provider. - provInference.setGpuProviderAllowList({}); - if (!provInference.loadModel(pack.rootPath / pack.modelFile)) { - std::cerr << "ERROR: could not load tensor model with provider '" << providerArg - << "': " << provInference.backendDiagnostics() << "\n"; - return 3; + std::vector bindings; + automix::ai::TensorInferenceResult cpuResult; + automix::ai::OnnxTensorInference cpuInference; + if (runCpuBaseline) { + cpuInference.setTensorContract(pack.tensorContract); + cpuInference.setExecutionProvider("cpu"); + if (!cpuInference.loadModel(pack.rootPath / pack.modelFile)) { + std::cerr << "ERROR: could not load tensor model on CPU: " << cpuInference.backendDiagnostics() << "\n"; + return 3; + } + bindings = makeBindings(cpuInference); + for (int i = 0; i < warmupRuns; ++i) { + checkRun(cpuInference.run(bindings)); + std::cerr << "[bench] cpu warmup " << (i + 1) << "/" << warmupRuns << " done" << std::endl; + } + for (int i = 0; i < timedRuns; ++i) { + cpuResult = timedRun(cpuInference, bindings, "cpu", i + 1, timedRuns, cpuLatencies); + } } - actualProvider = provInference.activeExecutionProvider(); - providerBackendDiagnostics = provInference.backendDiagnostics(); - std::vector provBindings = cpuBindings; + if (requestedIsCpu) { + actualProvider = "cpu"; + providerBackendDiagnostics = cpuInference.backendDiagnostics(); + provLatencies = cpuLatencies; + } else { + automix::ai::OnnxTensorInference provInference; + provInference.setTensorContract(pack.tensorContract); + provInference.setExecutionProvider(providerArg); + // The bench deliberately ignores the pack's allow-list so it measures the requested provider. + provInference.setGpuProviderAllowList({}); + if (!provInference.loadModel(pack.rootPath / pack.modelFile)) { + std::cerr << "ERROR: could not load tensor model with provider '" << providerArg + << "': " << provInference.backendDiagnostics() << "\n"; + return 3; + } + actualProvider = provInference.activeExecutionProvider(); + providerBackendDiagnostics = provInference.backendDiagnostics(); + if (bindings.empty()) { + bindings = makeBindings(provInference); + } - for (int i = 0; i < warmupRuns; ++i) { - checkRun(provInference.run(provBindings)); - } - automix::ai::TensorInferenceResult provResult; - for (int i = 0; i < timedRuns; ++i) { - const auto start = std::chrono::steady_clock::now(); - provResult = provInference.run(provBindings); - const auto end = std::chrono::steady_clock::now(); - checkRun(provResult); - provLatencies.push_back(std::chrono::duration(end - start).count()); - } + for (int i = 0; i < warmupRuns; ++i) { + checkRun(provInference.run(bindings)); + std::cerr << "[bench] " << actualProvider << " warmup " << (i + 1) << "/" << warmupRuns << " done" + << std::endl; + } + automix::ai::TensorInferenceResult provResult; + for (int i = 0; i < timedRuns; ++i) { + provResult = timedRun(provInference, bindings, actualProvider.c_str(), i + 1, timedRuns, provLatencies); + } - for (size_t i = 0; i < cpuResult.outputs.size() && i < provResult.outputs.size(); ++i) { - const auto& cOut = cpuResult.outputs[i]; - const auto& pOut = provResult.outputs[i]; - const size_t sz = std::min(cOut.data.size(), pOut.data.size()); - for (size_t j = 0; j < sz; ++j) { - double diff = std::abs(static_cast(cOut.data[j]) - static_cast(pOut.data[j])); - if (diff > maxDelta) maxDelta = diff; + for (size_t i = 0; i < cpuResult.outputs.size() && i < provResult.outputs.size(); ++i) { + const auto& cOut = cpuResult.outputs[i]; + const auto& pOut = provResult.outputs[i]; + const size_t sz = std::min(cOut.data.size(), pOut.data.size()); + for (size_t j = 0; j < sz; ++j) { + double diff = std::abs(static_cast(cOut.data[j]) - static_cast(pOut.data[j])); + if (diff > maxDelta) maxDelta = diff; + } } } + parityChecked = runCpuBaseline; } else { automix::ai::OnnxModelInference cpuInference; cpuInference.setExecutionProviderPreference("cpu"); @@ -825,7 +855,8 @@ int commandModelBench(const CommandArgs& args) { const bool providerMatches = automix::ai::gpu::benchProviderMatches(providerArg, actualProvider); const bool allRunsSucceeded = failedRuns == 0; - const bool shouldReportSpeedup = allRunsSucceeded && + if (!isTensor) parityChecked = true; + const bool shouldReportSpeedup = allRunsSucceeded && parityChecked && (automix::ai::gpu::canonicalProviderName(actualProvider) != automix::ai::gpu::kProviderCpu) && providerMatches; const double p90Ms = automix::ai::gpu::nearestRankPercentile(provLatencies, 0.9); const double cpuP90Ms = automix::ai::gpu::nearestRankPercentile(cpuLatencies, 0.9); @@ -852,16 +883,19 @@ int commandModelBench(const CommandArgs& args) { {"medianLatencyMs", medianMs}, {"minLatencyMs", minMs}, {"maxLatencyMs", maxMs}, - {"cpuMedianLatencyMs", cpuMedianMs}, - {"maxDeltaVsCpu", maxDelta}, + {"parityChecked", parityChecked}, {"latenciesMs", provLatencies}, - {"passedParity", allRunsSucceeded && maxDelta < 1e-3}, + {"passedParity", allRunsSucceeded && parityChecked && maxDelta < 1e-3}, {"failedRuns", failedRuns}, {"providerMatches", providerMatches}, {"packAllowList", pack.gpuProviders}, {"p90LatencyMs", p90Ms}, - {"cpuP90LatencyMs", cpuP90Ms}, }; + if (parityChecked) { + payload["maxDeltaVsCpu"] = maxDelta; + payload["cpuMedianLatencyMs"] = cpuMedianMs; + payload["cpuP90LatencyMs"] = cpuP90Ms; + } if (speedup.has_value()) { payload["speedupVsCpu"] = *speedup; @@ -884,7 +918,11 @@ int commandModelBench(const CommandArgs& args) { std::cout << " Timed runs: " << timedRuns << "\n"; std::cout << " Median latency: " << medianMs << " ms (min: " << minMs << " ms, max: " << maxMs << " ms)\n"; std::cout << " p90: " << p90Ms << " ms\n"; - std::cout << " CPU median latency: " << cpuMedianMs << " ms\n"; + if (parityChecked) { + std::cout << " CPU median latency: " << cpuMedianMs << " ms\n"; + } else { + std::cout << " CPU baseline: skipped (no parity check, no speedup)\n"; + } std::cout << " Provider match: " << (providerMatches ? "yes" : "no") << "\n"; if (outsideAllowList) { std::cout << " Note: '" << providerArg << "' is outside this pack's allow-list; measured anyway\n"; @@ -892,7 +930,9 @@ int commandModelBench(const CommandArgs& args) { if (speedup.has_value()) { std::cout << " Speedup vs CPU: " << *speedup << "x\n"; } - std::cout << " Max |Δ| vs CPU: " << maxDelta << "\n"; + if (parityChecked) { + std::cout << " Max |Δ| vs CPU: " << maxDelta << "\n"; + } std::cout << " Failed runs: " << failedRuns << "\n"; } From 4652206504afd7f54506cd0866ddbbdb5bb663eb Mon Sep 17 00:00:00 2001 From: Soficis Date: Sun, 4 Oct 2026 19:42:31 -0500 Subject: [PATCH 57/68] feat(ai): CoreML/ANE are opt-in for auto on Macs under 12 GiB; warn when the vocal model would run on CPU there Measured on an 8 GiB MacBook Neo: CoreML and ANE were SIGKILLed during the first BS-RoFormer inference and the CPU path took over 5 minutes per chunk. AUTOMIX_ENABLE_COREML=1 opts in; naming the provider explicitly still works. --- src/ai/GpuMemory.cpp | 38 ++++++++++++++++++++++++++++++++ src/ai/GpuMemory.h | 24 ++++++++++++++++++++ src/ai/OnnxTensorInference.cpp | 12 ++++++++-- src/ai/OnnxTensorInference.h | 4 +++- src/app/ui/MainLayout.cpp | 5 +++++ tests/unit/GpuBenchmarkTests.cpp | 31 ++++++++++++++++++++++++++ 6 files changed, 111 insertions(+), 3 deletions(-) diff --git a/src/ai/GpuMemory.cpp b/src/ai/GpuMemory.cpp index 3b68da9..0321aba 100644 --- a/src/ai/GpuMemory.cpp +++ b/src/ai/GpuMemory.cpp @@ -5,6 +5,7 @@ #include #include "ai/GpuRuntimePack.h" +#include "ai/OnnxTensorInference.h" namespace automix::ai { namespace { @@ -54,4 +55,41 @@ bool gpuHasRoomNow(const std::optional& memory, const std::uint64 return !memory.has_value() || memory->freeBytes >= requiredBytes; } +bool coreMlAutoAllowed(const std::uint64_t physicalMemoryBytes, const bool explicitOptIn) { + return explicitOptIn || physicalMemoryBytes == 0 || physicalMemoryBytes >= kCoreMlAutoMinMemoryBytes; +} + +std::string vocalModelCpuWarning(const std::uint64_t physicalMemoryBytes, const bool gpuSessionUsable, + const bool isMac) { + if (!isMac || gpuSessionUsable || physicalMemoryBytes == 0 || physicalMemoryBytes >= kCoreMlAutoMinMemoryBytes) { + return {}; + } + const auto gib = physicalMemoryBytes / (1024ull * 1024 * 1024); + return "This Mac has " + std::to_string(gib) + + " GB of memory and no usable GPU acceleration for the vocal model. It will run on the CPU: expect " + "several minutes per 20-second chunk (hours for a full song), heavy memory swapping, and possible " + "failure. Leave Vocal Model off, or use a Mac with 16 GB or more."; +} + +std::string vocalModelCpuWarning() { +#if JUCE_MAC + std::string provider; + const bool gpuUsable = gpuTensorSessionAvailable(&provider); + const auto physicalBytes = static_cast(juce::SystemStats::getMemorySizeInMegabytes()) * 1024 * 1024; + return vocalModelCpuWarning(physicalBytes, gpuUsable, true); +#else + return {}; +#endif +} + +bool coreMlAutoAllowed() { +#if JUCE_MAC + const bool optIn = juce::SystemStats::getEnvironmentVariable("AUTOMIX_ENABLE_COREML", "") == "1"; + const auto physicalBytes = static_cast(juce::SystemStats::getMemorySizeInMegabytes()) * 1024 * 1024; + return coreMlAutoAllowed(physicalBytes, optIn); +#else + return true; +#endif +} + } // namespace automix::ai diff --git a/src/ai/GpuMemory.h b/src/ai/GpuMemory.h index 2b9b0bf..dfc974f 100644 --- a/src/ai/GpuMemory.h +++ b/src/ai/GpuMemory.h @@ -2,6 +2,7 @@ #include #include +#include namespace automix::ai { @@ -28,4 +29,27 @@ bool gpuFitsModel(const std::optional& memory, std::uint64_t requ // retried on CPU anyway. bool gpuHasRoomNow(const std::optional& memory, std::uint64_t requiredBytes); +// Apple's CoreML and Neural Engine providers are opt-in on Macs with little RAM. +// Measured on an 8 GiB MacBook Neo (BS-RoFormer, 1 run, nothing else running): +// both providers were killed by the OS (SIGKILL) during the first inference, and +// the CPU path did not finish a run in 5 minutes. "auto" therefore skips them +// below kCoreMlAutoMinMemoryBytes unless the user opts in with +// AUTOMIX_ENABLE_COREML=1. Naming the provider explicitly always works. +inline constexpr std::uint64_t kCoreMlAutoMinMemoryBytes = 12ull * 1024 * 1024 * 1024; + +// Pure policy: may "auto" try CoreML/ANE on a machine with this much RAM? +// Unknown memory (0) is allowed, because nothing better is known. +bool coreMlAutoAllowed(std::uint64_t physicalMemoryBytes, bool explicitOptIn); + +// Warning for the Vocal Model toggle: empty unless the model would run on the CPU of a +// low-memory Mac (below kCoreMlAutoMinMemoryBytes, no GPU session usable). The 8 GiB +// MacBook Neo needed over 5 minutes per chunk on the CPU against about 25 s on a desktop. +std::string vocalModelCpuWarning(std::uint64_t physicalMemoryBytes, bool gpuSessionUsable, bool isMac); + +// Live version for this machine; probes for a usable GPU session. +std::string vocalModelCpuWarning(); + +// Live policy for this machine. Always true off macOS, where CoreML does not exist. +bool coreMlAutoAllowed(); + } // namespace automix::ai diff --git a/src/ai/OnnxTensorInference.cpp b/src/ai/OnnxTensorInference.cpp index 9fc02fd..3ec222e 100644 --- a/src/ai/OnnxTensorInference.cpp +++ b/src/ai/OnnxTensorInference.cpp @@ -94,7 +94,8 @@ bool isConcrete(const std::vector& dims) { std::vector tensorProviderCandidates(const std::string& requested, const std::vector& runtimeProviders, - const std::vector& allowList) { + const std::vector& allowList, + const bool autoAllowCoreMl) { std::vector reported; for (const auto& provider : runtimeProviders) { reported.push_back(gpu::canonicalProviderName(provider)); @@ -124,7 +125,11 @@ std::vector tensorProviderCandidates(const std::string& requested, candidates.push_back(wanted); } for (const auto& provider : gpu::providerPriorityChain()) { - if (provider != gpu::kProviderCpu && provider != wanted && isReported(provider) && isAllowed(provider)) { + // On low-memory Macs "auto" skips CoreML/ANE (see coreMlAutoAllowed); naming one still works. + const bool appleSkippedForAuto = + wanted == "auto" && !autoAllowCoreMl && (provider == gpu::kProviderCoreMl || provider == gpu::kProviderAne); + if (provider != gpu::kProviderCpu && provider != wanted && isReported(provider) && isAllowed(provider) && + !appleSkippedForAuto) { candidates.push_back(provider); } } @@ -269,6 +274,9 @@ bool gpuTensorSessionAvailable(std::string* providerOut) { if (candidate == gpu::kProviderCpu) { break; } + if (!coreMlAutoAllowed() && (candidate == gpu::kProviderCoreMl || candidate == gpu::kProviderAne)) { + continue; + } if (tensorProviderUsable(candidate)) { if (providerOut != nullptr) { *providerOut = candidate; diff --git a/src/ai/OnnxTensorInference.h b/src/ai/OnnxTensorInference.h index 387f562..e3f2f41 100644 --- a/src/ai/OnnxTensorInference.h +++ b/src/ai/OnnxTensorInference.h @@ -7,6 +7,7 @@ #include #include +#include "ai/GpuMemory.h" #include "ai/ITensorInference.h" #include "ai/ModelPackLoader.h" @@ -20,7 +21,8 @@ namespace automix::ai { // loadModel() treats every non-CPU entry as an attempt that may fail. std::vector tensorProviderCandidates(const std::string& requested, const std::vector& runtimeProviders, - const std::vector& allowList = {}); + const std::vector& allowList = {}, + bool autoAllowCoreMl = coreMlAutoAllowed()); // True when a GPU execution provider (e.g. "cuda", "webgpu") is proven usable on this system // by opening a real session on a tiny in-memory graph. Successes are cached per provider. diff --git a/src/app/ui/MainLayout.cpp b/src/app/ui/MainLayout.cpp index 62fcd00..27931a8 100644 --- a/src/app/ui/MainLayout.cpp +++ b/src/app/ui/MainLayout.cpp @@ -1081,6 +1081,11 @@ void MainLayout::wireControlDeckCallbacks() { const bool enabled = controlDeck_->getTensorSeparationToggle().getToggleState(); sessionManager_.session().renderSettings.tensorSeparationEnabled = enabled; if (enabled) { + if (const auto warning = ai::vocalModelCpuWarning(); !warning.empty()) { + taskOrchestrator_->appendHistory("Vocal Model warning: " + juce::String(warning)); + juce::AlertWindow::showMessageBoxAsync(juce::MessageBoxIconType::WarningIcon, "Vocal Model will be slow", + juce::String(warning)); + } offerGpuRuntimeIfUseful(); } }; diff --git a/tests/unit/GpuBenchmarkTests.cpp b/tests/unit/GpuBenchmarkTests.cpp index 6f2e87a..de05795 100644 --- a/tests/unit/GpuBenchmarkTests.cpp +++ b/tests/unit/GpuBenchmarkTests.cpp @@ -146,6 +146,37 @@ TEST_CASE("GpuProvider platform preferred provider", "[gpu][provider]") { #endif } +TEST_CASE("CoreML and ANE are opt-in for auto on low-memory Macs", "[gpu][tensor][coreml]") { + using automix::ai::coreMlAutoAllowed; + using automix::ai::kCoreMlAutoMinMemoryBytes; + constexpr std::uint64_t GiB = 1024ull * 1024 * 1024; + const std::vector mac = {"ane", "coreml", "cpu"}; + + CHECK_FALSE(coreMlAutoAllowed(8 * GiB, false)); + CHECK(coreMlAutoAllowed(8 * GiB, true)); + CHECK(coreMlAutoAllowed(kCoreMlAutoMinMemoryBytes, false)); + CHECK(coreMlAutoAllowed(16 * GiB, false)); + CHECK(coreMlAutoAllowed(0, false)); + + // auto on a low-memory Mac goes straight to CPU; opted in or roomy keeps the Apple providers + CHECK(tensorProviderCandidates("auto", mac, {}, false) == std::vector{"cpu"}); + CHECK(tensorProviderCandidates("auto", mac, {}, true) == std::vector{"ane", "coreml", "cpu"}); + // naming the provider is always honoured + CHECK(tensorProviderCandidates("coreml", mac, {}, false).front() == "coreml"); + CHECK(tensorProviderCandidates("ane", mac, {}, false).front() == "ane"); +} + +TEST_CASE("Vocal model warns about CPU only on low-memory Macs", "[gpu][coreml]") { + using automix::ai::vocalModelCpuWarning; + constexpr std::uint64_t GiB = 1024ull * 1024 * 1024; + CHECK_FALSE(vocalModelCpuWarning(8 * GiB, false, true).empty()); + CHECK(vocalModelCpuWarning(8 * GiB, false, true).find("8 GB") != std::string::npos); + CHECK(vocalModelCpuWarning(8 * GiB, true, true).empty()); // a GPU session is usable + CHECK(vocalModelCpuWarning(16 * GiB, false, true).empty()); // roomy + CHECK(vocalModelCpuWarning(8 * GiB, false, false).empty()); // not a Mac: no measurement behind it + CHECK(vocalModelCpuWarning(0, false, true).empty()); // unknown memory +} + TEST_CASE("tensorProviderCandidates allow-list filtering", "[gpu][tensor]") { // 1. Empty allow-list preserves all reported GPU candidates and keeps CPU last const auto c1 = tensorProviderCandidates("auto", {"cuda", "webgpu", "cpu"}, {}); From 779061302c09877b03a80d8ca2f87319eb689c7e Mon Sep 17 00:00:00 2001 From: Soficis Date: Sun, 4 Oct 2026 20:22:31 -0500 Subject: [PATCH 58/68] feat(ai): add Open-Unmix vocals as a light separation pack for low-memory machines Adds magnitude_channels input and ratio_mask output to the tensor runner (mix phase kept), a curated MIT pack (36 MB, CPU), NOTICE and licensing audit rows, and points the low-memory Mac warning at it. 60 s of audio separates in 0.9 s on an 8 GiB MacBook Neo, where BS-RoFormer needed over 5 minutes per chunk. --- NOTICE | 10 +++ docs/model-licensing-audit.json | 12 +++ src/ai/GpuMemory.cpp | 3 +- src/ai/HuggingFaceModelHub.cpp | 8 +- src/ai/ModelCatalogValidator.cpp | 9 +++ src/ai/ModelPackLoader.cpp | 6 +- src/ai/SeparationRunner.cpp | 50 +++++++++++- src/ai/SeparationRunner.h | 14 +++- src/ai/UmxPack.h | 52 +++++++++++++ tests/unit/TensorInferenceTests.cpp | 114 +++++++++++++++++++++++++++- 10 files changed, 264 insertions(+), 14 deletions(-) create mode 100644 src/ai/UmxPack.h diff --git a/NOTICE b/NOTICE index 328a536..426d881 100644 --- a/NOTICE +++ b/NOTICE @@ -63,6 +63,7 @@ of the audit date in docs/model-licensing-audit.json. SonyCSLParis/music2latent CC BY-NC 4.0 YES kramp/ito-master-onnx CC BY-NC 4.0 YES xycld/BS-RoFormer-ONNX MIT no + MixDirective/open-unmix-umxhq-vocals-onnx MIT no GitHub release sources, terms not yet verified: smartdaze/otowake-oto (htdemucs_6s.onnx) @@ -118,6 +119,15 @@ resolves it. only: the instrumental stem is the residual (mix - vocals), not a second separation. + MixDirective/open-unmix-umxhq-vocals-onnx (MIT) + Open-Unmix UMX-HQ vocals (Stoter, Uhlich, Liutkus, Mitsufuji; (c) 2019 + Inria), ONNX export by MixDirective. + https://opensource.org/license/mit + A 36 MB CPU-friendly alternative to BS-RoFormer for machines with little + memory; lower separation quality. No model file is re-hosted. The model + separates vocals only: the instrumental stem is the residual + (mix - vocals), not a second separation. + 6. Optional GPU runtime (NVIDIA CUDA libraries) ----------------------------------------------- diff --git a/docs/model-licensing-audit.json b/docs/model-licensing-audit.json index f30b479..b896526 100644 --- a/docs/model-licensing-audit.json +++ b/docs/model-licensing-audit.json @@ -124,6 +124,18 @@ ], "attribution": "BS-RoFormer, Lu et al., arXiv 2309.02612; ONNX export by xycld (huggingface.co/xycld/BS-RoFormer-ONNX)", "notes": "BS-RoFormer vocal separation ONNX; MIT confirmed on HF card (cardData.license and license:mit tag, checked 2026-09-30). Catalog installs fp32 (~645 MB, external-data sidecar inlined locally at install) where a CUDA session opens, else the single-file uint8-quantized build (~166 MB). Measured on RTX 5060 Ti: fp32/CUDA 85-91 s vs quantized/CPU 687 s for a 196 s track. Graph outputs vocals only; instrumental is the residual mix - vocals. Download-only, never bundled." + }, + { + "id": "MixDirective/open-unmix-umxhq-vocals-onnx", + "license": "MIT", + "commercialUsable": true, + "attributionRequired": false, + "flagged": false, + "assets": [ + "model.onnx" + ], + "attribution": "Open-Unmix UMX-HQ, Stoter, Uhlich, Liutkus, Mitsufuji (c) 2019 Inria; ONNX export by MixDirective (huggingface.co/MixDirective/open-unmix-umxhq-vocals-onnx)", + "notes": "Open-Unmix UMX-HQ vocals ONNX (36 MB, bidirectional LSTM, magnitude STFT in/out); MIT confirmed on HF card and LICENSE file (checked 2026-10-04). Low-memory alternative to BS-RoFormer. Graph outputs vocals only; instrumental is the residual mix - vocals. Download-only, never bundled." } ] } diff --git a/src/ai/GpuMemory.cpp b/src/ai/GpuMemory.cpp index 0321aba..bab5df5 100644 --- a/src/ai/GpuMemory.cpp +++ b/src/ai/GpuMemory.cpp @@ -68,7 +68,8 @@ std::string vocalModelCpuWarning(const std::uint64_t physicalMemoryBytes, const return "This Mac has " + std::to_string(gib) + " GB of memory and no usable GPU acceleration for the vocal model. It will run on the CPU: expect " "several minutes per 20-second chunk (hours for a full song), heavy memory swapping, and possible " - "failure. Leave Vocal Model off, or use a Mac with 16 GB or more."; + "failure. Install the light vocal model instead (Open-Unmix, 36 MB, about 20x faster than real time on " + "a CPU) from the Model Hub, or leave Vocal Model off."; } std::string vocalModelCpuWarning() { diff --git a/src/ai/HuggingFaceModelHub.cpp b/src/ai/HuggingFaceModelHub.cpp index a04c64d..da926fb 100644 --- a/src/ai/HuggingFaceModelHub.cpp +++ b/src/ai/HuggingFaceModelHub.cpp @@ -14,6 +14,7 @@ #include #include "ai/BsRoformerPack.h" +#include "ai/UmxPack.h" #include "ai/GpuMemory.h" #include "ai/ItoMasterAdapter.h" #include "ai/ModelCatalogValidator.h" @@ -546,6 +547,7 @@ std::vector curatedModelIds() { "StemSplitio/htdemucs-6s-onnx", "kramp/ito-master-onnx", "xycld/BS-RoFormer-ONNX", // == kBsRoformerRepoId; a literal because licensing tests parse this list + "MixDirective/open-unmix-umxhq-vocals-onnx", // == kUmxVocalsRepoId; literal for the same reason }; } @@ -985,9 +987,11 @@ HubInstallResult HuggingFaceModelHub::installModel(const std::string& modelIdOrR writeJson(result.metadataPath, metadata); std::optional tensorContract; - if (info->repoId == kBsRoformerRepoId) { + if (info->repoId == kBsRoformerRepoId || info->repoId == kUmxVocalsRepoId) { std::string probeError; - tensorContract = resolveInstalledTensorContract(bsRoformerCatalogContract(), primaryPath, probeError); + const auto catalogContract = + info->repoId == kBsRoformerRepoId ? bsRoformerCatalogContract() : umxVocalsCatalogContract(); + tensorContract = resolveInstalledTensorContract(catalogContract, primaryPath, probeError); if (!tensorContract.has_value()) { std::filesystem::remove(primaryPath, error); result.message = "Downloaded model does not match its tensor contract: " + probeError; diff --git a/src/ai/ModelCatalogValidator.cpp b/src/ai/ModelCatalogValidator.cpp index 5fc4813..d92879e 100644 --- a/src/ai/ModelCatalogValidator.cpp +++ b/src/ai/ModelCatalogValidator.cpp @@ -8,6 +8,7 @@ #include #include "ai/BsRoformerPack.h" +#include "ai/UmxPack.h" #include "ai/ItoMasterAdapter.h" #include "ai/OnnxTensorInference.h" #include "util/StringUtils.h" @@ -129,6 +130,10 @@ std::string inferTaskScope(const HubModelInfo& model) { if (isKnownTaskScope(model.taskScope)) { return model.taskScope; } + if (model.repoId == kUmxVocalsRepoId) { + // Pinned: the publisher's name ("MixDirective") would otherwise match the "mix" token. + return "separation"; + } std::string joined = toLower(model.useCase); joined += "|" + toLower(model.repoId); @@ -277,6 +282,10 @@ bool writeTurnkeyModelPackManifest(const std::filesystem::path& installPath, } } + if (model.repoId == kUmxVocalsRepoId) { + manifest["intended_use"] = kUmxVocalsIntendedUse; + } + const auto manifestPath = installPath / "model.json"; std::ofstream out(manifestPath); if (!out.is_open()) { diff --git a/src/ai/ModelPackLoader.cpp b/src/ai/ModelPackLoader.cpp index bd8ee77..f76dd3e 100644 --- a/src/ai/ModelPackLoader.cpp +++ b/src/ai/ModelPackLoader.cpp @@ -339,12 +339,12 @@ std::optional runnerConfigFromContract(const TensorContract& contr const auto layout = inputLayoutFromString(contract.inputLayout); if (!layout.has_value()) { errorOut = "tensor_contract input_layout '" + contract.inputLayout + - "' is not one of folded_stereo, split_channels"; + "' is not one of folded_stereo, split_channels, magnitude_channels"; return std::nullopt; } const auto mode = outputModeFromString(contract.outputMode); if (!mode.has_value()) { - errorOut = "tensor_contract output_mode '" + contract.outputMode + "' is not one of direct, mask"; + errorOut = "tensor_contract output_mode '" + contract.outputMode + "' is not one of direct, mask, ratio_mask"; return std::nullopt; } if (contract.stft.padMode != "reflect") { @@ -411,7 +411,7 @@ bool checkTensorContract(const TensorContract& contract, const int frames = analysis::stftFrameCount(config->chunkSamples, config->stft); const int graphStems = static_cast(std::count_if(contract.stems.begin(), contract.stems.end(), [](const auto& stem) { return stem.residualOf.empty(); })); - const int stemAxis = config->outputMode == OutputMode::Mask ? 1 : graphStems; + const int stemAxis = config->outputMode == OutputMode::Direct ? graphStems : 1; const auto impliedInput = tensorInputDims(config->inputLayout, config->channels, freqBins, frames); const auto impliedOutput = tensorOutputDims(config->inputLayout, stemAxis, config->channels, freqBins, frames); const auto stftSummary = "n_fft " + std::to_string(config->stft.nFft) + " (" + std::to_string(freqBins) + diff --git a/src/ai/SeparationRunner.cpp b/src/ai/SeparationRunner.cpp index 152dc1a..1338cfe 100644 --- a/src/ai/SeparationRunner.cpp +++ b/src/ai/SeparationRunner.cpp @@ -26,6 +26,13 @@ std::vector encodeInput(const analysis::Spectrogram& spec, const InputLay const int channels = spec.channels; const int bins = spec.freqBins; const int frames = spec.frames; + if (layout == InputLayout::MagnitudeChannels) { + std::vector magnitude(spec.real.size()); + for (std::size_t i = 0; i < magnitude.size(); ++i) { + magnitude[i] = std::hypot(spec.real[i], spec.imag[i]); + } + return magnitude; + } std::vector data(static_cast(channels) * static_cast(bins) * static_cast(frames) * 2); for (int ch = 0; ch < channels; ++ch) { @@ -83,6 +90,24 @@ analysis::Spectrogram decodeOutputStem(const std::vector& data, return spec; } +// Real mask = clip(estimated magnitude / input magnitude, 0, 1), bin by bin. +analysis::Spectrogram ratioMask(const std::vector& estimate, + const std::vector& inputMagnitude, + const analysis::Spectrogram& geometry) { + analysis::Spectrogram mask; + mask.channels = geometry.channels; + mask.freqBins = geometry.freqBins; + mask.frames = geometry.frames; + mask.sampleRate = geometry.sampleRate; + mask.real.resize(estimate.size()); + mask.imag.assign(estimate.size(), 0.0f); + for (std::size_t i = 0; i < estimate.size(); ++i) { + const float denominator = std::max(inputMagnitude[i], 1.0e-8f); + mask.real[i] = std::clamp(estimate[i] / denominator, 0.0f, 1.0f); + } + return mask; +} + // Crossfade weight of sample `i` within a chunk. Consecutive chunks overlap by // exactly `overlap` samples, and the fade-in of one chunk and the fade-out of // the previous are sin^2 / cos^2 of the same phase, so they sum to one there. @@ -119,7 +144,10 @@ std::string validateRunnerConfig(const RunnerConfig& config) { if (graphStems == 0) { return "the stem list names no graph-produced stem"; } - if (config.outputMode == OutputMode::Mask) { + if ((config.inputLayout == InputLayout::MagnitudeChannels) != (config.outputMode == OutputMode::RatioMask)) { + return "input_layout magnitude_channels and output_mode ratio_mask only work together"; + } + if (config.outputMode == OutputMode::Mask || config.outputMode == OutputMode::RatioMask) { if (graphStems != 1) { return "mask mode produces exactly one graph stem, but " + std::to_string(graphStems) + " are declared"; } @@ -150,6 +178,9 @@ std::optional inputLayoutFromString(const std::string& value) { if (value == "split_channels") { return InputLayout::SplitChannels; } + if (value == "magnitude_channels") { + return InputLayout::MagnitudeChannels; + } return std::nullopt; } @@ -160,6 +191,9 @@ std::optional outputModeFromString(const std::string& value) { if (value == "mask") { return OutputMode::Mask; } + if (value == "ratio_mask") { + return OutputMode::RatioMask; + } return std::nullopt; } @@ -167,6 +201,9 @@ std::vector tensorInputDims(const InputLayout layout, const int channel if (layout == InputLayout::FoldedStereo) { return {1, frames, static_cast(freqBins) * channels * 2}; } + if (layout == InputLayout::MagnitudeChannels) { + return {1, channels, freqBins, frames}; + } return {1, channels, freqBins, frames, 2}; } @@ -178,6 +215,9 @@ std::vector tensorOutputDims(const InputLayout layout, if (layout == InputLayout::FoldedStereo) { return {1, stemAxis, static_cast(freqBins) * channels, frames, 2}; } + if (layout == InputLayout::MagnitudeChannels) { + return {1, channels, freqBins, frames}; + } return {1, stemAxis, channels, freqBins, frames, 2}; } @@ -238,7 +278,7 @@ SeparationRunner::Result SeparationRunner::separate(const engine::AudioBuffer& m graphStemIndex.push_back(i); } } - const int stemAxis = config.outputMode == OutputMode::Mask ? 1 : static_cast(graphStemIndex.size()); + const int stemAxis = config.outputMode == OutputMode::Direct ? static_cast(graphStemIndex.size()) : 1; const int chunk = config.chunkSamples; const int overlap = config.overlapSamples; @@ -316,7 +356,11 @@ SeparationRunner::Result SeparationRunner::separate(const engine::AudioBuffer& m } std::vector> chunkStems(config.stems.size()); - if (config.outputMode == OutputMode::Mask) { + if (config.outputMode == OutputMode::RatioMask) { + const auto mask = ratioMask(produced->data, binding.data, spectrum); + const auto masked = analysis::complexMultiply(spectrum, mask, config.stft.zeroDc); + chunkStems[graphStemIndex.front()] = analysis::synthesize(masked, config.stft); + } else if (config.outputMode == OutputMode::Mask) { const auto mask = decodeOutputStem(produced->data, 0, config.inputLayout, spectrum); const auto masked = analysis::complexMultiply(spectrum, mask, config.stft.zeroDc); chunkStems[graphStemIndex.front()] = analysis::synthesize(masked, config.stft); diff --git a/src/ai/SeparationRunner.h b/src/ai/SeparationRunner.h index 1e4a0de..f691a11 100644 --- a/src/ai/SeparationRunner.h +++ b/src/ai/SeparationRunner.h @@ -20,15 +20,21 @@ namespace automix::ai { // SplitChannels (channels on their own axis): // input [1, C, F, T, 2] // output [1, N, C, F, T, 2] +// MagnitudeChannels (Open-Unmix style, magnitude only, no phase): +// input [1, C, F, T] |STFT| +// output [1, C, F, T] estimated target |STFT| (RatioMask mode only) // // N is the stem axis: 1 in Mask mode, one entry per graph-produced stem in -// Direct mode. The trailing axis of length 2 is always (real, imag). -enum class InputLayout { FoldedStereo, SplitChannels }; +// Direct mode. For the complex layouts the trailing axis of length 2 is always +// (real, imag). +enum class InputLayout { FoldedStereo, SplitChannels, MagnitudeChannels }; // Mask: the graph returns one complex mask for the target stem; the caller // multiplies it against the input spectrum. Direct: the graph returns one -// spectrogram per stem. -enum class OutputMode { Direct, Mask }; +// spectrogram per stem. RatioMask: the graph returns the target's magnitude; +// the caller divides it by the input magnitude, clips to [0, 1] and multiplies +// the complex input spectrum by that real mask (the mix phase is kept). +enum class OutputMode { Direct, Mask, RatioMask }; std::optional inputLayoutFromString(const std::string& value); std::optional outputModeFromString(const std::string& value); diff --git a/src/ai/UmxPack.h b/src/ai/UmxPack.h new file mode 100644 index 0000000..2fc122e --- /dev/null +++ b/src/ai/UmxPack.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include + +#include "ai/ModelPackLoader.h" + +namespace automix::ai { + +// Open-Unmix UMX-HQ vocals (sigsep/open-unmix-pytorch, MIT), ONNX export by +// MixDirective. A 36 MB bidirectional-LSTM that runs on any CPU: the fast +// alternative to BS-RoFormer on machines that cannot run that model. On an 8 GiB +// Mac, BS-RoFormer needed over 5 minutes per chunk on the CPU and CoreML/ANE +// were killed by the OS. Its graph takes the STFT magnitude only (no phase), so +// the mix phase is kept and the model's output is applied as a ratio mask. +inline constexpr const char* kUmxVocalsRepoId = "MixDirective/open-unmix-umxhq-vocals-onnx"; +inline constexpr const char* kUmxVocalsFile = "model.onnx"; +inline constexpr const char* kUmxVocalsIntendedUse = + "Vocal separation (Open-Unmix UMX-HQ, 36 MB, CPU friendly). Lower quality than BS-RoFormer but " + "needs little memory. The graph produces vocals only; the instrumental stem is the residual " + "mix - vocals, not a second separation."; + +// Catalog form of the pack's tensor contract. ~10 s chunks (431 frames at hop +// 1024) with ~2 s of overlap, which also gives the LSTM context at chunk edges. +// The chunk is a whole number of hops (430 x 1024): the inverse STFT returns +// (frames - 1) x hop samples, so any other length would come back short. +inline TensorContract umxVocalsCatalogContract() { + TensorContract contract; + contract.engine = "open_unmix"; + contract.sampleRate = 44100; + contract.stereo = true; + contract.chunkSamples = 440320; + contract.overlapSamples = 87040; + contract.stft.nFft = 4096; + contract.stft.hopLength = 1024; + contract.stft.winLength = 4096; + contract.stft.window = "hann"; + contract.stft.periodicWindow = true; + contract.stft.center = true; + contract.stft.padMode = "reflect"; + contract.stft.normalized = false; + contract.stft.zeroDc = false; + contract.inputLayout = "magnitude_channels"; + contract.inputs = {{"", {1, 2, 2049, 431}, "float32"}}; + contract.outputs = {{"", {1, 2, 2049, 431}, "float32"}}; + contract.outputMode = "ratio_mask"; + contract.targetStem = "vocals"; + contract.stems = {{"vocals", ""}, {"instrumental", "vocals"}}; + return contract; +} + +} // namespace automix::ai diff --git a/tests/unit/TensorInferenceTests.cpp b/tests/unit/TensorInferenceTests.cpp index ac28e65..62c20a0 100644 --- a/tests/unit/TensorInferenceTests.cpp +++ b/tests/unit/TensorInferenceTests.cpp @@ -29,6 +29,7 @@ #include "ai/SeparationRunner.h" #include "ai/StemSeparator.h" #include "ai/TensorTypes.h" +#include "ai/UmxPack.h" #include "analysis/SpectrogramFrontEnd.h" #include "domain/JsonSerialization.h" #include "domain/RenderSettings.h" @@ -1705,4 +1706,115 @@ TEST_CASE("Hub containment check refuses look-alike and escaping paths", "[ai][m REQUIRE_FALSE(ai::isInsideDirectory(hub.parent_path() / "modelhub2" / "x", hub)); REQUIRE_FALSE(ai::isInsideDirectory(hub / ".." / "elsewhere", hub)); REQUIRE_FALSE(ai::isInsideDirectory(std::filesystem::temp_directory_path(), hub)); -} \ No newline at end of file +} +namespace { + +// Open-Unmix geometry shrunk to a test: 16384-sample chunks (17 frames at hop 1024). +ai::RunnerConfig umxTestConfig() { + ai::RunnerConfig config; + config.chunkSamples = 16384; + config.overlapSamples = 4096; + config.stft.nFft = 4096; + config.stft.hopLength = 1024; + config.stft.winLength = 4096; + config.stft.center = true; + config.stft.normalized = false; + config.stft.zeroDc = false; + config.inputLayout = ai::InputLayout::MagnitudeChannels; + config.outputMode = ai::OutputMode::RatioMask; + config.targetStem = "vocals"; + config.stems = {{"vocals", ""}, {"instrumental", "vocals"}}; + return config; +} + +// A graph that answers with `gain` times the input magnitude. +FakeTensorInference scaledMagnitudeGraph(const float gain) { + const ai::TensorSpec input{"magnitude", ai::TensorElementType::Float32, {1, 2, 2049, 17}}; + const ai::TensorSpec output{"vocals_magnitude", ai::TensorElementType::Float32, {1, 2, 2049, 17}}; + return FakeTensorInference({input}, {output}, [output, gain](const std::vector& bindings) { + ai::TensorInferenceResult result; + result.usedModel = true; + ai::Tensor tensor; + tensor.spec = output; + tensor.data = bindings.front().data; + for (auto& value : tensor.data) { + value *= gain; + } + result.outputs.push_back(std::move(tensor)); + return result; + }); +} + +} // namespace + +TEST_CASE("Ratio-mask runner keeps the mix phase and applies the magnitude ratio", "[ai][tensor][umx]") { + const auto mix = makeTestSignal(2, 40000, 44100.0); + const auto config = umxTestConfig(); + REQUIRE(ai::validateRunnerConfig(config).empty()); + + // Half the magnitude: vocals = mix / 2, and the residual instrumental is the other half. + auto half = scaledMagnitudeGraph(0.5f); + const auto halved = ai::SeparationRunner::separate(mix, half, config); + INFO(halved.logMessage); + REQUIRE(halved.usedModel); + REQUIRE(halved.stemNames == std::vector{"vocals", "instrumental"}); + const auto& vocals = halved.stemAudio[0]; + const auto& instrumental = halved.stemAudio[1]; + float worstVocal = 0.0f; + float worstSum = 0.0f; + for (int ch = 0; ch < 2; ++ch) { + for (int i = 0; i < mix.getNumSamples(); ++i) { + worstVocal = std::max(worstVocal, std::abs(vocals.getSample(ch, i) - 0.5f * mix.getSample(ch, i))); + worstSum = std::max(worstSum, std::abs(vocals.getSample(ch, i) + instrumental.getSample(ch, i) - mix.getSample(ch, i))); + } + } + REQUIRE(worstVocal < 2.0e-3f); + REQUIRE(worstSum < 2.0e-3f); + + // An estimate above the input is clipped to a mask of 1: vocals = mix. + auto loud = scaledMagnitudeGraph(3.0f); + const auto clipped = ai::SeparationRunner::separate(mix, loud, config); + REQUIRE(clipped.usedModel); + REQUIRE(maxAbsDifference(clipped.stemAudio[0], mix) < 2.0e-3f); + + // The graph is fed magnitudes, so no value is negative. + REQUIRE_FALSE(half.lastBindings.empty()); + REQUIRE(*std::min_element(half.lastBindings.front().data.begin(), half.lastBindings.front().data.end()) >= 0.0f); + REQUIRE(half.lastBindings.front().data.size() == 2u * 2049u * 17u); +} + +TEST_CASE("Magnitude layout and ratio mask only work together", "[ai][tensor][umx]") { + auto config = umxTestConfig(); + config.outputMode = ai::OutputMode::Mask; + REQUIRE_FALSE(ai::validateRunnerConfig(config).empty()); + config = umxTestConfig(); + config.inputLayout = ai::InputLayout::SplitChannels; + REQUIRE_FALSE(ai::validateRunnerConfig(config).empty()); + REQUIRE(ai::inputLayoutFromString("magnitude_channels") == std::optional(ai::InputLayout::MagnitudeChannels)); + REQUIRE(ai::outputModeFromString("ratio_mask") == std::optional(ai::OutputMode::RatioMask)); +} + +TEST_CASE("Open-Unmix catalog entry is a curated separation pack with a valid contract", "[ai][tensor][catalog][umx]") { + const auto curated = ai::curatedModelIds(); + REQUIRE(std::find(curated.begin(), curated.end(), std::string(ai::kUmxVocalsRepoId)) != curated.end()); + + ai::HubModelInfo info; + info.repoId = ai::kUmxVocalsRepoId; + info.tags = {"onnx", "open-unmix", "music-source-separation", "vocals", "license:mit"}; + info.files = {"model.onnx", "LICENSE", "README.md"}; + info.primaryFile = ai::primaryFileForRepo(info.repoId, info.files, &info.hasOnnx); + REQUIRE(info.primaryFile == ai::kUmxVocalsFile); + info.useCase = ai::HuggingFaceModelHub::inferUseCase(info.repoId, info.tags, ""); + const auto compatibility = ai::validateCatalogModel(info); + REQUIRE(compatibility.compatible); + // "MixDirective" must not make this a mix model. + REQUIRE(compatibility.taskScope == "separation"); + + const auto contract = ai::umxVocalsCatalogContract(); + const std::vector inputs{{"magnitude", ai::TensorElementType::Float32, {1, 2, 2049, 431}}}; + const std::vector outputs{{"vocals_magnitude", ai::TensorElementType::Float32, {1, 2, 2049, 431}}}; + std::string error; + REQUIRE(ai::checkTensorContract(contract, inputs, outputs, error)); + REQUIRE(error.empty()); + REQUIRE(ai::runnerConfigFromContract(contract, error).has_value()); +} From 3b034bf0cccb684dea41007a356b73e30d7d4fb7 Mon Sep 17 00:00:00 2001 From: Soficis Date: Mon, 5 Oct 2026 10:15:26 -0500 Subject: [PATCH 59/68] fix(ai): pin Open-Unmix to a fixed revision and hash; skip the low-memory warning when it is already active The curated Open-Unmix pack took its revision and sha256 from the live HF API, so a later push to the repo would change what installs. applyCuratedPin overrides both with pinned values, and the existing hash check rejects anything else. The low-memory Mac warning told users to install Open-Unmix even when it was already the active separation pack; it now stays quiet in that case. Co-Authored-By: Claude Opus 5.5 --- src/ai/GpuMemory.cpp | 9 +++++---- src/ai/GpuMemory.h | 6 ++++-- src/ai/HuggingFaceModelHub.cpp | 9 +++++++++ src/ai/HuggingFaceModelHub.h | 4 ++++ src/ai/UmxPack.h | 3 +++ src/app/ui/MainLayout.cpp | 5 ++++- tests/unit/AiExtensionTests.cpp | 20 ++++++++++++++++++++ tests/unit/GpuBenchmarkTests.cpp | 13 +++++++------ 8 files changed, 56 insertions(+), 13 deletions(-) diff --git a/src/ai/GpuMemory.cpp b/src/ai/GpuMemory.cpp index bab5df5..aa5ded9 100644 --- a/src/ai/GpuMemory.cpp +++ b/src/ai/GpuMemory.cpp @@ -60,8 +60,8 @@ bool coreMlAutoAllowed(const std::uint64_t physicalMemoryBytes, const bool expli } std::string vocalModelCpuWarning(const std::uint64_t physicalMemoryBytes, const bool gpuSessionUsable, - const bool isMac) { - if (!isMac || gpuSessionUsable || physicalMemoryBytes == 0 || physicalMemoryBytes >= kCoreMlAutoMinMemoryBytes) { + const bool isMac, const bool lightModelActive) { + if (lightModelActive || !isMac || gpuSessionUsable || physicalMemoryBytes == 0 || physicalMemoryBytes >= kCoreMlAutoMinMemoryBytes) { return {}; } const auto gib = physicalMemoryBytes / (1024ull * 1024 * 1024); @@ -72,13 +72,14 @@ std::string vocalModelCpuWarning(const std::uint64_t physicalMemoryBytes, const "a CPU) from the Model Hub, or leave Vocal Model off."; } -std::string vocalModelCpuWarning() { +std::string vocalModelCpuWarning(const bool lightModelActive) { #if JUCE_MAC std::string provider; const bool gpuUsable = gpuTensorSessionAvailable(&provider); const auto physicalBytes = static_cast(juce::SystemStats::getMemorySizeInMegabytes()) * 1024 * 1024; - return vocalModelCpuWarning(physicalBytes, gpuUsable, true); + return vocalModelCpuWarning(physicalBytes, gpuUsable, true, lightModelActive); #else + (void)lightModelActive; return {}; #endif } diff --git a/src/ai/GpuMemory.h b/src/ai/GpuMemory.h index dfc974f..251c6d2 100644 --- a/src/ai/GpuMemory.h +++ b/src/ai/GpuMemory.h @@ -44,10 +44,12 @@ bool coreMlAutoAllowed(std::uint64_t physicalMemoryBytes, bool explicitOptIn); // Warning for the Vocal Model toggle: empty unless the model would run on the CPU of a // low-memory Mac (below kCoreMlAutoMinMemoryBytes, no GPU session usable). The 8 GiB // MacBook Neo needed over 5 minutes per chunk on the CPU against about 25 s on a desktop. -std::string vocalModelCpuWarning(std::uint64_t physicalMemoryBytes, bool gpuSessionUsable, bool isMac); +// Empty too when the light vocal model (Open-Unmix) is already the active separation pack. +std::string vocalModelCpuWarning(std::uint64_t physicalMemoryBytes, bool gpuSessionUsable, bool isMac, + bool lightModelActive); // Live version for this machine; probes for a usable GPU session. -std::string vocalModelCpuWarning(); +std::string vocalModelCpuWarning(bool lightModelActive); // Live policy for this machine. Always true off macOS, where CoreML does not exist. bool coreMlAutoAllowed(); diff --git a/src/ai/HuggingFaceModelHub.cpp b/src/ai/HuggingFaceModelHub.cpp index da926fb..5096b96 100644 --- a/src/ai/HuggingFaceModelHub.cpp +++ b/src/ai/HuggingFaceModelHub.cpp @@ -605,6 +605,13 @@ std::string HuggingFaceModelHub::resolveToken(const std::string& explicitToken) return ""; } +void applyCuratedPin(HubModelInfo& info) { + if (info.repoId == kUmxVocalsRepoId) { + info.revision = kUmxVocalsRevision; + info.fileSha256[kUmxVocalsFile] = kUmxVocalsSha256; + } +} + std::optional HuggingFaceModelHub::modelInfo(const std::string& modelIdOrRepoId, const std::string& token) const { auto repoId = trim(modelIdOrRepoId); @@ -673,6 +680,8 @@ std::optional HuggingFaceModelHub::modelInfo(const std::string& mo } } + applyCuratedPin(info); + // Only repos with a GPU variant pay for the probe. const bool preferGpuBuild = info.repoId == kBsRoformerRepoId && bsRoformerGpuBuildQualifies(); info.primaryFile = primaryFileForRepo(info.repoId, info.files, &info.hasOnnx, preferGpuBuild); diff --git a/src/ai/HuggingFaceModelHub.h b/src/ai/HuggingFaceModelHub.h index 9d05229..26cde38 100644 --- a/src/ai/HuggingFaceModelHub.h +++ b/src/ai/HuggingFaceModelHub.h @@ -88,6 +88,10 @@ class HuggingFaceModelHub { const std::string& fallbackQuery); }; +// Overrides the live revision and file hash with the pinned values for curated +// repos that have them (Open-Unmix); other repos are left untouched. +void applyCuratedPin(HubModelInfo& info); + // Curated model catalogue (catalog-only discovery source). Exposed for // license-coverage tests and downstream hub tooling. std::vector curatedModelIds(); diff --git a/src/ai/UmxPack.h b/src/ai/UmxPack.h index 2fc122e..e18d97a 100644 --- a/src/ai/UmxPack.h +++ b/src/ai/UmxPack.h @@ -15,6 +15,9 @@ namespace automix::ai { // the mix phase is kept and the model's output is applied as a ratio mask. inline constexpr const char* kUmxVocalsRepoId = "MixDirective/open-unmix-umxhq-vocals-onnx"; inline constexpr const char* kUmxVocalsFile = "model.onnx"; +// Pinned so a later push to the repo cannot change what installs; bump both deliberately. +inline constexpr const char* kUmxVocalsRevision = "e06097d3193a4c3168b5ddd2a479a680b1176c16"; +inline constexpr const char* kUmxVocalsSha256 = "f27742bb52b24cb039614dc76d0764e279df3eb2a9d6daa82c6daee3369ec747"; inline constexpr const char* kUmxVocalsIntendedUse = "Vocal separation (Open-Unmix UMX-HQ, 36 MB, CPU friendly). Lower quality than BS-RoFormer but " "needs little memory. The graph produces vocals only; the instrumental stem is the residual " diff --git a/src/app/ui/MainLayout.cpp b/src/app/ui/MainLayout.cpp index 27931a8..bcaf3ef 100644 --- a/src/app/ui/MainLayout.cpp +++ b/src/app/ui/MainLayout.cpp @@ -1081,7 +1081,10 @@ void MainLayout::wireControlDeckCallbacks() { const bool enabled = controlDeck_->getTensorSeparationToggle().getToggleState(); sessionManager_.session().renderSettings.tensorSeparationEnabled = enabled; if (enabled) { - if (const auto warning = ai::vocalModelCpuWarning(); !warning.empty()) { + const auto separationPack = resolveActiveModelPackForTask("separation"); + const bool lightModelActive = separationPack.has_value() && separationPack->tensorContract.has_value() && + separationPack->tensorContract->engine == "open_unmix"; + if (const auto warning = ai::vocalModelCpuWarning(lightModelActive); !warning.empty()) { taskOrchestrator_->appendHistory("Vocal Model warning: " + juce::String(warning)); juce::AlertWindow::showMessageBoxAsync(juce::MessageBoxIconType::WarningIcon, "Vocal Model will be slow", juce::String(warning)); diff --git a/tests/unit/AiExtensionTests.cpp b/tests/unit/AiExtensionTests.cpp index e414a52..3342b2f 100644 --- a/tests/unit/AiExtensionTests.cpp +++ b/tests/unit/AiExtensionTests.cpp @@ -25,6 +25,7 @@ #include "ai/ModelManager.h" #include "ai/ModelPackLoader.h" #include "ai/ModelStrategy.h" +#include "ai/UmxPack.h" #include "app/controllers/ModelController.h" #include "ai/ModelLicensePolicy.h" #include "app/ui/HeroWaveform.h" @@ -1009,6 +1010,25 @@ TEST_CASE("Stem separator only claims model-backed separation when a model weigh REQUIRE(claim - gateEnd < 400); } +TEST_CASE("Curated pin overrides the live revision and hash for Open-Unmix only", "[ai][hub]") { + automix::ai::HubModelInfo umx; + umx.repoId = automix::ai::kUmxVocalsRepoId; + umx.revision = "main"; + umx.fileSha256[automix::ai::kUmxVocalsFile] = "bogus"; + automix::ai::applyCuratedPin(umx); + CHECK(umx.revision == automix::ai::kUmxVocalsRevision); + CHECK(umx.fileSha256.at(automix::ai::kUmxVocalsFile) == automix::ai::kUmxVocalsSha256); + + automix::ai::HubModelInfo other; + other.repoId = "xycld/BS-RoFormer-ONNX"; + other.revision = "main"; + other.fileSha256["model.onnx"] = "bogus"; + automix::ai::applyCuratedPin(other); + CHECK(other.revision == "main"); + CHECK(other.fileSha256.at("model.onnx") == "bogus"); + CHECK(other.fileSha256.size() == 1); +} + TEST_CASE("Curated hub includes ITO-Master mapped to mastering-assistant with three-asset pack metadata", "[ai][licensing]") { const auto curated = automix::ai::curatedModelIds(); REQUIRE(std::find(curated.begin(), curated.end(), "kramp/ito-master-onnx") != curated.end()); diff --git a/tests/unit/GpuBenchmarkTests.cpp b/tests/unit/GpuBenchmarkTests.cpp index de05795..da4d446 100644 --- a/tests/unit/GpuBenchmarkTests.cpp +++ b/tests/unit/GpuBenchmarkTests.cpp @@ -169,12 +169,13 @@ TEST_CASE("CoreML and ANE are opt-in for auto on low-memory Macs", "[gpu][tensor TEST_CASE("Vocal model warns about CPU only on low-memory Macs", "[gpu][coreml]") { using automix::ai::vocalModelCpuWarning; constexpr std::uint64_t GiB = 1024ull * 1024 * 1024; - CHECK_FALSE(vocalModelCpuWarning(8 * GiB, false, true).empty()); - CHECK(vocalModelCpuWarning(8 * GiB, false, true).find("8 GB") != std::string::npos); - CHECK(vocalModelCpuWarning(8 * GiB, true, true).empty()); // a GPU session is usable - CHECK(vocalModelCpuWarning(16 * GiB, false, true).empty()); // roomy - CHECK(vocalModelCpuWarning(8 * GiB, false, false).empty()); // not a Mac: no measurement behind it - CHECK(vocalModelCpuWarning(0, false, true).empty()); // unknown memory + CHECK_FALSE(vocalModelCpuWarning(8 * GiB, false, true, false).empty()); + CHECK(vocalModelCpuWarning(8 * GiB, false, true, false).find("8 GB") != std::string::npos); + CHECK(vocalModelCpuWarning(8 * GiB, true, true, false).empty()); // a GPU session is usable + CHECK(vocalModelCpuWarning(16 * GiB, false, true, false).empty()); // roomy + CHECK(vocalModelCpuWarning(8 * GiB, false, false, false).empty()); // not a Mac: no measurement behind it + CHECK(vocalModelCpuWarning(0, false, true, false).empty()); // unknown memory + CHECK(vocalModelCpuWarning(8 * GiB, false, true, true).empty()); // Open-Unmix already active } TEST_CASE("tensorProviderCandidates allow-list filtering", "[gpu][tensor]") { From f7aa7a1ae096ddcd24889b87174fe7fe8562af1f Mon Sep 17 00:00:00 2001 From: Soficis Date: Mon, 5 Oct 2026 15:26:28 -0500 Subject: [PATCH 60/68] fix(ui): working shortcuts dialog, unsaved-changes prompt, readable status badge, Default Streaming as the default master - The shortcuts dialog was never added to the desktop, so "?" and Ctrl+/ showed nothing and swallowed the next click; it now opens as a DialogWindow. Key names and the batch em dash were built with the integer String constructor ("8212"). - Closing with unsaved changes now asks Save / Don't Save / Cancel; the header shows "*" while the session differs from the last save or load. - Clear's confirmation no longer contradicts itself or shows an internal ticket id. - The status badge uses dark text where white failed contrast; READY replaces IDLE. - New sessions default to the Default Streaming master preset instead of Udio Optimized. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 1 + src/app/Main.cpp | 15 ++++ src/app/ui/ControlDeck.cpp | 2 +- src/app/ui/MainLayout.cpp | 105 ++++++++++++++++++++--- src/app/ui/MainLayout.h | 16 +++- src/app/ui/SessionManager.cpp | 15 ++++ src/app/ui/SessionManager.h | 8 ++ src/app/ui/TaskCenterPanel.cpp | 22 +++-- src/app/ui/TaskCenterPanel.h | 1 + src/domain/JsonSerialization.cpp | 2 +- src/domain/Session.h | 2 +- tests/unit/SessionSerializationTests.cpp | 21 ++++- tests/unit/TaskCenterPanelTests.cpp | 2 +- 13 files changed, 186 insertions(+), 26 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index cdb1919..a131914 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -551,6 +551,7 @@ if(BUILD_TESTING) tests/unit/ControllerTests.cpp tests/unit/TaskCenterPanelTests.cpp tests/unit/SessionSerializationTests.cpp + src/app/ui/SessionManager.cpp tests/unit/AudioIoTests.cpp tests/unit/AnalysisTests.cpp tests/unit/StemOriginSafetyTests.cpp diff --git a/src/app/Main.cpp b/src/app/Main.cpp index 54fe999..3609780 100644 --- a/src/app/Main.cpp +++ b/src/app/Main.cpp @@ -28,6 +28,13 @@ class MainWindow final : public juce::DocumentWindow { setVisible(true); } + bool requestQuit(std::function quitNow) { + if (auto* layout = dynamic_cast(getContentComponent())) + return layout->requestQuit(std::move(quitNow)); + quitNow(); + return true; + } + void closeButtonPressed() override { juce::JUCEApplication::getInstance()->systemRequestedQuit(); } @@ -50,6 +57,14 @@ class AutoMixMasterApplication final : public juce::JUCEApplication { automix::ai::OrtRuntime::instance().warmUpAsync(); } + void systemRequestedQuit() override { + if (mainWindow_ == nullptr) { + quit(); + return; + } + mainWindow_->requestQuit([] { juce::JUCEApplication::quit(); }); + } + void shutdown() override { mainWindow_.reset(); juce::LookAndFeel::setDefaultLookAndFeel(nullptr); diff --git a/src/app/ui/ControlDeck.cpp b/src/app/ui/ControlDeck.cpp index 90cd0bb..26e40c1 100644 --- a/src/app/ui/ControlDeck.cpp +++ b/src/app/ui/ControlDeck.cpp @@ -25,7 +25,7 @@ ControlDeck::ControlDeck() { // Tooltips importButton_.setTooltip("Import Stems (Ctrl+I)"); autoMixButton_.setTooltip("Auto Mix (Ctrl+M)"); - autoMasterButton_.setTooltip("Auto Master"); + autoMasterButton_.setTooltip("Auto Master (Ctrl+Shift+A)"); autoMixMasterButton_.setTooltip("One-click: Auto Mix -> Auto Master -> Export (Ctrl+Shift+M)"); batchButton_.setTooltip("Batch Process"); exportButton_.setTooltip("Export (Ctrl+E)"); diff --git a/src/app/ui/MainLayout.cpp b/src/app/ui/MainLayout.cpp index bcaf3ef..7947680 100644 --- a/src/app/ui/MainLayout.cpp +++ b/src/app/ui/MainLayout.cpp @@ -159,6 +159,8 @@ MainLayout::MainLayout() { // 6. Application command manager: keyboard shortcuts are dispatched here commandManager_.setFirstCommandTarget(this); commandManager_.registerAllCommandsForTarget(this); + + sessionManager_.markSaved(); } // ── Controllers factory ──────────────────────────────────────── @@ -497,16 +499,21 @@ void MainLayout::initControllers() { if (result.cancelled) { safe->taskOrchestrator_->finishTaskCancelled(ActiveTask::Session, "Session save cancelled"); + safe->finishPendingSave(false); return; } if (!result.success) { safe->taskOrchestrator_->finishTaskFailed(ActiveTask::Session, result.errorText.toStdString()); + safe->finishPendingSave(false); return; } safe->taskOrchestrator_->appendHistory("Session saved to " + juce::String(result.path)); - safe->headerBar_->setSessionName(juce::File(result.path).getFileNameWithoutExtension()); + safe->sessionManager_.markSaved(); + safe->sessionShownModified_ = false; + safe->setSessionDisplayName(juce::File(result.path).getFileNameWithoutExtension()); safe->taskOrchestrator_->finishTaskCompleted(ActiveTask::Session, "Session saved"); + safe->finishPendingSave(true); }); }; cb.onLoadComplete = [safe](SessionLoadResult result) { @@ -672,7 +679,7 @@ class ShortcutsDialog final : public juce::Component { } closeButton_.setButtonText("Close"); - closeButton_.onClick = [this] { exitModalState(0); }; + closeButton_.onClick = [this] { closeHostWindow(); }; addAndMakeVisible(closeButton_); } @@ -698,13 +705,18 @@ class ShortcutsDialog final : public juce::Component { bool keyPressed(const juce::KeyPress& key) override { if (key == juce::KeyPress::escapeKey) { - exitModalState(0); + closeHostWindow(); return true; } return juce::Component::keyPressed(key); } private: + void closeHostWindow() { + if (auto* dw = findParentComponentOfClass()) + dw->exitModalState(0); + } + static constexpr int kDialogWidth = 460; static constexpr int kDialogHeight = 560; @@ -785,11 +797,14 @@ void MainLayout::onTogglePlayPause() { } void MainLayout::showShortcutsDialog() { - auto* dialog = new ShortcutsDialog(shortcutTable()); - dialog->setAlwaysOnTop(true); - dialog->centreWithSize(dialog->getWidth(), dialog->getHeight()); - dialog->setVisible(true); - dialog->enterModalState(true, nullptr, true); // modal manager owns + deletes it + juce::DialogWindow::LaunchOptions options; + options.content.setOwned(new ShortcutsDialog(shortcutTable())); + options.dialogTitle = "Keyboard Shortcuts"; + options.dialogBackgroundColour = colour(colours::surface); + options.escapeKeyTriggersCloseButton = true; + options.useNativeTitleBar = true; + options.resizable = false; + options.launchAsync(); } // ───────────────────────────────────────────────────────────────── @@ -799,6 +814,15 @@ void MainLayout::showShortcutsDialog() { void MainLayout::timerCallback() { updateTransportDisplay(); + if (++modifiedCheckTicks_ >= 30) { + modifiedCheckTicks_ = 0; + const bool modified = sessionManager_.isModified(); + if (modified != sessionShownModified_) { + sessionShownModified_ = modified; + refreshSessionTitle(); + } + } + // Reconcile the transport bar with the atomic play state (covers end-of-track // auto-stop, which is realtime-only and posts no change message). const bool playing = transportController_.isPlaying(); @@ -971,10 +995,10 @@ void MainLayout::onClearTracks() { return; // Nothing imported; nothing to clear. juce::AlertWindow::showOkCancelBox( - juce::MessageBoxIconType::WarningIcon, "Clear imported tracks", - "Clear " + juce::String(numTracks) + - " imported tracks? This cannot be undone \xe2\x80\x94 use Undo after T4.1.", - "Clear", "Cancel", this, + juce::MessageBoxIconType::WarningIcon, "Remove all stems?", + "Remove all " + juce::String(numTracks) + (numTracks == 1 ? " stem" : " stems") + + " from this session? This can't be undone.", + "Remove stems", "Cancel", this, juce::ModalCallbackFunction::create([safe = safeAsync(this), numTracks](int result) { if (safe != nullptr && confirmClear(numTracks, result == 1)) safe->performClearTracks(); @@ -1538,9 +1562,13 @@ void MainLayout::startBatchVerification(const std::string& outputFolder) { // Action: Save Session // ───────────────────────────────────────────────────────────────── -void MainLayout::onSaveSession() { +void MainLayout::onSaveSession(std::function done) { + finishPendingSave(false); + pendingSaveCompletion_ = std::move(done); + if (taskOrchestrator_->isTaskRunning()) { taskOrchestrator_->setStatus("Busy", "A task is already running"); + finishPendingSave(false); return; } @@ -1554,11 +1582,13 @@ void MainLayout::onSaveSession() { const auto selected = chooser.getResult(); if (selected == juce::File()) { saveSessionChooser_.reset(); + finishPendingSave(false); return; } if (!taskOrchestrator_->beginTask(ActiveTask::Session, "Saving session", "", "Session save started")) { saveSessionChooser_.reset(); + finishPendingSave(false); return; } @@ -1570,6 +1600,51 @@ void MainLayout::onSaveSession() { }); } +void MainLayout::finishPendingSave(bool success) { + if (auto done = std::move(pendingSaveCompletion_)) { + pendingSaveCompletion_ = nullptr; + done(success); + } +} + +void MainLayout::setSessionDisplayName(const juce::String& name) { + sessionDisplayName_ = name; + refreshSessionTitle(); +} + +void MainLayout::refreshSessionTitle() { + headerBar_->setSessionName(sessionDisplayName_ + (sessionShownModified_ ? " *" : "")); +} + +bool MainLayout::requestQuit(std::function quitNow) { + if (!sessionManager_.isModified()) { + quitNow(); + return true; + } + if (quitPromptOpen_) + return false; + quitPromptOpen_ = true; + + juce::AlertWindow::showYesNoCancelBox( + juce::MessageBoxIconType::QuestionIcon, "Save changes?", + "Save changes to " + sessionDisplayName_ + " before closing?", + "Save", "Don't Save", "Cancel", this, + juce::ModalCallbackFunction::create([safe = safeAsync(this), quitNow](int result) { + if (safe == nullptr) + return; + safe->quitPromptOpen_ = false; + if (result == 1) { + safe->onSaveSession([quitNow](bool saved) { + if (saved) + quitNow(); + }); + } else if (result == 2) { + quitNow(); + } + })); + return false; +} + // ───────────────────────────────────────────────────────────────── // Action: Load Session // ───────────────────────────────────────────────────────────────── @@ -1822,7 +1897,8 @@ void MainLayout::updateMeterPanel(const automaster::MasteringReport& report) { void MainLayout::applyLoadedSession(domain::Session loadedSession, const juce::String& sourcePath) { sessionManager_.replaceSession(std::move(loadedSession)); const auto& session = sessionManager_.session(); - headerBar_->setSessionName(juce::File(sourcePath).getFileNameWithoutExtension()); + sessionShownModified_ = false; + setSessionDisplayName(juce::File(sourcePath).getFileNameWithoutExtension()); updateStemPanelFromSession(controlDeck_->getStemPanel(), session); transportBar_->setLoopEnabled(session.timeline.loopEnabled); heroWaveform_->setZoom(session.timeline.zoom, 0.5); @@ -1833,6 +1909,7 @@ void MainLayout::applyLoadedSession(domain::Session loadedSession, const juce::S refreshProjectProfiles(); applySessionUiSelections(); syncSessionUiSelections(); + sessionManager_.markSaved(); taskOrchestrator_->appendHistory("Session loaded: " + sourcePath); rebuildPreview(); } diff --git a/src/app/ui/MainLayout.h b/src/app/ui/MainLayout.h index 77a4431..ae1c5cd 100644 --- a/src/app/ui/MainLayout.h +++ b/src/app/ui/MainLayout.h @@ -145,6 +145,10 @@ class MainLayout final : public juce::Component, void resized() override; bool keyPressed(const juce::KeyPress& key) override; + /// Runs quitNow immediately when the session is unchanged (returns true); + /// otherwise asks Save / Don't Save / Cancel and returns false. + bool requestQuit(std::function quitNow); + private: // Timer / Listener overrides void timerCallback() override; @@ -172,7 +176,10 @@ class MainLayout final : public juce::Component, void onAutoMixMaster(); // Pipeline: Mix -> Master -> Export void onBatch(); void onExport(); - void onSaveSession(); + void onSaveSession(std::function done = {}); + void finishPendingSave(bool success); + void refreshSessionTitle(); + void setSessionDisplayName(const juce::String& name); void onLoadSession(); void onModelsDialog(); void onSettings(); @@ -257,6 +264,13 @@ class MainLayout final : public juce::Component, std::unique_ptr previewManager_; SessionManager sessionManager_; + // Unsaved-changes tracking (message thread only). + juce::String sessionDisplayName_{"Untitled Session"}; + bool sessionShownModified_ = false; + int modifiedCheckTicks_ = 0; + bool quitPromptOpen_ = false; + std::function pendingSaveCompletion_; + // State std::vector analysisEntries_; std::vector rendererInfos_; diff --git a/src/app/ui/SessionManager.cpp b/src/app/ui/SessionManager.cpp index 9435f32..bf186e7 100644 --- a/src/app/ui/SessionManager.cpp +++ b/src/app/ui/SessionManager.cpp @@ -1,5 +1,7 @@ #include "app/ui/SessionManager.h" +#include "domain/JsonSerialization.h" + namespace automix::app { domain::Session& SessionManager::session() { @@ -14,4 +16,17 @@ void SessionManager::replaceSession(domain::Session session) { session_ = std::move(session); } +std::string SessionManager::snapshot() const { + const domain::Json json = session_; + return json.dump(); +} + +void SessionManager::markSaved() { + savedSnapshot_ = snapshot(); +} + +bool SessionManager::isModified() const { + return snapshot() != savedSnapshot_; +} + } // namespace automix::app diff --git a/src/app/ui/SessionManager.h b/src/app/ui/SessionManager.h index 536e022..f7e3e0a 100644 --- a/src/app/ui/SessionManager.h +++ b/src/app/ui/SessionManager.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include "domain/Session.h" @@ -15,8 +16,15 @@ class SessionManager { void replaceSession(domain::Session session); + /// Records the current session as the saved baseline for isModified(). + void markSaved(); + bool isModified() const; + private: + std::string snapshot() const; + domain::Session session_; + std::string savedSnapshot_; }; } // namespace automix::app diff --git a/src/app/ui/TaskCenterPanel.cpp b/src/app/ui/TaskCenterPanel.cpp index 4b55df8..04061b1 100644 --- a/src/app/ui/TaskCenterPanel.cpp +++ b/src/app/ui/TaskCenterPanel.cpp @@ -11,16 +11,16 @@ namespace automix::app { using namespace theme; TaskCenterPanel::TaskCenterPanel() : progressBar_(progressValue_), batchProgressBar_(batchProgressValue_) { - taskLabel_.setText("Ready", juce::dontSendNotification); + taskLabel_.setText("", juce::dontSendNotification); taskLabel_.setFont(typography::body()); taskLabel_.setColour(juce::Label::textColourId, colour(colours::text)); taskLabel_.setJustificationType(juce::Justification::centredLeft); - stateBadge_.setText("IDLE", juce::dontSendNotification); + stateBadge_.setText("READY", juce::dontSendNotification); stateBadge_.setFont(typography::caption()); stateBadge_.setJustificationType(juce::Justification::centred); stateBadge_.setColour(juce::Label::backgroundColourId, stateColour(TaskState::Idle)); - stateBadge_.setColour(juce::Label::textColourId, juce::Colours::white); + stateBadge_.setColour(juce::Label::textColourId, stateTextColour(TaskState::Idle)); progressLabel_.setText("0%", juce::dontSendNotification); progressLabel_.setFont(typography::caption()); @@ -279,7 +279,7 @@ void TaskCenterPanel::updateBatchSummary() { if (total == 0) { // No batch items: em dash "—". - etaLabel_.setText(juce::String(static_cast(0x2014)), juce::dontSendNotification); + etaLabel_.setText(juce::String::charToString(static_cast(0x2014)), juce::dontSendNotification); } else { const int currentIndex = static_cast(batchDetail_.itemIndex) + 1; if (currentIndex >= static_cast(total)) { @@ -303,6 +303,7 @@ void TaskCenterPanel::setTaskState(TaskState state) { currentState_ = state; stateBadge_.setText(juce::String(stateLabel(state)), juce::dontSendNotification); stateBadge_.setColour(juce::Label::backgroundColourId, stateColour(state)); + stateBadge_.setColour(juce::Label::textColourId, stateTextColour(state)); stateBadge_.repaint(); } @@ -369,9 +370,20 @@ juce::Colour TaskCenterPanel::stateColour(TaskState state) { return colour(colours::textMuted); } +juce::Colour TaskCenterPanel::stateTextColour(TaskState state) { + switch (state) { + case TaskState::Idle: + case TaskState::Cancelled: + case TaskState::Completed: return colour(colours::background); + case TaskState::Running: + case TaskState::Failed: return juce::Colours::white; + } + return colour(colours::background); +} + const char* TaskCenterPanel::stateLabel(TaskState state) { switch (state) { - case TaskState::Idle: return "IDLE"; + case TaskState::Idle: return "READY"; case TaskState::Running: return "RUNNING"; case TaskState::Cancelled: return "CANCELLED"; case TaskState::Completed: return "COMPLETED"; diff --git a/src/app/ui/TaskCenterPanel.h b/src/app/ui/TaskCenterPanel.h index bf3a79b..79b5d15 100644 --- a/src/app/ui/TaskCenterPanel.h +++ b/src/app/ui/TaskCenterPanel.h @@ -67,6 +67,7 @@ class TaskCenterPanel final : public juce::Component { static constexpr int kMaxVisibleQueueItems = 8; static juce::Colour stateColour(TaskState state); + static juce::Colour stateTextColour(TaskState state); static const char* stateLabel(TaskState state); void drawQueueItem(juce::Graphics& g, juce::Rectangle bounds, const BatchQueueItem& item, int index); static juce::String formatEta(const juce::RelativeTime& eta); diff --git a/src/domain/JsonSerialization.cpp b/src/domain/JsonSerialization.cpp index e6c73d9..4aaaa51 100644 --- a/src/domain/JsonSerialization.cpp +++ b/src/domain/JsonSerialization.cpp @@ -311,7 +311,7 @@ void from_json(const Json& j, Session& value) { value.residualBlend = std::clamp(j.value("residualBlend", 0.0), 0.0, 10.0); value.aiStemsEnabled = j.value("aiStemsEnabled", false); value.batchRecursiveEnabled = j.value("batchRecursiveEnabled", false); - value.selectedMasterPreset = masterPresetFromString(j.value("selectedMasterPreset", "udio_optimized")); + value.selectedMasterPreset = masterPresetFromString(j.value("selectedMasterPreset", "default_streaming")); value.selectedPlatformPreset = masterPresetFromString(j.value("selectedPlatformPreset", "youtube")); value.stems = j.value("stems", std::vector{}); value.buses = j.value("buses", std::vector{}); diff --git a/src/domain/Session.h b/src/domain/Session.h index e47898a..6ffda79 100644 --- a/src/domain/Session.h +++ b/src/domain/Session.h @@ -27,7 +27,7 @@ struct Session { double residualBlend = 0.0; bool aiStemsEnabled = false; bool batchRecursiveEnabled = false; - MasterPreset selectedMasterPreset = MasterPreset::UdioOptimized; + MasterPreset selectedMasterPreset = MasterPreset::DefaultStreaming; MasterPreset selectedPlatformPreset = MasterPreset::YouTube; std::vector stems; std::vector buses; diff --git a/tests/unit/SessionSerializationTests.cpp b/tests/unit/SessionSerializationTests.cpp index 2763d43..4654d2f 100644 --- a/tests/unit/SessionSerializationTests.cpp +++ b/tests/unit/SessionSerializationTests.cpp @@ -3,6 +3,7 @@ #include #include +#include "app/ui/SessionManager.h" #include "domain/JsonSerialization.h" #include "engine/SessionRepository.h" @@ -105,7 +106,7 @@ TEST_CASE("Session deserialization handles missing optional fields", "[session]" REQUIRE(decoded.residualBlend == Catch::Approx(0.0)); REQUIRE(decoded.aiStemsEnabled == false); REQUIRE(decoded.batchRecursiveEnabled == false); - REQUIRE(decoded.selectedMasterPreset == automix::domain::MasterPreset::UdioOptimized); + REQUIRE(decoded.selectedMasterPreset == automix::domain::MasterPreset::DefaultStreaming); REQUIRE(decoded.selectedPlatformPreset == automix::domain::MasterPreset::YouTube); REQUIRE(decoded.renderSettings.blockSize == 1024); REQUIRE(decoded.renderSettings.outputFormat == "auto"); @@ -179,4 +180,20 @@ TEST_CASE("Sessions from before PhaseLimiter became opt-in load with BuiltIn", " REQUIRE(automix::domain::Session{}.schemaVersion == 3); REQUIRE(automix::domain::RenderSettings{}.rendererName == "BuiltIn"); -} \ No newline at end of file +} +TEST_CASE("Default session uses the Default Streaming master preset", "[session]") { + const automix::domain::Session session; + REQUIRE(session.selectedMasterPreset == automix::domain::MasterPreset::DefaultStreaming); +} + +TEST_CASE("SessionManager tracks unsaved changes", "[session]") { + automix::app::SessionManager manager; + manager.markSaved(); + REQUIRE_FALSE(manager.isModified()); + + manager.session().selectedMasterPreset = automix::domain::MasterPreset::Broadcast; + REQUIRE(manager.isModified()); + + manager.markSaved(); + REQUIRE_FALSE(manager.isModified()); +} diff --git a/tests/unit/TaskCenterPanelTests.cpp b/tests/unit/TaskCenterPanelTests.cpp index 0202658..7d57270 100644 --- a/tests/unit/TaskCenterPanelTests.cpp +++ b/tests/unit/TaskCenterPanelTests.cpp @@ -80,7 +80,7 @@ TEST_CASE("TaskCenterPanel batch ETA row state transitions", "[ui][taskcenter][e juce::ScopedJuceInitialiser_GUI juceInit; automix::app::TaskCenterPanel panel; - const juce::String emDash(static_cast(0x2014)); + const auto emDash = juce::String::charToString(static_cast(0x2014)); SECTION("no batch items shows dash") { REQUIRE(panel.batchEtaText() == emDash); From fa47e5bdb8b3f63d0d6e444bed52b0f8920c9ea1 Mon Sep 17 00:00:00 2001 From: Soficis Date: Mon, 5 Oct 2026 15:32:46 -0500 Subject: [PATCH 61/68] feat(ui): clear visual hierarchy, guided empty state, and plain-language separation controls - Button variants (primary/secondary/quiet/danger): Import is the one primary action until stems exist, then Mix + Master; Save/Load/Models/Settings are quiet; Clear is styled as destructive. - Actions that need stems are disabled until stems are imported; transport too. - The empty waveform is a bordered drop zone that highlights on drag and opens Import on click. - Vocal Model and the model name appear only when AI Stem Separation is on; model names are plain language instead of Hugging Face repo ids. - Renderer and Active chain move into Advanced; combos widened; tooltips for Master, Platform, Renderer and Mode; accessibility titles for combos, sliders and transport. Co-Authored-By: Claude Opus 5.5 --- src/app/style/AutoMixLookAndFeel.cpp | 63 ++++++++++---- src/app/style/AutoMixLookAndFeel.h | 15 ++++ src/app/ui/ControlDeck.cpp | 122 ++++++++++++++++----------- src/app/ui/ControlDeck.h | 8 +- src/app/ui/GlowMeters.cpp | 1 + src/app/ui/HeaderBar.cpp | 6 ++ src/app/ui/HeroWaveform.cpp | 37 ++++++-- src/app/ui/HeroWaveform.h | 3 + src/app/ui/MainLayout.cpp | 46 ++++++++-- src/app/ui/MainLayout.h | 1 + src/app/ui/MainLayoutInternal.h | 15 ++++ src/app/ui/TaskCenterPanel.cpp | 4 + src/app/ui/TransportBar.cpp | 23 +++++ src/app/ui/TransportBar.h | 2 + 14 files changed, 266 insertions(+), 80 deletions(-) diff --git a/src/app/style/AutoMixLookAndFeel.cpp b/src/app/style/AutoMixLookAndFeel.cpp index 10bb576..500c24e 100644 --- a/src/app/style/AutoMixLookAndFeel.cpp +++ b/src/app/style/AutoMixLookAndFeel.cpp @@ -68,28 +68,54 @@ AutoMixLookAndFeel::AutoMixLookAndFeel() { // Button // ───────────────────────────────────────────────────────────────── +namespace { +juce::String variantOf(const juce::Button& button) { + return button.getProperties()[buttonVariant::key].toString(); +} +} // namespace + void AutoMixLookAndFeel::drawButtonBackground(juce::Graphics& g, juce::Button& button, const juce::Colour& /*backgroundColour*/, bool shouldDrawButtonAsHighlighted, bool shouldDrawButtonAsDown) { auto bounds = button.getLocalBounds().toFloat().reduced(0.5f); auto cornerSize = metrics::cornerRadius; - - juce::Colour bg; - if (!button.isEnabled()) { - bg = colour(colours::surfaceBorder); - } else if (shouldDrawButtonAsDown) { - bg = colour(colours::primaryPressed); - } else if (shouldDrawButtonAsHighlighted) { - bg = colour(colours::primary).interpolatedWith(colour(colours::primaryHover), 0.6f); - } else if (button.getToggleState()) { - bg = colour(colours::primary); + const auto variant = variantOf(button); + const bool enabled = button.isEnabled(); + + if (variant == buttonVariant::secondary) { + juce::Colour bg = colour(colours::surfaceLight); + if (enabled && shouldDrawButtonAsDown) + bg = bg.darker(0.1f); + else if (enabled && shouldDrawButtonAsHighlighted) + bg = bg.brighter(0.1f); + g.setColour(bg); + g.fillRoundedRectangle(bounds, cornerSize); + g.setColour(colour(colours::surfaceBorder)); + g.drawRoundedRectangle(bounds, cornerSize, 1.0f); + } else if (variant == buttonVariant::quiet || variant == buttonVariant::danger) { + if (enabled && (shouldDrawButtonAsHighlighted || shouldDrawButtonAsDown)) { + g.setColour(variant == buttonVariant::quiet ? colour(colours::surfaceLight) + : colour(colours::error).withAlpha(0.15f)); + g.fillRoundedRectangle(bounds, cornerSize); + } } else { - bg = colour(colours::primary); - } + juce::Colour bg; + if (!enabled) { + bg = colour(colours::surfaceBorder); + } else if (shouldDrawButtonAsDown) { + bg = colour(colours::primaryPressed); + } else if (shouldDrawButtonAsHighlighted) { + bg = colour(colours::primary).interpolatedWith(colour(colours::primaryHover), 0.6f); + } else if (button.getToggleState()) { + bg = colour(colours::primary); + } else { + bg = colour(colours::primary); + } - g.setColour(bg); - g.fillRoundedRectangle(bounds, cornerSize); + g.setColour(bg); + g.fillRoundedRectangle(bounds, cornerSize); + } // Focus ring drawFocusRing(g, button); @@ -101,7 +127,14 @@ void AutoMixLookAndFeel::drawButtonText(juce::Graphics& g, juce::TextButton& but auto font = getTextButtonFont(button, button.getHeight()); g.setFont(font); - auto textColour = button.isEnabled() ? colour(colours::text) : colour(colours::textDisabled); + const auto variant = variantOf(button); + juce::Colour textColour = colour(colours::text); + if (variant == buttonVariant::quiet) + textColour = colour(colours::primaryHover); + else if (variant == buttonVariant::danger) + textColour = colour(colours::error); + if (!button.isEnabled()) + textColour = colour(colours::textDisabled); g.setColour(textColour); diff --git a/src/app/style/AutoMixLookAndFeel.h b/src/app/style/AutoMixLookAndFeel.h index b6c5fde..359268c 100644 --- a/src/app/style/AutoMixLookAndFeel.h +++ b/src/app/style/AutoMixLookAndFeel.h @@ -6,6 +6,21 @@ namespace automix::app { +/// Button visual variants, selected via the "variant" component property. +/// No property (or an unknown value) means primary. +namespace buttonVariant { +inline const juce::Identifier key{"variant"}; +inline constexpr const char* primary = "primary"; +inline constexpr const char* secondary = "secondary"; +inline constexpr const char* quiet = "quiet"; +inline constexpr const char* danger = "danger"; +} // namespace buttonVariant + +inline void setButtonVariant(juce::Button& button, const char* variant) { + button.getProperties().set(buttonVariant::key, juce::String(variant)); + button.repaint(); +} + /// Custom LookAndFeel for AutoMixMaster. /// Applies the dark audio-app theme defined in Theme.h to all standard JUCE widgets. class AutoMixLookAndFeel final : public juce::LookAndFeel_V4 { diff --git a/src/app/ui/ControlDeck.cpp b/src/app/ui/ControlDeck.cpp index 26e40c1..63ce2e3 100644 --- a/src/app/ui/ControlDeck.cpp +++ b/src/app/ui/ControlDeck.cpp @@ -1,5 +1,6 @@ #include "app/ui/ControlDeck.h" +#include "app/style/AutoMixLookAndFeel.h" #include "app/ui/GlowMeters.h" #include "app/ui/StemPanel.h" @@ -35,33 +36,30 @@ ControlDeck::ControlDeck() { "instrumental that is the residual (mix - vocals), not a second separation. Takes minutes on CPU; falls back to " "the standard separator if the pack cannot run."); tensorSeparationToggle_.setEnabled(false); + rendererBox_.setTooltip("Engine that renders the exported file. BuiltIn needs no external tools."); + exportModeBox_.setTooltip("Final renders at full quality. Quick Preview renders faster for checking the result."); + masterPresetBox_.setTooltip("Mastering target. Default Streaming suits Spotify, Apple Music and YouTube."); + platformPresetBox_.setTooltip("Loudness target for the platform you will publish to."); + + // Accessibility titles (match the visible labels) + rendererBox_.setTitle("Renderer"); + profileBox_.setTitle("Profile"); + masterPresetBox_.setTitle("Master preset"); + platformPresetBox_.setTitle("Platform"); + exportFormatBox_.setTitle("Export format"); + exportModeBox_.setTitle("Render mode"); + rendererChainModeBox_.setTitle("Chain"); + residualBlendSlider_.setTitle("Residual blend"); batchRecursiveToggle_.setTooltip("Include subfolders when scanning batch input"); rendererChainToggle_.setTooltip("Run renderers in a staged chain"); rendererChainModeBox_.setTooltip("Renderer chain strategy"); residualBlendSlider_.setTooltip("Control residual audio blend"); - // Button visual hierarchy: primary > secondary > tertiary - // Primary — Mix+Master is the hero action; filled brand colour. - autoMixMasterButton_.setColour(juce::TextButton::buttonColourId, colour(colours::primary)); - autoMixMasterButton_.setColour(juce::TextButton::buttonOnColourId, colour(colours::primaryPressed)); - autoMixMasterButton_.setColour(juce::TextButton::textColourOffId, juce::Colours::white); - autoMixMasterButton_.setColour(juce::TextButton::textColourOnId, juce::Colours::white); - - // Secondary — Import and Export are the gateway/output actions; tinted text on dark surface. - for (juce::TextButton* btn : {&importButton_, &exportButton_}) { - btn->setColour(juce::TextButton::buttonColourId, colour(colours::surface)); - btn->setColour(juce::TextButton::buttonOnColourId, colour(colours::surfaceLight)); - btn->setColour(juce::TextButton::textColourOffId, colour(colours::primary)); - btn->setColour(juce::TextButton::textColourOnId, colour(colours::primaryHover)); - } - - // Tertiary — AutoMix, AutoMaster, Batch are advanced ops; visually receded. - for (juce::TextButton* btn : {&autoMixButton_, &autoMasterButton_, &batchButton_}) { - btn->setColour(juce::TextButton::buttonColourId, colour(colours::surface)); - btn->setColour(juce::TextButton::buttonOnColourId, colour(colours::surfaceLight)); - btn->setColour(juce::TextButton::textColourOffId, colour(colours::textMuted)); - btn->setColour(juce::TextButton::textColourOnId, colour(colours::text)); - } + // Button visual hierarchy: primary > secondary > quiet (see buttonVariant in AutoMixLookAndFeel.h). + setButtonVariant(autoMixMasterButton_, buttonVariant::primary); + for (juce::TextButton* btn : {&autoMixButton_, &autoMasterButton_, &batchButton_, &exportButton_}) + setButtonVariant(*btn, buttonVariant::secondary); + setButtonVariant(importButton_, buttonVariant::primary); // Keyboard focus on action buttons importButton_.setWantsKeyboardFocus(true); @@ -123,14 +121,14 @@ ControlDeck::ControlDeck() { rendererChainPreviewLabel_.setFont(typography::caption()); rendererChainPreviewLabel_.setColour(juce::Label::textColourId, colour(colours::textMuted)); rendererChainPreviewLabel_.setJustificationType(juce::Justification::centredLeft); - addAndMakeVisible(rendererChainPreviewLabel_); + addChildComponent(rendererChainPreviewLabel_); blendLabel_.setFont(typography::caption()); blendLabel_.setColour(juce::Label::textColourId, colour(colours::textMuted)); blendLabel_.setJustificationType(juce::Justification::centredLeft); separationModelStatusLabel_.setFont(typography::caption()); separationModelStatusLabel_.setColour(juce::Label::textColourId, colour(colours::warning)); separationModelStatusLabel_.setJustificationType(juce::Justification::centredLeft); - separationModelStatusLabel_.setText("Separation model: none", juce::dontSendNotification); + separationModelStatusLabel_.setText("Model: none installed", juce::dontSendNotification); residualBlendSlider_.setSliderStyle(juce::Slider::LinearHorizontal); residualBlendSlider_.setTextBoxStyle(juce::Slider::TextBoxRight, false, 48, 20); @@ -139,10 +137,7 @@ ControlDeck::ControlDeck() { rendererChainModeBox_.setEnabled(false); // Advanced disclosure toggle - advancedToggle_.setColour(juce::TextButton::buttonColourId, colour(colours::surface)); - advancedToggle_.setColour(juce::TextButton::buttonOnColourId, colour(colours::surfaceLight)); - advancedToggle_.setColour(juce::TextButton::textColourOffId, colour(colours::textMuted)); - advancedToggle_.setColour(juce::TextButton::textColourOnId, colour(colours::text)); + setButtonVariant(advancedToggle_, buttonVariant::quiet); advancedToggle_.onClick = [this] { advancedExpanded_ = !advancedExpanded_; advancedToggle_.setButtonText(advancedExpanded_ ? "v Advanced" : "> Advanced"); @@ -151,8 +146,6 @@ ControlDeck::ControlDeck() { }; // Always-visible settings - addAndMakeVisible(rendererLabel_); - addAndMakeVisible(rendererBox_); addAndMakeVisible(profileLabel_); addAndMakeVisible(profileBox_); addAndMakeVisible(masterPresetLabel_); @@ -160,11 +153,13 @@ ControlDeck::ControlDeck() { addAndMakeVisible(platformPresetLabel_); addAndMakeVisible(platformPresetBox_); addAndMakeVisible(separatedStemsToggle_); - addAndMakeVisible(tensorSeparationToggle_); - addAndMakeVisible(separationModelStatusLabel_); + addChildComponent(tensorSeparationToggle_); + addChildComponent(separationModelStatusLabel_); addAndMakeVisible(advancedToggle_); // Advanced settings — hidden by default, revealed by advancedToggle_ + addChildComponent(rendererLabel_); + addChildComponent(rendererBox_); addChildComponent(exportFormatLabel_); addChildComponent(exportFormatBox_); addChildComponent(exportModeLabel_); @@ -175,6 +170,8 @@ ControlDeck::ControlDeck() { addChildComponent(blendLabel_); addChildComponent(residualBlendSlider_); addChildComponent(batchRecursiveToggle_); + + setHasStems(false); } ControlDeck::~ControlDeck() = default; @@ -216,26 +213,25 @@ void ControlDeck::resized() { centerArea.removeFromTop(spacing::gapSmall); - // Always-visible context rows: Renderer/Profile and Master/Platform + // Always-visible context rows: Profile/Master/Platform, then the separation row auto settingsRow1 = centerArea.removeFromTop(28); - rendererLabel_.setBounds(settingsRow1.removeFromLeft(64).reduced(1)); - rendererBox_.setBounds(settingsRow1.removeFromLeft(160).reduced(1)); profileLabel_.setBounds(settingsRow1.removeFromLeft(50).reduced(1)); - profileBox_.setBounds(settingsRow1.removeFromLeft(140).reduced(1)); - - auto settingsRow2 = centerArea.removeFromTop(28); - masterPresetLabel_.setBounds(settingsRow2.removeFromLeft(50).reduced(1)); - masterPresetBox_.setBounds(settingsRow2.removeFromLeft(140).reduced(1)); - platformPresetLabel_.setBounds(settingsRow2.removeFromLeft(64).reduced(1)); - platformPresetBox_.setBounds(settingsRow2.removeFromLeft(140).reduced(1)); - settingsRow2.removeFromLeft(spacing::gapSmall); - separatedStemsToggle_.setBounds(settingsRow2.removeFromLeft(180).reduced(1)); - tensorSeparationToggle_.setBounds(settingsRow2.removeFromLeft(120).reduced(1)); - settingsRow2.removeFromLeft(spacing::gapSmall); - separationModelStatusLabel_.setBounds(settingsRow2.reduced(1)); - - auto chainPreviewRow = centerArea.removeFromTop(24); - rendererChainPreviewLabel_.setBounds(chainPreviewRow.removeFromLeft(640).reduced(1)); + profileBox_.setBounds(settingsRow1.removeFromLeft(200).reduced(1)); + masterPresetLabel_.setBounds(settingsRow1.removeFromLeft(50).reduced(1)); + masterPresetBox_.setBounds(settingsRow1.removeFromLeft(170).reduced(1)); + platformPresetLabel_.setBounds(settingsRow1.removeFromLeft(64).reduced(1)); + platformPresetBox_.setBounds(settingsRow1.removeFromLeft(150).reduced(1)); + + auto separationRow = centerArea.removeFromTop(28); + separatedStemsToggle_.setBounds(separationRow.removeFromLeft(180).reduced(1)); + tensorSeparationToggle_.setVisible(separationControlsVisible_); + separationModelStatusLabel_.setVisible(separationControlsVisible_); + if (separationControlsVisible_) { + separationRow.removeFromLeft(16); + tensorSeparationToggle_.setBounds(separationRow.removeFromLeft(120).reduced(1)); + separationRow.removeFromLeft(spacing::gapSmall); + separationModelStatusLabel_.setBounds(separationRow.reduced(1)); + } centerArea.removeFromTop(spacing::gapSmall); // Advanced disclosure row @@ -243,6 +239,9 @@ void ControlDeck::resized() { centerArea.removeFromTop(spacing::gapSmall); // Advanced settings — shown only when expanded + rendererLabel_.setVisible(advancedExpanded_); + rendererBox_.setVisible(advancedExpanded_); + rendererChainPreviewLabel_.setVisible(advancedExpanded_); exportFormatLabel_.setVisible(advancedExpanded_); exportFormatBox_.setVisible(advancedExpanded_); exportModeLabel_.setVisible(advancedExpanded_); @@ -255,6 +254,12 @@ void ControlDeck::resized() { batchRecursiveToggle_.setVisible(advancedExpanded_); if (advancedExpanded_) { + auto rendererRow = centerArea.removeFromTop(28); + rendererLabel_.setBounds(rendererRow.removeFromLeft(64).reduced(1)); + rendererBox_.setBounds(rendererRow.removeFromLeft(160).reduced(1)); + rendererRow.removeFromLeft(spacing::gapSmall); + rendererChainPreviewLabel_.setBounds(rendererRow.removeFromLeft(640).reduced(1)); + auto settingsRow3 = centerArea.removeFromTop(28); exportFormatLabel_.setBounds(settingsRow3.removeFromLeft(50).reduced(1)); exportFormatBox_.setBounds(settingsRow3.removeFromLeft(100).reduced(1)); @@ -282,7 +287,22 @@ void ControlDeck::setSeparationModelStatus(const juce::String& text, const bool separationModelStatusLabel_.setText(text, juce::dontSendNotification); separationModelStatusLabel_.setColour( juce::Label::textColourId, - ready ? colour(colours::success) : colour(colours::warning)); + ready ? colour(colours::textMuted) : colour(colours::warning)); +} + +void ControlDeck::setHasStems(const bool hasStems) { + autoMixButton_.setEnabled(hasStems); + autoMasterButton_.setEnabled(hasStems); + autoMixMasterButton_.setEnabled(hasStems); + exportButton_.setEnabled(hasStems); + batchButton_.setEnabled(true); + importButton_.setEnabled(true); + setButtonVariant(importButton_, hasStems ? buttonVariant::secondary : buttonVariant::primary); +} + +void ControlDeck::setSeparationControlsVisible(const bool visible) { + separationControlsVisible_ = visible; + resized(); } } // namespace automix::app diff --git a/src/app/ui/ControlDeck.h b/src/app/ui/ControlDeck.h index 8f5b891..ada2f5a 100644 --- a/src/app/ui/ControlDeck.h +++ b/src/app/ui/ControlDeck.h @@ -46,6 +46,11 @@ class ControlDeck final : public juce::Component { juce::ToggleButton& getBatchRecursiveToggle() { return batchRecursiveToggle_; } void setRendererChainPreviewText(const juce::String& text); void setSeparationModelStatus(const juce::String& text, bool ready); + /// Enables Auto Mix, Auto Master, Mix + Master and Export only when stems exist; Import becomes the + /// primary action while the session is empty. + void setHasStems(bool hasStems); + /// Progressive disclosure: the Vocal Model toggle and model label are shown only while AI Stem Separation is on. + void setSeparationControlsVisible(bool visible); private: std::unique_ptr stemPanel_; @@ -80,12 +85,13 @@ class ControlDeck final : public juce::Component { juce::Slider residualBlendSlider_; juce::ToggleButton separatedStemsToggle_{"AI Stem Separation"}; juce::ToggleButton tensorSeparationToggle_{"Vocal Model"}; - juce::Label separationModelStatusLabel_{"", "Separation model: none"}; + juce::Label separationModelStatusLabel_{"", "Model: none installed"}; juce::ToggleButton batchRecursiveToggle_{"Recursive Batch"}; // Advanced section toggle juce::TextButton advancedToggle_{"> Advanced"}; bool advancedExpanded_ = false; + bool separationControlsVisible_ = false; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(ControlDeck) }; diff --git a/src/app/ui/GlowMeters.cpp b/src/app/ui/GlowMeters.cpp index 4bd161e..124dcf5 100644 --- a/src/app/ui/GlowMeters.cpp +++ b/src/app/ui/GlowMeters.cpp @@ -8,6 +8,7 @@ namespace automix::app { using namespace theme; GlowMeters::GlowMeters() { + setTitle("Loudness meters"); lufsLabel_.setText("I: -- LUFS", juce::dontSendNotification); lufsLabel_.setFont(typography::caption()); lufsLabel_.setColour(juce::Label::textColourId, colour(colours::textMuted)); diff --git a/src/app/ui/HeaderBar.cpp b/src/app/ui/HeaderBar.cpp index e1362e9..97f8a64 100644 --- a/src/app/ui/HeaderBar.cpp +++ b/src/app/ui/HeaderBar.cpp @@ -1,5 +1,7 @@ #include "app/ui/HeaderBar.h" +#include "app/style/AutoMixLookAndFeel.h" + namespace automix::app { using namespace theme; @@ -17,6 +19,10 @@ HeaderBar::HeaderBar() { addAndMakeVisible(settingsButton_); addAndMakeVisible(profileSelector_); + for (juce::Button* btn : {static_cast(&saveButton_), static_cast(&loadButton_), + static_cast(&modelsButton_), static_cast(&settingsButton_)}) + setButtonVariant(*btn, buttonVariant::quiet); + saveButton_.setTooltip("Save Session (Ctrl+S)"); loadButton_.setTooltip("Load Session (Ctrl+O)"); modelsButton_.setTooltip("Model Manager (Ctrl+K)"); diff --git a/src/app/ui/HeroWaveform.cpp b/src/app/ui/HeroWaveform.cpp index 02702b0..4530d64 100644 --- a/src/app/ui/HeroWaveform.cpp +++ b/src/app/ui/HeroWaveform.cpp @@ -127,6 +127,17 @@ void HeroWaveform::mouseDrag(const juce::MouseEvent& event) { onSeek(progress); } +void HeroWaveform::mouseUp(const juce::MouseEvent& /*event*/) { + if (waveformPeaks_.empty() && onImportRequested) + onImportRequested(); +} + +juce::MouseCursor HeroWaveform::getMouseCursor() { + if (waveformPeaks_.empty()) + return juce::MouseCursor::PointingHandCursor; + return juce::Component::getMouseCursor(); +} + void HeroWaveform::mouseWheelMove(const juce::MouseEvent& event, const juce::MouseWheelDetails& wheel) { const double zoomSensitivity = 0.3; double zoomDelta = -wheel.deltaY * zoomSensitivity; @@ -355,16 +366,32 @@ void HeroWaveform::paint(juce::Graphics& g) { float midY = h * 0.5f; if (waveformPeaks_.empty()) { + const auto zone = bounds.reduced(12.0f); + if (isDragOver_) { + g.setColour(colour(colours::primary).withAlpha(0.08f)); + g.fillRoundedRectangle(zone, metrics::cornerRadiusLarge); + g.setColour(colour(colours::primary)); + g.drawRoundedRectangle(zone, metrics::cornerRadiusLarge, 2.0f); + } else { + juce::Path zonePath; + zonePath.addRoundedRectangle(zone, metrics::cornerRadiusLarge); + juce::Path dashedPath; + const float dashes[] = {6.0f, 4.0f}; + juce::PathStrokeType(1.5f).createDashedStroke(dashedPath, zonePath, dashes, 2); + g.setColour(colour(colours::surfaceBorder)); + g.fillPath(dashedPath); + } + auto upperHalf = bounds.withHeight(h * 0.5f); g.setFont(typography::subhead()); - g.setColour(colour(colours::primary).withAlpha(0.85f)); - g.drawText("Drop stems here or click Import (Ctrl+I)", upperHalf, juce::Justification::centredBottom); + g.setColour(colour(colours::text)); + g.drawText("Drop stems here or click to import", upperHalf, juce::Justification::centredBottom); auto lowerHalf = bounds.withY(h * 0.5f).withHeight(h * 0.5f); g.setFont(typography::caption()); g.setColour(colour(colours::textMuted)); - g.drawText("1 Import Stems -> 2 Mix + Master -> 3 Export\n" - "Tip: enable 'AI Stem Separation' in the control deck to split one full mix into stems.", + const auto arrow = juce::String(juce::CharPointer_UTF8("\xe2\x86\x92")); + g.drawText("1 Import stems " + arrow + " 2 Mix + Master " + arrow + " 3 Export", lowerHalf.reduced(0.0f, 8.0f), juce::Justification::centredTop); } else { // Draw stem overlay if we have per-stem data @@ -440,7 +467,7 @@ void HeroWaveform::paint(juce::Graphics& g) { drawZoomControls(g); } - if (isDragOver_) { + if (isDragOver_ && !waveformPeaks_.empty()) { g.setColour(colour(colours::primary).withAlpha(0.12f)); g.fillAll(); g.setColour(colour(colours::primary)); diff --git a/src/app/ui/HeroWaveform.h b/src/app/ui/HeroWaveform.h index 1af1b33..f6be808 100644 --- a/src/app/ui/HeroWaveform.h +++ b/src/app/ui/HeroWaveform.h @@ -21,6 +21,8 @@ class HeroWaveform final : public juce::Component, void paint(juce::Graphics& g) override; void mouseDown(const juce::MouseEvent& event) override; void mouseDrag(const juce::MouseEvent& event) override; + void mouseUp(const juce::MouseEvent& event) override; + juce::MouseCursor getMouseCursor() override; void mouseWheelMove(const juce::MouseEvent& event, const juce::MouseWheelDetails& wheel) override; void resized() override; @@ -38,6 +40,7 @@ class HeroWaveform final : public juce::Component, void setStemGroups(const std::vector& groups); // Callbacks + std::function onImportRequested; // empty-state zone clicked std::function onSeek; // progress fraction 0..1 std::function onZoomChanged; // new zoom factor std::function)> onFilesDropped; // audio/preset files dropped diff --git a/src/app/ui/MainLayout.cpp b/src/app/ui/MainLayout.cpp index 7947680..b0fadda 100644 --- a/src/app/ui/MainLayout.cpp +++ b/src/app/ui/MainLayout.cpp @@ -13,10 +13,12 @@ #include "app/ui/TaskOrchestrator.h" #include "app/ui/TransportBar.h" #include "app/ui/VerificationEngine.h" +#include "ai/BsRoformerPack.h" #include "ai/GpuRuntimePack.h" #include "ai/HuggingFaceModelHub.h" #include "ai/ModelStorage.h" #include "ai/OnnxTensorInference.h" +#include "ai/UmxPack.h" #include "renderers/RendererPipeline.h" #include "util/FileUtils.h" @@ -111,12 +113,9 @@ MainLayout::MainLayout() { addAndMakeVisible(*taskCenter_); // Destructive Clear: MainLayout-owned (not part of TransportBar), confirmed on click. - clearTracksButton_.setTooltip("Clear Imported Tracks (asks for confirmation)"); + clearTracksButton_.setTooltip("Remove all stems from the session"); clearTracksButton_.setWantsKeyboardFocus(true); - clearTracksButton_.setColour(juce::TextButton::buttonColourId, colour(colours::surface)); - clearTracksButton_.setColour(juce::TextButton::buttonOnColourId, colour(colours::surfaceLight)); - clearTracksButton_.setColour(juce::TextButton::textColourOffId, colour(colours::warning)); - clearTracksButton_.setColour(juce::TextButton::textColourOnId, colour(colours::warning)); + setButtonVariant(clearTracksButton_, buttonVariant::danger); clearTracksButton_.onClick = [this] { onClearTracks(); }; addAndMakeVisible(clearTracksButton_); @@ -161,6 +160,16 @@ MainLayout::MainLayout() { commandManager_.registerAllCommandsForTarget(this); sessionManager_.markSaved(); + refreshStemDependentUi(); +} + +void MainLayout::refreshStemDependentUi() { + const bool hasStems = !sessionManager_.session().stems.empty(); + if (controlDeck_ != nullptr) + controlDeck_->setHasStems(hasStems); + if (transportBar_ != nullptr) + transportBar_->setHasMedia(hasStems); + clearTracksButton_.setEnabled(hasStems); } // ── Controllers factory ──────────────────────────────────────── @@ -290,6 +299,7 @@ void MainLayout::initControllers() { safe->sessionManager_.session().stems = result.stems; safe->sessionManager_.session().originalMixPath = result.originalMixPath; updateStemPanelFromSession(safe->controlDeck_->getStemPanel(), safe->sessionManager_.session()); + safe->refreshStemDependentUi(); safe->taskOrchestrator_->finishTaskCompleted(ActiveTask::Import, "Import complete"); safe->rebuildPreview(); @@ -1011,6 +1021,7 @@ void MainLayout::performClearTracks() { transportBar_->setTimeDisplay(0.0, 0.0); sessionManager_.session().stems.clear(); detail::updateStemPanelFromSession(controlDeck_->getStemPanel(), sessionManager_.session()); + refreshStemDependentUi(); } // ───────────────────────────────────────────────────────────────── @@ -1100,6 +1111,7 @@ void MainLayout::wireControlDeckCallbacks() { const bool enabled = controlDeck_->getSeparatedStemsToggle().getToggleState(); sessionManager_.session().aiStemsEnabled = enabled; controlDeck_->getTensorSeparationToggle().setEnabled(enabled); + controlDeck_->setSeparationControlsVisible(enabled); }; controlDeck_->getTensorSeparationToggle().onClick = [this] { const bool enabled = controlDeck_->getTensorSeparationToggle().getToggleState(); @@ -1132,6 +1144,7 @@ void MainLayout::wireHeroWaveformCallbacks() { heroWaveform_->onSeek = [this](double progress) { transportController_.seekToFraction(std::clamp(progress, 0.0, 1.0)); }; + heroWaveform_->onImportRequested = [this] { onImport(); }; heroWaveform_->onFilesDropped = [this](std::vector files) { importFiles(std::move(files)); }; @@ -1900,6 +1913,7 @@ void MainLayout::applyLoadedSession(domain::Session loadedSession, const juce::S sessionShownModified_ = false; setSessionDisplayName(juce::File(sourcePath).getFileNameWithoutExtension()); updateStemPanelFromSession(controlDeck_->getStemPanel(), session); + refreshStemDependentUi(); transportBar_->setLoopEnabled(session.timeline.loopEnabled); heroWaveform_->setZoom(session.timeline.zoom, 0.5); refreshRenderers(); @@ -2092,9 +2106,10 @@ void MainLayout::updateSeparationModelBadge() { return; } + const juce::String noneText(juce::CharPointer_UTF8("Model: none installed \xe2\x80\x94 open Models")); const auto activeId = modelManager_.activePackId("separation"); if (activeId.empty()) { - controlDeck_->setSeparationModelStatus("Separation model: none", false); + controlDeck_->setSeparationModelStatus(noneText, false); return; } @@ -2103,7 +2118,7 @@ void MainLayout::updateSeparationModelBadge() { return util::toLower(pack.taskScope) == "separation" && pack.id == activeId; }); if (selected == packs.end()) { - controlDeck_->setSeparationModelStatus("Separation model: none", false); + controlDeck_->setSeparationModelStatus(noneText, false); return; } @@ -2111,7 +2126,21 @@ void MainLayout::updateSeparationModelBadge() { const auto modelPath = selected->rootPath / selected->modelFile; const bool ready = std::filesystem::is_regular_file(modelPath, error) && !error; const auto displayName = selected->name.empty() ? selected->id : selected->name; - auto badgeText = juce::String("Separation model: ") + juce::String(displayName); + const auto mentions = [&](const char* repoId) { + const juce::String repo(repoId); + const auto sanitizedRepo = repo.replaceCharacter('/', '_'); + for (const auto& field : {selected->id, selected->name, selected->source}) { + const juce::String value(field); + if (value.containsIgnoreCase(repo) || value.containsIgnoreCase(sanitizedRepo)) + return true; + } + return false; + }; + juce::String badgeText = "Model: " + juce::String(displayName); + if (mentions(ai::kBsRoformerRepoId)) + badgeText = "Model: BS-RoFormer (best quality, GPU recommended)"; + else if (mentions(ai::kUmxVocalsRepoId)) + badgeText = "Model: Open-Unmix (fast, lower quality)"; if (!ready) { badgeText << " (missing)"; } @@ -2217,6 +2246,7 @@ void MainLayout::applySessionUiSelections() { controlDeck_->getTensorSeparationToggle().setToggleState(session.renderSettings.tensorSeparationEnabled, juce::dontSendNotification); controlDeck_->getTensorSeparationToggle().setEnabled(session.aiStemsEnabled); + controlDeck_->setSeparationControlsVisible(session.aiStemsEnabled); controlDeck_->getBatchRecursiveToggle().setToggleState(session.batchRecursiveEnabled, juce::dontSendNotification); controlDeck_->getRendererChainToggle().setToggleState( session.renderSettings.rendererChainEnabled, diff --git a/src/app/ui/MainLayout.h b/src/app/ui/MainLayout.h index ae1c5cd..0238fd5 100644 --- a/src/app/ui/MainLayout.h +++ b/src/app/ui/MainLayout.h @@ -179,6 +179,7 @@ class MainLayout final : public juce::Component, void onSaveSession(std::function done = {}); void finishPendingSave(bool success); void refreshSessionTitle(); + void refreshStemDependentUi(); void setSessionDisplayName(const juce::String& name); void onLoadSession(); void onModelsDialog(); diff --git a/src/app/ui/MainLayoutInternal.h b/src/app/ui/MainLayoutInternal.h index 1ecce63..ceda4d5 100644 --- a/src/app/ui/MainLayoutInternal.h +++ b/src/app/ui/MainLayoutInternal.h @@ -18,6 +18,7 @@ #include #include +#include "app/style/AutoMixLookAndFeel.h" #include "ai/ModelManager.h" #include "ai/OnnxModelInference.h" #include "automaster/IAutoMasterStrategy.h" @@ -246,6 +247,7 @@ class SettingsPanel final : public juce::Component { gpuStatusLabel_.setText(gpuStatus, juce::dontSendNotification); gpuButton_.setButtonText(gpuButtonText); gpuButton_.setVisible(gpuButtonText.isNotEmpty()); + setButtonVariant(gpuButton_, gpuButtonText == "Remove" ? buttonVariant::danger : buttonVariant::secondary); gpuButton_.onClick = [this] { gpuButton_.setEnabled(false); // one action per dialog if (onGpuButton_) { @@ -269,6 +271,7 @@ class SettingsPanel final : public juce::Component { } void resized() override { + styleAudioSelectorButtons(audioSelector_); auto area = getLocalBounds().reduced(10); reportSidecarToggle_.setBounds(area.removeFromTop(28)); area.removeFromTop(6); @@ -283,6 +286,18 @@ class SettingsPanel final : public juce::Component { } private: + // The "Test" button lives inside JUCE's audio selector; find it by its text. + static void styleAudioSelectorButtons(juce::Component& parent) { + for (auto* child : parent.getChildren()) { + if (auto* button = dynamic_cast(child)) { + if (button->getButtonText() == "Test") + setButtonVariant(*button, buttonVariant::secondary); + } else if (child != nullptr) { + styleAudioSelectorButtons(*child); + } + } + } + juce::AudioDeviceSelectorComponent audioSelector_; juce::ToggleButton reportSidecarToggle_; std::function onWriteReportSidecarChanged_; diff --git a/src/app/ui/TaskCenterPanel.cpp b/src/app/ui/TaskCenterPanel.cpp index 04061b1..e884b04 100644 --- a/src/app/ui/TaskCenterPanel.cpp +++ b/src/app/ui/TaskCenterPanel.cpp @@ -1,5 +1,7 @@ #include "app/ui/TaskCenterPanel.h" +#include "app/style/AutoMixLookAndFeel.h" + #include #include @@ -43,6 +45,8 @@ TaskCenterPanel::TaskCenterPanel() : progressBar_(progressValue_), batchProgress juce::SystemClipboard::copyTextToClipboard(historyEditor_.getText()); }; + setButtonVariant(copyLogButton_, buttonVariant::quiet); + setButtonVariant(cancelButton_, buttonVariant::secondary); cancelButton_.setEnabled(false); cancelButton_.onClick = [this] { if (onCancel) diff --git a/src/app/ui/TransportBar.cpp b/src/app/ui/TransportBar.cpp index f5aec32..9aef5ff 100644 --- a/src/app/ui/TransportBar.cpp +++ b/src/app/ui/TransportBar.cpp @@ -1,5 +1,7 @@ #include "app/ui/TransportBar.h" +#include "app/style/AutoMixLookAndFeel.h" + #include #include @@ -19,6 +21,20 @@ TransportBar::TransportBar() { addAndMakeVisible(volumeSlider_); addAndMakeVisible(loopToggle_); + for (juce::Button* btn : {static_cast(&skipStartButton_), static_cast(&playPauseButton_), + static_cast(&stopButton_), static_cast(&skipEndButton_), + static_cast(&shortcutsButton_)}) + setButtonVariant(*btn, buttonVariant::secondary); + + // Accessibility titles + skipStartButton_.setTitle("Skip to start"); + playPauseButton_.setTitle("Play"); + stopButton_.setTitle("Stop"); + skipEndButton_.setTitle("Skip to end"); + shortcutsButton_.setTitle("Keyboard shortcuts"); + loopToggle_.setTitle("Loop"); + volumeSlider_.setTitle("Volume"); + // Tooltips skipStartButton_.setTooltip("Skip to Start (Home)"); playPauseButton_.setTooltip("Play / Pause (Space)"); @@ -110,6 +126,13 @@ void TransportBar::resized() { timeLabel_.setBounds(area); } +void TransportBar::setHasMedia(bool hasMedia) { + skipStartButton_.setEnabled(hasMedia); + playPauseButton_.setEnabled(hasMedia); + stopButton_.setEnabled(hasMedia); + skipEndButton_.setEnabled(hasMedia); +} + void TransportBar::setPlaying(bool playing) { isPlaying_ = playing; playPauseButton_.setButtonText(playing ? "Pause" : "Play"); diff --git a/src/app/ui/TransportBar.h b/src/app/ui/TransportBar.h index 236bf53..468dc15 100644 --- a/src/app/ui/TransportBar.h +++ b/src/app/ui/TransportBar.h @@ -17,6 +17,8 @@ class TransportBar final : public juce::Component, private juce::Slider::Listene bool keyPressed(const juce::KeyPress& key) override; void setPlaying(bool playing); + /// Enables the skip/play/stop buttons only when there is media to transport. + void setHasMedia(bool hasMedia); void setTimeDisplay(double currentSeconds, double totalSeconds); void setVolume(double volume); void setLoopEnabled(bool enabled); From 11d52355061c8a649ef432789d63fd048e05c589 Mon Sep 17 00:00:00 2001 From: Soficis Date: Mon, 5 Oct 2026 15:42:17 -0500 Subject: [PATCH 62/68] feat(ui): quieter task area, safer settings, and a layout that holds at the minimum window size - The batch strip shows only while a batch has items (nothing fed it before, so it only ever showed placeholders); the log is collapsible, collapsed by default, and opens on failure; dialog-open noise is no longer logged; idle progress is hidden. - Meters read "--" instead of -70.0 when there is no signal. - The header profile selector, which was never populated, is hidden; the deck's Profile combo is the single profile control. - Settings has Export / GPU acceleration / Audio output sections, and removing the GPU runtime asks first. The Model Browser opens at 760x560. - Disabled buttons are outlined instead of filled, so they no longer outshine enabled ones. - At 960x640 the settings row wraps instead of clipping Platform, and action buttons use a compact font instead of wrapping. - Tests: ControlDeck stem gating, separation visibility, meter formatting and layout at 940/1500 px; TaskCenterPanel batch strip, log and idle progress visibility. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 4 + src/app/style/AutoMixLookAndFeel.cpp | 16 ++-- src/app/ui/ControlDeck.cpp | 47 +++++++++--- src/app/ui/GlowMeters.cpp | 14 +++- src/app/ui/GlowMeters.h | 4 + src/app/ui/HeaderBar.cpp | 7 +- src/app/ui/MainLayout.cpp | 31 ++++++-- src/app/ui/MainLayoutInternal.h | 17 ++++- src/app/ui/TaskCenterPanel.cpp | 108 +++++++++++++++++++++------ src/app/ui/TaskCenterPanel.h | 14 ++++ tests/unit/ControlDeckTests.cpp | 98 ++++++++++++++++++++++++ tests/unit/TaskCenterPanelTests.cpp | 49 ++++++++++++ 12 files changed, 351 insertions(+), 58 deletions(-) create mode 100644 tests/unit/ControlDeckTests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index a131914..01f72ad 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -550,6 +550,7 @@ if(BUILD_TESTING) tests/unit/TestMain.cpp tests/unit/ControllerTests.cpp tests/unit/TaskCenterPanelTests.cpp + tests/unit/ControlDeckTests.cpp tests/unit/SessionSerializationTests.cpp src/app/ui/SessionManager.cpp tests/unit/AudioIoTests.cpp @@ -591,6 +592,9 @@ if(BUILD_TESTING) tests/integration/PipelineIntegrationTests.cpp src/app/ui/TaskCenterPanel.cpp src/app/style/AutoMixLookAndFeel.cpp + src/app/ui/ControlDeck.cpp + src/app/ui/StemPanel.cpp + src/app/ui/GlowMeters.cpp src/app/controllers/ModelController.cpp src/app/controllers/ImportController.cpp src/app/controllers/ExportController.cpp diff --git a/src/app/style/AutoMixLookAndFeel.cpp b/src/app/style/AutoMixLookAndFeel.cpp index 500c24e..5d7591a 100644 --- a/src/app/style/AutoMixLookAndFeel.cpp +++ b/src/app/style/AutoMixLookAndFeel.cpp @@ -83,7 +83,13 @@ void AutoMixLookAndFeel::drawButtonBackground(juce::Graphics& g, juce::Button& b const auto variant = variantOf(button); const bool enabled = button.isEnabled(); - if (variant == buttonVariant::secondary) { + if (!enabled) { + // Disabled: no fill, outline only for primary/secondary so it never out-shouts an enabled button. + if (variant != buttonVariant::quiet && variant != buttonVariant::danger) { + g.setColour(colour(colours::surfaceBorder)); + g.drawRoundedRectangle(bounds, cornerSize, 1.0f); + } + } else if (variant == buttonVariant::secondary) { juce::Colour bg = colour(colours::surfaceLight); if (enabled && shouldDrawButtonAsDown) bg = bg.darker(0.1f); @@ -101,9 +107,7 @@ void AutoMixLookAndFeel::drawButtonBackground(juce::Graphics& g, juce::Button& b } } else { juce::Colour bg; - if (!enabled) { - bg = colour(colours::surfaceBorder); - } else if (shouldDrawButtonAsDown) { + if (shouldDrawButtonAsDown) { bg = colour(colours::primaryPressed); } else if (shouldDrawButtonAsHighlighted) { bg = colour(colours::primary).interpolatedWith(colour(colours::primaryHover), 0.6f); @@ -399,7 +403,9 @@ void AutoMixLookAndFeel::drawToggleButton(juce::Graphics& g, juce::ToggleButton& // Fonts // ───────────────────────────────────────────────────────────────── -juce::Font AutoMixLookAndFeel::getTextButtonFont(juce::TextButton& /*button*/, int /*buttonHeight*/) { +juce::Font AutoMixLookAndFeel::getTextButtonFont(juce::TextButton& button, int /*buttonHeight*/) { + if (static_cast(button.getProperties().getWithDefault("compact", false))) + return typography::caption(); return typography::body(); } diff --git a/src/app/ui/ControlDeck.cpp b/src/app/ui/ControlDeck.cpp index 63ce2e3..0198fbc 100644 --- a/src/app/ui/ControlDeck.cpp +++ b/src/app/ui/ControlDeck.cpp @@ -188,8 +188,8 @@ void ControlDeck::resized() { auto area = getLocalBounds().reduced(static_cast(metrics::paddingMedium)); // Three-column layout - int stemWidth = std::max(200, area.getWidth() * 35 / 100); - int meterWidth = std::max(100, area.getWidth() * 15 / 100); + int stemWidth = juce::jlimit(200, 360, area.getWidth() * 28 / 100); + int meterWidth = juce::jlimit(110, 180, area.getWidth() * 13 / 100); auto stemArea = area.removeFromLeft(stemWidth); area.removeFromLeft(spacing::gapMedium); @@ -201,26 +201,49 @@ void ControlDeck::resized() { glowMeters_->setBounds(meterArea); // Action row - // Slots: import(1) | autoMix(1) | autoMaster(1) | Mix+Master(2) | batch(1) | export(1) = 7 slots + // Slots: import(1) | autoMix(1) | autoMaster(1) | Mix+Master(1.5) | batch(1) | export(1) = 6.5 slots auto actionRow = centerArea.removeFromTop(44); - const int slotW = actionRow.getWidth() / 7; + const int slotW = juce::roundToInt(static_cast(actionRow.getWidth()) / 6.5f); + const bool compact = slotW < 90; + for (auto* b : {&importButton_, &autoMixButton_, &autoMasterButton_, &autoMixMasterButton_, + &batchButton_, &exportButton_}) + b->getProperties().set("compact", compact); importButton_.setBounds(actionRow.removeFromLeft(slotW).reduced(2)); autoMixButton_.setBounds(actionRow.removeFromLeft(slotW).reduced(2)); autoMasterButton_.setBounds(actionRow.removeFromLeft(slotW).reduced(2)); - autoMixMasterButton_.setBounds(actionRow.removeFromLeft(slotW * 2).reduced(2)); + autoMixMasterButton_.setBounds(actionRow.removeFromLeft(juce::roundToInt(static_cast(slotW) * 1.5f)).reduced(2)); batchButton_.setBounds(actionRow.removeFromLeft(slotW).reduced(2)); exportButton_.setBounds(actionRow.reduced(2)); centerArea.removeFromTop(spacing::gapSmall); // Always-visible context rows: Profile/Master/Platform, then the separation row - auto settingsRow1 = centerArea.removeFromTop(28); - profileLabel_.setBounds(settingsRow1.removeFromLeft(50).reduced(1)); - profileBox_.setBounds(settingsRow1.removeFromLeft(200).reduced(1)); - masterPresetLabel_.setBounds(settingsRow1.removeFromLeft(50).reduced(1)); - masterPresetBox_.setBounds(settingsRow1.removeFromLeft(170).reduced(1)); - platformPresetLabel_.setBounds(settingsRow1.removeFromLeft(64).reduced(1)); - platformPresetBox_.setBounds(settingsRow1.removeFromLeft(150).reduced(1)); + // Pairs wrap onto a new row when they do not fit; combos shrink toward their minimum first. + { + struct Pair { + juce::Label* label; + juce::ComboBox* box; + int labelW, prefW, minW; + }; + const Pair pairs[] = {{&profileLabel_, &profileBox_, 50, 200, 150}, + {&masterPresetLabel_, &masterPresetBox_, 50, 170, 130}, + {&platformPresetLabel_, &platformPresetBox_, 64, 150, 120}}; + const int rowWidth = centerArea.getWidth(); + auto row = centerArea.removeFromTop(28); + int x = 0; + for (const auto& p : pairs) { + const int avail = rowWidth - x - p.labelW; + if (x > 0 && avail < p.minW) { + row = centerArea.removeFromTop(28); + x = 0; + } + const int comboW = std::min(p.prefW, std::max(p.minW, rowWidth - x - p.labelW)); + p.label->setBounds(row.getX() + x, row.getY(), p.labelW, row.getHeight()); + p.label->setBounds(p.label->getBounds().reduced(1)); + p.box->setBounds(juce::Rectangle(row.getX() + x + p.labelW, row.getY(), comboW, row.getHeight()).reduced(1)); + x += p.labelW + comboW; + } + } auto separationRow = centerArea.removeFromTop(28); separatedStemsToggle_.setBounds(separationRow.removeFromLeft(180).reduced(1)); diff --git a/src/app/ui/GlowMeters.cpp b/src/app/ui/GlowMeters.cpp index 124dcf5..5589968 100644 --- a/src/app/ui/GlowMeters.cpp +++ b/src/app/ui/GlowMeters.cpp @@ -7,6 +7,12 @@ namespace automix::app { using namespace theme; +juce::String GlowMeters::formatReadout(const juce::String& prefix, const double value, const juce::String& unit) { + if (!std::isfinite(value) || value <= kNoSignalDb) + return prefix + "--" + unit; + return prefix + juce::String(value, 1) + unit; +} + GlowMeters::GlowMeters() { setTitle("Loudness meters"); lufsLabel_.setText("I: -- LUFS", juce::dontSendNotification); @@ -108,10 +114,10 @@ void GlowMeters::timerCallback() { } // Pre-allocated string updates (avoid ostringstream allocation per frame) - lufsText_ = "I: " + juce::String(integratedLufs_, 1) + " LUFS"; - stText_ = "S: " + juce::String(shortTermLufs_, 1) + " LUFS"; - tpText_ = "TP: " + juce::String(truePeakDbtp_, 1) + " dBTP"; - momentText_ = "M: " + juce::String(momentaryLufs_, 1) + " LUFS"; + lufsText_ = formatReadout("I: ", integratedLufs_, " LUFS"); + stText_ = formatReadout("S: ", shortTermLufs_, " LUFS"); + tpText_ = formatReadout("TP: ", truePeakDbtp_, " dBTP"); + momentText_ = formatReadout("M: ", momentaryLufs_, " LUFS"); lufsLabel_.setText(lufsText_, juce::dontSendNotification); shortTermLabel_.setText(stText_, juce::dontSendNotification); diff --git a/src/app/ui/GlowMeters.h b/src/app/ui/GlowMeters.h index 3e6c282..c188702 100644 --- a/src/app/ui/GlowMeters.h +++ b/src/app/ui/GlowMeters.h @@ -21,6 +21,9 @@ class GlowMeters final : public juce::Component, private juce::Timer { void setTruePeak(double truePeakDbtp); void setMomentaryLufs(double momentary); + /// Values at or below kNoSignalDb (or non-finite) read as "--". + static juce::String formatReadout(const juce::String& prefix, double value, const juce::String& unit); + private: void timerCallback() override; void drawMeter(juce::Graphics& g, juce::Rectangle bounds, float levelDb, float peakDb) const; @@ -62,6 +65,7 @@ class GlowMeters final : public juce::Component, private juce::Timer { juce::Label momentaryLabel_; juce::Label lufsBarLabel_; + static constexpr double kNoSignalDb = -69.95; static constexpr float kMinDb = -60.0f; static constexpr float kMaxDb = 6.0f; static constexpr int kPeakHoldFrames = 40; diff --git a/src/app/ui/HeaderBar.cpp b/src/app/ui/HeaderBar.cpp index 97f8a64..42ec413 100644 --- a/src/app/ui/HeaderBar.cpp +++ b/src/app/ui/HeaderBar.cpp @@ -17,7 +17,9 @@ HeaderBar::HeaderBar() { addAndMakeVisible(loadButton_); addAndMakeVisible(modelsButton_); addAndMakeVisible(settingsButton_); - addAndMakeVisible(profileSelector_); + // Profile selection lives in the ControlDeck "Profile" combo; this selector is never populated. + addChildComponent(profileSelector_); + profileSelector_.setVisible(false); for (juce::Button* btn : {static_cast(&saveButton_), static_cast(&loadButton_), static_cast(&modelsButton_), static_cast(&settingsButton_)}) @@ -84,9 +86,6 @@ void HeaderBar::resized() { loadButton_.setBounds(rightArea.removeFromRight(80).reduced(2)); saveButton_.setBounds(rightArea.reduced(2)); - auto profileArea = area.removeFromLeft(220); - profileSelector_.setBounds(profileArea.reduced(2)); - sessionNameLabel_.setBounds(area); } diff --git a/src/app/ui/MainLayout.cpp b/src/app/ui/MainLayout.cpp index b0fadda..d01298a 100644 --- a/src/app/ui/MainLayout.cpp +++ b/src/app/ui/MainLayout.cpp @@ -146,6 +146,7 @@ MainLayout::MainLayout() { wireControlDeckCallbacks(); wireHeroWaveformCallbacks(); + taskCenter_->onPreferredHeightChanged = [this] { resized(); }; taskCenter_->onCancel = [this] { taskOrchestrator_->cancelActiveTask(); }; // 5. Audio device & transport @@ -606,7 +607,7 @@ void MainLayout::resized() { fb.items.add(juce::FlexItem(*headerBar_).withHeight(static_cast(kHeaderHeight))); fb.items.add(juce::FlexItem(*heroWaveform_).withFlex(1.5f).withMinHeight(120.0f)); fb.items.add(juce::FlexItem(*controlDeck_).withFlex(2.5f).withMinHeight(180.0f)); - fb.items.add(juce::FlexItem(*taskCenter_).withFlex(1.2f).withMinHeight(160.0f)); + fb.items.add(juce::FlexItem(*taskCenter_).withHeight(static_cast(taskCenter_->getPreferredHeight()))); fb.performLayout(area); } @@ -1814,8 +1815,9 @@ void MainLayout::onModelsDialog() { options.escapeKeyTriggersCloseButton = true; options.useNativeTitleBar = true; options.resizable = true; - options.launchAsync(); - taskOrchestrator_->appendHistory("Model browser opened"); + panel->setSize(760, 560); + if (auto* dialog = options.launchAsync()) + dialog->setResizeLimits(640, 460, 4096, 4096); } // ───────────────────────────────────────────────────────────────── @@ -1834,7 +1836,7 @@ void MainLayout::onSettings() { : "Export sidecar JSON disabled"); }, gpuRuntimeStatusText(), gpuRuntimeButtonText(), [this] { onGpuRuntimeButton(); }); - settingsPanel->setSize(540, 430); + settingsPanel->setSize(540, 520); juce::DialogWindow::LaunchOptions options; options.content.setOwned(settingsPanel); @@ -1844,7 +1846,6 @@ void MainLayout::onSettings() { options.useNativeTitleBar = true; options.resizable = false; options.launchAsync(); - taskOrchestrator_->appendHistory("Settings dialog opened"); } // ───────────────────────────────────────────────────────────────── @@ -2483,9 +2484,23 @@ juce::String MainLayout::gpuRuntimeButtonText() const { void MainLayout::onGpuRuntimeButton() { namespace pack = ai::GpuRuntimePack; if (pack::isInstalled(pack::defaultRoot())) { - const auto removed = pack::uninstall(); - ai::invalidateTensorProviderProbeCache(); - taskOrchestrator_->appendHistory(juce::String(removed.message)); + juce::AlertWindow::showAsync( + juce::MessageBoxOptions::makeOptionsOkCancel( + juce::MessageBoxIconType::WarningIcon, + "Remove GPU runtime?", + "This deletes the downloaded NVIDIA libraries. The vocal model will run on the CPU until you install them " + "again.", + "Remove", + "Cancel"), + [safe = juce::Component::SafePointer(this)](const int result) { + if (result == 0 || safe == nullptr) { + return; + } + namespace pack = ai::GpuRuntimePack; + const auto removed = pack::uninstall(); + ai::invalidateTensorProviderProbeCache(); + safe->taskOrchestrator_->appendHistory(juce::String(removed.message)); + }); return; } gpuRuntimeOffered_ = true; // asked explicitly; no need to offer again this run diff --git a/src/app/ui/MainLayoutInternal.h b/src/app/ui/MainLayoutInternal.h index ceda4d5..832e007 100644 --- a/src/app/ui/MainLayoutInternal.h +++ b/src/app/ui/MainLayoutInternal.h @@ -257,6 +257,15 @@ class SettingsPanel final : public juce::Component { addAndMakeVisible(gpuStatusLabel_); addChildComponent(gpuButton_); + for (auto* heading : {&exportHeading_, &gpuHeading_, &audioHeading_}) { + heading->setFont(theme::typography::subhead()); + heading->setColour(juce::Label::textColourId, theme::colour(theme::colours::textMuted)); + addAndMakeVisible(*heading); + } + exportHeading_.setText("Export", juce::dontSendNotification); + gpuHeading_.setText("GPU acceleration", juce::dontSendNotification); + audioHeading_.setText("Audio output", juce::dontSendNotification); + reportSidecarToggle_.setButtonText("Write .report.json sidecar next to each exported file"); reportSidecarToggle_.setTooltip("Disable to export only audio files without per-file JSON report sidecars."); reportSidecarToggle_.setToggleState(writeReportJsonSidecar, juce::dontSendNotification); @@ -273,8 +282,10 @@ class SettingsPanel final : public juce::Component { void resized() override { styleAudioSelectorButtons(audioSelector_); auto area = getLocalBounds().reduced(10); + exportHeading_.setBounds(area.removeFromTop(24)); reportSidecarToggle_.setBounds(area.removeFromTop(28)); - area.removeFromTop(6); + area.removeFromTop(10); + gpuHeading_.setBounds(area.removeFromTop(24)); auto gpuRow = area.removeFromTop(28); if (gpuButton_.isVisible()) { gpuButton_.setBounds(gpuRow.removeFromRight(130)); @@ -282,6 +293,7 @@ class SettingsPanel final : public juce::Component { } gpuStatusLabel_.setBounds(gpuRow); area.removeFromTop(10); + audioHeading_.setBounds(area.removeFromTop(24)); audioSelector_.setBounds(area); } @@ -301,6 +313,9 @@ class SettingsPanel final : public juce::Component { juce::AudioDeviceSelectorComponent audioSelector_; juce::ToggleButton reportSidecarToggle_; std::function onWriteReportSidecarChanged_; + juce::Label exportHeading_; + juce::Label gpuHeading_; + juce::Label audioHeading_; juce::Label gpuStatusLabel_; juce::TextButton gpuButton_; std::function onGpuButton_; diff --git a/src/app/ui/TaskCenterPanel.cpp b/src/app/ui/TaskCenterPanel.cpp index e884b04..1773d96 100644 --- a/src/app/ui/TaskCenterPanel.cpp +++ b/src/app/ui/TaskCenterPanel.cpp @@ -41,6 +41,9 @@ TaskCenterPanel::TaskCenterPanel() : progressBar_(progressValue_), batchProgress .withPointHeight(12.0f))); historyEditor_.setText("Task history will appear here."); + toggleLogButton_.onClick = [this] { setLogVisible(!logVisible_); }; + setButtonVariant(toggleLogButton_, buttonVariant::quiet); + copyLogButton_.onClick = [this] { juce::SystemClipboard::copyTextToClipboard(historyEditor_.getText()); }; @@ -80,6 +83,7 @@ TaskCenterPanel::TaskCenterPanel() : progressBar_(progressValue_), batchProgress addAndMakeVisible(stateBadge_); addAndMakeVisible(progressBar_); addAndMakeVisible(progressLabel_); + addAndMakeVisible(toggleLogButton_); addAndMakeVisible(copyLogButton_); addAndMakeVisible(cancelButton_); addAndMakeVisible(queueHeaderLabel_); @@ -90,6 +94,9 @@ TaskCenterPanel::TaskCenterPanel() : progressBar_(progressValue_), batchProgress addAndMakeVisible(batchProgressBar_); addAndMakeVisible(historyEditor_); + progressLabel_.setVisible(false); // Idle (READY) at start + progressBar_.setVisible(false); + historyEditor_.setVisible(false); // Log is collapsed by default updateBatchSummary(); } @@ -100,7 +107,7 @@ void TaskCenterPanel::paint(juce::Graphics& g) { g.setColour(colour(colours::surfaceBorder)); g.fillRect(0, 0, getWidth(), 1); - if (!queueItems_.empty()) { + if (batchActive_ && !queueItems_.empty()) { auto area = getLocalBounds().reduced(static_cast(metrics::paddingMedium)); // Mirror resized() layout: skip top row (24) + queue header row (18) area.removeFromTop(24); @@ -149,43 +156,92 @@ void TaskCenterPanel::drawQueueItem(juce::Graphics& g, juce::Rectangle bo void TaskCenterPanel::resized() { auto area = getLocalBounds().reduced(static_cast(metrics::paddingMedium)); - // Top row: state badge + task label + progress + cancel - auto topRow = area.removeFromTop(24); + // Top row: state badge + task label + progress + log toggle + copy log + cancel + auto topRow = area.removeFromTop(kTopRowHeight); cancelButton_.setBounds(topRow.removeFromRight(64).reduced(1)); copyLogButton_.setBounds(topRow.removeFromRight(84).reduced(1)); + toggleLogButton_.setBounds(topRow.removeFromRight(84).reduced(1)); progressLabel_.setBounds(topRow.removeFromRight(48).reduced(1)); auto progressArea = topRow.removeFromRight(std::min(220, topRow.getWidth() / 2)); progressBar_.setBounds(progressArea.reduced(2)); stateBadge_.setBounds(topRow.removeFromLeft(80).reduced(1)); taskLabel_.setBounds(topRow); - // Batch queue header row - auto queueHeaderRow = area.removeFromTop(18); - queueEtaLabel_.setBounds(queueHeaderRow.removeFromRight(100).reduced(1)); - queueThroughputLabel_.setBounds(queueHeaderRow.removeFromRight(80).reduced(1)); - queueHeaderLabel_.setBounds(queueHeaderRow.reduced(1)); + if (batchActive_) { + // Batch queue header row + auto queueHeaderRow = area.removeFromTop(18); + queueEtaLabel_.setBounds(queueHeaderRow.removeFromRight(100).reduced(1)); + queueThroughputLabel_.setBounds(queueHeaderRow.removeFromRight(80).reduced(1)); + queueHeaderLabel_.setBounds(queueHeaderRow.reduced(1)); + + // Queue items + if (!queueItems_.empty()) { + int queueHeight = std::min(static_cast(queueItems_.size()), kMaxVisibleQueueItems) * kQueueItemHeight; + area.removeFromTop(queueHeight + 4); + } - // Queue items - if (!queueItems_.empty()) { - int queueHeight = std::min(static_cast(queueItems_.size()), kMaxVisibleQueueItems) * kQueueItemHeight; - area.removeFromTop(queueHeight + 4); + // Batch ETA / summary row: ETA (left) + overall progress bar (middle) + counts (right) + auto summaryRow = area.removeFromTop(22); + etaLabel_.setBounds(summaryRow.removeFromLeft(92).reduced(1)); + batchCountsLabel_.setBounds(summaryRow.removeFromRight(190).reduced(1)); + batchProgressBar_.setBounds(summaryRow.reduced(2, 4)); } - // Batch ETA / summary row: ETA (left) + overall progress bar (middle) + counts (right) - auto summaryRow = area.removeFromTop(22); - etaLabel_.setBounds(summaryRow.removeFromLeft(92).reduced(1)); - batchCountsLabel_.setBounds(summaryRow.removeFromRight(190).reduced(1)); - batchProgressBar_.setBounds(summaryRow.reduced(2, 4)); - - // Remaining: history editor + // Remaining: history editor (only when the log is shown) area.removeFromTop(4); - historyEditor_.setBounds(area); + if (logVisible_) + historyEditor_.setBounds(area); } -void TaskCenterPanel::setQueueItems(const std::vector& items) { - queueItems_ = items; +int TaskCenterPanel::getPreferredHeight() const { + int height = 2 * static_cast(metrics::paddingMedium) + kTopRowHeight; + if (batchActive_) { + height += 18 + 22; + if (!queueItems_.empty()) + height += std::min(static_cast(queueItems_.size()), kMaxVisibleQueueItems) * kQueueItemHeight + 4; + } + if (logVisible_) + height += 4 + kLogAreaHeight; + return height; +} + +void TaskCenterPanel::setLogVisible(bool visible) { + if (logVisible_ == visible) + return; + logVisible_ = visible; + toggleLogButton_.setButtonText(logVisible_ ? "Hide log" : "Show log"); + historyEditor_.setVisible(logVisible_); resized(); repaint(); + if (onPreferredHeightChanged) + onPreferredHeightChanged(); +} + +bool TaskCenterPanel::isBatchStripVisible() const { + return batchActive_; +} + +bool TaskCenterPanel::isLogVisible() const { + return logVisible_; +} + +void TaskCenterPanel::refreshBatchVisibility() { + batchActive_ = batchDetail_.totalCount > 0 || !queueItems_.empty(); + queueHeaderLabel_.setVisible(batchActive_); + queueEtaLabel_.setVisible(batchActive_); + queueThroughputLabel_.setVisible(batchActive_); + etaLabel_.setVisible(batchActive_); + batchCountsLabel_.setVisible(batchActive_); + batchProgressBar_.setVisible(batchActive_); + resized(); + repaint(); + if (onPreferredHeightChanged) + onPreferredHeightChanged(); +} + +void TaskCenterPanel::setQueueItems(const std::vector& items) { + queueItems_ = items; + refreshBatchVisibility(); } void TaskCenterPanel::setQueueEta(const juce::String& eta) { @@ -214,8 +270,7 @@ void TaskCenterPanel::removeQueueItem(int index) { if (index < 0 || index >= static_cast(queueItems_.size())) return; queueItems_.erase(queueItems_.begin() + index); - resized(); - repaint(); + refreshBatchVisibility(); if (onQueueItemRemoved) onQueueItemRemoved(index); } @@ -277,6 +332,7 @@ juce::String TaskCenterPanel::formatEta(const juce::RelativeTime& eta) { } void TaskCenterPanel::updateBatchSummary() { + refreshBatchVisibility(); const size_t total = batchDetail_.totalCount; const size_t completed = batchDetail_.completedCount; const size_t failed = batchDetail_.failedCount; @@ -305,6 +361,10 @@ void TaskCenterPanel::setCanCancel(bool canCancel) { void TaskCenterPanel::setTaskState(TaskState state) { currentState_ = state; + progressLabel_.setVisible(state != TaskState::Idle); + progressBar_.setVisible(state != TaskState::Idle); // the bar paints its own "0%" text + if (state == TaskState::Failed) + setLogVisible(true); stateBadge_.setText(juce::String(stateLabel(state)), juce::dontSendNotification); stateBadge_.setColour(juce::Label::backgroundColourId, stateColour(state)); stateBadge_.setColour(juce::Label::textColourId, stateTextColour(state)); diff --git a/src/app/ui/TaskCenterPanel.h b/src/app/ui/TaskCenterPanel.h index 79b5d15..08bd239 100644 --- a/src/app/ui/TaskCenterPanel.h +++ b/src/app/ui/TaskCenterPanel.h @@ -55,7 +55,14 @@ class TaskCenterPanel final : public juce::Component { /// Current item-status counts text ("completed/failed/total"). juce::String batchCountsText() const; + /// Height needed for the top row, the batch strip (when active) and the log (when shown). + int getPreferredHeight() const; + bool isBatchStripVisible() const; + bool isLogVisible() const; + bool isProgressPercentVisible() const { return progressLabel_.isVisible(); } + // Callbacks + std::function onPreferredHeightChanged; std::function onCancel; std::function onQueueItemMoved; std::function onQueueItemRemoved; @@ -65,6 +72,8 @@ class TaskCenterPanel final : public juce::Component { static constexpr int kHistoryTrimChunkLines = 300; static constexpr int kQueueItemHeight = 24; static constexpr int kMaxVisibleQueueItems = 8; + static constexpr int kTopRowHeight = 24; + static constexpr int kLogAreaHeight = 160; static juce::Colour stateColour(TaskState state); static juce::Colour stateTextColour(TaskState state); @@ -72,12 +81,15 @@ class TaskCenterPanel final : public juce::Component { void drawQueueItem(juce::Graphics& g, juce::Rectangle bounds, const BatchQueueItem& item, int index); static juce::String formatEta(const juce::RelativeTime& eta); void updateBatchSummary(); + void refreshBatchVisibility(); + void setLogVisible(bool visible); juce::Label taskLabel_; juce::Label stateBadge_; double progressValue_ = 0.0; juce::ProgressBar progressBar_; juce::Label progressLabel_; + juce::TextButton toggleLogButton_{"Show log"}; juce::TextButton copyLogButton_{"Copy Log"}; juce::TextButton cancelButton_{"Cancel"}; juce::TextEditor historyEditor_; @@ -97,6 +109,8 @@ class TaskCenterPanel final : public juce::Component { juce::ProgressBar batchProgressBar_; TaskState currentState_ = TaskState::Idle; + bool batchActive_ = false; + bool logVisible_ = false; bool hasHistoryEntries_ = false; int historyLineCount_ = 0; diff --git a/tests/unit/ControlDeckTests.cpp b/tests/unit/ControlDeckTests.cpp new file mode 100644 index 0000000..b5daf69 --- /dev/null +++ b/tests/unit/ControlDeckTests.cpp @@ -0,0 +1,98 @@ +#include +#include + +#include +#include + +#include "app/ui/ControlDeck.h" +#include "app/ui/GlowMeters.h" + +namespace { + +juce::Button* findButtonByText(juce::Component& parent, const juce::String& text) { + for (auto* child : parent.getChildren()) { + if (auto* button = dynamic_cast(child); button != nullptr && button->getButtonText() == text) + return button; + if (child != nullptr) { + if (auto* nested = findButtonByText(*child, text)) + return nested; + } + } + return nullptr; +} + +} // namespace + +TEST_CASE("ControlDeck gates stem-dependent actions on setHasStems", "[ui][controldeck]") { + juce::ScopedJuceInitialiser_GUI juceInit; + + automix::app::ControlDeck deck; + deck.setSize(1000, 300); + + const auto enabled = [&](const char* name) { + auto* button = findButtonByText(deck, name); + REQUIRE(button != nullptr); + return button->isEnabled(); + }; + + deck.setHasStems(false); + REQUIRE_FALSE(enabled("Auto Mix")); + REQUIRE_FALSE(enabled("Auto Master")); + REQUIRE_FALSE(enabled("Mix + Master")); + REQUIRE_FALSE(enabled("Export")); + REQUIRE(enabled("Batch")); + REQUIRE(enabled("Import")); + + deck.setHasStems(true); + REQUIRE(enabled("Auto Mix")); + REQUIRE(enabled("Auto Master")); + REQUIRE(enabled("Mix + Master")); + REQUIRE(enabled("Export")); + REQUIRE(enabled("Batch")); + REQUIRE(enabled("Import")); +} + +TEST_CASE("ControlDeck shows the Vocal Model toggle only with AI Stem Separation", "[ui][controldeck]") { + juce::ScopedJuceInitialiser_GUI juceInit; + + automix::app::ControlDeck deck; + deck.setSize(1000, 300); + + deck.setSeparationControlsVisible(false); + REQUIRE_FALSE(deck.getTensorSeparationToggle().isVisible()); + + deck.setSeparationControlsVisible(true); + REQUIRE(deck.getTensorSeparationToggle().isVisible()); +} + +TEST_CASE("GlowMeters readouts show -- for no signal", "[ui][glowmeters]") { + using automix::app::GlowMeters; + REQUIRE(GlowMeters::formatReadout("M: ", -70.0, " LUFS") == "M: -- LUFS"); + REQUIRE(GlowMeters::formatReadout("TP: ", -69.95, " dBTP") == "TP: -- dBTP"); + REQUIRE(GlowMeters::formatReadout("I: ", -std::numeric_limits::infinity(), " LUFS") == "I: -- LUFS"); + REQUIRE(GlowMeters::formatReadout("S: ", -14.0, " LUFS") == "S: -14.0 LUFS"); +} + +TEST_CASE("ControlDeck settings row keeps Platform combo visible and non-overlapping", "[ui][controldeck]") { + juce::ScopedJuceInitialiser_GUI juceInit; + + for (const int width : {940, 1500}) { + automix::app::ControlDeck deck; + deck.setSize(width, 420); + + const auto platform = deck.getPlatformPresetBox().getBounds(); + const auto profile = deck.getProfileBox().getBounds(); + const auto master = deck.getMasterPresetBox().getBounds(); + REQUIRE_FALSE(platform.isEmpty()); + REQUIRE(deck.getLocalBounds().contains(platform)); + REQUIRE(platform.getRight() <= deck.getWidth()); + REQUIRE_FALSE(platform.intersects(profile)); + REQUIRE_FALSE(platform.intersects(master)); + + auto* autoMaster = findButtonByText(deck, "Auto Master"); + auto* import = findButtonByText(deck, "Import"); + REQUIRE(autoMaster != nullptr); + REQUIRE(import != nullptr); + REQUIRE(autoMaster->getHeight() == import->getHeight()); + } +} diff --git a/tests/unit/TaskCenterPanelTests.cpp b/tests/unit/TaskCenterPanelTests.cpp index 7d57270..6a2164f 100644 --- a/tests/unit/TaskCenterPanelTests.cpp +++ b/tests/unit/TaskCenterPanelTests.cpp @@ -232,3 +232,52 @@ TEST_CASE("confirmClear gates destructive clear on dialog result and non-empty s REQUIRE_FALSE(confirmClear(0, false)); } } + +TEST_CASE("TaskCenterPanel shows the batch strip only for a batch", "[ui][taskcenter][batch]") { + juce::ScopedJuceInitialiser_GUI juceInit; + + automix::app::TaskCenterPanel panel; + panel.setSize(900, 400); + REQUIRE_FALSE(panel.isBatchStripVisible()); + const int idleHeight = panel.getPreferredHeight(); + + automix::engine::BatchQueueRunner::ProgressDetail detail; + detail.totalCount = 3; + panel.setBatchProgress(detail, 0.0); + REQUIRE(panel.isBatchStripVisible()); + REQUIRE(panel.getPreferredHeight() > idleHeight); + + automix::engine::BatchQueueRunner::ProgressDetail cleared; + panel.setBatchProgress(cleared, 0.0); + REQUIRE_FALSE(panel.isBatchStripVisible()); +} + +TEST_CASE("TaskCenterPanel log is hidden by default and shown on failure", "[ui][taskcenter][log]") { + juce::ScopedJuceInitialiser_GUI juceInit; + + automix::app::TaskCenterPanel panel; + panel.setSize(900, 400); + auto* editor = findHistoryEditor(panel); + REQUIRE(editor != nullptr); + REQUIRE_FALSE(panel.isLogVisible()); + REQUIRE_FALSE(editor->isVisible()); + const int collapsedHeight = panel.getPreferredHeight(); + + panel.setTaskState(automix::app::TaskState::Failed); + REQUIRE(panel.isLogVisible()); + REQUIRE(editor->isVisible()); + REQUIRE(panel.getPreferredHeight() == collapsedHeight + 4 + 160); +} + +TEST_CASE("TaskCenterPanel hides the percent label while idle", "[ui][taskcenter]") { + juce::ScopedJuceInitialiser_GUI juceInit; + + automix::app::TaskCenterPanel panel; + REQUIRE_FALSE(panel.isProgressPercentVisible()); + + panel.setTaskState(automix::app::TaskState::Running); + REQUIRE(panel.isProgressPercentVisible()); + + panel.setTaskState(automix::app::TaskState::Idle); + REQUIRE_FALSE(panel.isProgressPercentVisible()); +} From 3c30498b709b364b2b333a0b21ea186aaa0e1fef Mon Sep 17 00:00:00 2001 From: Soficis Date: Mon, 5 Oct 2026 16:40:00 -0500 Subject: [PATCH 63/68] fix(ai): store hub sidecars flat so nested models load; join the ONNX warm-up thread at shutdown A sidecar for a model in a repo subfolder was downloaded into that subfolder, but the inline step looked it up by bare file name and never found it. All hub assets now land directly in the install directory. The warm-up thread was detached, so quitting during ONNX Runtime start-up could race library teardown. It is now joined in shutdown(). Co-Authored-By: Claude Opus 5.5 --- src/ai/HuggingFaceModelHub.cpp | 17 +++++++++++++---- src/ai/HuggingFaceModelHub.h | 5 +++++ src/ai/OrtRuntime.cpp | 16 ++++++++++++++-- src/ai/OrtRuntime.h | 7 +++++++ src/app/Main.cpp | 2 ++ tests/unit/AiExtensionTests.cpp | 7 +++++++ tests/unit/TensorInferenceTests.cpp | 8 ++++++++ 7 files changed, 56 insertions(+), 6 deletions(-) diff --git a/src/ai/HuggingFaceModelHub.cpp b/src/ai/HuggingFaceModelHub.cpp index 5096b96..9bd4d07 100644 --- a/src/ai/HuggingFaceModelHub.cpp +++ b/src/ai/HuggingFaceModelHub.cpp @@ -551,6 +551,14 @@ std::vector curatedModelIds() { }; } +std::filesystem::path localAssetPath(const std::filesystem::path& installPath, const std::string& repoPath) { + const auto name = std::filesystem::path(repoPath).filename(); + if (name.empty() || name == "." || name == "..") { + return installPath; + } + return installPath / name; +} + // Artifacts that must accompany the primary model file to form a complete pack // (the ITO-Master mastering route consumes all three as one model pack). std::vector auxiliaryAssetsFor(const std::string& repoId, @@ -852,7 +860,7 @@ HubInstallResult HuggingFaceModelHub::installModel(const std::string& modelIdOrR const auto destinationRoot = options.destinationRoot.empty() ? defaultModelHubRoot() : options.destinationRoot; const auto installKey = sanitizeRepoId(info->modelId.empty() ? info->repoId : info->modelId); const auto installPath = destinationRoot / installKey; - const auto primaryPath = installPath / std::filesystem::path(info->primaryFile).filename(); + const auto primaryPath = localAssetPath(installPath, info->primaryFile); result.installPath = installPath; result.primaryFilePath = primaryPath; result.revision = info->revision.empty() ? "main" : info->revision; @@ -903,7 +911,8 @@ HubInstallResult HuggingFaceModelHub::installModel(const std::string& modelIdOrR // ITO-Master pack needs mastering_tcn.onnx + config.json alongside the // primary fxencoder.onnx). Each is SHA-256 verified when the repo exposes it. for (const auto& auxiliaryAsset : auxiliaryAssetsFor(info->repoId, info->primaryFile, info->files)) { - const auto auxiliaryPath = installPath / auxiliaryAsset; + const auto auxiliaryPath = localAssetPath(installPath, auxiliaryAsset); + const auto auxiliaryName = auxiliaryPath.filename().string(); const auto auxiliaryUrl = "https://huggingface.co/" + info->repoId + "/resolve/" + revision + "/" + escapePathPreservingSlash(auxiliaryAsset); if (!downloadToFile(auxiliaryUrl, auxiliaryPath, effectiveToken, &detail)) { @@ -925,8 +934,8 @@ HubInstallResult HuggingFaceModelHub::installModel(const std::string& modelIdOrR return result; } } - result.downloadedFiles.push_back(auxiliaryAsset); - result.auxiliaryFiles.push_back(auxiliaryAsset); + result.downloadedFiles.push_back(auxiliaryName); + result.auxiliaryFiles.push_back(auxiliaryName); } // ONNX Runtime cannot load some external-weight exports at all (shape diff --git a/src/ai/HuggingFaceModelHub.h b/src/ai/HuggingFaceModelHub.h index 26cde38..a6fe880 100644 --- a/src/ai/HuggingFaceModelHub.h +++ b/src/ai/HuggingFaceModelHub.h @@ -122,4 +122,9 @@ std::vector auxiliaryAssetsFor(const std::string& repoId, const std::string& primaryFile, const std::vector& files); +// Where a repo file lands on disk: always directly inside `installPath`, using +// only the file's own name. Repo subfolders ("onnx/model.onnx") are dropped and +// ".." segments can never escape the install directory. +std::filesystem::path localAssetPath(const std::filesystem::path& installPath, const std::string& repoPath); + } // namespace automix::ai diff --git a/src/ai/OrtRuntime.cpp b/src/ai/OrtRuntime.cpp index 406c081..c6c9253 100644 --- a/src/ai/OrtRuntime.cpp +++ b/src/ai/OrtRuntime.cpp @@ -79,9 +79,21 @@ OrtRuntime& OrtRuntime::instance() { OrtRuntime::OrtRuntime() = default; void OrtRuntime::warmUpAsync() { - std::thread([this]() { + std::lock_guard lock(warmUpMutex_); + if (warmUpThread_.joinable() || warmUpStarted_) { + return; + } + warmUpStarted_ = true; + warmUpThread_ = std::thread([this]() { ensureInitialized(); - }).detach(); + }); +} + +void OrtRuntime::joinWarmUp() { + std::lock_guard lock(warmUpMutex_); + if (warmUpThread_.joinable()) { + warmUpThread_.join(); + } } void OrtRuntime::ensureInitialized() { diff --git a/src/ai/OrtRuntime.h b/src/ai/OrtRuntime.h index 4342a86..7db557a 100644 --- a/src/ai/OrtRuntime.h +++ b/src/ai/OrtRuntime.h @@ -3,6 +3,7 @@ #include #include #include +#include #include #ifndef AUTOMIX_HAS_NATIVE_ORT @@ -29,6 +30,8 @@ class OrtRuntime { static OrtRuntime& instance(); void warmUpAsync(); + /// Blocks until the warm-up thread (if any) has finished. Safe to call repeatedly. + void joinWarmUp(); Ort::Env& env(); std::vector availableProviders(); @@ -54,6 +57,9 @@ class OrtRuntime { std::string diagnostics_; std::string initError_; std::once_flag initOnce_; + std::mutex warmUpMutex_; + std::thread warmUpThread_; + bool warmUpStarted_ = false; }; #else @@ -66,6 +72,7 @@ class OrtRuntime { } void warmUpAsync() {} + void joinWarmUp() {} std::vector availableProviders() { return {"cpu"}; } std::string diagnostics() { return "ONNX Runtime native SDK not enabled."; } diff --git a/src/app/Main.cpp b/src/app/Main.cpp index 54fe999..d369706 100644 --- a/src/app/Main.cpp +++ b/src/app/Main.cpp @@ -51,6 +51,8 @@ class AutoMixMasterApplication final : public juce::JUCEApplication { } void shutdown() override { + // Let the ONNX Runtime warm-up thread finish before the window and libraries tear down. + automix::ai::OrtRuntime::instance().joinWarmUp(); mainWindow_.reset(); juce::LookAndFeel::setDefaultLookAndFeel(nullptr); lookAndFeel_.reset(); diff --git a/tests/unit/AiExtensionTests.cpp b/tests/unit/AiExtensionTests.cpp index 3342b2f..9a491a7 100644 --- a/tests/unit/AiExtensionTests.cpp +++ b/tests/unit/AiExtensionTests.cpp @@ -1342,3 +1342,10 @@ TEST_CASE("Linux builds enable libcurl so HTTPS downloads work", "[build-config] SUCCEED("JUCE_USE_CURL is only required on Linux; Windows and macOS use native HTTP stacks."); #endif } + +TEST_CASE("Hub assets are stored flat inside the install directory", "[ai][hub]") { + const std::filesystem::path installPath = std::filesystem::path("models") / "pack"; + CHECK(automix::ai::localAssetPath(installPath, "onnx/model.onnx.data") == installPath / "model.onnx.data"); + CHECK(automix::ai::localAssetPath(installPath, "model.onnx") == installPath / "model.onnx"); + CHECK(automix::ai::localAssetPath(installPath, "a/b/../../x.onnx") == installPath / "x.onnx"); +} diff --git a/tests/unit/TensorInferenceTests.cpp b/tests/unit/TensorInferenceTests.cpp index 62c20a0..671bc9c 100644 --- a/tests/unit/TensorInferenceTests.cpp +++ b/tests/unit/TensorInferenceTests.cpp @@ -1423,6 +1423,14 @@ TEST_CASE("OrtRuntime reports available providers and diagnostics", "[ai][tensor CHECK(!runtime.diagnostics().empty()); } +TEST_CASE("OrtRuntime warm-up can be requested twice and joined twice", "[ai][tensor][native]") { + auto& runtime = ai::OrtRuntime::instance(); + REQUIRE_NOTHROW(runtime.warmUpAsync()); + REQUIRE_NOTHROW(runtime.warmUpAsync()); + REQUIRE_NOTHROW(runtime.joinWarmUp()); + REQUIRE_NOTHROW(runtime.joinWarmUp()); +} + TEST_CASE("Separation runs on CPU when the GPU lacks free memory for the model", "[ai][tensor][gpu][native]") { const auto memory = ai::queryCudaDeviceMemory(); const char* expectCuda = std::getenv("AUTOMIX_EXPECT_CUDA"); From 42a8fb482de460e8fc1aae174238a84ff4a55f4a Mon Sep 17 00:00:00 2001 From: Soficis Date: Mon, 5 Oct 2026 16:48:43 -0500 Subject: [PATCH 64/68] fix(ui): confirm quitting over a running task, narrow click-to-import, restore the single-file tip - Quitting while a task runs now asks first, then continues into the unsaved-changes prompt. - The empty state opens Import only on a left click released inside it, and only when the session has no stems; stems without a preview show "Preview unavailable" instead of the import prompt. - The empty state again points single-file users at AI Stem Separation. - Session files saved before the master preset field existed load as Udio Optimized again, as they were mastered; new sessions still default to Default Streaming. - Unit tests cover the quit decision, the click decision and the preset fallback. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 1 + src/app/ui/HeroWaveform.cpp | 37 +++++++++++++++++------- src/app/ui/HeroWaveform.h | 10 +++++++ src/app/ui/MainLayout.cpp | 26 ++++++++++++++++- src/app/ui/MainLayout.h | 17 +++++++++-- src/domain/JsonSerialization.cpp | 4 ++- tests/unit/SessionSerializationTests.cpp | 17 ++++++++++- tests/unit/UiDecisionTests.cpp | 34 ++++++++++++++++++++++ 8 files changed, 130 insertions(+), 16 deletions(-) create mode 100644 tests/unit/UiDecisionTests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 01f72ad..63080ad 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -551,6 +551,7 @@ if(BUILD_TESTING) tests/unit/ControllerTests.cpp tests/unit/TaskCenterPanelTests.cpp tests/unit/ControlDeckTests.cpp + tests/unit/UiDecisionTests.cpp tests/unit/SessionSerializationTests.cpp src/app/ui/SessionManager.cpp tests/unit/AudioIoTests.cpp diff --git a/src/app/ui/HeroWaveform.cpp b/src/app/ui/HeroWaveform.cpp index 4530d64..8cb2dc7 100644 --- a/src/app/ui/HeroWaveform.cpp +++ b/src/app/ui/HeroWaveform.cpp @@ -127,13 +127,22 @@ void HeroWaveform::mouseDrag(const juce::MouseEvent& event) { onSeek(progress); } -void HeroWaveform::mouseUp(const juce::MouseEvent& /*event*/) { - if (waveformPeaks_.empty() && onImportRequested) +void HeroWaveform::mouseUp(const juce::MouseEvent& event) { + if (shouldOpenImportOnClick(hasStems_, event.mods.isLeftButtonDown(), event.mouseWasClicked(), + getLocalBounds().contains(event.getPosition())) && + onImportRequested) onImportRequested(); } +void HeroWaveform::setHasStems(bool hasStems) { + if (hasStems_ == hasStems) + return; + hasStems_ = hasStems; + repaint(); +} + juce::MouseCursor HeroWaveform::getMouseCursor() { - if (waveformPeaks_.empty()) + if (!hasStems_) return juce::MouseCursor::PointingHandCursor; return juce::Component::getMouseCursor(); } @@ -385,14 +394,20 @@ void HeroWaveform::paint(juce::Graphics& g) { auto upperHalf = bounds.withHeight(h * 0.5f); g.setFont(typography::subhead()); g.setColour(colour(colours::text)); - g.drawText("Drop stems here or click to import", upperHalf, juce::Justification::centredBottom); - - auto lowerHalf = bounds.withY(h * 0.5f).withHeight(h * 0.5f); - g.setFont(typography::caption()); - g.setColour(colour(colours::textMuted)); - const auto arrow = juce::String(juce::CharPointer_UTF8("\xe2\x86\x92")); - g.drawText("1 Import stems " + arrow + " 2 Mix + Master " + arrow + " 3 Export", - lowerHalf.reduced(0.0f, 8.0f), juce::Justification::centredTop); + g.drawText(hasStems_ ? "Preview unavailable" : "Drop stems here or click to import", upperHalf, + juce::Justification::centredBottom); + + if (!hasStems_) { + auto lowerHalf = bounds.withY(h * 0.5f).withHeight(h * 0.5f); + g.setFont(typography::caption()); + g.setColour(colour(colours::textMuted)); + const auto arrow = juce::String(juce::CharPointer_UTF8("\xe2\x86\x92")); + auto steps = lowerHalf.reduced(0.0f, 8.0f); + g.drawText("1 Import stems " + arrow + " 2 Mix + Master " + arrow + " 3 Export", + steps.removeFromTop(18.0f), juce::Justification::centredTop); + g.drawText("One file? Turn on AI Stem Separation first.", steps.removeFromTop(18.0f), + juce::Justification::centredTop); + } } else { // Draw stem overlay if we have per-stem data if (numStemGroups_ > 0) { diff --git a/src/app/ui/HeroWaveform.h b/src/app/ui/HeroWaveform.h index f6be808..75cd629 100644 --- a/src/app/ui/HeroWaveform.h +++ b/src/app/ui/HeroWaveform.h @@ -38,6 +38,8 @@ class HeroWaveform final : public juce::Component, void setLoopRange(bool enabled, double startProgress, double endProgress); struct StemGroup { std::vector peaks; juce::Colour colour; juce::String name; }; void setStemGroups(const std::vector& groups); + /// Whether the session has any stems; drives the empty-state prompt, click-to-import and cursor. + void setHasStems(bool hasStems); // Callbacks std::function onImportRequested; // empty-state zone clicked @@ -62,6 +64,13 @@ class HeroWaveform final : public juce::Component, return {x, kZoomControlTop, kZoomButtonSize, kZoomButtonSize}; } + /// Pure decision backing click-to-import: only a plain left-button click (not a drag) that is + /// released inside the component, on a session with no stems, opens Import. + static constexpr bool shouldOpenImportOnClick(bool hasStems, bool leftButton, bool wasClicked, + bool insideBounds) { + return !hasStems && leftButton && wasClicked && insideBounds; + } + /// Pure dirty-check seam backing setPlayheadProgress: returns true when the playhead's /// x-pixel changed relative to `lastPixel` (updating `lastPixel`), i.e. a repaint is /// actually needed. Unchanged pixels skip the repaint. @@ -117,6 +126,7 @@ class HeroWaveform final : public juce::Component, int numStemGroups_ = 0; bool isDragOver_ = false; + bool hasStems_ = false; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(HeroWaveform) }; diff --git a/src/app/ui/MainLayout.cpp b/src/app/ui/MainLayout.cpp index d01298a..bea9361 100644 --- a/src/app/ui/MainLayout.cpp +++ b/src/app/ui/MainLayout.cpp @@ -168,6 +168,8 @@ void MainLayout::refreshStemDependentUi() { const bool hasStems = !sessionManager_.session().stems.empty(); if (controlDeck_ != nullptr) controlDeck_->setHasStems(hasStems); + if (heroWaveform_ != nullptr) + heroWaveform_->setHasStems(hasStems); if (transportBar_ != nullptr) transportBar_->setHasMedia(hasStems); clearTracksButton_.setEnabled(hasStems); @@ -1631,7 +1633,13 @@ void MainLayout::refreshSessionTitle() { } bool MainLayout::requestQuit(std::function quitNow) { - if (!sessionManager_.isModified()) { + return requestQuitStep(std::move(quitNow), false); +} + +bool MainLayout::requestQuitStep(std::function quitNow, bool taskQuitConfirmed) { + const auto step = nextQuitStep(taskOrchestrator_->isTaskRunning(), taskQuitConfirmed, + sessionManager_.isModified()); + if (step == QuitStep::QuitNow) { quitNow(); return true; } @@ -1639,6 +1647,22 @@ bool MainLayout::requestQuit(std::function quitNow) { return false; quitPromptOpen_ = true; + if (step == QuitStep::ConfirmRunningTask) { + juce::AlertWindow::showOkCancelBox( + juce::MessageBoxIconType::WarningIcon, "Task still running", + "A task is still running. Quitting now will cancel it, and an unfinished export may be left " + "incomplete. Quit anyway?", + "Quit", "Cancel", this, + juce::ModalCallbackFunction::create([safe = safeAsync(this), quitNow](int result) { + if (safe == nullptr) + return; + safe->quitPromptOpen_ = false; + if (result == 1) + safe->requestQuitStep(quitNow, true); + })); + return false; + } + juce::AlertWindow::showYesNoCancelBox( juce::MessageBoxIconType::QuestionIcon, "Save changes?", "Save changes to " + sessionDisplayName_ + " before closing?", diff --git a/src/app/ui/MainLayout.h b/src/app/ui/MainLayout.h index 0238fd5..da3cd80 100644 --- a/src/app/ui/MainLayout.h +++ b/src/app/ui/MainLayout.h @@ -31,6 +31,17 @@ namespace automix::app { +/// Next step of the quit flow (pure decision, unit-tested; the prompts themselves are modal UI). +enum class QuitStep { QuitNow, ConfirmRunningTask, PromptSave }; + +/// A running task must be confirmed once before anything else; after that (or with no task) a +/// modified session gets the Save prompt and an unmodified one quits immediately. +inline QuitStep nextQuitStep(bool taskRunning, bool taskQuitConfirmed, bool modified) { + if (taskRunning && !taskQuitConfirmed) + return QuitStep::ConfirmRunningTask; + return modified ? QuitStep::PromptSave : QuitStep::QuitNow; +} + // ── Keyboard shortcut table (single source of truth) ──────────────────── // Command ids start at 1000 (0 is reserved as "no command" by JUCE). @@ -145,8 +156,9 @@ class MainLayout final : public juce::Component, void resized() override; bool keyPressed(const juce::KeyPress& key) override; - /// Runs quitNow immediately when the session is unchanged (returns true); - /// otherwise asks Save / Don't Save / Cancel and returns false. + /// Runs quitNow immediately when no task is running and the session is unchanged (returns true); + /// otherwise confirms quitting over a running task and/or asks Save / Don't Save / Cancel and + /// returns false. bool requestQuit(std::function quitNow); private: @@ -179,6 +191,7 @@ class MainLayout final : public juce::Component, void onSaveSession(std::function done = {}); void finishPendingSave(bool success); void refreshSessionTitle(); + bool requestQuitStep(std::function quitNow, bool taskQuitConfirmed); void refreshStemDependentUi(); void setSessionDisplayName(const juce::String& name); void onLoadSession(); diff --git a/src/domain/JsonSerialization.cpp b/src/domain/JsonSerialization.cpp index 4aaaa51..cccb667 100644 --- a/src/domain/JsonSerialization.cpp +++ b/src/domain/JsonSerialization.cpp @@ -311,7 +311,9 @@ void from_json(const Json& j, Session& value) { value.residualBlend = std::clamp(j.value("residualBlend", 0.0), 0.0, 10.0); value.aiStemsEnabled = j.value("aiStemsEnabled", false); value.batchRecursiveEnabled = j.value("batchRecursiveEnabled", false); - value.selectedMasterPreset = masterPresetFromString(j.value("selectedMasterPreset", "default_streaming")); + // Session files saved before this field existed were mastered with Udio Optimized, so keep that + // fallback for them; new sessions get Default Streaming from the Session struct default instead. + value.selectedMasterPreset = masterPresetFromString(j.value("selectedMasterPreset", "udio_optimized")); value.selectedPlatformPreset = masterPresetFromString(j.value("selectedPlatformPreset", "youtube")); value.stems = j.value("stems", std::vector{}); value.buses = j.value("buses", std::vector{}); diff --git a/tests/unit/SessionSerializationTests.cpp b/tests/unit/SessionSerializationTests.cpp index 4654d2f..813f2d7 100644 --- a/tests/unit/SessionSerializationTests.cpp +++ b/tests/unit/SessionSerializationTests.cpp @@ -106,7 +106,7 @@ TEST_CASE("Session deserialization handles missing optional fields", "[session]" REQUIRE(decoded.residualBlend == Catch::Approx(0.0)); REQUIRE(decoded.aiStemsEnabled == false); REQUIRE(decoded.batchRecursiveEnabled == false); - REQUIRE(decoded.selectedMasterPreset == automix::domain::MasterPreset::DefaultStreaming); + REQUIRE(decoded.selectedMasterPreset == automix::domain::MasterPreset::UdioOptimized); REQUIRE(decoded.selectedPlatformPreset == automix::domain::MasterPreset::YouTube); REQUIRE(decoded.renderSettings.blockSize == 1024); REQUIRE(decoded.renderSettings.outputFormat == "auto"); @@ -186,6 +186,21 @@ TEST_CASE("Default session uses the Default Streaming master preset", "[session] REQUIRE(session.selectedMasterPreset == automix::domain::MasterPreset::DefaultStreaming); } +TEST_CASE("Old session without selectedMasterPreset keeps the Udio Optimized fallback", "[session]") { + const nlohmann::json legacy = {{"schemaVersion", 1}, {"sessionName", "legacy"}}; + REQUIRE(legacy.get().selectedMasterPreset == + automix::domain::MasterPreset::UdioOptimized); +} + +TEST_CASE("Explicit default_streaming master preset round-trips", "[session]") { + automix::domain::Session session; + REQUIRE(session.selectedMasterPreset == automix::domain::MasterPreset::DefaultStreaming); + const nlohmann::json encoded = session; + REQUIRE(encoded.at("selectedMasterPreset").get() == "default_streaming"); + REQUIRE(encoded.get().selectedMasterPreset == + automix::domain::MasterPreset::DefaultStreaming); +} + TEST_CASE("SessionManager tracks unsaved changes", "[session]") { automix::app::SessionManager manager; manager.markSaved(); diff --git a/tests/unit/UiDecisionTests.cpp b/tests/unit/UiDecisionTests.cpp new file mode 100644 index 0000000..36a6b95 --- /dev/null +++ b/tests/unit/UiDecisionTests.cpp @@ -0,0 +1,34 @@ +#include + +#include "app/ui/HeroWaveform.h" +#include "app/ui/MainLayout.h" + +using automix::app::HeroWaveform; +using automix::app::QuitStep; +using automix::app::nextQuitStep; + +TEST_CASE("Quit flow confirms a running task before anything else", "[ui][quit]") { + // No task: modified -> save prompt, unmodified -> quit now. + REQUIRE(nextQuitStep(false, false, false) == QuitStep::QuitNow); + REQUIRE(nextQuitStep(false, false, true) == QuitStep::PromptSave); + REQUIRE(nextQuitStep(false, true, false) == QuitStep::QuitNow); + REQUIRE(nextQuitStep(false, true, true) == QuitStep::PromptSave); + // Task running and not yet confirmed: always ask first. + REQUIRE(nextQuitStep(true, false, false) == QuitStep::ConfirmRunningTask); + REQUIRE(nextQuitStep(true, false, true) == QuitStep::ConfirmRunningTask); + // Confirmed: continue into the unsaved-changes flow. + REQUIRE(nextQuitStep(true, true, false) == QuitStep::QuitNow); + REQUIRE(nextQuitStep(true, true, true) == QuitStep::PromptSave); +} + +TEST_CASE("Click-to-import only fires for a left click inside an empty session", "[ui][import]") { + for (int bits = 0; bits < 16; ++bits) { + const bool hasStems = (bits & 1) != 0; + const bool left = (bits & 2) != 0; + const bool clicked = (bits & 4) != 0; + const bool inside = (bits & 8) != 0; + const bool expected = !hasStems && left && clicked && inside; + INFO("hasStems=" << hasStems << " left=" << left << " clicked=" << clicked << " inside=" << inside); + REQUIRE(HeroWaveform::shouldOpenImportOnClick(hasStems, left, clicked, inside) == expected); + } +} From a07d5dd58d68f066283aca70c195dbd93d7cdfbb Mon Sep 17 00:00:00 2001 From: Soficis Date: Mon, 5 Oct 2026 17:12:36 -0500 Subject: [PATCH 65/68] fix(util): pin every LAME download to a SHA-256 and stop trusting unverified cached binaries The MP3 fallback downloaded an encoder and ran it with no integrity check beyond "--version prints something". - Each built-in source now carries the SHA-256 of the exact archive. A download that does not match is deleted before anything is extracted or run, and a source with no hash is refused. - Homebrew bottles are fetched directly by their pinned digest instead of resolving a mutable tag through two manifests. - AUTOMIX_LAME_DOWNLOAD_URL and AUTOMIX_LAME_VERSION now require AUTOMIX_LAME_DOWNLOAD_SHA256. - The cached binary is used only when a marker written by a verified install still matches it, so binaries cached before this change are downloaded again. - Windows on ARM uses the x64 build; its previous URL returns 404. Debian and Homebrew hashes come from their published indexes. rarewares.org publishes none, so the two Windows pins are the files as served on 2026-10-05. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 1 + src/util/LameDownloader.cpp | 234 +++++++++++++++++------------ src/util/LameDownloader.h | 13 ++ src/util/WavWriter.cpp | 4 +- tests/unit/LameDownloaderTests.cpp | 54 +++++++ 5 files changed, 206 insertions(+), 100 deletions(-) create mode 100644 tests/unit/LameDownloaderTests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index cdb1919..04eda45 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -551,6 +551,7 @@ if(BUILD_TESTING) tests/unit/ControllerTests.cpp tests/unit/TaskCenterPanelTests.cpp tests/unit/SessionSerializationTests.cpp + tests/unit/LameDownloaderTests.cpp tests/unit/AudioIoTests.cpp tests/unit/AnalysisTests.cpp tests/unit/StemOriginSafetyTests.cpp diff --git a/src/util/LameDownloader.cpp b/src/util/LameDownloader.cpp index 7ed0287..95e075d 100644 --- a/src/util/LameDownloader.cpp +++ b/src/util/LameDownloader.cpp @@ -19,6 +19,7 @@ #include #include "util/FileUtils.h" +#include "util/Sha256.h" #include "util/StringUtils.h" namespace automix::util { @@ -38,13 +39,18 @@ enum class SourceType { Ghcr, }; +// Every source is an archive that ends up executed, so each one carries the +// SHA-256 of the exact file it must deliver. A source with no hash is refused. +// For Ghcr the hash is also the address: Homebrew bottles are stored as blobs +// named by their own SHA-256, so the pinned bottle is fetched directly. struct DownloadSource { SourceType type = SourceType::Zip; std::string url; - std::string ghcrOs; - std::string ghcrArch; + std::string sha256; }; +const std::string kGhcrBlobBaseUrl = "https://ghcr.io/v2/homebrew/core/lame/blobs/sha256:"; + struct TempDirectory { explicit TempDirectory(const std::string& prefix) { const auto base = @@ -152,65 +158,80 @@ std::string platformKey() { #endif } -std::vector platformSources() { - const auto version = readEnvironment("AUTOMIX_LAME_VERSION").value_or(kDefaultLameVersion); - if (const auto manualUrl = readEnvironment("AUTOMIX_LAME_DOWNLOAD_URL"); manualUrl.has_value()) { - const auto lower = toLower(*manualUrl); - if (lower.ends_with(".zip")) { - return {{SourceType::Zip, *manualUrl, "", ""}}; - } - if (lower.ends_with(".deb")) { - return {{SourceType::Debian, *manualUrl, "", ""}}; - } - return {{SourceType::DirectBinary, *manualUrl, "", ""}}; - } - - const auto key = platformKey(); - if (key == "win32-x64") { +// Sources for `key` at `version`. The hashes are only valid for the default +// version; platformSources() replaces them when the version is overridden. +// Hash origins: Debian's package index for the .deb files, the Homebrew bottle +// index for the Ghcr blobs. rarewares.org publishes no hashes, so the two ZIP +// pins were taken from the files as served on 2026-10-05. +std::vector sourcesForPlatform(const std::string& key, const std::string& version) { + const std::string rarewares = "https://www.rarewares.org/files/mp3/lame" + version; + const std::string debian = "https://deb.debian.org/debian/pool/main/l/lame/lame_" + version + "-6_"; + if (key == "win32-x64" || key == "win32-arm64") { + // There is no ARM64 build to pin; Windows on ARM runs the x64 one. return { - {SourceType::Zip, "https://www.rarewares.org/files/mp3/lame" + version + ".1-x64.zip", "", ""}, + {SourceType::Zip, rarewares + ".1-x64.zip", "9a9c815203316e5203847e93100c6acf0d5d7a5be7744c9018825ded037052e7"}, }; } if (key == "win32-ia32") { return { - {SourceType::Zip, "https://www.rarewares.org/files/mp3/lame" + version + ".1-win32.zip", "", ""}, - }; - } - if (key == "win32-arm64") { - return { - {SourceType::Zip, "https://www.rarewares.org/files/mp3/LAME-" + version + "-Win-ARM64.zip", "", ""}, + {SourceType::Zip, rarewares + ".1-win32.zip", "2518e1138953c235fb2bfcefbc38883dd04538d6ae0a19692562576ba37bafec"}, }; } if (key == "linux-x64") { return { - {SourceType::Debian, "https://deb.debian.org/debian/pool/main/l/lame/lame_" + version + "-6_amd64.deb", "", ""}, - {SourceType::Ghcr, "", "linux", "amd64"}, + {SourceType::Debian, debian + "amd64.deb", "786ba06d2f222661e1f09b610de7b18c60f411a373d4fd3f595ec890f062089e"}, + {SourceType::Ghcr, "", "ee8318f10b1b986d57826f0f59800c43f62d58e8d52cf9c94b8924e28739e656"}, }; } if (key == "linux-arm64") { return { - {SourceType::Debian, "https://deb.debian.org/debian/pool/main/l/lame/lame_" + version + "-6_arm64.deb", "", ""}, - {SourceType::Ghcr, "", "linux", "arm64"}, + {SourceType::Debian, debian + "arm64.deb", "aba5023ffde46709e4bccc9e1c10142a7d77f2884d2a9af84cab6a28f8792bd2"}, + {SourceType::Ghcr, "", "3e9bc793b37a72ce61d28dbbdb8dd160a0785e91b7d9ab6e964ba9e6a8a549d4"}, }; } if (key == "linux-arm") { return { - {SourceType::Debian, "https://deb.debian.org/debian/pool/main/l/lame/lame_" + version + "-6_armhf.deb", "", ""}, + {SourceType::Debian, debian + "armhf.deb", "f77e72665a30bae6d83ca3719845309a2db12b1adf27c422415c4a12930ea76b"}, }; } if (key == "darwin-x64") { return { - {SourceType::Ghcr, "", "darwin", "amd64"}, + {SourceType::Ghcr, "", "737751faa513a68ac2499bb5cc607bc366e15dab8ff3bff5443567a455af5c3f"}, }; } if (key == "darwin-arm64") { return { - {SourceType::Ghcr, "", "darwin", "arm64"}, + {SourceType::Ghcr, "", "2ff2c6ad3cfd26e1ba53230631e2f04734a4638c344cce50ff0b8fc36b45c403"}, }; } return {}; } +// AUTOMIX_LAME_DOWNLOAD_URL and AUTOMIX_LAME_VERSION point at files the built-in +// hashes do not cover, so they only work together with AUTOMIX_LAME_DOWNLOAD_SHA256. +std::vector platformSources() { + const auto manualSha = toLower(readEnvironment("AUTOMIX_LAME_DOWNLOAD_SHA256").value_or("")); + if (const auto manualUrl = readEnvironment("AUTOMIX_LAME_DOWNLOAD_URL"); manualUrl.has_value()) { + const auto lower = toLower(*manualUrl); + if (lower.ends_with(".zip")) { + return {{SourceType::Zip, *manualUrl, manualSha}}; + } + if (lower.ends_with(".deb")) { + return {{SourceType::Debian, *manualUrl, manualSha}}; + } + return {{SourceType::DirectBinary, *manualUrl, manualSha}}; + } + + const auto version = readEnvironment("AUTOMIX_LAME_VERSION").value_or(kDefaultLameVersion); + auto sources = sourcesForPlatform(platformKey(), version); + if (version != kDefaultLameVersion) { + for (auto& source : sources) { + source.sha256 = manualSha; + } + } + return sources; +} + bool runProcess(const juce::StringArray& command, const int timeoutMs, std::string* processOutput, @@ -306,6 +327,26 @@ bool downloadToFile(const std::string& url, return true; } +// Downloads a source and refuses it unless it matches its hash. Nothing from the +// file is extracted or run before this returns true. +bool downloadVerified(const std::string& url, + const std::string& sha256, + const std::filesystem::path& outputPath, + const std::string& extraHeaders, + std::string* detail) { + if (!isSha256Hex(sha256)) { + if (detail != nullptr) { + *detail = "No SHA-256 is pinned for this download (set AUTOMIX_LAME_DOWNLOAD_SHA256 when overriding " + "the URL or version): " + url; + } + return false; + } + if (!downloadToFile(url, outputPath, extraHeaders, detail)) { + return false; + } + return LameDownloader::verifyDownload(outputPath, sha256, detail); +} + std::optional fetchJson(const std::string& url, const std::string& extraHeaders, std::string* detail) { @@ -561,7 +602,7 @@ bool copyBinaryToCache(const std::filesystem::path& source, const std::filesyste bool installFromZip(const DownloadSource& source, const std::filesystem::path& targetBinary, std::string* detail) { TempDirectory temp("automix_lame_zip"); const auto archivePath = temp.path / "lame.zip"; - if (!downloadToFile(source.url, archivePath, "", detail)) { + if (!downloadVerified(source.url, source.sha256, archivePath, "", detail)) { return false; } @@ -630,7 +671,7 @@ bool installFromDebian(const DownloadSource& source, const std::filesystem::path #else TempDirectory temp("automix_lame_deb"); const auto debPath = temp.path / "lame.deb"; - if (!downloadToFile(source.url, debPath, "", detail)) { + if (!downloadVerified(source.url, source.sha256, debPath, "", detail)) { return false; } @@ -681,8 +722,6 @@ bool installFromDebian(const DownloadSource& source, const std::filesystem::path } bool installFromGhcr(const DownloadSource& source, const std::filesystem::path& targetBinary, std::string* detail) { - const auto version = readEnvironment("AUTOMIX_LAME_VERSION").value_or(kDefaultLameVersion); - const auto tokenJson = fetchJson("https://ghcr.io/token?service=ghcr.io&scope=repository:homebrew/core/lame:pull", "", detail); if (!tokenJson.has_value() || !tokenJson->contains("token")) { if (detail != nullptr && detail->empty()) { @@ -699,67 +738,13 @@ bool installFromGhcr(const DownloadSource& source, const std::filesystem::path& return false; } - const std::string authHeader = "Authorization: Bearer " + token + "\n"; - const auto manifestList = fetchJson( - "https://ghcr.io/v2/homebrew/core/lame/manifests/" + version, - authHeader + "Accept: application/vnd.oci.image.index.v1+json\n", - detail); - if (!manifestList.has_value() || !manifestList->contains("manifests")) { - if (detail != nullptr && detail->empty()) { - *detail = "Failed to fetch GHCR manifest list."; - } - return false; - } - - std::string manifestDigest; - for (const auto& manifest : (*manifestList)["manifests"]) { - const auto platform = manifest.value("platform", nlohmann::json::object()); - if (platform.value("os", "") == source.ghcrOs && platform.value("architecture", "") == source.ghcrArch) { - manifestDigest = manifest.value("digest", ""); - break; - } - } - if (manifestDigest.empty()) { - if (detail != nullptr) { - *detail = "No GHCR manifest found for " + source.ghcrOs + "/" + source.ghcrArch; - } - return false; - } - - const auto manifest = fetchJson( - "https://ghcr.io/v2/homebrew/core/lame/manifests/" + manifestDigest, - authHeader + "Accept: application/vnd.oci.image.manifest.v1+json\n", - detail); - if (!manifest.has_value() || !manifest->contains("layers")) { - if (detail != nullptr && detail->empty()) { - *detail = "Failed to fetch GHCR image manifest."; - } - return false; - } - - std::string layerDigest; - std::string mediaType; - for (const auto& layer : (*manifest)["layers"]) { - mediaType = layer.value("mediaType", ""); - if (mediaType.find("tar") != std::string::npos) { - layerDigest = layer.value("digest", ""); - break; - } - } - if (layerDigest.empty()) { - if (detail != nullptr) { - *detail = "No tar layer found in GHCR image manifest."; - } - return false; - } - TempDirectory temp("automix_lame_ghcr"); - const bool gzipLayer = mediaType.find("gzip") != std::string::npos; - const auto layerPath = temp.path / (gzipLayer ? "layer.tar.gz" : "layer.tar"); - if (!downloadToFile("https://ghcr.io/v2/homebrew/core/lame/blobs/" + layerDigest, - layerPath, - authHeader + "Accept: application/octet-stream\n", - detail)) { + const auto layerPath = temp.path / "layer.tar.gz"; + if (!downloadVerified(kGhcrBlobBaseUrl + source.sha256, + source.sha256, + layerPath, + "Authorization: Bearer " + token + "\nAccept: application/octet-stream\n", + detail)) { return false; } @@ -773,7 +758,7 @@ bool installFromGhcr(const DownloadSource& source, const std::filesystem::path& return false; } - if (!extractTarArchive(layerPath, extractDir, gzipLayer ? "z" : "", "", detail)) { + if (!extractTarArchive(layerPath, extractDir, "z", "", detail)) { return false; } @@ -795,10 +780,62 @@ std::filesystem::path internalCacheBinaryPath() { return appData / "AutoMixMaster" / "codecs" / "lame" / key / binaryName(); } +// The cached binary is only trusted while this file, written after a verified +// install, still holds the binary's own hash. A binary cached before downloads +// were verified has no such file and is fetched again. +std::filesystem::path cacheMarkerPath(const std::filesystem::path& binary) { + return std::filesystem::path(binary.string() + ".sha256"); +} + +void writeCacheMarker(const std::filesystem::path& binary) { + std::ofstream marker(cacheMarkerPath(binary), std::ios::binary | std::ios::trunc); + marker << fileSha256(binary); +} + } // namespace std::filesystem::path LameDownloader::cacheBinaryPath() { return internalCacheBinaryPath(); } +bool LameDownloader::cachedBinaryIsVerified() { + const auto binary = cacheBinaryPath(); + if (!isRegularFile(binary)) { + return false; + } + std::ifstream marker(cacheMarkerPath(binary), std::ios::binary); + std::string recorded; + marker >> recorded; + return isSha256Hex(recorded) && recorded == fileSha256(binary); +} + +std::vector LameDownloader::pinnedSources() { + std::vector pins; + for (const char* key : {"win32-x64", "win32-ia32", "win32-arm64", "linux-x64", "linux-arm64", "linux-arm", + "darwin-x64", "darwin-arm64"}) { + for (const auto& source : sourcesForPlatform(key, kDefaultLameVersion)) { + pins.push_back({key, source.type == SourceType::Ghcr ? kGhcrBlobBaseUrl + source.sha256 : source.url, + source.sha256}); + } + } + return pins; +} + +bool LameDownloader::verifyDownload(const std::filesystem::path& file, + const std::string& expectedSha256, + std::string* detail) { + const auto actual = fileSha256(file); + if (isSha256Hex(expectedSha256) && actual == toLower(expectedSha256)) { + return true; + } + + std::error_code error; + std::filesystem::remove(file, error); + if (detail != nullptr) { + *detail = "SHA-256 mismatch for " + file.filename().string() + " (expected " + expectedSha256 + ", got " + + (actual.empty() ? "unreadable file" : actual) + "); the download was discarded."; + } + return false; +} + bool LameDownloader::isSupportedOnCurrentPlatform() { return !platformSources().empty(); } LameDownloader::DownloadResult LameDownloader::ensureAvailable(const bool forceDownload) { @@ -807,7 +844,7 @@ LameDownloader::DownloadResult LameDownloader::ensureAvailable(const bool forceD DownloadResult result; const auto targetBinary = cacheBinaryPath(); - if (!forceDownload && !flagEnabled("AUTOMIX_LAME_FORCE_DOWNLOAD") && isRegularFile(targetBinary)) { + if (!forceDownload && !flagEnabled("AUTOMIX_LAME_FORCE_DOWNLOAD") && cachedBinaryIsVerified()) { std::string detail; if (ensureExecutable(targetBinary, &detail)) { result.success = true; @@ -837,7 +874,7 @@ LameDownloader::DownloadResult LameDownloader::ensureAvailable(const bool forceD case SourceType::DirectBinary: { TempDirectory temp("automix_lame_direct"); const auto downloadedPath = temp.path / binaryName(); - if (downloadToFile(source.url, downloadedPath, "", &attemptDetail)) { + if (downloadVerified(source.url, source.sha256, downloadedPath, "", &attemptDetail)) { installed = copyBinaryToCache(downloadedPath, targetBinary, &attemptDetail); } break; @@ -854,6 +891,7 @@ LameDownloader::DownloadResult LameDownloader::ensureAvailable(const bool forceD } if (installed) { + writeCacheMarker(targetBinary); result.success = true; result.executablePath = targetBinary; result.detail = "Downloaded fallback LAME binary to " + targetBinary.string(); diff --git a/src/util/LameDownloader.h b/src/util/LameDownloader.h index dae5314..a77c67b 100644 --- a/src/util/LameDownloader.h +++ b/src/util/LameDownloader.h @@ -2,6 +2,7 @@ #include #include +#include namespace automix::util { @@ -14,7 +15,19 @@ class LameDownloader { std::string detail; }; + struct PinnedSource { + std::string platformKey; + std::string url; + std::string sha256; + }; + static std::filesystem::path cacheBinaryPath(); + /// True when the cached binary exists and is the one a verified install left there. + static bool cachedBinaryIsVerified(); + /// Every built-in download with the SHA-256 it must match, for all platforms. + static std::vector pinnedSources(); + /// Checks a downloaded file against its pinned SHA-256; a mismatch deletes the file. + static bool verifyDownload(const std::filesystem::path& file, const std::string& expectedSha256, std::string* detail); static bool isSupportedOnCurrentPlatform(); static DownloadResult ensureAvailable(bool forceDownload = false); }; diff --git a/src/util/WavWriter.cpp b/src/util/WavWriter.cpp index 765569b..cfdd6ff 100644 --- a/src/util/WavWriter.cpp +++ b/src/util/WavWriter.cpp @@ -252,8 +252,8 @@ std::optional resolveBundledLameExecutable() { } std::optional findLameExecutable() { - if (const auto downloaded = LameDownloader::cacheBinaryPath(); isRegularFile(downloaded)) { - return downloaded; + if (LameDownloader::cachedBinaryIsVerified()) { + return LameDownloader::cacheBinaryPath(); } if (const auto bundled = resolveBundledLameExecutable(); bundled.has_value()) { diff --git a/tests/unit/LameDownloaderTests.cpp b/tests/unit/LameDownloaderTests.cpp new file mode 100644 index 0000000..cbae03f --- /dev/null +++ b/tests/unit/LameDownloaderTests.cpp @@ -0,0 +1,54 @@ +#include +#include +#include +#include + +#include + +#include "util/LameDownloader.h" +#include "util/Sha256.h" + +using automix::util::LameDownloader; + +TEST_CASE("Every LAME download source is pinned to a SHA-256 over HTTPS", "[util][lame]") { + const auto pins = LameDownloader::pinnedSources(); + REQUIRE(!pins.empty()); + + std::set platforms; + for (const auto& pin : pins) { + INFO(pin.platformKey << " " << pin.url); + CHECK(automix::util::isSha256Hex(pin.sha256)); + CHECK(pin.url.rfind("https://", 0) == 0); + platforms.insert(pin.platformKey); + } + for (const char* key : {"win32-x64", "win32-ia32", "win32-arm64", "linux-x64", "linux-arm64", "linux-arm", + "darwin-x64", "darwin-arm64"}) { + INFO(key); + CHECK(platforms.count(key) == 1); + } +} + +TEST_CASE("A LAME download that does not match its pin is rejected and deleted", "[util][lame]") { + const auto dir = std::filesystem::temp_directory_path() / "automix_lame_pin_test"; + std::filesystem::create_directories(dir); + const auto file = dir / "lame.zip"; + const auto write = [&file]() { std::ofstream(file, std::ios::binary) << "not really lame"; }; + + write(); + const auto actual = automix::util::fileSha256(file); + std::string detail; + CHECK(LameDownloader::verifyDownload(file, actual, &detail)); + CHECK(std::filesystem::exists(file)); + + std::string wrong = actual; + wrong[0] = wrong[0] == '0' ? '1' : '0'; + CHECK_FALSE(LameDownloader::verifyDownload(file, wrong, &detail)); + CHECK(detail.find("mismatch") != std::string::npos); + CHECK_FALSE(std::filesystem::exists(file)); + + write(); + CHECK_FALSE(LameDownloader::verifyDownload(file, "", &detail)); + CHECK_FALSE(std::filesystem::exists(file)); + + std::filesystem::remove_all(dir); +} From 5e5b9038ad8de0a1cc3b3bc5795d148b4f536025 Mon Sep 17 00:00:00 2001 From: Soficis Date: Mon, 5 Oct 2026 17:27:01 -0500 Subject: [PATCH 66/68] feat(util): LAME pins follow upstream through a reviewed, published pin list Fixed pins alone break the MP3 fallback when an upstream replaces a file (Debian stable has already moved to 3.100-6+b3 and Homebrew to LAME 4.0). - assets/lame-pins.json is the current pin list. The app reads it from master before downloading and tries those sources first; the built-in pins remain as the fallback when the list is unreachable, invalid, or names a build that does not run on the machine. - The list may only name files under the known rarewares.org and Debian locations, or a Homebrew bottle digest. One bad entry rejects the list. - tools/update_lame_pins.py regenerates the list from Debian's package index, the Homebrew bottle index and the rarewares.org files. - A weekly workflow runs it and opens a pull request when pins change. It is not merged automatically. - AUTOMIX_LAME_SKIP_PIN_UPDATE=1 keeps the app on the built-in pins. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/lame_pins.yml | 56 ++++++++++++++ assets/lame-pins.json | 61 +++++++++++++++ src/util/LameDownloader.cpp | 115 ++++++++++++++++++++++++++-- src/util/LameDownloader.h | 4 + tests/unit/LameDownloaderTests.cpp | 55 +++++++++++++ tools/update_lame_pins.py | 119 +++++++++++++++++++++++++++++ 6 files changed, 405 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/lame_pins.yml create mode 100644 assets/lame-pins.json create mode 100644 tools/update_lame_pins.py diff --git a/.github/workflows/lame_pins.yml b/.github/workflows/lame_pins.yml new file mode 100644 index 0000000..2e9d39d --- /dev/null +++ b/.github/workflows/lame_pins.yml @@ -0,0 +1,56 @@ +name: LAME Pin Update + +# Keeps assets/lame-pins.json in step with upstream LAME packages. The app reads +# that file from master, so merging the pull request this opens is what updates +# installed copies. It is never merged automatically: a changed hash is exactly +# what a tampered upstream would look like, so a person confirms it first. + +on: + schedule: + - cron: "0 5 * * 1" + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + update-pins: + runs-on: ubuntu-24.04 + + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: master + + - name: Refresh pins + run: python3 tools/update_lame_pins.py | tee pin-changes.txt + + - name: Open pull request + env: + GH_TOKEN: ${{ github.token }} + run: | + if git diff --quiet -- assets/lame-pins.json; then + echo "Pins are current." + exit 0 + fi + branch=chore/lame-pins + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git switch -C "$branch" + git add assets/lame-pins.json + git commit -m "chore(util): refresh LAME download pins" + git push --force origin "$branch" + { + echo "Upstream LAME packages changed. Merging this updates the pins every installed copy uses." + echo + echo "Check each change against its source before merging. Windows (zip) changes have no independent confirmation." + echo + echo '```' + cat pin-changes.txt + echo '```' + } > pr-body.md + gh pr view "$branch" --json state -q .state 2>/dev/null | grep -q OPEN \ + && gh pr edit "$branch" --body-file pr-body.md \ + || gh pr create --base master --head "$branch" --title "chore(util): refresh LAME download pins" --body-file pr-body.md diff --git a/assets/lame-pins.json b/assets/lame-pins.json new file mode 100644 index 0000000..6146d85 --- /dev/null +++ b/assets/lame-pins.json @@ -0,0 +1,61 @@ +{ + "schema": 1, + "sources": [ + { + "platform": "darwin-arm64", + "type": "ghcr", + "sha256": "b0cfa1500aff96430c865fa5e3e8b5494bb9ff70dd4f4fe8f9e7684da626649a" + }, + { + "platform": "darwin-x64", + "type": "ghcr", + "sha256": "62e5e6acdb340cfdae39e4a4ad49e8b2efcd46bfe91897b670a2c0d5a0693c19" + }, + { + "platform": "linux-arm", + "type": "deb", + "url": "https://deb.debian.org/debian/pool/main/l/lame/lame_3.100-6+b3_armhf.deb", + "sha256": "b7c5a96b803db07f67b74b7611240979797fb6eab0b4705e23eb0ce639b9078e" + }, + { + "platform": "linux-arm64", + "type": "deb", + "url": "https://deb.debian.org/debian/pool/main/l/lame/lame_3.100-6+b3_arm64.deb", + "sha256": "4c6c6ee693633c846de685902641dbda18ef98f73a136ebef7c09cf6ee683bcd" + }, + { + "platform": "linux-arm64", + "type": "ghcr", + "sha256": "bd3d4df9fd0722b758bea12328bd1345d2ca507842f4074b88fe5d10cae13b73" + }, + { + "platform": "linux-x64", + "type": "deb", + "url": "https://deb.debian.org/debian/pool/main/l/lame/lame_3.100-6+b3_amd64.deb", + "sha256": "feceeb296f9df9340f6e2ce48decce73c9cd6c70e7fe959495eb89ee59bc8d47" + }, + { + "platform": "linux-x64", + "type": "ghcr", + "sha256": "260e9309ef40e8ad7373bfae07f20d5357e036d32b14ea3ce6526fdf15181a37" + }, + { + "platform": "win32-arm64", + "type": "zip", + "url": "https://www.rarewares.org/files/mp3/lame3.100.1-x64.zip", + "sha256": "9a9c815203316e5203847e93100c6acf0d5d7a5be7744c9018825ded037052e7" + }, + { + "platform": "win32-ia32", + "type": "zip", + "url": "https://www.rarewares.org/files/mp3/lame3.100.1-win32.zip", + "sha256": "2518e1138953c235fb2bfcefbc38883dd04538d6ae0a19692562576ba37bafec" + }, + { + "platform": "win32-x64", + "type": "zip", + "url": "https://www.rarewares.org/files/mp3/lame3.100.1-x64.zip", + "sha256": "9a9c815203316e5203847e93100c6acf0d5d7a5be7744c9018825ded037052e7" + } + ] +} diff --git a/src/util/LameDownloader.cpp b/src/util/LameDownloader.cpp index 95e075d..abf24ec 100644 --- a/src/util/LameDownloader.cpp +++ b/src/util/LameDownloader.cpp @@ -51,6 +51,18 @@ struct DownloadSource { const std::string kGhcrBlobBaseUrl = "https://ghcr.io/v2/homebrew/core/lame/blobs/sha256:"; +// The pin list on the default branch, kept current by tools/update_lame_pins.py. +// It lets installed copies follow upstream LAME updates without a release. It +// can only name files on the hosts below, so changing it is not enough to make +// the app run something else: the named host must also serve the matching file. +constexpr const char* kPinManifestUrl = + "https://raw.githubusercontent.com/soficis/AutoMixMaster/master/assets/lame-pins.json"; +constexpr int kPinManifestTimeoutMs = 8000; +constexpr const char* kAllowedDownloadPrefixes[] = { + "https://www.rarewares.org/files/mp3/", + "https://deb.debian.org/debian/pool/main/l/lame/", +}; + struct TempDirectory { explicit TempDirectory(const std::string& prefix) { const auto base = @@ -207,9 +219,36 @@ std::vector sourcesForPlatform(const std::string& key, const std return {}; } +std::optional fetchJson(const std::string& url, + const std::string& extraHeaders, + std::string* detail, + int timeoutMs = 45000); + +// Sources for this platform from the published pin list, or none when it cannot +// be fetched or does not validate. +std::vector publishedSources() { + const auto manifest = fetchJson(kPinManifestUrl, "", nullptr, kPinManifestTimeoutMs); + if (!manifest.has_value()) { + return {}; + } + + std::vector sources; + for (const auto& pin : LameDownloader::parsePinManifest(manifest->dump(), nullptr)) { + if (pin.platformKey != platformKey()) { + continue; + } + const auto type = pin.type == "zip" ? SourceType::Zip : pin.type == "deb" ? SourceType::Debian : SourceType::Ghcr; + sources.push_back({type, type == SourceType::Ghcr ? std::string() : pin.url, pin.sha256}); + } + return sources; +} + // AUTOMIX_LAME_DOWNLOAD_URL and AUTOMIX_LAME_VERSION point at files the built-in // hashes do not cover, so they only work together with AUTOMIX_LAME_DOWNLOAD_SHA256. -std::vector platformSources() { +// With `allowPublished`, the published pin list is tried first and the built-in +// pins stay as the fallback (offline, list unreachable, or a newer build that +// does not run on this machine). +std::vector platformSources(const bool allowPublished = false) { const auto manualSha = toLower(readEnvironment("AUTOMIX_LAME_DOWNLOAD_SHA256").value_or("")); if (const auto manualUrl = readEnvironment("AUTOMIX_LAME_DOWNLOAD_URL"); manualUrl.has_value()) { const auto lower = toLower(*manualUrl); @@ -228,6 +267,19 @@ std::vector platformSources() { for (auto& source : sources) { source.sha256 = manualSha; } + return sources; + } + + if (allowPublished && !flagEnabled("AUTOMIX_LAME_SKIP_PIN_UPDATE")) { + auto published = publishedSources(); + for (auto& source : sources) { + const bool known = std::any_of(published.begin(), published.end(), + [&source](const DownloadSource& other) { return other.sha256 == source.sha256; }); + if (!known) { + published.push_back(std::move(source)); + } + } + return published; } return sources; } @@ -349,11 +401,12 @@ bool downloadVerified(const std::string& url, std::optional fetchJson(const std::string& url, const std::string& extraHeaders, - std::string* detail) { + std::string* detail, + const int timeoutMs) { int statusCode = 0; const auto baseOptions = juce::URL::InputStreamOptions(juce::URL::ParameterHandling::inAddress) - .withConnectionTimeoutMs(45000) + .withConnectionTimeoutMs(timeoutMs) .withNumRedirectsToFollow(8) .withStatusCode(&statusCode); const auto input = juce::URL(url).createInputStream( @@ -812,13 +865,65 @@ std::vector LameDownloader::pinnedSources() { for (const char* key : {"win32-x64", "win32-ia32", "win32-arm64", "linux-x64", "linux-arm64", "linux-arm", "darwin-x64", "darwin-arm64"}) { for (const auto& source : sourcesForPlatform(key, kDefaultLameVersion)) { - pins.push_back({key, source.type == SourceType::Ghcr ? kGhcrBlobBaseUrl + source.sha256 : source.url, + const char* type = source.type == SourceType::Zip ? "zip" : source.type == SourceType::Debian ? "deb" : "ghcr"; + pins.push_back({key, type, source.type == SourceType::Ghcr ? kGhcrBlobBaseUrl + source.sha256 : source.url, source.sha256}); } } return pins; } +std::vector LameDownloader::parsePinManifest(const std::string& jsonText, + std::string* detail) { + const auto reject = [detail](const std::string& reason) { + if (detail != nullptr) { + *detail = "LAME pin list rejected: " + reason; + } + return std::vector{}; + }; + + const auto json = nlohmann::json::parse(jsonText, nullptr, false); + if (!json.is_object() || json.value("schema", 0) != 1 || !json.contains("sources") || !json["sources"].is_array()) { + return reject("not a schema 1 pin list."); + } + + std::vector pins; + for (const auto& entry : json["sources"]) { + if (!entry.is_object() || !entry.value("platform", nlohmann::json()).is_string() || + !entry.value("type", nlohmann::json()).is_string() || !entry.value("sha256", nlohmann::json()).is_string() || + !entry.value("url", nlohmann::json("")).is_string()) { + return reject("an entry has missing or non-text fields."); + } + + PinnedSource pin; + pin.platformKey = entry["platform"].get(); + pin.type = entry["type"].get(); + pin.sha256 = toLower(entry["sha256"].get()); + if (!isSha256Hex(pin.sha256)) { + return reject("an entry has no valid SHA-256."); + } + + if (pin.type == "ghcr") { + pin.url = kGhcrBlobBaseUrl + pin.sha256; + } else if (pin.type == "zip" || pin.type == "deb") { + pin.url = entry.value("url", ""); + const bool allowedHost = std::any_of(std::begin(kAllowedDownloadPrefixes), std::end(kAllowedDownloadPrefixes), + [&pin](const char* prefix) { return pin.url.rfind(prefix, 0) == 0; }); + const auto fileName = pin.url.substr(pin.url.find_last_of('/') + 1); + const bool plainFile = !fileName.empty() && fileName.ends_with("." + pin.type) && + pin.url.find_first_of("?#\\%") == std::string::npos && + pin.url.find("..") == std::string::npos; + if (!allowedHost || !plainFile) { + return reject("an entry points outside the known download locations: " + pin.url); + } + } else { + return reject("unknown source type '" + pin.type + "'."); + } + pins.push_back(std::move(pin)); + } + return pins; +} + bool LameDownloader::verifyDownload(const std::filesystem::path& file, const std::string& expectedSha256, std::string* detail) { @@ -859,7 +964,7 @@ LameDownloader::DownloadResult LameDownloader::ensureAvailable(const bool forceD return result; } - const auto sources = platformSources(); + const auto sources = platformSources(true); if (sources.empty()) { result.detail = "No fallback LAME downloader source configured for this platform."; return result; diff --git a/src/util/LameDownloader.h b/src/util/LameDownloader.h index a77c67b..2a36db6 100644 --- a/src/util/LameDownloader.h +++ b/src/util/LameDownloader.h @@ -17,6 +17,7 @@ class LameDownloader { struct PinnedSource { std::string platformKey; + std::string type; // "zip", "deb" or "ghcr" std::string url; std::string sha256; }; @@ -26,6 +27,9 @@ class LameDownloader { static bool cachedBinaryIsVerified(); /// Every built-in download with the SHA-256 it must match, for all platforms. static std::vector pinnedSources(); + /// Parses the published pin list (assets/lame-pins.json). Any entry that is malformed or + /// points outside the known download hosts rejects the whole list (returns empty). + static std::vector parsePinManifest(const std::string& jsonText, std::string* detail); /// Checks a downloaded file against its pinned SHA-256; a mismatch deletes the file. static bool verifyDownload(const std::filesystem::path& file, const std::string& expectedSha256, std::string* detail); static bool isSupportedOnCurrentPlatform(); diff --git a/tests/unit/LameDownloaderTests.cpp b/tests/unit/LameDownloaderTests.cpp index cbae03f..a4f522f 100644 --- a/tests/unit/LameDownloaderTests.cpp +++ b/tests/unit/LameDownloaderTests.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include @@ -28,6 +29,60 @@ TEST_CASE("Every LAME download source is pinned to a SHA-256 over HTTPS", "[util } } +TEST_CASE("The published LAME pin list in the repo is valid and covers every platform", "[util][lame]") { + std::ifstream file(std::filesystem::path(AUTOMIX_SOURCE_DIR) / "assets" / "lame-pins.json", std::ios::binary); + REQUIRE(file.is_open()); + const std::string text((std::istreambuf_iterator(file)), std::istreambuf_iterator()); + + std::string detail; + const auto pins = LameDownloader::parsePinManifest(text, &detail); + INFO(detail); + REQUIRE(!pins.empty()); + + std::set platforms; + for (const auto& pin : pins) { + platforms.insert(pin.platformKey); + } + for (const auto& builtIn : LameDownloader::pinnedSources()) { + INFO(builtIn.platformKey); + CHECK(platforms.count(builtIn.platformKey) == 1); + } +} + +TEST_CASE("A LAME pin list that could redirect the download is rejected whole", "[util][lame]") { + const std::string sha(64, 'a'); + const auto list = [](const std::string& entry) { return R"({"schema":1,"sources":[)" + entry + "]}"; }; + const auto zip = [&sha](const std::string& url) { + return R"({"platform":"win32-x64","type":"zip","url":")" + url + R"(","sha256":")" + sha + R"("})"; + }; + const std::string good = zip("https://www.rarewares.org/files/mp3/lame4.0-x64.zip"); + + REQUIRE(LameDownloader::parsePinManifest(list(good), nullptr).size() == 1); + CHECK(LameDownloader::parsePinManifest(list(R"({"platform":"darwin-arm64","type":"ghcr","sha256":")" + sha + R"("})"), + nullptr) + .size() == 1); + + std::string detail; + for (const auto& bad : { + zip("https://evil.example/lame.zip"), + zip("http://www.rarewares.org/files/mp3/lame.zip"), + zip("https://www.rarewares.org.evil.example/files/mp3/lame.zip"), + zip("https://www.rarewares.org/files/mp3/../../x/lame.zip"), + zip("https://www.rarewares.org/files/mp3/lame.exe"), + zip("https://www.rarewares.org/files/mp3/lame.zip?x=.zip"), + std::string(R"({"platform":"win32-x64","type":"zip","url":"https://www.rarewares.org/files/mp3/l.zip","sha256":"abc"})"), + std::string(R"({"platform":"win32-x64","type":"exe","url":"https://www.rarewares.org/files/mp3/l.zip","sha256":")") + + sha + R"("})", + }) { + INFO(bad); + // One bad entry poisons the list even next to a good one. + CHECK(LameDownloader::parsePinManifest(list(good + "," + bad), &detail).empty()); + CHECK(!detail.empty()); + } + CHECK(LameDownloader::parsePinManifest("not json", &detail).empty()); + CHECK(LameDownloader::parsePinManifest(R"({"schema":2,"sources":[]})", &detail).empty()); +} + TEST_CASE("A LAME download that does not match its pin is rejected and deleted", "[util][lame]") { const auto dir = std::filesystem::temp_directory_path() / "automix_lame_pin_test"; std::filesystem::create_directories(dir); diff --git a/tools/update_lame_pins.py b/tools/update_lame_pins.py new file mode 100644 index 0000000..06c2823 --- /dev/null +++ b/tools/update_lame_pins.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Regenerate assets/lame-pins.json, the list of LAME downloads the app trusts. + +The app fetches that file from the default branch before downloading an MP3 +encoder, so merging a change to it updates every installed copy without a +release. Run by .github/workflows/lame_pins.yml, which opens a pull request +when the output changes; a person reviews it before it is merged. + +Where each hash comes from: + - Debian: the SHA256 field of the `lame` entry in Debian stable's package index. + - Homebrew: the bottle digests in the GHCR image index for the current version. + - rarewares.org (Windows): no index or published hash exists, so the known + files are downloaded and hashed. A changed hash there has no independent + confirmation and needs a careful look before merging. + +A source that cannot be refreshed keeps its current entry and is reported. +""" + +import hashlib +import json +import lzma +import pathlib +import sys +import urllib.request + +MANIFEST = pathlib.Path(__file__).resolve().parent.parent / "assets" / "lame-pins.json" +GHCR = "https://ghcr.io/v2/homebrew/core/lame" +DEBIAN = "https://deb.debian.org/debian/" + +# platform key -> Debian architecture / Homebrew (os, architecture) / rarewares file +DEBIAN_ARCH = {"linux-x64": "amd64", "linux-arm64": "arm64", "linux-arm": "armhf"} +BOTTLE = { + "linux-x64": ("linux", "amd64"), + "linux-arm64": ("linux", "arm64"), + "darwin-x64": ("darwin", "amd64"), + "darwin-arm64": ("darwin", "arm64"), +} +WINDOWS_ZIP = { + "win32-x64": "https://www.rarewares.org/files/mp3/lame3.100.1-x64.zip", + "win32-arm64": "https://www.rarewares.org/files/mp3/lame3.100.1-x64.zip", + "win32-ia32": "https://www.rarewares.org/files/mp3/lame3.100.1-win32.zip", +} + + +def fetch(url, headers=None): + request = urllib.request.Request(url, headers={"User-Agent": "automix-lame-pins", **(headers or {})}) + with urllib.request.urlopen(request, timeout=120) as response: + return response.read() + + +def debian_sources(): + sources = [] + for platform, arch in DEBIAN_ARCH.items(): + index = lzma.decompress(fetch(f"{DEBIAN}dists/stable/main/binary-{arch}/Packages.xz")).decode("utf-8") + stanza = next(s for s in index.split("\n\n") if s.startswith("Package: lame\n")) + fields = dict(line.split(": ", 1) for line in stanza.splitlines() if ": " in line and not line.startswith(" ")) + sources.append({"platform": platform, "type": "deb", "url": DEBIAN + fields["Filename"], "sha256": fields["SHA256"]}) + return sources + + +def os_version(manifest): + digits = "".join(c if c.isdigit() or c == "." else " " for c in manifest["platform"].get("os.version", "0")) + return tuple(int(part) for part in digits.split()[0].split(".") if part) if digits.split() else (0,) + + +def homebrew_sources(): + version = json.loads(fetch("https://formulae.brew.sh/api/formula/lame.json"))["versions"]["stable"] + token = json.loads(fetch("https://ghcr.io/token?service=ghcr.io&scope=repository:homebrew/core/lame:pull"))["token"] + auth = {"Authorization": f"Bearer {token}"} + index = json.loads(fetch(f"{GHCR}/manifests/{version}", {**auth, "Accept": "application/vnd.oci.image.index.v1+json"})) + + sources = [] + for platform, (os_name, arch) in BOTTLE.items(): + bottles = [m for m in index["manifests"] + if m["platform"]["os"] == os_name and m["platform"]["architecture"] == arch] + # The bottle built for the oldest OS release runs on the widest range of machines. + bottle = min(bottles, key=os_version) + sources.append({"platform": platform, "type": "ghcr", "sha256": bottle["annotations"]["sh.brew.bottle.digest"]}) + return sources + + +def windows_sources(current): + sources = [] + hashes = {} + for platform, default_url in WINDOWS_ZIP.items(): + url = next((s["url"] for s in current if s["platform"] == platform and s["type"] == "zip"), default_url) + if url not in hashes: + hashes[url] = hashlib.sha256(fetch(url)).hexdigest() + sources.append({"platform": platform, "type": "zip", "url": url, "sha256": hashes[url]}) + return sources + + +def main(): + current = json.loads(MANIFEST.read_text(encoding="utf-8"))["sources"] if MANIFEST.exists() else [] + sources = [] + failed = False + for name, kind, refresh in (("Debian", "deb", debian_sources), + ("Homebrew", "ghcr", homebrew_sources), + ("rarewares.org", "zip", lambda: windows_sources(current))): + try: + sources += refresh() + except Exception as error: # keep the entries we already trust + failed = True + print(f"warning: could not refresh {name} pins ({error}); keeping the current ones", file=sys.stderr) + sources += [s for s in current if s["type"] == kind] + + for old in current: + new = next((s for s in sources if (s["platform"], s["type"]) == (old["platform"], old["type"])), None) + if new is not None and new["sha256"] != old["sha256"]: + note = " -- NO independent confirmation, review before merging" if old["type"] == "zip" else "" + print(f"changed: {old['platform']} {old['type']} {old['sha256'][:12]} -> {new['sha256'][:12]}{note}") + + sources.sort(key=lambda s: (s["platform"], s["type"])) + MANIFEST.write_text(json.dumps({"schema": 1, "sources": sources}, indent=2) + "\n", encoding="utf-8", newline="\n") + return 1 if failed and not sources else 0 + + +if __name__ == "__main__": + sys.exit(main()) From df77692dd9b9e20de1e0aed6cabdaec61051b5b1 Mon Sep 17 00:00:00 2001 From: Soficis Date: Mon, 5 Oct 2026 17:41:23 -0500 Subject: [PATCH 67/68] fix(util): publish only LAME bottles that run on their own; drop the Linux bottle sources The app copies just bin/lame out of a Homebrew bottle. Checked against the real bottles and on an Apple-silicon Mac: - 3.100 macOS bottles link only system libraries and run. - 4.0 macOS bottles need Homebrew's libmpg123 and fail to launch, so publishing them only added a wasted download before the fallback. - Linux bottles of both versions use a Homebrew placeholder as their loader path and can never run, so the built-in Linux bottle sources were dead and are removed. Linux uses the Debian package. The updater now downloads each candidate bottle, checks it against its digest and skips it when the encoder still points at a Homebrew path. A platform with no usable bottle is left out of the published list and the app uses its built-in pin. Co-Authored-By: Claude Opus 5.5 --- assets/lame-pins.json | 20 -------------------- src/util/LameDownloader.cpp | 5 +++-- tests/unit/LameDownloaderTests.cpp | 15 ++++++++------- tools/update_lame_pins.py | 29 ++++++++++++++++++++++++----- 4 files changed, 35 insertions(+), 34 deletions(-) diff --git a/assets/lame-pins.json b/assets/lame-pins.json index 6146d85..1a6b9f3 100644 --- a/assets/lame-pins.json +++ b/assets/lame-pins.json @@ -1,16 +1,6 @@ { "schema": 1, "sources": [ - { - "platform": "darwin-arm64", - "type": "ghcr", - "sha256": "b0cfa1500aff96430c865fa5e3e8b5494bb9ff70dd4f4fe8f9e7684da626649a" - }, - { - "platform": "darwin-x64", - "type": "ghcr", - "sha256": "62e5e6acdb340cfdae39e4a4ad49e8b2efcd46bfe91897b670a2c0d5a0693c19" - }, { "platform": "linux-arm", "type": "deb", @@ -23,22 +13,12 @@ "url": "https://deb.debian.org/debian/pool/main/l/lame/lame_3.100-6+b3_arm64.deb", "sha256": "4c6c6ee693633c846de685902641dbda18ef98f73a136ebef7c09cf6ee683bcd" }, - { - "platform": "linux-arm64", - "type": "ghcr", - "sha256": "bd3d4df9fd0722b758bea12328bd1345d2ca507842f4074b88fe5d10cae13b73" - }, { "platform": "linux-x64", "type": "deb", "url": "https://deb.debian.org/debian/pool/main/l/lame/lame_3.100-6+b3_amd64.deb", "sha256": "feceeb296f9df9340f6e2ce48decce73c9cd6c70e7fe959495eb89ee59bc8d47" }, - { - "platform": "linux-x64", - "type": "ghcr", - "sha256": "260e9309ef40e8ad7373bfae07f20d5357e036d32b14ea3ce6526fdf15181a37" - }, { "platform": "win32-arm64", "type": "zip", diff --git a/src/util/LameDownloader.cpp b/src/util/LameDownloader.cpp index abf24ec..4e68a31 100644 --- a/src/util/LameDownloader.cpp +++ b/src/util/LameDownloader.cpp @@ -175,6 +175,9 @@ std::string platformKey() { // Hash origins: Debian's package index for the .deb files, the Homebrew bottle // index for the Ghcr blobs. rarewares.org publishes no hashes, so the two ZIP // pins were taken from the files as served on 2026-10-05. +// Only bottles whose encoder runs on its own are usable: the 3.100 macOS ones +// do. Linux bottles never do (their loader path is a Homebrew placeholder), and +// neither do the 4.0 macOS ones (they need Homebrew's libmpg123). std::vector sourcesForPlatform(const std::string& key, const std::string& version) { const std::string rarewares = "https://www.rarewares.org/files/mp3/lame" + version; const std::string debian = "https://deb.debian.org/debian/pool/main/l/lame/lame_" + version + "-6_"; @@ -192,13 +195,11 @@ std::vector sourcesForPlatform(const std::string& key, const std if (key == "linux-x64") { return { {SourceType::Debian, debian + "amd64.deb", "786ba06d2f222661e1f09b610de7b18c60f411a373d4fd3f595ec890f062089e"}, - {SourceType::Ghcr, "", "ee8318f10b1b986d57826f0f59800c43f62d58e8d52cf9c94b8924e28739e656"}, }; } if (key == "linux-arm64") { return { {SourceType::Debian, debian + "arm64.deb", "aba5023ffde46709e4bccc9e1c10142a7d77f2884d2a9af84cab6a28f8792bd2"}, - {SourceType::Ghcr, "", "3e9bc793b37a72ce61d28dbbdb8dd160a0785e91b7d9ab6e964ba9e6a8a549d4"}, }; } if (key == "linux-arm") { diff --git a/tests/unit/LameDownloaderTests.cpp b/tests/unit/LameDownloaderTests.cpp index a4f522f..3eee753 100644 --- a/tests/unit/LameDownloaderTests.cpp +++ b/tests/unit/LameDownloaderTests.cpp @@ -29,7 +29,7 @@ TEST_CASE("Every LAME download source is pinned to a SHA-256 over HTTPS", "[util } } -TEST_CASE("The published LAME pin list in the repo is valid and covers every platform", "[util][lame]") { +TEST_CASE("The published LAME pin list in the repo is valid and names only known platforms", "[util][lame]") { std::ifstream file(std::filesystem::path(AUTOMIX_SOURCE_DIR) / "assets" / "lame-pins.json", std::ios::binary); REQUIRE(file.is_open()); const std::string text((std::istreambuf_iterator(file)), std::istreambuf_iterator()); @@ -39,13 +39,14 @@ TEST_CASE("The published LAME pin list in the repo is valid and covers every pla INFO(detail); REQUIRE(!pins.empty()); - std::set platforms; - for (const auto& pin : pins) { - platforms.insert(pin.platformKey); - } + // A platform may be absent: the app then uses its built-in pins. + std::set known; for (const auto& builtIn : LameDownloader::pinnedSources()) { - INFO(builtIn.platformKey); - CHECK(platforms.count(builtIn.platformKey) == 1); + known.insert(builtIn.platformKey); + } + for (const auto& pin : pins) { + INFO(pin.platformKey); + CHECK(known.count(pin.platformKey) == 1); } } diff --git a/tools/update_lame_pins.py b/tools/update_lame_pins.py index 06c2823..2d15ca5 100644 --- a/tools/update_lame_pins.py +++ b/tools/update_lame_pins.py @@ -8,7 +8,12 @@ Where each hash comes from: - Debian: the SHA256 field of the `lame` entry in Debian stable's package index. - - Homebrew: the bottle digests in the GHCR image index for the current version. + - Homebrew (macOS): the bottle digests in the GHCR image index for the current + version. Each candidate bottle is downloaded, checked against its digest and + inspected: the app copies only bin/lame out of it, so a bottle whose encoder + still points at a Homebrew path cannot run and is not published. With no + usable bottle the platform is left out and the app uses its built-in pins. + Linux bottles are never usable this way, so Linux relies on Debian. - rarewares.org (Windows): no index or published hash exists, so the known files are downloaded and hashed. A changed hash there has no independent confirmation and needs a careful look before merging. @@ -17,10 +22,12 @@ """ import hashlib +import io import json import lzma import pathlib import sys +import tarfile import urllib.request MANIFEST = pathlib.Path(__file__).resolve().parent.parent / "assets" / "lame-pins.json" @@ -30,8 +37,6 @@ # platform key -> Debian architecture / Homebrew (os, architecture) / rarewares file DEBIAN_ARCH = {"linux-x64": "amd64", "linux-arm64": "arm64", "linux-arm": "armhf"} BOTTLE = { - "linux-x64": ("linux", "amd64"), - "linux-arm64": ("linux", "arm64"), "darwin-x64": ("darwin", "amd64"), "darwin-arm64": ("darwin", "arm64"), } @@ -63,6 +68,15 @@ def os_version(manifest): return tuple(int(part) for part in digits.split()[0].split(".") if part) if digits.split() else (0,) +def runs_standalone(digest, auth): + blob = fetch(f"{GHCR}/blobs/sha256:{digest}", auth) + if hashlib.sha256(blob).hexdigest() != digest: + raise ValueError(f"bottle {digest[:12]} does not match its digest") + with tarfile.open(fileobj=io.BytesIO(blob)) as bottle: + encoder = next(m for m in bottle.getmembers() if m.name.endswith("/bin/lame")) + return b"@@HOMEBREW" not in bottle.extractfile(encoder).read() + + def homebrew_sources(): version = json.loads(fetch("https://formulae.brew.sh/api/formula/lame.json"))["versions"]["stable"] token = json.loads(fetch("https://ghcr.io/token?service=ghcr.io&scope=repository:homebrew/core/lame:pull"))["token"] @@ -74,8 +88,13 @@ def homebrew_sources(): bottles = [m for m in index["manifests"] if m["platform"]["os"] == os_name and m["platform"]["architecture"] == arch] # The bottle built for the oldest OS release runs on the widest range of machines. - bottle = min(bottles, key=os_version) - sources.append({"platform": platform, "type": "ghcr", "sha256": bottle["annotations"]["sh.brew.bottle.digest"]}) + for bottle in sorted(bottles, key=os_version): + digest = bottle["annotations"]["sh.brew.bottle.digest"] + if runs_standalone(digest, auth): + sources.append({"platform": platform, "type": "ghcr", "sha256": digest}) + break + else: + print(f"note: no LAME {version} bottle for {platform} runs on its own; the app keeps its built-in pin") return sources From fd545a13e4a30fbf2dea7e8b336bb082fa62c7d7 Mon Sep 17 00:00:00 2001 From: Soficis Date: Mon, 5 Oct 2026 18:07:57 -0500 Subject: [PATCH 68/68] docs(readme): rewrite for users with new screenshots; fix the zoom minus glyph and clipped meter captions README - Corrects the shortcuts (Ctrl+Shift+A is Auto Master; Ctrl+/ opens the shortcut list) and the inference notes, which still said no audio-tensor path exists. - Adds presets, the light vocal model, export formats and sessions. - Build steps and developer notes move into collapsed sections. UI - The zoom-out button showed "?": a \u escape in a narrow string is mangled by MSVC. It is now UTF-8 bytes. - The L/R captions were drawn above the meter panel and clipped. - The loudness bar was painted behind the first readout. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 5 + README.md | 524 ++++++++++++---------------- assets/screenshots/main-empty.png | Bin 0 -> 45544 bytes assets/screenshots/main-session.png | Bin 0 -> 62815 bytes src/app/ui/GlowMeters.cpp | 16 +- src/app/ui/HeroWaveform.cpp | 3 +- 6 files changed, 238 insertions(+), 310 deletions(-) create mode 100644 assets/screenshots/main-empty.png create mode 100644 assets/screenshots/main-session.png diff --git a/.gitignore b/.gitignore index ba61e7f..ed4bbc9 100644 --- a/.gitignore +++ b/.gitignore @@ -144,3 +144,8 @@ docs/* !docs/model-licensing-audit.json + +# README screenshots that are captured but not embedded +assets/screenshots/model-hub.png +assets/screenshots/settings.png +assets/screenshots/shortcuts.png diff --git a/README.md b/README.md index 759d12e..6d33414 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,7 @@ **Version 0.4.2** - -AutoMixMaster application interface +AutoMixMaster with a session loaded **FIXED-RULE AUDIO WORKFLOW FOR MIXING AND MASTERING MUSIC STEMS** @@ -15,11 +14,10 @@

Overview • -Feature Set • +What It Does • First Session • -Quick Start • -System Requirements • -Build + Install • +Install • +What You Need • Licensing

@@ -27,287 +25,145 @@ ## Overview -AutoMixMaster helps you turn raw stems into a cleaner, release-ready track with fewer manual steps. +Mixing and mastering take years to learn. AutoMixMaster does both with one button. -It focuses on predictable, fixed-rule processing so results are repeatable and beginner-friendly, with optional extras like AI stem separation, batch mode, and bundled renderer integrations. +Drop in your stems. Press **Mix + Master**. Get a finished track. ---- +The app works by fixed rules, so the same stems give the same result every time. The AI features are extras. Each one falls back to the fixed rules when you have no model installed. + +
+AutoMixMaster on first launch +
-## Feature Set +--- -> **Core capabilities at a glance** +## What It Does -| Module | Description | +| Feature | What you get | | :--- | :--- | -| **Auto Mix + Auto Master** | Deterministic stem balancing, gain staging, and limiting workflow. | -| **One-Click Pipeline** | `Mix + Master` (`Ctrl+Shift+M`) runs Auto Mix → Auto Master → Export. | -| **ITO-Master AI Mastering** | Experimental AI mastering strategy driving a 46-parameter native white-box FX chain with licensing consent gating. | -| **AI Stem Separation (Optional)** | Splits a single full-mix import into stems before processing when enabled (`Ctrl+Shift+A`). | -| **Task-Scoped Model Browser** | Install/uninstall models and set active packs per task (`mix`, `master`, `analysis`, `separation`) from Hugging Face or GitHub Releases. | -| **Batch Processing** | Queue folders, auto-group stems by filename role patterns, and render one mastered song per group (`_AutoMixMaster_YYYYMMDD_XX.`). Supports recursive discovery via UI toggle or `AUTOMIX_BATCH_RECURSIVE=1`. | -| **Renderer Integrations** | Built-in discovery for PhaseLimiter, FFmpeg, SoX, and rsgain; only available tools are shown (`*_BIN` env overrides supported). | -| **Verification Reporting** | Export verification report plus batch completion summary; optional per-export `.report.json` sidecar. | -| **Task Center + ETA** | Real-time progress tracking with batch ETA countdown, summary status row, timestamped activity log, and copy-log utility. | -| **Transport & Audio Preview** | Realtime-safe lock-free audio preview buffer, live peak/RMS meters, 0–1.5x gain control (+3.5 dB), and modal confirmation for session clearing. | -| **Shortcuts & Commands** | `ApplicationCommandManager` integration with built-in Keyboard Shortcuts Cheatsheet modal (`?`). | -| **Analysis Meters** | Live LUFS and peak metering via GlowMeters. | +| **Auto Mix + Auto Master** | The app balances your stems, sets their levels and limits the result. | +| **Mix + Master** | One button (`Ctrl+Shift+M`) mixes, masters and exports. | +| **Master presets** | Default Streaming, Broadcast, Udio Optimized or Custom. | +| **Platform targets** | Spotify, Apple Music, YouTube, Amazon Music, Tidal or Broadcast EBU R128. | +| **AI Stem Separation** | Optional. Splits one full mix into stems, so you can start from a single file. | +| **Vocal Model** | Optional. BS-RoFormer pulls the vocals out of a mix. It is slow without a strong NVIDIA card. | +| **Light vocal model** | Optional. Open-Unmix is a 36 MB download for weaker machines. It is fast, and rougher. | +| **AI mastering** | Experimental. The ITO-Master model picks the mastering settings. | +| **Models window** | `Ctrl+K`. Download, remove and choose models. | +| **Batch** | Point the app at folders. It groups the stems by file name and renders one mastered song per group. | +| **Export** | WAV, AIFF, FLAC, OGG or MP3. Each export comes with a report that checks the result. | +| **Preview** | Play the mix, watch the level and loudness meters, solo or mute any stem. | +| **Sessions** | Save, load, undo and redo. The app asks before it throws away unsaved work. | +| **Progress** | A progress bar, a time estimate for batches and a log you can copy. | + +### Shortcuts + +| Action | Keys | +| :--- | :--- | +| Import | `Ctrl+I` | +| Auto Mix | `Ctrl+M` | +| Auto Master | `Ctrl+Shift+A` | +| Mix + Master | `Ctrl+Shift+M` | +| Export | `Ctrl+E` | +| Models | `Ctrl+K` | +| Save / Load session | `Ctrl+S` / `Ctrl+O` | +| Undo / Redo | `Ctrl+Z` / `Ctrl+Y` | +| Play / Pause | `Space` | +| Show all shortcuts | `Ctrl+/` | --- ## First Session -Welcome to your first mixing and mastering session. AutoMixMaster simplifies the process into a few core steps: +1. **Import your audio.** Drop files on the waveform area, click it, or press `Ctrl+I`. The app reads WAV, AIFF, FLAC, MP3 and OGG. +2. **Only one file? Turn on AI Stem Separation.** The app splits a single full mix into stems first. With several files it treats them as stems and skips this step. +3. **Press Mix + Master.** The app mixes, masters and exports. -1. **Import audio**: Drag and drop files onto the waveform area, or click `Import`. Supported formats: WAV, AIFF, FLAC, MP3, OGG. -2. **(Optional) enable AI Stem Separation**: Toggle **AI Stem Separation** and check the badge beside it (`Separation model: `). - - If exactly one full-mix track is loaded, separation runs before Auto Mix. - - If multiple files are loaded, they are treated as regular stems and separation is skipped. -3. **Manage models in Model Browser**: Open **Models** to fetch catalog entries, install/uninstall models, and set active packs per task (`mix`, `master`, `analysis`, `separation`) using **Set Active** or **Use Selected for Task**. -4. **Auto Mix**: Click **Auto Mix** to analyze stems and apply deterministic balancing rules. -5. **Auto Master**: Click **Auto Master** to apply mastering strategy and limiting. -6. **One-click pipeline**: Click **Mix + Master** (`Ctrl+Shift+M`) to run Auto Mix → Auto Master → Export. If AI Stem Separation is enabled and one full mix is loaded, separation is performed first, then the pipeline continues automatically. -7. **Export**: Use **Export** (`Ctrl+E`) for manual output control, or rely on pipeline export. +Want more control? Run **Auto Mix**, **Auto Master** and **Export** one at a time. Pick a master preset and a platform target before you master. --- -## Quick Start - -Getting started with AutoMixMaster is simple. - -> ⚠️ **Testing disclaimer:** only the **Windows** version has been manually tested end-to-end so far. -> Linux, macOS, and ARM64 artifacts are currently provided as best-effort builds. - -### Windows (Pre-compiled Executable) - -Windows users can download the portable release zip (`AutoMixMaster-windows-.zip`), extract it, and run `AutoMixMaster.exe`. - -### macOS (Pre-compiled App Bundle) - -macOS users can download the release zip (`AutoMixMaster-macos-.zip`), extract it, and open `AutoMixMaster.app`. - -### Linux (Prebuilt Packages) - -Linux users can download either: - -- **AppImage** (`AutoMixMaster--.AppImage`) for a portable one-file launch. -- **Debian package** (`automixmaster__.deb`) for Ubuntu/Debian install. -- **Flatpak bundle** (`AutoMixMaster-linux-.flatpak`) for Flatpak-based installs. - -### Build From Source - -If you are on Linux, or prefer to build the application from source on Windows, refer to the [Build + Install](#build--install) section below for verified instructions. - -## Estimated System Requirements - -These are **practical estimates** for AI-heavy workflows (especially ONNX-based separation/mix/master inference), not strict hard limits. - -AutoMixMaster is designed to benefit from **GPU acceleration** via ONNX Runtime providers. - -### Minimum OS requirements (release artifacts) - -- **Windows:** **Windows 10 or Windows 11** (x64 or ARM64¹) -- **macOS (Apple Silicon / ARM64):** **macOS 14+** -- **macOS (Intel / x64):** **macOS 15+** -- **Linux:** **Ubuntu 24.04 LTS+** for current prebuilt `.deb`/AppImage artifacts - -> ¹ *Note: On Windows 11 ARM64, PhaseLimiter runs seamlessly via Windows on ARM built-in x64 emulation (WOW64/Prism); AI tensor inference runs natively on ARM64.* -> Note: Ubuntu 22.04 may still work if you build from source on 22.04 with compatible dependencies, but official CI/release packaging currently targets Ubuntu 24.04. - -### Minimum workable - -- **CPU:** modern **6-core / 12-thread** desktop CPU (Ryzen 5 5600 / Core i5-12400 class) -- **RAM:** **16 GB minimum** -- **GPU:** compatible acceleration path with ~**6 GB VRAM** - - Windows: **WebGPU (DirectX 12 / Vulkan)** or **NVIDIA CUDA** - - Linux: **WebGPU (Vulkan, requires `libvulkan1`)** or **NVIDIA CUDA** - - macOS (Apple Silicon): **CoreML / ANE** - - *(Note: Intel Macs do not support AI tensor/model inference; heuristics and audio processing remain functional)* -- **Storage:** ~10 GB free (models, temp files, exports) -- **PhaseLimiter rendering:** requires ffmpeg (install ffmpeg on Windows; `brew install ffmpeg` / `apt install ffmpeg` elsewhere, or set `FFMPEG_BIN`). - -### Recommended (smoother) - -- **CPU:** **8 cores / 16 threads or better** (Ryzen 7 / Core i7 class) -- **RAM:** **32 GB** -- **GPU:** **8–12 GB VRAM** - -### Heavy batch / long sessions - -- **CPU:** **12 cores+** strongly recommended -- **RAM:** **32–64 GB** -- **GPU:** **12 GB+ VRAM** - -### Why these estimates - -- GPU acceleration matters most: WebGPU needs a **DirectX 12 or Vulkan-capable** GPU, CoreML uses Apple Silicon GPU/ANE, and CUDA needs an **NVIDIA CUDA-capable** GPU ([CUDA](https://onnxruntime.ai/docs/execution-providers/CUDA-ExecutionProvider.html)). -- Demucs notes roughly **3 GB minimum** and around **7 GB typical** GPU memory, so **8 GB+ VRAM** is a safer real-world target; CPU-only runs work but are slower ([Demucs README](https://github.com/facebookresearch/demucs/blob/main/README.md)). +## Install -### ONNX Runtime - -ONNX Runtime is an **optional** dependency. When it is not found at configure time the build -falls back to a deterministic adapter and every model-dependent feature degrades to a -heuristic — it does not fail the build. - -| | | -|---|---| -| Validated against | **ORT 1.30.x** (1.30.0, 2026-09-10) | -| Minimum for the optional GPU paths | **1.22** | -| Release cadence | roughly monthly — pin a minor series, not a patch | - -The build locates ONNX Runtime with `find_package(onnxruntime 1.30.0 EXACT CONFIG)` when fetched -via `AUTOMIX_FETCH_ORT=ON`, or `find_path`/`find_library` for system-installed SDKs. - -#### Provider status (as of 1.30.x) - -| Provider | Status | Notes | -|---|---|---| -| **CPU** | always available | The baseline. Every GPU path falls back here on OOM or device loss, so the app never loses inference capability. | -| **CUDA** | current | Default packages target **CUDA 13.0** since 1.27. cuDNN and CUDA runtime libraries are loaded dynamically at runtime when present. | -| **WebGPU** | current | Native plugin EP on Windows/Linux (via `Microsoft.ML.OnnxRuntime.EP.WebGpu` 0.4.0) and in-tree provider on Apple Silicon macOS. Default non-NVIDIA path for Windows and Linux. Requires `libvulkan1` on Linux. | -| **CoreML** | current | Built-in on macOS. Covers the **Apple Neural Engine** (`MLComputeUnits=CPUAndNeuralEngine` or `MLComputeUnits=ALL`). Note: Intel Macs do not support AI inference. | -| **DirectML** | maintenance mode | The `Microsoft.ML.OnnxRuntime.DirectML` NuGet is **frozen at 1.24.4** and caps at **opset ≤ 20**. WebGPU is now the primary non-NVIDIA Windows path. | -| **OpenVINO** | split | Legacy wheel pinned at 1.24.1; the plugin `onnxruntime-ep-openvino` 1.7.0 requires ORT ≥ 1.23. | -| **Windows ML** | GA (2025-09-23) | The recommended path for new Windows work. C++ needs the **self-contained** NuGet; framework-dependent C/C++ packages are not published. | - -AutoMixMaster probes available providers and walks its own priority chain — **ANE → CoreML → -CUDA → WebGPU → OpenVINO → DirectML → CPU** (`src/ai/GpuProvider.h`). If session creation or inference -fails, the provider is recorded as failed and the chain continues, so a broken or missing GPU -runtime degrades to CPU instead of failing the render. - -> **fp16 caveat:** the CPU execution provider does not run fp16 graphs. Quantize to int8 (QDQ -> format) for CPU-only deployment; 16-bit and 4-bit quantization additionally require **opset ≥ 21**. - -#### Optional runtime capabilities - -Two further runtime paths are detected at configure time and are **off unless the installed -ONNX Runtime exposes the matching API**. The provider priority chain above is unchanged either -way, and both features default to off. - -| Capability | Compile guard | Minimum ORT | Status | -|---|---|---|---| -| WebGPU provider supplied as a plugin library | `AUTOMIX_HAS_EP_PLUGIN` | 1.23 | Wired for WebGPU via `OrtRuntime` and `RegisterExecutionProviderLibrary` | -| Per-GPU compiled-model cache (EPContext) | `AUTOMIX_HAS_EP_CONTEXT` | 1.22 | Policy implemented; the `OrtCompileApi` call is not yet wired | - -`src/ai/GpuProvider.h` holds the deciding logic for both — `parseOrtVersion`, -`supportsEpPlugin`, `supportsEpContext`, `decidePluginEpAttempt` and -`compiledModelCacheKey` — as pure functions, so it is covered by the test suite even on a -build with no ONNX Runtime SDK present. The cache key covers the model digest, the provider, -the GPU architecture, the driver version and the ORT version, so recompiling for a different -card or driver can never reuse another card's artifact. A model digest that is not a valid -64-character SHA-256 yields no key at all, because a key that cannot distinguish two models -would alias their caches. - -To finish the wiring, the guarded code should ask `decidePluginEpAttempt(...)` and, when it -returns `attempt == false`, log its `reason` and continue down the existing priority chain; -`Ort::GetAvailableProviders()` already covers every built-in provider. - -#### GPU acceleration for the vocal model (Windows + NVIDIA) - -The BS-RoFormer vocal model runs on CUDA when three things are true: the build links the -**CUDA build of ONNX Runtime**, the user has installed the **GPU runtime pack**, and the GPU -has room for the model. Otherwise it runs on the CPU, with the reason in the log. - -- **GPU runtime pack.** NVIDIA's CUDA runtime, cuBLAS, cuFFT and cuDNN (~1 GB download, - ~1.3 GB on disk) are not shipped. When *Vocal Model* is switched on, or from *Settings → - GPU acceleration*, the app offers a one-time download of NVIDIA's own redistributable - packages from pypi.org, pinned by SHA-256, into - `%LOCALAPPDATA%\AutoMixMaster\gpu-runtime\`. No admin rights, no system CUDA, no - PATH changes; the libraries are preloaded by full path. *Settings* can remove it again. - It is offered only for an NVIDIA GPU with ≥ 11.5 GiB of memory and driver **580 or newer** - (CUDA 13). `automix_dev_tools gpu-runtime status|install|remove|upgrade-models` does the - same from the command line. -- **Model build.** Where a CUDA session opens and the GPU totals ≥ 11.5 GiB, the catalog - installs BS-RoFormer's **fp32** build (its external weights are folded into one file at - install time, because ONNX Runtime 1.30 cannot load that export otherwise); elsewhere the - smaller **quantized** build, which is faster on CPU but 4-7x slower than fp32 on a GPU. Installing - the runtime pack upgrades an already-installed quantized model automatically. -- **Memory.** fp32 needs 10 GiB of free GPU memory while it runs (measured peak 9.2 GiB). - With less free, it runs on the CPU rather than spill into shared memory, which is slower. - -Measured on an RTX 5060 Ti (16 GB) for a 196 s track: fp32 on CUDA **66–91 s**, quantized on -CPU 687 s, quantized on CUDA 303–504 s. +> ⚠️ Only the **Windows** version has been tested by hand from start to finish. The Linux, macOS and ARM64 builds may have rough edges. +| System | Download | Then | +| :--- | :--- | :--- | +| **Windows** | `AutoMixMaster-windows-.zip` | Extract it and run `AutoMixMaster.exe`. | +| **macOS** | `AutoMixMaster-macos-.zip` | Extract it and open `AutoMixMaster.app`. | +| **Linux** | `.AppImage`, `.deb` or `.flatpak` | Run the AppImage, or install the package. | ---- +**macOS:** the app is unsigned. If macOS refuses to open it, go to **System Settings → Privacy & Security** and click **Open Anyway**. -## Build + Install +
+Build it yourself ### Windows (Visual Studio 2026) -1. Configure - ```bash cmake -S . -B build -G "Visual Studio 18 2026" -A x64 -``` - -1. Build - -```bash cmake --build build --config Release --parallel +ctest --test-dir build -C Release --output-on-failure ``` -#### Windows release package - -`packaging/windows/build-release.ps1` configures a fresh build directory against an ONNX -Runtime SDK, builds, runs the tests and writes a portable ZIP with CPack. Use the CUDA build -of ONNX Runtime so the package can use the GPU: +**Release package.** `packaging/windows/build-release.ps1` builds, runs the tests and writes a portable ZIP. Give it the CUDA build of ONNX Runtime so the package can use the GPU: ```powershell powershell -File packaging\windows\build-release.ps1 -OnnxRuntimeDir C:\lib\onnxruntime-win-x64-gpu_cuda13-1.30.0 ``` -The ZIP holds the app, its assets and the ONNX Runtime DLLs (the CUDA provider adds ~190 MB). -It never contains model weights or NVIDIA's CUDA libraries: the install step fails if any -`.onnx` file or `cudart`/`cublas`/`cudnn`/`cufft` library is present. For a developer build -that runs on CUDA without the runtime pack, point `-DAUTOMIX_CUDA_RUNTIME_DIR` at a folder of -those DLLs; they are copied next to the executables, never into the package. +The ZIP never holds model files or NVIDIA's CUDA libraries. The install step fails if it finds one. To run a developer build on CUDA without the GPU pack, point `-DAUTOMIX_CUDA_RUNTIME_DIR` at a folder of those DLLs. ### Ubuntu Linux (24.04+) -1. Install dependencies - ```bash sudo apt-get install -y \ - libasound2-dev libfreetype6-dev libx11-dev libxcomposite-dev \ - libxcursor-dev libxext-dev libxinerama-dev libxrandr-dev \ - libxrender-dev libwebkit2gtk-4.1-dev libglu1-mesa-dev mesa-common-dev -``` - -1. Configure + build + build-essential cmake pkg-config \ + libasound2-dev libjack-jackd2-dev libfreetype6-dev libfontconfig1-dev \ + libx11-dev libxcomposite-dev libxcursor-dev libxext-dev libxinerama-dev \ + libxrandr-dev libxrender-dev libwebkit2gtk-4.1-dev libgtk-3-dev \ + libglu1-mesa-dev mesa-common-dev libcurl4-openssl-dev -```bash cmake -S . -B build -DCMAKE_BUILD_TYPE=Release cmake --build build --parallel +ctest --test-dir build --output-on-failure ``` -1. Run tests (optional but recommended) +Do not skip `libcurl4-openssl-dev`. Without it the app cannot download models. + +**Packages.** `./tools/package_linux.sh` writes a `.deb` and an AppImage to `dist/linux/`. + +**Flatpak.** ```bash -ctest --test-dir build --output-on-failure +sudo apt-get install -y flatpak flatpak-builder +flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo +flatpak --user install -y flathub org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08 +./tools/build_flatpak.sh ``` -### macOS (Apple Silicon + Intel) +The bundle lands in `dist/flatpak/AutoMixMaster.flatpak`. The manifest is `packaging/flatpak/io.automixmaster.AutoMixMaster.yml`. It fetches its sources ahead of time, so the build needs no network inside the sandbox. -1. Install build tools +### macOS (Apple Silicon + Intel) ```bash xcode-select --install brew install cmake ninja ``` -1. Configure + build (pick one architecture) +Pick one architecture: ```bash -# Apple Silicon (arm64) +# Apple Silicon cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_OSX_ARCHITECTURES=arm64 -DBUILD_TESTING=OFF -DBUILD_TOOLS=OFF -# Intel (x64) +# Intel cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_OSX_ARCHITECTURES=x86_64 -DBUILD_TESTING=OFF -DBUILD_TOOLS=OFF cmake --build build --target AutoMixMasterApp --parallel ``` -1. Configure + build (universal binary: arm64 + x86_64) +Or build for both at once: ```bash cmake -S . -B build-universal \ @@ -319,7 +175,7 @@ cmake -S . -B build-universal \ cmake --build build-universal --target AutoMixMasterApp --parallel ``` -1. Install app bundle +Then install the app: ```bash APP_BUNDLE="$(find build build-universal -maxdepth 6 -type d -name 'AutoMixMaster.app' 2>/dev/null | head -n 1)" @@ -328,100 +184,154 @@ sudo cp -R "$APP_BUNDLE" /Applications/ open /Applications/AutoMixMaster.app ``` -> **macOS Note:** On first launch of the unsigned application, macOS Gatekeeper may prompt that the developer cannot be verified. To allow the application to open, go to **System Settings → Privacy & Security**, scroll down to the Security section, and click **Open Anyway**. +### Extra render tools +The app needs none of these. It finds them if you put them here or set the variable: -### Linux Package Builds (.deb + AppImage) +- `assets/ffmpeg/bin/ffmpeg(.exe)` or `FFMPEG_BIN` +- `assets/sox/bin/sox(.exe)` or `SOX_BIN` +- `assets/rsgain/bin/rsgain(.exe)` or `RSGAIN_BIN` -After building, create distributable Linux packages with: +PhaseLimiter runs only when you select it, and it needs ffmpeg. -```bash -./tools/package_linux.sh -``` +
-Output artifacts are written to `dist/linux/`. +--- -### Flatpak +## What You Need -Manifest path: +The fixed-rule mix and master run on almost any recent computer. The numbers below are for the AI features. They are estimates, not hard limits. -`packaging/flatpak/io.automixmaster.AutoMixMaster.yml` +| | Works | Comfortable | Big batches | +| :--- | :--- | :--- | :--- | +| **Processor** | 6 cores | 8 cores | 12 cores or more | +| **Memory** | 16 GB | 32 GB | 32–64 GB | +| **Graphics memory** | 6 GB | 8–12 GB | 12 GB or more | +| **Free disk** | 10 GB | | | -> Note: the Flatpak manifest prefetches JUCE/nlohmann/libebur128 sources and passes `FETCHCONTENT_SOURCE_DIR_*` flags so CMake does not need live GitHub access inside the Flatpak sandbox. +**Systems the release builds support** -Install Flatpak tooling: +- **Windows** 10 or 11, x64 or ARM64 +- **macOS** 14 or later on Apple Silicon; 15 or later on Intel +- **Linux:** Ubuntu 24.04 or later -```bash -sudo apt-get install -y flatpak flatpak-builder -``` +**Graphics cards** -Add Flathub and install required runtime/SDK: +- **Windows and Linux:** an NVIDIA card is fastest. Other cards work if they support DirectX 12 or Vulkan. Linux needs `libvulkan1`. +- **Apple Silicon Macs:** the app uses the built-in graphics chip. +- **Intel Macs:** no AI features. Mixing and mastering still work. +- **No usable card:** the AI features run on the processor. They work, and they are slow. -```bash -flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo -flatpak --user install -y flathub org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08 -``` +**Macs with less than 12 GB of memory.** The Vocal Model runs slowly on them. The app warns you and points you to the light Open-Unmix model. -Build bundle: +### The Vocal Model on an NVIDIA card (Windows) -```bash -./tools/build_flatpak.sh -``` +The Vocal Model runs on your graphics card when all of this is true: -Output: +- The card has at least 12 GB of memory, with 10 GB free. +- Your NVIDIA driver is version 580 or newer. +- You have installed the **GPU pack**. -- `dist/flatpak/AutoMixMaster.flatpak` +The GPU pack is a one-time download of about 1 GB. The app offers it when you switch on the Vocal Model. You can also install or remove it under **Settings → GPU acceleration**. It needs no admin rights and changes nothing else on your system. -### Bundled Renderer Tools (Optional) +Without these, the Vocal Model runs on the processor and the log tells you why. The difference is large: one 196-second track took 66 to 91 seconds on an RTX 5060 Ti and 687 seconds on the processor. -The renderer registry can auto-discover optional CLI tools if you place binaries under: +--- -- `assets/ffmpeg/bin/ffmpeg(.exe)` or set `FFMPEG_BIN` -- `assets/sox/bin/sox(.exe)` or set `SOX_BIN` -- `assets/rsgain/bin/rsgain(.exe)` or set `RSGAIN_BIN` +## Licensing -If a tool is missing, it is hidden from selectable available renderers automatically. +AutoMixMaster is free software under the **GNU General Public License v3**. ---- +**The app ships with no AI models.** You download the ones you want from the Models window. The app shows each model's license and asks you to accept it first. -## Licensing +**Some models are for non-commercial use only.** If you sell your music, use the MIT-licensed models or the fixed rules. + +| Model | Used for | License | Commercial use | +| :--- | :--- | :--- | :--- | +| BS-RoFormer | Vocal Model | MIT | Yes | +| Open-Unmix | Light vocal model | MIT | Yes | +| Whisper, CLAP, PANNs | Analysis | MIT | Yes | +| Demucs / HTDemucs | Stem separation | CC-BY-NC 4.0 weights | No | +| Denoiser | Vocal clean-up | CC-BY-NC 4.0 | No | +| ITO-Master | AI mastering | CC-BY-NC 4.0 | No | -AutoMixMaster is distributed under the **GNU General Public License v3 (GPLv3)**. +The full license record for every model is in [docs/model-licensing-audit.json](docs/model-licensing-audit.json). -### Core & Libraries +**Software the app is built on** | Component | License | Role | | :--- | :--- | :--- | -| JUCE 8.0.8 | AGPLv3 / Commercial | Audio & GUI Framework | -| libebur128 | MIT | EBU R128 Loudness Metering | -| nlohmann/json | MIT | JSON Serialization & Model Metadata | -| Catch2 3.7.1 | BSL-1.0 | Unit Testing Framework | -| PhaseLimiter | GPL-2.0 / Custom | Optional External Limiter | -| FFmpeg | GPL-compatible / LGPL | Optional External Audio Renderer | -| SoX | GPL-2.0-or-later | Optional External Processor | -| rsgain | BSD-2-Clause | Optional ReplayGain Tagging Stage | +| JUCE 8.0.8 | AGPLv3 / Commercial | Audio and interface framework | +| libebur128 | MIT | Loudness metering | +| nlohmann/json | MIT | Reading and writing JSON | +| Catch2 3.7.1 | BSL-1.0 | Tests | +| PhaseLimiter | GPL-2.0 / Custom | Optional limiter | +| FFmpeg | GPL-compatible / LGPL | Optional renderer | +| SoX | GPL-2.0-or-later | Optional processor | +| rsgain | BSD-2-Clause | Optional loudness tagging | + +--- -### AI Model Hub & Third-Party Weights +## Developer Notes -Model weights are **not bundled** into the installer or executable binaries. Users can optionally download models on-demand through the built-in Model Hub (`ModelManager`), which preserves and displays upstream model licensing metadata (`license` / `cardData` tags): +Most people can stop reading here. These notes are for building against ONNX Runtime or writing a model pack. -| Model / Model Family | Upstream Author | License | Usage & Compatibility | -| :--- | :--- | :--- | :--- | -| **HTDemucs / Demucs 4-stem** | Meta Research | MIT (code), CC-BY-NC 4.0 (MUSDB18-HQ weights) | Stem Separation (Non-Commercial weights) | -| **HTDemucs 6-stem (`htdemucs_6s`)** | Meta / Community ONNX | CC-BY-NC 4.0 | 6-Stem Separation (Guitar/Piano/Drums/Bass/Vocal/Other) | -| **Denoiser (`dns64` / Speech Enhancer)** | Meta Research | CC-BY-NC 4.0 | Speech & Vocal Denoising (Non-Commercial evaluation) | -| **ITO-Master (`ito-master-v1`)** | Community / Open | CC-BY-NC 4.0 | AI Mastering (46-param native white-box FX chain) | -| **Whisper Tiny / Small** | OpenAI | MIT | Transcripts, Vocal Alignment & Pitch Analysis | -| **CLAP (`clap-htsat`)** | LAION | MIT | Style Retrieval & Audio Embeddings | -| **PANNs (`PANNs_CNN14`)** | Bio-DSP / Open | MIT | General Audio Tagging & Classification | +
+ONNX Runtime and graphics providers + +### ONNX Runtime + +ONNX Runtime is optional. Without it, the build still succeeds and every model feature falls back to the fixed rules. + +| | | +|---|---| +| Validated against | **ORT 1.30.x** (1.30.0, 2026-09-10) | +| Minimum for the optional GPU paths | **1.22** | +| Release cadence | roughly monthly — pin a minor series, not a patch | + +The build finds ONNX Runtime with `find_package(onnxruntime 1.30.0 EXACT CONFIG)` when fetched via `AUTOMIX_FETCH_ORT=ON`, or with `find_path`/`find_library` for a system SDK. + +#### Provider status (as of 1.30.x) + +| Provider | Status | Notes | +|---|---|---| +| **CPU** | always available | The baseline. Every GPU path falls back here on out-of-memory or device loss. | +| **CUDA** | current | Default packages target **CUDA 13.0** since 1.27. cuDNN and the CUDA runtime load at run time when present. | +| **WebGPU** | current | A plugin provider on Windows and Linux (`Microsoft.ML.OnnxRuntime.EP.WebGpu` 0.4.0) and in-tree on Apple Silicon. The default non-NVIDIA path on Windows and Linux. Needs `libvulkan1` on Linux. | +| **CoreML** | current | Built in on macOS. Covers the **Apple Neural Engine** (`MLComputeUnits=CPUAndNeuralEngine` or `ALL`). Intel Macs run no AI inference. | +| **DirectML** | maintenance mode | The `Microsoft.ML.OnnxRuntime.DirectML` NuGet is frozen at 1.24.4 and caps at opset ≤ 20. WebGPU replaces it. | +| **OpenVINO** | split | The legacy wheel is pinned at 1.24.1. The plugin `onnxruntime-ep-openvino` 1.7.0 needs ORT ≥ 1.23. | +| **Windows ML** | GA (2025-09-23) | Recommended for new Windows work. C++ needs the self-contained NuGet. | + +The app probes the providers and walks its own priority chain: **ANE → CoreML → CUDA → WebGPU → OpenVINO → DirectML → CPU** (`src/ai/GpuProvider.h`). A provider that fails is recorded and skipped. A broken GPU runtime slows the render down. It does not stop it. + +> **fp16 caveat:** the CPU provider does not run fp16 graphs. Quantize to int8 (QDQ format) for CPU-only use. 16-bit and 4-bit quantization also need opset ≥ 21. + +#### Optional runtime capabilities + +Two more paths are detected at configure time. Both are off unless the installed ONNX Runtime exposes the matching API. Neither changes the priority chain. + +| Capability | Compile guard | Minimum ORT | Status | +|---|---|---|---| +| WebGPU provider supplied as a plugin library | `AUTOMIX_HAS_EP_PLUGIN` | 1.23 | Wired for WebGPU via `OrtRuntime` and `RegisterExecutionProviderLibrary` | +| Per-GPU compiled-model cache (EPContext) | `AUTOMIX_HAS_EP_CONTEXT` | 1.22 | Policy implemented. The `OrtCompileApi` call is not wired yet. | + +`src/ai/GpuProvider.h` holds the deciding logic as pure functions: `parseOrtVersion`, `supportsEpPlugin`, `supportsEpContext`, `decidePluginEpAttempt` and `compiledModelCacheKey`. The tests cover them even on a build with no ONNX Runtime SDK. + +The cache key covers the model digest, the provider, the GPU architecture, the driver version and the ORT version. One card can never reuse another card's compiled model. A digest that is not a valid 64-character SHA-256 yields no key at all. -> **Non-Commercial Notice**: Models licensed under **CC-BY-NC 4.0** (such as Meta Demucs, Denoiser, and ITO-Master weights) are restricted to personal, educational, and non-commercial evaluation use. Commercial workflows can use open-source MIT-licensed models (e.g. Whisper, CLAP) or the built-in deterministic heuristic DSP engines. User consent gating is enforced prior to model download and execution. +To finish the wiring, the guarded code should call `decidePluginEpAttempt(...)`. When it returns `attempt == false`, log its `reason` and continue down the chain. -> **Model Licensing Audit**: For complete machine-checkable model license metadata and audit specifications, see [docs/model-licensing-audit.json](docs/model-licensing-audit.json). +
-#### Mix-Scope Model Contract +
+Model pack contracts -No curated `mix` model ships today — the AI mix path is fully wired (`AutoMixStrategyAI`), so it activates as soon as a valid mix pack is installed, and otherwise falls back to the deterministic heuristic. A downloadable mix model must satisfy all of the following: +### Mix-Scope Model Contract + +No curated `mix` model ships today. The AI mix path is wired (`AutoMixStrategyAI`). It activates when you install a valid mix pack. Until then it uses the fixed rules. + +A mix model must meet all of these: | Requirement | Value | Enforced by | | :--- | :--- | :--- | @@ -429,18 +339,20 @@ No curated `mix` model ships today — the AI mix path is fully wired (`AutoMixS | Manifest metadata | non-empty `license`, `source`, `feature_schema_version` | `ModelPackLoader` | | `feature_schema_version` | `1.0.0` | `FeatureSchemaV1::isCompatible` | | Required output keys | `confidence`, `global_gain_db` (±12 dB), `global_pan_bias` (±1.0) | `ModelPackLoader` + `OnnxModelInference` | -| Optional per-stem keys | `stem_gain_db` (±24 dB), `stem_pan` (±1.0) — a superset of the required keys | `AutoMixStrategyAI` | -| Input features | **66 floats per stem, concatenated** — `input_feature_count` must equal `66 × stem count` exactly | `OnnxModelInference::run` | +| Optional per-stem keys | `stem_gain_db` (±24 dB), `stem_pan` (±1.0) | `AutoMixStrategyAI` | +| Input features | **66 floats per stem, concatenated** — `input_feature_count` must equal `66 × stem count` | `OnnxModelInference::run` | | `allowed_tasks` | must include `mix_parameters` | `OnnxModelInference::run` | -Two consequences worth knowing before authoring a pack: +Two things to know before you author a pack: -- **The stem count is baked into the model's input width.** Because features are concatenated per stem, a pack trained for 4 stems (`input_feature_count: 264`) is rejected outright on a 3-stem session. A model intended for varying stem counts must accept a padded or per-stem input, not a fixed concatenation. -- **The leading public model is not plug-and-play.** `csteinmetz1/automix-toolkit` (Apache-2.0) is the best-licensed downloadable mixer — it predicts per-track gain and pan, which maps cleanly onto the `stem_gain_db` / `stem_pan` keys — but its published weights are PyTorch `.ckpt` checkpoints and its input is an audio encoder (log-mel/STFT), not the 66-float feature vector. Using it requires an ONNX export **and** a host-side audio-encoder frontend, so it is deliberately absent from the curated list rather than listed as broken. +- **The stem count is baked into the input width.** A pack trained for 4 stems (`input_feature_count: 264`) is rejected on a 3-stem session. A model for varying stem counts must accept a padded or per-stem input. +- **The leading public model is not plug-and-play.** `csteinmetz1/automix-toolkit` (Apache-2.0) predicts per-track gain and pan, which maps onto the `stem_*` keys. But its weights are PyTorch `.ckpt` files and its input is an audio encoder, not the 66-float vector. It needs an ONNX export and a host-side encoder, so it is not on the curated list. -#### Model Inference Contract (all scopes) +### Model Inference Contracts -`IModelInference` is a **features-in, scalars-out** interface. A request carries one flat `std::vector` (`InferenceRequest::features`); a response carries flat named scalars (`InferenceResult::outputs`). There is no audio-tensor path through it. These six tasks are the complete set: +The app has two inference interfaces. + +**1. `IModelInference`: features in, scalars out.** A request carries one flat `std::vector`. A response carries named scalars. These tasks use it: | Task | Input | Output keys | Consumer | | :--- | :--- | :--- | :--- | @@ -449,23 +361,27 @@ Two consequences worth knowing before authoring a pack: | `role_classifier` | 66 floats per stem | `prob_vocals`, `prob_bass`, `prob_drums`, `prob_fx` | `StemRoleClassifierAI` | | `stem_separation` | per-4096-sample-frame feature vector | `stem_weight` \| `source_weight` \| `mask_` \| `_weight` | `StemSeparator` | | `mix_master_override` | all stems' features, concatenated | `dryWet`, `targetLufs`, `preGainDb` (legacy) | `ModelStrategy` | -| `ito_fxencoder`, `ito_predictor` | the first N stereo samples flattened channel-major into one `features` vector (encoder); the same vector with the encoder's 2048 outputs appended to it (predictor) — fed positionally, never bound by name | 2048-dim embedding, then 46 normalized chain parameters | `ItoMasterModelRunner` | +| `ito_fxencoder`, `ito_predictor` | the first N stereo samples, flattened channel-major (encoder); the same vector plus the encoder's 2048 outputs (predictor) | 2048-dim embedding, then 46 normalized chain parameters | `ItoMasterModelRunner` | -**Consequence: a model whose output is an audio-shaped tensor, or whose input needs audio semantics, cannot be used through this interface.** The wall is not the number of graph inputs — `xycld/BS-RoFormer-ONNX`, for example, has exactly one input and one output. It is two things the contract has no words for: (1) **output rank and volume** — BS-RoFormer returns a rank-5 `[1, 1, 2050, 801, 2]` float tensor (~3.3 M values), and `InferenceResult::outputs` is a `map` that cannot carry a tensor at any rank; (2) **audio semantics on the way in** — `features` is a flat vector with no shape, no axis meaning, no channel identity, no phase and no STFT front-end, so there is no way to say "801 frames × 1025 bins × 2 channels × real/imag". That excludes essentially the whole published audio ecosystem — Demucs/HTDemucs, BS-Roformer and Mel-Band Roformer, Open-Unmix, Spleeter, Whisper, CLAP, PANNs, CED, Basic Pitch, CREPE, skey, beat-this, chordmini — regardless of license. Installing one yields a pack that validates and downloads, then either fails the `features.size() != input_feature_count` check or receives a feature vector where it expects audio. +This interface cannot carry audio. Its output is a `map`, and its input has no shape, channels or phase. A model that returns an audio-shaped tensor does not fit. -This applies to the three **already-curated** separation models (`rysertio/Demucs-onnx`, `StemSplitio/htdemucs-ft-onnx`, `StemSplitio/htdemucs-6s-onnx`): the separator feeds them a per-frame feature vector and reads back per-stem weights, so with no weight key in the response it applies its own heuristic. `StemSeparator` now reports that case honestly — `SeparationResult::usedModel` is `false` and the log says the fallback weights were used — rather than claiming "Model-backed overlap-add separation completed". +The three curated Demucs models (`rysertio/Demucs-onnx`, `StemSplitio/htdemucs-ft-onnx`, `StemSplitio/htdemucs-6s-onnx`) go through this interface. They return no weight keys, so the separator applies its own fixed rules. `StemSeparator` reports that honestly: `SeparationResult::usedModel` is `false`, and the log says it used fallback weights. -The verified-later candidates below are held back by that single missing frontend, not by their licenses (licenses confirmed against the Hugging Face model API; all ungated): +**2. `ITensorInference`: shaped tensors in and out.** `OnnxTensorInference` passes named float32 tensors to ONNX Runtime. `SeparationRunner` adds a host-side STFT around it. This is how the audio models run: -| Model | License | Why it is not curated yet | +| Model | Input the host builds | Output the host applies | | :--- | :--- | :--- | -| `xycld/BS-RoFormer-ONNX` | MIT | emits a real-valued rank-5 mask tensor (real/imag on the trailing axis) that the caller multiplies against a host-side STFT; needs a tensor-level interface and an STFT front-end | -| `musetric/skey-onnx` | MIT | expects 22.05 kHz audio, not the 66-float vector | -| `musetric/chordmini-onnx` | MIT | expects a 144-bin log-CQT the host does not compute | -| `musetric/beat-this-onnx` | MIT | expects a 128-bin log-mel the host does not compute | -| `mispeech/ced-base` | Apache-2.0 | expects 16 kHz waveform input | -| Basic Pitch `nmp.onnx` | Apache-2.0 | expects a 43844-sample CQT input | +| `xycld/BS-RoFormer-ONNX` | STFT of the mix | a real/imaginary mask | +| `MixDirective/open-unmix-umxhq-vocals-onnx` | STFT magnitudes | a ratio mask; the mix phase is kept | -**The unblock is one interface, not a bigger catalog.** `ItoMasterModelRunner` already drives a real audio→audio→parameters graph in-process, so the pattern is proven; generalising it into a tensor-level audio interface (shaped, named float32 tensors in and out, plus a host-side STFT, alongside `IModelInference`) is what would make the entire download ecosystem reachable, and is the reason adding more curated ids before then only adds download size. +These candidates are still held back. Each needs a front end the host does not compute yet. Their licenses are fine (confirmed against the Hugging Face model API; all ungated): +| Model | License | What it needs | +| :--- | :--- | :--- | +| `musetric/skey-onnx` | MIT | 22.05 kHz audio input | +| `musetric/chordmini-onnx` | MIT | a 144-bin log-CQT | +| `musetric/beat-this-onnx` | MIT | a 128-bin log-mel | +| `mispeech/ced-base` | Apache-2.0 | 16 kHz waveform input | +| Basic Pitch `nmp.onnx` | Apache-2.0 | a 43844-sample CQT input | +
diff --git a/assets/screenshots/main-empty.png b/assets/screenshots/main-empty.png new file mode 100644 index 0000000000000000000000000000000000000000..52fa8ae332f8c67de232dffc0b3f16d72d1969c9 GIT binary patch literal 45544 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

{2a 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 literal 0 HcmV?d00001 diff --git a/assets/screenshots/main-session.png b/assets/screenshots/main-session.png new file mode 100644 index 0000000000000000000000000000000000000000..cf2a4e80ff8fb69dcd26ff005aec7a8ee3114525 GIT binary patch literal 62815 zcmeFY2UL@3)Gmr`P#i%=0Y?nMic&;JDTWeJ5iEd+5PC#Fq=OJ@ASwc*Xh4vnR7Gll zP(vpwN(2N%AR&Yz1c;;n0TMzAxgX=q{Qo(3-Fwbi=d8QdUF-iAhSwzDe)rz*F3;Zk zY0B`Hp^{F)B&lT``dvnL%>-#tUSC%Ocr*cF>cXB+Ozv$f^t4{vlhN(K8 zR{D8H!!ETOUZZDU;cA7uHB0xNSgfx8gdw_jk$iiIzGKf>jo%s2d> z?fWc_n?y}qjXGrCe9sXVG|2bv=&U8bnZ{g5Z@$&h>D^cIjGUyIQEjA3a=%LQ{$lMn zlA2W=X&yYcDp{2!zrV+6bvWW)|E9m`Jw;zJT%4T7R9B~#e@QKO%29_`pR&h!M&kU* zue`epvdBwtPAbz@BO(wNOv&~hj4dO_28Os%dMG)pp@?IY3ZD>MR2O$-`EP;}d3iSy z^#nDit4{aWWoFeP5x5{utoNs3Q!<&x>~vaK6jW&&EcY?B#}?O^1+>;bC_!Tr-d$mm zRe>waUZM~Axw>Pufqg~je+Yfjn=}?`$?d`myT}@%ZyHBt&pxBVwY{rp*{@o}7r-4m zw8MRSiVLS>&5iSwRzH#htIihoZg;LKzc^MA=Er@L)^bQt79tMdPD&bNwtQ!Szmzvn zWyy}%C?!h7>@zYsBgFNpb#_*_ef^n?z;@xt@{O4m?-xQD4U}pOw&{Sj(>1Dmt<}=0LB`*<^J~t-Njy&|Up+7Uo|9xC)`st( z@<=-}9!?hOaXdOsHZU7zi}oCbvff%&CC_B0b{kN?c)$OqNv_0Xr;T#nXy4I54sIg* z_^tIbc@%?8#|WVt*+(NPFn9^ua=^aEGcb7g;U_Y)pXRRIb5>JZ-`u%;qeD!^TEju9 z7|uSZ-lvCC13almRHX(f-9r;`6v6Vo!-WOgejd@SJI4|%Z!zNp0m2H1$(HtFXVDG7 zraB)hyPNB-E;C=8PIb=+fxlukE^xiY-Pj)794b6`+E3^l+}&l6=qSLI|#43`&(zX-ug}-{`3UuQOkG zq?;C`H=BR5^6Ku3XxGQl)R(_L&%co6(iaQ^$oPp_1V|%i*;sl z_KnAW@~NcUx6$?1`N`&e`U~u{dx>gtO1-XsssTQ&Y~T|d{2^(S{CftrOG`1*0>^z< zT_sEL?JYN0iz81Crn#lTSIe32UX$D+xXovpIPK$I(%^6_T-4wo*>(x%ZXQNI-Z1+K zsic5Sf1`Ak1KEOMQ+2lD0@Rtc0p2z$COD7b!EXV&rO(*1JzCjd(#Lra-dHU=1d7@> z{?L1_?bp9pyWjX&sG?nnV*~1tp9+%* zavzAE8Xg^<+-4qig$vCNKF=N4ZQf&+9%+ZWP54A^WuVk4$hAK2)!3!#?LDX{T%h1| zm6va*4=zC9Mt&SP6BCfLJW%LnE(DwRm}2ubJg)vtE}efJm!?Ec@IuMQ5Joq5a6?AQ zd44Skrd{wIKD`W%oRmQ6Z0io=r%J#7|=kivD75NQ9vf8#VGd1fTy_q@HQ0S(dsi(WS!#w;-K;nLQ zrGjO>=X)Qd*VY{g*z4B0p0LdnhWCh04acymJt2DHW+F2hF7m-X-sH5UF>07S*bzYS z2@lYvl7{^wTJSAKD*o%K8@Rq^c=;R@RtA{ItId9`!-NiZR9`SD71OXI&$B8+O)G8N ztI37gsTqnt_mIfdmP!^?ytF=U(mKthTu4-F?6j$C_$o?3O?OmR?WUdhVy1_1<~}O? zBGtA$VriBxdw!}g)Bkob!>5i`Ao(|yy&%9T!TM`?JCF9BWi9(-r1~P0#W~6b#|8Mc zd9gC^ioz#}I(LhW_ZCDPWLWQQZ04s@e7n|W+2HB_h75p0{<_}r4x0R}2#FW}`%8X4 z9Qf%ZmNo79dxQ25+mzKV$-g)dr?k4H8A5n7rIQ(_54N&N@#);1_T98~*W={{2d7^o}g)D@FZ-%d23{s;bw&H(WC4zxwYD zk;XwC^>AB6f1~T#_s=*~<0}Jko&n0}UzyrE-5a~@*VW}H*-@5Zvm(wuDk{(_Da#xF zhZ)&g@L$Z_8rY$lGpqHN0uZ3X4EbH6DANWR z*Ad-|){I;;j$FI(AA5drZ|wMM&&?e>h=1{a^*{DFoils%uRV4h*0%XegBpWTe=%@! zSWayyTive4U5e(mMDy!v!5xo%*&CGnmrA!LDU{AGLFr3roD8V2F|VqK`AB2#=R-N46(g7evO81|!+apA z*nD#7d{% z17(#X7=gQkS3fHUwn_TfEJ2U8WkVys>x)4yGq(|9y;R(5r)rOuWvb!a(=iT>Q8dS zKUXOHtiRG+My-{K6zS}fOUNVf0y>dYh4D^l#byI-J?ngs(HR!6mVc!jOfZN0EK(&T zPM#=lUPPmNKNXLl#L*2NPm^Z%Q****xZZ;$LYDe~_zLIjB}0wXr;9c9N5i{!HrdJ> z9AgzGln?#(VRA@8-gj&7uEVgeK8fC2f|x^~23m=UY){az-@wuCczxW-!SkOAHg~*A z7~p^N&maNIl1kr@8*IqD(O%+ZA-SK(gXHS+DUjBR>a_}+FVT|N}@|u zSjVV@LBXf_S(;(cf^qt<5_@4et6%aasw)*JbqR9;i$Q4iwj}!(+N*@osWSx`h>7TL zQ5A_chE=FJKkFQYY-N-3T+c-B4OJSszWGw_5SF&5{rcm; zL-8hLtnIAb3QmjVO8Ki#k6+|0S!s>4y?k0(D7-^SdXG@HQndo37V&I7H13s@x)51} ziQb1&-S&(o6MO~@Hh&L{Yv}``lIr+`1cVdN@q`m>&t7z@oL@X z`^si}@WZ+1C^8AMj?YWO(B_-(LfE0)#?V&Ly_cOqH0;wYoo>pfm-T=YkUPty-$HRJ zrB4bUn5vi%PohrV-hk1^ZAuT#ycwEto3+%|qQXkNndlI-Q(i+}S0bKvNcQF?iFQ)` zqtEyHvEnnx6Tu9YpSZhhLExu+W8IOreT zxCAq@%^fkdw_FdPxxSqbr&JDj*xvV&)a2{R9`>rw$u9H|s6MkSsmU|xGtBg>Gwprr zv`Jzwd~=88A(OfNP6Lw92et+Don+MIw{oU6e)-C41*h_7Z0lzpy&((!7(iu9v0 zrnH!}@+M~qKES=Hl)3LH8!7v2w?0&%dJkWDn@$Clw9;vSUsJ0$!|X%F9~fjghcxWC=LcPyr*32aXlI=@ms1~2tIGEvWJ zYi4-161nnRgJ;E$zax*O#b=d(#7wDYK>YDVS!l6*ueq<4jT#JaSE6xjWNr!R6SnLg z_^$X>G4O%j_R6N>-T`FDq4h*eHx;e-2hU&AK?7Dh<%{y?L*hfO}|mw>?TK-iZ(audkCezZUo#qWDrJ(MfuYiQ>VIlUp|>Roj;$M$_?l&6$w{)zyj2zr ziZHrY7psr*gswx~w%1A|@YW3y_rr&qR$ej3BF3NVFMd|8A}9)?c>7V`AH-Et3GgI^ zkel5(C|{iK=#dHfZL!1eF})ZV&Cl!9v10!Q@GmcWLw}-+vk#|U{ot;>vl<9n+m_V7 zj?XMr8tzS7i=i&RbuLYJQ1PItxyHv)AyR zX;1{}y9!-Tyt!lcC(OOyhr9Kj>)rQ8-%&8QMUGSR6jnHHYnp#M{N z@V3gDpiE6;cD>NSn^*kMu5P_j)uF)Dvz zH`w*m&3vueVVnkO&f@MrYAC7s7hV66GpqTtyx4O%-umf{HR2I!G@kajP^(D2tQuq~ zZ#p{s57hRh$o#d_N%|c~&P(5)fd8zCoTEy)0DX*2(l{t>$v8SD*_OZ5nq@>E_y^2$ zzWv0~fbNo4zXUCWz#a4*i0Q+?}G zYD8c!spG71>sMdKLnFey-okeKsGcrL8>cRIHcb4=H?xJdqN-jUswZOQ{9WSTk9ygs z6vmK+ddFocNJm0egh4|=q6Mc<8v-m&dO5RmA5OjTIQ|2j=3@~d=pNQ8TKT)6zAkfG z?l4wwrR+eEL(_aT>uWXh%D8X$&BW%}A=3E>OP@hmVz?QVr_=yfD^-t>vBwc-47W6` zSIPqf0J5t`2-$?dwx+%Wk28IaA)-s%*D{fM+WRFWo?;J$2m^79KlqXbl<9!ATKE^~ z5q`^v|I^gpwqDI@Ae!2k7Y~26M_oLiF(d(>~7i4+9jmWf#rT zVrq%#fs~O9jaA>RuOvZCX+GdGMR`>VR&AVSu+zl{`A&fg-#MT*Jo}j+V)M)5+0@#+ zl9K)kw%+*M%UfM0VgZfcooclIFaw2WZjeYn5#uQj)nMe_>Pd)e_K6#pPfY_G^f&G> zi@WSI_{VTP?O6k@bnHeiB9Ag{K~o}ocG@?vZ5smO-_Dq0&DFi0(Fh>f)4v^NcsgxC zOX+cJR`}{tMxP$qLLHEyvwj-8mg*i}-|GeAK8eAu=_3^ee7%}1?w8VJj}-!a=Q8y@ zmnZ`MO8od~=^>veP5tfPIoaH1^{XxI8X2Mc*kzE5OL?>To zz3@=f#O8>Y@*72?HDQ}2e$CnV_U7Hv2?PK0iiV2=Y9`k8zsn?S>YpXr)pZ?`tsy>` zIUu{wiwEH4V6em37pM!%PQO$3Hd8%=dTVI8nLwEGTL5}So zV7udbi&xOtKe*W1m8WSgeKkmd_v+(kE?qb&b?xptu(ZUxAlr(!=X#;?mJ3r@^wSH5 zF`)*B_dtD6(}BA;mZ=z4#Y7t|vC@20hE}@F8n?;Cb)0vqJu7We<}3W8vhhsua7mTk zp11A2gn3r9wK%)gH2Ox3dj6j79IW#Em_vcHe&xCdYFAuN%T61h*U|eoLCFNtVTcvORt_1rd zCd3*h-JwZ^)NblnykD||9pM297l-~Xr=GE=g}9M2Ap2FSnaXj4T_iBPN>-|Ibi|s9 zr@A?6-a`T2rrA9(_zmzxQ|Ybe@NWgxo3eV(PNn_7iVv9-z=th zpPb|ygVHMhVR+bC&l^jM)z^C!DM-SBd>M3PdXdN5_LrV5gE3p4%9(kl&%oP6iwm`G z*!kcvo4uY1qVPq|k8w+sjLWKZOZUaPC1oSnVL^a;E;=o4+?+=m(&4^(gV+O|i))qa z2-a{0Ck?CrF7%%bN%V$o5Rm?KRA*5pD7W6%ZM53*KH%JlX`-4`fV#;uvDubF0~0iM z4hI%glVsI14&tOI**^Q$I)A*VTB+t_zY_Cm*?&||r#G^IK`LT;(>I&_PJD?oiiB9s zX%p*o`uNoYMO$jv<3{}^Y}n1hVNAdc*n(&JSX*igD$Anj$wqtpe#<3ipQnX1>NR){eR7ze=QEUZa01aZZq_LN2_esfSicsA@iQeuNNBZPM$7=N}KqeKmQZ{w9wW z-4CgD+nJ;HwJ{-%EcD1mp7Wp%QHxXKVg`&@U&jM>dU9;zlJ{w0PmP!R(t3k-9~SVR zdg1PdeG=R+_H2gp6#=(vDppO1_w}2&Fk)j7TI_6utyH})61-?3*1dmd;T5PNQMT`J z{FV>x<-Xh-4=^)?;pb8hL{+<5#-jFq9furL9RJ{EA%W${;X_J%idUAFV27i8JkM^F zxE=yTj6o%G`z$Px>BreweV=6BZ=NUBbcJM-!8GVRF1M3V_hW&zfKA9*>N=$fwraoZ z$Z-BM7gb4+FX%nhumsmq>}EgeShvt*w5G^gu>Amrj`w$4hpx zm)yIK$=+`7s_a}`P&U4;g_bergews6;)8XT?Hzy+uAd2mb>HX5O%EryfKDQb}$dD zT`S$VBk5V01{HqVF>*%|OJ%Hh&z9<_@Ed!ahHHepZwWvN`eEXY;CRUH!}fc6Hghy+kc`Y)m2#|BiR3i=KAFQ|w*S2kvc_$enxg z0S>;yC)_#u(A8pr9!&;@WE{Z2A9(AHd>DJPdMw#9;c+1?jT*FG{7Fi@%RN5DexG1* z-Oc%iO3k$xQ!S_c`>$BsAKYy8`W3tDe9g#Y;+SMqz;&A%<_*T%a^W9;UbqhQosxI5 zb@)x4i^rrgT#Bd1x|dFW^0Q?7+xxck!27C}s0BC{aF#3PTRIAUv=m~1hRFGgTcV38 zc6+ynQSEhYa?=|}FRa`1Bq1$oPKIt!jF0;xA*Z{MQlAjDaFwz%kECpDsNhOy_kbk zock8Z)#(@6Kd3LyBpr#ZQwRb&tpHQn`5{9w^ z<)&6EItr%a6n3o4_s`$*_^S5tT4h-LreHSA&F-`BjfpBn%90&A4{3Doi)6>7*B@5q zR;h9D^4q#3d^;b@VNtnTOxLmOhCE*ZT0Hi8X^Oujfu{tGk zxrus{*iX+18L>SkNp2zB=t1R{tV*5brx)(_jvqX~jv_I6k^VlcC7+kRiQZk>M{3o4 zoSyLRpRLXI-d{!-Xi2rCw$D4dIsH8rsyU`+wq@*ev7cd;qZM;g|DNp%RE)Lk(RS!S zp@enY4QCHwwwyN|g+e*i9?33PGcz^5bI=5b6I9sj>9nIvx@nC9M_P3=V_A{d9*T^? z+xfD2!O)q3vP);aX!M8bHN7I6n=|@Jm)1@9uPgnn>I>>u9fYh%X<7ZB36#Wg8s4<~ zBiWL`Fhs7Flhrrj|Q{lH|xqNZQ0zqmeqVfmr4A2QH50O2qir2ZxK+=!f z+Ww5#Ky1NECj2wu+Z!nLTjaYx*^2{(*(sR#b<6(iI!Tn9K5z825b1O6Up=%B0W|cV z_r0-O;5h$7{r)M=lyYxZ%;UX&CEMW2=l>}{pB5|YIR~FG`50Q5iv ze(%6R`Ygb1mj1-H!l~HaJ%h1W`Tz8l*~^8~8!z5m>M)Oj2v&Q=M}S%jEa?~CPpMMY z_qa2ssqaM;GS>v-+3Jps1}h`iEPQz3|C&$x*WEcf+VV=l45Iz~ufl}_%S2?y5mGFk z*5jBNxs);9>D$cyy@6feC0=tON3OJJMu-Jaov%u^wG)h(e>HPKR1aNT$`L0qf$RnRXx9eruzhtJURX>1-Cd2bHAO&u z;n1?)7yj#VDIzJYE_V5SZe7Zn-vV9jFy9n`oXsaqx6jvIh&$nh&ndi4N`UU(b6DI4 zMM6iuofr3_fi<6qn2!)E=?a)W4YY!2d;6a(NNnY^T_QGjlo3!ISi&7(%BFjd%DJjy(mK~j zscM=FH^d=V+nLc|p+EPdvYGhf{Rsm1?^`#Uhkx^Ekf^k;8w+<2?otn#iq+1NNt&iX z#c9m5yylmb-w!02!ieyS#f@`3)?%k5UWjd z8dv@AOlIAxHXKwc*#X!1XPEsiolb~M>Qnpg?GhU1^nbU@r+_I2-g7?Kp6vOa6!#yt zH2lUP_fIzPn9t?^AR9rq?EMn+WUofa2DtpGe>Hl*1o>gNS;v;AceTovivLqQ+o{L$ z*j}w{@I2yZ!uB$+vOu`x@qegE9E`701~}qAQvTAjCE@NbW%gw*Weg$T{NE9M;_{0^ zwa?khZ~Lp14FcvaR(vVbxYT>xdDH{s>FAlo=QwQn$89BY_x#s#-RS;H4?kV+zx43G zpoe|bx0b{^+fyB1Do8*5jlS3P94K#_S+*(2W!ZvocjM1Dj&Dus+pDUDKW1Oca%-4) zckDH~Z}Y&ZSN9>8c3=N*zeRIgit%O`p)wVu2RfLRbu+P_>=45DBmTK&cXArlwbd>G z^ov~1Y(PA?bT`m%HskbAJJ9|Rzy6FRWiBc(+vr5^R*!?Rj1a84LrDf z=1`y*&wQAH&rp!rTVShP{v)(Glm#M>oDMcMFbBD8=0TjQxMuXdhX^FLhx^<-(x8Nz z?CzI~wzwLQRq&P`DpnuwdM5d6GM0LTfp!lx8$Y`$eB3pRvLqB?>dlM@VFRZwJ;PEh zY4hrlKpvCyy!^>I@pKxzzw(f^f2A-Jz8#d%u*7vO%t1H{8e}t2`jn7<7xJgxJhZ!0 z>mB!QyH^Uaa&Q8sH_{4syMKGz_yGH)1!2^tf=T!8nbjRm2t*)fUULIWs4WBR`v6C0 zYaNsZ*d7#0f)ydYH!7Sb7zm?HGkACCp(l%|zL^a|x;NK@h68Ij37&e-#?l^58-Zjj z-=NJM3_h*D7{dTBYNh*~X;%du~m{xraT2at99!{oz7kn#Apct+@4^XLHk>lB0c zG1+?B?AGh)FVipt(#sCf?9Wl3@PiZfnRitkGQsT%(tMtyQ*zdA?kxGC8*D08Rqa*X zkGsCSnG5)|tibQ14HGt*WnZBD>PW8I(dz@P<17fO zS+GcbUEb3iS9dkN%nZ54-Sp7e>4pg-L~{(}#9P5F+wVt9sDT<$hlMS!qgfN*d$TF` zrV^?^u&8H{x#UTR!8K6GaTq~z>;g#U^YOqUYLsm)$Bn#bTDD2AZ*8bthl{v|e}gy6 zu0EB4pQ;*Jcmo+jPJkbYo`TVjA=z71E+UZ2WO0_ykU*Lq8f!riC1a?Y<~wk3;q!&M z6N+|?Yh)Jf2W(j$F#=VsYdr(>+p##XB_X=MgnBiwt(ZEOPv#B1)q^JyF+~ z{WXa+-vHHX;5G}YveHm0LV6==_X|qEta8$p#^4o&>e{C9M1`m|4!k?qX)SGCS3YNj z4}d3iQN*ay>6(=p@Eu*O@EUI7;rKStoe1Av1iT05ApFD>DzuWuw#tpV2au>KCgt2!)R zqr+5TI@p0r8wkkpw%EIi#CFS3DW@v5W@D(+3OR(edSm|nYlG~@CBUv?Agxm_EW$7t z9ML20k;@T1^=@mGbihyg1&X9ZIgq>W2Z9~=Y-XltzH^5L1w7yQ5MtK%z5+{)tP>ui zgncS3ETUT9EmTdnJn&lqPV_C|2&3K~g=`~LJHn6uRSs#m|L!Wy-WY=z43Z+R>MS$z=d9)Nau|dp+g+F9n1v~R0hSEtShF6lH294 zx9O{tP!Y6Ic~7!TAv!(=l3jn!Bq%dvxk0RKU&ALlfH3gaehwjMUWp6W-&*)T9ZKGd@4EH3;%-ENXHKMdc?I%e87JW zc39zs=2MYDa zLi+2fTzXfbDGVFX3zy#Rsu+wPD}C;AVJ65|b0>Xh6fYOwM(QkZBwS!(i({$Ry@rq= zxPO00Rg1RB1;-kSyNAf6!;XE0fi&zQ?R|%xQOMy2%eB@v9lLsZD)V!e_&MkY4(*qZ zw!DEr&P~HRqzyzl=RxzWi`6XJ4Ma0TD(l=Tl>A1sjMDhgOfO6m;c%-2LhO)%3g5V| zLKMf0Hk3^0{JK|bko{a&+#<3;jL>1sUao_H1VVU=o@7~o7_LwF1p5SG??{amp%IkE z?OJOHD5OqWgTyCSM(z%0O-=J81dw?=MreqCe?1?Kcxb<3ilcbHOg6Jk z>(+O1|8dBY5VjpQx(AJw8A&an`dO7wot^exUx5(Uc*2aRo%8`xC118N>#A{LsaeJ^ zbFKU(@|+hwnT4MQL&k2cee0@@&lZi7TQgAUSs@%+;Eug=qZu=$DcV!9g+Fp(D~^k8 z0%ySyU{D8V4>EysO3K20f5`P8HNF^Aj?KynT71RzXYD`H$beo*_ikH8oEgpgXdZEN zs3n%g&h}b^=+4tMf}RErKa6Z3?RY}B==vtDC_TRFN9IT8Y(0>fN_*`tJo77TXTY+p zIHj!Rdmg#{LXMQK$Ci(c;zVLxkH5fUzIjrkIMzkoO=9;x^-_AJkq&?H6Y(MM|B zAJTQ4f?Yyb42q61d@Q~IuIHod*0D`AKfMtZ*uF=2qX`U$||J-ay$BW z$y@*t1k^VW;&wkRg2v!`jTY2uebEr*qz4JR>`Wa#iTcsL*C; z!>pplKHOZhpc+9FS^EyRsPG?1ZqbqS6&M;DTe!=gK+g0++U+CfREOa$e|k9=c#L`w z{KpXE9a|p%M3%e^iTADWMbaN6{}rhxzmYT-Vys zfy(nc#beQv3>#S7Te{7A(~(mtZ?IoE(Sz)4Mu8>4PtgQ~*@QeNBK+9QiXn*4IudpZ zsN_IxTES_{qYNI!Ic#W6XW0Xxq+>y#AFkueieF${9Ls7sN%)>Y_F2iX6jsB;d^IP| z&N&-3Z&-YT@X9YH7xJ!p->$(*o(DJDtRR)dc9}?<^WiDcbvPIF06c|aK%ZU&F|5M_ zKwu<4VK-ug3^iC8%6SClIHG>^wgoup`7mIQAW?JTj+T|&Ag9Q>!vmG&IJ0Mk7?|wW zp$Y`8#p4pgX2bPC2vIh6UqQ4#qfMq1E$@&B#NP-|oxNHKRW`#8Z@gQB@RC?gW#E&u zs>P!$$k@FO`Ks+X>|`{=jH4QKbvwp~6-bSQI}qr}{1wF)C5TL^bbExbV5DC3wVe84 zE#MQxKwQ?~u~H5oCO7#YM?x}muU3E1VABU}3>Cc=5CgHspI?n7#*Kp+8H03iT>KBu z8w9$zmd<03gf=$9U>1ZnBC7#vubT*oa*GJnUO!zwP6N9Cg)x0aXOc2djF!=_Mz9|i zUjV7%w+X231Q8YWk_l5YkmD1=9f1s!fl^f?Zgh%R=>32#XBaCmO_!~R-2?;qqb1s} ziY4BS`<`1V?@z$Ydd@Ck1sUF56HsqxVMG|Sb+cvF$?HMMXw9K2 z^c|LRrEjn4_roW?Nm{000b#->PK(c~DX1HdDt^rFFWajHjyOXJ7}Wld@O{WXW=|7# zHJGPVW52;0KlOYHCVUFPSo!vpE8Nbho#ZcQ9yRTSpMAYT4y3xUt`3=Jx4cHk_FsH{ z4ZTvi)3=2X@Oi!T0tu7Cp@gD!tI%-;gHu2{p@_<+V?WUSO$(<^yb^cW-SLq7<_~7)~jn zR)@5<>N~uqwPv*bZs0H(Q8lY|rWkU9Pmib9ce0M&RBj=$p|xq%bX88uZ+ShQNLj~S zW%gOCBmQd?WqSQm>75}b-%vH(d&U4@y6}1yvc|gz!n@TYnpx8<08bq(cR|c>9mH9| zAg@)7Wtk{L5pd8r5X=QJ!0s-gc7WApmEi=wN~wYTO+uN7Eui$qh}gBLG>nTyH&4~f z9+8PpdRu`ux3g+NCy1MK(cP(Zj+V$fXFWCfmkbdr_SF*G%wNpV*SoK^1cvxQjke(5xAZsAh#dNdASM+ zo+x~%A_~1&f^J*my$S;P87#Llzn+d7Idu*8CfJc6OD||f+Y6|z1MFgZ6(5*2fw9u6 zuPbiUpaF>ac@dRRwIj}c#fV7j0H8#vV+2U_WJm9|gfeu9<5HQoXw`2!2$l5+sA909 z*>@Jyl6k!fh*Km6<1&u@Oky_>4E9S)1tpy}D6#-~MJ&(TIuR^ajJp`2w^2xrc2&3h zT2w@+GiZE)GqP+7Lo^GAucPezIv}mHf&)|ETm~!ow~8Uw0@8dWvnhv^|Z`% zY(xOaFXs}-D~da&V+4ZLmsvNVdm{pa#|Cq{BAdJ@mC$W<78nPDAS&V*qI7%IO~SCW zu7Ds;WPlk6kl|uMUe0z^`!rQX4CRD?;s4IH4*GS&)bJ*-Os`K1^Xi0gFY*H4`O}Pi zT(q93#Z^@OAq5Oks1?wS+tjsdehWI8CtZ5N92T($$-vkRd#Kw48`+%NR~~kw@O=L_ z{W}8_Ut!YAU5|oY0<#PsZ(2${GT|MgC9xeksrtKSS$$gxh>)$&sxW5hZplHI?SSLh zs{mkIJNYY=_QPFQT8m@FF$JPr&#NI>BY`hJI9&$SYne6C7h7Hpky=2+lt3AI*oosi z4OS)-9;y0T{i*UzUt4&gUYygNR*;vxrE%jmh2$uSY2c5E_dit8xAym23O8}@&tBD- zzyw72nSlS~$%&D>bv;?{YL*5U4a9t`Yg~djaOl(!n!FvRX$<%I zc7(5xfQ0CU%%#A0fN!+!5anV7h{QsCT4_cRH3Ls6cgb3{7{Z==PY)e^&;hZI`~IB; z{SxfB)a}2K5M|q%hprnLZ8cA!+ZrN9eko&EgBVvnSR3n>yF`MtrOyC|YOl-~>Jip5 zhDJM;fMB!lWF`_;wNPwnBc@@{32T=lnjJv!MYcD^NL=km7>*nI-8iN$4lPfr zEPu;fiA=gl(7i&p(bTggpeTH)u|9m~;5=}oUW057fQyVD^eHiHB*Xaj9N9Sy#8OBb z>zNc|h)al1h{41xKX_XKpGY$$(QQBvRDi(xO^U%A@JXTDz@1gfsClD<0XqGyf@gzV z!fr$}tvv{&nmLT3Kx5W(C}hJl05mk-N?+vIvdnSU(y&yYw8*o%NE#l8E%}pS0}ZD$ zY&aOVA~O>V8V~wllhIo4trHaDWEFlWzRjno9P=x(>*iLn}ImlT(h?kRG4( zKsbocHhm|8YuoIzKFya>s5sGZ>DmT_KQ=rzv6GrR{9dE*FKoXF3gWyHTY&hD2_6+ z1<8ZdRyu)`v+lQ|$hkgR3or(WOPf&+->~Vc5Z}>zZXJ!fK;#?WhA1_NumxeT1^fZ_ zY!3$SRE(D2Gf+vKz6MmH^AkFLJ`5d8<*%CHRxLZtCfCU;=A+uD$Du@@kx4)cXIUW# z%CV+#&v*QE>WQcyzux?X|klSHQFaOa?S2@zh;iaVD%^hAV zs8Wumj^nAi`9lx1HiCv?UF~2hX1|X6Pbjx-Nb$P{Wi~UDo%88!UMp5PT{uU6K9N)# zWYpi9@TmG}qj{F~w;oLEm~Zl2V4-YL7AG|x>>~q{D9^pyjUN9s4bSK_FY}MQJDY31 z^7Wa_)}XF!j`=0nE{0Pme_!X~vtAh^46XLSu|4Dfa!wsQ5BHU>x{qyDi>o^`JE-P9*3+bXIh7`SnAzyz9-rg{yw9pYW7;f!;_94Oq6CQDJ7=^ zXhy|Xy!UEslrmhxaDRLYiv+=Rw;)cmksfd(4^yI6$M4V_1!dO8JpOzvTNXF_p1 z!}hzf3>_oJB7vliow%YYs`6<>iw`-d=g-nNS?gkhK{?GwDTrxzU4Zh9u0t?Fh>+n5 zT8F$rQ`OGH0kXCd3}U=}K&&+zV%1N2y*Be{e!-DkQ&a2z z0BL1`1AS&CSzZ9rdQqJRA(Gq+i(FU<^EW}_h#`&Hwm(KfBHkPd;r5VXfew=L06VLN z$eyUkjwq)3qsUT-(w+>6otEf2A!2;j7!3AVc0NIz*8&}jph8_~aB&M7LX?sJA>|fn z^d&X_L#`8mW3ubGtkgf;5T*9T%$A9WiFqJe_gt>OclWl?8iYR>#H2=jI!6z^E$*`d zdFhXF;+Y?Gqq2ckqj~j?ID^48xr?o6)UH%YPnWprP_Bis1#G33!Q+BsdP8GAg*$-) z2EQ-UdFQIogUXOxNGq@Hz`M#>pi5H)!C`AX8p|MSzBsl&20ge+)M-3VrrU^~jFKXC zk#}SV*)#Fr?w*Dpr=3F`2=4PyE}_uK{v*D|SimD+W9jd?1fahhRRs@-;me}>oLb)) zV2AgW-+x);qTWA9fy-`G?oNKDn}p#*5+)ecs}SkBH;f^$_#iV`6RS01 z(8W14%+Ks50Y06KDBZnr8i91SxkS+rg$guBf#%Sf4dN}%1Bs!sj#~TwTfx2qxZd>y2#P z)`r*H?;t@0k<=EsMiaMi1Tt423P9YcJRsDp0mYc=EVz))iztIcwVStHmK&wo!FIJH zN_)RMfaK>Abp0}F*L(WP-Bjlm6^u({d+e>xE2`x}JKWs&oI1i)3mr_olgi00C|D;(}5P-j`x}6%l%Q7nt8J`bRfxF4pc_znG2|Z{Uc6hUb|67 z_9}oA%KYF((wf-Dz1>{#YgUsoqdo|QgbiR#2G~8SYj1#VY74xGihVJ_cFY3mQxR+~ z_=2Y;W3y?LelVsKeL>*sJy?mROs)l#pf$}WTyyAAVnTIv9R}zojYAFe?nAO`n=ydI zL#-lgJcSt?51Hu?qC%|4Z8f2Qd4>V42B3{m2{G^&B($|g4HC!innMTJV*$wTy2^h|KukY=azmk9>SD}W@Lfwm&HLGCQ&mgYphtR7EaQ7C zC%Z9cRxBTjIbHg!bNJr-?r|Adafwo`Gw}Jxl1&)5!i~dWoN>1ZKYrAY@05IY;|DvV zf@-yWy9pl=!7r<7UzBzyY`@k!6c<8Ql20W^{TQvo3wCU}-G zAVk~GcY#RB!upabeHMo65f7cK(Ao$h`=jj9R&;IuNPravjc}`qjS*Zo9WYOUSo8J; ze=dd)VQ}AYHKq~^bA<(IotAvP#!0CLBJpe~^;rODocHW8WQ_o%>eX0CYYEUee&{wx zOsgd&i%#Zk)W=IVz20EAEo2>I>|>oN%9Y3xW9(uo%ot3JVaAN# zsn7CyKHu;2`#sN}zdycz^m>)XHRpAn$9Zh;_i-Ey04Yq82EIhS(#?cqQeNsz^QVow zIU*n*)zST1+!yZ0R?I7*v3u-=Z%CUaJOk+P(j3m6#z8G_p6~ZVvBJF|xWMOj8EfG( z1{rRdAL_|>uIb~Am#R{iw|3u)xRmb&@?h_XkhNSr`k@4Cp{7abrvmT%G5u2a(h!dH z5Zbg^s?#OQTe^I8c zJS9s6oejP_+Nys)fPDhlN?lv28y@F6{)Cr4d46uC$4|$SET|c@o7f*FG}7APLto<0 zOM~zq*0-(1o>TR%#%oRo(eoKoAZiV-pnAzLd!}Qp8Wu$y=iXOr(u6Y#r&=%#7W0E# zgolT(V-OE-38C|vz8%@nP|8YT9W8kCrUGmv7hu=5KPo~|3a=&=>`)$p)O8qm7c@)zHM{Ip2R+uqcA#tbp;|Va%Hdd1LJ^ zvy9Sq4|e+*EH8j8TkS_?@Taf{f-?=|6p(A~X@v_k7aRd^hpg@-I?weS|KD5Ec*V|elF=TA9K%O10?OS?DVM#+wh zW=?#!(%zonci|)JpS2#Cpfq#VFZk}srbjAh^zuf`w#)|+I^w45mQP~YJOfli^f+@H z7Qi28Tn<2K5{VudF4uY-pll7>{ZejsH1<_LpKNd%E5zi`kCq<`7?KLrI(+qWj`h=% z3BlEUFIgU|hy?WhDAww;VkfI#T|ig5zM$|4F&AeG0}E4LpdD@#PK-jVj15r~>kOw6 zoqc4{fquX5=riLAg$p&avc1`v;w7`QDiEO^Dy7}2)x*3CV7LOpH13Tsm`B6Op0p1s zkhDhMoX}C_yJsad@WT~+uyt>~(KWeNYa*n@Q@QEcF=sGVmbnDdn(?35l&VtzBa4!f zO1Vu#M>8t&>PEuQk8Tq#qDfOVBOb&{yvz~XA9gDIw2@B|gK^opc$pWht)L2dU=ikp zqIBiEZ%~;wvzRJ8%6@*6A!$`I$meN`2*dUKya7n&(0xapL!HAMpHSYPmAF;IfgV?$1 zK7FwPj|wUjdiFG?&fI}zy}la4zy(TTstP9@kPx2*_iB9XlKFzKH;p4`h$5ZYK*SIj z3mz8f_)Ff#nIttH5VZ7LN)Ni|lnRWa8G^>`2_5>z)UdyI!W*zkQ<){YhUnuaRUNFu6Y}=$CI{hY#H~>0$X;ZeF`R z-`Qc`DGsgbB3%2eWS%Swz>!x`){5o=hDsC%==XdQnYpkdLWUR?0W|}~q_C%JEU*4^ zSXc3e(>VoYOGUBHMIM(52VK_Y_+Zidv&OLSeuod;Yi3)@TV&Su%5 z2;Q(D1rO;s)6Y>yU9#ZAd0g~4fil+meZ!pdDSZJ$*+6u3=i~!nF>~ST^bZgd4VfeR z6p2HQWFeMW%3i?hM$WmT%-TybN@=%o77QwO9hjs+hk8GAG0p0GLyWTur}b z0Pyoz-Ird}Yj=dvW(b{JXde?rz|UFz{1v1y)i6k!xZ<%yuVBXy5Cs74vf#C{xnZ5T zjOIh##q|u|WROO_gGSo;q@rZ*VwV@nbUt>uq7vtc+)fPj%fK%m_ye(C$#d@R;uPyj zvaE#?%1Q0IN3P_g<7zW$y4|$qS2%}-49J{%rrF;Nm}6u-BO*8$IF-7|U0*~t%^^sZ zf&>};h^0X%9e6ngaQ;daA;UK*{1G$&FsA*9;e4D$HwV5WypB@Z)@fBttR1~K#jLzY zy>`u!?6~ydC<@z}he?M9?>u71D)Lw4kMko_e3f+bZgXTL!Q4C9+OebW{9ZH?wS3%u zq|is2X{XARdm!R;;x@Z~AzkWZ0&;I{BOm#sZY%r9sEY$2ln1eZjP%`xCv={E+X-?Z z7YlK~6|d(-D0FzzILqtFn3Ms9;a(WU3f|r2w_wg2VgWx@=>f?7&-*#rBg;M`B8@K2 zJKg5inkl;xCWt}0y?g-v-G3Qg616LW>)k)8VahutB1a1tO%-$YK+gX9=EA>OSOH7Y zPiF1lvc8PVc~p_ry%=grX!&i#U{8iqpGNuFBA)68{P00r<~vkp3z8NLSVe_GQCM12|uyOKdR`6x*# zNqsL+^cgpDq3(B&0HhHj$vj(Qg%B0$zr3^@Bx`nYek491ZJcnl7DdI)(e`+qcqRH9 z05qEWQdtQJrZh;$WqJBR`{@SFc|?X~<8l|?Z_WD)ZQL_sjl%76K=}H0DGp?(y2;qH zC?4SOI02Vk?Ox&4p1`KWdj92&+nFH0;g9ag#dB%JzRLoJuEcM5Dd)S*5wYw-hVQ-$ z*MhA;X22S>ee3W=tzCp#r(+#ZBu#l52X7A$--CTORfY3c=FcVFti}5Tz@a1GL>d9@ zicFi45{h?s;)=zYSV2^I7_t*k_{Tp&qJA?UrUJ0gf4b}5%_RhKY3DGASgNSg*K~X} zS-7j~))iD#_~UbS)te~hx47yM0O~0xK};*;sMx}xXiEgaf%WYuT>S^F*rZ5l3mrYl zWs5I2T;TV)D6hDz;M)a^d_cy#Pjc?~`H>gf%tKf?0RPEn=*%e{S-S2TS9s`h8oJ>O zO2JLI_9=gh_&_Mi!yYT4FLaK0#8d#AlxFog_J^sky9C0=Zo0EcN3&2E*yA`l0{9^+ zeTql8qADRrdHID`>bQbR|IC1ILC(gTy^Uk#%pRfdmmlHNlDfa=O?coiv5@Et@E}us ze?Q#-l0$*Z3#XQA;NRhEmA}6t0;6D#U^X=0#+K>Mmnq~~Bf=g9+;O8zPLHr6R&d+Y zngSjD5qSzvimH9GA z6G#cgQ@XlV)K27BBteILQ9pAP$pN9$3-CTxDBYt(p`R1A9&k=j=NVA|meS~pdLZC4 zPp+1ZjWS2Xcb8Ky@5*)F{au3%0pcVYp#YP^1$m6cBzN4-LNnv_q>E`{MC^S6gOv%T zj-$se+1-`xr=!Q;qSPjnj;D2yHxr2)d`PXl8Alzs!* zerfS1WHK4ct$JgWuYK4GiO0|K+ez)~Dw-(+z-)D&OzNqY9eIM3`H8Yq!OgJn$|o#`KHqU@}pMy1*gu}F&D|a_Tj!4 z0mJesDYTy*Qs{AlSa)yxl}z|itDc73ErbfMw}`TB?uyln3y3Gfsm)BA<*287tPpe= zB`m%4dbRuQ7cgK#KZirJXdHAOkj>$Y5UwT3nW)=eC5QUPJR2hT9@jZV&;j5=Pfu>? zgOFv_0Z9KIzL5V#CPa2%Am16a6#&o1qodic5Br*JYd@B)^0M{VpJPg2`{-8;<-x!07)8WK8Yag${b0@rwK9f%u0Cy zgDUx3q^O%6Gq>kJ^%*^v?(#4;`m0s5HNyY&H{@M?BfkD|H|-1szg)Z(05yJ!;*7otfKJ}~G&64&XMr z2qb0QBU~#%%%qTC5P^_UC^71bG#W&$04i~b0%i-rfw{Vj=fGxJ{ffQ8({*4&m z17L*CgEeR|0x$`3tI5kp?QeJkpIJ9n+vVr%$u7=^Md+y$a3tGhTE}s~cuq}IvuYrn zxhFVphvMy$7UQ{X8HeD3IB=9_B7Ln&`M_&Fp*eP%mGr85=6rv-H#_(7k@M+z_<7@* zsxKl=M_VLmh|F5Z_K8@@s7cj(Dcb6Ne!j8wgYf(sOW?eYMWT^7M)^FlY*vMr5XgX7 z98F+Ald%o@p3;i4@zimH1lhwif7wUmeNP7wvTvVa6mUXX_VW=wt^{Kq2c>GbY4?&A zYC@L*G3XU0BARbq3)E|Z>tMeJK(7-QAkoFOrx=_7xr1xoh|Zm-!#nwi1K^lR#s?Rg zrLk<@l_`s*6m&&MS1Ug>**i)7UhKOmhUCmrKtRy%!U?cK_)li1(fUUW9ZPwpImSGX zPPFRMOexR(UYLgGL#C%U^|Ib|R`lt?F>{5B(CUIx4N-ac>l4>K=CQ%Idfuapg~6#h zFSguqRg{;aXU&Z{EE5M7j5m{9BBBI7!lC!t7)p1w%b>&Zc4)b8Tn!e%E5y^y2I z>Kw)fTXU41u?=dv{oXXddI1^Zw>*4#2lMJjYT(8@MCU=ld1@FjC0MJr01ulT<{b56 zgw>im>E6!{zl~Kkz?2egk$ge0O{JusB(w4%JC=Lz%a8(wOY_aP8{TH_nn_S&S|Zid z9EG;ddwOJ`DCtpNV>2B@XY;Rbu9m~5?@0hCuxb`ZhHU{SCDy26kSt@^;dEBT|qb@u@6J3qs1FSb`1;0!$Om^m`sv_T8d+gNYG zVYGXoSV@w6``$ZEvQT6qt++mLjTWba%`&)8N$an)k+)msSbJQZ` z>*Zzi70Y})jrV01V1nwX&SVo*o8eQfcwe_A8pcPrTX->G-y_H-Y`{EQ;KFw@&TeMI z8?VPUXjdKy-a9aMWb*K4(mI9nQ>TMEJE-F5jvtN0?chY1rM*9#V}C@(9>?Y~&(h_i z1PKl^hU4dg?2u|dtoOhxb4fzDXRM4Cb2aEH<7aRMaQ*7c(Wp>$_0YcoCDs|FJzYy# zmCm>&b)xVXto3E?>=S5J2{M9=&f`>V>c=#s3;>3k6F3|j>C-Tcd z_B6GCk^Ej{gNZ|ZfV%IoDgjqdHV?P7I51-WBrS$DNClp7coXg5Ld@IlVq)V5?%A7{a@JHax6W5>`R#dDUmewF8cY|N# zh5h;?jdh-YXJHh&I8h}iY2HCzgTc!m7tp@^k--eSLDZnnu$00LPzdze2iEfAzEp5j z&v)iDATeP^?cf<;Bk~xZ zOnGSL5Hq+1@UoR!y=dUD*9KAR7 zVH$S9^Acyz{%EP$WmhsWrpwPYSV|MWzuZPW-v<}WH^QNfl;S)_yylQ}ce#Sj zClgK9mvbmxZA~nP_PKxotAIGa6r}Xk?QXSdSKZmRX0W@sVo;xjNs!a|w6#Dh_46ZE z@OM=2(sxk(tm5vAQqquumxl0=W3`(l%f?H78q)Enl{P?K!xg%zk%eDjTMBaemC!_9?19yRyVG9}C*F8s1g@QheNO;vq-$efE z^rtslYghNy-XLH1m)+R}O0v{c-ygOYWDIzS?M{B#%k}jRMl{6N(K(KejSaPUN0692Z#Ef3=*yF=Jcve7BIYgKAUK{!DzSuCI>SJ znjp?}SrTDfm zr%~<+k~>PtwTaGo<@sS=3~E#+Fgj&13MO|xG}K4^TnlpedbPDbS~Y=RMU>u?0@kq2 zML@GEM`EG#F(#Tzuf-<~1EeCHQ23=}h{M;(!T;?hrX#tDEG}i@g ztj=e+);cmZvJS$beOD5rb{(-oh7NRtGJ!c(%9ReW+06(#cPqJF9F;#>IITO!cbsYwN@R`R!)9J@rwG5vA%EydqA zgB)$6=Tz9eYCH>ahhRQ*8vPt#RwQ^Q_B)C$oj zrVY*`JSCZYkQtm2m}CpsY~s5ZPMOw-?eOE1wTG6xW^LayZ4?}v$;&1-KDMN@d5n|^ z00=pFl;EwTh@{9B0yVjUbStS~@J|}&`4r1Wk>Y8m4xW%6uCZ*mU{~&>cg?Oy zGUty6iAHwQ{8*(rKS0!%o+tDRf@`ZqaP0_?d7-+4bCQ$A?le;KJd~2l zI5oQ=GENML@zj&9V)qkPnoRYvNH3gA$grI?lFx18qn-gV-FLzQ;cKPV8uz>};*yN; z7&^#s-`$rq<)&MB89C_tg4Ib@@GWheLxXcrrz8X^^Ra&SO}y`y433cedTd zT!}0PghinHfzN~RYZMBrOaRxlF4&o+VY)2f_7s~~A^l-BrMTMJbl<;1X5i#;v?tDG zT$<%WVE8@`cjLxtvMO_ZG(N$}Q|Kqws3?gWty)_}-7bxP3mIPK%fTZzdI#1p(d*Ok z(t;0Wh)WQ-73$Rs7aF#v&GuQNNO_J(smabZp!7y$nm81`{FsVY^La#8#|w2dOZMT)L!H>*=sT+o-*`mwmk z2>xCwiC#zWf-AtUdNrQn^d7XiPeT!C4RH+@l_A@llt!(+g!S5xk<#3y=JQJr!2Rd~87 z6=XQG<4k&crgr7m#l5p!NAq3K9Ioo3^#ZOAV-+ZaF6`_pWGougmvmpaxW?7pjtclS zpB>fV0hXvz2d@3XRRT0`p}6wO7V@Koh0YUKTJy zsb}DV@OHqRlkVFJYS|@JVk?lma#rx-3BEOAohGWXNBM*r>!A?jHc0!C-~r=ho8g^5 z;ek{@F8A5t3IeixydC-)^+ZbzChF7;A>+HL)T|fqXGT2$3ohy>oBiIU z^*Si7v&w#!MB0o3NEMlGg_;fys?ibp(^$c`a9V@VR3YXWiP0qFoOQz3)ZsB;h(LA` ze$3OICs|gz1z6J;7JZ~5h!XUJ72F+dCbc+{0(%OaiAqAuW8kP%romdp$>_;oIw0tl z9As7|3H@}L3yad|XDaKEjXSKfFvofmUw|O7?3P}Mb;gcuu^K+NS*x#4BsA@{pOXn3 zo(VHEuvEH)y2yHRdxvBB*{n5yBe-f9BR%#M(R1TuOwleIRPA?%7}&Mm#%`0}@H!Lq{-8fOYQ#1ha?X}CRH`1#)<;JL zx;h4=otCK-(LDy=;q?jpwddxP2r`;qk!Iq z8G=zB$eR%f>mgd0F&);iYaoPa37QV7Dp& z*aY>2E=~xbgSp5{OsnTY)&>C9tDBB3PgNaOs?hD3`r2aGcU!GyJqe%q_cSo_y3QrW zj|<)Dek6f27XJVm;wMl5!}f!};=F&|WkoBo`#0bMwlzj#wxPxeklMaw#S_TQ?t*F?&n#8voFV!0OMPYdJ_0h*z67K z)k1CINdW?2Z)}JjYFM)l8cHo>1jYn_-1X=jq}&i3Oa?s*Jfop047HxlYw6}p&y#Sq zRc~;i-B}=Q1<*le@P`yO9Tb2u6%xBSnrF5-bmGhrJ~d< zhVUFFZl1hq+&UiGKsMSAY;MZAS-`ON)n$XyQ5QkK1YirHf#B+>U`*X;;-YEkUd3f?ZJ|dwa(cC08eLfzVGF`5uwcg{#%3|aaW&s#H zrO>}7vXjJkB;-tnqF8&eT7y%p9{lVml$sn?4w*zveaUAS$H9|@M@f!I-0mXp?J{5= zf8qlJlpeGILLi@O!H!i^mfcHL*%Cb{rZG@?haRHXYE&_G!nI*v@;wt(-|7JCp(76H zyRUg)qUtlaDqd9b8FPp2{h*%mTvDL+&gj_!wKgeS|4g7$i|r1bN|{jQzL`&f9d1$a zP)KH}mQoPKV>CSP#AmePaZ1~hlN9`o6PpV2Q#dn~hF-VxwNDz&5?5~~e#P^W%e4Xu z%lCANRg30bLb;AiGjidMP)w!5MV6AkEt=R`&=>mUAWJctTHZ1z>N^!dlJN}jsN|7; zm@>#y{mDKdOR4Tj3OXoABB$bgvoUYRO*s1F+ffgkx6SjZNh<5|IWv`OfgDEqSmV zsx49TqUt){@fQfIiO#eLF8Q33Q>|w<8X+kaI14sqQMx%BXq*$-1&oD1%m~KK_4itK zF>QF1$(y*HCIQ6TdV=C9WfypW?{&Ay&!rG=mln$p0fy@e5#J^%pBMh=6%05?uowDw zt|j8)a6A*ZS-pPWy=jo6y%{2{sBDYAm4}zPbq>^ZO#m|PdwwwiCD;d`QKdi&B-OjY z3D&9%)33&REg$*-FV(*;#Q|@hsPv?nwJ<0x`)Yj=zQN@+iE)Dsb;she1V}!^qYvJB zB)3P$FPUD*h}r%03Ckw!9iUOaU5QG#YX0cVc-8{6; zOR@l2NyP_qaQG6aSMk7FWN<5|;hoGq=78Q+OY#L^nYgl&vHBGx#}jZ~S6X%G%_()M zPZ#0}`Y#+f?4u1>Y{y@}n~x=$0!#sG1lWs%`6;%t-BNDDf2yJpBH?Zbf#FR2>OblS zs5rX=$D1QS*ai3v=m#MiW$B!9(4>HS^a=tJm&}FvnJOZ^j9D3$U}%HbYkhnDa85;D=?LYs=}$BZ0fnz8D*BY;h#THa|>mOksD(6>>Q*bR->zT_Ew zY0Q_btJ5;=*Y^9+c5Wj;_B128J>Bom-6)|=s--WOi4-iWOW zW)H!%y z(@i72G#Am^d_!>3g3Y?@id0r5EOEfX24P`_jybxZq=48|D@~#oARX4I+k7m5fRq9$ zIYoGebT0p0U<_fTlK?WVF8O$s^$kB^twrOyEXnQDtUOxV|TzSK#<&n;dqo6mJu5pwj6*hkev0eL6;BvxQ0Vr zezF3FZwSc@N#Owtuz?J}05pDG`M?#}E01N`I9; zAeFF2d{62OC^^#`ob=!OWF(!8+IweIX`2Om7vJS)!2?R+CfQu;)SLFYtxhh!dv~@M z-qt%Xw9ugco8iF7xFU96Q-bLdV@GwOvByG)<^~-*@|s{%jd16NfCNgKnDwh5cGr?G zs(@h3Cgj&|AJFtqWD~Ue))L2s0XLtAxs|{btPdjqNDQNcd1%T$8#I4Q87%Qj^a5k{ zCTVVtAce+8_z|Cit4BO(@BP!gH&1H=j-QUY17fGi(s&ZWENnmZD5&6s9YVwu6gr^N zs`seZS|QV3Y0nZs>8u;Qm?}16lvKdLNC5_{6`g`_*uI#Hmn{gs>48wd%OBlnsvas6 zb^zAOOMkMfzop-e;JlP9kral_V1h0cST!Jspfl4)7X zBy%o8x*4tasYICyP6x?BQ1~# zVR4^k3!t3fmej`i-F`~0C6Kj)0Gbzy4_X&oUJm|)S;_6!lIB&Xyl%+~glk1vY$xE# zh#wKoG(jM$HydOHm*BKu6qyfd4a7eP*5$#=!Ocnj=wQOlP~(=Apx1j^*f{{>JLyDQ8(2kj2734+0ebkXPvqaXFzYxxB#CXe)UeFYwiaS3H-H`Q>@p+Nr) zlckxz`7{nI${JyMerIXGnEH{fV()Tkda<|N>+hOlPb6-^JEuY>nZX^^iIK?SiR??Z zyi$?_3e&BRQc3!0P(;S@h>}os`>c^a-8%QkA$+9XzgJDvH#(*pm{OQ-ZnYKO zKgiMu)!bjK3%JxOg{D+cDeo?Y&t6{pONuYE3S^q?Up zxmvUKC>#+}zutj;P2CRJtGPP=#3j)wIC56cOzAMOSr0I813tNd1b&o0M#?OwQfPyb zc(Jyttdy-m$i%cI_w@D&^Xt*sLE6b$4S9k*=ASH)_=WfI5}|4=nzgXl<@cMiEYP~G zLiUW_2uowYrIS`n!!kcT06p8pG4UDr+4|V;W(XEo`X&;2;!}$^sBpFOX2&`VJ9Yn@ zIh9MIg5EP-ez--bLP(&953UsVFG}!w1K^!ui<(x5GxOOvKs}c`VNJ~dpAyIrfqGuk zDbn=&kjb>cO5n0oY>^FJe#^i;;IiG!mn)M1K9!Nsrw}D74miQA+Qj38^ zkEdlVo{CB%Vee{&9pJ>Lo@Scb8#S>VmN2lTnR|FDSdDH*YQYVzsK_RHx3T z^Hq44iPT{j)T+;ypLOb>IptenvDSLNfYCYpvGeHXORNkDW~Ci}B(;#Gv1y}ZLvEMf zxI$tV=lXK65u)nFHPi8#c0+;lAiLM&{h?>mUehoc*9%hy zmS!&K0Wx)dV&Mfe6>A3C2|UaLKLSNfb4q-I6_O`9 z1`jA>5Y(?v9sCBq3UGixa<_zQqXrJ2Pu*iUFa5ryGp5M`wfWnGc->4h@+hQt{HvMf zj|kR&l_un?8A3bQ^CYP2n1hubN=gd0cesrrW#XeALWL%9a<#kuIC_2J2KQWsyCrf- zefZLHWn?Y`uoIw>Z&4CL#)vqaTzPc$?Y^Nr#+?j2!2*>-QQpr)kK^uNS8e>F%yXGd z7y|tm|J~kkuK62ib%=tW?0@}_K92WyZI4M$%0~;;>|YoDqdDWh{_CGD9zkDb)y7GS zpfVqOsV4w1@cAeyc7B}z^l@DI^B+(8C!T2*{jaXVtN!P&|JalN*@yqxhyS?@|3B3a z`KY}lyP$*rT-~Zu>|MQzE&ux0+W!;F;{Vu!+VcB9CtJIJoF@HZg@lj9w%s-V!RiN1 zukUUe+BEL_`KQrir9Z=W{I=?=%-LDXqcWG zv*?2QxRq3O!mf&KjLmX$?-L2LFO%n2=`V?@pFrmc)~Ke0Bic_{r_)cBg_Wl^L-k$t z!LPSo&F{MJsr8dy)h_A!7pqq)f)QhT_aI@-$i?eVk}{Oyde!lX zCD=XXN5eib(;w>UtUmve0`Zo9s+I@e)Cvtn_<#B_Lp$coZiRELd+C@G)b@c`3-JP^#XlFO! zLL;}{h>DULPLQ-SmhjUcJ zNzDtcI>aFN-_9t%edF!~qMILl#kU$ZKob1>R|^_BfA^__9Z|B9(g#On%iOvbm4M^kgxys%W>xYyZ!)82+R z^D$lZ3m5;f)&xkbNhOS9xAwWj0)Kw(^JA)@SM(A3Gr7Ox`-dzA^Q){Dh43b*o^g=2 zE_-Vu5Y)K*HTSPSQMwPt*{qx`ChynowO^)#v3A{;&h*bw6{IZ2B>rgH_Nsaf8TODe ziyy|wrti^8y_|9tY!VR0JlkD{jg@~HMqXe8x#q;@NBsrlS{asj&M=DGSoPu`$7(_T zTH($9E&r#0^;f_H#3EnXNcoAP`)~WLRvwQ2qv>egiZoCzbIsl>q@`zhgTWxr3i@ql zoI^hRlH`9afX`UqBwm-&n(>|QZ428SGFd$B607y|(XT0+?7U_`8p1FxdT`~~Ftj6twg7W58Wk&LZZFPEQw`os^6J&gO0;|#?gXsFJ5 zAoX0I-2tYl4fu_*`sG-=7UhN)tmjjCwYus%!_9wfhu-wch;MEFc;c;hexI;B?WZBa zT+aX14*m1`89mift^0$lQnRdBwaZL(Al7cb%s^mgs+87G#ta2FKcO&|+F6t_7X79# zd}wfGVE2xL{vTuz|ITgufUG{i&jeqD?}KAIw<4+cc5P<>Z*hM)P$Z4$eEH}F#)+$c zFRqBvy5~{ZNnfppL=@}Oth3IN)BDTfEq(iqLk=5mT~3yq&Nj}Qf34n6#=Me<&iVPW zP3u)s>w*8*SLvKx8e6_OZmOUeXfRb}{vZ4IOLIBP?8S|rRcKpd==Zb$;SJ1IY&^>H z5#qqe!I9~jDSiyzt+$eI*VfAj?%OdH<#s0wI+pZ9V|;&Qyh`XIa!+CC@vqfw*=rXv zM^+FKrvqfYbNV_7G&{#y2xp)#cSs@1t_~h4ylNwbu!Znmb?PE}V8+qa z*8OpJG0vo6$^Xu9S0$|a#MZ3nYJVB7FgondEjPT1c^9Szta zd9UKQKk~RkO838w6z}TgeK@R|VEJ)Cg@Ut*Skpuy5sK)2K;Tcj+Ok)GP12F8nILW4 zJI0I2yeuJ{l=%F>e1d`e^YvqXx72M5&v;uo#dh1F+cwnmM^m5M;?W+lsvQVs6PRkZ zQ>;C3->sz`HGQX2Bh#idCV=Er?(w>#z1DCId9VxtoKWHhsyyrKUilwVj-TED(eaad zjvJHu3142LcT4*X^s@WdJlb}=N@IzW;t~=gBKuuVH_hLQSNk3+_iT(#30=P%)Oma5!Q-++C58I?mq32 z<`TA!w(@y#D*E>#lp8b_)Hte3I`2%8(uu`a(e!5`@`we^xl-SkQZBM)!5M8su}bVtpl=Zof@3hT1nXZk#zddeE-nodM{+yNuhoNl+6 zcIuKQviY4Hb<>Q64+Os;#o_J8nP!bf;7&t*K&949QYn zR;gkjj`h*%2FksYEwdxe*Aj@v#=OedBuM3?8?{}#MMbWxp}%-GdHZlI>6 zloj#X%gXRes@%|8wt#0?ZroWshV^<8ESgl~h~CdT+?YM?kY{UCM!BL_B8-gWB9n5? zTYqj{)0Ba9lPAA-%XY4NPQ4NI-N_eWDhg7}>qJ$faNrU$i5$Q3qKT+nOP zUgfamd_(h_`~BX1(qc{5z!y~}nFQu*k4u7S>z-Sn`aF}H`OcH!vT|F4BC$o2m>k~E z&Y1B1x?vpw%p00;O9FOHym>g^ilCw0&R;P`MUMI^B{C1J zNY!Vl6-o^ooX~Gz9scE~UFlp{M3q&ObKI7e?l^DSD;x37>i%`PDU4jK=HRa0H&cv( z>p)nOSRdmLir-{2*Xq_99jat!YwH+xgGs*OyKYym428EB%WsKC4?Rn2{AC_9+c%lO z$1PI&ET6KXvg%(1+lGZ^^2n{e9U9=94}k5wr2MAdk(j383T`w;G0*REgD0PIWQn!Z z^k;GS(P4_EjjNdLs8mIyTYLHI#aJoF^5jwY@)n!-{a3G?9>Ji?T7@i6TfU|vE^y;i zjvMRC!rKoEPxo2L?f8-7GP*;F&=F=xTVX1+aZS&Iz2x^7c9pLxyZH2QVEP%FC>fLH zIN)Q%xknVfbqsBm1j7h<8$((h)9y@nY4sOv3DMTORp;igD16F3%NVhm_uz=s)DCmy zt>!!7#6ETWMP(!IcGpk*19@)mSzmZmqovzuRg;bx=+ZyMGAvAt_)AMuhiv|6%CIwd zPmV1zBVT-4;7eb2b=2}!(JN`>l1pcF8|eW`71mPTpnaz@wX!pbpO2CGr4SZ&_3km0 zFT6claXnUtpB1Q>*YdwOQ~&!dRy34fuWUdy-XW)0F2bg~X}lmAXX&s&UxcD_&OWt| zA!cro*cyHRPf59RKvzZ^Bl7(VTltNh2lCUpfRf&$)}nMLHtgcc&XGQXowM2rCegZM zKfDxw?%Y}T*OfVbCF$j>sLEv)$q=98QSR1h`q|(8g~3yMA6uM`VL~wV1T9Rt9A_-~ z7%ojKtodcio$}jGTXY*-Fb>~URl4GSm1c8hHCZTDsbCqp#Ow z^xEvwO4-#*HfTI}EFcfCm;SEtWYaWxny9@{XQoyhl0cIw+_vx^1mPrvMih&Re7Gfou0*y^YN2 zjw8ic#`N6tVrn;U@WcX-%ukN+N7G6CLZD>lE|1;i_U~1wUHpf1+@aS+6AOKh$kSV> zSTOjxkn!pQ&BdL1c`np_`x|#!5>uPM+e+fGy6SB7^$<@S8n~5M4~Y;@uPw%H_ZgP2 z?Y8VqT2KQwmUkeD-X%EsPGe0|p5G|e#C%oqos2NZ?ntqyaxNiMH*pC`+8SPY3(%W z(wgt#l^Vz|o(r6=oBlXIRkvS#DtEck_Y#7!GRIGyl#HcbmL=fR<;xrZ((_qqHe0cXo%aE=|>$Gbg-f zm+}TkQSKVt)y=c-icoLfH`^^Eq(>}8omF^x94T{4U4?5(|J>4V@!Ay|Rdrretj@`} zje4U;yRT~&vv{>fUI5F18K-9 z&C9=$I0Cg|VbOcREo$37!$MhV*fm=zs})W4hOa_tlh;jTW^VU5+Y!Lp|KBaZe+h}V z$K!pe3#&8z;+cQM+9|(|u)TYMRzDi^dEN7S-oSZpoXY|^3QLK+$Lfv$8wp+6WpPvN zzr=I3 zE2y10lnOKkYbAlOlJo(g|CSYt|Nr$o=KtmMex$*>{@e4OT=-v75{G|8Uw;B-Q2s|Q z;Z+I#nKD7XKASP8pQ_O1}cXs4r4NzNv< z+mxhoieXeLCW zC{lon}4)XmQ&zuN;RDg^d)$*%cM{=2LN@l5`P?0=o4 z&nV1%v0H?`)p0y3pLw-S8{&OjE`}IoU6$G>234qL;mA$ z`~`3>?o=&+_}n^}z(1?wyuK;qPP4lk(~aA@7*M8%ZpIN!sqRu~+Q>}J4x>cOLn#E4 zeSIDs`Et6kK}n-q6lESO0D19lDf-!kQ}wn@(|i?@+HCGdb7KY%J$a;3w;n*{oR^pS zu^~Yl)-|6kh@fal;k0v7%tdtP8+Q){kk}_>p^IdsNGvroVuYU8TegJ<@{NE;)^4bvlj@_BF%5D@BPb(wim{6lBoP0G?e zaXzLY_5-^|*Jb3V0rT1HPHH{y281!ZiD6n5!r{Pvz`B^&3<2M#yq_(Ss7t}-oogl2 zPk^v;rQ3XPj`|A_ULpPCTE%aK^6`?mDwMwT9ed*(0C(`bn4XT0qU1}l*Pkd9dFe}v zabU|YR}k1RxTle<7$WHTM)Fa?!}NdP8x_d3&a z{urcu&DVngFyII3wJ8*6XUa2aqris#$LHQ6qmhqIzd%d>X@qgVf=yYnZJ%>TDXeOt zcN8qn^c#M+9Dv;u&q}|DyC>Vn!w}mgU>tBL$?`f`7YJu7Gqx3+soL)_^L5}f@52Bj zRQ2)7*RTCy1<21%Z1{YmTdFP;fePh!s(L4=a2iy0blie$-nTlx-D$D_=`Y)*BI3VY zMK@pv2=rKBCCzi^Y_VKOb@l0$}P=c(rBtnb*tfF`d;BkYMU!DX9FP zW*#R5cFZ>e1)8~txcOh2xjkH%RCzr%#`%Vs^!@Z{-YI-=tKDq^0a}uErm~~BS({AG z_}aEMx9UnAbTgq93={H>f{xJB1RkyT`@e z^V$?uX03IXO1a%M=WJ_9R!(AiC5d;PvqtpiIGw|r zDv+IGaD&{{haXVgyXR8{#QR&Ic!9Xge$H5s8K-RFqTQxBH6{^)-n7}=( z?tiBIv9(tA5-{mbtLG>Ep(iluOUT6wC;js3?$-<^ec1)%g(n|+e#0k!M#viaFl8Tl z%C`Z{pBM6NgRRCLEhq0;2e+sJz^la(A>X4#l36h`q#9zLS0C0pQ_$^OxvNE?m^ocX z*l+hSYR=tg>RvF-0!CedSsK>ETh|f*1Cv-5Tzzph?%WQaswnd7J)#N+JM6L|{URf< zIjXle&#&6bw{Ow(5g4lkxLgF+SP;r~-1reQ8~~eDoWk7fIY|ic6dHt9V)9xHUvVyP z{#cfC6qD8OSIJO5rttO9)>Ub_&Z%nrP^YlE&b}TD0$r|cNi9%#-d_N~z~oPZAO%#o zJ`hVAagHbXao#df(*$yhM@5#8J=Pf(aS%WAQ8k#mOAmS(2HaPl@7Hzt+{afdUyQU_ z&(6vkqM~|kx=?1HCnALj(92N)nE@9fO`C6mbllsS1{&IV{0|CBP|P~)?<=>zRN@|7 zukEt*VE@k6BRqA3>sAwvSGkTYJKZSabb+x=`(@lpkuj>;w0Z9b!fJh-zb9%Y>vV-b zhV+R6r`+*p6)dL|y>c{6d1#t(Eo3-mAVBZ@)2_)rNv;psQ&@2CA@40aGQVs)1JcRz zF_OY67x(Cb{Z3xTJ4iDZKb%JhWb5tOYP3^l@zP{=&g)RQ)+{XhtGsNx4PV2}BGIgm zZ18|ytML;vkY4`8U_eRzM9YCZWk`Vn;o&LgMMZys(mQtIA^shgaP~EEYTD@F-*+g9 zP%x_rL%PUE?^D*HWw*tZY@o~%c0i$E*`2=nmZflBB>0Wu&o+rQD$92lK+ioEhg0^W zq0s}=2DK|I3d_ASV+N*!!ze<%HIrkggi>2EThILd)!(db&%k5|Is-Xi#e_3dP3puxbn@= zV};@{cct`1r2KT?Cg8j9i}Ha4NV1C=0H14}@uhCAktD#Z4h8aFoea-XzU6)1u#!nOJ`JQp(K{iD~Xiq4^Hdw3^ z+!Lbz<|gK${|nfbY=4T%sf&yj{5lvWG`94bVEMSN?=M*T)CmTxI_h^>1CpmmlYFzJ zl+ztwUN`cbD~s^xoJ9*{&oGiedV!?e*+epBEg`UA_~o8r44%cRA8P3O5vGIBQt{WR zqgPFb4jYbFpET>|*S7Gs)I9M(LHN`cV60%A!)d#ZCv3+Q7MXHIiZ=+$Dahdxq{=$8 zYpc=gqUwAy%JH==^ttiS0(z}~zPfXof1WcMPnxM;MbE+tTSK$E_}V-Zj5-~bw>NQ4 zBxn&Zo?<%#jUIEBJvQ^Cf0-Pt(6%9q`Dr25d_!!6%UW^>dlPY3(-Hv>KiXegW4Hp` z6L3<&SaZm1&8o09i1>$(%XT<~>`x+`p;{1se7ap|Wwx3LeRzGozS5`Vx~MeTt4iHf zs>{K?3BAQYK>z}|qw&dSZk(6~rYpiuMZ;)y5QlTOA)Ov7Y4aAO?o6qW#9V#8}V4W0^ z0~u59h$KJS%(>Mx-)cpNmZ?5^9oJ#cil~rF=9GNikKp#n@zS@K5gn(vtL>>2?;X7C zmhnDS$|kz%yQi_s!-neY-b#%H4e!Ok<8-_5nhFhk0#mb}VwlY_n2Kb?afspT(0Q=u zA>GA4pJPO9KXn~5$=L6nKxVQqgp0Mji{h&3aYx`I23{k`6C${B$tNAn^ZuWxbqbYz zjDD6it-j@LMf$JRN>6D`;g#dY5~RQ98cG_5>8SNFZSeAnGw_U&V8fNk{hXIayX#uU zx18*!*}?6qJzlmDR__~!ckB^)2b!-i8(vM>{K(HNr6Lg_e!Q|zeTos_L(0Qj9BQ-k zai?UtiC;2K6!81&^93(X;dSnheeDB{lC+2`5qYkCD`P_x`SF$j{pbFIDFc&ssA$)tJ$oo6-F@#zQ}soMJ7z)+ANqCnZxqKV3w`!zp^L0-SK(B&3<<2 zsDXAs*!m~AA5F|Y@+Q~XTl;^S9H`%=PPlOIs3B!MX!p66<~bCp!~9WujH*2(O89gG z?x=#AHhZ5i1Pg!MLwU#4aUP}Kbot};qKa61{7u&jca|00h^;-bMnuw-;_IUqT8I5=~tNSqwKHVYxr?LT0%7I3Bup1;szt z665e1?@+?i88_WgK&gP9U|ZlgFIy&sq2OXHyVZES{JN#Wi4fB<`I7HgQefR`HjumP zFo3|UGktYKz!`t;HFAXxD(~94Jaf;AIrr^0_`)m;&YS7Rzo&W3&&C4~(59_qffiuNjr=RdJ$;41&wf+|01D$)OGNWK` z*(cZ=aM{3iaGe%h%X3R^@R&gB=C!KQr%>rH^B6%@5rpw=C_xtf0Vtv6=6tNsnlWna%sWxyBk0n9JvEPLz8 z__i>;T2tm;GPk)lYd!O)Lv~78&VEHevGawiu0xMbXIv-pwlK&yL9eSn<}4Zj@c%B? zYs`HSnD#VVoj)GVRton=M4e*zCb>_M&%Ivhjsx*G{9^7d45VNaFGgunpu*{JghiTk z8HT-+{4fr);FYhmS9~DdnnHS5wIcTtnoMN7L|yLc5YjV76H}_dXRwsg!YR0 zAsM*ceZ#H1KxB~*slG*U+S{j+Ok*+bMq&b{cOK<3?UF|8Z+7lojqcBz-v?`yCaJp0 z{Fy)zx$q-NczQE|P=F}}YB(XhsFvung_|f$9wVt1m+rKWfvN zT{wl(c+J^;#n3bV$tuEfMgX)^mY&h zW%;PjQnvmuIhhg34Hwr_U!b{pPE#gh_3QB472XG4a=fhM3|gGGf5%(AuTX9-z4U3t zs0nsnrOz-D+QPQGPwdG8EC6==bY3o(-KKDqd{ctFzUHP^3n6q^EZ+Np@uR%~4yXkk znQS?1x6#mkd?%&5Uheg6w27IZL&}=mt&p%(^twev|RTDAY`7f z#8zc4q3;W1_k)s0w03Hyni}KkXTh2-=>B5|07HAoyl47~L3V1~j9}^n=eq0~u}1t} z^#IySD=c`QKw%uE*-y`1na;5gmo^*oJM0}=J}q*1qtM`BSJR~&za;V~nwK6uywrFNYM79ABo|@L&)HI7&SdE>ZTn9 zSch6nULWD7In%<}*3dfzMZh`$!9J(df48Q!|0H8|Zr*qDfxjU0zZXLaVD#K9P^jLu zqKpZ~)$mxW?SpS8U}-vx&&FONI2e*4SQxf&Jf8pd@BcU2-gf&=%BALBJGf3C!rTkm`Lb_tjZQ`!EUgi~ zz4c~V$nl+L2GIg=*UM zxKk$#Hw9YUoi_0-9FJ5jd=Ng1g7Z%MZ4WPc=L|1`VU>zR9r!_}^ic^8mmX1c>~R#O~F+FGXugTkOGG%!H!} zWk8v8C{=dn(ef1+Akw7w_VN1A#L9~k^THlc1%$1ohb<7cpv?^QCbq!9jyuc%2tYDU z(*RN>g}wl$snts8F1(|#OTJupnv=7wqv^P6|I>L4hy-A1Ez|0-FI?x}$oq$pBX8j| z0>MbT=Be#~dS5O(ue)49v-0{u&uaskH`l5bgtS%rcrvcwle1ISDo;1TivZ54VgCYSv0I$=WZdzFz~K3mCs&3nlyP_er6z z6i7W1ZorPO2bL;>r>Q9gpRJ#BJ=C78QX^r_uxKvSQV-pu! zwad3X*>9|y*Llx7Kp%JNIo_`+v#f0O(UZv;^HA>%ov-m>09L;TV#r>J1S*&XN5M!3 z{Q1U%og;0k=cs9*yDy&mHKyluzD|iRB?i-na^<9>>wmQn(*4|b;a#&Z4}3t+rp_0! zRlzMfW#Qv98^SHeYtIocO7F3IRxw_G^}`I+CJ5FFd>wcyL7(yI*|UCGxHazSW0isI3B4qM?MHB+5H;;^}6JIV~L+c z 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 F{|joLBgX&$ literal 0 HcmV?d00001 diff --git a/src/app/ui/GlowMeters.cpp b/src/app/ui/GlowMeters.cpp index 5589968..2206b74 100644 --- a/src/app/ui/GlowMeters.cpp +++ b/src/app/ui/GlowMeters.cpp @@ -211,8 +211,11 @@ void GlowMeters::paint(juce::Graphics& g) { auto area = getLocalBounds().toFloat().reduced(metrics::paddingSmall); // Labels at bottom - float labelHeight = 68.0f; - auto meterArea = area.withTrimmedBottom(labelHeight); + // 16 px for the LUFS bar strip, then the four readouts and the bar caption. + float labelHeight = 84.0f; + // The top strip holds the L/R captions drawn above the bars. + const float channelCaptionHeight = 14.0f; + auto meterArea = area.withTrimmedBottom(labelHeight).withTrimmedTop(channelCaptionHeight); // Two meter bars side by side float meterWidth = std::min(32.0f, meterArea.getWidth() * 0.35f); @@ -231,8 +234,10 @@ void GlowMeters::paint(juce::Graphics& g) { // L/R labels g.setColour(colour(colours::textMuted)); g.setFont(typography::caption()); - g.drawText("L", leftBounds.withHeight(14.0f).translated(0.0f, -14.0f), juce::Justification::centred); - g.drawText("R", rightBounds.withHeight(14.0f).translated(0.0f, -14.0f), juce::Justification::centred); + g.drawText("L", leftBounds.withHeight(channelCaptionHeight).translated(0.0f, -channelCaptionHeight), + juce::Justification::centred); + g.drawText("R", rightBounds.withHeight(channelCaptionHeight).translated(0.0f, -channelCaptionHeight), + juce::Justification::centred); // LUFS bar below meters auto lufsBarArea = juce::Rectangle(area.getX(), meterArea.getBottom() + 6.0f, @@ -242,7 +247,8 @@ void GlowMeters::paint(juce::Graphics& g) { void GlowMeters::resized() { auto area = getLocalBounds().reduced(static_cast(metrics::paddingSmall)); - auto labelArea = area.removeFromBottom(68); + auto labelArea = area.removeFromBottom(84); + labelArea.removeFromTop(16); // the LUFS bar is painted here momentaryLabel_.setBounds(labelArea.removeFromTop(14)); shortTermLabel_.setBounds(labelArea.removeFromTop(14)); diff --git a/src/app/ui/HeroWaveform.cpp b/src/app/ui/HeroWaveform.cpp index 8cb2dc7..a966704 100644 --- a/src/app/ui/HeroWaveform.cpp +++ b/src/app/ui/HeroWaveform.cpp @@ -297,7 +297,8 @@ void HeroWaveform::drawZoomControls(juce::Graphics& g) { }; drawBtn(0, "+", zoomInHover_); - drawBtn(1, "\u2212", zoomOutHover_); + // UTF-8 bytes, not "\u2212": MSVC turns that escape into "?" in a narrow string. + drawBtn(1, juce::String(juce::CharPointer_UTF8("\xe2\x88\x92")), zoomOutHover_); drawBtn(2, "R", zoomResetHover_); // Zoom level text