From 7b9360a9dc77e53d14fefce6143f5cf3dac44dd1 Mon Sep 17 00:00:00 2001 From: Kimmo Fonsell Date: Fri, 2 Oct 2026 11:39:04 +0300 Subject: [PATCH 01/25] Plan the Windows port, and build and test it in CI docs/windows.md sets out what already ports, what has to change, and the decisions that are not technical. The new Windows job is its first step: configure, build and run ctest with MSVC and Ninja, with no validation, packaging or release, to find out which of the expected problems are real. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 29 +++++++ docs/roadmap.md | 6 ++ docs/windows.md | 152 ++++++++++++++++++++++++++++++++++++ 3 files changed, 187 insertions(+) create mode 100644 docs/windows.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f13753e..b702175 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -15,6 +15,35 @@ concurrency: cancel-in-progress: true jobs: + # Build and test only: no validation, packaging or release yet. This job is here to find out + # what the port actually needs; docs/windows.md has the plan it is the first step of. + windows: + name: Windows (Release) + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 1 + + # Puts MSVC on the PATH, so Ninja can be used the same way as on macOS rather than the + # multi-config Visual Studio generator. + - uses: ilammy/msvc-dev-cmd@v1 + with: + arch: x64 + + # AMPSIM_COPY_PLUGIN off: JUCE's default VST3 destination is under Program Files, and a + # failed copy would end the build before it says anything about the code. + - name: Configure + run: cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DAMPSIM_COPY_PLUGIN=OFF + + - name: Build + run: cmake --build build + + - name: Test + run: ctest --test-dir build --output-on-failure + macos: name: macOS (Release) # macos-latest rather than a pinned version: GitHub retires old runner images, and a pinned diff --git a/docs/roadmap.md b/docs/roadmap.md index cc3cb32..e98106a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -10,6 +10,12 @@ agreed, which is why it is not here. ## Still to settle +### Windows builds + +The standalone and the VST3 for Windows. [`windows.md`](windows.md) has the plan: what already +ports, what has to change before it builds, and the decisions about fonts, ASIO, signing and the +installer that are not technical. + ### Whether to tame a downshifted tone in code A heavily downshifted distorted tone goes muddy and loses its articulation. Measured, by putting a diff --git a/docs/windows.md b/docs/windows.md new file mode 100644 index 0000000..18111ca --- /dev/null +++ b/docs/windows.md @@ -0,0 +1,152 @@ +# Windows: the standalone and the VST3 + +A plan, not a record of work done. Nothing here has been built or compiled on Windows yet. The +first step below is the one that turns the guesses into facts. + +The scope is the two formats Windows can have — the standalone and the VST3. The AU is Apple-only. +JUCE drops a format the platform does not support from `FORMATS`, so the `juce_add_plugin` call can +stay as it is. Making that explicit with a platform check would be more honest for whoever reads +it. + +## What already ports without change + +A survey of the source for anything tied to macOS found less than expected: + +- **The DSP is plain C++17 on `juce::dsp`.** No intrinsics, no POSIX calls, no `M_PI`, no + Objective-C. The chain, the tuner, the transpose and the metronome have nothing to port. +- **NAM Core supports Windows.** Its own `CMakeLists.txt` has a Windows branch, and NAM's plugin + ships on Windows. Eigen builds under MSVC. +- **`$` works on MSVC.** CMake 3.24 maps it to `/WHOLEARCHIVE`, + so NAM's file-scope architecture registration keeps working. +- **The data directories are already guarded.** `BundledAssets.cpp` and `PresetManager.cpp` only + append `Application Support` under `JUCE_MAC`. Elsewhere, `userApplicationDataDirectory` is + `%APPDATA%`, so presets and the bundled model land in `%APPDATA%\AmpSim\`. +- **The panel uses no image assets.** The icon PNGs become an `.ico` through JUCE. +- **MIDI learn in the standalone** goes through `StandalonePluginHolder`, which works the same way + on Windows. + +## What has to change before it builds or works properly + +In the order the first build is likely to find them: + +1. **Model paths with non-ASCII characters will fail to open.** `ModelLoader.cpp:78` builds a + `std::filesystem::path` from `getFullPathName().toStdString()`. That string is UTF-8, but on + Windows a narrow string passed to `path` is read in the ANSI code page. A file under + `C:\Users\Jörg\` or in a folder named `Äänet` would report "file not found". macOS hides this + because there narrow strings *are* UTF-8. The fix: build the path from + `toWideCharPointer()` under `_WIN32`. It should get a test that loads a model from a directory + with a non-ASCII name, which then also guards macOS. +2. **Compiler flags NAM's own build would have set.** We build `nam_core` ourselves rather than + through NAM's CMake, so `NOMINMAX` and `WIN32_LEAN_AND_MEAN` (which its Windows branch adds) are + missing. Eigen-heavy translation units may also need `/bigobj`. Two sources (`PluginEditor.cpp`, + `ModelResampler.h`) contain non-ASCII characters in comments. MSVC warns about those under a + non-UTF-8 code page, so add `/utf-8`. All of these go behind `if(MSVC)`. +3. **The plugin copy after a Release build would fail.** JUCE's default VST3 destination on Windows + is `C:\Program Files\Common Files\VST3`, which needs administrator rights. There are two options: + set `VST3_COPY_DIR` to the per-user location `%LOCALAPPDATA%\Programs\Common\VST3` (which VST3 + hosts scan), or keep `AMPSIM_COPY_PLUGIN` off on Windows and install only through the + installer. The Release-only reason for copying (keeping a Debug build out of the host) still + applies. +4. **The fonts are not there.** `AmpLookAndFeel::font` asks for Avenir Next and `stencil` for + Futura. Both ship with macOS, and neither is on Windows, so JUCE falls back to the default sans + face. Every page lays out in fixed logical points, and different text metrics are where clipped + captions and labels in the wrong place come from. This needs a decision; see below. +5. **The microphone privacy switch.** Windows 10 and 11 have *Settings → Privacy → Microphone → + Let desktop apps access your microphone*. Unlike macOS, it needs nothing in the binary. But when + it is off, the result is the same: the device appears and silence arrives. That belongs in the + guide. There is nothing to fix in code. + +Smaller things, none of which block a first build: + +- `tools/HostCheck.cpp` hard-codes the `~/Library/Audio/Plug-Ins` paths and hosts the AU. It is a + developer tool and can stay macOS-only, or take its paths from the platform. +- A saved session stores absolute model and IR paths. A session made on a Mac and opened on + Windows names files that do not exist there, including the bundled ones. The same thing happens + between two Macs with different user names, so this is not Windows-specific. It becomes more + visible once a project can move between systems. +- The CI release notes say "for Macs with Apple silicon". `packaging/README.md`, the guide's + install section and the `CLAUDE.md` line that lists Windows as deliberately out of scope all need + updating once this is agreed. + +## Decisions that are not technical + +Each is a choice between options with real costs on both sides. They are set out here to be +decided, not pre-decided. + +### Fonts + +| Option | For | Against | +| --- | --- | --- | +| Let Windows fall back to its default face | No work, no licensing question | Segoe UI is the "system font every other plugin defaults to" that `AmpLookAndFeel.h` says it chose Avenir to avoid. Metrics differ, so every caption needs checking at every scale | +| Name a Windows face per platform (e.g. Segoe UI Variable, Bahnschrift) | Ships with Windows 10/11, nothing to embed | Two typographic identities. The panel has to be rendered and checked on both | +| Embed open-licensed fonts on every platform (e.g. an OFL geometric face for the stencil, a humanist sans for the interface) | One look everywhere; the snapshot harness checks it once | Changes the Mac panel too, which was settled in the rework. Adds BinaryData. The OFL is permissive, but each font's licence still needs reading | + +Avenir Next and Futura are commercial Linotype faces. Embedding them is not an option. + +### Low-latency audio in the standalone + +A JUCE standalone on Windows offers Windows Audio (WASAPI shared), Windows Audio exclusive mode, +low-latency mode, and DirectSound by default. ASIO is what most guitar interfaces' own drivers +provide, and it is what a guitarist on Windows will look for. + +| Option | For | Against | +| --- | --- | --- | +| WASAPI only | No extra SDK. Exclusive mode gets close to ASIO latency on many interfaces | Many interface vendors' best path is their ASIO driver. Exclusive mode takes the device away from other apps | +| Also enable ASIO (`JUCE_ASIO=1` plus the Steinberg ASIO SDK) | The driver guitarists expect. Lowest latency on most USB interfaces | The SDK is a separate download, kept out of the repo or added as a pinned dependency. **Its licence terms need checking before it is added**, under the same rule as any other dependency | + +This only affects the standalone. The VST3 uses whatever driver its host does. + +### Signing and the first-launch warning + +An unsigned Windows installer or executable gets SmartScreen's "Windows protected your PC". It +can be dismissed with **More info → Run anyway**, much like Gatekeeper's *Open Anyway*. Windows +has no ad-hoc signature to fall back on, so the choice is between signing and not signing. + +| Option | For | Against | +| --- | --- | --- | +| Unsigned, with the bypass documented | Free; matches the current macOS position | Every user sees the warning. Some managed machines block unsigned installers outright | +| Microsoft's Trusted Signing (Azure Artifact Signing) | Low monthly cost; integrates with CI | Eligibility for individuals depends on country and identity verification; check before planning around it | +| A conventional OV or EV code-signing certificate | Works anywhere | Annual cost. Since 2023 these come on hardware tokens or cloud HSMs, which complicates signing in CI | + +### Installer format + +| Option | For | Against | +| --- | --- | --- | +| A `.zip` with the `.vst3` bundle and the `.exe` | Trivial to produce | The user has to know where VST3s go | +| Inno Setup | Free, scriptable, and on GitHub's Windows runner images. Can offer per-user or all-users installs | One more script to maintain beside `package.sh` | +| WiX / MSI | What enterprise deployment expects | The most work, for an audience this project does not obviously have | + +### Architecture + +x64 is the default target and covers nearly all Windows audio machines. Windows on ARM (Snapdragon +laptops) can run x64 plugins only inside an x64 host, and native ARM64 hosts need ARM64 plugins. +Adding ARM64 later is a second CI matrix entry. Eigen's vectorisation on x64 defaults to SSE2; +`/arch:AVX2` would be faster, but it would refuse to load on older CPUs without a runtime dispatch. +Whether that matters depends on the benchmark below. + +## Proposed order of work + +1. **A Windows CI job that only configures, builds and runs `ctest`.** It goes on a branch, using + `windows-latest` with MSVC — either the Visual Studio generator, or Ninja after a + developer-prompt action. It does not package or publish. This is the cheapest way to find out + which of the items above actually bite. It also needs no Windows machine, which matters because + this repository is developed on Apple silicon. +2. **Fix what that build finds**, starting with the model-path encoding and the MSVC flags. Each + gets a test where it can have one. +3. **Validate the VST3 with pluginval in that job.** pluginval publishes a Windows binary; it is + the same check the macOS job already runs. +4. **Run the benchmark (`ampsim_bench`) on the Windows runner.** It is the first real number for + NAM on x64 under MSVC. A CI runner is noisy, so treat the figure as an order of magnitude, not a + specification. +5. **Settle the fonts.** Render the editor with the snapshot harness on Windows and look at it, as + `CLAUDE.md` asks for any panel change. +6. **Try it by hand.** A Windows 11 ARM virtual machine on the Mac (Parallels, VMware Fusion or + UTM) runs the x64 builds under emulation. That is enough to check the panel, presets, MIDI learn + and that a VST3 host such as REAPER loads the plugin. It says nothing about latency or CPU, which + need a real Windows PC and an audio interface. +7. **Packaging and release.** Pick the installer format and the signing position, add a Windows + artefact to the tagged release, and update the guide, the packaging README, the changelog and + `CLAUDE.md`. + +Steps 1 to 4 are mechanical and can be done without a decision. Step 5 onward waits on the choices +above. From 9316f945abf13cd9b79fc1c47ec4c52a810af4f5 Mon Sep 17 00:00:00 2001 From: Kimmo Fonsell Date: Fri, 2 Oct 2026 11:57:33 +0300 Subject: [PATCH 02/25] Size nothing in the resampler until the host settings are known A model prepared before the device was open reached prepare() with a host rate and block size of zero. The rate ratio was then infinite, its product with the zero block NaN, and NaN cast to int undefined: 0 on Apple silicon, INT_MIN on x64, where the Windows build threw "vector too long". With no host settings the resampler is now passthrough with nothing sized; AmpModel already refuses such a model and re-prepares it once the settings arrive. Co-Authored-By: Claude Opus 5.5 --- docs/windows.md | 12 ++++++++++++ src/dsp/ModelResampler.h | 11 +++++++++-- tests/ResamplerTests.cpp | 13 +++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/docs/windows.md b/docs/windows.md index 18111ca..8686f13 100644 --- a/docs/windows.md +++ b/docs/windows.md @@ -8,6 +8,18 @@ JUCE drops a format the platform does not support from `FORMATS`, so the `juce_a stay as it is. Making that explicit with a platform check would be more honest for whoever reads it. +## What the first CI build found + +MSVC 19.51 on `windows-latest` built the standalone, the VST3 and the tests with no source +changes and none of the flags below. The only warnings were C4458, where a local shadows a JUCE +member (`playHead`, `text`), and one int-to-float conversion. 122 of 123 tests passed. + +The failure was a real bug on every platform. `ModelResampler::prepare` divided by a host rate of +zero when a model was prepared before the device was open, and cast the resulting NaN to `int`. +That is undefined behaviour. Apple silicon happens to give 0, so it never showed. x64 gives +`INT_MIN`, which became a "vector too long" exception. This is fixed, and the fix has a resampler +test that fails on macOS too. + ## What already ports without change A survey of the source for anything tied to macOS found less than expected: diff --git a/src/dsp/ModelResampler.h b/src/dsp/ModelResampler.h index cf8ada9..0c6b326 100644 --- a/src/dsp/ModelResampler.h +++ b/src/dsp/ModelResampler.h @@ -44,11 +44,18 @@ class ModelResampler modelRate = modelSampleRate; maxHostBlock = maxHostBlockSize; - passthrough = (modelRate <= 0.0) || juce::approximatelyEqual (hostRate, modelRate); + // No host settings yet is a real case: the loader can prepare a model before the device + // is open. There is nothing to size for then, and the ratio below would be rate / 0 — + // whose product with a zero block is NaN, and NaN cast to int is undefined. Apple silicon + // happens to give 0; x64 gives INT_MIN, which became a vector of 2^64 samples. A model + // prepared like this is refused by AmpModel and re-prepared once the settings are known. + const auto hostKnown = hostRate > 0.0 && maxHostBlock > 0; + + passthrough = ! hostKnown || (modelRate <= 0.0) || juce::approximatelyEqual (hostRate, modelRate); if (passthrough) { - maxModelBlock = maxHostBlockSize; + maxModelBlock = juce::jmax (0, maxHostBlockSize); reset(); return; } diff --git a/tests/ResamplerTests.cpp b/tests/ResamplerTests.cpp index 254718e..53931b1 100644 --- a/tests/ResamplerTests.cpp +++ b/tests/ResamplerTests.cpp @@ -62,6 +62,19 @@ TEST_CASE ("The resampler is bypassed when the host already runs at the model's REQUIRE (resampler.getLatencyInHostSamples() == 0); } +TEST_CASE ("A resampler prepared before the host settings are known sizes nothing", "[resampler]") +{ + // The loader can prepare a model before the device is open. Dividing by that zero rate made + // the block size NaN cast to int: 0 on Apple silicon, INT_MIN on x64, and on Windows a + // "vector too long" exception out of prepare(). Passthrough is the state with nothing sized. + ModelResampler resampler; + resampler.prepare (0.0, 48000.0, 0); + + REQUIRE (resampler.isPassthrough()); + REQUIRE (resampler.getMaxModelBlockSize() == 0); + REQUIRE (resampler.getLatencyInHostSamples() == 0); +} + TEST_CASE ("The resampler reconstructs the signal it was given", "[resampler]") { const auto hostRate = GENERATE (44100.0, 88200.0, 96000.0); From a9b39a0639fcd472c2f48a97076857b2b2fba6e6 Mon Sep 17 00:00:00 2001 From: Kimmo Fonsell Date: Fri, 2 Oct 2026 12:13:09 +0300 Subject: [PATCH 03/25] Validate the Windows VST3 with pluginval, and keep the build Same validator and strictness as the macOS job. The standalone and the VST3 are uploaded as a run artefact for trying by hand; not an installer. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 24 ++++++++++++++++++++++-- docs/windows.md | 5 ++++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b702175..a7c567b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -15,8 +15,8 @@ concurrency: cancel-in-progress: true jobs: - # Build and test only: no validation, packaging or release yet. This job is here to find out - # what the port actually needs; docs/windows.md has the plan it is the first step of. + # Build, test and validate; no installer or release yet. docs/windows.md has the plan this job + # is the first steps of. windows: name: Windows (Release) runs-on: windows-latest @@ -44,6 +44,26 @@ jobs: - name: Test run: ctest --test-dir build --output-on-failure + # The same validator and strictness as the macOS job: threading and state bugs a DAW hides. + - name: Install pluginval + run: | + Invoke-WebRequest -Uri https://github.com/Tracktion/pluginval/releases/download/v1.0.4/pluginval_Windows.zip -OutFile pluginval.zip + Expand-Archive pluginval.zip -DestinationPath pluginval + + - name: Validate the VST3 + run: | + .\pluginval\pluginval.exe --strictness-level 10 --validate build\AmpSim_artefacts\Release\VST3\AmpSim.vst3 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + # Not an installer: the raw build, for trying by hand until packaging is decided. + - uses: actions/upload-artifact@v4 + with: + name: AmpSim-Windows + path: | + build/AmpSim_artefacts/Release/Standalone/AmpSim.exe + build/AmpSim_artefacts/Release/VST3/AmpSim.vst3 + if-no-files-found: error + macos: name: macOS (Release) # macos-latest rather than a pinned version: GitHub retires old runner images, and a pinned diff --git a/docs/windows.md b/docs/windows.md index 8686f13..0d5123a 100644 --- a/docs/windows.md +++ b/docs/windows.md @@ -18,7 +18,10 @@ The failure was a real bug on every platform. `ModelResampler::prepare` divided zero when a model was prepared before the device was open, and cast the resulting NaN to `int`. That is undefined behaviour. Apple silicon happens to give 0, so it never showed. x64 gives `INT_MIN`, which became a "vector too long" exception. This is fixed, and the fix has a resampler -test that fails on macOS too. +test that fails on macOS too. With the fix in, all 124 pass on Windows. + +A clean build takes about twelve minutes on the runner. Six of those are spent linking the VST3 +and the standalone, which is link-time code generation, not compiling. ## What already ports without change From 6fa2bbff99878a084108d953c55a8f66ffbb1a76 Mon Sep 17 00:00:00 2001 From: Kimmo Fonsell Date: Fri, 2 Oct 2026 13:57:07 +0300 Subject: [PATCH 04/25] Run the Windows pluginval step under bash so it is waited for pluginval.exe is a GUI-subsystem program and PowerShell does not wait for those: the step returned five seconds in, during the editor tests, and passed without a result. bash waits and propagates the exit code. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 8 ++++++-- docs/windows.md | 4 ++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a7c567b..ead9131 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -50,10 +50,14 @@ jobs: Invoke-WebRequest -Uri https://github.com/Tracktion/pluginval/releases/download/v1.0.4/pluginval_Windows.zip -OutFile pluginval.zip Expand-Archive pluginval.zip -DestinationPath pluginval + # bash, not PowerShell: pluginval.exe is a GUI-subsystem program, and PowerShell does not + # wait for those. The first run returned after five seconds, mid-way through the editor + # tests, with a green tick that meant nothing. bash waits, and takes its exit code. - name: Validate the VST3 + shell: bash run: | - .\pluginval\pluginval.exe --strictness-level 10 --validate build\AmpSim_artefacts\Release\VST3\AmpSim.vst3 - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + ./pluginval/pluginval.exe --strictness-level 10 \ + --validate build/AmpSim_artefacts/Release/VST3/AmpSim.vst3 # Not an installer: the raw build, for trying by hand until packaging is decided. - uses: actions/upload-artifact@v4 diff --git a/docs/windows.md b/docs/windows.md index 0d5123a..5c9c7a2 100644 --- a/docs/windows.md +++ b/docs/windows.md @@ -20,6 +20,10 @@ That is undefined behaviour. Apple silicon happens to give 0, so it never showed `INT_MIN`, which became a "vector too long" exception. This is fixed, and the fix has a resampler test that fails on macOS too. With the fix in, all 124 pass on Windows. +The first pluginval run on Windows only looked like a pass. PowerShell does not wait for a +GUI-subsystem program, and `pluginval.exe` is one, so the step returned while pluginval was still +in the editor tests and reported success without a result. The step now runs under bash. + A clean build takes about twelve minutes on the runner. Six of those are spent linking the VST3 and the standalone, which is link-time code generation, not compiling. From e9fe1762fdc9cfec06a1c0865a176ca9daf5889a Mon Sep 17 00:00:00 2001 From: Kimmo Fonsell Date: Fri, 2 Oct 2026 14:13:57 +0300 Subject: [PATCH 05/25] Run the benchmark on the Windows runner The host check is now built on Apple only: it hosts the AU and looks for the bundles in ~/Library, neither of which exists on Windows, and with it in the way AMPSIM_BUILD_TOOLS could not be turned on there. The benchmark runs at 48 kHz and at 44.1 kHz, the second with the resampler in the chain. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 13 +++++++++++-- CMakeLists.txt | 26 +++++++++++++++----------- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ead9131..5903629 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -34,9 +34,10 @@ jobs: arch: x64 # AMPSIM_COPY_PLUGIN off: JUCE's default VST3 destination is under Program Files, and a - # failed copy would end the build before it says anything about the code. + # failed copy would end the build before it says anything about the code. AMPSIM_BUILD_TOOLS + # for the benchmark below. - name: Configure - run: cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DAMPSIM_COPY_PLUGIN=OFF + run: cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DAMPSIM_COPY_PLUGIN=OFF -DAMPSIM_BUILD_TOOLS=ON - name: Build run: cmake --build build @@ -59,6 +60,14 @@ jobs: ./pluginval/pluginval.exe --strictness-level 10 \ --validate build/AmpSim_artefacts/Release/VST3/AmpSim.vst3 + # The first figure for NAM on x64 under MSVC. A shared runner is noisy, so read it as an + # order of magnitude rather than a specification; it asserts nothing and cannot fail the job + # on a number. 48 kHz is the model's own rate, 44.1 kHz puts the resampler in the chain. + - name: Benchmark + run: | + ./build/ampsim_bench.exe 48000 + ./build/ampsim_bench.exe 44100 + # Not an installer: the raw build, for trying by hand until packaging is decided. - uses: actions/upload-artifact@v4 with: diff --git a/CMakeLists.txt b/CMakeLists.txt index ede14ca..6005379 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -155,17 +155,21 @@ endif() # Off by default: these measure the plugin rather than being part of it, and only a Release build # of them says anything true. if(AMPSIM_BUILD_TOOLS) - # A host, not the plugin: it loads the built bundles the way Logic does, through the AU and - # VST3 wrappers, which is the layer every other test here skips. - juce_add_console_app(ampsim_hostcheck PRODUCT_NAME "ampsim_hostcheck") - target_sources(ampsim_hostcheck PRIVATE tools/HostCheck.cpp) - target_compile_definitions(ampsim_hostcheck PRIVATE - JUCE_PLUGINHOST_AU=1 - JUCE_PLUGINHOST_VST3=1 - JUCE_MODAL_LOOPS_PERMITTED=1 - JUCE_USE_CURL=0 - JUCE_WEB_BROWSER=0) - target_link_libraries(ampsim_hostcheck PRIVATE juce::juce_audio_utils) + # macOS only: it hosts the AU, which no other platform has, and looks for the bundles where + # macOS installs them. + if(APPLE) + # A host, not the plugin: it loads the built bundles the way Logic does, through the AU + # and VST3 wrappers, which is the layer every other test here skips. + juce_add_console_app(ampsim_hostcheck PRODUCT_NAME "ampsim_hostcheck") + target_sources(ampsim_hostcheck PRIVATE tools/HostCheck.cpp) + target_compile_definitions(ampsim_hostcheck PRIVATE + JUCE_PLUGINHOST_AU=1 + JUCE_PLUGINHOST_VST3=1 + JUCE_MODAL_LOOPS_PERMITTED=1 + JUCE_USE_CURL=0 + JUCE_WEB_BROWSER=0) + target_link_libraries(ampsim_hostcheck PRIVATE juce::juce_audio_utils) + endif() add_executable(ampsim_bench tools/Benchmark.cpp) target_include_directories(ampsim_bench PRIVATE ${CMAKE_SOURCE_DIR}/src) From 706848df273b7984a430df410ca4420f083aca56 Mon Sep 17 00:00:00 2001 From: Kimmo Fonsell Date: Fri, 2 Oct 2026 14:14:03 +0300 Subject: [PATCH 06/25] Record the Windows pluginval pass Co-Authored-By: Claude Opus 5.5 --- docs/windows.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/windows.md b/docs/windows.md index 5c9c7a2..cfed854 100644 --- a/docs/windows.md +++ b/docs/windows.md @@ -22,7 +22,10 @@ test that fails on macOS too. With the fix in, all 124 pass on Windows. The first pluginval run on Windows only looked like a pass. PowerShell does not wait for a GUI-subsystem program, and `pluginval.exe` is one, so the step returned while pluginval was still -in the editor tests and reported success without a result. The step now runs under bash. +in the editor tests and reported success without a result. The step now runs under bash. Under bash, pluginval ran for +eighteen seconds, completed every group at strictness 10 and reported `SUCCESS`. The groups +include the editor and its automation, parameter fuzzing, thread safety and the bus layouts. Its +VST3 validator stage is skipped, as it is on macOS, because no validator path is set. A clean build takes about twelve minutes on the runner. Six of those are spent linking the VST3 and the standalone, which is link-time code generation, not compiling. From 3862b1cfd2dde98161ebcc109d1ee305e328b737 Mon Sep 17 00:00:00 2001 From: Kimmo Fonsell Date: Fri, 2 Oct 2026 15:06:21 +0300 Subject: [PATCH 07/25] Record the first Windows benchmark, and print the rate it ran at The footer said 48 kHz whatever rate the benchmark was given, which the 44.1 kHz run on the Windows runner made visible. Co-Authored-By: Claude Opus 5.5 --- docs/windows.md | 28 ++++++++++++++++++++++++++++ tools/Benchmark.cpp | 3 ++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/docs/windows.md b/docs/windows.md index cfed854..22a0fa1 100644 --- a/docs/windows.md +++ b/docs/windows.md @@ -27,6 +27,34 @@ eighteen seconds, completed every group at strictness 10 and reported `SUCCESS`. include the editor and its automation, parameter fuzzing, thread safety and the bus layouts. Its VST3 validator stage is skipped, as it is on macOS, because no validator path is set. +### What it costs on the runner + +`ampsim_bench` on `windows-latest` (x64, MSVC, default `/arch`, so SSE2), against the same +benchmark on an M-series Mac. Percentages are of one core: + +| | 64 samples | 512 samples | +| --- | --- | --- | +| 48 kHz, amp + cab, Windows runner | 12.9 % | 10.6 % | +| 48 kHz, amp + cab, Mac | 3.5 % | 2.8 % | +| 44.1 kHz, amp + cab, Windows runner | 17.1 % | 15.0 % | +| 44.1 kHz, amp + cab, Mac | 5.7 % | 4.5 % | +| 44.1 kHz, everything on, Windows runner | 17.7 % | 15.4 % | + +What this establishes: + +- **It keeps up with a wide margin.** The worst case, everything on at 44.1 kHz and 64 samples, is + under a fifth of one core. +- **The shape is the same as on the Mac.** The model is the cost; every pedal together adds well + under one point, and a bypassed plugin is under 0.15 %. +- **The resampler costs about twice as much in absolute terms**: roughly four points at 44.1 kHz, + against two on the Mac. That is the same proportion of the total, so it does not point at the + resampler in particular. +- **Overall the runner is three to four times the Mac.** These figures do not say why. A shared + cloud VM, MSVC's code generation, and Eigen limited to SSE2 (where the Mac build gets NEON with + fused multiply-add) would all push the same way. Telling them apart needs two builds measured + on the same machine in the same job, for example the default against `/arch:AVX2`, or MSVC + against clang-cl. + A clean build takes about twelve minutes on the runner. Six of those are spent linking the VST3 and the standalone, which is link-time code generation, not compiling. diff --git a/tools/Benchmark.cpp b/tools/Benchmark.cpp index 7c916c8..a4ae0c2 100644 --- a/tools/Benchmark.cpp +++ b/tools/Benchmark.cpp @@ -172,6 +172,7 @@ int main (int argc, char** argv) } std::cout << "\nEach figure is the median of seven two-second passes, as a percentage of one\n" - "core at 48 kHz. A plugin must stay well under 100 % or it cannot keep up.\n"; + "core at " << juce::String (sampleRate / 1000.0, 1) << " kHz. A plugin must stay" + " well under 100 % or it cannot keep up.\n"; return 0; } From b4021cf254717afe9f0b1e16bb775f08f951ab57 Mon Sep 17 00:00:00 2001 From: Kimmo Fonsell Date: Fri, 2 Oct 2026 15:07:47 +0300 Subject: [PATCH 08/25] Benchmark an AVX2 build beside the default one on the Windows runner An experiment to separate the runner's hardware from the code it runs: the benchmark alone, rebuilt with /arch:AVX2, run in the same job as the default build and interleaved with it. The step fails if the flag does not reach the compile lines, so a no-op comparison cannot pass for a result. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5903629..999dc7b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -60,13 +60,29 @@ jobs: ./pluginval/pluginval.exe --strictness-level 10 \ --validate build/AmpSim_artefacts/Release/VST3/AmpSim.vst3 - # The first figure for NAM on x64 under MSVC. A shared runner is noisy, so read it as an - # order of magnitude rather than a specification; it asserts nothing and cannot fail the job - # on a number. 48 kHz is the model's own rate, 44.1 kHz puts the resampler in the chain. + # An experiment, not part of the product: the benchmark alone, built with /arch:AVX2 so + # Eigen can use AVX2 and FMA instead of SSE2. _INIT rather than CMAKE_CXX_FLAGS, which would + # replace MSVC's defaults (/EHsc among them) instead of adding to them. + - name: Build the AVX2 benchmark + run: | + cmake -B build-avx2 -G Ninja -DCMAKE_BUILD_TYPE=Release -DAMPSIM_COPY_PLUGIN=OFF ` + -DAMPSIM_BUILD_TOOLS=ON -DAMPSIM_BUILD_TESTS=OFF "-DCMAKE_CXX_FLAGS_INIT=/arch:AVX2" + if (-not (Select-String -Path build-avx2/build.ninja -Pattern '/arch:AVX2' -SimpleMatch -Quiet)) { + throw "/arch:AVX2 did not reach the compile lines" + } + cmake --build build-avx2 --target ampsim_bench + + # Both builds on the same machine in the same job, which is the only comparison a shared + # runner allows: between runs the hardware changes. Interleaved default, AVX2, AVX2, + # default, so a runner that speeds up or slows down part-way is spread across both. 48 kHz + # is the model's own rate; 44.1 kHz puts the resampler in the chain. It asserts nothing. - name: Benchmark run: | - ./build/ampsim_bench.exe 48000 - ./build/ampsim_bench.exe 44100 + (Get-CimInstance Win32_Processor).Name + "--- default (SSE2) ---"; ./build/ampsim_bench.exe 48000 + "--- /arch:AVX2 ---"; ./build-avx2/ampsim_bench.exe 48000 + "--- /arch:AVX2 ---"; ./build-avx2/ampsim_bench.exe 44100 + "--- default (SSE2) ---"; ./build/ampsim_bench.exe 44100 # Not an installer: the raw build, for trying by hand until packaging is decided. - uses: actions/upload-artifact@v4 From d0575258aa998983be115fbb97b3820e76a72d37 Mon Sep 17 00:00:00 2001 From: Kimmo Fonsell Date: Fri, 2 Oct 2026 15:30:57 +0300 Subject: [PATCH 09/25] Record the SSE2 against AVX2 benchmark on the Windows runner Co-Authored-By: Claude Opus 5.5 --- docs/windows.md | 51 +++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/docs/windows.md b/docs/windows.md index 22a0fa1..97be7ac 100644 --- a/docs/windows.md +++ b/docs/windows.md @@ -49,11 +49,38 @@ What this establishes: - **The resampler costs about twice as much in absolute terms**: roughly four points at 44.1 kHz, against two on the Mac. That is the same proportion of the total, so it does not point at the resampler in particular. -- **Overall the runner is three to four times the Mac.** These figures do not say why. A shared - cloud VM, MSVC's code generation, and Eigen limited to SSE2 (where the Mac build gets NEON with - fused multiply-add) would all push the same way. Telling them apart needs two builds measured - on the same machine in the same job, for example the default against `/arch:AVX2`, or MSVC - against clang-cl. +- **Overall the runner is three to four times the Mac.** The table alone cannot say why. + +The figures above also show that one run cannot be compared with another. The next run's default +build measured 10.1 % where this one measured 12.9 %, because each run gets whatever machine is +free. That one was an AMD EPYC 9V74 (Zen 4). + +### SSE2 against AVX2, on the same machine + +The benchmark was built a second time with `/arch:AVX2`, which lets Eigen use AVX2 and FMA. Both +builds then ran in one job, interleaved as default, AVX2, AVX2, default: + +| amp + cab | default (SSE2) | `/arch:AVX2` | change | +| --- | --- | --- | --- | +| 48 kHz, 64 samples | 10.06 % | 6.78 % | −33 % | +| 48 kHz, 512 samples | 8.70 % | 5.38 % | −38 % | +| 44.1 kHz, 64 samples | 13.65 % | 10.00 % | −27 % | +| 44.1 kHz, 512 samples | 12.34 % | 8.64 % | −30 % | + +- **The model gains and the resampler barely does.** The cost the resampler adds (the 44.1 kHz + figure minus the 48 kHz one) is 3.6 points under SSE2 and 3.2 under AVX2. JUCE's windowed-sinc + interpolator is not vectorised the way Eigen is. So at 44.1 kHz with AVX2 the resampler is about + a third of the total, against about a quarter at 48 kHz. +- **About half the gap to the Mac was the instruction set.** With AVX2 the runner is 1.8 to 1.9 + times the Mac's cost, against three to four times without it. The rest is the machine, the + compiler, or both. Separating those would take a clang-cl build measured the same way. +- **The pedals are unaffected either way**, and still add under a point together. + +What it means for the architecture decision below: an AVX2 build is measurably cheaper. The +price is a CPU requirement. Every Intel Core since Haswell (2013) and every AMD Zen (2017) has +AVX2. Some Pentium and Celeron parts lacked it for years after that. A plugin built for AVX2 and +loaded on a CPU without it does not refuse politely: it faults on the first such instruction, +which takes the host down with it. A clean build takes about twelve minutes on the runner. Six of those are spent linking the VST3 and the standalone, which is link-time code generation, not compiling. @@ -170,9 +197,17 @@ has no ad-hoc signature to fall back on, so the choice is between signing and no x64 is the default target and covers nearly all Windows audio machines. Windows on ARM (Snapdragon laptops) can run x64 plugins only inside an x64 host, and native ARM64 hosts need ARM64 plugins. -Adding ARM64 later is a second CI matrix entry. Eigen's vectorisation on x64 defaults to SSE2; -`/arch:AVX2` would be faster, but it would refuse to load on older CPUs without a runtime dispatch. -Whether that matters depends on the benchmark below. +Adding ARM64 later is a second CI matrix entry. + +Eigen on x64 defaults to SSE2. `/arch:AVX2` measured 27 to 38 % cheaper (see the benchmark above). +Making the AMD Zen and Intel Haswell generation the minimum is the cost of that. The options: + +| Option | For | Against | +| --- | --- | --- | +| SSE2 only (the default) | Runs on any x64 CPU | The higher cost measured above | +| AVX2 only | The lower cost, one build | On a CPU without AVX2 it crashes the host rather than failing to load. The installer could check and refuse | +| Both, chosen by the installer | Each machine gets the build it can run | Two builds to test and ship, and an installer that inspects the CPU | +| Runtime dispatch inside one binary | One build that uses AVX2 where it exists | NAM and Eigen compiled twice into separate namespaces and picked by `cpuid`. The most work, and it reaches into a dependency | ## Proposed order of work From 5ceb89592c620ab9e441a205a3ee1f37d4974809 Mon Sep 17 00:00:00 2001 From: Kimmo Fonsell Date: Fri, 2 Oct 2026 15:34:00 +0300 Subject: [PATCH 10/25] Build for AVX2 on Windows /arch:AVX2 measured 27 to 38 % cheaper on the amp model on the same runner. It is set for every MSVC-style build ahead of nam_core and the plugin, so Eigen gets AVX2 and FMA. The SSE2 comparison is gone from CI; the benchmark step now fails if the flag stops reaching the compiler, since nothing else would notice. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 27 ++++++++------------------- CMakeLists.txt | 8 ++++++++ docs/windows.md | 7 ++++++- 3 files changed, 22 insertions(+), 20 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 999dc7b..c3554a5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -60,29 +60,18 @@ jobs: ./pluginval/pluginval.exe --strictness-level 10 \ --validate build/AmpSim_artefacts/Release/VST3/AmpSim.vst3 - # An experiment, not part of the product: the benchmark alone, built with /arch:AVX2 so - # Eigen can use AVX2 and FMA instead of SSE2. _INIT rather than CMAKE_CXX_FLAGS, which would - # replace MSVC's defaults (/EHsc among them) instead of adding to them. - - name: Build the AVX2 benchmark + # Also proves the build is AVX2: a build that lost the flag would still pass every test, + # and only cost more. Then the benchmark, which asserts nothing; a shared runner changes + # hardware between runs, so compare figures only within one job. 48 kHz is the model's own + # rate; 44.1 kHz puts the resampler in the chain. + - name: Benchmark run: | - cmake -B build-avx2 -G Ninja -DCMAKE_BUILD_TYPE=Release -DAMPSIM_COPY_PLUGIN=OFF ` - -DAMPSIM_BUILD_TOOLS=ON -DAMPSIM_BUILD_TESTS=OFF "-DCMAKE_CXX_FLAGS_INIT=/arch:AVX2" - if (-not (Select-String -Path build-avx2/build.ninja -Pattern '/arch:AVX2' -SimpleMatch -Quiet)) { + if (-not (Select-String -Path build/build.ninja -Pattern '/arch:AVX2' -SimpleMatch -Quiet)) { throw "/arch:AVX2 did not reach the compile lines" } - cmake --build build-avx2 --target ampsim_bench - - # Both builds on the same machine in the same job, which is the only comparison a shared - # runner allows: between runs the hardware changes. Interleaved default, AVX2, AVX2, - # default, so a runner that speeds up or slows down part-way is spread across both. 48 kHz - # is the model's own rate; 44.1 kHz puts the resampler in the chain. It asserts nothing. - - name: Benchmark - run: | (Get-CimInstance Win32_Processor).Name - "--- default (SSE2) ---"; ./build/ampsim_bench.exe 48000 - "--- /arch:AVX2 ---"; ./build-avx2/ampsim_bench.exe 48000 - "--- /arch:AVX2 ---"; ./build-avx2/ampsim_bench.exe 44100 - "--- default (SSE2) ---"; ./build/ampsim_bench.exe 44100 + ./build/ampsim_bench.exe 48000 + ./build/ampsim_bench.exe 44100 # Not an installer: the raw build, for trying by hand until packaging is decided. - uses: actions/upload-artifact@v4 diff --git a/CMakeLists.txt b/CMakeLists.txt index 6005379..1ff2274 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -16,6 +16,14 @@ option(AMPSIM_BUILD_TOOLS "Build the offline measurement tools" OFF) # JUCE is a pinned submodule (9.0.2); see README for the update policy. add_subdirectory(external/JUCE) +# Windows builds require AVX2. Eigen then uses AVX2 and FMA instead of SSE2, which measured 27 to +# 38 % cheaper on the amp model; the price is a CPU from the Haswell / Zen generation onwards, +# and a CPU without AVX2 crashes on the first such instruction rather than refusing to load. See +# docs/windows.md. MSVC is also true for clang-cl. Before nam_core and AmpSim, so both get it. +if(MSVC) + add_compile_options(/arch:AVX2) +endif() + # --- Neural Amp Modeler Core ------------------------------------------------- # Built here rather than via NAM's own CMakeLists, which sets global compiler flags and builds # its tools and tests. Sources are globbed the way nam-juce does it. diff --git a/docs/windows.md b/docs/windows.md index 97be7ac..83fae2d 100644 --- a/docs/windows.md +++ b/docs/windows.md @@ -200,7 +200,12 @@ laptops) can run x64 plugins only inside an x64 host, and native ARM64 hosts nee Adding ARM64 later is a second CI matrix entry. Eigen on x64 defaults to SSE2. `/arch:AVX2` measured 27 to 38 % cheaper (see the benchmark above). -Making the AMD Zen and Intel Haswell generation the minimum is the cost of that. The options: +Making the AMD Zen and Intel Haswell generation the minimum is the cost of that. + +**Decided: AVX2 only.** `CMakeLists.txt` sets `/arch:AVX2` for every MSVC-style build, and the CI +benchmark step fails if the flag goes missing. Because a CPU without AVX2 crashes the host rather +than failing to load, the installer (step 7) should check for AVX2 and refuse to install without +it. The guide should state the requirement too. The options that were weighed: | Option | For | Against | | --- | --- | --- | From c7bbc2f9567b3be57097eaceab14adb725eb5b16 Mon Sep 17 00:00:00 2001 From: Kimmo Fonsell Date: Fri, 2 Oct 2026 22:33:23 +0300 Subject: [PATCH 11/25] Measure clang-cl and three NAM build options against the Windows build Four more builds of the benchmark alone in the Windows job, each different from the normal build in one thing: NAM_ENABLE_A2_FAST, NAM_USE_INLINE_GEMM, NAM with whole-program optimisation, and clang-cl with lld-link. They run on the same runner in alternating order, and each is allowed to fail without costing the others their measurements. The benchmark now prints what it was built with and whether NAM took its A2 fast path, which it does silently. The bundled capture is a SlimmableContainer of two A2-shaped WaveNets, so the check looks inside containers. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 64 ++++++++++++++++++++++++++--- docs/windows.md | 17 ++++++++ tools/Benchmark.cpp | 80 ++++++++++++++++++++++++++++++++++++- 3 files changed, 154 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c3554a5..7bb939e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -60,18 +60,70 @@ jobs: ./pluginval/pluginval.exe --strictness-level 10 \ --validate build/AmpSim_artefacts/Release/VST3/AmpSim.vst3 - # Also proves the build is AVX2: a build that lost the flag would still pass every test, - # and only cost more. Then the benchmark, which asserts nothing; a shared runner changes - # hardware between runs, so compare figures only within one job. 48 kHz is the model's own - # rate; 44.1 kHz puts the resampler in the chain. + # An experiment, not part of the product: four more builds of the benchmark alone, each + # differing from the normal build in one thing. All are AVX2, from CMakeLists.txt. Flags go + # in through *_INIT, which adds to CMake's defaults instead of replacing them. Each build + # prints what it was built with, so the tables below identify themselves. Each is allowed + # to fail: a variant that will not build is itself an answer, and should not cost the + # others their measurements. The benchmark runs whichever were built. + - name: "Experiment: NAM's A2 fast path" + continue-on-error: true + run: | + cmake -B build-a2 -G Ninja -DCMAKE_BUILD_TYPE=Release -DAMPSIM_COPY_PLUGIN=OFF ` + -DAMPSIM_BUILD_TOOLS=ON -DAMPSIM_BUILD_TESTS=OFF "-DCMAKE_CXX_FLAGS_INIT=/DNAM_ENABLE_A2_FAST" + cmake --build build-a2 --target ampsim_bench + + - name: "Experiment: NAM's inline GEMM" + continue-on-error: true + run: | + cmake -B build-gemm -G Ninja -DCMAKE_BUILD_TYPE=Release -DAMPSIM_COPY_PLUGIN=OFF ` + -DAMPSIM_BUILD_TOOLS=ON -DAMPSIM_BUILD_TESTS=OFF "-DCMAKE_CXX_FLAGS_INIT=/DNAM_USE_INLINE_GEMM" + cmake --build build-gemm --target ampsim_bench + + # JUCE's recommended flags give the plugin's own code /GL, and nam_core none. /GL everywhere + # and /LTCG at every link brings NAM into whole-program optimisation too. + - name: "Experiment: NAM with whole-program optimisation" + continue-on-error: true + run: | + cmake -B build-namlto -G Ninja -DCMAKE_BUILD_TYPE=Release -DAMPSIM_COPY_PLUGIN=OFF ` + -DAMPSIM_BUILD_TOOLS=ON -DAMPSIM_BUILD_TESTS=OFF "-DCMAKE_CXX_FLAGS_INIT=/GL" ` + "-DCMAKE_EXE_LINKER_FLAGS_INIT=/LTCG" "-DCMAKE_STATIC_LINKER_FLAGS_INIT=/LTCG" + cmake --build build-namlto --target ampsim_bench + + # lld-link and llvm-lib because JUCE gives clang-cl -flto, whose objects are LLVM bitcode + # that link.exe and lib.exe cannot read. + - name: "Experiment: clang-cl" + continue-on-error: true + run: | + $llvm = Split-Path (Get-Command clang-cl).Source + "clang-cl from $llvm" + cmake -B build-clang -G Ninja -DCMAKE_BUILD_TYPE=Release -DAMPSIM_COPY_PLUGIN=OFF ` + -DAMPSIM_BUILD_TOOLS=ON -DAMPSIM_BUILD_TESTS=OFF ` + "-DCMAKE_C_COMPILER=$llvm/clang-cl.exe" "-DCMAKE_CXX_COMPILER=$llvm/clang-cl.exe" ` + "-DCMAKE_LINKER=$llvm/lld-link.exe" "-DCMAKE_AR=$llvm/llvm-lib.exe" + cmake --build build-clang --target ampsim_bench + + # Every build on the same machine in the same job, which is the only comparison a shared + # runner allows. 48 kHz runs the builds in one order and 44.1 kHz in the reverse, so a + # runner that drifts part-way is spread across all of them. Only the two summary rows are + # kept. It asserts nothing, except that the normal build is AVX2: a build that lost the flag + # would pass every test and only cost more. - name: Benchmark run: | if (-not (Select-String -Path build/build.ninja -Pattern '/arch:AVX2' -SimpleMatch -Quiet)) { throw "/arch:AVX2 did not reach the compile lines" } (Get-CimInstance Win32_Processor).Name - ./build/ampsim_bench.exe 48000 - ./build/ampsim_bench.exe 44100 + $builds = @('build', 'build-a2', 'build-gemm', 'build-namlto', 'build-clang') | + Where-Object { Test-Path "$_/ampsim_bench.exe" } + $keep = 'at \d+ Hz|built with|A2 fast path|configuration|amp \+ cab \(|everything on' + foreach ($rate in 48000, 44100) { + foreach ($b in $builds) { + "=== $b" + & "./$b/ampsim_bench.exe" $rate | Select-String -Pattern $keep | ForEach-Object { $_.Line } + } + [array]::Reverse($builds) + } # Not an installer: the raw build, for trying by hand until packaging is decided. - uses: actions/upload-artifact@v4 diff --git a/docs/windows.md b/docs/windows.md index 83fae2d..f1e70ef 100644 --- a/docs/windows.md +++ b/docs/windows.md @@ -76,6 +76,23 @@ builds then ran in one job, interleaved as default, AVX2, AVX2, default: compiler, or both. Separating those would take a clang-cl build measured the same way. - **The pedals are unaffected either way**, and still add under a point together. +### NAM's own build options, measured first on the Mac + +The bundled capture is a `SlimmableContainer` of two WaveNets, with 3 and 8 channels. Both match +NAM's A2 shape, so `NAM_ENABLE_A2_FAST` does take effect: the benchmark now prints whether it +did. Our build compiled that path out until now, because NAM's own CMake is what defines the +macro and we do not use it. Each build was compared with the normal one in the same session, +interleaved twice, on an M-series Mac at 48 kHz, amp + cab: + +| | 64 samples | 512 samples | +| --- | --- | --- | +| normal build | 4.2 % | 2.96 % | +| `NAM_ENABLE_A2_FAST` | 3.95 % (−7 %) | 2.87 % (−3 %) | +| `NAM_USE_INLINE_GEMM` | 4.22 % (±0) | 3.46 % (+17 %) | + +The Windows job measures the same two options, plus clang-cl and NAM with whole-program +optimisation, on the runner. + What it means for the architecture decision below: an AVX2 build is measurably cheaper. The price is a CPU requirement. Every Intel Core since Haswell (2013) and every AMD Zen (2017) has AVX2. Some Pentium and Celeron parts lacked it for years after that. A plugin built for AVX2 and diff --git a/tools/Benchmark.cpp b/tools/Benchmark.cpp index a4ae0c2..ab516ae 100644 --- a/tools/Benchmark.cpp +++ b/tools/Benchmark.cpp @@ -21,6 +21,11 @@ up whatever else the machine was doing. */ #include "PluginProcessor.h" +#include "BundledAssets.h" + +#if defined(NAM_ENABLE_A2_FAST) + #include "wavenet/a2_fast.h" +#endif #include @@ -28,6 +33,77 @@ namespace { double sampleRate = 48000.0; + /** What this binary was built with, so that a table from one of several builds compared in + the same CI job says which one it is. */ + juce::String buildDescription() + { + juce::StringArray parts; + + #if defined(__clang__) + parts.add ("clang " __clang_version__); + #elif defined(_MSC_VER) + parts.add ("MSVC " + juce::String (_MSC_VER)); + #else + parts.add ("unknown compiler"); + #endif + + #if defined(__AVX2__) + parts.add ("AVX2"); + #endif + #if defined(NAM_ENABLE_A2_FAST) + parts.add ("NAM_ENABLE_A2_FAST"); + #endif + #if defined(NAM_USE_INLINE_GEMM) + parts.add ("NAM_USE_INLINE_GEMM"); + #endif + + return parts.joinIntoString (", "); + } + + /** NAM takes its A2 fast path silently, and only for a model of exactly that shape, so a + build with it enabled can measure the same as one without for either of two reasons. */ + juce::String a2FastPath() + { + #if defined(NAM_ENABLE_A2_FAST) + try + { + const auto text = BundledAssets::ampModel().loadFileAsString().toStdString(); + const auto model = nlohmann::json::parse (text); + + // A slimmable capture is a container of WaveNets, and each is checked on its own. + std::vector wavenets; + + if (model.value ("architecture", std::string()) == "SlimmableContainer") + for (const auto& sub : model.at ("config").at ("submodels")) + wavenets.push_back (sub.at ("model")); + else + wavenets.push_back (model); + + juce::StringArray verdicts; + + for (const auto& net : wavenets) + { + int channels = 0; + + if (net.value ("architecture", std::string()) != "WaveNet") + verdicts.add ("not a WaveNet"); + else if (nam::wavenet::a2_fast::is_a2_shape (net.at ("config"), &channels)) + verdicts.add ("taken, " + juce::String (channels) + " channels"); + else + verdicts.add ("not taken, not A2-shaped"); + } + + return verdicts.joinIntoString ("; "); + } + catch (const std::exception& e) + { + return "unknown (" + juce::String (e.what()) + ")"; + } + #else + return "not compiled in"; + #endif + } + void set (juce::AudioProcessorValueTreeState& state, const juce::String& id, float value) { if (auto* p = state.getParameter (id)) @@ -112,7 +188,9 @@ int main (int argc, char** argv) waitForModel (processor); std::cout << "model loaded: " << (processor.isModelLoaded() ? "yes" : "no") - << ", latency " << processor.getLatencySamples() << " samples\n\n"; + << ", latency " << processor.getLatencySamples() << " samples\n" + << "built with: " << buildDescription() << "\n" + << "A2 fast path: " << a2FastPath() << "\n\n"; struct Setup { const char* name; std::function apply; }; From afa0c0c93b5fbace6344e32cf3714ff13a1d6883 Mon Sep 17 00:00:00 2001 From: Kimmo Fonsell Date: Fri, 2 Oct 2026 23:19:02 +0300 Subject: [PATCH 12/25] Record the build-option experiment on the Windows runner Co-Authored-By: Claude Opus 5.5 --- docs/windows.md | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/docs/windows.md b/docs/windows.md index f1e70ef..fba595d 100644 --- a/docs/windows.md +++ b/docs/windows.md @@ -90,8 +90,35 @@ interleaved twice, on an M-series Mac at 48 kHz, amp + cab: | `NAM_ENABLE_A2_FAST` | 3.95 % (−7 %) | 2.87 % (−3 %) | | `NAM_USE_INLINE_GEMM` | 4.22 % (±0) | 3.46 % (+17 %) | -The Windows job measures the same two options, plus clang-cl and NAM with whole-program -optimisation, on the runner. +### The same options, and two more, on the Windows runner + +Five builds ran in one job on an AMD EPYC 9V74, 48 kHz in one order and 44.1 kHz in the reverse. +Each differs from the normal build (MSVC, AVX2) in one thing. Amp + cab, percent of one core: + +| | 48 kHz, 64 | 48 kHz, 512 | 44.1 kHz, 64 | 44.1 kHz, 512 | +| --- | --- | --- | --- | --- | +| normal | 7.34 | 5.63 | 10.19 | 8.76 | +| `NAM_ENABLE_A2_FAST` (taken for both submodels) | 6.89 | 5.95 | 9.44 | 7.99 | +| `NAM_USE_INLINE_GEMM` | 5.85 | 6.35 | 9.21 | 8.33 | +| clang-cl 20.1.8 with lld-link | 7.01 | 5.01 | 9.99 | 9.61 | +| NAM with `/GL`, every link `/LTCG` | 12.54 | 10.58 | 15.72 | 15.70 | + +**This run was noisy, and that limits what it can show.** In several builds the "everything on" +row came out *cheaper* than "amp + cab". It does strictly more work, and in earlier runs it never +did. So the runner's noise this time was about a point, roughly fifteen percent, and anything +smaller than that is not a result: + +- **Whole-program optimisation of NAM is clearly worse**: 1.5 to 1.8 times the cost, at every + block size and both rates, far outside the noise. The cause is not known. Whatever it is, it is + not worth pursuing. +- **A2, inline GEMM and clang-cl are within the noise.** A2 was lower at 44.1 kHz in both + columns and mixed at 48 kHz. That is the same direction as the Mac's cleaner measurement, but it + does not confirm it. Inline GEMM and clang-cl move both ways. +- clang-cl built and ran without changes beyond the linker and archiver, so it is an option if a + later measurement favours it. + +Resolving differences this small would need many more interleaved repetitions per build than +one pass at each rate, or a quiet machine of one's own. What it means for the architecture decision below: an AVX2 build is measurably cheaper. The price is a CPU requirement. Every Intel Core since Haswell (2013) and every AMD Zen (2017) has From 1eece445e3513ed0d5d366735ceb61a4f4b2eae5 Mon Sep 17 00:00:00 2001 From: Kimmo Fonsell Date: Fri, 2 Oct 2026 23:28:55 +0300 Subject: [PATCH 13/25] Turn on NAM's A2 fast path, with a test against the generic WaveNet NAM's own CMake defines NAM_ENABLE_A2_FAST by default; this project builds NAM itself and had left it compiled out. The bundled capture is two A2-shaped WaveNets, and the path measured 3 to 7 % cheaper on the Mac. It is a second implementation of the same model, so the new test builds each bundled submodel both ways from the same weights and null-tests them: -122 and -132 dB apart. The -100 dB threshold fails a single weight changed by 1 % (-77 dB), and the test fails outright without the define. The CI experiments have answered their questions and are removed. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 64 ++-------------------- CLAUDE.md | 8 +++ CMakeLists.txt | 6 ++ docs/windows.md | 7 +++ tests/ModelTests.cpp | 106 ++++++++++++++++++++++++++++++++++++ 5 files changed, 133 insertions(+), 58 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7bb939e..c3554a5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -60,70 +60,18 @@ jobs: ./pluginval/pluginval.exe --strictness-level 10 \ --validate build/AmpSim_artefacts/Release/VST3/AmpSim.vst3 - # An experiment, not part of the product: four more builds of the benchmark alone, each - # differing from the normal build in one thing. All are AVX2, from CMakeLists.txt. Flags go - # in through *_INIT, which adds to CMake's defaults instead of replacing them. Each build - # prints what it was built with, so the tables below identify themselves. Each is allowed - # to fail: a variant that will not build is itself an answer, and should not cost the - # others their measurements. The benchmark runs whichever were built. - - name: "Experiment: NAM's A2 fast path" - continue-on-error: true - run: | - cmake -B build-a2 -G Ninja -DCMAKE_BUILD_TYPE=Release -DAMPSIM_COPY_PLUGIN=OFF ` - -DAMPSIM_BUILD_TOOLS=ON -DAMPSIM_BUILD_TESTS=OFF "-DCMAKE_CXX_FLAGS_INIT=/DNAM_ENABLE_A2_FAST" - cmake --build build-a2 --target ampsim_bench - - - name: "Experiment: NAM's inline GEMM" - continue-on-error: true - run: | - cmake -B build-gemm -G Ninja -DCMAKE_BUILD_TYPE=Release -DAMPSIM_COPY_PLUGIN=OFF ` - -DAMPSIM_BUILD_TOOLS=ON -DAMPSIM_BUILD_TESTS=OFF "-DCMAKE_CXX_FLAGS_INIT=/DNAM_USE_INLINE_GEMM" - cmake --build build-gemm --target ampsim_bench - - # JUCE's recommended flags give the plugin's own code /GL, and nam_core none. /GL everywhere - # and /LTCG at every link brings NAM into whole-program optimisation too. - - name: "Experiment: NAM with whole-program optimisation" - continue-on-error: true - run: | - cmake -B build-namlto -G Ninja -DCMAKE_BUILD_TYPE=Release -DAMPSIM_COPY_PLUGIN=OFF ` - -DAMPSIM_BUILD_TOOLS=ON -DAMPSIM_BUILD_TESTS=OFF "-DCMAKE_CXX_FLAGS_INIT=/GL" ` - "-DCMAKE_EXE_LINKER_FLAGS_INIT=/LTCG" "-DCMAKE_STATIC_LINKER_FLAGS_INIT=/LTCG" - cmake --build build-namlto --target ampsim_bench - - # lld-link and llvm-lib because JUCE gives clang-cl -flto, whose objects are LLVM bitcode - # that link.exe and lib.exe cannot read. - - name: "Experiment: clang-cl" - continue-on-error: true - run: | - $llvm = Split-Path (Get-Command clang-cl).Source - "clang-cl from $llvm" - cmake -B build-clang -G Ninja -DCMAKE_BUILD_TYPE=Release -DAMPSIM_COPY_PLUGIN=OFF ` - -DAMPSIM_BUILD_TOOLS=ON -DAMPSIM_BUILD_TESTS=OFF ` - "-DCMAKE_C_COMPILER=$llvm/clang-cl.exe" "-DCMAKE_CXX_COMPILER=$llvm/clang-cl.exe" ` - "-DCMAKE_LINKER=$llvm/lld-link.exe" "-DCMAKE_AR=$llvm/llvm-lib.exe" - cmake --build build-clang --target ampsim_bench - - # Every build on the same machine in the same job, which is the only comparison a shared - # runner allows. 48 kHz runs the builds in one order and 44.1 kHz in the reverse, so a - # runner that drifts part-way is spread across all of them. Only the two summary rows are - # kept. It asserts nothing, except that the normal build is AVX2: a build that lost the flag - # would pass every test and only cost more. + # Also proves the build is AVX2: a build that lost the flag would still pass every test, + # and only cost more. Then the benchmark, which asserts nothing; a shared runner changes + # hardware between runs, so compare figures only within one job. 48 kHz is the model's own + # rate; 44.1 kHz puts the resampler in the chain. - name: Benchmark run: | if (-not (Select-String -Path build/build.ninja -Pattern '/arch:AVX2' -SimpleMatch -Quiet)) { throw "/arch:AVX2 did not reach the compile lines" } (Get-CimInstance Win32_Processor).Name - $builds = @('build', 'build-a2', 'build-gemm', 'build-namlto', 'build-clang') | - Where-Object { Test-Path "$_/ampsim_bench.exe" } - $keep = 'at \d+ Hz|built with|A2 fast path|configuration|amp \+ cab \(|everything on' - foreach ($rate in 48000, 44100) { - foreach ($b in $builds) { - "=== $b" - & "./$b/ampsim_bench.exe" $rate | Select-String -Pattern $keep | ForEach-Object { $_.Line } - } - [array]::Reverse($builds) - } + ./build/ampsim_bench.exe 48000 + ./build/ampsim_bench.exe 44100 # Not an installer: the raw build, for trying by hand until packaging is decided. - uses: actions/upload-artifact@v4 diff --git a/CLAUDE.md b/CLAUDE.md index 9327743..4eaa63a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -175,6 +175,14 @@ the chain has finished, and again on the early return the fully-bypassed path ta `$` (the `NAM_CORE_WHOLE` variable). A normal static link drops those translation units and every model fails with "No config parser registered for architecture". +**NAM is built by this project's CMake, not its own, so NAM's defaults do not apply.** Its +`NAM_ENABLE_A2_FAST` was compiled out that way until it was noticed. It is a specialised WaveNet +for the A2 shape, which the bundled capture has (a `SlimmableContainer` of a 3- and an 8-channel +A2 WaveNet), and NAM takes it silently. `ampsim_bench` prints whether it was taken. Because it is +a second implementation of the same model, `tests/ModelTests.cpp` null-tests it against the +generic WaveNet on the bundled capture. Check NAM's `CMakeLists.txt` for anything else it +defines when the submodule is updated. + ## The panel `AmpLookAndFeel` holds the whole visual identity; `AmpPalette` holds the colours, so a second diff --git a/CMakeLists.txt b/CMakeLists.txt index 1ff2274..a1dd024 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -43,6 +43,12 @@ target_include_directories(nam_core SYSTEM PUBLIC # and every block needs a conversion. target_compile_definitions(nam_core PUBLIC NAM_SAMPLE_FLOAT DSP_SAMPLE_FLOAT) +# NAM's specialised WaveNet for the A2 shape, which the bundled capture has. NAM's own CMake turns +# it on by default; building NAM here instead had silently left it compiled out. It measured 3 to +# 7 % cheaper on the Mac. tests/ModelTests.cpp null-tests it against the generic WaveNet, since it +# is a second implementation of the same model. +target_compile_definitions(nam_core PUBLIC NAM_ENABLE_A2_FAST) + # NAM registers its architectures (WaveNet, LSTM, ConvNet, Linear) with file-scope static objects # in their own .cpp files. Nothing references those objects, so a normal static-library link drops # the whole translation unit and loading any model fails with "No config parser registered for diff --git a/docs/windows.md b/docs/windows.md index fba595d..8532709 100644 --- a/docs/windows.md +++ b/docs/windows.md @@ -120,6 +120,13 @@ smaller than that is not a result: Resolving differences this small would need many more interleaved repetitions per build than one pass at each rate, or a quiet machine of one's own. +**Decided: the A2 fast path is on, everywhere, and the other three are not.** `NAM_ENABLE_A2_FAST` +is a public definition of `nam_core`. It is a second implementation of the same model, so +`tests/ModelTests.cpp` builds each of the bundled capture's submodels both ways, from the same +weights, and null-tests one against the other. They agree to −122 and −132 dB. The threshold is +−100 dB, which a single weight 1 % out (−77 dB) fails. The experiment steps are gone from CI; +the benchmark still prints what it was built with and whether the A2 path was taken. + What it means for the architecture decision below: an AVX2 build is measurably cheaper. The price is a CPU requirement. Every Intel Core since Haswell (2013) and every AMD Zen (2017) has AVX2. Some Pentium and Celeron parts lacked it for years after that. A plugin built for AVX2 and diff --git a/tests/ModelTests.cpp b/tests/ModelTests.cpp index abf1511..1ba0988 100644 --- a/tests/ModelTests.cpp +++ b/tests/ModelTests.cpp @@ -9,9 +9,15 @@ */ #include "TestHelpers.h" +#include "BundledAssets.h" #include "dsp/AmpModel.h" #include +#include +#include +#include + +#include #include #include @@ -446,3 +452,103 @@ TEST_CASE ("A capture that does not say how loud it is, is left alone", "[model] REQUIRE_THAT (ampModel.getNormalisationDb(), WithinAbs (0.0, 1.0e-6)); } + +TEST_CASE ("NAM's A2 fast path plays the bundled capture as the generic WaveNet does", "[model]") +{ + // The bundled capture is a container of two A2-shaped WaveNets, and NAM_ENABLE_A2_FAST hands + // both to a second, specialised implementation. Nothing else here would notice if that + // implementation drifted from the reference one: every other model test uses NAM's examples, + // which are not A2-shaped, and the bundled assets are off in the tests. So build each + // submodel both ways from the same weights and null-test one against the other. + #if ! defined (NAM_ENABLE_A2_FAST) + FAIL ("NAM_ENABLE_A2_FAST is not defined, so the plugin plays the generic WaveNet"); + #else + REQUIRE (BundledAssets::install().isEmpty()); + + const auto file = nlohmann::json::parse (BundledAssets::ampModel().loadFileAsString().toStdString()); + REQUIRE (file.at ("architecture") == "SlimmableContainer"); + + const auto& submodels = file.at ("config").at ("submodels"); + REQUIRE (submodels.size() == 2); + + constexpr int blockSize = 256; + constexpr int numBlocks = 300; // 1.6 s at 48 kHz + + for (const auto& submodel : submodels) + { + const auto& net = submodel.at ("model"); + const auto& config = net.at ("config"); + const auto rate = net.at ("sample_rate").get(); + const auto weights = net.at ("weights").get>(); + + int channels = 0; + REQUIRE (nam::wavenet::a2_fast::is_a2_shape (config, &channels)); + INFO (channels << "-channel submodel"); + + nam::ModelMetadata metadata; + metadata.sample_rate = rate; + + auto fast = nam::create_dsp (nam::wavenet::create_config (config, rate), weights, metadata); + auto generic = nam::create_dsp (std::make_unique ( + nam::wavenet::parse_config_json (config, rate)), + weights, metadata); + REQUIRE (fast != nullptr); + REQUIRE (generic != nullptr); + + // The fast path really was taken, or this would compare the generic WaveNet with itself. + const auto& fastModel = *fast; + const auto& genericModel = *generic; + REQUIRE (typeid (fastModel) != typeid (genericModel)); + + fast->Reset (rate, blockSize); + generic->Reset (rate, blockSize); + + // Plucked low E with its harmonics, restruck every half second: enough level to drive the + // capture into its nonlinearity, where two implementations are most likely to part. + std::vector input ((size_t) blockSize), fastOut ((size_t) blockSize), genericOut ((size_t) blockSize); + double outputEnergy = 0.0, residualEnergy = 0.0; + int n = 0; + + for (int b = 0; b < numBlocks; ++b) + { + for (auto& x : input) + { + const auto t = (double) n / rate; + const auto sinceStrike = std::fmod (t, 0.5); + double sum = 0.0; + + for (int k = 1; k <= 6; ++k) + sum += std::sin (juce::MathConstants::twoPi * 82.41 * k * t) / k; + + x = (float) (0.4 * std::exp (-6.0 * sinceStrike) * sum); + ++n; + } + + float* in[] { input.data() }; + float* outFast[] { fastOut.data() }; + float* outGeneric[] { genericOut.data() }; + fast->process (in, outFast, blockSize); + generic->process (in, outGeneric, blockSize); + + for (int i = 0; i < blockSize; ++i) + { + outputEnergy += (double) genericOut[(size_t) i] * genericOut[(size_t) i]; + const auto d = (double) fastOut[(size_t) i] - genericOut[(size_t) i]; + residualEnergy += d * d; + } + } + + // The capture makes a sound, so a residual of nothing is not two silent outputs agreeing. + REQUIRE (outputEnergy / (blockSize * numBlocks) > 1.0e-4); + + const auto residualDb = 10.0 * std::log10 (residualEnergy / outputEnergy + 1.0e-30); + INFO ("residual " << residualDb << " dB relative to the output"); + + // Calibrated on the Mac: the two agree to -122 dB (3 channels) and -132 dB (8), which is + // float summation in a different order. Changing one weight of thousands by 1 % reads + // -77 dB, and by 0.1 % reads -97 and -108. -100 dB leaves twenty dB for another + // platform's rounding while still catching a single weight 1 % out in either submodel. + REQUIRE (residualDb < -100.0); + } + #endif +} From a8b30ae69f7fdb1b5c9555cab48a51f09e435223 Mon Sep 17 00:00:00 2001 From: Kimmo Fonsell Date: Fri, 2 Oct 2026 23:50:27 +0300 Subject: [PATCH 14/25] Record the A2 test passing on Windows Co-Authored-By: Claude Opus 5.5 --- docs/windows.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/windows.md b/docs/windows.md index 8532709..c5e359e 100644 --- a/docs/windows.md +++ b/docs/windows.md @@ -124,7 +124,8 @@ one pass at each rate, or a quiet machine of one's own. is a public definition of `nam_core`. It is a second implementation of the same model, so `tests/ModelTests.cpp` builds each of the bundled capture's submodels both ways, from the same weights, and null-tests one against the other. They agree to −122 and −132 dB. The threshold is -−100 dB, which a single weight 1 % out (−77 dB) fails. The experiment steps are gone from CI; +−100 dB, which a single weight 1 % out (−77 dB) fails. The test also passes on the +Windows runner (MSVC, AVX2), and the benchmark there reports the path taken for both submodels. The experiment steps are gone from CI; the benchmark still prints what it was built with and whether the A2 path was taken. What it means for the architecture decision below: an AVX2 build is measurably cheaper. The From 64b0a8608993db0c20c8d8e64808a2c473ade4fb Mon Sep 17 00:00:00 2001 From: Kimmo Fonsell Date: Sat, 3 Oct 2026 00:03:26 +0300 Subject: [PATCH 15/25] Embed Figtree and Jost, so the panel looks the same on Windows Avenir Next and Futura ship with macOS only, and are commercial faces that cannot be embedded. Figtree replaces Avenir for the interface and Jost replaces Futura for names printed on objects, on every platform. Both are OFL with no Reserved Font Name; the files are static Regular and Medium instances of the google/fonts variable fonts, with the licences beside them. Figtree is also the look-and-feel's default sans, so JUCE's own popups no longer fall back to the system font. Every height on the panel was chosen for the old faces, so each new one is scaled to put its capitals where theirs were, from cap height over ascent plus descent measured in the font files. Unscaled, the tempo reading was cut off as "120 BP..." and every printed name shrank; scaled, the three pages match the old ones closely. tools/PanelSnapshot.cpp renders each page to a PNG. It is how this was checked, and the Windows job now runs it and uploads the images. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 11 +++ CLAUDE.md | 18 +++-- CMakeLists.txt | 26 +++++++ README.md | 4 + docs/roadmap.md | 4 +- docs/windows.md | 22 +++++- resources/fonts/Figtree-Medium.ttf | Bin 0 -> 41992 bytes resources/fonts/Figtree-Regular.ttf | Bin 0 -> 41988 bytes resources/fonts/Jost-Medium.ttf | Bin 0 -> 61008 bytes resources/fonts/OFL-Figtree.txt | 93 +++++++++++++++++++++++ resources/fonts/OFL-Jost.txt | 93 +++++++++++++++++++++++ src/ui/AmpLookAndFeel.cpp | 39 ++++++++-- src/ui/AmpLookAndFeel.h | 22 +++++- tools/PanelSnapshot.cpp | 111 ++++++++++++++++++++++++++++ 14 files changed, 425 insertions(+), 18 deletions(-) create mode 100644 resources/fonts/Figtree-Medium.ttf create mode 100644 resources/fonts/Figtree-Regular.ttf create mode 100644 resources/fonts/Jost-Medium.ttf create mode 100644 resources/fonts/OFL-Figtree.txt create mode 100644 resources/fonts/OFL-Jost.txt create mode 100644 tools/PanelSnapshot.cpp diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c3554a5..9559804 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -60,6 +60,17 @@ jobs: ./pluginval/pluginval.exe --strictness-level 10 \ --validate build/AmpSim_artefacts/Release/VST3/AmpSim.vst3 + # Each page of the panel as Windows renders it, for looking at: nothing here can judge a + # caption that has moved or a font that has fallen back, but a person can, from the artefact. + - name: Render the panel + run: ./build/ampsim_snapshot.exe panel 1.5 + + - uses: actions/upload-artifact@v4 + with: + name: AmpSim-Windows-panel + path: panel/*.png + if-no-files-found: error + # Also proves the build is AVX2: a build that lost the flag would still pass every test, # and only cost more. Then the benchmark, which asserts nothing; a shared runner changes # hardware between runs, so compare figures only within one job. 48 kHz is the model's own diff --git a/CLAUDE.md b/CLAUDE.md index 4eaa63a..a7d1af5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -303,11 +303,19 @@ Things that are easy to get wrong here: - **Give every parameter a `stringFromValue`.** Without one JUCE prints the raw float, and a mix knob reads 0.3499999. A test in `StateTests.cpp` fails on more than one decimal anywhere. -**Look at the panel rather than reasoning about it.** An offline harness that renders the editor -with `createComponentSnapshot` to a PNG takes a couple of minutes to write and catches things no -amount of reading the paint code will: see the milestone 5 entry in `NOTES.md`. It caught four more -in the rework, including a knob that grew to fill a fifth of the window and a caption sitting a -hundred points away from the row it named. +**Look at the panel rather than reasoning about it.** `tools/PanelSnapshot.cpp` (`ampsim_snapshot`, +built with `-DAMPSIM_BUILD_TOOLS=ON`) renders each page of the real editor to a PNG with +`createComponentSnapshot`, and catches things no amount of reading the paint code will. The +Windows CI job runs it and uploads the images. A harness like it caught four problems in the +panel rework, including a knob that grew to fill a fifth of the window and a caption sitting a +hundred points away from the row it named, and the font change's "120 BP…". + +**The typefaces are embedded, and scaled.** Figtree (interface) and Jost (printed names) come from +`resources/fonts` through `EmbeddedTypefaces`, so the panel looks the same on every platform. +Every height on the panel was chosen for Avenir Next and Futura, which they replaced, so +`AmpLookAndFeel::font` and `stencil` scale each face to put its capitals where the old ones were. +Pass heights as before and leave the scale alone. A new face needs its own scale measured from +its file, cap height over ascent plus descent, not guessed. ## The user guide diff --git a/CMakeLists.txt b/CMakeLists.txt index a1dd024..78fa919 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -106,6 +106,20 @@ juce_add_binary_data(AmpSimAssets SOURCES set_target_properties(AmpSimAssets PROPERTIES POSITION_INDEPENDENT_CODE ON) +# The panel's two typefaces, embedded so it looks the same on every platform: Avenir Next and +# Futura ship with macOS only. Figtree and Jost are under the SIL Open Font License, whose text is +# beside them in resources/fonts; the .ttf files are static instances cut from the variable fonts +# in the google/fonts repository. Not packed: unlike the model, nothing about a font needs hiding. +juce_add_binary_data(AmpSimFonts + NAMESPACE EmbeddedFonts + HEADER_NAME EmbeddedFonts.h + SOURCES + resources/fonts/Figtree-Regular.ttf + resources/fonts/Figtree-Medium.ttf + resources/fonts/Jost-Medium.ttf) + +set_target_properties(AmpSimFonts PROPERTIES POSITION_INDEPENDENT_CODE ON) + target_sources(AmpSim PRIVATE src/PluginProcessor.cpp src/PluginEditor.cpp @@ -142,6 +156,7 @@ target_compile_definitions(AmpSim PUBLIC target_link_libraries(AmpSim PRIVATE AmpSimAssets + AmpSimFonts ${NAM_CORE_WHOLE} juce::juce_audio_utils juce::juce_dsp @@ -194,4 +209,15 @@ if(AMPSIM_BUILD_TOOLS) ${NAM_CORE_WHOLE} juce::juce_audio_utils juce::juce_dsp) + + # Renders each page of the panel to a PNG. See tools/PanelSnapshot.cpp. + add_executable(ampsim_snapshot tools/PanelSnapshot.cpp) + target_include_directories(ampsim_snapshot PRIVATE ${CMAKE_SOURCE_DIR}/src) + target_compile_definitions(ampsim_snapshot PRIVATE JUCE_MODAL_LOOPS_PERMITTED=1) + target_link_libraries(ampsim_snapshot + PRIVATE + AmpSim + ${NAM_CORE_WHOLE} + juce::juce_audio_utils + juce::juce_dsp) endif() diff --git a/README.md b/README.md index f84e6a3..faf273c 100644 --- a/README.md +++ b/README.md @@ -239,5 +239,9 @@ Third-party code, all as pinned submodules rather than vendored copies: | [Catch2](https://github.com/catchorg/Catch2) | BSL-1.0 | | VST3 SDK (bundled with JUCE) | GPLv3 or Steinberg's proprietary terms | +The panel's two typefaces are embedded in the binary: [Figtree](https://github.com/erikdkennedy/figtree) +and [Jost](https://github.com/indestructible-type/Jost), both under the +[SIL Open Font License 1.1](https://openfontlicense.org). The licence texts are in `resources/fonts`. + Amp captures and impulse responses carry their own licences, and some are captures of trademarked amplifiers — a personal build can use them; publishing a plugin with an amp's name on it cannot. diff --git a/docs/roadmap.md b/docs/roadmap.md index e98106a..1a4377b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -83,7 +83,9 @@ What was decided along the way, so it is not re-litigated: control is set". A single cold accent on near-black is what a dark plugin looks like when nobody designed it. - **Avenir Next for the interface, Futura for names printed on an object** — the wordmark, the - model on its plate, a pedal's name. Both ship with macOS. + model on its plate, a pedal's name. Both ship with macOS. *Since replaced by Figtree and Jost, + embedded and scaled to the same cap heights, so the Windows build looks the same; see + [`windows.md`](windows.md).* - **The amp became an object too**, not only the pedals: a chassis with the capture stamped on a plate and the five controls on a strip below it. - **The pedals are one left-to-right run with the amp drawn in the middle**, rather than two rows. diff --git a/docs/windows.md b/docs/windows.md index c5e359e..16f129c 100644 --- a/docs/windows.md +++ b/docs/windows.md @@ -179,7 +179,7 @@ In the order the first build is likely to find them: 4. **The fonts are not there.** `AmpLookAndFeel::font` asks for Avenir Next and `stencil` for Futura. Both ship with macOS, and neither is on Windows, so JUCE falls back to the default sans face. Every page lays out in fixed logical points, and different text metrics are where clipped - captions and labels in the wrong place come from. This needs a decision; see below. + captions and labels in the wrong place come from. **Done:** see Fonts below. 5. **The microphone privacy switch.** Windows 10 and 11 have *Settings → Privacy → Microphone → Let desktop apps access your microphone*. Unlike macOS, it needs nothing in the binary. But when it is off, the result is the same: the device appears and silence arrives. That belongs in the @@ -212,6 +212,26 @@ decided, not pre-decided. Avenir Next and Futura are commercial Linotype faces. Embedding them is not an option. +**Decided: embed Figtree for the interface and Jost for printed names, on every platform.** Both +are under the SIL Open Font License with no Reserved Font Name, which matters because the files +in `resources/fonts` are static Regular and Medium instances cut from the variable fonts in +google/fonts, and the OFL counts that as a modified version. The licence texts sit beside them. +The instances keep the copyright and licence in their name tables, which the OFL accepts as the +notice that has to travel with each copy. The installer should still ship the two licence texts. + +- **Every height on the panel was chosen for the old faces, so each new face is scaled to put its + capitals where theirs were.** A JUCE font height is the face's ascent plus descent, a different + share of the letters in every face. Measured from the files: Figtree's cap height is 0.583 of + that span against Avenir Next's 0.518, and Jost's 0.484 against Futura's 0.586. At those scales + Figtree's widths land within one per cent of Avenir's. Jost sets about six per cent wider than + Futura, which still fits everywhere it is used. Unscaled, the tempo reading was cut off as + "120 BP…" and every name on an object shrank. +- **Figtree is also the default sans-serif for the look-and-feel**, so what JUCE draws itself — + popup menus, the slider value bubble — no longer falls back to the system font. That is a + change on the Mac too. +- `tools/PanelSnapshot.cpp` renders each page to a PNG. It is what these were checked with, and + the Windows CI job uploads its output so the Windows panel can be looked at. + ### Low-latency audio in the standalone A JUCE standalone on Windows offers Windows Audio (WASAPI shared), Windows Audio exclusive mode, diff --git a/resources/fonts/Figtree-Medium.ttf b/resources/fonts/Figtree-Medium.ttf new file mode 100644 index 0000000000000000000000000000000000000000..c7116dee8718eefc10ed41a1bbfc756d42b71bb1 GIT binary patch literal 41992 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 literal 0 HcmV?d00001 diff --git a/resources/fonts/Figtree-Regular.ttf b/resources/fonts/Figtree-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..58a6e8d351f20e69e80e9a1710a8cab3ea22dd65 GIT binary patch literal 41988 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 literal 0 HcmV?d00001 diff --git a/resources/fonts/Jost-Medium.ttf b/resources/fonts/Jost-Medium.ttf new file mode 100644 index 0000000000000000000000000000000000000000..738e9d1e46475c9f19c3deda5a3abb87d485bc6a GIT binary patch literal 61008 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 Rojr{N9eyd;Vda0w_`fxDxq$!x literal 0 HcmV?d00001 diff --git a/resources/fonts/OFL-Figtree.txt b/resources/fonts/OFL-Figtree.txt new file mode 100644 index 0000000..80b12bb --- /dev/null +++ b/resources/fonts/OFL-Figtree.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Figtree Project Authors (https://github.com/erikdkennedy/figtree) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/resources/fonts/OFL-Jost.txt b/resources/fonts/OFL-Jost.txt new file mode 100644 index 0000000..85b6b99 --- /dev/null +++ b/resources/fonts/OFL-Jost.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Jost Project Authors (https://github.com/indestructible-type) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/ui/AmpLookAndFeel.cpp b/src/ui/AmpLookAndFeel.cpp index 4c262c4..c19cf4d 100644 --- a/src/ui/AmpLookAndFeel.cpp +++ b/src/ui/AmpLookAndFeel.cpp @@ -11,6 +11,8 @@ #include "AmpLookAndFeel.h" #include "ParameterSlider.h" +#include "EmbeddedFonts.h" + const juce::Colour AmpPalette::background { 0xff131519 }; const juce::Colour AmpPalette::bar { 0xff0e1013 }; const juce::Colour AmpPalette::surface { 0xff1b1e24 }; @@ -140,23 +142,46 @@ void AmpMaterials::drawPlate (juce::Graphics& g, juce::Rectangle bounds) } } -juce::Font AmpLookAndFeel::font (float height, bool medium) +EmbeddedTypefaces::EmbeddedTypefaces() + : interfaceRegular (juce::Typeface::createSystemTypefaceFor (EmbeddedFonts::FigtreeRegular_ttf, + (size_t) EmbeddedFonts::FigtreeRegular_ttfSize)), + interfaceMedium (juce::Typeface::createSystemTypefaceFor (EmbeddedFonts::FigtreeMedium_ttf, + (size_t) EmbeddedFonts::FigtreeMedium_ttfSize)), + stencil (juce::Typeface::createSystemTypefaceFor (EmbeddedFonts::JostMedium_ttf, + (size_t) EmbeddedFonts::JostMedium_ttfSize)) { - auto options = juce::FontOptions ("Avenir Next", height, juce::Font::plain); - - if (medium) - options = options.withStyle ("Medium"); +} - return juce::Font (options); +// A JUCE font height is the face's ascent plus descent, which takes up a different share of the +// letters in every face. Every height on the panel was chosen for Avenir Next and Futura, so each +// face is scaled to put its capitals where theirs were: Figtree's cap height is 0.583 of its +// span against Avenir's 0.518, Jost's 0.484 against Futura's 0.586. At those scales Figtree's +// widths also land within one per cent of Avenir's; Jost sets about six per cent wider than +// Futura. Measured from the font files with fontTools, OS/2 cap height over the ascent and +// descent JUCE uses. +static constexpr float interfaceScale = 0.518f / 0.583f; +static constexpr float stencilScale = 0.586f / 0.484f; + +// Each call takes the shared typefaces rather than holding them in a static: a static would +// outlive JUCE's own shutdown, and on Windows release them after DirectWrite has gone. While an +// AmpLookAndFeel exists this is a reference count, not a reload. +juce::Font AmpLookAndFeel::font (float height, bool medium) +{ + juce::SharedResourcePointer shared; + return juce::Font (juce::FontOptions (medium ? shared->interfaceMedium : shared->interfaceRegular) + .withHeight (height * interfaceScale)); } juce::Font AmpLookAndFeel::stencil (float height) { - return juce::Font (juce::FontOptions ("Futura", height, juce::Font::plain).withStyle ("Medium")); + juce::SharedResourcePointer shared; + return juce::Font (juce::FontOptions (shared->stencil).withHeight (height * stencilScale)); } AmpLookAndFeel::AmpLookAndFeel() { + setDefaultSansSerifTypeface (typefaces->interfaceRegular); + setColour (juce::Label::textColourId, AmpPalette::text); setColour (juce::TextButton::buttonColourId, AmpPalette::raised); setColour (juce::TextButton::textColourOffId, AmpPalette::text); diff --git a/src/ui/AmpLookAndFeel.h b/src/ui/AmpLookAndFeel.h index 433e962..b70d522 100644 --- a/src/ui/AmpLookAndFeel.h +++ b/src/ui/AmpLookAndFeel.h @@ -68,6 +68,15 @@ struct AmpMaterials static void drawPlate (juce::Graphics&, juce::Rectangle); }; +/** The panel's typefaces, loaded from the binary rather than asked of the system, so the panel + looks the same on every platform. Shared, and alive for as long as any AmpLookAndFeel is. */ +struct EmbeddedTypefaces +{ + EmbeddedTypefaces(); + + juce::Typeface::Ptr interfaceRegular, interfaceMedium, stencil; +}; + class AmpLookAndFeel final : public juce::LookAndFeel_V4 { public: @@ -89,12 +98,14 @@ class AmpLookAndFeel final : public juce::LookAndFeel_V4 juce::Font getLabelFont (juce::Label&) override; juce::Label* createSliderTextBox (juce::Slider&) override; - /** The interface's voice. Avenir Next: humanist, ships with macOS, and not the system font - every other plugin defaults to. */ + /** The interface's voice. Figtree, embedded: it was Avenir Next until there was a Windows + build, and Avenir ships with macOS only. Also the default for anything JUCE draws itself, + such as a popup menu, so nothing on the window falls back to the system font. */ static juce::Font font (float height, bool medium = false); - /** For names printed on an object — a pedal's, the plugin's. Futura reads as something - screen-printed onto metal, which is exactly what it is standing in for. */ + /** For names printed on an object — a pedal's, the plugin's. Jost, embedded, which is drawn + after Futura: it reads as something screen-printed onto metal, which is what it is + standing in for. */ static juce::Font stencil (float height); /** Where a knob takes its body and track colours from, so the same drawing serves a control @@ -111,4 +122,7 @@ class AmpLookAndFeel final : public juce::LookAndFeel_V4 static void drawKnob (juce::Graphics&, juce::Rectangle bounds, float proportion, float originProportion, juce::Colour bodyColour, juce::Colour trackColour, float ringThickness); + +private: + juce::SharedResourcePointer typefaces; }; diff --git a/tools/PanelSnapshot.cpp b/tools/PanelSnapshot.cpp new file mode 100644 index 0000000..7490829 --- /dev/null +++ b/tools/PanelSnapshot.cpp @@ -0,0 +1,111 @@ +/* + This file is part of AmpSim, a guitar amp simulator built on Neural Amp Modeler. + Copyright (C) 2026 Kimmo Fonsell + + AmpSim is free software: you can redistribute it and/or modify it under the terms of the GNU + Affero General Public License as published by the Free Software Foundation, either version 3 + of the License, or (at your option) any later version. See the LICENSE file, or + . +*/ + +/* + Renders each page of the panel to a PNG, so the panel can be looked at rather than reasoned + about. CLAUDE.md asks for that after any change to the panel. This is also how the Windows + build's panel gets seen without a Windows machine: CI runs it and uploads the images. + + ampsim_snapshot [output directory] [scale] + + It drives the real editor with the built-in amp and cab loaded, as a fresh instance has them, + and switches pages through the tab buttons' own click handlers. +*/ +#include "PluginEditor.h" +#include "PluginProcessor.h" + +#include + +namespace +{ + void runMessageLoop (int milliseconds) + { + juce::MessageManager::getInstance()->runDispatchLoopUntil (milliseconds); + } + + void waitForModel (AmpSimAudioProcessor& processor) + { + juce::AudioBuffer buffer (2, 512); + juce::MidiBuffer midi; + + for (int i = 0; i < 400 && ! processor.isModelLoaded(); ++i) + { + runMessageLoop (25); + buffer.clear(); + processor.processBlock (buffer, midi); + } + } + + void findTabs (juce::Component& parent, juce::Array& tabs) + { + for (auto* child : parent.getChildren()) + { + if (auto* tab = dynamic_cast (child)) + tabs.add (tab); + + findTabs (*child, tabs); + } + } +} + +int main (int argc, char** argv) +{ + juce::ScopedJuceInitialiser_GUI gui; + + const auto outputDirectory = argc > 1 ? juce::File::getCurrentWorkingDirectory().getChildFile (argv[1]) + : juce::File::getCurrentWorkingDirectory(); + const auto scale = argc > 2 ? juce::String (argv[2]).getFloatValue() : 2.0f; + + if (! outputDirectory.createDirectory()) + { + std::cerr << "Could not create " << outputDirectory.getFullPathName() << "\n"; + return 1; + } + + AmpSimAudioProcessor processor; + processor.prepareToPlay (48000.0, 512); + waitForModel (processor); + + std::unique_ptr editor (processor.createEditor()); + runMessageLoop (200); + + juce::Array tabs; + findTabs (*editor, tabs); + + if (tabs.isEmpty()) + { + std::cerr << "No tabs found on the editor\n"; + return 1; + } + + for (auto* tab : tabs) + { + tab->onClick(); + runMessageLoop (200); + + const auto image = editor->createComponentSnapshot (editor->getLocalBounds(), true, scale); + const auto file = outputDirectory.getChildFile ("panel-" + tab->getButtonText().toLowerCase() + ".png"); + + file.deleteFile(); + juce::FileOutputStream stream (file); + juce::PNGImageFormat png; + + if (! stream.openedOk() || ! png.writeImageToStream (image, stream)) + { + std::cerr << "Could not write " << file.getFullPathName() << "\n"; + return 1; + } + + std::cout << file.getFullPathName() << " (" << image.getWidth() << " x " << image.getHeight() << ")\n"; + } + + editor.reset(); + return 0; +} From 4002c81d4d2cec470f20b4fc34e17c4d5810bd90 Mon Sep 17 00:00:00 2001 From: Kimmo Fonsell Date: Sat, 3 Oct 2026 00:26:23 +0300 Subject: [PATCH 16/25] Record the Windows panel renders matching the Mac's Co-Authored-By: Claude Opus 5.5 --- docs/windows.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/windows.md b/docs/windows.md index 16f129c..28bddcf 100644 --- a/docs/windows.md +++ b/docs/windows.md @@ -231,6 +231,9 @@ notice that has to travel with each copy. The installer should still ship the tw change on the Mac too. - `tools/PanelSnapshot.cpp` renders each page to a PNG. It is what these were checked with, and the Windows CI job uploads its output so the Windows panel can be looked at. +- **Checked on Windows.** The CI renders of all three pages, at 1.5x on the runner, match the Mac + renders: the same faces, nothing cut off, and every caption and reading in the same place. The + only differences are in antialiasing. ### Low-latency audio in the standalone From 027d08a2c91393322ec17f13678e6e7164c7c0d2 Mon Sep 17 00:00:00 2001 From: Kimmo Fonsell Date: Sat, 3 Oct 2026 00:39:45 +0300 Subject: [PATCH 17/25] Plan the Windows installer with Inno Setup Co-Authored-By: Claude Opus 5.5 --- docs/windows.md | 114 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/docs/windows.md b/docs/windows.md index 28bddcf..0fb8679 100644 --- a/docs/windows.md +++ b/docs/windows.md @@ -268,6 +268,8 @@ has no ad-hoc signature to fall back on, so the choice is between signing and no | Inno Setup | Free, scriptable, and on GitHub's Windows runner images. Can offer per-user or all-users installs | One more script to maintain beside `package.sh` | | WiX / MSI | What enterprise deployment expects | The most work, for an audience this project does not obviously have | +**Decided: Inno Setup.** The plan for it is under *Packaging with Inno Setup* below. + ### Architecture x64 is the default target and covers nearly all Windows audio machines. Windows on ARM (Snapdragon @@ -315,3 +317,115 @@ it. The guide should state the requirement too. The options that were weighed: Steps 1 to 4 are mechanical and can be done without a decision. Step 5 onward waits on the choices above. + +## Packaging with Inno Setup + +The plan for step 7. Nothing in it is built yet. It mirrors `packaging/package.sh` where the two +platforms ask the same question, and says so where they differ. + +### What it produces + +`AmpSim--windows-x64-setup.exe`: one installer, built by `ISCC.exe` from a script at +`packaging/windows/AmpSim.iss`. A `packaging/package-windows.ps1` drives it, the counterpart of +`package.sh`: build Release, stage, run ISCC, write to `build/artefacts`. The version comes out +of `CMakeLists.txt` with the same pattern `package.sh` and the release step use. It is passed to +ISCC as `/DAppVersion=…`, so the installer, the file name and the tag cannot disagree. The +`windows-latest` image ships Inno Setup 6.7.1, so CI installs nothing. + +### What it installs + +| Component | Where (all users) | Where (current user) | Selectable | +| --- | --- | --- | --- | +| VST3 | `{commoncf64}\VST3\AmpSim.vst3`, i.e. `C:\Program Files\Common Files\VST3` | `{localappdata}\Programs\Common\VST3\AmpSim.vst3` | yes | +| Standalone | `{autopf}\AmpSim\AmpSim.exe`, with a Start menu entry | the same under `{autopf}`, which Inno maps to the user's own programs folder | yes | +| Licences | `{app}\licenses\`: `LICENSE` (AGPL), `OFL-Figtree.txt`, `OFL-Jost.txt` | the same | no | + +Both components are on by default and each can be unticked. That is the same split the macOS +`.pkg` offers, for the same reason: plenty of people want the plugin without the standalone. + +The VST3 is copied as the whole bundle directory (`Contents\x86_64-win\AmpSim.vst3`, +`Contents\Resources\moduleinfo.json`), never as the inner file alone. VST3 hosts look for the +bundle. + +The bundled amp model and cab IR are **not** the installer's business. The plugin writes them to +`%APPDATA%\AmpSim\Bundled` on first run, as it does on macOS, and presets go beside them. + +### Fixed points, not decisions + +- **One `AppId` GUID, generated once and never changed.** Inno recognises an upgrade by it, so a + new version installs over the old one in place. Changing it later makes two entries in + *Installed apps*, the same mistake as changing a parameter ID. +- **`ArchitecturesAllowed=x64compatible` and `ArchitecturesInstallIn64BitMode=x64compatible`.** + The build is x64 only. On Windows on ARM this installs the x64 build, which runs only in an x64 + host. +- **`CloseApplications=yes`.** A running standalone, or a host with the VST3 loaded, would + otherwise leave the old DLL locked and the upgrade half done. +- **Uninstalling leaves `%APPDATA%\AmpSim` alone.** Presets are the user's work. That is also + what the macOS uninstall instructions do. +- **The installer links to the source.** The AGPL obliges anyone distributing a binary to offer + the corresponding source. `AppURL` and the finished page point at the GitHub repository and the + tag, and `LICENSE` is the licence page the installer shows first. + +### What has to be done in the code first + +1. **The C++ runtime.** JUCE leaves CMake's default, the DLL runtime (`/MD`), so the standalone + and the VST3 need `VCRUNTIME140.dll` and its siblings. Many Windows machines have them from + other software, but not all, and a plugin that cannot find them fails to load in the host with + no useful message. There are two ways out: + + | Option | For | Against | + | --- | --- | --- | + | Link the runtime statically: `CMAKE_MSVC_RUNTIME_LIBRARY` set to `MultiThreaded` | Nothing to install alongside; the usual choice for audio plugins; one line in `CMakeLists.txt` | Each binary carries its own copy, about a few hundred KB more. Every target must agree, NAM and Catch2 included, which a global setting gives | + | Ship and run `vc_redist.x64.exe` from the installer | The standard Microsoft route | The installer needs administrator rights for it even on a per-user install. Larger download. The redistributable has its own licence terms to follow | + +2. **The AVX2 check.** The build needs AVX2, and without it the plugin crashes the host, so the + installer must refuse first. Inno's Pascal script can call `IsProcessorFeaturePresent` from + `kernel32.dll` with `PF_AVX2_INSTRUCTIONS_AVAILABLE` (40) in `InitializeSetup`, and stop with a + message that names the requirement. **To verify:** which Windows 10 builds answer that feature + number at all. An older build that returns false on an AVX2 CPU would refuse a machine that + could run the plugin. If that turns out to matter, the fallback is a tiny helper executable, + built with the project, that asks `cpuid` and `xgetbv` directly and is run from the script. + Windows on ARM emulates x64, and whether its emulator reports AVX2 to an x64 process is a + second thing to check there. + +### Decisions still open + +| Decision | Options | +| --- | --- | +| Who it installs for | **All users only** (needs administrator rights; one VST3 location every host scans). **Per-user only** (no prompt; uses the per-user VST3 folder, which hosts that follow the VST3 spec scan, but some older hosts may not). **Ask** (`PrivilegesRequiredOverridesAllowed=dialog`; both paths have to be tested) | +| Signing | Unchanged from *Signing and the first-launch warning* above. Inno's `SignTool` directive signs the installer and the uninstaller; the `.vst3` and `.exe` inside are signed separately, before ISCC runs. Unsigned works, with SmartScreen's warning on first run | +| A desktop shortcut for the standalone | Off, on, or offered as a checkbox | + +### The release workflow + +At present the macOS job publishes the GitHub Release itself. With two platforms that has to +move: + +- The Windows job runs `package-windows.ps1` and uploads the installer as an artefact, on every + run, as the macOS job does with its `.pkg` and `.dmg`. +- A third job, `release`, runs on `v*` tags only. It needs both jobs, downloads both artefacts, + checks the tag against `CMakeLists.txt` once, and creates the release with all three files. + Moving the check and the publish there means a tag cannot publish one platform when the other + failed. +- The release notes stop saying "for Macs with Apple silicon". They name both platforms and + their requirements: macOS 11 on Apple silicon, Windows 10 or 11 on x64 with an AVX2 CPU. + +### Documentation that changes with it + +- `packaging/README.md` gains a Windows half: SmartScreen's *More info → Run anyway* if unsigned, + the AVX2 requirement, where the two components went, and the Windows microphone privacy switch. +- The user guide's install section, and its system requirements. +- `CHANGELOG.md`: Windows support, the font change on both platforms, and the resampler fix. +- `CLAUDE.md`: the scope line that lists Windows builds as deliberately out, the CI and release + description, and the packaging paragraph. + +### Order + +1. Settle the C++ runtime, and check the CI artefact on a Windows machine without the + redistributable (step 6 can do both). +2. Settle who it installs for. +3. Write `AmpSim.iss` and `package-windows.ps1`, with the AVX2 check, and build the installer in + CI as an artefact. +4. Install, upgrade and uninstall it by hand in a VM. Check that REAPER finds the VST3 at + whichever location was chosen, and that the AVX2 check behaves. +5. Split the release out into its own job, then tag. From 034564b389e749034ad1ccf68e2bcc18d8cd8226 Mon Sep 17 00:00:00 2001 From: Kimmo Fonsell Date: Sat, 3 Oct 2026 00:44:08 +0300 Subject: [PATCH 18/25] Build a Windows installer with Inno Setup, and link the runtime statically The C++ runtime is now linked statically on Windows, so neither the standalone nor the VST3 needs the Visual C++ redistributable; the packaging script refuses a binary that still imports it. packaging/windows/AmpSim.iss asks whether to install for everyone or for the current user, and puts the VST3 in the system or the per-user VST3 folder to match. It refuses a processor without AVX2, since the build would crash its host there, and requires Windows 10 2004, the first version that answers that question. The VST3 and the standalone are separate components, the licences always go with them, and uninstalling leaves the user's presets. packaging/package-windows.ps1 builds, checks and stages, and runs ISCC with the version read from CMakeLists.txt. CI builds the installer on every run, installs it both ways, installs it again over itself, and uninstalls it. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 48 +++++++++++- CMakeLists.txt | 7 ++ docs/windows.md | 25 ++++-- packaging/README.md | 65 ++++++++++++++++ packaging/package-windows.ps1 | 95 +++++++++++++++++++++++ packaging/windows/AmpSim.iss | 140 ++++++++++++++++++++++++++++++++++ 6 files changed, 373 insertions(+), 7 deletions(-) create mode 100644 packaging/package-windows.ps1 create mode 100644 packaging/windows/AmpSim.iss diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9559804..b3a187a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -84,7 +84,7 @@ jobs: ./build/ampsim_bench.exe 48000 ./build/ampsim_bench.exe 44100 - # Not an installer: the raw build, for trying by hand until packaging is decided. + # The raw build as well as the installer, for trying by hand without installing anything. - uses: actions/upload-artifact@v4 with: name: AmpSim-Windows @@ -93,6 +93,52 @@ jobs: build/AmpSim_artefacts/Release/VST3/AmpSim.vst3 if-no-files-found: error + # Reuses the build above, as the macOS job does; it also refuses a binary that imports the + # DLL C++ runtime, which nothing else here would notice. + - name: Package + run: ./packaging/package-windows.ps1 -BuildDir build + + # Both ways the installer can be run, each installed twice so the second is an upgrade over + # the first, then uninstalled. Start-Process -Wait because the installer is a GUI program, + # which PowerShell otherwise does not wait for. The AVX2 refusal cannot be tested here: the + # runner has AVX2. + - name: Install, upgrade and uninstall + run: | + $setup = (Get-ChildItem build/artefacts/*-setup.exe).FullName + function Run($file, $arguments) { + $p = Start-Process -FilePath $file -ArgumentList $arguments -Wait -PassThru + if ($p.ExitCode -ne 0) { throw "$file $arguments exited with $($p.ExitCode)" } + } + $cases = @( + @{ Mode = '/ALLUSERS'; Vst3 = "$env:CommonProgramFiles\VST3\AmpSim.vst3"; App = "$env:ProgramFiles\AmpSim" }, + @{ Mode = '/CURRENTUSER'; Vst3 = "$env:LOCALAPPDATA\Programs\Common\VST3\AmpSim.vst3"; App = "$env:LOCALAPPDATA\Programs\AmpSim" } + ) + foreach ($c in $cases) { + "=== $($c.Mode)" + foreach ($pass in 'install', 'upgrade') { + Run $setup "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART $($c.Mode) /LOG=`"$PWD\$pass.log`"" + foreach ($path in "$($c.Vst3)\Contents\x86_64-win\AmpSim.vst3", "$($c.App)\AmpSim.exe", + "$($c.App)\licenses\LICENSE-AmpSim.txt", "$($c.App)\licenses\OFL-Jost.txt") { + if (-not (Test-Path $path)) { Get-Content "$pass.log" -Tail 40; throw "$pass did not install $path" } + } + "$pass ok" + } + Run "$($c.App)\unins000.exe" "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART" + # The uninstaller hands over to a copy of itself and exits, so wait on the files. + $deadline = (Get-Date).AddSeconds(60) + while ((Test-Path $c.Vst3) -or (Test-Path "$($c.App)\AmpSim.exe")) { + if ((Get-Date) -gt $deadline) { throw "$($c.Mode): still installed 60 s after uninstalling" } + Start-Sleep -Seconds 1 + } + "uninstall ok" + } + + - uses: actions/upload-artifact@v4 + with: + name: AmpSim-Windows-installer + path: build/artefacts/*-setup.exe + if-no-files-found: error + macos: name: macOS (Release) # macos-latest rather than a pinned version: GitHub retires old runner images, and a pinned diff --git a/CMakeLists.txt b/CMakeLists.txt index 78fa919..e1d303c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -24,6 +24,13 @@ if(MSVC) add_compile_options(/arch:AVX2) endif() +# The C++ runtime linked statically on Windows (/MT), so the standalone and the VST3 need nothing +# installed beside them. CMake's default is the DLL runtime, and a plugin that cannot find +# VCRUNTIME140.dll fails to load in the host with no useful message. A variable rather than a +# per-target property so every target agrees, NAM and Catch2 included; mixing runtimes does not +# link. packaging/package-windows.ps1 refuses a binary that still imports the DLL runtime. +set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") + # --- Neural Amp Modeler Core ------------------------------------------------- # Built here rather than via NAM's own CMakeLists, which sets global compiler flags and builds # its tools and tests. Sources are globbed the way nam-juce does it. diff --git a/docs/windows.md b/docs/windows.md index 0fb8679..1cd1d16 100644 --- a/docs/windows.md +++ b/docs/windows.md @@ -320,7 +320,7 @@ above. ## Packaging with Inno Setup -The plan for step 7. Nothing in it is built yet. It mirrors `packaging/package.sh` where the two +The plan for step 7. The installer script, its build script and CI's install test exist; the release job does not. It mirrors `packaging/package.sh` where the two platforms ask the same question, and says so where they differ. ### What it produces @@ -388,13 +388,26 @@ The bundled amp model and cab IR are **not** the installer's business. The plugi Windows on ARM emulates x64, and whether its emulator reports AVX2 to an x64 process is a second thing to check there. -### Decisions still open - -| Decision | Options | +### Decided since + +- **The C++ runtime is linked statically** (`CMAKE_MSVC_RUNTIME_LIBRARY` in `CMakeLists.txt`). + `package-windows.ps1` runs `dumpbin /dependents` on both binaries and refuses to package one + that imports `VCRUNTIME`, `MSVCP` or the UCRT. +- **The installer asks who to install for** (`PrivilegesRequired=lowest` with + `PrivilegesRequiredOverridesAllowed=dialog`). For one user is the default, because it needs no + administrator. CI installs both ways. +- **The AVX2 check is the API, with a minimum Windows version.** Microsoft documents + `PF_AVX2_INSTRUCTIONS_AVAILABLE` as answered from Windows 10 version 2004 (build 19041); before + that it returns zero whatever the processor has. So `MinVersion=10.0.19041`, and no helper + executable is needed. Windows 10 releases before 2004 are long out of support. +- **The desktop shortcut is an unticked checkbox**, Inno Setup's usual form. It is shown only + when the standalone is being installed. + +These were chosen at the time: | --- | --- | -| Who it installs for | **All users only** (needs administrator rights; one VST3 location every host scans). **Per-user only** (no prompt; uses the per-user VST3 folder, which hosts that follow the VST3 spec scan, but some older hosts may not). **Ask** (`PrivilegesRequiredOverridesAllowed=dialog`; both paths have to be tested) | +| ~~Who it installs for~~ | **All users only** (needs administrator rights; one VST3 location every host scans). **Per-user only** (no prompt; uses the per-user VST3 folder, which hosts that follow the VST3 spec scan, but some older hosts may not). **Ask** (`PrivilegesRequiredOverridesAllowed=dialog`; both paths have to be tested) | | Signing | Unchanged from *Signing and the first-launch warning* above. Inno's `SignTool` directive signs the installer and the uninstaller; the `.vst3` and `.exe` inside are signed separately, before ISCC runs. Unsigned works, with SmartScreen's warning on first run | -| A desktop shortcut for the standalone | Off, on, or offered as a checkbox | +| ~~A desktop shortcut for the standalone~~ | Off, on, or offered as a checkbox | ### The release workflow diff --git a/packaging/README.md b/packaging/README.md index f67cc18..3bb3d35 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -79,3 +79,68 @@ to it. With both, the first-launch dialog disappears. The changes would be to `p `codesign --sign -` calls take the certificate name and `--options runtime`, `productbuild` gains `--sign "Developer ID Installer: ..."`, and `xcrun notarytool submit --wait` plus `xcrun stapler staple` run at the end. + +# On Windows + +`AmpSim--windows-x64-setup.exe` installs the VST3 plug-in and the standalone application. +Either can be left out on the *Select Components* page. + +## What it needs + +- **Windows 10 version 2004 (May 2020) or later, or Windows 11, on x64.** On Windows on ARM it + installs the x64 build, which loads only in an x64 host. +- **A processor with AVX2**: an Intel Core from 2013 (Haswell) on, or any AMD Ryzen. The plugin is + built for it and would crash its host on a processor without it, so the installer checks first + and refuses with a message rather than installing something that cannot run. + +## For everyone, or just for you + +The installer asks first. + +| | VST3 goes to | The standalone goes to | Needs | +| --- | --- | --- | --- | +| Install for all users | `C:\Program Files\Common Files\VST3` | `C:\Program Files\AmpSim` | an administrator's approval | +| Install for me only | `%LOCALAPPDATA%\Programs\Common\VST3` | `%LOCALAPPDATA%\Programs\AmpSim` | nothing | + +Both are folders the VST3 specification tells hosts to scan. If a host does not find the plug-in +after an install for one user, it is one that only looks in the system folder: add the per-user +folder to its plug-in paths, or reinstall for all users. + +Presets, and the built-in amp model and cabinet the plug-in writes out on first run, live in +`%APPDATA%\AmpSim`. Uninstalling leaves them there, as it does on macOS. + +## What you will see the first time + +The installer is not signed, so Microsoft Defender SmartScreen stops it with *Windows protected +your PC*. Choose **More info**, then **Run anyway**. As with Gatekeeper's *Open Anyway*, that +records that *you* have decided to trust it; it verifies nothing about who built it. Some managed +machines block unsigned installers outright, and then there is no way past it but an administrator. + +## No sound in the standalone + +Two Windows settings produce silence while the input device looks fine: + +- **Settings → Privacy & security → Microphone → Let desktop apps access your microphone** must be + on. Unlike macOS, Windows never asks: the device appears and nothing arrives. +- The standalone mutes its input by default, the same as on macOS. Untick it in the *Options* + dialog. + +## Building the installer + +From a Developer PowerShell for Visual Studio, with Inno Setup 6 installed: + +```powershell +./packaging/package-windows.ps1 +``` + +It builds Release into `build-release`, checks that neither binary imports the DLL C++ runtime, +stages them, and runs Inno Setup's compiler on `packaging/windows/AmpSim.iss`. The installer lands +in `build-release/artefacts`. CI does the same on every push, then installs it both ways, installs +it again over itself, and uninstalls it. + +## If it is signed later + +Signing removes the SmartScreen warning once a certificate has built up a reputation, or at once +for an EV certificate or Microsoft's Trusted Signing. The `.vst3` and the `.exe` are signed with +`signtool` in `package-windows.ps1` before staging, and a `SignTool=` line in `AmpSim.iss` +signs the installer and the uninstaller that Inno Setup writes. diff --git a/packaging/package-windows.ps1 b/packaging/package-windows.ps1 new file mode 100644 index 0000000..2cb3955 --- /dev/null +++ b/packaging/package-windows.ps1 @@ -0,0 +1,95 @@ +# Builds AmpSim in Release and packages it for installation on another Windows machine, the +# counterpart of package.sh. Run it from a Developer PowerShell for Visual Studio, which puts MSVC +# and dumpbin on the PATH. Inno Setup 6 must be installed; GitHub's Windows runner image has it. +# +# ./packaging/package-windows.ps1 [-BuildDir build-release] +# +# The installer lands in /artefacts. It is not signed, so SmartScreen warns on first run; +# packaging/README.md says how to get past that and what signing would change. + +param( + # A caller that has already built Release, CI say, points this at that tree instead of paying + # for a second full build. + [string] $BuildDir = "build-release" +) + +$ErrorActionPreference = 'Stop' + +$root = Split-Path -Parent $PSScriptRoot +$build = if ([System.IO.Path]::IsPathRooted($BuildDir)) { $BuildDir } else { Join-Path $root $BuildDir } +$staging = Join-Path $build "package" +$out = Join-Path $build "artefacts" + +# The same pattern package.sh and the release step use, so the three cannot disagree about which +# version this is. +$cmakeLists = Get-Content (Join-Path $root "CMakeLists.txt") -Raw +if ($cmakeLists -notmatch '(?m)^project\(AmpSim VERSION ([0-9.]+)') { + throw "Could not read the version out of CMakeLists.txt" +} +$version = $Matches[1] +"==> AmpSim $version" + +# Any native command that fails ends the script, the way set -e does in package.sh. +function Invoke-Checked { + & $args[0] $args[1..($args.Count - 1)] + if ($LASTEXITCODE -ne 0) { throw "$($args[0]) failed with exit code $LASTEXITCODE" } +} + +# --- build ------------------------------------------------------------------------------------- + +Invoke-Checked cmake -S $root -B $build -G Ninja -DCMAKE_BUILD_TYPE=Release ` + -DAMPSIM_BUILD_TESTS=OFF -DAMPSIM_COPY_PLUGIN=OFF | Out-Null +Invoke-Checked cmake --build $build --target AmpSim_VST3 AmpSim_Standalone | Out-Null + +$artefacts = Join-Path $build "AmpSim_artefacts/Release" +$vst3 = Join-Path $artefacts "VST3/AmpSim.vst3" +$exe = Join-Path $artefacts "Standalone/AmpSim.exe" + +# --- check ------------------------------------------------------------------------------------- + +# The C++ runtime is linked statically (CMakeLists.txt), so neither binary should import it. One +# that does would load on the machine that built it and fail on a machine without the Visual C++ +# redistributable, which is not something any test here would see. +if (-not (Get-Command dumpbin -ErrorAction SilentlyContinue)) { + throw "dumpbin is not on the PATH: run this from a Developer PowerShell for Visual Studio" +} +foreach ($binary in @($exe, (Join-Path $vst3 "Contents/x86_64-win/AmpSim.vst3"))) { + $imports = & dumpbin /nologo /dependents $binary + $runtime = $imports | Select-String -Pattern 'VCRUNTIME|MSVCP|ucrtbase|api-ms-win-crt' + if ($runtime) { + throw "$binary imports the DLL runtime, which the installer does not provide:`n$($runtime -join "`n")" + } + "==> $(Split-Path -Leaf $binary) links the C++ runtime statically" +} + +# --- stage ------------------------------------------------------------------------------------- + +Remove-Item -Recurse -Force $staging, $out -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force (Join-Path $staging "VST3"), (Join-Path $staging "Standalone"), $out | Out-Null +Copy-Item -Recurse $vst3 (Join-Path $staging "VST3") +Copy-Item $exe (Join-Path $staging "Standalone") + +# --- package ----------------------------------------------------------------------------------- + +$iscc = Get-Command ISCC.exe -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty Source -First 1 +if (-not $iscc) { + $iscc = Join-Path ${env:ProgramFiles(x86)} "Inno Setup 6/ISCC.exe" +} +if (-not (Test-Path $iscc)) { throw "Inno Setup 6 (ISCC.exe) was not found" } + +$defines = @( + "/DAppVersion=$version", + "/DRepoRoot=$root", + "/DStageDir=$staging", + "/DOutputDir=$out" +) + +# JUCE generates the application icon from resources/icon-*.png; the installer wears the same one. +$icon = Join-Path $build "AmpSim_artefacts/JuceLibraryCode/icon.ico" +if (Test-Path $icon) { $defines += "/DIconFile=$icon" } + +"==> building the installer" +Invoke-Checked $iscc /Q @defines (Join-Path $root "packaging/windows/AmpSim.iss") + +Get-ChildItem $out | ForEach-Object { "==> $($_.FullName)" } diff --git a/packaging/windows/AmpSim.iss b/packaging/windows/AmpSim.iss new file mode 100644 index 0000000..8d398dd --- /dev/null +++ b/packaging/windows/AmpSim.iss @@ -0,0 +1,140 @@ +; The Windows installer, compiled by Inno Setup 6's ISCC. Run packaging/package-windows.ps1 +; rather than this directly: it builds Release, stages the binaries and passes the defines below, +; the version among them, read out of CMakeLists.txt the way the macOS packaging reads it. +; +; It asks at the start whether to install for everyone (administrator rights, the system VST3 +; folder) or for the current user only (no prompt, the per-user VST3 folder). docs/windows.md has +; the reasoning behind each choice here. + +#ifndef AppVersion + #error AppVersion is not defined: run packaging/package-windows.ps1 +#endif +#ifndef RepoRoot + #error RepoRoot is not defined: run packaging/package-windows.ps1 +#endif +#ifndef StageDir + #error StageDir is not defined: run packaging/package-windows.ps1 +#endif +#ifndef OutputDir + #error OutputDir is not defined: run packaging/package-windows.ps1 +#endif + +#define RepoURL "https://github.com/CatastrophicCoder/ampsim" + +[Setup] +; Never change AppId. Inno recognises an upgrade by it, so a new version installs over the old one; +; a new id would put two AmpSims in Installed apps, the installer's equivalent of renaming a +; parameter ID. +AppId={{6EA8C743-B761-4524-BEDA-A20BAA128F6B} +AppName=AmpSim +AppVersion={#AppVersion} +AppVerName=AmpSim {#AppVersion} +AppPublisher=Catastrophic Audio +AppPublisherURL={#RepoURL} +AppSupportURL={#RepoURL}/issues +AppUpdatesURL={#RepoURL}/releases +VersionInfoVersion={#AppVersion} + +; Asks: everyone, or just me. Per-user is the default because it needs no administrator rights. +PrivilegesRequired=lowest +PrivilegesRequiredOverridesAllowed=dialog + +; {autopf} is Program Files for everyone and %LOCALAPPDATA%\Programs for one user. +DefaultDirName={autopf}\AmpSim +DisableProgramGroupPage=yes + +; The build is x64 only. On Windows on ARM this installs the x64 build, which loads only in an x64 +; host. 10.0.19041 is Windows 10 2004: the first version whose IsProcessorFeaturePresent answers +; for AVX2 at all, which the check in [Code] depends on. +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +MinVersion=10.0.19041 + +; A running standalone, or a host with the plugin loaded, holds the old binary open. +CloseApplications=yes +RestartApplications=no + +; The AGPL is the licence the user agrees to, and the one that obliges an offer of the source. +LicenseFile={#RepoRoot}\LICENSE +#ifdef IconFile +SetupIconFile={#IconFile} +#endif +UninstallDisplayIcon={uninstallexe} +WizardStyle=modern + +OutputDir={#OutputDir} +OutputBaseFilename=AmpSim-{#AppVersion}-windows-x64-setup +Compression=lzma2/max +SolidCompression=yes + +; Not signed yet, so SmartScreen warns on first run; packaging/README.md says how to get past it. +; Signing goes here as a SignTool= line once a certificate exists, and the .vst3 and .exe have to be +; signed before ISCC runs, in package-windows.ps1. + +[Types] +Name: "full"; Description: "The plug-in and the standalone application" +Name: "custom"; Description: "Choose"; Flags: iscustom + +[Components] +; The same split as the macOS installer: plenty of people want the plugin and not the standalone. +Name: "vst3"; Description: "VST3 plug-in"; Types: full custom +Name: "standalone"; Description: "Standalone application"; Types: full custom + +[Tasks] +Name: "desktopicon"; Description: "Create a desktop shortcut for the standalone"; Components: standalone; Flags: unchecked + +[InstallDelete] +; The bundle is replaced whole on upgrade, so nothing an older version shipped is left inside it. +Type: filesandordirs; Name: "{code:Vst3Dir}\AmpSim.vst3"; Components: vst3 + +[Files] +; A VST3 is a folder, and hosts look for the folder: copy all of it, never the inner DLL alone. +Source: "{#StageDir}\VST3\AmpSim.vst3\*"; DestDir: "{code:Vst3Dir}\AmpSim.vst3"; Components: vst3; Flags: recursesubdirs createallsubdirs ignoreversion +Source: "{#StageDir}\Standalone\AmpSim.exe"; DestDir: "{app}"; Components: standalone; Flags: ignoreversion + +; Always installed. The two fonts are embedded in both binaries, and the OFL asks for its text to +; travel with them. +Source: "{#RepoRoot}\LICENSE"; DestDir: "{app}\licenses"; DestName: "LICENSE-AmpSim.txt"; Flags: ignoreversion +Source: "{#RepoRoot}\resources\fonts\OFL-Figtree.txt"; DestDir: "{app}\licenses"; Flags: ignoreversion +Source: "{#RepoRoot}\resources\fonts\OFL-Jost.txt"; DestDir: "{app}\licenses"; Flags: ignoreversion + +[Icons] +Name: "{autoprograms}\AmpSim"; Filename: "{app}\AmpSim.exe"; Components: standalone +Name: "{autodesktop}\AmpSim"; Filename: "{app}\AmpSim.exe"; Tasks: desktopicon + +; Presets and the unpacked model live in %APPDATA%\AmpSim, which is the user's work and is left +; alone on uninstall, as it is on macOS. Nothing here lists it. + +[Messages] +FinishedLabel=Setup has finished installing [name] on your computer.%n%nAmpSim is free software under the GNU Affero General Public License v3. The source code for this version is at {#RepoURL}/tree/v{#AppVersion}. + +[Code] +// The build is compiled for AVX2 (CMakeLists.txt). On a processor without it the plugin does not +// fail to load: it faults on the first such instruction and takes the host down with it. So refuse +// here, before anything is installed, and say why. +const + PF_AVX2_INSTRUCTIONS_AVAILABLE = 40; + +function IsProcessorFeaturePresent(ProcessorFeature: Cardinal): Integer; + external 'IsProcessorFeaturePresent@kernel32.dll stdcall'; + +function InitializeSetup(): Boolean; +begin + Result := IsProcessorFeaturePresent(PF_AVX2_INSTRUCTIONS_AVAILABLE) <> 0; + + if not Result then + SuppressibleMsgBox('AmpSim needs a processor with AVX2: an Intel Core from 2013 (Haswell) or ' + + 'later, or any AMD Ryzen. This computer''s processor does not have it, so ' + + 'AmpSim would crash rather than run, and has not been installed.', + mbCriticalError, MB_OK, IDOK); +end; + +// The system VST3 folder for everyone, the per-user one otherwise. Both are where the VST3 +// specification says a host looks; an install for one user needs no administrator rights. +function Vst3Dir(Param: String): String; +begin + if IsAdminInstallMode then + Result := ExpandConstant('{commoncf64}\VST3') + else + Result := ExpandConstant('{localappdata}\Programs\Common\VST3'); +end; From f4d67457812fad8e7876ee308242e90024fec5fe Mon Sep 17 00:00:00 2001 From: Kimmo Fonsell Date: Sat, 3 Oct 2026 01:07:15 +0300 Subject: [PATCH 19/25] Record the first installer run in CI Co-Authored-By: Claude Opus 5.5 --- docs/windows.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/windows.md b/docs/windows.md index 1cd1d16..3fe24f6 100644 --- a/docs/windows.md +++ b/docs/windows.md @@ -403,6 +403,13 @@ The bundled amp model and cab IR are **not** the installer's business. The plugi - **The desktop shortcut is an unticked checkbox**, Inno Setup's usual form. It is shown only when the standalone is being installed. +**First CI run of all of it: passed.** The whole build links with the static runtime, `dumpbin` +finds no runtime DLL in either binary, ISCC built `AmpSim-0.3.0-windows-x64-setup.exe`, and the +install, the upgrade over it, and the uninstall all passed for all users and for the current +user. Still untested anywhere: the AVX2 refusal (every runner has AVX2), the interactive +who-for dialog (CI passes `/ALLUSERS` and `/CURRENTUSER` instead), and whether a host finds the +per-user VST3 folder. + These were chosen at the time: | --- | --- | | ~~Who it installs for~~ | **All users only** (needs administrator rights; one VST3 location every host scans). **Per-user only** (no prompt; uses the per-user VST3 folder, which hosts that follow the VST3 spec scan, but some older hosts may not). **Ask** (`PrivilegesRequiredOverridesAllowed=dialog`; both paths have to be tested) | From d4231263bf6912586d193bb24222c6f1d156f200 Mon Sep 17 00:00:00 2001 From: Kimmo Fonsell Date: Sun, 4 Oct 2026 00:05:51 +0300 Subject: [PATCH 20/25] Process a block larger than announced in pieces, rather than overrunning JUCE's documentation for prepareToPlay says hosts may exceed the block size it announces. Every buffer in the chain is sized to that announcement, so a bigger block wrote past the end of them: a test that announces 64 samples and sends 1024 crashed with a segmentation fault, after an assertion in the drive pedal's oversampler. This is the likely cause of the corrupted output heard with the Windows standalone in WASAPI exclusive mode. processBlock now takes an oversized block in pieces no larger than it was prepared for. The metronome offsets the host's position by where each piece starts, so it stays on the grid. Co-Authored-By: Claude Opus 5.5 --- docs/windows.md | 28 +++++++++++++++++++++ src/PluginProcessor.cpp | 27 +++++++++++++++++++- src/PluginProcessor.h | 4 +++ tests/ModelTests.cpp | 56 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 114 insertions(+), 1 deletion(-) diff --git a/docs/windows.md b/docs/windows.md index 3fe24f6..fb6a6f6 100644 --- a/docs/windows.md +++ b/docs/windows.md @@ -318,6 +318,34 @@ it. The guide should state the requirement too. The options that were weighed: Steps 1 to 4 are mechanical and can be done without a decision. Step 5 onward waits on the choices above. +## Tried by hand: step 6 + +On a laptop with an Intel i5-8350U (a 15 W part with AVX2) and an Audient iD4 mkII, from the CI +installer: + +- **The installer, the standalone and the VST3 install, and REAPER opens the plug-in.** REAPER + then reported the audio device closed and gave no sound. That lies between REAPER and the + interface, not in the plug-in, and the likely cause is two programs contending for it. +- **The standalone in *Windows Audio (Low Latency Mode)* was stuck at 44.1 kHz and 441 samples + (10 ms)**, and felt laggy. Shared mode takes its rate from the device's format in Windows' + Sound settings and its period from the audio engine. Low Latency Mode only shortens the + period where the driver supports it, which this one does not. The 44.1 kHz also puts the + resampler in the chain. +- **In *Windows Audio (Exclusive Mode)* the buffer went down to 132 samples.** That felt better + at first, until the output became corrupted. + +**That corruption was a real bug, on every platform: a block larger than `prepareToPlay` +announced overran the chain's buffers.** JUCE's documentation says hosts may exceed the +announced size. A test that announces 64 samples and then sends 1024 crashed with a segmentation +fault, after an assertion in the drive pedal's oversampler. `processBlock` now takes an +oversized block in pieces that fit, and the metronome offsets the host's position for each +piece. Whether the exclusive-mode driver really sent oversized blocks is not proven. The +laptop's CPU missing deadlines at 132 samples would sound similar, and nothing has been measured +on it yet. + +What would give a guitar latency on this interface is ASIO. Audient's own Windows driver +provides it, and the standalone cannot use it while `JUCE_ASIO` is off. + ## Packaging with Inno Setup The plan for step 7. The installer script, its build script and CI's install test exist; the release job does not. It mirrors `packaging/package.sh` where the two diff --git a/src/PluginProcessor.cpp b/src/PluginProcessor.cpp index e33998e..09fee66 100644 --- a/src/PluginProcessor.cpp +++ b/src/PluginProcessor.cpp @@ -690,6 +690,29 @@ bool AmpSimAudioProcessor::isBusesLayoutSupported (const BusesLayout& layouts) c void AmpSimAudioProcessor::processBlock (juce::AudioBuffer& buffer, juce::MidiBuffer& midi) { + // A host can hand over more samples than prepareToPlay announced, and JUCE's documentation + // says to expect it. Every buffer in the chain is sized to the announcement — the mono buffer, + // the dry copy, the drive's oversampler, the resampler's FIFOs — so a bigger block wrote past + // the end of them and crashed. Take it in pieces that fit instead. The MIDI goes with the + // first piece: all it carries here is controller moves for MIDI learn. + if (const auto prepared = monoBuffer.getNumSamples(); prepared > 0 && buffer.getNumSamples() > prepared) + { + juce::MidiBuffer none; + + for (int start = 0; start < buffer.getNumSamples(); start += prepared) + { + const auto length = juce::jmin (prepared, buffer.getNumSamples() - start); + juce::AudioBuffer piece (buffer.getArrayOfWritePointers(), buffer.getNumChannels(), + start, length); + + samplesIntoHostBlock = start; + processBlock (piece, start == 0 ? midi : none); + } + + samplesIntoHostBlock = 0; + return; + } + juce::ScopedNoDenormals noDenormals; // Before anything else, so a controller move takes effect on the block it arrived in. @@ -933,10 +956,12 @@ void AmpSimAudioProcessor::addMetronome (juce::AudioBuffer& buffer, int n if (const auto hostBpm = position->getBpm()) tempo = (float) *hostBpm; + // The host reports the position at the start of its block; a piece of a block split + // in processBlock starts later than that, by samplesIntoHostBlock. if (position->getIsPlaying()) if (const auto ppq = position->getPpqPosition()) { - quarterNotes = *ppq; + quarterNotes = *ppq + samplesIntoHostBlock / getSampleRate() * tempo / 60.0; following = &quarterNotes; } } diff --git a/src/PluginProcessor.h b/src/PluginProcessor.h index ff44e0f..d1740d9 100644 --- a/src/PluginProcessor.h +++ b/src/PluginProcessor.h @@ -310,6 +310,10 @@ class AmpSimAudioProcessor final : public juce::AudioProcessor, juce::AudioBuffer dryBuffer; // dry copy for the bypass crossfade juce::AudioBuffer monoBuffer; // the mono signal the amp model sees + // Where the piece being processed starts within the host's block, when processBlock has had + // to split one larger than prepareToPlay announced. Zero otherwise. Audio thread only. + int samplesIntoHostBlock = 0; + AmpModel ampModel; ModelLoader modelLoader { ampModel }; MidiLearn midiLearn { apvts }; diff --git a/tests/ModelTests.cpp b/tests/ModelTests.cpp index 1ba0988..61c6045 100644 --- a/tests/ModelTests.cpp +++ b/tests/ModelTests.cpp @@ -552,3 +552,59 @@ TEST_CASE ("NAM's A2 fast path plays the bundled capture as the generic WaveNet } #endif } + +TEST_CASE ("A block larger than the host announced is processed rather than overrun", "[model][processor]") +{ + // JUCE's documentation for prepareToPlay: the block size is a strong hint, hosts can exceed + // it, and a plugin should be written defensively. Every buffer in the chain is sized to the + // hint, so a bigger block wrote past the end of the mono buffer in a Release build, and the + // resampler's guard silenced the whole block. 44.1 kHz puts the resampler in the chain. + constexpr double rate = 44100.0; + constexpr int announced = 64; + + AmpSimAudioProcessor processor; + processor.prepareToPlay (rate, announced); + processor.loadModel (exampleModel ("wavenet.nam")); + + juce::AudioBuffer small (processor.getTotalNumOutputChannels(), announced); + juce::MidiBuffer midi; + const auto deadline = juce::Time::getMillisecondCounter() + 10000; + + while (! processor.isModelLoaded() && juce::Time::getMillisecondCounter() < deadline) + { + small.clear(); + processor.processBlock (small, midi); + juce::Thread::sleep (1); + } + + REQUIRE (processor.isModelLoaded()); + + // Let the model swap's fade finish, so the block below hears the model at full level. + for (int b = 0; b < 100; ++b) + { + small.clear(); + processor.processBlock (small, midi); + } + + // Sixteen times what was announced, in one call. + constexpr int oversized = announced * 16; + juce::AudioBuffer big (processor.getTotalNumOutputChannels(), oversized); + + for (int ch = 0; ch < big.getNumChannels(); ++ch) + for (int i = 0; i < oversized; ++i) + big.setSample (ch, i, 0.1f * std::sin (juce::MathConstants::twoPi * 220.0f * (float) i / (float) rate)); + + processor.processBlock (big, midi); + + double energy = 0.0; + + for (int i = oversized / 2; i < oversized; ++i) + { + const auto x = big.getSample (0, i); + REQUIRE (std::isfinite (x)); + energy += (double) x * x; + } + + // Silence is what the resampler's guard produced; a running amp does not. + REQUIRE (std::sqrt (energy / (oversized / 2)) > 1.0e-3); +} From 9cc71f8e61326e818a948dfca3ccd0ab0732d37e Mon Sep 17 00:00:00 2001 From: Kimmo Fonsell Date: Sun, 4 Oct 2026 00:06:57 +0300 Subject: [PATCH 21/25] Enable ASIO in the Windows standalone Every Windows Audio mode goes through Windows' audio engine, which on the Audient iD4 tested by hand stopped at a 10 ms period and 44.1 kHz. ASIO uses the interface's own driver. The headers are those bundled with JUCE 9, from Steinberg's SDK under its GPLv3 option, compatible with the AGPLv3; the installer ships the SDK's licence, and CI checks that both it and JUCE_ASIO=1 make it into the build. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 13 ++++++++----- CMakeLists.txt | 10 ++++++++++ README.md | 1 + docs/windows.md | 10 +++++++++- packaging/windows/AmpSim.iss | 2 ++ 5 files changed, 30 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b3a187a..e32b2fe 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -71,14 +71,16 @@ jobs: path: panel/*.png if-no-files-found: error - # Also proves the build is AVX2: a build that lost the flag would still pass every test, - # and only cost more. Then the benchmark, which asserts nothing; a shared runner changes + # Also proves the build is AVX2 and has ASIO: a build that lost either would still pass every + # test, and only cost more or offer the standalone no low-latency driver. Then the benchmark, which asserts nothing; a shared runner changes # hardware between runs, so compare figures only within one job. 48 kHz is the model's own # rate; 44.1 kHz puts the resampler in the chain. - name: Benchmark run: | - if (-not (Select-String -Path build/build.ninja -Pattern '/arch:AVX2' -SimpleMatch -Quiet)) { - throw "/arch:AVX2 did not reach the compile lines" + foreach ($flag in '/arch:AVX2', 'JUCE_ASIO=1') { + if (-not (Select-String -Path build/build.ninja -Pattern $flag -SimpleMatch -Quiet)) { + throw "$flag did not reach the compile lines" + } } (Get-CimInstance Win32_Processor).Name ./build/ampsim_bench.exe 48000 @@ -118,7 +120,8 @@ jobs: foreach ($pass in 'install', 'upgrade') { Run $setup "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART $($c.Mode) /LOG=`"$PWD\$pass.log`"" foreach ($path in "$($c.Vst3)\Contents\x86_64-win\AmpSim.vst3", "$($c.App)\AmpSim.exe", - "$($c.App)\licenses\LICENSE-AmpSim.txt", "$($c.App)\licenses\OFL-Jost.txt") { + "$($c.App)\licenses\LICENSE-AmpSim.txt", "$($c.App)\licenses\OFL-Jost.txt", + "$($c.App)\licenses\LICENSE-ASIO-SDK.txt") { if (-not (Test-Path $path)) { Get-Content "$pass.log" -Tail 40; throw "$pass did not install $path" } } "$pass ok" diff --git a/CMakeLists.txt b/CMakeLists.txt index e1d303c..3afc8ea 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -160,6 +160,16 @@ target_compile_definitions(AmpSim PUBLIC JUCE_VST3_CAN_REPLACE_VST2=0 JUCE_REPORT_APP_USAGE=0) +# ASIO in the Windows standalone. Every Windows Audio mode goes through Windows' own audio engine, +# which on a typical interface stops at a 10 ms period and the device's shared sample rate; ASIO +# talks to the interface's own driver, which is what a guitarist on Windows plays through. The +# headers are the ones bundled with JUCE, from Steinberg's SDK, used under its GPLv3 option, which +# is compatible with this project's AGPLv3; the installer ships that licence text. A plug-in uses +# its host's driver, so this only matters to the standalone. +if(WIN32) + target_compile_definitions(AmpSim PUBLIC JUCE_ASIO=1) +endif() + target_link_libraries(AmpSim PRIVATE AmpSimAssets diff --git a/README.md b/README.md index faf273c..0b3b16c 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,7 @@ Third-party code, all as pinned submodules rather than vendored copies: | [nlohmann/json](https://github.com/nlohmann/json) | MIT | | [Catch2](https://github.com/catchorg/Catch2) | BSL-1.0 | | VST3 SDK (bundled with JUCE) | GPLv3 or Steinberg's proprietary terms | +| ASIO SDK headers (bundled with JUCE; Windows standalone only) | GPLv3 or Steinberg's proprietary terms | The panel's two typefaces are embedded in the binary: [Figtree](https://github.com/erikdkennedy/figtree) and [Jost](https://github.com/indestructible-type/Jost), both under the diff --git a/docs/windows.md b/docs/windows.md index fb6a6f6..de33373 100644 --- a/docs/windows.md +++ b/docs/windows.md @@ -237,6 +237,8 @@ notice that has to travel with each copy. The installer should still ship the tw ### Low-latency audio in the standalone +**Decided after step 6: ASIO is enabled.** See *Tried by hand* above. + A JUCE standalone on Windows offers Windows Audio (WASAPI shared), Windows Audio exclusive mode, low-latency mode, and DirectSound by default. ASIO is what most guitar interfaces' own drivers provide, and it is what a guitarist on Windows will look for. @@ -344,7 +346,13 @@ laptop's CPU missing deadlines at 132 samples would sound similar, and nothing h on it yet. What would give a guitar latency on this interface is ASIO. Audient's own Windows driver -provides it, and the standalone cannot use it while `JUCE_ASIO` is off. +provides it, and the standalone could not use it while `JUCE_ASIO` was off. + +**Decided: ASIO is on in the Windows build** (`JUCE_ASIO=1` in `CMakeLists.txt`). The headers are +the copies of Steinberg's SDK bundled with JUCE 9, under the SDK's GPLv3 option, which is +compatible with the AGPLv3. The installer ships the SDK's licence as `LICENSE-ASIO-SDK.txt`. +The standalone still starts on JUCE's first device type, Windows Audio; ASIO is chosen once in +*Options* and remembered. ## Packaging with Inno Setup diff --git a/packaging/windows/AmpSim.iss b/packaging/windows/AmpSim.iss index 8d398dd..cd4a31a 100644 --- a/packaging/windows/AmpSim.iss +++ b/packaging/windows/AmpSim.iss @@ -97,6 +97,8 @@ Source: "{#StageDir}\Standalone\AmpSim.exe"; DestDir: "{app}"; Components: stand Source: "{#RepoRoot}\LICENSE"; DestDir: "{app}\licenses"; DestName: "LICENSE-AmpSim.txt"; Flags: ignoreversion Source: "{#RepoRoot}\resources\fonts\OFL-Figtree.txt"; DestDir: "{app}\licenses"; Flags: ignoreversion Source: "{#RepoRoot}\resources\fonts\OFL-Jost.txt"; DestDir: "{app}\licenses"; Flags: ignoreversion +; The standalone's ASIO support is built from Steinberg's SDK headers, used under its GPLv3 option. +Source: "{#RepoRoot}\external\JUCE\modules\juce_audio_devices\native\asio\LICENSE.txt"; DestDir: "{app}\licenses"; DestName: "LICENSE-ASIO-SDK.txt"; Flags: ignoreversion [Icons] Name: "{autoprograms}\AmpSim"; Filename: "{app}\AmpSim.exe"; Components: standalone From 21e7d2d88fac61e2d58716f7f2f9541bdb2effda Mon Sep 17 00:00:00 2001 From: Kimmo Fonsell Date: Sun, 4 Oct 2026 00:56:48 +0300 Subject: [PATCH 22/25] Record AmpSim in REAPER through ASIO on the test laptop Co-Authored-By: Claude Opus 5.5 --- docs/windows.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/windows.md b/docs/windows.md index de33373..9a9bd7e 100644 --- a/docs/windows.md +++ b/docs/windows.md @@ -354,6 +354,12 @@ compatible with the AGPLv3. The installer ships the SDK's licence as `LICENSE-AS The standalone still starts on JUCE's first device type, Windows Audio; ASIO is chosen once in *Options* and remembered. +**Through ASIO in REAPER, on the same laptop, it is fine.** Audient's own driver at 128 samples +reported 4.1 ms in and 5.5 ms out, about 10 ms round trip, and REAPER's performance meter showed +AmpSim at about 3.7 % of the CPU while playing. That is in line with the Mac's 3 to 4 %, so the +lag in the standalone was Windows' shared audio path, not processing cost. 128 samples was the +smallest buffer that played cleanly on this machine. + ## Packaging with Inno Setup The plan for step 7. The installer script, its build script and CI's install test exist; the release job does not. It mirrors `packaging/package.sh` where the two From a7bd81f8b90b8ae428ecb654a9f346889bb2913f Mon Sep 17 00:00:00 2001 From: Kimmo Fonsell Date: Sun, 4 Oct 2026 00:59:22 +0300 Subject: [PATCH 23/25] Publish releases from a job that waits for both platforms The macOS job used to publish on its own. A release job now runs on v* tags once the macOS and Windows jobs have both passed, checks the tag against CMakeLists.txt once, and attaches the .pkg, the .dmg and the Windows installer together, with notes covering both platforms' requirements. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 52 ++++++++++++++++++++++++++++++------- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e32b2fe..1c54edd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -147,8 +147,6 @@ jobs: # macos-latest rather than a pinned version: GitHub retires old runner images, and a pinned # label that has been retired fails before it builds anything. runs-on: macos-latest - permissions: - contents: write # only used by the release step on tags steps: - uses: actions/checkout@v4 @@ -207,11 +205,35 @@ jobs: path: build/artefacts/* if-no-files-found: error - # Tags only. The tag has to match the version in CMakeLists.txt, which is also where - # package.sh reads it from and so where the artefacts take their names — the same sed - # expression, deliberately, so the two cannot disagree about what is being released. - - name: Publish release - if: startsWith(github.ref, 'refs/tags/v') + # Tags only, and only once both platforms have built, tested and packaged: a tag cannot publish + # one platform while the other failed. The tag has to match the version in CMakeLists.txt, + # which is also where package.sh and package-windows.ps1 read it from and so where the files take + # their names; the same pattern, deliberately, so they cannot disagree about what is released. + release: + name: Publish release + if: startsWith(github.ref, 'refs/tags/v') + needs: [macos, windows] + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + # Only CMakeLists.txt is needed, for the version; not the submodules. + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - uses: actions/download-artifact@v4 + with: + name: AmpSim-macOS + path: release + + - uses: actions/download-artifact@v4 + with: + name: AmpSim-Windows-installer + path: release + + - name: Publish env: GH_TOKEN: ${{ github.token }} run: | @@ -220,7 +242,17 @@ jobs: echo "Tag $GITHUB_REF_NAME does not match the version $version in CMakeLists.txt" >&2 exit 1 fi - gh release create "$GITHUB_REF_NAME" build/artefacts/* \ + ls -l release + gh release create "$GITHUB_REF_NAME" release/* \ --title "AmpSim $version" \ - --notes "AmpSim $version for Macs with Apple silicon (macOS 11 or later). Ad-hoc signed: the first use needs System Settings > Privacy & Security > Open Anyway; see the installer's welcome page. User guide: https://catastrophiccoder.github.io/ampsim/" \ - --verify-tag + --notes-file - \ + --verify-tag < Privacy & Security > Open Anyway. + + **Windows:** Windows 10 version 2004 or later, or Windows 11, on x64 with an AVX2 processor (Intel Core from 2013, any AMD Ryzen). \`setup.exe\` installs the VST3 and the standalone, for you or for everyone. Unsigned: SmartScreen needs More info > Run anyway. In the standalone, choose your interface's ASIO driver in Options for low latency. + + Installing, and what to do the first time: https://github.com/CatastrophicCoder/ampsim/blob/v$version/packaging/README.md + User guide: https://catastrophiccoder.github.io/ampsim/ + EOF From d08853eae27a23a531bcede19f8530747f95d92c Mon Sep 17 00:00:00 2001 From: Kimmo Fonsell Date: Sun, 4 Oct 2026 01:01:53 +0300 Subject: [PATCH 24/25] Document Windows: install, ASIO, building, and what changed The README, the user guide and packaging/README.md describe installing on Windows, its requirements, and playing through ASIO rather than Windows' shared audio path, with the guide's troubleshooting covering what came up on real hardware. CHANGELOG.md records Windows, the new typefaces, the A2 fast path and the two fixes. CLAUDE.md drops Windows from what is out of scope and describes the Windows build, CI and release job; the roadmap moves Windows to Done and leaves signing as the open question. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 27 ++++++++++++++++++ CLAUDE.md | 65 +++++++++++++++++++++++++++++++++++-------- README.md | 59 +++++++++++++++++++++++++++++---------- docs/guide/index.html | 48 ++++++++++++++++++++++++++++++-- docs/roadmap.md | 28 ++++++++++++++++--- docs/windows.md | 3 ++ packaging/README.md | 17 +++++++++++ 7 files changed, 214 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff06299..8afc296 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,35 @@ describes how the current version behaves; this records what moved between versi ## Unreleased +### Added + +- **Windows.** The VST3 and the standalone, for x64 Windows 10 (version 2004 or later) and 11, + with an Inno Setup installer that asks whether to install for you or for everyone. The build + needs a processor with AVX2, which every Intel Core since 2013 and every AMD Ryzen has; the + installer checks and refuses rather than install something that would crash its host. The + standalone supports **ASIO**, which is how to play through it with low latency. Unsigned, so + SmartScreen asks once. + +### Changed + +- **New typefaces, on both platforms.** The panel's lettering was Avenir Next and Futura, which + ship with macOS only and cannot be embedded. It is now Figtree and Jost, both open-licensed and + built in, scaled to the same cap heights so nothing on the panel moved. JUCE's own popups use + Figtree too, rather than the system font. +- **The amp model costs a little less.** Neural Amp Modeler's specialised path for the bundled + capture's architecture had been compiled out by accident; it is now on, and is tested to play + the capture as the general one does. + ### Fixed +- **A host sending a bigger block than it announced no longer crashes the plugin.** Every buffer in + the chain was sized to the announced block, and a larger one overran them. Hosts are allowed to + do this; it showed up with the Windows standalone in exclusive mode, as corrupted sound, and + applies on macOS too. +- **Loading a model before the audio device was open no longer depends on luck.** The resampler + divided by a sample rate of zero, which happened to be harmless on Apple silicon and threw on + Windows. + - **The plug-ins no longer offer MIDI learn.** In 0.3.0 a knob's right-click menu offered to learn a MIDI controller in every format, and in the Audio Unit or the VST3 it then waited for a controller it could never hear, since neither receives MIDI. The menu now appears only in the standalone; in diff --git a/CLAUDE.md b/CLAUDE.md index a7d1af5..d539f27 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,13 @@ needed to build or package — it buys a Developer ID certificate and notarisati removes the first-launch Gatekeeper dialog on someone else's Mac. `packaging/README.md` says exactly what would change if one is bought later. +**Windows builds too**: the VST3 and the standalone, x64, packaged by +`packaging/package-windows.ps1` into an Inno Setup installer (`packaging/windows/AmpSim.iss`) +that asks whether to install for everyone or for one user. [`docs/windows.md`](docs/windows.md) +is the record of how the port was done and why each choice was made — AVX2 only, the static C++ +runtime, ASIO in the standalone, the embedded fonts — with the measurements behind them. Read it +before changing any of those. + Conventions worth following for every block added after this point: - Parameter IDs live in `namespace ParamID` in `PluginProcessor.h`. Never change an existing ID or @@ -52,6 +59,21 @@ Two macOS things that make it look broken rather than misconfigured: - **JUCE mutes a standalone's input by default** (`shouldMuteInput` in its settings file). Right for a synth, wrong for an amp; it is a checkbox in the Options dialog. +And on Windows: + +- **Every *Windows Audio* mode goes through Windows' own audio engine**, which on a typical + interface fixes the period at 10 ms and the rate at whatever Windows' Sound settings say. That + is too slow to play through, and it looked like a slow plugin when it was not. **ASIO is the + path** (`JUCE_ASIO=1`, Windows only, using the SDK headers bundled with JUCE). The standalone + still opens on Windows Audio the first time; ASIO is chosen once in Options and remembered. +- **Windows has its own microphone privacy switch** (*Let desktop apps access your microphone*), + and when it is off the effect is macOS's: the device appears and silence arrives. Nothing in + the binary controls it. +- **A host may send a bigger block than `prepareToPlay` announced**, and JUCE's documentation + says to expect it. `processBlock` splits such a block into pieces that fit; before that it + overran every buffer in the chain and crashed. Windows' exclusive mode is where it showed, and + `tests/ModelTests.cpp` holds the case. + ## The model chain `AmpModel` owns a `LoadedModel` (a `nam::DSP` plus the `ModelResampler` configured for it). They are @@ -529,7 +551,7 @@ a modelled passive stack. Each is explained where it is implemented. ## What the project is -A **minimal** macOS guitar amp simulator plugin (JUCE, C++17), built as AU / VST3 / Standalone. Four parts, per the Goal section of `ampsim_plan.md`: +A **minimal** guitar amp simulator plugin (JUCE, C++17), built as AU / VST3 / Standalone on macOS and as VST3 / Standalone on Windows. Four parts, per the Goal section of `ampsim_plan.md`: 1. **Amp** — a pre-trained `.nam` model run through [NeuralAmpModelerCore](https://github.com/sdatkinson/NeuralAmpModelerCore) (MIT). No hand-written amp DSP; the model supplies the tone. 2. **Custom amp-like UI** — a front panel with Gain, three-band EQ (Bass, Mid, Treble) and Master Volume. This is the project's own contribution and the reason it is not just a NAM loader. @@ -542,7 +564,7 @@ ML Sound Lab's Amped Block Letter is the yardstick the plan measures against, no tuner, MIDI mapping, presets, the multi-mic cab, the installer, the transpose and the metronome were all asked for and built on top of them — and an earlier version of this file told a reader to treat each of those as scope creep. Do not reject a request on the strength of that list. What is -still deliberately out: Windows and Linux builds, parametric or multi-model switching, a +still deliberately out: Linux builds, a native Windows-on-ARM build, parametric or multi-model switching, a reorderable pedal chain, and anything stereo before the cab. Each has a reason recorded where it would be implemented, and `docs/roadmap.md` holds the rest of what is open. @@ -576,6 +598,13 @@ fixtures: `makePreparedProcessor()`, `runConstant()` for DC through the chain, ` `blocksForRamp()` for waiting out a smoother. Add a new block's tests as their own file in `tests/CMakeLists.txt`. +**On Windows**, from a *Developer PowerShell for Visual Studio* (it puts MSVC, Ninja and +`dumpbin` on the PATH), the same commands build the VST3 and the standalone, and +`./packaging/package-windows.ps1` builds the installer with Inno Setup 6. Nobody working on this +has a Windows machine to build on, so in practice the Windows job in CI is the Windows build: +push a branch and run the workflow by hand from the Actions tab, since only `main`, tags and pull +requests trigger it on their own. + A test is only worth committing if it fails when the behaviour it describes is broken — check that by reverting the fix, not by assuming. @@ -595,11 +624,18 @@ plugin is still built, in `build/AmpSim_artefacts/Debug/`, for pluginval and for Use `-DCMAKE_BUILD_TYPE=Release` for anything judged by ear or by CPU load. -**CI builds, tests, validates and packages every push and PR; a `v*` tag does all of that and then -publishes a GitHub Release with the `.pkg` and the `.dmg`.** The tag has to match the version in -`CMakeLists.txt` or the run fails before it publishes — the workflow reads it with the same `sed` -expression `packaging/package.sh` uses, so the release and the files in it cannot disagree about -what version they are. To cut a release: bump `project(AmpSim VERSION ...)`, add the version's entry to `CHANGELOG.md`, +**CI builds, tests, validates and packages every push and PR, on macOS and on Windows; a `v*` tag +does all of that and then a third job publishes a GitHub Release with the `.pkg`, the `.dmg` and +the Windows `setup.exe`.** That job waits for both platforms, so a tag cannot publish one while +the other failed. The tag has to match the version in `CMakeLists.txt` or the run fails before it +publishes — the workflow reads it with the same `sed` expression `packaging/package.sh` uses, and +`package-windows.ps1` reads it the same way, so the release and the files in it cannot disagree +about what version they are. + +The Windows job also renders the panel (`AmpSim-Windows-panel`), runs the benchmark, and installs, +upgrades and uninstalls the installer both ways. **Run anything that launches a GUI-subsystem +`.exe` under bash, or with `Start-Process -Wait`**: PowerShell does not wait for those, and a +pluginval step once passed in five seconds without having finished. To cut a release: bump `project(AmpSim VERSION ...)`, add the version's entry to `CHANGELOG.md`, commit and push, then ```bash @@ -607,7 +643,8 @@ git tag -a vX.Y.Z -m "AmpSim X.Y.Z" && git push origin vX.Y.Z # after the vers ``` The runner is `macos-latest`, which is Apple silicon, and nothing sets `CMAKE_OSX_ARCHITECTURES`, -so the published build is arm64 against a deployment target of macOS 11. +so the published build is arm64 against a deployment target of macOS 11. The Windows build is x64 +and needs AVX2 and Windows 10 2004 or later, which the installer checks. ## What it costs @@ -629,6 +666,10 @@ Two consequences worth keeping in mind before optimising anything in the chain: not the model is not worth hand-tuning for speed**, and **the small block sizes are where the cost is**, because the model's own overhead per call does not shrink with the block. +On Windows the same benchmark runs in CI on a shared runner, whose hardware changes between runs, +so only figures from one job can be compared with each other. On a real laptop (an i5-8350U) +through ASIO in REAPER, AmpSim took about 3.7 % — the Mac's range. + The figure to watch is not the average. See the transpose's note above: a cost that arrives in bursts drops audio while averaging under one per cent. For that, Instruments' Time Profiler at a 64-sample buffer is the tool, not this one. @@ -710,11 +751,12 @@ src/ ui/AmpLookAndFeel.h/.cpp, AmpKnob.h/.cpp, ParameterSlider.h ui/AmpPage, PedalsPage, PedalObject, CabPage, PresetRow (.h/.cpp each) resources/ the packed amp model and cab IR → BinaryData +resources/fonts/ Figtree and Jost (static instances) and their OFL texts → BinaryData tests/ Catch2 suites + TestHelpers.h tests/fixtures/ clean DI guitar recordings (empty) -tools/ Benchmark.cpp, behind -DAMPSIM_BUILD_TOOLS=ON -docs/ roadmap.md, signal-chain-review.md, guide/index.html, images/ -packaging/ package.sh and its README +tools/ Benchmark.cpp, PanelSnapshot.cpp, HostCheck.cpp (macOS), behind -DAMPSIM_BUILD_TOOLS=ON +docs/ roadmap.md, signal-chain-review.md, windows.md, guide/index.html, images/ +packaging/ package.sh (macOS), package-windows.ps1 and windows/AmpSim.iss (Windows), README ``` Any empty directory left in that list is the planned layout, held by `.gitkeep`. @@ -727,4 +769,5 @@ Offline C++ tests assert on *measurable* properties, not on sound: the -3 dB poi - JUCE 8+ is AGPLv3 or a free Personal commercial tier below a revenue limit — which one applies is an open question in the plan, and it determines whether the repo can be public. - A GPL dependency makes the whole plugin GPL if distributed. Check each third-party DSP library's licence before adding it. +- The Windows standalone's ASIO support uses Steinberg's SDK headers under their GPLv3 option, which is compatible with the AGPLv3; the installer ships that licence. The embedded fonts are OFL, and their licence texts sit in `resources/fonts` and go into the Windows installer. - `.nam` model files and capture datasets carry their own licences, and captures of trademarked amps cannot be republished under the amp's name. diff --git a/README.md b/README.md index 0b3b16c..5d40f2d 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,10 @@ # AmpSim -**A guitar amp simulator for macOS, by Catastrophic Audio** +**A guitar amp simulator for macOS and Windows, by Catastrophic Audio** [![License: AGPL v3](https://img.shields.io/badge/license-AGPL--3.0-blue.svg)](LICENSE) -![Platform: macOS](https://img.shields.io/badge/platform-macOS-lightgrey.svg) +![Platforms: macOS | Windows](https://img.shields.io/badge/platforms-macOS%20%7C%20Windows-lightgrey.svg) ![Formats: AU | VST3 | Standalone](https://img.shields.io/badge/formats-AU%20%7C%20VST3%20%7C%20Standalone-orange.svg) ![C++17](https://img.shields.io/badge/C%2B%2B-17-00599C.svg) ![JUCE 9](https://img.shields.io/badge/JUCE-9.0.2-8DC63F.svg) @@ -65,28 +65,38 @@ it so the repeats are of the already-distorted tone. ## Download and install Ready-made packages are on the [releases page](https://github.com/CatastrophicCoder/ampsim/releases). -The `.pkg` installs the AU and VST3 into `/Library/Audio/Plug-Ins/` and the standalone app into -`/Applications`; the `.dmg` holds just the app. In Logic the plugin appears as -**Catastrophic Audio: AmpSim**, with the other amps and distortion plugins. + +**macOS** (Apple silicon, macOS 11 or later): the `.pkg` installs the AU and VST3 into +`/Library/Audio/Plug-Ins/` and the standalone app into `/Applications`; the `.dmg` holds just the +app. In Logic the plugin appears as **Catastrophic Audio: AmpSim**, with the other amps and +distortion plugins. + +**Windows** (Windows 10 version 2004 or later, or 11, on x64 with an AVX2 processor): the +`setup.exe` installs the VST3 and the standalone, for you or for everyone — it asks. In the +standalone, choose your interface's **ASIO** driver in *Options*: the *Windows Audio* modes go +through Windows' own mixer and are too slow to play through. To build your own: ```sh -./packaging/package.sh # or: cmake --build build --target package-macos +./packaging/package.sh # macOS; or: cmake --build build --target package-macos +./packaging/package-windows.ps1 # Windows, from a Developer PowerShell, with Inno Setup 6 ``` Builds Release, ad-hoc signs everything and writes an installer and a disk image to `build-release/artefacts`. No Apple Developer Program membership is needed to build or package. -The cost lands on whoever installs it: the packages carry no Developer ID, so macOS blocks them on -first launch until the person goes to **System Settings → Privacy & Security** and clicks **Open -Anyway**. [`packaging/README.md`](packaging/README.md) covers that, what ad-hoc signing does and -does not do, and what would change with a Developer ID. +The cost lands on whoever installs it: nothing is signed with a developer certificate, so macOS +blocks the packages on first launch until the person goes to **System Settings → Privacy & +Security** and clicks **Open Anyway**, and Windows SmartScreen stops the installer until they +choose **More info → Run anyway**. [`packaging/README.md`](packaging/README.md) covers both, what +ad-hoc signing does and does not do, and what signing would change. ### Cutting a release -CI builds, tests, validates and packages every push. A `v*` tag does the same and then publishes a -GitHub Release with the installer and disk image attached: +CI builds, tests, validates and packages every push, on macOS and Windows. A `v*` tag does the same +and then, once both have passed, publishes a GitHub Release with the macOS installer and disk +image and the Windows installer attached: ```sh # bump project(AmpSim VERSION X.Y.Z) in CMakeLists.txt first, and commit and push it @@ -159,6 +169,24 @@ is roughly sixty times slower, and when both installed to the same place a Debug replaced the one a DAW was loading. Pass `-DAMPSIM_COPY_PLUGIN=ON` to install a Debug build anyway. Use Release for anything you intend to judge by ear or by CPU load. +### On Windows + +The Windows build is the VST3 and the standalone, x64 only. It needs Visual Studio 2022 or later, +or just its Build Tools, with the *Desktop development with C++* workload, which brings MSVC, +CMake and Ninja. Inno Setup 6 builds the installer. Work in a **Developer PowerShell for Visual +Studio**, then: + +```powershell +cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DAMPSIM_COPY_PLUGIN=OFF +cmake --build build +ctest --test-dir build --output-on-failure +./packaging/package-windows.ps1 # the installer, in build-release/artefacts +``` + +`AMPSIM_COPY_PLUGIN=OFF` because JUCE's default destination is under Program Files, which needs +an administrator. The build requires AVX2 and links the C++ runtime statically; +[`docs/windows.md`](docs/windows.md) explains both, and everything else about the port. + JUCE is pinned on purpose: Apple toolchain and JUCE updates are a reliable source of "the build broke and nothing changed". Update it deliberately, in its own commit, and re-validate afterwards. @@ -207,8 +235,8 @@ assistant. ## What it does not do -- **macOS and Apple silicon only.** Nothing is Mac-specific in the DSP, but no other platform has - been built or tested. +- **macOS on Apple silicon, and Windows on x64 with AVX2.** No Linux build, no Intel Mac build, + and no native Windows-on-ARM build: there the x64 build loads only in an x64 host. - **Mono.** Stereo input is summed at the top of the chain. - **One model, one cabinet, no channel switching.** - **The mic-position blend is unproven musically.** The interpolation is exact and tested, but @@ -218,7 +246,8 @@ assistant. input made the Audio Unit a MIDI-controlled effect, which Logic feeds from a side chain; the same setting carries the VST3's MIDI input. In Logic, Controller Assignments (Cmd-L) map any plugin's parameters instead. -- **Nothing is notarised**, so anyone you give a build to has to allow it through Gatekeeper. +- **Nothing is notarised or code-signed**, so anyone you give a build to has to allow it through + Gatekeeper or SmartScreen. ## Licence diff --git a/docs/guide/index.html b/docs/guide/index.html index ad41147..e2bea65 100644 --- a/docs/guide/index.html +++ b/docs/guide/index.html @@ -118,7 +118,8 @@

