diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f13753e..1c54edd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -15,13 +15,138 @@ concurrency: cancel-in-progress: true jobs: + # 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 + + 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. AMPSIM_BUILD_TOOLS + # for the benchmark below. + - name: Configure + run: cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DAMPSIM_COPY_PLUGIN=OFF -DAMPSIM_BUILD_TOOLS=ON + + - name: Build + run: cmake --build build + + - 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 + + # 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 + + # 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 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: | + 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 + ./build/ampsim_bench.exe 44100 + + # The raw build as well as the installer, for trying by hand without installing anything. + - 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 + + # 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", + "$($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" + } + 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 # 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 @@ -80,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: | @@ -93,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 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 9327743..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 @@ -175,6 +197,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 @@ -295,11 +325,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 @@ -513,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. @@ -526,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. @@ -560,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. @@ -579,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 @@ -591,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 @@ -613,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. @@ -694,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`. @@ -711,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/CMakeLists.txt b/CMakeLists.txt index ede14ca..3afc8ea 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -16,6 +16,21 @@ 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() + +# 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. @@ -35,6 +50,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 @@ -92,6 +113,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 @@ -125,9 +160,20 @@ 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 + AmpSimFonts ${NAM_CORE_WHOLE} juce::juce_audio_utils juce::juce_dsp @@ -155,17 +201,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) @@ -176,4 +226,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..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 @@ -238,6 +267,11 @@ 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 +[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/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 cc3cb32..1861f84 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -10,6 +10,12 @@ agreed, which is why it is not here. ## Still to settle +### Whether to sign the builds + +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 A heavily downshifted distorted tone goes muddy and loses its articulation. Measured, by putting a @@ -65,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 @@ -77,7 +103,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 new file mode 100644 index 0000000..9c6c809 --- /dev/null +++ b/docs/windows.md @@ -0,0 +1,499 @@ +# 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 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. 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. 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. + +### 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.** 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. + +### 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 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. + +**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 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 +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. + +## 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. **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 + 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. + +**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. +- **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 + +**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. + +| 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 | + +**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 +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 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. + +**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 | +| --- | --- | --- | +| 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 + +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. + +## 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 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. + +**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. + +**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 +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. + +### 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. + +**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) | +| 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. + +**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 f67cc18..7fa34fb 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -79,3 +79,85 @@ 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. + +## 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: + +```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..cd4a31a --- /dev/null +++ b/packaging/windows/AmpSim.iss @@ -0,0 +1,142 @@ +; 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 +; 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 +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; diff --git a/resources/fonts/Figtree-Medium.ttf b/resources/fonts/Figtree-Medium.ttf new file mode 100644 index 0000000..c7116de Binary files /dev/null and b/resources/fonts/Figtree-Medium.ttf differ diff --git a/resources/fonts/Figtree-Regular.ttf b/resources/fonts/Figtree-Regular.ttf new file mode 100644 index 0000000..58a6e8d Binary files /dev/null and b/resources/fonts/Figtree-Regular.ttf differ diff --git a/resources/fonts/Jost-Medium.ttf b/resources/fonts/Jost-Medium.ttf new file mode 100644 index 0000000..738e9d1 Binary files /dev/null and b/resources/fonts/Jost-Medium.ttf differ 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/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/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/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/tests/ModelTests.cpp b/tests/ModelTests.cpp index abf1511..61c6045 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,159 @@ 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 +} + +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); +} 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); diff --git a/tools/Benchmark.cpp b/tools/Benchmark.cpp index 7c916c8..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; }; @@ -172,6 +250,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; } 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; +}