AmpSim user guide

Installing

-

macOS, Apple silicon. The installer puts three things on your machine: an Audio Unit +

macOS

+

Apple silicon, macOS 11 or later. The installer puts three things on your machine: an Audio Unit and a VST3 in /Library/Audio/Plug-Ins/, and a standalone app in /Applications. Take whichever you need.

    @@ -133,9 +134,34 @@

    Installing

  1. In Logic the plugin is Catastrophic Audio → AmpSim, among the amps and distortion plugins. Insert it on an audio track like any other amp sim.
+

Windows

+

Windows 10 version 2004 or later, or Windows 11, on x64, with a processor that has AVX2: an + Intel Core from 2013 on, or any AMD Ryzen. The installer checks, and refuses rather than + installing something that cannot run. It puts a VST3 and a standalone app on + your machine, and asks first whether to install them for you or for everyone.

+
    +
  1. Run AmpSim-…-windows-x64-setup.exe from the + releases page. It is not + signed, so SmartScreen says Windows protected your PC: choose More info, then + Run anyway.
  2. +
  3. For me only needs no administrator and puts the VST3 in + %LOCALAPPDATA%\Programs\Common\VST3; for all users puts it in + C:\Program Files\Common Files\VST3. If your host does not find AmpSim after + an install for one user, add that folder to its plug-in paths and rescan.
  4. +
  5. In a host, set the audio device to your interface's ASIO driver. In REAPER that is + Preferences → Audio → Device. Only one program can use an ASIO driver at a time, so + close the standalone first.
  6. +
+
In the Windows standalone, choose ASIO. In Options, set the audio + device type to ASIO and pick your interface's own driver — install it from the maker + if it is not listed. The Windows Audio types go through Windows' mixer, which usually + fixes the buffer at 10 ms and the rate at whatever Windows' Sound settings say: workable for + listening, too slow to play through. 48 kHz also spares AmpSim converting to its model's + rate. And if no sound comes in at all, check Settings → Privacy & security → + Microphone → Let desktop apps access your microphone.
In the standalone, untick “Mute audio input” in the Options dialog the - first time. A standalone plugin mutes its input by default, which is right for a synth and - wrong for an amp — with it ticked the meters move and you hear nothing.
+ first time, on either platform. A standalone plugin mutes its input by default, which is right + for a synth and wrong for an amp — with it ticked the meters move and you hear nothing.
@@ -347,6 +373,22 @@

A new build does not show up in Logic.

Logic keeps a plugin's code loaded until it quits: quit Logic (⌘Q) and reopen the project.

+

On Windows the standalone feels laggy, and I cannot change the buffer or the rate.

+

It is using one of the Windows Audio device types, which go through Windows' own + mixer: the buffer is whatever Windows uses, usually 10 ms, and the rate is whatever the + device is set to in Windows' Sound settings. Switch the device type to ASIO in + Options and pick your interface's own driver. At 128 samples that is around 10 ms from + string to ear, rather than several times that.

+ +

On Windows the host says the audio device is closed, or in use.

+

An ASIO driver serves one program at a time. Close the AmpSim standalone, or whatever else + has the interface open, and restart the host.

+ +

On Windows the sound breaks up at small buffers.

+

Raise the buffer a step at a time until it stays clean. A laptop on a power-saving plan + slows its processor down, which is worth changing while playing. 128 samples is a common + floor on a laptop; a desktop usually goes lower.

+

Is it stereo?

No. Mono from end to end, with a stereo input summed at the top. A guitar amp is mono and so is a NAM capture; the cabinet is where stereo would begin if anything in the chain were.

diff --git a/docs/roadmap.md b/docs/roadmap.md index 1a4377b..1861f84 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -10,11 +10,11 @@ agreed, which is why it is not here. ## Still to settle -### Windows builds +### Whether to sign the builds -The standalone and the VST3 for Windows. [`windows.md`](windows.md) has the plan: what already -ports, what has to change before it builds, and the decisions about fonts, ASIO, signing and the -installer that are not technical. +Neither platform's build is signed with a developer certificate, so Gatekeeper and SmartScreen +each ask once. The options and their costs are in [`packaging/README.md`](../packaging/README.md) +for macOS and in [`windows.md`](windows.md) for Windows. ### Whether to tame a downshifted tone in code @@ -71,6 +71,26 @@ rework has left the pickers as chooser buttons. ## Done +### Windows + +Built: the VST3 and the standalone, x64, with an Inno Setup installer, built, tested, validated, +installed and uninstalled in CI on every push, and published beside the macOS packages from the +same tag. [`windows.md`](windows.md) is the record, with the measurements behind each decision. +The ones that shape the code: + +- **AVX2 only**, measured 27 to 38 % cheaper than SSE2; the installer refuses a processor + without it, since the plugin would crash its host. +- **The C++ runtime linked statically**, so nothing has to be installed beside it. +- **ASIO in the standalone.** Windows' shared audio path was too slow to play through on real + hardware; through ASIO the same laptop played at 128 samples with AmpSim at under 4 % of the + CPU. +- **Figtree and Jost embedded on both platforms** in place of Avenir Next and Futura, scaled to + the same cap heights. + +Two bugs that affected every platform came out of it: a division by a zero sample rate when a +model loaded before the device opened, and a block larger than the host announced overrunning +every buffer in the chain. + ### The panel rework Delivered. `docs/panel.png` and `docs/pedals.png` show the result, and the **The panel** section of diff --git a/docs/windows.md b/docs/windows.md index 9a9bd7e..cc9afea 100644 --- a/docs/windows.md +++ b/docs/windows.md @@ -491,3 +491,6 @@ move: 4. Install, upgrade and uninstall it by hand in a VM. Check that REAPER finds the VST3 at whichever location was chosen, and that the AVX2 check behaves. 5. Split the release out into its own job, then tag. + +**Status:** 1 to 5 are done. The release job publishes the macOS and Windows packages together +from a `v*` tag, once both platforms have passed. Signing is the one decision still open. diff --git a/packaging/README.md b/packaging/README.md index 3bb3d35..7fa34fb 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -125,6 +125,23 @@ Two Windows settings produce silence while the input device looks fine: - The standalone mutes its input by default, the same as on macOS. Untick it in the *Options* dialog. +## Playing through it: use ASIO + +The *Windows Audio* device types go through Windows' own mixer. On the interface this was +tested with, an Audient iD4 mkII, that fixed the buffer at 441 samples and the rate at 44.1 kHz, +and felt laggy, because it is. **ASIO** talks to the interface's own driver: + +1. Install the interface maker's Windows driver. Windows' built-in one runs the interface but + provides no ASIO. +2. In the standalone's **Options**, set the device type to **ASIO** and choose that driver; in a + host, set its audio device the same way (REAPER: **Preferences → Audio → Device**). +3. Choose **48 kHz**, which is the amp model's own rate, and the smallest buffer that plays + cleanly. On the test laptop that was 128 samples, about 10 ms round trip, with AmpSim at about + 4 % of the CPU. + +An ASIO driver serves one program at a time: close the standalone before opening a host, or the +host reports the device closed. + ## Building the installer From a Developer PowerShell for Visual Studio, with Inno Setup 6 installed: From 59d0a76bc03c793c9849540df612695258cd2b0f Mon Sep 17 00:00:00 2001 From: Kimmo Fonsell Date: Sun, 4 Oct 2026 01:03:56 +0300 Subject: [PATCH 25/25] Record the Windows standalone working through ASIO Co-Authored-By: Claude Opus 5.5 --- docs/windows.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/windows.md b/docs/windows.md index cc9afea..9c6c809 100644 --- a/docs/windows.md +++ b/docs/windows.md @@ -360,6 +360,9 @@ AmpSim at about 3.7 % of the CPU while playing. That is in line with the Mac's 3 lag in the standalone was Windows' shared audio path, not processing cost. 128 samples was the smallest buffer that played cleanly on this machine. +**The standalone through ASIO works too**, on the same laptop and interface, with the build that +has ASIO enabled and the oversized-block fix. That completes step 6. + ## Packaging with Inno Setup The plan for step 7. The installer script, its build script and CI's install test exist; the release job does not. It mirrors `packaging/package.sh` where the two