diff --git a/.den/commit-style.md b/.den/commit-style.md new file mode 100644 index 0000000..a90f69e --- /dev/null +++ b/.den/commit-style.md @@ -0,0 +1,13 @@ + +Write ONE commit message for the diff below. + +Format: +- summary: ≤ 72 chars, imperative, capitalized, no period +- After a blank line, add a short body saying what changed and why +- Describe what the change does, not how the code looks +- Don't invent context not in the diff +- Write in the same language as this repository's recent commit messages +- Output only the message, nothing else + +DIFF: +{{diff}} diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 0000000..1b1c4a0 --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,51 @@ +# Everything a runner needs before `pnpm app` or `pnpm release` can start, per +# OS — shared by the CI and release workflows so the two cannot drift. The +# scripts find the rest themselves: Visual Studio's bundled CMake on Windows, +# Xcode's toolchain on macOS, and on Linux they name any missing apt package. +name: Set up the Plectrify toolchain +description: Node, pnpm, the root tooling and the per-OS build dependencies. + +inputs: + native: + description: Install the native build dependencies (false for jobs that build nothing) + default: 'true' + +runs: + using: composite + steps: + - uses: pnpm/action-setup@v4 + with: + version: 12.15.0 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Linux build dependencies + if: runner.os == 'Linux' && inputs.native == 'true' + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + cmake ninja-build pkg-config build-essential unzip \ + libwebkit2gtk-4.1-dev libgtk-3-dev libasound2-dev libjack-jackd2-dev \ + libcurl4-openssl-dev libfreetype-dev libfontconfig-dev \ + libx11-dev libxrandr-dev libxinerama-dev libxcursor-dev libxext-dev \ + libxcomposite-dev libgl-dev + + - name: macOS build dependencies + if: runner.os == 'macOS' && inputs.native == 'true' + shell: bash + run: brew install ninja || true + + # Git identity for the tag the Windows release step creates and the site + # commit promotion pushes. Harmless everywhere else. + - name: Git identity + shell: bash + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Root tooling + shell: bash + run: pnpm install --frozen-lockfile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..92fd681 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +# Build and test on every platform Plectrify ships for, on every push to main and +# every pull request. Runs the same gate a developer runs locally — +# `pnpm app --dist --no-run`: UI format/check/test/build, the native app, every +# CTest target — so CI and the dev loop cannot disagree about what "green" means. +# Publishes nothing and needs no secrets. +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + build: + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + include: + - name: Windows + os: windows-2022 + - name: macOS + os: macos-15 + - name: Linux + os: ubuntu-24.04 + steps: + # Windows runners check out with core.autocrlf=true, and Prettier (like + # every other tool here) expects the LF the repository stores. + - name: Keep LF line endings + run: git config --global core.autocrlf false + + - uses: actions/checkout@v4 + + - uses: ./.github/actions/setup + + - name: Build and test + run: pnpm app --dist --no-run diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml new file mode 100644 index 0000000..3707f9e --- /dev/null +++ b/.github/workflows/promote.yml @@ -0,0 +1,63 @@ +# Step 3 of a release (RELEASING.md): flip the verified pre-release to latest and +# point plectrify.com at it. Started by hand, and only after someone has +# installed every platform's build from the pre-release and played through it — +# this is the sign-off, and it is irreversible: a promoted version is immutable. +# +# The script refuses a release still missing its macOS or Linux artifact. It +# then rewrites site/src/lib/site.ts, commits and pushes that to the branch this +# runs on (run it from main), and deploys the site. The deploy needs the +# CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID repository secrets; without +# them, or with "Deploy the site" unticked, the release is promoted and the site +# left alone (`pnpm --dir site run deploy` from anywhere with credentials). +name: Promote + +on: + workflow_dispatch: + inputs: + version: + description: Version to promote — must equal the VERSION file (e.g. 0.2.0) + required: true + site: + description: Deploy the site + type: boolean + default: true + +concurrency: + group: release + cancel-in-progress: false + +permissions: + contents: write + +jobs: + promote: + name: Promote to latest + # release.windows.ts owns promotion; it needs nothing Windows-specific to do + # it, but runs only there. + runs-on: windows-2022 + timeout-minutes: 30 + env: + GH_TOKEN: ${{ github.token }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + steps: + # Windows runners check out with core.autocrlf=true, and Prettier (like + # every other tool here) expects the LF the repository stores. + - name: Keep LF line endings + run: git config --global core.autocrlf false + + - uses: actions/checkout@v4 + + - uses: ./.github/actions/setup + with: + native: false + + - name: Promote + shell: bash + run: | + flags=(--promote --version "${{ inputs.version }}") + if [ "${{ inputs.site }}" != "true" ] || [ -z "$CLOUDFLARE_API_TOKEN" ]; then + echo "::warning::Not deploying the site (unticked, or no CLOUDFLARE_API_TOKEN secret)." + flags+=(--no-site) + fi + pnpm release "${flags[@]}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..509b838 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,126 @@ +# Steps 1 and 2 of a release (RELEASING.md), on GitHub's runners instead of +# three machines. Started by hand with the version from VERSION, which the +# scripts check against the file — this can confirm the version, never set it. +# +# windows pnpm release builds the installer, tags vX.Y.Z, creates the +# GitHub pre-release with the source archive +# macos pnpm release builds the pkg at that tag and uploads it +# linux pnpm release builds the .deb at that tag and uploads it +# +# The Mac and Linux jobs check out the tag the Windows job pushed, and their +# scripts refuse to upload unless it names the commit they built — the same gate +# that held when this was three machines. Promotion stays a separate, manual +# workflow (promote.yml): it is the sign-off that someone installed the build +# and played through it. This is the only way a release is built: `pnpm +# release` refuses to run outside GitHub Actions. +# +# Needs no secrets beyond the built-in GITHUB_TOKEN: everything published here +# goes to this repository's GitHub release. Release notes are the commit bullets +# (the claude CLI that drafts them locally is not on the runners); edit them on +# GitHub before promoting. Each job also keeps its artifacts on the run for a +# while, for installing before promotion. +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: Version to release — must equal the VERSION file (e.g. 0.2.0) + required: true + +concurrency: + group: release + cancel-in-progress: false + +permissions: + contents: write + +env: + GH_TOKEN: ${{ github.token }} + +jobs: + windows: + name: Windows — installer + pre-release + runs-on: windows-2022 + timeout-minutes: 120 + steps: + # Windows runners check out with core.autocrlf=true, and Prettier (like + # every other tool here) expects the LF the repository stores. + - name: Keep LF line endings + run: git config --global core.autocrlf false + + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # the notes are built from the commits since the last tag + + - uses: ./.github/actions/setup + + - name: Release + run: pnpm release --version ${{ inputs.version }} + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: windows + path: artifacts/ + if-no-files-found: ignore + + macos: + name: macOS — pkg + needs: windows + runs-on: macos-15 + timeout-minutes: 120 + steps: + # Windows runners check out with core.autocrlf=true, and Prettier (like + # every other tool here) expects the LF the repository stores. + - name: Keep LF line endings + run: git config --global core.autocrlf false + + - uses: actions/checkout@v4 + with: + ref: v${{ inputs.version }} + + - uses: ./.github/actions/setup + + - name: Release + run: pnpm release --version ${{ inputs.version }} + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: macos + path: | + artifacts/*.pkg + artifacts/*.sha256 + artifacts/*.json + if-no-files-found: ignore + + linux: + name: Linux — .deb + needs: windows + runs-on: ubuntu-24.04 + timeout-minutes: 120 + steps: + # Windows runners check out with core.autocrlf=true, and Prettier (like + # every other tool here) expects the LF the repository stores. + - name: Keep LF line endings + run: git config --global core.autocrlf false + + - uses: actions/checkout@v4 + with: + ref: v${{ inputs.version }} + + - uses: ./.github/actions/setup + + - name: Release + run: pnpm release --version ${{ inputs.version }} + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: linux + path: | + artifacts/*.deb + artifacts/*.sha256 + artifacts/*.json + if-no-files-found: ignore diff --git a/AGENTS.md b/AGENTS.md index 6c520ca..2535aa1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,8 +4,8 @@ Guidance for AI coding agents working in this repository. ## Project overview -Plectrify is a **guitar-rig VST3 host** for Windows and macOS (Apple -Silicon): a JUCE (C++17) engine that runs a user-ordered chain of VST3 +Plectrify is a **guitar-rig VST3 host** for Windows, macOS (Apple +Silicon) and Linux (x86-64): a JUCE (C++17) engine that runs a user-ordered chain of VST3 plugins (amp sims, effects) live from a guitar input. It ships in two shapes from one engine: the standalone application, and a VST3 plugin that runs the same rig inside a DAW (`Source/plugin/`; core support — release @@ -38,10 +38,13 @@ working rack autosaves so it restores on the next launch. (`JUCE_PLUGINHOST_VST3=1`; VST2/LADSPA are disabled in `CMakeLists.txt`). ASIO is on for Windows builds only (`JUCE_ASIO=1` behind `if(WIN32)`) using the ASIO 2.3 headers bundled with JUCE — no external SDK, no configure-time - detection; macOS talks CoreAudio natively and has no ASIO at all. + detection; macOS talks CoreAudio natively and has no ASIO at all. Linux: + Ninja + GCC/Clang, x86-64 only, WebKitGTK 4.1 for the web view, libcurl for + HTTPS (`JUCE_USE_CURL=1` there only), JACK preferred and ALSA as fallback + (`JUCE_JACK=1`). - **UI**: Svelte 5 (runes) + TypeScript + TailwindCSS v4, built with Vite 6, packaged with **pnpm** (not npm). -- **Bridge**: JUCE web-view backend (WebView2 / WKWebView) with native +- **Bridge**: JUCE web-view backend (WebView2 / WKWebView / WebKitGTK) with native integration; events over `window.__JUCE__` (no direct method calls across the boundary). The UI never learns which backend it is in beyond `AppInfo.platform`, a wording-only discriminator. @@ -52,13 +55,14 @@ working rack autosaves so it restores on the next launch. CMakeLists.txt # native app target, JUCE FetchContent, WebView2 wiring (Windows) scripts/ # root dev/release tooling — every entry point `pnpm` runs run.ts # `pnpm app` — dispatches to run.windows.ts / run.macos.ts - run.windows.ts # Windows dev loop; run.macos.ts is its mac twin - release.ts # `pnpm release:build` / `pnpm release` — dispatch by platform; - # `pnpm release:promote` is step 3 + run.windows.ts # Windows dev loop; run.macos.ts / run.linux.ts are its twins + release.ts # `pnpm release` — GitHub Actions only (.github/workflows/); + # dispatches by platform, `--promote` is step 3 release.windows.ts # Windows release pipeline (Inno Setup installer) release.macos.ts # macOS release pipeline (signed/notarized installer pkg) - uninstall.ts # `pnpm purge` — remove every install and trace, both OSes in one file - shared.ts # helpers: OS-neutral (shared.ts) and per-OS (windows.ts, macos.ts) + release.linux.ts # Linux release pipeline (.deb) + uninstall.ts # `pnpm purge` — remove every install and trace, every OS in one file + shared.ts # helpers: OS-neutral (shared.ts) and per-OS (windows.ts, macos.ts, linux.ts) Source/ # C++ — vertical slices, each folder is on the include path backup/ # BackupArchive (the *.plectrifybackup zip; standalone-only) app/ # Main.cpp (app entry), MainComponent (standalone shell), @@ -68,6 +72,7 @@ Source/ # C++ — vertical slices, each folder is on the include p plugin/ # the VST3 build: PluginProcessor (AudioProcessor + HostServices), # PluginEditor (the shared web view in a DAW's window) plugins/ # PluginManager (VST3 scan / cache / async instantiation) + namplugin/ # Linux only: our JUCE build of Neural Amp Modeler (see Linux below) rackui/ # PluginEditorWindow (hosts a plugin's own editor) tone3000/ # TONE3000 account, its own browser window, downloads, NAM state codec ui/ # Svelte front-end @@ -81,16 +86,21 @@ site/ # plectrify.com — static marketing + docs site (see site third_party/ # vendored, gitignored: Microsoft.Web.WebView2 NuGet package (Windows) build/ # CMake output (gitignored); exe at build/Plectrify_artefacts//Plectrify.exe build-macos-*/ # macOS CMake trees (one per config; single-config generators) +build-linux-*/ # Linux CMake trees, likewise +.github/workflows/ # CI (all three OSes) and the Release/Promote workflows ``` ## Build and run -One dev-loop command on both OSes. `scripts/run.ts` dispatches to the platform +One dev-loop command on every OS. `scripts/run.ts` dispatches to the platform implementation; the tooling is all TypeScript run through tsx, so a single `pnpm install` at the repo root is the whole setup. Prereqs beyond Node+pnpm: Visual Studio 2022/18 with the C++ workload on Windows (`cmake` and `cl` need **not** be on PATH — the script finds the VS-bundled binaries), Xcode Command -Line Tools + CMake (optionally Ninja) on macOS. +Line Tools + CMake (optionally Ninja) on macOS; on Linux CMake, Ninja, a C++ +compiler and the `-dev` packages `scripts/linux.ts` names when one is missing +(WebKitGTK 4.1, GTK 3, ALSA, JACK, curl, FreeType, fontconfig, X11 — the list +in `.github/actions/setup/action.yml` is the canonical one). ```sh pnpm install # once, at the repo root — fetches tsx @@ -120,15 +130,16 @@ pnpm app --plugin # build the Debug VST3 and install it for this user's DAWs `pnpm purge` uninstalls Plectrify and clears every trace it left — the application, the VST3 and (on macOS) the AU, the per-user data root, the -machine-wide content root, the Windows Add/Remove Programs entry and the macOS -installer receipts. Unlike the rest of `scripts/`, it is **one file for both -OSes** (`scripts/uninstall.ts`): a purge is a list of paths, not a build, so the -two halves differ in nothing but the paths. It prints what it would remove and -deletes nothing without `--yes`; `--keep-user-data` spares rigs and patches and -`--keep-packages` spares plugins installed from the Packages panel, which is -what the shipped uninstallers already do. On macOS the managed plugin directory -is shared with every other VST3 vendor, so only the files the install markers -name are removed there — never the folder. +machine-wide content root, the Windows Add/Remove Programs entry, the macOS +installer receipts and the Linux .deb (through `dpkg --purge`). Unlike the rest +of `scripts/`, it is **one file for every OS** (`scripts/uninstall.ts`): a purge +is a list of paths, not a build, so the branches differ in nothing but the +paths. It prints what it would remove and deletes nothing without `--yes`; +`--keep-user-data` spares rigs and patches and `--keep-packages` spares plugins +installed from the Packages panel, which is what the shipped uninstallers +already do. On macOS and Linux the managed plugin directory is shared with +every other VST3 vendor, so only the files the install markers name are removed +there — never the folder. Platform notes: Windows builds into `build/` (VS generator, multi-config) and carries two hazards the script owns — the MSBuild `.tlog` interrupted-build @@ -136,8 +147,30 @@ marker and killing WebView2 orphans that hold the profile lock (see `scripts/windows.ts`). macOS builds into per-config trees (`build-macos-debug`, `build-macos-release`; Ninja/Makefiles are single-config) and must serve the UI from `Contents/Resources/ui` in Release (see `provideResource`); Debug -keeps the source-tree `ui/dist` fallback on both OSes, so the dev loop copies -nothing. +keeps the source-tree `ui/dist` fallback on every OS, so the dev loop copies +nothing. Linux builds into `build-linux-debug`/`build-linux-release` like the +Mac and serves Release's UI from `ui/` beside the binary like Windows; a +plugin's resources live in `Contents/Resources`, which `moduleResourceDir` +maps from the `.vst3`'s `Contents/x86_64-linux`. + +**Linux specifics.** It is the third target, not a replacement, and it uses +the native title bar like macOS. Its paths: data root `~/.config/Plectrify`, +managed plugins `~/.vst3` (shared with other vendors, so non-exclusive exactly +as on macOS), content root `/var/lib/Plectrify`, created mode 1777 by the +.deb's postinst — a dev machine needs `sudo install -d -m 1777 +/var/lib/Plectrify` once, which the dev loop warns about. A first run opens at +most 32 input channels (`inputChannelsToOpen`): PipeWire's ALSA `default` +device advertises 128 and its converter crashes beyond 64. Upstream Neural Amp +Modeler is iPlug2 with no Linux target, so `Source/namplugin/` is our own JUCE +VST3 around NeuralAmpModelerCore and AudioDSPTools (pinned in +`CMakeLists.txt`), built in-tree as `PlectrifyNam` rather than fetched through +`bundled-plugins.json`. It is deliberately named `NeuralAmpModeler` and keeps +upstream's parameter order and iPlug2 state layout, so `NamStateCodec`, the +TONE3000 knob template and every patch work unchanged — keep both in step with +upstream. The core is C++20 and compiled as an OBJECT library, because its +architecture registry lives in static initialisers a static archive drops. +The release is a .deb (`scripts/release.linux.ts`, which explains why not an +AppImage), asset slug `linux-x64`. **Window chrome is per-platform.** Windows gets Plectrify's own title bar, the themed surface `WindowLookAndFeel` draws, with square caption buttons; macOS @@ -414,7 +447,12 @@ slug, and **no platform is the default**: ``` Keys are written alphabetically so the file cannot be read as "Windows, plus -some others". At least one is required — a package offered nowhere is not a +some others". The slugs are `linux-x64`, `macos-arm64` and `windows-x64` +(`ASSET_PLATFORMS`). A `linux-x64` asset is pinned by hand from an upstream +build that unzips to a `.vst3` — `build-plugin`/`host-plugin` have no Linux +route yet, which is why Surge XT and Dragonfly (tarballs upstream) are not +offered there. No patch pack has a Linux folder yet either: each must be +authored against `/var/lib/Plectrify`. At least one is required — a package offered nowhere is not a package — and a platform with no entry is not offered it: the row renders greyed with install disabled, visible rather than hidden, so the catalogue never looks quietly thinner per OS. Selection is one map lookup of @@ -1102,11 +1140,12 @@ between two lanes; no lane can be emptied or born by one. The TS mirror is ### Persistence (all under the per-user data root) -The per-user root is `%APPDATA%/Plectrify/` on Windows and -`~/Library/Application Support/Plectrify/` on macOS — one definition, +The per-user root is `%APPDATA%/Plectrify/` on Windows, +`~/Library/Application Support/Plectrify/` on macOS and `~/.config/Plectrify/` +on Linux — one definition, `plectrify::appDataDir()` in `Source/app/AppPaths.h`, which is also the web -page's file sandbox. The `%APPDATA%` shorthand below means that root on both -OSes. +page's file sandbox. The `%APPDATA%` shorthand below means that root on every +OS. - `rigs/` + `rigs/index.json` — saved full-chain rigs. TS owns the format, naming and listing; C++ only captures/applies plugin state (`captureRig` / @@ -1152,12 +1191,13 @@ OSes. leaves behind; see *Backup and restore* below. Outside the per-user root, under the machine-wide content root — -`%PROGRAMDATA%/Plectrify/` on Windows, `/Users/Shared/Plectrify/` on macOS -(`CatalogueInstaller::contentRootDirectory`) — with one exception: on macOS -the managed plugin directory is `~/Library/Audio/Plug-Ins/VST3` (the per-user -VST3 convention; machine-wide `/Library` is admin-owned there, which would -break the no-elevation install model), and the install markers live inside it -at `.plectrify-installed/`: +`%PROGRAMDATA%/Plectrify/` on Windows, `/Users/Shared/Plectrify/` on macOS, +`/var/lib/Plectrify/` on Linux (`CatalogueInstaller::contentRootDirectory`) — +with one exception: on macOS the managed plugin directory is +`~/Library/Audio/Plug-Ins/VST3` (the per-user VST3 convention; machine-wide +`/Library` is admin-owned there, which would break the no-elevation install +model), and on Linux likewise `~/.vst3`; the install markers live inside it at +`.plectrify-installed/`: - `plugins/` — VST3s the user installed from the **Packages** panel (`CatalogueInstaller`), plus `.plectrify-installed/.json` install markers diff --git a/CMakeLists.txt b/CMakeLists.txt index a27fee0..046ee3c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -24,7 +24,9 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) # CI-friendly builds. First configure downloads JUCE (a few minutes). # --------------------------------------------------------------------------- # The WebView2 SDK is Windows-only: macOS uses the OS-provided WKWebView, which -# needs no SDK package and no runtime installer. +# needs no SDK package and no runtime installer, and Linux uses WebKitGTK from +# the distribution (libwebkit2gtk-4.1-dev at build time, the runtime library at +# run time). if (WIN32) # Where JUCE's FindWebView2 looks for the Microsoft.Web.WebView2 NuGet package # (fetched into third_party/). A plain (non-cache) variable so it reliably @@ -67,7 +69,7 @@ FetchContent_Declare( # cycle on every configure; clean build trees still clone and patch once. UPDATE_DISCONNECTED TRUE # Small guarded host-side fixes JUCE 8.0.14 needs, applied at fetch - # time. Each platform-specific patch is applied on both OSes anyway, + # time. Each platform-specific patch is applied on every OS anyway, # because the patched tree is what the release scripts publish as # corresponding source: one tree, one answer to "what was this built from". # - webview2-zoom: 8.0.14 does not expose WebView2's zoom settings @@ -119,7 +121,11 @@ FetchContent_MakeAvailable(JUCE) # entry (without which macOS silently denies audio input — a guitar rig's whole # point), and the hardened runtime notarization requires. The # disable-library-validation entitlement is what lets a hardened-runtime -# process load third-party VST3 code not signed by our team. +# process load third-party VST3 code not signed by our team. Linux asks JUCE +# for its two optional system libraries: WebKitGTK for the embedded UI (without +# NEEDS_WEB_BROWSER JUCE compiles the browser out there) and libcurl, because +# JUCE's own socket fallback speaks plain HTTP and every URL Plectrify fetches — +# catalogue, packages, TONE3000 — is HTTPS. if (WIN32) set(PLECTRIFY_PLATFORM_APP_ARGS NEEDS_WEBVIEW2 TRUE) elseif (APPLE) @@ -130,7 +136,7 @@ elseif (APPLE) HARDENED_RUNTIME_ENABLED TRUE HARDENED_RUNTIME_OPTIONS com.apple.security.device.audio-input com.apple.security.cs.disable-library-validation) else() - set(PLECTRIFY_PLATFORM_APP_ARGS "") + set(PLECTRIFY_PLATFORM_APP_ARGS NEEDS_WEB_BROWSER TRUE NEEDS_CURL TRUE) endif() juce_add_gui_app(Plectrify @@ -357,12 +363,19 @@ function(plectrify_configure_target target) JUCE_USE_WINRT_MIDI=0) endif() + # JUCE's URL is curl-backed on Linux or HTTP-only; Windows and macOS use + # the OS stack and keep curl out (see NEEDS_CURL above). + if (CMAKE_SYSTEM_NAME STREQUAL "Linux") + target_compile_definitions(${target} PRIVATE JUCE_USE_CURL=1) + else() + target_compile_definitions(${target} PRIVATE JUCE_USE_CURL=0) + endif() + target_compile_definitions(${target} PRIVATE JUCE_PLUGINHOST_VST3=1 # host VST3 plugins JUCE_PLUGINHOST_VST=0 # legacy VST2 needs the Steinberg SDK/licence JUCE_PLUGINHOST_LADSPA=0 JUCE_WEB_BROWSER=1 # host the embedded Svelte UI - JUCE_USE_CURL=0 # Debug-only source-tree fallback for serving ui/dist. Release binaries # must not bake a dev-machine path; they serve only the staged ui/ # directory — next to the executable on Windows (scripts/run.windows.ts @@ -445,6 +458,15 @@ if (WIN32) target_compile_definitions(Plectrify PRIVATE JUCE_ASIO=1) endif() +# JACK — Linux's ASIO, for the same reason and with the same app-only scope. +# JUCE dlopens libjack at run time, so a machine without it simply lists ALSA +# alone; on a current desktop the library is PipeWire's, which serves JACK +# clients alongside everything else on the sound server. ALSA is JUCE's +# default and needs no switch. +if (CMAKE_SYSTEM_NAME STREQUAL "Linux") + target_compile_definitions(Plectrify PRIVATE JUCE_JACK=1) +endif() + # The window-chrome assets (title-bar wordmark font, icons) are the shell's # alone — the plugin has no JUCE-drawn chrome. target_link_libraries(Plectrify PRIVATE PlectrifyAssets) @@ -469,11 +491,12 @@ elseif (APPLE) HARDENED_RUNTIME_ENABLED TRUE HARDENED_RUNTIME_OPTIONS com.apple.security.device.audio-input com.apple.security.cs.disable-library-validation) else() - set(PLECTRIFY_PLATFORM_PLUGIN_ARGS "") + # The app's two Linux system libraries, for the same reasons. + set(PLECTRIFY_PLATFORM_PLUGIN_ARGS NEEDS_WEB_BROWSER TRUE NEEDS_CURL TRUE) endif() # AU exists for Logic and GarageBand, the mainstream hosts that load no other -# format; every other mac DAW takes the VST3. Windows has no AU at all. +# format; every other mac DAW takes the VST3. Windows and Linux have no AU at all. if (APPLE) set(PLECTRIFY_PLUGIN_FORMATS VST3 AU) else() @@ -530,6 +553,115 @@ if (WIN32) target_link_options(PlectrifyPlugin_VST3 PRIVATE $<$:/DEBUG /OPT:REF /OPT:ICF>) endif() +# --------------------------------------------------------------------------- +# Neural Amp Modeler for Linux (Source/namplugin/). Upstream's plugin is built on +# iPlug2, which has no Linux target, and Plectrify ships NAM because every +# TONE3000 tone is a capture for it — so on Linux the same DSP is wrapped in a +# JUCE VST3 of our own. It is a separate plugin with no engine sources, built +# here only so one configure produces everything a Linux release stages. +# +# The DSP is fetched at exactly the commits NAM v0.7.15 pins as submodules, as +# hash-checked archives (no submodule recursion, nothing floating). SOURCE_SUBDIR +# names a directory that does not exist so FetchContent only unpacks: their own +# CMakeLists build test tools we have no use for. +# +# PRODUCT_NAME is upstream's PLUG_NAME on purpose: patches, the drawer and the +# TONE3000 slice find NAM by name, so this is the name that makes every one of +# them work unchanged. Manufacturer and class id are ours. +# --------------------------------------------------------------------------- +if (CMAKE_SYSTEM_NAME STREQUAL "Linux") + FetchContent_Declare(NeuralAmpModelerCore + URL https://github.com/sdatkinson/NeuralAmpModelerCore/archive/9c7b185de346fe0725dea537bcee4bc38b5bb6d6.tar.gz + URL_HASH SHA256=e10ab1f1bb3b28623508c26cd143c8f10bdd5571b3cf725c56c70dc67cde3398 + DOWNLOAD_EXTRACT_TIMESTAMP TRUE + SOURCE_SUBDIR plectrify-unpack-only) + FetchContent_Declare(AudioDSPTools + URL https://github.com/sdatkinson/AudioDSPTools/archive/0827c6c2fc0deced568536142ea86f189e0b98a1.tar.gz + URL_HASH SHA256=2557d2ee58481b1bafa63e528b9c6f2b2ae67adb2f55e2c1d13c90ea99b420d5 + DOWNLOAD_EXTRACT_TIMESTAMP TRUE + SOURCE_SUBDIR plectrify-unpack-only) + FetchContent_Declare(Eigen + URL https://gitlab.com/libeigen/eigen/-/archive/ed8cda3ce4cb9d12640f9236fed968655a93b604/eigen-ed8cda3ce4cb9d12640f9236fed968655a93b604.tar.gz + URL_HASH SHA256=64a7514025aafeaa8c613870f3cd395fb98b8687fe3d2b64b1392fba59c4d9e2 + DOWNLOAD_EXTRACT_TIMESTAMP TRUE + SOURCE_SUBDIR plectrify-unpack-only) + FetchContent_MakeAvailable(NeuralAmpModelerCore AudioDSPTools Eigen) + + juce_add_plugin(PlectrifyNam + PRODUCT_NAME "NeuralAmpModeler" + COMPANY_NAME "Plectrify" + VERSION "0.7.15" + FORMATS VST3 + PLUGIN_MANUFACTURER_CODE Plct + PLUGIN_CODE Pnam + VST3_CATEGORIES Fx Distortion + IS_SYNTH FALSE + NEEDS_MIDI_INPUT FALSE + NEEDS_MIDI_OUTPUT FALSE + IS_MIDI_EFFECT FALSE + COPY_PLUGIN_AFTER_BUILD FALSE + VST3_AUTO_MANIFEST FALSE) + juce_generate_juce_header(PlectrifyNam) + + # NAM's own DSP as an OBJECT library whose objects go straight into the + # VST3 module. Not merely into PlectrifyNam: that is JUCE's shared-code + # *static* library, and NAM registers its model architectures (WaveNet, + # LSTM, ...) from static initialisers nothing references — so the linker + # drops them from an archive and every capture then fails to load with + # "No config parser registered for architecture". + file(GLOB PLECTRIFY_NAM_CORE_SOURCES CONFIGURE_DEPENDS + "${neuralampmodelercore_SOURCE_DIR}/NAM/*.cpp" + "${neuralampmodelercore_SOURCE_DIR}/NAM/wavenet/*.cpp") + add_library(PlectrifyNamDsp OBJECT + Source/namplugin/ToneStack.cpp + ${PLECTRIFY_NAM_CORE_SOURCES} + "${audiodsptools_SOURCE_DIR}/dsp/dsp.cpp" + "${audiodsptools_SOURCE_DIR}/dsp/ImpulseResponse.cpp" + "${audiodsptools_SOURCE_DIR}/dsp/NoiseGate.cpp" + "${audiodsptools_SOURCE_DIR}/dsp/RecursiveLinearFilter.cpp" + "${audiodsptools_SOURCE_DIR}/dsp/wav.cpp") + set_target_properties(PlectrifyNamDsp PROPERTIES POSITION_INDEPENDENT_CODE ON) + + # NeuralAmpModelerCore is C++20; none of these targets shares sources with + # the C++17 engine, so they can simply have their own standard. That has to + # include JUCE's VST3 wrapper target: it compiles JUCE headers into the same + # module, and std::atomic is a different type under C++17 and C++20, + # which made every JUCE parameter class an ODR violation at link time. + target_compile_features(PlectrifyNam_VST3 PRIVATE cxx_std_20) + foreach(nam_target PlectrifyNam PlectrifyNamDsp) + target_compile_features(${nam_target} PRIVATE cxx_std_20) + target_include_directories(${nam_target} PRIVATE Source/namplugin) + # Third-party headers: SYSTEM, so our warning flags do not judge them. + target_include_directories(${nam_target} SYSTEM PRIVATE + "${neuralampmodelercore_SOURCE_DIR}" + "${audiodsptools_SOURCE_DIR}" + "${neuralampmodelercore_SOURCE_DIR}/Dependencies/nlohmann" + "${eigen_SOURCE_DIR}") + # Upstream's own build switch (NeuralAmpModeler-win.props). + target_compile_definitions(${nam_target} PRIVATE NAM_ENABLE_A2_FAST) + # Optimised in every configuration. A model is Eigen maths that is many + # times slower at -O0 with Eigen's asserts on, so a Debug NAM — which + # is what the dev loop stages — crackled at any playable buffer size. + # This is third-party DSP nobody steps through; Debug is for our code. + target_compile_options(${nam_target} PRIVATE $<$:-O2>) + target_compile_definitions(${nam_target} PRIVATE $<$:EIGEN_NO_DEBUG>) + endforeach() + + target_sources(PlectrifyNam PRIVATE + Source/namplugin/NamPluginProcessor.cpp + Source/namplugin/NamPluginEditor.cpp) + target_sources(PlectrifyNam_VST3 PRIVATE $) + target_compile_definitions(PlectrifyNam PRIVATE + JUCE_USE_CURL=0 + JUCE_WEB_BROWSER=0 + JUCE_VST3_CAN_REPLACE_VST2=0) + target_link_libraries(PlectrifyNam PRIVATE + juce::juce_audio_utils + PUBLIC + juce::juce_recommended_config_flags + juce::juce_recommended_lto_flags) +endif() + # Focused synthetic-signal coverage for the tuner DSP. This remains a separate # target so normal application builds do not pull test code into the host. include(CTest) diff --git a/RELEASING.md b/RELEASING.md index 2a24b05..78e39ac 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,45 +1,31 @@ # Releasing Plectrify (maintainer guide) -## Steps - -**1. Prepare.** Set `VERSION` (`MAJOR.MINOR.PATCH`) and commit everything — -every script refuses a dirty tree. The GitHub release is the changelog; there is -no CHANGELOG in the repo. - -**2. Windows — build and test.** - -```sh -pnpm release:build -``` - -Builds, tests and packages the installer into `artifacts/-/`, -publishing nothing. Install it and play through it: nothing after this rebuilds -the app. - -**3. Windows — publish the pre-release.** +Releases are built by GitHub Actions and nowhere else — `pnpm release` refuses +to run outside a runner. To build and test locally, use `pnpm app --dist` +(`--config Release` for a Release build); it runs the same gates and publishes +nothing. -```sh -pnpm release -``` +## Steps -Builds again, tags `vX.Y.Z`, pushes it, and creates the GitHub release — always -marked pre-release — with the installer, the corresponding-source archive, -checksums and the manifest. Notes are drafted by the local `claude` CLI, falling -back to commit bullets. +**1. Prepare.** Set `VERSION` (`MAJOR.MINOR.PATCH`), commit, and get it onto +`main` with CI green. The GitHub release is the changelog; there is no +CHANGELOG in the repo. -**4. Mac — build, test, add the pkg.** At the same commit; the script refuses -otherwise. +**2. Run the Release workflow** (Actions → Release → Run workflow, on `main`, +with the version from `VERSION`): -```sh -git checkout vX.Y.Z -pnpm release:build # local pkg, publishes nothing -pnpm release # uploads the pkg, its checksum and the manifest -``` +- **Windows** builds and tests the installer, tags `vX.Y.Z`, pushes the tag and + creates the GitHub release — always marked pre-release — with the installer, + the corresponding-source archive, checksums and the manifest. +- **macOS** and **Linux** then check out that tag, build and test the pkg and + the .deb, and upload each with its checksum and manifest. Their scripts + refuse to upload unless the tag names the commit they built. -Re-running replaces the mac assets only, leaving the Windows ones and the notes -alone. +Notes are the commit bullets since the last tag. Each job also attaches its +artifacts to the workflow run. -**5. Verify.** Review and correct the notes on GitHub — promotion never touches +**3. Verify.** Install every platform's build from the pre-release and play +through it. Review and correct the notes on GitHub — promotion never touches them — then check the catalogue the installed builds fetch: ```sh @@ -49,42 +35,35 @@ pnpm --dir packaging verify-live A release can publish perfectly while the CDN serves 404s; the first symptom is an empty Packages panel on someone else's machine. -**6. Windows — promote.** - -```sh -pnpm release:promote -``` - -Marks the release **latest** (refused until the mac assets are there), then -points plectrify.com at it: `site/src/lib/site.ts` is rewritten, committed, -pushed and deployed. If only the deploy fails, the promotion still stands — run -`pnpm --dir site run deploy`. `--no-site` skips that half. +**4. Run the Promote workflow** (Actions → Promote, on `main`, same version). +Marks the release **latest** (refused until the macOS and Linux assets are +there), then points plectrify.com at it: `site/src/lib/site.ts` is rewritten, +committed, pushed to `main` and deployed. The deploy needs the +`CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` repository secrets; without +them, or with *Deploy the site* unticked, the site is left alone. If only the +deploy fails, the promotion still stands — run `pnpm --dir site run deploy`. ## Notes -**Mac signing.** There is no Apple Developer Program membership, so the mac -build is ad-hoc signed by default: the pkg is unsigned, not notarized, and -without the hardened runtime. Whoever downloads it must clear the quarantine -flag (`site/src/routes/docs/opening-on-macos/+page.md` is linked from the -download buttons), and every update re-asks for microphone permission. Once -there is a membership, add `--developer-id` to either command, then make it the -default in `scripts/release.ts` and update the README, the getting-started page -and the download buttons. +**Secrets.** Steps 2 and 4 need nothing but the built-in `GITHUB_TOKEN`; only +the site deploy needs Cloudflare's. The catalogue signing key is unrelated to +releases and stays offline (`packaging/README.md`). -**Redoing a step.** A pre-release can be rebuilt and republished — the release -and tag are deleted first, so hand-edited notes are lost. A promoted version is -immutable; correct it by bumping `VERSION`. +**Mac signing.** There is no Apple Developer Program membership, so the mac +build is ad-hoc signed: the pkg is unsigned, not notarized, and without the +hardened runtime. Whoever downloads it must clear the quarantine flag +(`site/src/routes/docs/opening-on-macos/+page.md` is linked from the download +buttons), and every update re-asks for microphone permission. Developer ID +signing (`--developer-id`) would need both certificates imported into a runner +keychain and a notarytool profile named `plectrify` — the workflow does neither +yet. + +**Redoing a step.** A pre-release can be rebuilt by re-running the workflow — +the release and tag are deleted first, so hand-edited notes are lost. A single +failed macOS or Linux job can be re-run on its own; it replaces only its own +assets. A promoted version is immutable; correct it by bumping `VERSION`. **Flags.** `--version` can only agree or abort, never override `VERSION`. -`--skip-tests` cannot publish — pair it with `pnpm release:build`. Everything -else is forwarded to `scripts/release.windows.ts` / `scripts/release.macos.ts` -untouched; only `--local` and `--developer-id` belong to the dispatcher. -`ui/package.json` mirrors the three commands for when you are already in `ui/`. - -**Prerequisites.** `pnpm install` at the root (`packaging/` and `ui/` install -themselves); `gh auth login` for anything that publishes. Windows bootstraps -Inno Setup, and caches the pinned WebView2 and VC++ redistributables in -`.release-cache/` (`--webview2-installer`, `--vcredist-installer`, -`--inno-setup-installer` for offline builds). `--developer-id` on the Mac also -needs both Developer ID certificates in the login keychain and a notarytool -profile named `plectrify`. +Everything else is forwarded to `scripts/release..ts` untouched; only +`--developer-id` belongs to the dispatcher. Windows caches the pinned WebView2 +and VC++ redistributables in `.release-cache/` and uses the runner's Inno Setup. diff --git a/Source/app/AppPaths.h b/Source/app/AppPaths.h index 893879b..3f2b96e 100644 --- a/Source/app/AppPaths.h +++ b/Source/app/AppPaths.h @@ -6,7 +6,10 @@ namespace plectrify { /** Per-user Plectrify data root — %APPDATA%\Plectrify on Windows, ~/Library/Application Support/Plectrify on macOS (JUCE's - userApplicationDataDirectory is bare ~/Library there). + userApplicationDataDirectory is bare ~/Library there), and + ~/.config/Plectrify on Linux (JUCE's answer there, honouring neither + XDG_CONFIG_HOME nor XDG_DATA_HOME — one fixed place is what + `pnpm purge` and a backup can name). This tree doubles as the web page's file sandbox (MainComponent::resolveAppFile), so nothing that must stay out of the @@ -38,7 +41,10 @@ namespace plectrify /Contents/x86_64-win/, and the resources mirror the mac layout at the sibling Contents/Resources. currentExecutableFile resolves the module that contains this code (the DLL, in a plugin), never the host - exe. */ + exe. + + Linux: the same as Windows, with the VST3 module directory named + x86_64-linux instead — the bundle layout is the SDK's on both. */ inline juce::File moduleResourceDir() { #if JUCE_MAC @@ -48,7 +54,8 @@ namespace plectrify const auto moduleDir = juce::File::getSpecialLocation (juce::File::currentExecutableFile) .getParentDirectory(); - if (moduleDir.getFileName().equalsIgnoreCase ("x86_64-win") + if ((moduleDir.getFileName().equalsIgnoreCase ("x86_64-win") + || moduleDir.getFileName().equalsIgnoreCase ("x86_64-linux")) && moduleDir.getParentDirectory().getFileName().equalsIgnoreCase ("Contents")) return moduleDir.getParentDirectory().getChildFile ("Resources"); diff --git a/Source/app/AudioSetupRules.h b/Source/app/AudioSetupRules.h index 49875de..c9ac0cb 100644 --- a/Source/app/AudioSetupRules.h +++ b/Source/app/AudioSetupRules.h @@ -26,10 +26,15 @@ namespace plectrify::audiosetup everything else on that list is a shared mixer with a buffer of its own on top of ours. macOS has no such split — CoreAudio *is* the low-latency path — so its single type is chosen by the fallback below rather than by - name. */ + name. Linux has the Windows split: JACK (served by PipeWire on a current + desktop) shares the interface at the latency the player sets, while ALSA + either opens the hardware exclusively — failing outright while a desktop + sound server holds it — or plays through that server's own buffer. A + JACK type with no server behind it fails to open, and the caller's + fallback then takes JUCE's default, exactly as a dead ASIO driver does. */ inline const juce::StringArray& preferredDeviceTypes() { - static const juce::StringArray types { "ASIO" }; + static const juce::StringArray types { "ASIO", "JACK" }; return types; } @@ -86,4 +91,22 @@ namespace plectrify::audiosetup // which is the closest this device can come to being safe. return sorted.getLast(); } + + /** The most input channels a device is ever opened with. + + Opening every input the device lists is what lets the wizard hear + which jack the guitar is in — but a device's list is not always a set + of jacks. PipeWire's ALSA bridge (the `default` device on a current + Linux desktop) advertises 128 virtual channels, and its audioconvert + stage handles at most 64 (SPA_AUDIO_MAX_CHANNELS): opening all of them + crashes the sound server's thread inside our process. No interface a + guitar is plugged into comes near the cap, so it costs nothing real. */ + constexpr int maxOpenedInputChannels = 32; + + /** How many of a device's listed inputs to switch on: all of them, up to + maxOpenedInputChannels. */ + inline int inputChannelsToOpen (int listedInputs) + { + return juce::jlimit (0, maxOpenedInputChannels, listedInputs); + } } diff --git a/Source/app/CrashReporter.h b/Source/app/CrashReporter.h index 66b3c77..a2a3946 100644 --- a/Source/app/CrashReporter.h +++ b/Source/app/CrashReporter.h @@ -91,11 +91,16 @@ namespace plectrify #if JUCE_WINDOWS SetUnhandledExceptionFilter (detail::writeCrashDump); #else - // No minidump machinery on macOS — the OS writes a .ips crash report - // itself. A log line still marks that we went down abnormally. + // No minidump machinery off Windows — macOS writes a .ips crash report + // itself, and Linux leaves a core to systemd-coredump or apport. A log + // line still marks that we went down abnormally. juce::SystemStats::setApplicationCrashHandler ([] (void*) { + #if JUCE_MAC juce::Logger::writeToLog ("CRASH: fatal signal — see the macOS crash report"); + #else + juce::Logger::writeToLog ("CRASH: fatal signal - see coredumpctl or /var/crash"); + #endif }); #endif } diff --git a/Source/app/Main.cpp b/Source/app/Main.cpp index 408b9be..f869d4e 100644 --- a/Source/app/Main.cpp +++ b/Source/app/Main.cpp @@ -62,7 +62,12 @@ class PlectrifyApplication : public juce::JUCEApplication // custom chrome is Windows-only. WindowLookAndFeel is still set on // both, but on macOS only its frame colour is ever consulted; the // title bar it can draw is never asked for. - #if JUCE_MAC + // + // Linux takes the native decoration too: the window manager's + // frame is what tiling, snapping, workspaces and Wayland's + // server-side decorations all hang off, and a borderless X11 + // window opts out of every one of them. + #if JUCE_MAC || JUCE_LINUX setUsingNativeTitleBar (true); #else setUsingNativeTitleBar (false); diff --git a/Source/app/MainComponent.cpp b/Source/app/MainComponent.cpp index c21426e..69e5ea4 100644 --- a/Source/app/MainComponent.cpp +++ b/Source/app/MainComponent.cpp @@ -496,7 +496,9 @@ void MainComponent::applyRecommendedDeviceSetup() buffer > 0) setup.bufferSize = buffer; - if (const auto inputs = device->getInputChannelNames().size(); inputs > 0) + if (const auto inputs = plectrify::audiosetup::inputChannelsToOpen ( + device->getInputChannelNames().size()); + inputs > 0) { setup.inputChannels.clear(); setup.inputChannels.setRange (0, inputs, true); @@ -516,7 +518,8 @@ void MainComponent::enableAllInputChannels() // than set generously and left to be clamped: JUCE's Windows backends size // their channel map from the highest set bit, so a mask claiming channels // that are not there is read as channels that are. - const auto inputs = device->getInputChannelNames().size(); + const auto inputs = plectrify::audiosetup::inputChannelsToOpen ( + device->getInputChannelNames().size()); auto setup = deviceManager.getAudioDeviceSetup(); if (inputs <= 0 || setup.inputChannels.countNumberOfSetBits() >= inputs) return; // already all on — and reopening a device costs a dropout diff --git a/Source/app/PlectrifyEngine.cpp b/Source/app/PlectrifyEngine.cpp index 2eebfdc..5c1e76a 100644 --- a/Source/app/PlectrifyEngine.cpp +++ b/Source/app/PlectrifyEngine.cpp @@ -630,6 +630,8 @@ juce::var PlectrifyEngine::buildAppInfoState() // worth stating) can branch without parsing a display string. #if JUCE_MAC info->setProperty ("platform", "macos"); + #elif JUCE_LINUX + info->setProperty ("platform", "linux"); #else info->setProperty ("platform", "windows"); #endif diff --git a/Source/app/SystemStatsHelper.h b/Source/app/SystemStatsHelper.h index cc34ed7..71784da 100644 --- a/Source/app/SystemStatsHelper.h +++ b/Source/app/SystemStatsHelper.h @@ -7,6 +7,8 @@ #include #elif JUCE_MAC #include +#elif JUCE_LINUX + #include #endif namespace plectrify @@ -26,6 +28,13 @@ namespace plectrify if (task_info (mach_task_self(), MACH_TASK_BASIC_INFO, reinterpret_cast (&info), &count) == KERN_SUCCESS) return static_cast (info.resident_size) / (1024.0 * 1024.0); + #elif JUCE_LINUX + // statm's second field is the resident set, in pages. + const auto fields = juce::StringArray::fromTokens ( + juce::File ("/proc/self/statm").loadFileAsString(), true); + if (fields.size() >= 2) + return fields[1].getDoubleValue() * static_cast (::sysconf (_SC_PAGESIZE)) + / (1024.0 * 1024.0); #endif return 0.0; } diff --git a/Source/namplugin/NamPluginEditor.cpp b/Source/namplugin/NamPluginEditor.cpp new file mode 100644 index 0000000..937538c --- /dev/null +++ b/Source/namplugin/NamPluginEditor.cpp @@ -0,0 +1,109 @@ +#include "NamPluginEditor.h" + +namespace +{ +constexpr int slotHeight = 32; +constexpr int margin = 10; +} // namespace + +NamPluginEditor::NamPluginEditor (NamPluginProcessor& p) + : AudioProcessorEditor (p), processor (p), parameters (p) +{ + setUpSlot (modelSlot, "Model", "*.nam", true); + setUpSlot (irSlot, "IR", "*.wav", false); + + status.setColour (juce::Label::textColourId, juce::Colours::orange); + addAndMakeVisible (status); + addAndMakeVisible (parameters); + + setResizable (true, true); + setResizeLimits (420, 360, 1200, 1400); + setSize (520, 620); + + timerCallback(); + startTimerHz (4); +} + +NamPluginEditor::~NamPluginEditor() +{ + stopTimer(); +} + +void NamPluginEditor::setUpSlot (FileSlot& slot, const juce::String& title, + const juce::String& pattern, bool isModel) +{ + slot.title.setText (title, juce::dontSendNotification); + slot.title.setFont (juce::FontOptions (15.0f, juce::Font::bold)); + slot.file.setMinimumHorizontalScale (0.6f); + + slot.choose.onClick = [this, pattern, isModel, title] + { + chooser = std::make_unique ("Choose " + title, juce::File(), pattern); + chooser->launchAsync (juce::FileBrowserComponent::openMode | juce::FileBrowserComponent::canSelectFiles, + [this, isModel] (const juce::FileChooser& fc) + { + const auto file = fc.getResult(); + if (file == juce::File()) + return; + + const auto error = isModel ? processor.loadModel (file) + : processor.loadImpulseResponse (file); + status.setText (error, juce::dontSendNotification); + timerCallback(); + }); + }; + + slot.clear.onClick = [this, isModel] + { + if (isModel) + processor.clearModel(); + else + processor.clearImpulseResponse(); + status.setText ({}, juce::dontSendNotification); + timerCallback(); + }; + + for (auto* c : std::initializer_list { &slot.title, &slot.file, &slot.choose, &slot.clear }) + addAndMakeVisible (c); +} + +void NamPluginEditor::showSlot (FileSlot& slot, const juce::String& path) +{ + const auto text = path.isEmpty() ? juce::String ("(none)") : juce::File (path).getFileName(); + if (slot.file.getText() != text) + { + slot.file.setText (text, juce::dontSendNotification); + slot.file.setTooltip (path); + } + slot.clear.setEnabled (path.isNotEmpty()); +} + +void NamPluginEditor::timerCallback() +{ + showSlot (modelSlot, processor.getModelPath()); + showSlot (irSlot, processor.getImpulseResponsePath()); +} + +void NamPluginEditor::paint (juce::Graphics& g) +{ + g.fillAll (getLookAndFeel().findColour (juce::ResizableWindow::backgroundColourId)); +} + +void NamPluginEditor::resized() +{ + auto area = getLocalBounds().reduced (margin); + + for (auto* slot : { &modelSlot, &irSlot }) + { + auto row = area.removeFromTop (slotHeight); + slot->title.setBounds (row.removeFromLeft (60)); + slot->clear.setBounds (row.removeFromRight (70).reduced (2)); + slot->choose.setBounds (row.removeFromRight (80).reduced (2)); + slot->file.setBounds (row); + area.removeFromTop (4); + } + + status.setBounds (area.removeFromTop (22)); + area.removeFromTop (margin); + parameters.setBounds (area); +} diff --git a/Source/namplugin/NamPluginEditor.h b/Source/namplugin/NamPluginEditor.h new file mode 100644 index 0000000..d5d375d --- /dev/null +++ b/Source/namplugin/NamPluginEditor.h @@ -0,0 +1,49 @@ +#pragma once + +#include + +#include "NamPluginProcessor.h" + +/** + The Linux Neural Amp Modeler's own window: two file slots (the capture and + the cabinet IR) above JUCE's generic parameter editor. + + Deliberately plain. Inside Plectrify the six knobs live on the module card and + a TONE3000 pick loads the capture without this window ever opening; what this + is for is the rest — choosing a capture by hand, the IR, the noise gate and + calibration switches — which upstream's iPlug2 editor offers and a host's + generic view would leave without a way to pick a file. + + Message thread only. Polls the processor's paths on a timer, since a host or + Plectrify can load a new state while the window is open. +*/ +class NamPluginEditor final : public juce::AudioProcessorEditor, + private juce::Timer +{ +public: + explicit NamPluginEditor (NamPluginProcessor&); + ~NamPluginEditor() override; + + void paint (juce::Graphics&) override; + void resized() override; + +private: + /** One file slot: what is loaded, a button to choose another, one to clear. */ + struct FileSlot + { + juce::Label title, file; + juce::TextButton choose { "Load..." }, clear { "Clear" }; + }; + + void timerCallback() override; + void setUpSlot (FileSlot&, const juce::String& title, const juce::String& pattern, bool isModel); + void showSlot (FileSlot&, const juce::String& path); + + NamPluginProcessor& processor; + FileSlot modelSlot, irSlot; + juce::Label status; + juce::GenericAudioProcessorEditor parameters; + std::unique_ptr chooser; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (NamPluginEditor) +}; diff --git a/Source/namplugin/NamPluginProcessor.cpp b/Source/namplugin/NamPluginProcessor.cpp new file mode 100644 index 0000000..0a9cd73 --- /dev/null +++ b/Source/namplugin/NamPluginProcessor.cpp @@ -0,0 +1,422 @@ +#include "NamPluginProcessor.h" +#include "NamPluginEditor.h" + +#include "NAM/activations.h" +#include "NAM/get_dsp.h" + +#include + +namespace +{ +const char* const stateMarker = "###NeuralAmpModeler###"; // iPlug2 NAM's, verbatim +constexpr double dcBlockerFrequency = 5.0; // upstream's kDCBlockerFrequency +constexpr double normalizedLoudnessTarget = -18.0; // upstream's targetLoudness + +double dbToAmp (double db) { return std::pow (10.0, db / 20.0); } + +/** One iPlug2 IByteChunk::PutStr field: an int32 byte count, then the bytes, + no terminator. Little-endian, as every platform Plectrify ships on is. */ +void putString (juce::MemoryOutputStream& out, const juce::String& text) +{ + const auto* utf8 = text.toRawUTF8(); + const auto length = (int) std::strlen (utf8); + out.writeInt (length); + out.write (utf8, (size_t) length); +} + +bool getString (juce::MemoryInputStream& in, juce::String& result) +{ + if (in.getNumBytesRemaining() < 4) + return false; + const auto length = in.readInt(); + if (length < 0 || length > in.getNumBytesRemaining()) + return false; + juce::MemoryBlock bytes; + in.readIntoMemoryBlock (bytes, length); + result = juce::String::fromUTF8 ((const char*) bytes.getData(), length); + return true; +} +} // namespace + +NamPluginProcessor::NamPluginProcessor() + : AudioProcessor (BusesProperties() + .withInput ("Input", juce::AudioChannelSet::stereo(), true) + .withOutput ("Output", juce::AudioChannelSet::stereo(), true)) +{ + nam::activations::Activation::enable_fast_tanh(); + + // Upstream's EParams order, names and ranges (NeuralAmpModeler.cpp, v0.7.15), + // after the Bypass iPlug2 exports first. The order *is* the interface: knob + // mappings address parameters by index. + addParameter (bypass = new juce::AudioParameterBool ({ "bypass", 1 }, "Bypass", false)); + addParameter (inputLevel = new juce::AudioParameterFloat ({ "input", 1 }, "Input", + juce::NormalisableRange (-20.0f, 20.0f, 0.1f), 0.0f, juce::AudioParameterFloatAttributes().withLabel ("dB"))); + addParameter (noiseGateThreshold = new juce::AudioParameterFloat ({ "threshold", 1 }, "Threshold", + juce::NormalisableRange (-100.0f, 0.0f, 0.1f), -80.0f, juce::AudioParameterFloatAttributes().withLabel ("dB"))); + addParameter (toneBass = new juce::AudioParameterFloat ({ "bass", 1 }, "Bass", + juce::NormalisableRange (0.0f, 10.0f, 0.1f), 5.0f)); + addParameter (toneMid = new juce::AudioParameterFloat ({ "middle", 1 }, "Middle", + juce::NormalisableRange (0.0f, 10.0f, 0.1f), 5.0f)); + addParameter (toneTreble = new juce::AudioParameterFloat ({ "treble", 1 }, "Treble", + juce::NormalisableRange (0.0f, 10.0f, 0.1f), 5.0f)); + addParameter (outputLevel = new juce::AudioParameterFloat ({ "output", 1 }, "Output", + juce::NormalisableRange (-40.0f, 40.0f, 0.1f), 0.0f, juce::AudioParameterFloatAttributes().withLabel ("dB"))); + addParameter (noiseGateActive = new juce::AudioParameterBool ({ "noiseGateActive", 1 }, "NoiseGateActive", true)); + addParameter (eqActive = new juce::AudioParameterBool ({ "toneStack", 1 }, "ToneStack", true)); + addParameter (irToggle = new juce::AudioParameterBool ({ "irToggle", 1 }, "IRToggle", true)); + addParameter (calibrateInput = new juce::AudioParameterBool ({ "calibrateInput", 1 }, "CalibrateInput", false)); + addParameter (inputCalibrationLevel = new juce::AudioParameterFloat ({ "inputCalibrationLevel", 1 }, "InputCalibrationLevel", + juce::NormalisableRange (-60.0f, 60.0f, 0.1f), 12.0f, juce::AudioParameterFloatAttributes().withLabel ("dBu"))); + addParameter (outputMode = new juce::AudioParameterChoice ({ "outputMode", 1 }, "OutputMode", + juce::StringArray { "Raw", "Normalized", "Calibrated" }, 1)); + addParameter (slim = new juce::AudioParameterFloat ({ "slim", 1 }, "Slim", + juce::NormalisableRange (0.0f, 1.0f, 0.01f), 0.0f)); + + stateParameters = { inputLevel, noiseGateThreshold, toneBass, toneMid, toneTreble, outputLevel, + noiseGateActive, eqActive, irToggle, calibrateInput, inputCalibrationLevel, + outputMode, slim }; + + toneStack = std::make_unique(); + noiseGateTrigger.AddListener (&noiseGateGain); + + startTimerHz (10); +} + +NamPluginProcessor::~NamPluginProcessor() +{ + stopTimer(); +} + +bool NamPluginProcessor::isBusesLayoutSupported (const BusesLayout& layouts) const +{ + // Upstream's PLUG_CHANNEL_IO "1-1 1-2 2-2": mono or stereo either side, + // but never stereo in to mono out. + const auto in = layouts.getMainInputChannelSet(); + const auto out = layouts.getMainOutputChannelSet(); + const auto monoOrStereo = [] (const juce::AudioChannelSet& set) + { return set == juce::AudioChannelSet::mono() || set == juce::AudioChannelSet::stereo(); }; + + return monoOrStereo (in) && monoOrStereo (out) && in.size() <= out.size(); +} + +void NamPluginProcessor::prepareToPlay (double sampleRate, int maximumExpectedSamplesPerBlock) +{ + currentSampleRate = sampleRate; + currentBlockSize = juce::jmax (1, maximumExpectedSamplesPerBlock); + + inputBuffer.assign ((size_t) currentBlockSize, 0.0); + outputBuffer.assign ((size_t) currentBlockSize, 0.0); + inputPointers[0] = inputBuffer.data(); + outputPointers[0] = outputBuffer.data(); + + // Processing is suspended here, so the live objects are ours to touch. + const juce::SpinLock::ScopedLockType lock (stagingLock); + + for (auto* m : { model.get(), stagedModel.get() }) + if (m != nullptr) + m->Reset (sampleRate, currentBlockSize); + + // An IR is resampled by rebuilding it from its own data at the new rate. + for (auto* ir : { &impulseResponse, &stagedIr }) + if (*ir != nullptr && ! juce::approximatelyEqual ((*ir)->GetSampleRate(), sampleRate)) + *ir = std::make_unique ((*ir)->GetData(), sampleRate); + + toneStack->Reset (sampleRate, currentBlockSize); + lastBass = lastMid = lastTreble = -1.0f; // force the stack to refresh + highPass.SetParams (recursive_linear_filter::HighPassParams (sampleRate, dcBlockerFrequency)); + + liveLatency = model != nullptr ? model->getLatency() : 0; + setLatencySamples (liveLatency); +} + +double NamPluginProcessor::getTailLengthSeconds() const +{ + // Upstream's: ten cycles of the DC blocker's decay; the model and IR are + // ignored there too. + return 10.0 / dcBlockerFrequency; +} + +void NamPluginProcessor::applyStaging() +{ + const juce::SpinLock::ScopedTryLockType lock (stagingLock); + if (! lock.isLocked()) + return; + + if ((removeModel || stagedModel != nullptr) && retiredModel == nullptr) + { + retiredModel = std::move (model); + model = std::move (stagedModel); // null when this was a removal + removeModel = false; + liveLatency = model != nullptr ? model->getLatency() : 0; + } + + if ((removeIr || stagedIr != nullptr) && retiredIr == nullptr) + { + retiredIr = std::move (impulseResponse); + impulseResponse = std::move (stagedIr); + removeIr = false; + } +} + +void NamPluginProcessor::updateGains() noexcept +{ + auto inputDb = (double) inputLevel->get(); + if (model != nullptr && model->HasInputLevel() && calibrateInput->get()) + inputDb += (double) inputCalibrationLevel->get() - model->GetInputLevel(); + inputGain = dbToAmp (inputDb); + + auto outputDb = (double) outputLevel->get(); + if (model != nullptr) + { + switch (outputMode->getIndex()) + { + case 1: // Normalized + if (model->HasLoudness()) + outputDb += normalizedLoudnessTarget - model->GetLoudness(); + break; + case 2: // Calibrated + if (model->HasOutputLevel()) + outputDb += model->GetOutputLevel() - (double) inputCalibrationLevel->get(); + break; + default: break; // Raw + } + } + outputGain = dbToAmp (outputDb); +} + +void NamPluginProcessor::updateToneStack() noexcept +{ + const auto refresh = [this] (float& last, float now, const char* name) + { + if (juce::exactlyEqual (last, now)) + return; + last = now; + toneStack->SetParam (name, (double) now); + }; + + refresh (lastBass, toneBass->get(), "bass"); + refresh (lastMid, toneMid->get(), "middle"); + refresh (lastTreble, toneTreble->get(), "treble"); +} + +void NamPluginProcessor::processBlock (juce::AudioBuffer& buffer, juce::MidiBuffer&) +{ + const juce::ScopedNoDenormals noDenormals; + + applyStaging(); + updateGains(); + updateToneStack(); + + if (model != nullptr) + if (auto* slimmable = model->getSlimmableModel()) + slimmable->SetSlimmableSize ((double) slim->get()); + + const auto numIn = getTotalNumInputChannels(); + const auto numOut = buffer.getNumChannels(); + const auto gateOn = noiseGateActive->get(); + const auto eqOn = eqActive->get(); + const auto irOn = irToggle->get() && impulseResponse != nullptr; + + if (gateOn) + { + // Upstream's fixed gate shape; only the threshold is a parameter. + noiseGateTrigger.SetParams (dsp::noise_gate::TriggerParams ( + 0.01, (double) noiseGateThreshold->get(), 0.1, 0.005, 0.01, 0.05)); + noiseGateTrigger.SetSampleRate (currentSampleRate); + } + + // In chunks no larger than prepareToPlay promised the model. + for (int start = 0; start < buffer.getNumSamples(); start += currentBlockSize) + { + const auto frames = juce::jmin (currentBlockSize, buffer.getNumSamples() - start); + const auto numFrames = (size_t) frames; + + // Mono in: the average of what arrives, as upstream does inside a DAW + // (a stereo track carrying one guitar would otherwise come in 6 dB hot). + const auto gain = inputGain / juce::jmax (1, numIn); + for (int s = 0; s < frames; ++s) + { + double sum = 0.0; + for (int c = 0; c < numIn; ++c) + sum += (double) buffer.getSample (c, start + s); + inputBuffer[(size_t) s] = gain * sum; + } + + double** stage = inputPointers; + if (gateOn) + stage = noiseGateTrigger.Process (stage, 1, numFrames); + + if (model != nullptr) + model->process (stage, outputPointers, frames); + else + std::copy (stage[0], stage[0] + frames, outputPointers[0]); + + stage = outputPointers; + if (gateOn) + stage = noiseGateGain.Process (stage, 1, numFrames); + if (eqOn) + stage = toneStack->Process (stage, 1, frames); + if (irOn) + stage = impulseResponse->Process (stage, 1, numFrames); + stage = highPass.Process (stage, 1, numFrames); + + // Mono out, broadcast to every output channel. + for (int c = 0; c < numOut; ++c) + for (int s = 0; s < frames; ++s) + buffer.setSample (c, start + s, (float) (outputGain * stage[0][s])); + } +} + +void NamPluginProcessor::timerCallback() +{ + // Free what the audio thread retired — the one place it is safe to. + { + const juce::SpinLock::ScopedLockType lock (stagingLock); + retiredModel.reset(); + retiredIr.reset(); + } + + if (getLatencySamples() != liveLatency.load()) + setLatencySamples (liveLatency.load()); +} + +// --------------------------------------------------------------------------- +// Model and IR +// --------------------------------------------------------------------------- +juce::String NamPluginProcessor::loadModel (const juce::File& file) +{ + try + { + // Linux only, where a path is narrow and UTF-8 already. + auto dsp = nam::get_dsp (std::filesystem::path (file.getFullPathName().toStdString())); + + if (dsp->NumInputChannels() != 1 || dsp->NumOutputChannels() != 1) + return "A model must have one input and one output channel."; + + auto staged = std::make_unique (std::move (dsp), currentSampleRate); + staged->Reset (currentSampleRate, currentBlockSize); + if (auto* slimmable = staged->getSlimmableModel()) + slimmable->SetSlimmableSize ((double) slim->get()); + + { + const juce::SpinLock::ScopedLockType lock (stagingLock); + stagedModel = std::move (staged); + removeModel = false; + } + const juce::ScopedLock pl (pathLock); + modelPath = file.getFullPathName(); + return {}; + } + catch (const std::exception& e) + { + return e.what(); + } +} + +juce::String NamPluginProcessor::loadImpulseResponse (const juce::File& file) +{ + try + { + auto staged = std::make_unique ( + file.getFullPathName().toRawUTF8(), currentSampleRate); + + if (staged->GetWavState() != dsp::wav::LoadReturnCode::SUCCESS) + return "That file could not be read as a WAV impulse response."; + + { + const juce::SpinLock::ScopedLockType lock (stagingLock); + stagedIr = std::move (staged); + removeIr = false; + } + const juce::ScopedLock pl (pathLock); + irPath = file.getFullPathName(); + return {}; + } + catch (const std::exception& e) + { + return e.what(); + } +} + +void NamPluginProcessor::clearModel() +{ + { + const juce::SpinLock::ScopedLockType lock (stagingLock); + stagedModel.reset(); + removeModel = true; + } + const juce::ScopedLock pl (pathLock); + modelPath.clear(); +} + +void NamPluginProcessor::clearImpulseResponse() +{ + { + const juce::SpinLock::ScopedLockType lock (stagingLock); + stagedIr.reset(); + removeIr = true; + } + const juce::ScopedLock pl (pathLock); + irPath.clear(); +} + +juce::String NamPluginProcessor::getModelPath() const +{ + const juce::ScopedLock pl (pathLock); + return modelPath; +} + +juce::String NamPluginProcessor::getImpulseResponsePath() const +{ + const juce::ScopedLock pl (pathLock); + return irPath; +} + +// --------------------------------------------------------------------------- +// State — iPlug2 NAM's SerializeState layout, byte for byte. +// --------------------------------------------------------------------------- +void NamPluginProcessor::getStateInformation (juce::MemoryBlock& destData) +{ + juce::MemoryOutputStream out (destData, false); + putString (out, stateMarker); + putString (out, stateVersion); + putString (out, getModelPath()); + putString (out, getImpulseResponsePath()); + + // iPlug2's SerializeParams: each parameter's plain value as a double. + for (auto* p : stateParameters) + out.writeDouble ((double) p->convertFrom0to1 (p->getValue())); +} + +void NamPluginProcessor::setStateInformation (const void* data, int sizeInBytes) +{ + juce::MemoryInputStream in (data, (size_t) sizeInBytes, false); + + juce::String marker, version, namPath, impulsePath; + if (! getString (in, marker) || marker != stateMarker + || ! getString (in, version) || ! getString (in, namPath) || ! getString (in, impulsePath)) + return; // not a NAM state: leave everything as it is + + for (auto* p : stateParameters) + { + if (in.getNumBytesRemaining() < (juce::int64) sizeof (double)) + break; // an older layout with fewer parameters keeps the rest at default + p->setValueNotifyingHost (p->convertTo0to1 ((float) in.readDouble())); + } + + // Like upstream: a path that no longer loads leaves that slot empty. + if (namPath.isEmpty() || loadModel (juce::File (namPath)).isNotEmpty()) + clearModel(); + if (impulsePath.isEmpty() || loadImpulseResponse (juce::File (impulsePath)).isNotEmpty()) + clearImpulseResponse(); +} + +juce::AudioProcessorEditor* NamPluginProcessor::createEditor() +{ + return new NamPluginEditor (*this); +} + +juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter() +{ + return new NamPluginProcessor(); +} diff --git a/Source/namplugin/NamPluginProcessor.h b/Source/namplugin/NamPluginProcessor.h new file mode 100644 index 0000000..30527b4 --- /dev/null +++ b/Source/namplugin/NamPluginProcessor.h @@ -0,0 +1,158 @@ +#pragma once + +#include + +#include +#include +#include + +#include "ResamplingNam.h" +#include "ToneStack.h" +#include "dsp/ImpulseResponse.h" +#include "dsp/NoiseGate.h" +#include "dsp/RecursiveLinearFilter.h" + +/** + Neural Amp Modeler for Linux: NAM's own DSP (NeuralAmpModelerCore and + AudioDSPTools, at the commits NAM v0.7.15 pins) in a JUCE VST3, because the + upstream plugin is built on iPlug2, which has no Linux target. + + It is built to be *indistinguishable from upstream to Plectrify*, and that is + the whole design: + + - the same plugin name, "NeuralAmpModeler", which is what patches, the + drawer and the TONE3000 slice find it by (the manufacturer is Plectrify + and the VST3 class id is its own, so no host confuses the two); + - the same parameters in the same order, with Bypass first exactly as + iPlug2 exports it — so the TONE3000 knob template's indices (1 = Input + … 6 = Output) mean the same knobs on every OS; + - the same state layout iPlug2's SerializeState writes (marker, version, + model path, IR path, then every parameter as a double), so + NamStateCodec rewrites a capture into it with no second code path. + + The audio chain is upstream's, stage for stage: input gain (with optional + input calibration) → noise-gate trigger → model (resampled to the capture's + own rate) → gate gain → tone stack → IR → 5 Hz DC blocker → output gain + (raw, loudness-normalized or calibrated). + + Threading. Models and IRs are loaded off the audio thread and *staged*; the + audio thread swaps a staged object in under a try-lock and moves the one it + replaced into a retired slot, which a message-thread timer frees — so the + audio thread never allocates or deletes. Paths are guarded by their own lock + because hosts may ask for state from any thread. +*/ +class NamPluginProcessor final : public juce::AudioProcessor, + private juce::Timer +{ +public: + NamPluginProcessor(); + ~NamPluginProcessor() override; + + // --- AudioProcessor ---------------------------------------------------- + void prepareToPlay (double sampleRate, int maximumExpectedSamplesPerBlock) override; + void releaseResources() override {} + bool isBusesLayoutSupported (const BusesLayout& layouts) const override; + void processBlock (juce::AudioBuffer&, juce::MidiBuffer&) override; + using AudioProcessor::processBlock; + + juce::AudioProcessorEditor* createEditor() override; + bool hasEditor() const override { return true; } + + const juce::String getName() const override { return "NeuralAmpModeler"; } + bool acceptsMidi() const override { return false; } + bool producesMidi() const override { return false; } + double getTailLengthSeconds() const override; + + int getNumPrograms() override { return 1; } + int getCurrentProgram() override { return 0; } + void setCurrentProgram (int) override {} + const juce::String getProgramName (int) override { return {}; } + void changeProgramName (int, const juce::String&) override {} + + void getStateInformation (juce::MemoryBlock& destData) override; + void setStateInformation (const void* data, int sizeInBytes) override; + + juce::AudioProcessorParameter* getBypassParameter() const override { return bypass; } + + // --- Model and IR ------------------------------------------------------ + /** Load a .nam capture. Returns an empty string on success, otherwise why + it failed (the previous model stays). Any thread but the audio one. */ + juce::String loadModel (const juce::File& file); + /** Load a cabinet IR (.wav). Same contract as loadModel. */ + juce::String loadImpulseResponse (const juce::File& file); + void clearModel(); + void clearImpulseResponse(); + + juce::String getModelPath() const; + juce::String getImpulseResponsePath() const; + + /** The parameter layout's version string, written into the state where + iPlug2's NAM writes PLUG_VERSION_STR. It names the upstream release whose + parameter list this one mirrors — that is what a reader of the state + needs to know, not how this wrapper is versioned. */ + static constexpr const char* stateVersion = "0.7.15"; + +private: + using ResamplingNamPtr = std::unique_ptr; + using IrPtr = std::unique_ptr; + + void timerCallback() override; + + /** Apply whatever is staged, if the lock is free and the retired slots are + empty. Audio thread only. */ + void applyStaging(); + /** Input and output gain from the parameters and the live model's + calibration data — upstream's _SetInputGain/_SetOutputGain. */ + void updateGains() noexcept; + void updateToneStack() noexcept; + + // --- Parameters, in upstream's order (Bypass first, as iPlug2 exports it). + juce::AudioParameterBool* bypass = nullptr; + juce::AudioParameterFloat* inputLevel = nullptr; + juce::AudioParameterFloat* noiseGateThreshold = nullptr; + juce::AudioParameterFloat* toneBass = nullptr; + juce::AudioParameterFloat* toneMid = nullptr; + juce::AudioParameterFloat* toneTreble = nullptr; + juce::AudioParameterFloat* outputLevel = nullptr; + juce::AudioParameterBool* noiseGateActive = nullptr; + juce::AudioParameterBool* eqActive = nullptr; + juce::AudioParameterBool* irToggle = nullptr; + juce::AudioParameterBool* calibrateInput = nullptr; + juce::AudioParameterFloat* inputCalibrationLevel = nullptr; + juce::AudioParameterChoice* outputMode = nullptr; + juce::AudioParameterFloat* slim = nullptr; + + /** Every serialised parameter, in state order (Bypass excluded — iPlug2 + does not count it among the plugin's own parameters either). */ + juce::Array stateParameters; + + // --- DSP, owned by the audio thread once prepared. + ResamplingNamPtr model; + IrPtr impulseResponse; + std::unique_ptr toneStack; + dsp::noise_gate::Trigger noiseGateTrigger; + dsp::noise_gate::Gain noiseGateGain; + recursive_linear_filter::HighPass highPass; + + std::vector inputBuffer, outputBuffer; + double* inputPointers[1] {}; + double* outputPointers[1] {}; + + double inputGain = 1.0, outputGain = 1.0; + float lastBass = -1.0f, lastMid = -1.0f, lastTreble = -1.0f; + + // --- Staging between loader threads and the audio thread. + juce::SpinLock stagingLock; + ResamplingNamPtr stagedModel, retiredModel; + IrPtr stagedIr, retiredIr; + bool removeModel = false, removeIr = false; + + mutable juce::CriticalSection pathLock; + juce::String modelPath, irPath; + + std::atomic liveLatency { 0 }; + double currentSampleRate = 48000.0; + int currentBlockSize = 512; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (NamPluginProcessor) +}; diff --git a/Source/namplugin/ResamplingNam.h b/Source/namplugin/ResamplingNam.h new file mode 100644 index 0000000..df8f5c5 --- /dev/null +++ b/Source/namplugin/ResamplingNam.h @@ -0,0 +1,101 @@ +// Adapted from Neural Amp Modeler v0.7.15 (NeuralAmpModeler/NeuralAmpModeler.h: +// GetNAMSampleRate and class ResamplingNAM), MIT License, Copyright (c) 2023 +// Steven Atkinson — see THIRD_PARTY_NOTICES.md. Unchanged apart from naming the +// file and namespace; the resampler is what lets a 48 kHz capture play at +// whatever rate the host runs. +#pragma once + +#include +#include +#include +#include + +#include "NAM/dsp.h" +#include "NAM/slimmable.h" + +// AudioDSPTools' resampler was lifted out of iPlug2 and still reads two of its +// constants. These are iPlug2's own values (IPlugConstants.h), defined here +// because this build has no iPlug2 to take them from. +#ifndef DEFAULT_BLOCK_SIZE + #define DEFAULT_BLOCK_SIZE 1024 +#endif +namespace iplug +{ +static constexpr double PI = 3.1415926535897932384626433832795; +} + +#include "dsp/ResamplingContainer/ResamplingContainer.h" + +namespace plectrify::nam_plugin +{ +// Some models are from when NAM did not record a sample rate; those are +// assumed to be 48 kHz, which is how almost all of them were trained. +inline double getNamSampleRate (const std::unique_ptr& model) +{ + const double assumedSampleRate = 48000.0; + const double reported = model->GetExpectedSampleRate(); + return reported <= 0.0 ? assumedSampleRate : reported; +} + +class ResamplingNam : public nam::DSP +{ +public: + ResamplingNam (std::unique_ptr encapsulated, const double expectedSampleRate) + : nam::DSP (encapsulated->NumInputChannels(), encapsulated->NumOutputChannels(), expectedSampleRate), + mEncapsulated (std::move (encapsulated)), + mResampler (getNamSampleRate (mEncapsulated)) + { + mBlockProcessFunc = [this] (NAM_SAMPLE** input, NAM_SAMPLE** output, int numFrames) + { mEncapsulated->process (input, output, numFrames); }; + + if (mEncapsulated->HasLoudness()) + SetLoudness (mEncapsulated->GetLoudness()); + if (mEncapsulated->HasInputLevel()) + SetInputLevel (mEncapsulated->GetInputLevel()); + if (mEncapsulated->HasOutputLevel()) + SetOutputLevel (mEncapsulated->GetOutputLevel()); + + const int maxBlockSize = 2048; // conservative, as upstream + Reset (expectedSampleRate, maxBlockSize); + } + + void prewarm() override { mEncapsulated->prewarm(); } + + void process (NAM_SAMPLE** input, NAM_SAMPLE** output, const int numFrames) override + { + if (numFrames > mMaxExternalBlockSize) + throw std::runtime_error ("More frames were provided than the max expected!"); + + if (! needToResample()) + mEncapsulated->process (input, output, numFrames); + else + mResampler.ProcessBlock (input, output, numFrames, mBlockProcessFunc); + } + + int getLatency() const { return needToResample() ? mResampler.GetLatency() : 0; } + + void Reset (const double sampleRate, const int maxBlockSize) override + { + mExpectedSampleRate = sampleRate; + mMaxExternalBlockSize = maxBlockSize; + mResampler.Reset (sampleRate, maxBlockSize); + + const double upRatio = sampleRate / getEncapsulatedSampleRate(); + const auto maxEncapsulatedBlockSize = + static_cast (std::ceil (static_cast (maxBlockSize) / upRatio)); + mEncapsulated->ResetAndPrewarm (sampleRate, maxEncapsulatedBlockSize); + } + + double getEncapsulatedSampleRate() const { return getNamSampleRate (mEncapsulated); } + + nam::SlimmableModel* getSlimmableModel() { return dynamic_cast (mEncapsulated.get()); } + +private: + bool needToResample() const { return GetExpectedSampleRate() != getEncapsulatedSampleRate(); } + + std::unique_ptr mEncapsulated; + dsp::ResamplingContainer mResampler; + int mMaxExternalBlockSize = 0; + std::function mBlockProcessFunc; +}; +} // namespace plectrify::nam_plugin diff --git a/Source/namplugin/ToneStack.cpp b/Source/namplugin/ToneStack.cpp new file mode 100644 index 0000000..7b92ef1 --- /dev/null +++ b/Source/namplugin/ToneStack.cpp @@ -0,0 +1,63 @@ +// Copied unchanged from Neural Amp Modeler v0.7.15 (NeuralAmpModeler/ToneStack.cpp), +// MIT License, Copyright (c) 2023 Steven Atkinson — see THIRD_PARTY_NOTICES.md. +#include "ToneStack.h" + +DSP_SAMPLE** dsp::tone_stack::BasicNamToneStack::Process(DSP_SAMPLE** inputs, const int numChannels, + const int numFrames) +{ + DSP_SAMPLE** bassPointers = mToneBass.Process(inputs, numChannels, numFrames); + DSP_SAMPLE** midPointers = mToneMid.Process(bassPointers, numChannels, numFrames); + DSP_SAMPLE** treblePointers = mToneTreble.Process(midPointers, numChannels, numFrames); + return treblePointers; +} + +void dsp::tone_stack::BasicNamToneStack::Reset(const double sampleRate, const int maxBlockSize) +{ + dsp::tone_stack::AbstractToneStack::Reset(sampleRate, maxBlockSize); + + // Refresh the params! + SetParam("bass", mBassVal); + SetParam("middle", mMiddleVal); + SetParam("treble", mTrebleVal); +} + +void dsp::tone_stack::BasicNamToneStack::SetParam(const std::string name, const double val) +{ + if (name == "bass") + { + // HACK: Store for refresh + mBassVal = val; + const double sampleRate = GetSampleRate(); + const double bassGainDB = 4.0 * (val - 5.0); // +/- 20 + // Hey ChatGPT, the bass frequency is 150 Hz! + const double bassFrequency = 150.0; + const double bassQuality = 0.707; + recursive_linear_filter::BiquadParams bassParams(sampleRate, bassFrequency, bassQuality, bassGainDB); + mToneBass.SetParams(bassParams); + } + else if (name == "middle") + { + // HACK: Store for refresh + mMiddleVal = val; + const double sampleRate = GetSampleRate(); + const double midGainDB = 3.0 * (val - 5.0); // +/- 15 + // Hey ChatGPT, the middle frequency is 425 Hz! + const double midFrequency = 425.0; + // Wider EQ on mid bump up to sound less honky. + const double midQuality = midGainDB < 0.0 ? 1.5 : 0.7; + recursive_linear_filter::BiquadParams midParams(sampleRate, midFrequency, midQuality, midGainDB); + mToneMid.SetParams(midParams); + } + else if (name == "treble") + { + // HACK: Store for refresh + mTrebleVal = val; + const double sampleRate = GetSampleRate(); + const double trebleGainDB = 2.0 * (val - 5.0); // +/- 10 + // Hey ChatGPT, the treble frequency is 1800 Hz! + const double trebleFrequency = 1800.0; + const double trebleQuality = 0.707; + recursive_linear_filter::BiquadParams trebleParams(sampleRate, trebleFrequency, trebleQuality, trebleGainDB); + mToneTreble.SetParams(trebleParams); + } +} diff --git a/Source/namplugin/ToneStack.h b/Source/namplugin/ToneStack.h new file mode 100644 index 0000000..3e6348a --- /dev/null +++ b/Source/namplugin/ToneStack.h @@ -0,0 +1,64 @@ +// Copied unchanged from Neural Amp Modeler v0.7.15 (NeuralAmpModeler/ToneStack.h), +// MIT License, Copyright (c) 2023 Steven Atkinson — see THIRD_PARTY_NOTICES.md. +// Only the include paths differ: AudioDSPTools is on the include path here. +#pragma once + +#include +#include "dsp/dsp.h" +#include "dsp/RecursiveLinearFilter.h" + +namespace dsp +{ +namespace tone_stack +{ +class AbstractToneStack +{ +public: + AbstractToneStack() = default; + virtual ~AbstractToneStack() = default; + // Compute in the real-time loop + virtual DSP_SAMPLE** Process(DSP_SAMPLE** inputs, const int numChannels, const int numFrames) = 0; + // Any preparation. Call from Reset() in the plugin + virtual void Reset(const double sampleRate, const int maxBlockSize) + { + mSampleRate = sampleRate; + mMaxBlockSize = maxBlockSize; + }; + // Set the various parameters of your tone stack by name. + // Call this during OnParamChange() + virtual void SetParam(const std::string name, const double val) = 0; + +protected: + double GetSampleRate() const { return mSampleRate; }; + double mSampleRate = 0.0; + int mMaxBlockSize = 0; +}; + +class BasicNamToneStack : public AbstractToneStack +{ +public: + BasicNamToneStack() + { + SetParam("bass", 5.0); + SetParam("middle", 5.0); + SetParam("treble", 5.0); + }; + ~BasicNamToneStack() = default; + + DSP_SAMPLE** Process(DSP_SAMPLE** inputs, const int numChannels, const int numFrames) override; + void Reset(const double sampleRate, const int maxBlockSize) override; + // :param val: Assumed to be between 0 and 10, 5 is "noon" + void SetParam(const std::string name, const double val) override; + +protected: + recursive_linear_filter::LowShelf mToneBass; + recursive_linear_filter::Peaking mToneMid; + recursive_linear_filter::HighShelf mToneTreble; + + // HACK not DRY w knob defs + double mBassVal = 5.0; + double mMiddleVal = 5.0; + double mTrebleVal = 5.0; +}; +}; // namespace tone_stack +}; // namespace dsp diff --git a/Source/plugins/Catalogue.h b/Source/plugins/Catalogue.h index cdb9d6b..476d5d8 100644 --- a/Source/plugins/Catalogue.h +++ b/Source/plugins/Catalogue.h @@ -56,6 +56,8 @@ enum class PackageKind the compiler proves every build has exactly one answer. */ #if JUCE_MAC inline const char* const catalogueRuntimePlatform = "macos-arm64"; +#elif JUCE_LINUX +inline const char* const catalogueRuntimePlatform = "linux-x64"; #else inline const char* const catalogueRuntimePlatform = "windows-x64"; #endif diff --git a/Source/plugins/CatalogueInstaller.cpp b/Source/plugins/CatalogueInstaller.cpp index d9d2176..c4a0ca0 100644 --- a/Source/plugins/CatalogueInstaller.cpp +++ b/Source/plugins/CatalogueInstaller.cpp @@ -2,7 +2,7 @@ #include "PluginManager.h" -#if JUCE_MAC +#if JUCE_MAC || JUCE_LINUX #include #endif @@ -128,7 +128,7 @@ bool isAvailableHere (const std::vector& packages, } // namespace -/** Create a content package's directory, and on macOS give it — and every level +/** Create a content package's directory, and on macOS and Linux give it — and every level of the shared content root above it — the mode /Users/Shared itself carries: writable by every account, sticky so each can still only remove what it owns. @@ -138,7 +138,8 @@ bool isAvailableHere (const std::vector& packages, installDir beneath it, and no second account could add a package there — or another patch beside an existing one — without elevation. ProgramData's inherited ACL already gives the Windows build exactly these rights, which is - why that branch needs nothing. + why that branch needs nothing. Linux behaves like macOS below the root, + which its installer has already created 1777 (see contentRootDirectory). Every level, because a 0755 root blocks a writable child just as thoroughly. And only ever widening: chmod on a directory another account created fails, @@ -150,7 +151,7 @@ void CatalogueInstaller::createSharedContentDirectory (const juce::File& directo { directory.createDirectory(); - #if JUCE_MAC + #if JUCE_MAC || JUCE_LINUX const auto root = CatalogueInstaller::contentRootDirectory(); for (auto level = directory; level == root || level.isAChildOf (root); @@ -570,6 +571,15 @@ juce::File CatalogueInstaller::contentRootDirectory() // creates). The plugin dir is per-user on macOS, so this cannot derive // from it the way the Windows branch does. return juce::File ("/Users/Shared/Plectrify"); + #elif JUCE_LINUX + // The same requirement, and Linux has no stock answer to it at all: no + // fixed directory every account may write to without elevation exists + // (/tmp is cleared, ~ differs per user). So the installer creates this one + // world-writable and sticky, the mode /Users/Shared carries, and the dev + // loop asks for it once. Its absence makes content installs fail with a + // write error rather than land somewhere a pack's baked paths do not + // point. + return juce::File ("/var/lib/Plectrify"); #else return installDirectory().getParentDirectory(); #endif diff --git a/Source/plugins/PluginManager.cpp b/Source/plugins/PluginManager.cpp index 7312c52..3d557fd 100644 --- a/Source/plugins/PluginManager.cpp +++ b/Source/plugins/PluginManager.cpp @@ -28,6 +28,12 @@ juce::File PluginManager::getManagedPluginDirectory() // model — and other hosts still find the plugins here. return juce::File::getSpecialLocation (juce::File::userHomeDirectory) .getChildFile ("Library/Audio/Plug-Ins/VST3"); + #elif JUCE_LINUX + // The per-user VST3 convention again, for the same reason: the + // machine-wide /usr/lib/vst3 needs root, and every Linux host already + // searches ~/.vst3. + return juce::File::getSpecialLocation (juce::File::userHomeDirectory) + .getChildFile (".vst3"); #else return juce::File::getSpecialLocation (juce::File::commonApplicationDataDirectory) .getChildFile ("Plectrify") diff --git a/Source/plugins/PluginManager.h b/Source/plugins/PluginManager.h index 4ac837c..35cc7d9 100644 --- a/Source/plugins/PluginManager.h +++ b/Source/plugins/PluginManager.h @@ -39,9 +39,11 @@ class PluginManager : private juce::Thread the same machine can add packages but not overwrite the first account's. On macOS the machine-wide /Library is admin-owned, so the per-user VST3 convention keeps installs elevation-free while other - hosts still find the plugins. Both are deliberately outside the web - view's file sandbox (%APPDATA%/Plectrify resp. ~/Library/Application - Support/Plectrify), which the page can read and delete within. + hosts still find the plugins. Linux follows macOS for the same reason: + /usr/lib/vst3 is root's, so it is the per-user ~/.vst3 every Linux host + searches. All are deliberately outside the web view's file sandbox + (%APPDATA%/Plectrify resp. ~/Library/Application Support/Plectrify + resp. ~/.config/Plectrify), which the page can read and delete within. Created on demand: the scanner needs the directory to exist before it will search it. */ @@ -49,14 +51,15 @@ class PluginManager : private juce::Thread /** Whether that directory is Plectrify's alone. It is on Windows, where it is a folder under Plectrify's own ProgramData root that nothing else - writes to; it is not on macOS, where it is the user's - ~/Library/Audio/Plug-Ins/VST3, shared with every other VST3 installer. + writes to; it is not on macOS or Linux, where it is the user's + ~/Library/Audio/Plug-Ins/VST3 resp. ~/.vst3, shared with every other + VST3 installer. The distinction decides what an unaccounted-for bundle in there means — our own debris, or somebody else's plugin — which is the whole of mayReplaceManagedPlugin()'s `directoryIsExclusive`. */ static constexpr bool managedPluginDirectoryIsExclusive = - #if JUCE_MAC + #if JUCE_MAC || JUCE_LINUX false; #else true; diff --git a/Source/tone3000/Tone3000BrowserWindow.cpp b/Source/tone3000/Tone3000BrowserWindow.cpp index 26e649e..ead4759 100644 --- a/Source/tone3000/Tone3000BrowserWindow.cpp +++ b/Source/tone3000/Tone3000BrowserWindow.cpp @@ -149,7 +149,7 @@ juce::Array> attachedDisplays() for (const auto& display : juce::Desktop::getInstance().getDisplays().displays) // The *user* area, so a window is never restored underneath the Windows // taskbar or the mac menu bar, where its title bar cannot be grabbed. - areas.add (display.userArea); + areas.add (display.userBounds.toNearestInt()); return areas; } @@ -199,7 +199,7 @@ Tone3000BrowserWindow::Tone3000BrowserWindow (const juce::String& startUrl, // the second opening onwards it is whatever the user left it at. const auto fallback = juce::Rectangle { 1100, 820 }.withCentre ( juce::Desktop::getInstance().getDisplays().getPrimaryDisplay() != nullptr - ? juce::Desktop::getInstance().getDisplays().getPrimaryDisplay()->userArea.getCentre() + ? juce::Desktop::getInstance().getDisplays().getPrimaryDisplay()->userBounds.toNearestInt().getCentre() : juce::Point { 700, 450 }); restoredBounds = Tone3000WindowState::place (saved, attachedDisplays(), fallback); diff --git a/Source/tone3000/Tone3000Service.cpp b/Source/tone3000/Tone3000Service.cpp index b0646a8..72d2523 100644 --- a/Source/tone3000/Tone3000Service.cpp +++ b/Source/tone3000/Tone3000Service.cpp @@ -8,7 +8,7 @@ #include #include -#if JUCE_MAC +#if JUCE_MAC || JUCE_LINUX #include // ::chmod — the credentials file is tightened to 0600 #endif @@ -218,9 +218,9 @@ void Tone3000Service::saveCredentials() file.replaceWithText (juce::JSON::toString (Tone3000Auth::toVar (current))); - #if JUCE_MAC - // Windows' %APPDATA% is already per-user ACL'd; a mac home directory is - // world-readable by default, and a bearer token should not be. + #if JUCE_MAC || JUCE_LINUX + // Windows' %APPDATA% is already per-user ACL'd; a mac or Linux home + // directory is often world-readable, and a bearer token should not be. ::chmod (file.getFullPathName().toRawUTF8(), 0600); #endif } diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index e0dff29..4938e8b 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -91,6 +91,26 @@ that permits it, not a copy of anyone else's build. are recorded in a `PROVENANCE-.txt` published beside that archive. - The plugin's own third-party notices ship inside the plugin bundle. +**On Linux** the plugin is a different build of the same DSP. Upstream's plugin +is built on iPlug2, which has no Linux target, so Plectrify wraps Neural Amp +Modeler's own processing in a JUCE VST3 of its own (`Source/namplugin/`, part of +Plectrify's source under `LICENSE`) and compiles it with the rest of the +application. It reports the same name and parameters as upstream so that +patches and TONE3000 tones load into it unchanged; its manufacturer is +Plectrify. Third-party code compiled into it, each at the exact commit +Neural Amp Modeler v0.7.15 pins (see `CMakeLists.txt`): + +- NeuralAmpModelerCore, MIT, Copyright (c) 2023 Steven Atkinson — + https://github.com/sdatkinson/NeuralAmpModelerCore +- AudioDSPTools, MIT, Copyright (c) 2023 Steven Atkinson — + https://github.com/sdatkinson/AudioDSPTools (its resampler derives from + iPlug2, zlib-style licence) +- The tone stack and resampling wrapper of NeuralAmpModelerPlugin, MIT, + Copyright (c) 2023 Steven Atkinson, copied into `Source/namplugin/` +- nlohmann/json, MIT, Copyright (c) 2013-2022 Niels Lohmann +- Eigen, MPL-2.0 — https://gitlab.com/libeigen/eigen (source for the exact + commit is linked from `CMakeLists.txt`; Eigen is header-only and unmodified) + ## Other VST3 plugins Beyond the one above, Plectrify redistributes no third-party plugin. (The diff --git a/Tests/app/AudioSetupRulesTests.cpp b/Tests/app/AudioSetupRulesTests.cpp index d120841..81e0662 100644 --- a/Tests/app/AudioSetupRulesTests.cpp +++ b/Tests/app/AudioSetupRulesTests.cpp @@ -62,6 +62,10 @@ namespace expectEquals (preferredDeviceType ({ "CoreAudio" }), "CoreAudio", "a single type is chosen by the fallback"); + // Linux: JACK (PipeWire's, usually) over raw ALSA, whatever JUCE's order. + expectEquals (preferredDeviceType ({ "ALSA", "JACK" }), + "JACK", "JACK wins over ALSA"); + expectEquals (preferredDeviceType ({ "Windows Audio", "DirectSound" }), "Windows Audio", "no preferred family: the host's own first choice"); @@ -104,12 +108,21 @@ namespace expectEquals (preferredBufferSize (typical, 0.0), 2048, "an unknown sample rate falls back to the longest block"); } + + void testInputChannels() + { + expectEquals (inputChannelsToOpen (2), 2, "a real interface opens every jack"); + expectEquals (inputChannelsToOpen (128), maxOpenedInputChannels, + "PipeWire's 128 virtual inputs are capped"); + expectEquals (inputChannelsToOpen (0), 0, "no inputs, none opened"); + } } int main() { testDeviceType(); testBufferSize(); + testInputChannels(); if (failures == 0) std::cout << "AudioSetupRules tests passed\n"; diff --git a/package.json b/package.json index 85fa90f..5d8b1b5 100644 --- a/package.json +++ b/package.json @@ -2,12 +2,10 @@ "name": "plectrify-tools", "private": true, "type": "module", - "description": "Root dev/release tooling, one language on both OSes: `pnpm app` dispatches to scripts/run.windows.ts / run.macos.ts, and `pnpm release:build` / `pnpm release` dispatch to scripts/release.windows.ts / release.macos.ts — commands named for the job, never for the machine you are sitting at — with `pnpm release:promote` the separate third step. One `pnpm install` here fetches tsx; ui/ and packaging/ stay their own packages.", + "description": "Root dev/release tooling, one language on every OS: `pnpm app` dispatches to scripts/run..ts, and `pnpm release` — run by GitHub Actions only — dispatches to scripts/release..ts. One `pnpm install` here fetches tsx; ui/ and packaging/ stay their own packages.", "scripts": { "app": "tsx scripts/run.ts", - "release:build": "tsx scripts/release.ts --local", "release": "tsx scripts/release.ts", - "release:promote": "tsx scripts/release.windows.ts --promote", "purge": "tsx scripts/uninstall.ts", "check": "tsc --noEmit" }, diff --git a/packaging/catalogue.json b/packaging/catalogue.json index f44f92a..df435e7 100644 --- a/packaging/catalogue.json +++ b/packaging/catalogue.json @@ -172,7 +172,7 @@ "by 'the other platform'." ], "schemaVersion": 4, - "revision": 19, + "revision": 20, "_noticesComment": [ "Licence disclosure travels WITH the catalogue rather than shipping in the", "installer. If the offered plugins change, the notices change with them - no new", @@ -457,6 +457,11 @@ "*.vst3" ], "assets": { + "linux-x64": { + "url": "https://github.com/vvvar/PeakEater/releases/download/v0.8.2/peakeater-v0.8.2-Linux-x86_64.zip", + "sha256": "0ddf7d839fa436e62c2e9d5b1f188d695424d5aba2f242ad0a89bb8634f299b9", + "downloadBytes": 4586800 + }, "macos-arm64": { "url": "https://cdn.plectrify.com/plugins/v1/hosted/peakeater/peakeater-0.8.2-macos-arm64.zip", "sha256": "a0bf29b010ae364d55c1194b61ef45f17ba4ecfc7cb07eac21c0b499d72267e1", @@ -496,6 +501,11 @@ "*.vst3" ], "assets": { + "linux-x64": { + "url": "https://github.com/jerryuhoo/Fire/releases/download/v1.5.0/Fire-1.5.0-Linux.zip", + "sha256": "774b75bc213ebb1728aebee2f51d7ee371f3cfd0eb103b4fc7faff8efc43ab8a", + "downloadBytes": 7121884 + }, "macos-arm64": { "url": "https://github.com/jerryuhoo/Fire/releases/download/v1.5.0/Fire-1.5.0-macOS.zip", "sha256": "e4c63f6954ad89a4137d50de71dd9dd00486ad8afeca2f0ebaeb3b7c837e24b3", @@ -533,6 +543,11 @@ "*.vst3" ], "assets": { + "linux-x64": { + "url": "https://github.com/tiagolr/filtr/releases/download/v1.3.0/filtr-linux-v1.3.0.zip", + "sha256": "30840e30ed7cf323e7f8142aaba260b538fcdd531be74d2fdf796fb6f0dacfa0", + "downloadBytes": 5487278 + }, "macos-arm64": { "url": "https://github.com/tiagolr/filtr/releases/download/v1.3.0/filtr-macos-v1.3.0.zip", "sha256": "4a3c2f6128dd6c9b883857ac1bf37d8265dd3e44c9fe77a03f8c61d2834f43f1", @@ -569,6 +584,11 @@ "*.vst3" ], "assets": { + "linux-x64": { + "url": "https://github.com/tiagolr/gate12/releases/download/v1.3.3/gate12-linux-v1.3.3.zip", + "sha256": "fa19ebf1cacd064c5c2fc687894ffc7400a92e9887f6db3cc3f2f59042d07cad", + "downloadBytes": 5135883 + }, "macos-arm64": { "url": "https://github.com/tiagolr/gate12/releases/download/v1.3.3/gate12-macos-v1.3.3.zip", "sha256": "2c11517bd9b59a83fa813fe9f933c4233deff5c7571b4e299c8b6fd91b2e7189", @@ -603,6 +623,11 @@ "*.vst3" ], "assets": { + "linux-x64": { + "url": "https://github.com/tiagolr/time12/releases/download/v1.2.3/time12-linux-v1.2.3.zip", + "sha256": "d1e19e0c2e5332626af3077d4dd98dcaeb4b4c2b33ee74c267a732c29df10613", + "downloadBytes": 5066023 + }, "macos-arm64": { "url": "https://github.com/tiagolr/time12/releases/download/v1.2.3/time12-macos-v1.2.3.zip", "sha256": "9066d0f46fe0cbce102296180d157a176708654b9d6488f6d616e9e3d5cf45e4", @@ -637,6 +662,11 @@ "*.vst3" ], "assets": { + "linux-x64": { + "url": "https://github.com/maurocosentino/lupex-chorus-bbd/releases/download/v0.1.0/ChorusBBD-Linux-VST3.zip", + "sha256": "fdfd1f1c0d30b51303bd3dcf91390dc359d01f55394156c2272b36968be96d06", + "downloadBytes": 4088838 + }, "windows-x64": { "url": "https://github.com/maurocosentino/lupex-chorus-bbd/releases/download/v0.1.0/ChorusBBD-Windows-VST3.zip", "sha256": "47d60a4c5dac109984001fb63a81f6b575fdc542796cf4bcca1e417522e0d28d", @@ -701,6 +731,11 @@ "*.vst3" ], "assets": { + "linux-x64": { + "url": "https://github.com/maurocosentino/lupex-analog-delay/releases/download/v1.1.0/Lupex-Linux-VST3.zip", + "sha256": "e1b49fa909b6f2b18e317b449e1aebe976fd033d40b57509b5b3336d21cdbc61", + "downloadBytes": 4077998 + }, "windows-x64": { "url": "https://github.com/maurocosentino/lupex-analog-delay/releases/download/v1.1.0/Lupex-Windows-VST3.zip", "sha256": "d3a02e93782e6ccd49132a4a4328123ecd07109051418ce55cf71c3e435e9f11", @@ -732,6 +767,11 @@ "*.vst3" ], "assets": { + "linux-x64": { + "url": "https://github.com/tiagolr/qdelay/releases/download/v1.1.2/qdelay-linux-v1.1.2.zip", + "sha256": "3127713fd2aa9dabfc8dc52912f40be983c9c057c1cee0c090bdc50c98b1c101", + "downloadBytes": 5451971 + }, "macos-arm64": { "url": "https://github.com/tiagolr/qdelay/releases/download/v1.1.2/qdelay-macos-v1.1.2.zip", "sha256": "44a6e68f62c60f14b6ae3da64e93387af51682aadcd421b51e53078fb6642fcf", @@ -804,6 +844,11 @@ "*.vst3" ], "assets": { + "linux-x64": { + "url": "https://github.com/tiagolr/sirial/releases/download/v1.1.1/sirial-linux-v1.1.1.zip", + "sha256": "4937c3c27ba4ffe41a76de4d5020ba4923ac828e6916258916fe329335c4aeff", + "downloadBytes": 4959438 + }, "macos-arm64": { "url": "https://github.com/tiagolr/sirial/releases/download/v1.1.1/sirial-macos-v1.1.1.zip", "sha256": "8b824a76797d7d4310f673818c7794cf958215e435b35b4396db6beaa0a871fa", @@ -875,6 +920,11 @@ "*.vst3" ], "assets": { + "linux-x64": { + "url": "https://github.com/tiagolr/reevr/releases/download/v1.4.0/reevr-linux-v1.4.0.zip", + "sha256": "243da8bd8b90e60372f75ddea9a6ae54c5a1dd7f2071b0050e47ed613c511f8f", + "downloadBytes": 7549063 + }, "macos-arm64": { "url": "https://github.com/tiagolr/reevr/releases/download/v1.4.0/reevr-macos-v1.4.0.zip", "sha256": "92e069e942ac0f286169b32d0fd175483a10e07da6470a3582c93cc8a741c9e3", @@ -986,6 +1036,11 @@ "*.vst3" ], "assets": { + "linux-x64": { + "url": "https://github.com/xunil-cloud/CloudReverb/releases/download/v0.5/CloudReverb-v0.5-Linux-x86_64.zip", + "sha256": "81cda6a9979cdbaf4d7a261338e59b87c2d4f4a123d05049a5aeaaabe399e6d1", + "downloadBytes": 7050789 + }, "macos-arm64": { "url": "https://github.com/xunil-cloud/CloudReverb/releases/download/v0.5/CloudReverb-v0.5-macOS.zip", "sha256": "88b60035570f680e29ccc2465711dd1ea9886d80c6c1bcca5f8bf09b2cd49b9c", diff --git a/packaging/catalogue.json.sig b/packaging/catalogue.json.sig index cb0c9f7..9adca2c 100644 --- a/packaging/catalogue.json.sig +++ b/packaging/catalogue.json.sig @@ -1 +1 @@ -53a3f12c9cfeb639276c56871a761e86b992f057a3f998c81332427a5ca34e292f4d150852a619778c77e304266ba6f73953b4272e1c4b67699991a52cc311a43e2f019f234f0e2d9403e17252d15612ec12d16ca8c15870ee134a0793f5e8688c23541b47a1e1933707b444a0ea74b0af09d63128b62e70b9d5e7b3a7c0b9d45891d6baab6928c5f4f88bfe67aa19b0db64dd5b218471f85ceacf11eced4c1fbf05d4ee88d6225eb5f3397a9134172bdfe129b57a6326779f19d6021ccad6828eb5ff3d54fb669cd4da4b136f1d22793f969f7a99924d75f4dd2ab77faec77e062c5e3f55795df8647b1b30dd931c0381f950a9a4b3971fa92c33e4d8b6c3d6 \ No newline at end of file +9c57990ace89f7f41d5bfab8e1123e6355bdd69b2135b857b04fa72007827fb589fa5d585259d7a1d1554f3453c44602fbd377e53e807188488c4b66a4eb030ed564ef972cf96ddd52295aae9ba0c65b9cdd0f01a40067fa92fcfa21d140c74fe14c84d8a4305b7c00c098bf9f05292c71046a17e54c3a4372d58a523e2494b39e0464c21ed5ea8d2e3f6d838aaa2ccbcaa19965f1b5abae45ca934dcf6a58eb7716e4993feeb8a3ef883b9c3648aad379fa6cc638575e12adadcaa78f64eb92505f73731f51316251b2081915c10e0eaa2dfc111d2c387b14dc39d56b42ff1b56c0266f1721f76a14bd84cb0531baecd9f9d84060c1ce3097ce747f8e242364 \ No newline at end of file diff --git a/packaging/scripts/build-plugin.ts b/packaging/scripts/build-plugin.ts index 6c49967..5db9e79 100644 --- a/packaging/scripts/build-plugin.ts +++ b/packaging/scripts/build-plugin.ts @@ -45,7 +45,7 @@ const platforms: Record Promise>> = { const load = platforms[process.platform]; if (!load) { fail( - `there is no build route for ${process.platform}. Plectrify's catalogue offers windows-x64 and macos-arm64; run this on one of those.`, + `there is no build route for ${process.platform}: only windows-x64 and macos-arm64 have one; a linux-x64 asset is pinned by hand from an upstream build that unzips to a .vst3.`, ); } diff --git a/packaging/scripts/check-updates.ts b/packaging/scripts/check-updates.ts index b1a8cb1..8f1568f 100644 --- a/packaging/scripts/check-updates.ts +++ b/packaging/scripts/check-updates.ts @@ -58,6 +58,7 @@ function isAssetFor(platform: string, name: string): boolean { if (platform === 'windows-x64') return /win|windows/.test(n) && /\.(zip|exe)$/.test(n); if (platform === 'macos-arm64') return /mac|osx|darwin/.test(n) && /\.(zip|dmg|pkg)$/.test(n); + if (platform === 'linux-x64') return /linux/.test(n) && !/arm|aarch64/.test(n) && /\.zip$/.test(n); return false; } diff --git a/packaging/scripts/host-content.ts b/packaging/scripts/host-content.ts index f1fc4a9..199984e 100644 --- a/packaging/scripts/host-content.ts +++ b/packaging/scripts/host-content.ts @@ -22,6 +22,7 @@ import { basename, dirname, join } from 'node:path'; import { parseArgs } from 'node:util'; import { unzipSync } from 'fflate'; import { scriptArgs } from './cli.ts'; +import { ASSET_PLATFORMS } from './manifest.ts'; import { R2_PUBLIC_BASE, buildArchive } from './pack.ts'; import { wranglerInherit } from './wrangler.ts'; @@ -262,13 +263,13 @@ console.log( // differ per OS, drop the platforms it does not suit rather than // shipping them bytes that do not fit. assets: Object.fromEntries( - ['macos-arm64', 'windows-x64'].map((platform) => [ + ASSET_PLATFORMS.map((platform) => [ platform, { url: `${R2_PUBLIC_BASE}/${key}`, sha256: archiveHash, downloadBytes: archiveBytes, - // This script just uploaded it, so both platforms are served by us. + // This script just uploaded it, so every platform is served by us. selfHosted: true, }, ]), diff --git a/packaging/scripts/host-plugin.ts b/packaging/scripts/host-plugin.ts index a60b707..8d21b59 100644 --- a/packaging/scripts/host-plugin.ts +++ b/packaging/scripts/host-plugin.ts @@ -29,7 +29,7 @@ const platforms: Record Promise<{ default?: never } & Record segment even under single-config generators + (JUCEUtils.cmake's products_folder). */ +export function appBinary(config: string): string { + return join(buildDir(config), 'Plectrify_artefacts', config, 'Plectrify'); +} + +/** Shipped plugins that Linux builds from this tree rather than downloads, + keyed by their packaging/bundled-plugins.json id. Neural Amp Modeler is the + one: upstream's iPlug2 build has no Linux target, so Source/namplugin wraps + the same DSP in a JUCE VST3 (see CMakeLists.txt). */ +export const IN_TREE_PLUGINS: Record = { + 'neural-amp-modeler': { target: 'PlectrifyNam_VST3', bundle: 'NeuralAmpModeler.vst3' }, +}; + +export function inTreePluginBundle(config: string, bundle: string): string { + return join(buildDir(config), 'PlectrifyNam_artefacts', config, 'VST3', bundle); +} + +export function pluginBundle(config: string): string { + return join(buildDir(config), 'PlectrifyPlugin_artefacts', config, 'VST3', 'Plectrify.vst3'); +} + +/** The machine-wide content root (CatalogueInstaller::contentRootDirectory). + The installer creates it; a dev machine has to, once, by hand. */ +export const CONTENT_ROOT = '/var/lib/Plectrify'; + +/** Configure, and refuse early with the one apt line that fixes it when a + system library JUCE needs on Linux is missing — CMake's own pkg-config error + names a module, not a package, and arrives after JUCE has been cloned. */ +export function configure(config: string): void { + assertBuildDependencies(); + const generator = capture('which', ['ninja']).trim() !== '' ? ['-G', 'Ninja'] : []; + run(`Configuring (${config})`, 'cmake', [ + '-B', buildDir(config), + '-S', ROOT, + `-DCMAKE_BUILD_TYPE=${config}`, + ...generator, + ]); +} + +/** pkg-config module → the Debian/Ubuntu package that provides it. JUCE's + Linux backends: WebKitGTK + GTK for the web view, ALSA for audio, curl for + HTTPS, freetype/fontconfig for text, plus the X11 headers its windowing + includes directly (they have no pkg-config module, so they are checked by + header). JACK is dlopened at run time and needs only its header. */ +const PKG_CONFIG_DEPS: [module: string, aptPackage: string][] = [ + ['webkit2gtk-4.1', 'libwebkit2gtk-4.1-dev'], + ['gtk+-3.0', 'libgtk-3-dev'], + ['alsa', 'libasound2-dev'], + ['libcurl', 'libcurl4-openssl-dev'], + ['freetype2', 'libfreetype-dev'], + ['fontconfig', 'libfontconfig-dev'], +]; + +const HEADER_DEPS: [header: string, aptPackage: string][] = [ + ['/usr/include/X11/Xlib.h', 'libx11-dev'], + ['/usr/include/X11/extensions/Xrandr.h', 'libxrandr-dev'], + ['/usr/include/X11/extensions/Xinerama.h', 'libxinerama-dev'], + ['/usr/include/X11/Xcursor/Xcursor.h', 'libxcursor-dev'], + ['/usr/include/X11/extensions/XShm.h', 'libxext-dev'], + ['/usr/include/X11/extensions/Xcomposite.h', 'libxcomposite-dev'], + ['/usr/include/jack/jack.h', 'libjack-jackd2-dev'], + ['/usr/include/GL/gl.h', 'libgl-dev'], +]; + +const TOOLS: [tool: string, aptPackage: string][] = [ + ['cmake', 'cmake'], + ['pkg-config', 'pkg-config'], + ['g++', 'build-essential'], + ['git', 'git'], +]; + +export function assertBuildDependencies(): void { + const missing = new Set(); + + for (const [tool, pkg] of TOOLS) { + if (capture('which', [tool]).trim() === '') missing.add(pkg); + } + const havePkgConfig = capture('which', ['pkg-config']).trim() !== ''; + for (const [module, pkg] of PKG_CONFIG_DEPS) { + if (!havePkgConfig || spawnSync('pkg-config', ['--exists', module]).status !== 0) missing.add(pkg); + } + for (const [header, pkg] of HEADER_DEPS) { + if (!existsSync(header)) missing.add(pkg); + } + + if (missing.size > 0) { + fail( + 'missing build dependencies. Install them with:\n\n' + + ` sudo apt install ${[...missing].sort().join(' ')} ninja-build\n`, + ); + } +} + +/** Warn — never fail — when the shared content root is not there to install + into. Everything but content packages and TONE3000 downloads works without + it, and the dev loop should not demand sudo before it will build. */ +export function checkContentRoot(): void { + let ok = false; + try { + ok = statSync(CONTENT_ROOT).isDirectory(); + accessSync(CONTENT_ROOT, constants.W_OK); + } catch { + ok = false; + } + if (!ok) { + console.warn( + `\nwarning: ${CONTENT_ROOT} is missing or not writable, so content packages and TONE3000\n` + + ' downloads cannot install. The installer creates it; on a dev machine, once:\n\n' + + ` sudo install -d -m 1777 ${CONTENT_ROOT}\n`, + ); + } +} + +export function buildTarget(config: string, target: string, cleanFirst = false): void { + run(`Building ${target} (${config})`, 'cmake', [ + '--build', buildDir(config), + '--target', target, + ...(cleanFirst ? ['--clean-first'] : []), + ]); +} + +/** Ask any running instance to die and wait until it is gone, so the linker + can overwrite the binary and a relaunch never races the outgoing process. + Matched by exact process name, so the WebKit helper processes — which + die with their parent — are not what is waited on. */ +export async function stopApp(): Promise { + spawnSync('pkill', ['-x', 'Plectrify']); + for (let i = 0; i < 100; i++) { + if (spawnSync('pgrep', ['-x', 'Plectrify']).status !== 0) return; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + fail('a Plectrify process is still running and would not exit. Quit it and retry.'); +} + +/** Launch the app and confirm it is still alive a moment later, so a startup + crash is reported rather than printed as "Launching..." and exit 0. */ +export async function startApp( + config: string, + extraEnv: Record = {}, + note = '', +): Promise { + const binary = appBinary(config); + console.log(`==> Launching ${binary}${note}`); + + const child = spawn(binary, [], { + env: { ...process.env, ...extraEnv }, + detached: true, + stdio: 'ignore', + }); + child.unref(); + + const exited = await Promise.race([ + new Promise((resolve) => child.once('exit', () => resolve(true))), + new Promise((resolve) => setTimeout(() => resolve(false), 3000)), + ]); + + if (exited) { + fail( + 'Plectrify exited immediately after launch. Run the binary from a terminal to see why:\n' + + ` ${binary}\n` + + "A crash leaves a core with systemd-coredump ('coredumpctl list Plectrify') or under /var/crash.", + ); + } +} + +/** Stage ui/dist beside the executable, where moduleResourceDir() looks in a + Release build. Debug serves the source tree's ui/dist directly. */ +export function stageUiBesideBinary(config: string): void { + if (config === 'Debug') return; + + const dist = join(ROOT, 'ui/dist'); + if (!existsSync(join(dist, 'index.html'))) { + fail('ui/dist is missing — build the UI first (rerun without --no-ui).'); + } + + const target = join(appBinary(config), '..', 'ui'); + rmSync(target, { recursive: true, force: true }); + cpSync(dist, target, { recursive: true }); +} diff --git a/scripts/release.linux.ts b/scripts/release.linux.ts new file mode 100644 index 0000000..38cd377 --- /dev/null +++ b/scripts/release.linux.ts @@ -0,0 +1,394 @@ +/** + * The Linux release pipeline: build, test and package Plectrify as a .deb, then + * add it to the GitHub pre-release the Windows pipeline created. The twin of + * release.macos.ts, and the same step in the same order — run at the tagged + * commit, after the Windows step, before promotion: + * + * It is the `linux` job of .github/workflows/release.yml, reached through + * `pnpm release` (release.ts), which refuses to run outside GitHub Actions. + * --no-upload builds and packages without publishing. + * + * WHAT THE PACKAGE INSTALLS + * + * /opt/plectrify/ the app: Plectrify, ui/, plugins/ (Neural Amp + * Modeler), the licence, notices and source offer + * /usr/bin/plectrify a link to it + * /usr/share/applications/ the desktop entry + * /usr/share/icons/hicolor/ the icon + * /usr/lib/vst3/Plectrify.vst3 the plugin, self-contained (ui/ and NAM under + * Contents/Resources, the layout moduleResourceDir + * resolves) — the machine-wide VST3 folder every + * Linux host searches + * /var/lib/Plectrify created 1777 by postinst: the shared content + * root (CatalogueInstaller::contentRootDirectory) + * + * A .deb rather than an AppImage or a tarball for the same reason the Mac ships + * a pkg rather than a DMG: something has to create directories outside the + * user's home — the VST3 folder and the content root — and only an installer + * running as root can. Removing the package leaves /var/lib/Plectrify alone, + * like the Windows uninstaller leaves ProgramData: those are the user's + * downloads. + * + * Neural Amp Modeler is not downloaded here as on the other platforms: Linux + * builds its own (Source/namplugin, target PlectrifyNam_VST3) in this same + * configure, so the bundled copy is always this commit's. + */ +import { createHash } from 'node:crypto'; +import { + chmodSync, copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, + writeFileSync, +} from 'node:fs'; +import { join } from 'node:path'; +import { parseArgs } from 'node:util'; +import { + JUCE_PATCHED_FILES, JUCE_PATCHES, ROOT, TEST_TARGETS, bundledPluginsMissingFor, capture, fail, + pnpm, run, +} from './shared.ts'; +import { + IN_TREE_PLUGINS, + appBinary, + assertLinux, + buildDir, + buildTarget, + configure, + inTreePluginBundle, + pluginBundle, +} from './linux.ts'; + +assertLinux('release.linux.ts'); + +const { values } = parseArgs({ + options: { + version: { type: 'string' }, + 'skip-tests': { type: 'boolean', default: false }, + 'no-upload': { type: 'boolean', default: false }, + }, +}); + +const CONFIG = 'Release'; +const PLATFORM = 'linux-x64'; + +if (values['skip-tests'] && !values['no-upload']) { + fail('--skip-tests cannot publish; pair it with --no-upload. Run the full suite before uploading.'); +} + +// --- Version, tree, licence: the same gates as the other two pipelines ------ +const declaredVersion = readFileSync(join(ROOT, 'VERSION'), 'utf8').trim(); +const version = values.version ?? declaredVersion; +if (!/^\d+\.\d+\.\d+$/.test(version)) { + fail('version must be numeric semantic version MAJOR.MINOR.PATCH, for example 0.1.0.'); +} +if (version !== declaredVersion) { + fail(`VERSION contains '${declaredVersion}', but the command requested '${version}'.`); +} + +if (capture('git', ['-C', ROOT, 'status', '--porcelain']).trim() !== '') { + fail('the working tree is dirty. Commit the release sources before packaging.'); +} +const commit = capture('git', ['-C', ROOT, 'rev-parse', 'HEAD']).trim(); +if (!/^[0-9a-f]{40}$/i.test(commit)) fail('could not determine the release source commit.'); +const branch = capture('git', ['-C', ROOT, 'symbolic-ref', '--quiet', '--short', 'HEAD']).trim() + || '(detached HEAD)'; + +// Byte-exact canonical AGPLv3, same pin as the other release scripts. +const licenseBytes = readFileSync(join(ROOT, 'LICENSE')); +const licenseHash = createHash('sha256').update(licenseBytes).digest('hex'); +if ( + licenseBytes.length !== 34523 + || licenseHash !== '0d96a4ff68ad6d4b6f1f30f713b18d5184912ba8dd389f86aa7710db079abcb0' +) { + fail('LICENSE is not the complete canonical 34,523-byte GNU AGPLv3 document.'); +} + +// A shipped plugin this platform neither downloads nor builds would leave the +// app without something it assumes is there. +const unshippable = bundledPluginsMissingFor(PLATFORM).filter((p) => !(p.id in IN_TREE_PLUGINS)); +if (unshippable.length > 0) { + fail(`no ${PLATFORM} build of ${unshippable.map((p) => p.name).join(', ')} (packaging/bundled-plugins.json).`); +} + +if (!existsSync(join(ROOT, 'packaging/node_modules'))) { + pnpm('Installing packaging tooling', ['--dir', join(ROOT, 'packaging'), 'install', '--frozen-lockfile']); +} +pnpm('Validating the plugin catalogue', ['--dir', join(ROOT, 'packaging'), 'validate']); + +// --- The tag must exist and name this commit -------------------------------- +const tag = `v${version}`; +const repoSlug = (() => { + const origin = capture('git', ['-C', ROOT, 'remote', 'get-url', 'origin']).trim(); + const match = /github\.com[:/]([^/]+)\/([^/]+?)(\.git)?$/.exec(origin); + if (!match) fail(`could not derive a GitHub owner/name from the origin remote '${origin}'.`); + return `${match[1]}/${match[2]}`; +})(); + +let sourceArchiveHash = ''; +if (!values['no-upload']) { + // Peel an annotated tag to its commit; see release.macos.ts for why the + // first ls-remote line is not the answer. + const refs = capture('git', ['-C', ROOT, 'ls-remote', 'origin', `refs/tags/${tag}`, `refs/tags/${tag}^{}`]) + .split('\n') + .map((line) => line.trim().split(/\s+/)) + .filter((parts) => parts.length === 2 && /^[0-9a-f]{40}$/i.test(parts[0]!)); + const refCommit = (name: string) => refs.find((parts) => parts[1] === name)?.[0] ?? ''; + const taggedCommit = refCommit(`refs/tags/${tag}^{}`) || refCommit(`refs/tags/${tag}`); + + if (taggedCommit === '') { + fail(`no remote tag ${tag}. Publish the Windows pre-release first — it creates the tag, the GitHub release and the corresponding-source archive this package rides with.`); + } + if (taggedCommit.toLowerCase() !== commit.toLowerCase()) { + fail(`remote tag ${tag} names ${taggedCommit}, but this checkout is at ${commit}. Check out the release commit.`); + } + + const json = capture('gh', ['release', 'view', tag, '--repo', repoSlug, '--json', 'isPrerelease']); + if (json.trim() === '') { + fail(`could not read a GitHub release for ${tag} — publish the Windows pre-release first, and check that gh is authenticated.`); + } + if ((JSON.parse(json) as { isPrerelease?: boolean }).isPrerelease !== true) { + fail(`release ${tag} has already been promoted to latest, and a promoted version is immutable. Bump VERSION and release again.`); + } + + sourceArchiveHash = capture('gh', [ + 'release', 'download', tag, '--repo', repoSlug, + '--pattern', `Plectrify-${version}-source.zip.sha256`, '--output', '-', + ]).trim().split(/\s+/)[0] ?? ''; + if (!/^[0-9a-f]{64}$/.test(sourceArchiveHash)) { + fail(`the ${tag} release carries no Plectrify-${version}-source.zip.sha256 — publish it from release.windows.ts first.`); + } +} + +// --- UI gate + build --------------------------------------------------------- +const uiDir = join(ROOT, 'ui'); +pnpm('Installing UI dependencies', ['install', '--frozen-lockfile'], { cwd: uiDir }); +pnpm('UI formatting check', ['format:check'], { cwd: uiDir }); +pnpm('svelte-check', ['check'], { cwd: uiDir }); +pnpm('UI tests', ['test'], { cwd: uiDir }); +pnpm('Building UI', ['build'], { cwd: uiDir }); + +// --- Native build + tests ---------------------------------------------------- +// A release tree starts clean: incremental builds can link stale objects +// against changed headers (see the AGENTS.md note on header-inline code). +rmSync(buildDir(CONFIG), { recursive: true, force: true }); +configure(CONFIG); +buildTarget(CONFIG, 'Plectrify'); +buildTarget(CONFIG, 'PlectrifyPlugin_VST3'); +for (const { target } of Object.values(IN_TREE_PLUGINS)) buildTarget(CONFIG, target); +if (!values['skip-tests']) { + for (const target of TEST_TARGETS) buildTarget(CONFIG, target); + run('Running native tests', 'ctest', ['--test-dir', buildDir(CONFIG), '--output-on-failure']); +} + +const app = appBinary(CONFIG); +if (!existsSync(app)) fail(`release app was not produced at ${app}.`); +const vst3Built = pluginBundle(CONFIG); +if (!existsSync(join(vst3Built, 'Contents/x86_64-linux/Plectrify.so'))) { + fail(`release VST3 was not produced at ${vst3Built}.`); +} + +// --- The published source must be the exact patched tree built from -------- +const juceCommitPin = (() => { + const cmake = readFileSync(join(ROOT, 'CMakeLists.txt'), 'utf8'); + const match = /GIT_TAG\s+([0-9a-f]{40})/i.exec(cmake); + if (!match) fail('could not read the pinned JUCE commit from CMakeLists.txt.'); + return match[1]!.toLowerCase(); +})(); +const juceSource = join(buildDir(CONFIG), '_deps/juce-src'); +const juceCommit = capture('git', ['-C', juceSource, 'rev-parse', 'HEAD']).trim().toLowerCase(); +if (juceCommit !== juceCommitPin) { + fail(`expected JUCE commit ${juceCommitPin}, but the release build used '${juceCommit}'.`); +} +for (const { patch, file, marker } of JUCE_PATCHES) { + if (!readFileSync(join(juceSource, file), 'utf8').includes(marker)) { + fail(`the configured JUCE tree does not contain Plectrify's ${patch}.`); + } +} +const juceChanged = capture('git', ['-C', juceSource, 'diff', '--name-only']) + .split('\n').map((line) => line.trim()).filter(Boolean).sort(); +if (juceChanged.join('\n') !== JUCE_PATCHED_FILES.join('\n')) { + fail('the JUCE checkout contains changes other than Plectrify\'s declared patches.'); +} +if (capture('git', ['-C', juceSource, 'ls-files', '--others', '--exclude-standard']).trim() !== '') { + fail('the JUCE checkout contains untracked files; refusing to publish ambiguous corresponding source.'); +} + +// --- Stage the package tree -------------------------------------------------- +const outputDir = join(ROOT, 'artifacts'); +const stage = join(outputDir, 'deb-stage'); +rmSync(stage, { recursive: true, force: true }); + +const optDir = join(stage, 'opt/plectrify'); +mkdirSync(optDir, { recursive: true }); +copyFileSync(app, join(optDir, 'Plectrify')); +chmodSync(join(optDir, 'Plectrify'), 0o755); + +/** What the app and the plugin each carry as read-only resources: the UI, the + bundled plugins, and the documents that travel with a binary. */ +function stageResources(resources: string): void { + mkdirSync(resources, { recursive: true }); + cpSync(join(ROOT, 'ui/dist'), join(resources, 'ui'), { recursive: true }); + for (const { bundle } of Object.values(IN_TREE_PLUGINS)) { + cpSync(inTreePluginBundle(CONFIG, bundle), join(resources, 'plugins', bundle), { + recursive: true, + verbatimSymlinks: true, + }); + } + copyFileSync(join(ROOT, 'LICENSE'), join(resources, 'LICENSE')); + copyFileSync(join(juceSource, 'LICENSE.md'), join(resources, 'JUCE_LICENSE.md')); + copyFileSync(join(ROOT, 'THIRD_PARTY_NOTICES.md'), join(resources, 'THIRD_PARTY_NOTICES.md')); + writeFileSync(join(resources, 'SOURCE_OFFER.txt'), sourceOffer()); + writeFileSync(join(resources, 'BUILD_INFO.txt'), buildInfo()); +} + +function sourceOffer(): string { + const url = `https://github.com/${repoSlug}/releases/download/${tag}/Plectrify-${version}-source.zip`; + return `CORRESPONDING SOURCE FOR PLECTRIFY ${version} + +Plectrify is distributed under the GNU Affero General Public License, version 3 +only. The complete corresponding source used to build this binary, including +the exact patched JUCE source tree, is published beside the installers: + +${url} +${sourceArchiveHash ? `\nSHA-256: ${sourceArchiveHash}\n` : ''} +This offer covers Plectrify itself — the standalone application and the +Plectrify VST3 plug-in this package installs, which are builds of the same +AGPLv3 source. Both host VST3 plug-ins and are built against Steinberg's VST3 +SDK as bundled with JUCE, which Plectrify uses under the SDK's GPLv3 option; see +THIRD_PARTY_NOTICES.md. + +The Neural Amp Modeler this package ships is Plectrify's own Linux build of that +project's MIT-licensed DSP; its source is part of the archive above +(Source/namplugin) together with the exact third-party commits it compiles, +recorded in CMakeLists.txt and THIRD_PARTY_NOTICES.md. +`; +} + +function buildInfo(): string { + return `Plectrify release +Version: ${version} +Git commit: ${commit} +Git branch: ${branch} +JUCE commit: ${juceCommit} (JUCE 8.0.14) +Plectrify licence: GNU AGPL version 3 only +Corresponding source: Plectrify-${version}-source.zip +`; +} + +// The app resolves resources beside its executable (moduleResourceDir). +stageResources(optDir); + +// The plugin: a copy of the built bundle, with the same resources under +// Contents/Resources, which moduleResourceDir maps x86_64-linux/ to. +const vst3Staged = join(stage, 'usr/lib/vst3/Plectrify.vst3'); +mkdirSync(join(vst3Staged, '..'), { recursive: true }); +cpSync(vst3Built, vst3Staged, { recursive: true, verbatimSymlinks: true }); +stageResources(join(vst3Staged, 'Contents/Resources')); + +mkdirSync(join(stage, 'usr/bin'), { recursive: true }); +symlinkSync('/opt/plectrify/Plectrify', join(stage, 'usr/bin/plectrify')); + +mkdirSync(join(stage, 'usr/share/applications'), { recursive: true }); +writeFileSync( + join(stage, 'usr/share/applications/plectrify.desktop'), + `[Desktop Entry] +Type=Application +Name=Plectrify +GenericName=Guitar Rig +Comment=Play your guitar through a chain of VST3 amps and effects +Exec=/opt/plectrify/Plectrify +Icon=plectrify +Terminal=false +Categories=AudioVideo;Audio;Music; +StartupWMClass=Plectrify +`, +); +const iconDir = join(stage, 'usr/share/icons/hicolor/1024x1024/apps'); +mkdirSync(iconDir, { recursive: true }); +copyFileSync(join(ROOT, 'Assets/PlectrifyIcon.png'), join(iconDir, 'plectrify.png')); + +// --- Package metadata --------------------------------------------------------- +const debian = join(stage, 'DEBIAN'); +mkdirSync(debian, { recursive: true }); +const installedKb = Number(capture('du', ['-sk', '--exclude=DEBIAN', stage]).split(/\s+/)[0] ?? 0); + +// Each runtime library under both its pre- and post-t64 name (Ubuntu 24.04 +// renamed them for the 64-bit time_t transition), so one package installs on +// either side of it. WebKitGTK and curl are what NEEDS_WEB_BROWSER/NEEDS_CURL +// link; JACK is dlopened and therefore only recommended. +writeFileSync( + join(debian, 'control'), + `Package: plectrify +Version: ${version} +Section: sound +Priority: optional +Architecture: amd64 +Maintainer: Plectrify +Homepage: https://plectrify.com +Installed-Size: ${installedKb} +Depends: libwebkit2gtk-4.1-0, libgtk-3-0t64 | libgtk-3-0, libasound2t64 | libasound2, libcurl4t64 | libcurl4, libfreetype6, libfontconfig1, libx11-6, libxext6, libxrandr2, libxinerama1, libxcursor1 +Recommends: pipewire-jack | libjack-jackd2-0 +Description: Guitar-rig host for VST3 amps and effects + Plectrify runs a chain of VST3 plugins live from a guitar input, with + knob mapping, rigs, a tuner and TONE3000 captures. Installs the + standalone application and the Plectrify VST3 plug-in. +`, +); +writeFileSync( + join(debian, 'postinst'), + `#!/bin/sh +set -e +# The shared content root: fixed, so patch packs' baked paths resolve on every +# machine, and writable by every account without elevation, sticky so each can +# remove only what it owns — the mode /Users/Shared carries on macOS. +install -d -m 1777 /var/lib/Plectrify +chmod 1777 /var/lib/Plectrify +exit 0 +`, +); +chmodSync(join(debian, 'postinst'), 0o755); + +// --- Build the .deb ------------------------------------------------------------ +const debName = `Plectrify-${version}-linux-x64.deb`; +const debPath = join(outputDir, debName); +rmSync(debPath, { force: true }); +run('Building the .deb', 'dpkg-deb', ['--root-owner-group', '-Zxz', '--build', stage, debPath]); + +const debHash = createHash('sha256').update(readFileSync(debPath)).digest('hex'); +const checksumPath = join(outputDir, `${debName}.sha256`); +writeFileSync(checksumPath, `${debHash} ${debName}\n`, 'ascii'); + +const manifestPath = join(outputDir, 'release-manifest-linux.json'); +writeFileSync( + manifestPath, + `${JSON.stringify( + { + product: 'Plectrify', + version, + sourceCommit: commit, + sourceBranch: branch, + juceCommit, + jucePatches: JUCE_PATCHES.map(({ patch }) => patch), + binaryLicense: 'AGPL-3.0-only', + platform: PLATFORM, + installer: debName, + sha256: debHash, + sourceArchive: `Plectrify-${version}-source.zip`, + sourceSha256: sourceArchiveHash, + }, + null, + 2, + )}\n`, +); + +if (values['no-upload']) { + console.log(`\nRelease build ready: ${debPath}`); + console.log('Not uploaded (--no-upload). Install it with: sudo apt install ' + debPath); + process.exit(0); +} + +// --clobber replaces this platform's assets on the pre-release only; the gate +// above refuses a promoted version. +run(`Uploading to the ${tag} release`, 'gh', [ + 'release', 'upload', tag, + debPath, checksumPath, manifestPath, + '--repo', repoSlug, '--clobber', +]); +console.log(`\nPublished ${debName} on https://github.com/${repoSlug}/releases/tag/${tag}`); diff --git a/scripts/release.macos.ts b/scripts/release.macos.ts index b4b6425..31e6644 100644 --- a/scripts/release.macos.ts +++ b/scripts/release.macos.ts @@ -2,26 +2,22 @@ * Build, sign, notarize and publish the macOS release artifact — the mac * counterpart of release.windows.ts, which stays the Windows pipeline untouched. * - * pnpm release:build build + ad-hoc sign locally, publishing nothing * pnpm release build + ad-hoc sign + upload (no Apple account) * pnpm release --developer-id build + sign + notarize + upload - * pnpm release:build --developer-id rehearse locally (still signs/notarizes) - * pnpm release:build --skip-tests iterate on packaging problems only * - * Those are release.ts, which dispatches here on macOS and to - * release.windows.ts on Windows; this file is the mac half of those commands, - * and it is the dispatcher that supplies --ad-hoc (the default while Plectrify - * has no Apple Developer Program membership) and the --no-upload that makes a - * build local. This script's own flags are unchanged and mean what they always - * did: --developer-id is release.ts's, and never reaches here. + * Those are release.ts, run by the `macos` job of .github/workflows/release.yml + * and refused anywhere else; this file is the mac half of that command, and it + * is the dispatcher that supplies --ad-hoc (the default while Plectrify has no + * Apple Developer Program membership). --developer-id is release.ts's, and never + * reaches here; --no-upload builds without publishing. * * THE MAC ARTIFACT RIDES THE WINDOWS RELEASE. Step 1 on the Windows box creates * the tag, the GitHub pre-release, and the AGPL corresponding-source archive; * this script verifies it is building the very same commit, then uploads * Plectrify--macos-arm64.pkg beside the Windows installer. Update * discovery reads only the release tag, so a second asset needs no app change. - * Run order per release: pnpm release (Windows box) → pnpm release (Mac) → - * pnpm release:promote (Windows box). + * Run order per release: the Release workflow (Windows job, then Mac and + * Linux), then the Promote workflow. * * THE ARTIFACT IS AN INSTALLER PACKAGE, NOT A DISK IMAGE. A DMG can only offer * drag targets, and the plugin's — /Library/Audio/Plug-Ins/VST3 — does not @@ -233,7 +229,7 @@ if (!values['no-upload']) { const taggedCommit = refCommit(`refs/tags/${tag}^{}`) || refCommit(`refs/tags/${tag}`); if (taggedCommit === '') { - fail(`no remote tag ${tag}. Publish the Windows pre-release first ('pnpm release' on the Windows box) — it creates the tag, the GitHub release and the corresponding-source archive this artifact rides with.`); + fail(`no remote tag ${tag}. Publish the Windows pre-release first (the Windows job of the Release workflow) — it creates the tag, the GitHub release and the corresponding-source archive this artifact rides with.`); } if (taggedCommit.toLowerCase() !== commit.toLowerCase()) { fail(`remote tag ${tag} names ${taggedCommit}, but this checkout is at ${commit}. Check out the release commit.`); @@ -247,7 +243,7 @@ if (!values['no-upload']) { const release = (() => { const json = capture('gh', ['release', 'view', tag, '--repo', repoSlug, '--json', 'isPrerelease']); if (json.trim() === '') { - fail(`could not read a GitHub release for ${tag} — publish the Windows pre-release first ('pnpm release' on the Windows box), and check that gh is authenticated.`); + fail(`could not read a GitHub release for ${tag} — publish the Windows pre-release first (the Windows job of the Release workflow), and check that gh is authenticated.`); } try { return JSON.parse(json) as { isPrerelease?: boolean }; @@ -704,4 +700,4 @@ if (adHoc) { 'release notes and the site\'s download page still carry the Open Anyway instructions.', ); } -console.log('Once both platforms are verified, promote with: pnpm release:promote (Windows box).'); +console.log('Once every platform is verified, run the Promote workflow.'); diff --git a/scripts/release.ts b/scripts/release.ts index 5e8cc00..4015ef4 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -1,66 +1,46 @@ /** - * `pnpm release` and `pnpm release:build` — the release commands, on whichever - * machine you are at. + * `pnpm release` — the release command, run by GitHub Actions and nowhere else. * - * A release is three steps across two machines (RELEASING.md), and until now - * each was a different command with a different flag: `release:windows - * --pre-release`, then `release:macos`, then `release:windows --promote`. - * Every one of those is unambiguous from where you are standing — there is - * only one half of a release you *can* do on a given OS — so the flag was - * asking you to restate what the machine already knew. + * Releases are built on GitHub's runners (.github/workflows/release.yml and + * promote.yml, RELEASING.md), never on a maintainer's machine: a local build + * carries whatever that machine happens to have installed, and nothing records + * what that was. This refuses to run outside Actions so a release can only ever + * come from a clean runner at a tagged commit. Building and testing locally is + * `pnpm app --dist`, which runs the same gates and publishes nothing. * - * So this dispatches on platform, exactly as run.ts does for `pnpm app`: + * It dispatches on platform, exactly as run.ts does for `pnpm app`, because + * each runner can only do its own half: * - * both pnpm release:build build and test locally, publishing nothing - * Windows pnpm release step 1 — build, tag, publish the pre-release - * macOS pnpm release step 2 — build, sign, upload the installer pkg - * Windows pnpm release:promote step 3 — flip the verified release to latest + * Windows pnpm release step 1 — build, tag, publish the pre-release + * macOS pnpm release step 2 — build, sign, upload the installer pkg + * Linux pnpm release step 2 — build, upload the .deb + * Windows pnpm release --promote step 3 — flip the verified release to latest * - * THE COMMANDS ARE NAMED FOR THE JOB, NOT FOR THE MACHINE. `release:windows` - * and `release:macos` were the platform scripts wearing their file names, and - * the name then said the one thing you did not need telling — you know which - * box you are sitting at — while saying nothing about what the command did. - * Worse, the pair had drifted into meaning *different steps*: `release:windows` - * built locally and published nothing, `release:macos` built and uploaded. One - * table row is now one command on both OSes, and the platform scripts are what - * this file calls rather than what a maintainer types. + * Promotion stays a step of its own (its own workflow): steps 1 and 2 produce + * artifacts, which is safe to repeat while a version is still a pre-release. + * Step 3 is the sign-off that someone installed the build and played through + * it, and it is irreversible — a promoted version is immutable, because people + * may already hold its published checksums. * - * Promotion stays a command of its own, and deliberately so: steps 1 and 2 - * produce artifacts, which is safe to repeat and cheap to redo while a version - * is still a pre-release. Step 3 is the sign-off that you installed the build - * and played through it, and it is irreversible — a promoted version is - * immutable, because people may already hold its published checksums. Folding - * it into `pnpm release` would let a command that means "build the thing" - * quietly also mean "declare it good". - * - * run.ts used to say the release pipelines were deliberately NOT dispatched, - * on the grounds that picking one silently would hide which half you were - * doing. The concern was right and the remedy was the wrong one: the platform - * *is* the half, so nothing is being picked. What was actually needed was for - * the step to be announced rather than inferred, which is what the banner - * below does. - * - * Every flag the platform scripts take still works and is forwarded untouched - * (`--version` to double-check what you are shipping, `--skip-tests` to iterate - * on packaging). Passing --promote or --pre-release explicitly is honoured - * rather than overridden, so the older invocations in anyone's shell history - * keep doing what they always did. + * Every flag the platform scripts take is forwarded untouched (`--version` to + * double-check what is shipping, `--no-site` to promote without deploying). */ const args = process.argv.slice(2); -/** `pnpm release:build` — consumed here; the platform scripts express it in - their own vocabulary (see localFlags below). */ -const local = args.includes('--local'); +if (process.env.GITHUB_ACTIONS !== 'true') { + console.error('error: releases are built by GitHub Actions only (RELEASING.md).'); + console.error(' Run the Release workflow; to build and test locally, use pnpm app --dist.'); + process.exit(1); +} /** Opt back into Developer ID signing on the Mac once a membership exists; see macosFlags. Consumed here too — release.macos.ts knows only --ad-hoc. */ const developerId = args.includes('--developer-id'); -const forwarded = args.filter((arg) => arg !== '--local' && arg !== '--developer-id'); +const forwarded = args.filter((arg) => arg !== '--developer-id'); /** Injected so the Windows script publishes rather than only building. A - bare `release.windows.ts` packages an installer and stops, which is the - right default for that script alone but not for the release command. */ + bare `release.windows.ts` packages an installer and stops. */ function withPreRelease(list: string[]): string[] { const publishing = list.includes('--pre-release') || list.includes('--promote'); return publishing ? list : ['--pre-release', ...list]; @@ -79,10 +59,6 @@ function withPreRelease(list: string[]): string[] { * is that ask, and is also what --identity/--installer-identity imply: naming a * certificate is not something you do by accident, and --ad-hoc refuses to be * combined with either. - * - * The local build then adds --no-upload, where Windows expresses the same thing - * by *omitting* --pre-release. Both scripts publish nothing; only the polarity - * of the switch differs, which is exactly what this dispatcher is for. */ function macosFlags(list: string[]): string[] { const signed = developerId @@ -90,8 +66,7 @@ function macosFlags(list: string[]): string[] { || list.includes('--installer-identity') || list.some((arg) => arg.startsWith('--identity=') || arg.startsWith('--installer-identity=')); const adHoc = !signed && !list.includes('--ad-hoc') ? ['--ad-hoc'] : []; - const noUpload = local && !list.includes('--no-upload') ? ['--no-upload'] : []; - return [...adHoc, ...noUpload, ...list]; + return [...adHoc, ...list]; } function announce(step: string, what: string): void { @@ -108,16 +83,13 @@ async function run(script: string, flags: string[]): Promise { } if (process.platform === 'win32') { - const flags = local ? forwarded : withPreRelease(forwarded); + const flags = withPreRelease(forwarded); - if (local) { - announce('Build and test locally', 'Windows installer, publishing nothing'); - console.log('Install it and play through it before publishing anything.\n'); - } else if (flags.includes('--promote')) { + if (flags.includes('--promote')) { announce('Release step 3 of 3', 'promoting the verified pre-release to latest'); } else { announce('Release step 1 of 3', 'Windows installer + GitHub pre-release'); - console.log('Next: pnpm release on the Mac, at this same commit.\n'); + console.log('Next: the macOS and Linux jobs build at the tag this pushes.\n'); } await run('./release.windows.ts', flags); @@ -132,16 +104,16 @@ if (process.platform === 'win32') { ? 'macOS installer pkg, ad-hoc signed bundles (pkg unsigned, not notarized)' : 'signed, notarized macOS installer pkg'; - if (local) { - announce('Build and test locally', `${what}, publishing nothing`); - console.log('Install it and play through it before publishing anything.\n'); - } else { - announce('Release step 2 of 3', what); - console.log('Next: pnpm release:promote on Windows, once you have tested the build.\n'); - } + announce('Release step 2 of 3', what); + console.log('Next: the Promote workflow, once every build is tested.\n'); await run('./release.macos.ts', flags); +} else if (process.platform === 'linux') { + announce('Release step 2 of 3', 'Linux .deb'); + console.log('Next: the Promote workflow, once every build is tested.\n'); + + await run('./release.linux.ts', forwarded); } else { - console.error('error: Plectrify releases from Windows and macOS only.'); + console.error('error: Plectrify releases from Windows, macOS and Linux only.'); process.exit(1); } diff --git a/scripts/release.windows.ts b/scripts/release.windows.ts index 61d0387..cee93a5 100644 --- a/scripts/release.windows.ts +++ b/scripts/release.windows.ts @@ -1,17 +1,15 @@ /** - * Build and package a local Plectrify Windows x64 release. + * Build and package the Plectrify Windows x64 release. * - * pnpm release:build build + package only, publishing nothing * pnpm release step 1 — build + publish the pre-release - * pnpm release:promote step 3 — promote it to latest + * pnpm release --promote step 3 — promote it to latest * - * All three are release.ts dispatching here — with nothing, --pre-release and - * --promote respectively. The commands are named for the job rather than for - * the machine (there is no `release:windows` any more): on the Mac the same two - * first names reach release.macos.ts instead. + * Both are release.ts dispatching here (with --pre-release and --promote), run + * by the `windows` job of .github/workflows/release.yml and by promote.yml, and + * refused outside GitHub Actions. * - * Publishing is deliberately opt-in. The normal path builds and packages - * locally; --pre-release additionally creates an annotated tag and a GitHub + * Publishing is opt-in at this level: without a flag the script builds and + * packages only; --pre-release additionally creates an annotated tag and a GitHub * release that is always marked pre-release, uploading the installer, matching * corresponding source, and checksums using the locally authenticated gh CLI. * It refuses to run with --skip-tests: an untested build is never published. @@ -19,10 +17,9 @@ * once --promote has made a version the latest production release it is * immutable, and republishing it is refused — correct it with a new version. * - * The macOS artifact rides the release this script creates: run `pnpm release` - * on the Mac between steps 1 and 3 (see RELEASING.md). --promote refuses a - * release that does not yet carry that artifact, so "latest" never means - * Windows only. This replaced release-windows.ps1 one-for-one; PowerShell + * The macOS and Linux artifacts ride the release this script creates: their + * jobs run between steps 1 and 3 (see RELEASING.md). --promote refuses a + * release that does not yet carry both, so "latest" never means Windows only. This replaced release-windows.ps1 one-for-one; PowerShell * survives only as a utility for Authenticode verification and elevation * (windows.ts). */ @@ -48,8 +45,8 @@ const { values } = parseArgs({ promote: { type: 'boolean', default: false }, 'skip-tests': { type: 'boolean', default: false }, // Promotion also points plectrify.com at the new version and deploys it. - // This opts out — for a promote run from a machine with no wrangler - // credentials, where the site is then deployed separately. + // This opts out — for a promote run with no Cloudflare credentials, where + // the site is then deployed separately. 'no-site': { type: 'boolean', default: false }, 'webview2-installer': { type: 'string' }, 'vcredist-installer': { type: 'string' }, @@ -126,14 +123,15 @@ if (values.promote) { const slug = repoSlug(); const assets = capture('gh', ['release', 'view', tag, '--repo', slug, '--json', 'assets']); if (assets.trim() === '') { - fail(`no published GitHub release ${tag} exists to promote. Run 'pnpm release' first, and check that gh is authenticated.`); + fail(`no published GitHub release ${tag} exists to promote. Run the Release workflow first.`); } - // One release, two artifacts, in order (RELEASING.md): this command is the - // third step, and running it before the Mac has uploaded — or after it half - // has — publishes a "latest" release with no macOS download on it. The names - // are release.macos.ts's, so a mac artifact that was never built and one - // whose upload was interrupted read the same way here: not promotable yet. + // One release, three artifacts, in order (RELEASING.md): this command is the + // third step, and running it before the Mac or Linux has uploaded — or after + // one half has — publishes a "latest" release missing a download. The names + // are release.macos.ts's and release.linux.ts's, so an artifact that was never + // built and one whose upload was interrupted read the same way here: not + // promotable yet. const releaseAssets = (() => { try { return (JSON.parse(assets) as { assets?: { name?: string }[] }).assets ?? []; @@ -142,14 +140,31 @@ if (values.promote) { } })(); const published = new Set(releaseAssets.map((asset) => asset.name)); - const macArtifacts = [ - `Plectrify-${version}-macos-arm64.pkg`, - `Plectrify-${version}-macos-arm64.pkg.sha256`, - 'release-manifest-macos.json', + const platformArtifacts = [ + { + os: 'macOS', + where: 'macOS', + names: [ + `Plectrify-${version}-macos-arm64.pkg`, + `Plectrify-${version}-macos-arm64.pkg.sha256`, + 'release-manifest-macos.json', + ], + }, + { + os: 'Linux', + where: 'Linux', + names: [ + `Plectrify-${version}-linux-x64.deb`, + `Plectrify-${version}-linux-x64.deb.sha256`, + 'release-manifest-linux.json', + ], + }, ]; - const missing = macArtifacts.filter((name) => !published.has(name)); - if (missing.length > 0) { - fail(`the ${tag} release is missing its macOS artifact (${missing.join(', ')}). Run 'pnpm release' on the Mac at this commit before promoting.`); + for (const { os, where, names } of platformArtifacts) { + const missing = names.filter((name) => !published.has(name)); + if (missing.length > 0) { + fail(`the ${tag} release is missing its ${os} artifact (${missing.join(', ')}). Re-run the Release workflow's ${where} job before promoting.`); + } } // Notes are not touched here: they were generated at publish time and may @@ -602,9 +617,8 @@ ${sourceReleaseUrl} SHA-256: ${sourceHash} -If this is a locally built package that has not been published, run -'pnpm release' from the release commit; it creates ${sourceArchiveName} -beside the installer. +If this package was built without publishing, the Release workflow run +for this commit creates ${sourceArchiveName} beside the installer. THIRD-PARTY PLUGINS @@ -800,7 +814,7 @@ if (values['pre-release']) { '--notes-file', notesPath, ]); console.log(`Published pre-release: https://github.com/${slug}/releases/tag/${tag}`); - console.log(`Add the mac artifact next (pnpm release, on the Mac), then promote with: pnpm release:promote`); + console.log('The macOS and Linux jobs add their artifacts next; then run the Promote workflow.'); } console.log(`Release ready: ${installerPath}`); diff --git a/scripts/run.linux.ts b/scripts/run.linux.ts new file mode 100644 index 0000000..082d160 --- /dev/null +++ b/scripts/run.linux.ts @@ -0,0 +1,249 @@ +/** + * Build the Svelte UI + the native app, then launch it — the Linux twin of + * run.macos.ts, with the same modes. Run from the repo root: + * + * pnpm install once, for tsx + * pnpm app Default HMR loop: start the Vite dev server + + * launch the Debug app pointed at it. + * pnpm app --ui-only Rebuild ui/dist + relaunch, skipping cmake. + * pnpm app --dist Full build + gate: UI (format + check + test + + * build), native (cmake), native tests (ctest), + * then run serving the baked ui/dist. + * pnpm app --dist --no-ui Full build, skip the UI build. + * pnpm app --dist --no-run Full build only, don't launch. + * pnpm app --clean Rebuild the native target from scratch. + * pnpm app --dist --config Release + * Release build; stages ui/ beside the binary. + * pnpm app --plugin Build the Debug VST3 instead of the app and + * install it into ~/.vst3 for this user's DAWs. + * Starts Vite like the default loop; launch the + * DAW with PLECTRIFY_DEV_URL set for live HMR. + * + * Needs the system libraries JUCE builds against on Linux; configure() names + * the exact `apt install` line when any are missing. + */ +import { existsSync, mkdirSync, rmSync, cpSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { parseArgs } from 'node:util'; +import { + ROOT, + TEST_TARGETS, + bundledPluginsMissingFor, + capture, + devServerStatus, + fail, + fetchBundledPluginArchives, + pnpm, + run, +} from './shared.ts'; +import { DEV_SERVER_PORT, DEV_SERVER_URL } from './devServer.ts'; +import { + IN_TREE_PLUGINS, + appBinary, + assertLinux, + buildDir, + buildTarget, + checkContentRoot, + configure, + inTreePluginBundle, + pluginBundle, + stageUiBesideBinary, + startApp, + stopApp, +} from './linux.ts'; + +assertLinux('run.linux.ts'); + +const PLATFORM = 'linux-x64'; + +const { values } = parseArgs({ + options: { + dist: { type: 'boolean', default: false }, + 'ui-only': { type: 'boolean', default: false }, + 'no-ui': { type: 'boolean', default: false }, + 'no-run': { type: 'boolean', default: false }, + clean: { type: 'boolean', default: false }, + plugin: { type: 'boolean', default: false }, + config: { type: 'string', default: 'Debug' }, + }, +}); + +const config = values.config!; +const uiDir = join(ROOT, 'ui'); + +if (values.plugin && (values.dist || values['ui-only'])) { + fail('--plugin is the Debug dev loop only; a Release .vst3 is staged by the release pipeline (pnpm release).'); +} + +/** Copies the built bundle into ~/.vst3, beside the catalogue installer's + plugins, where every Linux VST3 host looks. Replaced whole, never merged. */ +function installPluginForHosts(): void { + const built = pluginBundle(config); + if (!existsSync(join(built, 'Contents'))) fail(`plugin bundle not found: ${built}`); + + const destination = join(homedir(), '.vst3', 'Plectrify.vst3'); + console.log(`==> Installing ${destination}`); + mkdirSync(join(destination, '..'), { recursive: true }); + rmSync(destination, { recursive: true, force: true }); + cpSync(built, destination, { recursive: true, verbatimSymlinks: true }); +} + +function installUiDependencies(): void { + pnpm('Installing UI dependencies', ['install', '--frozen-lockfile'], { cwd: uiDir }); +} + +/** Put the plugins Plectrify ships where a Debug build looks for them + (third_party/plugins). The ones Linux builds in-tree are built and copied + (always, so the dev build never runs a stale one); the rest come from their + pinned archives. A shipped plugin with neither is reported and skipped: the + dev loop still runs, it just lacks what that plugin enables. Needs the tree + configured. */ +function stageBundledPlugins(): void { + const destination = join(ROOT, 'third_party', 'plugins'); + + for (const { target, bundle } of Object.values(IN_TREE_PLUGINS)) { + buildTarget(config, target); + const staged = join(destination, bundle); + mkdirSync(destination, { recursive: true }); + rmSync(staged, { recursive: true, force: true }); + cpSync(inTreePluginBundle(config, bundle), staged, { recursive: true, verbatimSymlinks: true }); + } + + const missing = bundledPluginsMissingFor(PLATFORM).filter((p) => !(p.id in IN_TREE_PLUGINS)); + for (const plugin of missing) { + console.warn( + `warning: ${plugin.name} has no ${PLATFORM} build in packaging/bundled-plugins.json; ` + + 'the dev build runs without it.', + ); + } + const skip = [...Object.keys(IN_TREE_PLUGINS), ...missing.map((p) => p.id)]; + for (const { plugin, archive } of fetchBundledPluginArchives(PLATFORM, skip)) { + if (existsSync(join(destination, plugin.bundleName))) continue; + + mkdirSync(destination, { recursive: true }); + console.log(`==> Staging ${plugin.name} ${plugin.version} for the dev build`); + // unzip keeps the mode bits the reproducible archiver records, so the + // shared object stays loadable. + run('stage plugin', 'unzip', ['-q', '-o', archive, '-d', destination]); + + if (!existsSync(join(destination, plugin.bundleName))) + fail(`${plugin.name}'s archive did not contain ${plugin.bundleName}.`); + } +} + +function devPortOwner(): string { + const owner = capture('ss', ['-ltnp', `sport = :${DEV_SERVER_PORT}`]).trim(); + return owner === '' ? 'unknown process' : owner; +} + +// --------------------------------------------------------------------------- +// --ui-only: rebuild the static UI and relaunch, skipping cmake entirely. +// --------------------------------------------------------------------------- +if (values['ui-only']) { + installUiDependencies(); + pnpm('Building UI (pnpm build)', ['build'], { cwd: uiDir }); + + if (!existsSync(appBinary(config))) { + fail(`app not found: ${appBinary(config)}. Run 'pnpm app --dist' once for a full build.`); + } + await stopApp(); + stageUiBesideBinary(config); + if (!values['no-run']) await startApp(config); + process.exit(0); +} + +// --------------------------------------------------------------------------- +// --dist: full build serving the baked ui/dist (UI -> cmake -> tests -> run). +// --------------------------------------------------------------------------- +if (values.dist) { + if (!values['no-ui']) { + installUiDependencies(); + pnpm('UI formatting check', ['format:check'], { cwd: uiDir }); + pnpm('svelte-check', ['check'], { cwd: uiDir }); + pnpm('UI tests', ['test'], { cwd: uiDir }); + pnpm('Building UI', ['build'], { cwd: uiDir }); + } + + configure(config); + stageBundledPlugins(); + checkContentRoot(); + await stopApp(); + buildTarget(config, 'Plectrify', values.clean); + + // Every test target, or ctest re-runs whatever stale binaries exist. + for (const target of TEST_TARGETS) { + buildTarget(config, target); + } + run('Native tests', 'ctest', ['--test-dir', buildDir(config), '--output-on-failure']); + + stageUiBesideBinary(config); + if (!values['no-run']) { + await startApp(config); + } else { + console.log(`Built: ${appBinary(config)}`); + } + process.exit(0); +} + +// --------------------------------------------------------------------------- +// Default: hot-reload loop. Vite serves the UI with HMR; the Debug app loads +// it over http with the native bridge still injected. +// --------------------------------------------------------------------------- +if (config !== 'Debug') { + fail(`the HMR dev loop needs a Debug build. Use 'pnpm app --dist --config ${config}' instead.`); +} + +if (!existsSync(join(uiDir, 'node_modules'))) { + installUiDependencies(); +} + +let status = await devServerStatus(); +if (status === 'foreign') { + fail( + `port ${DEV_SERVER_PORT} is held by something that is not Plectrify's Vite dev server, so ` + + `the app would load the wrong UI. Stop it and retry.\n${devPortOwner()}`, + ); +} + +if (status === 'free') { + console.log('==> Starting Vite dev server (pnpm dev)'); + const { spawn } = await import('node:child_process'); + const vite = spawn('pnpm', ['dev'], { cwd: uiDir, detached: true, stdio: 'ignore' }); + vite.unref(); + + for (let i = 0; i < 30 && status !== 'vite'; i++) { + await new Promise((resolve) => setTimeout(resolve, 500)); + status = await devServerStatus(); + } + if (status !== 'vite') fail(`Plectrify Vite dev server did not come up on ${DEV_SERVER_URL}`); +} else { + console.log(`==> Plectrify Vite dev server already running on :${DEV_SERVER_PORT}`); +} + +configure(config); +stageBundledPlugins(); +checkContentRoot(); + +if (values.plugin) { + buildTarget(config, 'PlectrifyPlugin_VST3', values.clean); + installPluginForHosts(); + console.log(` +==> Debug Plectrify.vst3 installed into ~/.vst3 for this user's DAWs. + The Vite dev server is running on ${DEV_SERVER_URL}. For live HMR the + DAW must inherit PLECTRIFY_DEV_URL — e.g.: + + PLECTRIFY_DEV_URL=${DEV_SERVER_URL} reaper + + Launched plainly, the plugin serves the last-built ui/dist instead.`); + process.exit(0); +} + +await stopApp(); +buildTarget(config, 'Plectrify', values.clean); + +if (!values['no-run']) { + await startApp(config, { PLECTRIFY_DEV_URL: DEV_SERVER_URL }, ` (UI from ${DEV_SERVER_URL}, HMR live)`); +} else { + console.log(`Built: ${appBinary(config)} (dev-server mode)`); +} diff --git a/scripts/run.ts b/scripts/run.ts index ba5af14..6990523 100644 --- a/scripts/run.ts +++ b/scripts/run.ts @@ -1,6 +1,6 @@ /** * `pnpm app` — one dev-loop command on every OS. Dispatches to the platform - * implementation (run.windows.ts / run.macos.ts), which share their modes: + * implementation (run.windows.ts / run.macos.ts / run.linux.ts), which share their modes: * no flag for the HMR loop, --ui-only, --dist, --no-ui, --no-run, --clean, * --plugin (build + install the Debug VST3 for a DAW to host instead of * launching the app), --config . The release pipelines dispatch @@ -12,7 +12,9 @@ if (process.platform === 'win32') { await import('./run.windows.ts'); } else if (process.platform === 'darwin') { await import('./run.macos.ts'); +} else if (process.platform === 'linux') { + await import('./run.linux.ts'); } else { - console.error('error: Plectrify builds on Windows and macOS only.'); + console.error('error: Plectrify builds on Windows, macOS and Linux only.'); process.exit(1); } diff --git a/scripts/shared.ts b/scripts/shared.ts index 48c21ef..841981a 100644 --- a/scripts/shared.ts +++ b/scripts/shared.ts @@ -205,19 +205,35 @@ export interface BundledPlugin { assets: Record; } +function readBundledPlugins(): BundledPlugin[] { + const path = join(ROOT, 'packaging', 'bundled-plugins.json'); + return (JSON.parse(readFileSync(path, 'utf8')) as { plugins?: BundledPlugin[] }).plugins ?? []; +} + +/** The shipped plugins that have no build for a platform yet. The dev loop asks + this so it can say what is missing and carry on, where a release must refuse + (see bundledPluginsFor). Linux is the case it exists for: Neural Amp + Modeler's iPlug2 source has no Linux VST3 target. */ +export function bundledPluginsMissingFor(platformSlug: string): BundledPlugin[] { + return readBundledPlugins().filter((plugin) => !plugin.assets?.[platformSlug]?.url); +} + /** The shipped-plugin manifest, for the platform a release is being built on. Both release scripts read this rather than the catalogue: these are not packages any more (nothing offers, installs or uninstalls them) — they are part of the application, and the only thing either script needs is a pinned URL and hash to stage from. */ -export function bundledPluginsFor(platformSlug: string): (BundledPlugin & { +export function bundledPluginsFor( + platformSlug: string, + /** Ids the caller stages some other way (Linux builds Neural Amp Modeler + in-tree) or has already decided to do without. */ + exclude: readonly string[] = [], +): (BundledPlugin & { asset: { url: string; sha256: string; downloadBytes?: number }; })[] { - const path = join(ROOT, 'packaging', 'bundled-plugins.json'); - const manifest = JSON.parse(readFileSync(path, 'utf8')) as { plugins?: BundledPlugin[] }; - - return (manifest.plugins ?? []).map((plugin) => { + const wanted = readBundledPlugins().filter((plugin) => !exclude.includes(plugin.id)); + return wanted.map((plugin) => { const asset = plugin.assets?.[platformSlug]; // A shipped plugin with no build for this platform is not a smaller // release, it is a broken one: the app assumes it is there. Refuse rather @@ -240,11 +256,14 @@ export function bundledPluginsFor(platformSlug: string): (BundledPlugin & { Extraction is left to the caller: the two platforms need different tools (tar.exe on Windows; ditto on macOS, which preserves the permission bits and symlinks a signed bundle cannot survive without). */ -export function fetchBundledPluginArchives(platformSlug: string): { +export function fetchBundledPluginArchives( + platformSlug: string, + exclude: readonly string[] = [], +): { plugin: ReturnType[number]; archive: string; }[] { - const plugins = bundledPluginsFor(platformSlug); + const plugins = bundledPluginsFor(platformSlug, exclude); if (plugins.length === 0) return []; const cacheRoot = join(ROOT, '.release-cache'); diff --git a/scripts/uninstall.ts b/scripts/uninstall.ts index 09d3418..48367c4 100644 --- a/scripts/uninstall.ts +++ b/scripts/uninstall.ts @@ -1,11 +1,11 @@ /** * `pnpm purge` — remove every Plectrify installation and every trace it left - * behind, on Windows and macOS from one script. + * behind, on Windows, macOS and Linux from one script. * - * One file rather than a windows/macos pair, unlike run.ts and release.ts: + * One file rather than a per-OS set, unlike run.ts and release.ts: * those dispatch because building is genuinely two different jobs, while this - * is a single list of paths that happens to read differently per OS. The two - * halves share the whole of their structure — enumerate, report, delete, + * is a single list of paths that happens to read differently per OS. The + * branches share the whole of their structure — enumerate, report, delete, * report what could not be deleted — and differ in nothing but the paths. * * Nothing is deleted without --yes. The default run prints exactly what it @@ -23,10 +23,13 @@ * * Elevation. The machine-wide targets — Program Files, the shared VST3 folder * and ProgramData on Windows; /Applications and /Library/Audio/Plug-Ins on - * macOS — were written by an installer that asked for admin rights, so removing - * them needs the same. Windows refuses up front and asks to be re-run from an - * elevated prompt (a running console process cannot elevate itself). macOS - * shells the individual system removals out to sudo, which prompts. + * macOS; the .deb's files and /var/lib/Plectrify on Linux — were written by an + * installer that asked for admin rights, so removing them needs the same. + * Windows refuses up front and asks to be re-run from an elevated prompt (a + * running console process cannot elevate itself). macOS and Linux shell the + * individual system removals out to sudo, which prompts; on Linux the package + * itself is removed through dpkg, so its database does not go on listing files + * this script deleted. * * Deliberately left alone: the WebView2 Runtime and the Visual C++ * redistributable on Windows — shared with every other application, and the @@ -51,14 +54,15 @@ const { values } = parseArgs({ const windows = process.platform === 'win32'; const macos = process.platform === 'darwin'; -if (!windows && !macos) fail('Plectrify runs on Windows and macOS only.'); +const linux = process.platform === 'linux'; +if (!windows && !macos && !linux) fail('Plectrify runs on Windows, macOS and Linux only.'); const dryRun = !values.yes; const home = homedir(); /** One thing to remove. `elevated` means the path belongs to an installation written with admin rights, which is what decides whether this process may - touch it at all (Windows) or has to ask sudo to (macOS). */ + touch it at all (Windows) or has to ask sudo to (macOS, Linux). */ interface Target { path: string; what: string; @@ -166,6 +170,26 @@ function presentReceipts(): string[] { return macos ? PKG_RECEIPTS.filter((id) => probe('pkgutil', ['--pkg-info', id]) === 0) : []; } +// --- Linux: the .deb ------------------------------------------------------------------------ +// Removed through dpkg rather than by path, for the same reason the receipts +// are forgotten on macOS: dpkg's database otherwise keeps a package installed +// whose files are gone. A purge (not a remove) also drops its conffiles record. +// /var/lib/Plectrify survives it — postinst creates that folder but the package +// does not own it — and is swept with the packages below. +const DEB_PACKAGE = 'plectrify'; + +function debInstalled(): boolean { + return linux && capture('dpkg-query', ['-W', '-f=${Status}', DEB_PACKAGE]).includes('install ok installed'); +} + +function purgeDeb(): void { + if (!debInstalled()) return; + console.log(`==> Removing the ${DEB_PACKAGE} package`); + if (runInteractive('sudo', ['dpkg', '--purge', DEB_PACKAGE]) !== 0) { + console.warn(`warning: dpkg could not remove ${DEB_PACKAGE}; its files are swept below instead.`); + } +} + function forgetReceipts(): void { for (const receipt of presentReceipts()) { console.log(`==> Forgetting receipt ${receipt}`); @@ -177,13 +201,13 @@ function forgetReceipts(): void { // --- Packages the user installed from inside the app --------------------------------------- // On Windows all of these live under %PROGRAMDATA%\Plectrify, a folder of ours, -// so the whole tree goes with the rest of the path sweep. macOS is the awkward -// one: its managed plugin directory is ~/Library/Audio/Plug-Ins/VST3, shared -// with every other VST3 installer on the machine, so the only defensible -// removal is the one the app itself performs — delete exactly the files the -// install markers say Plectrify wrote, and nothing else in that folder. -function macManagedPluginTargets(): Target[] { - const markerDir = join(home, 'Library', 'Audio', 'Plug-Ins', 'VST3', '.plectrify-installed'); +// so the whole tree goes with the rest of the path sweep. macOS and Linux are +// the awkward ones: their managed plugin directory (~/Library/Audio/Plug-Ins/VST3, +// ~/.vst3) is shared with every other VST3 installer on the machine, so the +// only defensible removal is the one the app itself performs — delete exactly +// the files the install markers say Plectrify wrote, and nothing else there. +function managedPluginTargets(pluginDir: string): Target[] { + const markerDir = join(pluginDir, '.plectrify-installed'); if (!existsSync(markerDir)) return []; const found: Target[] = []; @@ -245,6 +269,31 @@ if (windows) { if (!values['keep-packages']) { add(programData && join(programData, 'Plectrify'), 'installed packages and content', true); } +} else if (linux) { + // What the .deb installed (release.linux.ts). dpkg takes these first; the + // sweep catches an interrupted install or a hand-copied tree. + add('/opt/plectrify', 'application', true); + add('/usr/bin/plectrify', 'application link', true); + add('/usr/share/applications/plectrify.desktop', 'desktop entry', true); + add('/usr/share/icons/hicolor/1024x1024/apps/plectrify.png', 'icon', true); + add('/usr/lib/vst3/Plectrify.vst3', 'VST3 plug-in', true); + + // The dev loop's `--plugin` install. + add(join(home, '.vst3', 'Plectrify.vst3'), 'VST3 plug-in (dev build)'); + + if (!values['keep-user-data']) { + const config = process.env['XDG_CONFIG_HOME'] || join(home, '.config'); + const data = process.env['XDG_DATA_HOME'] || join(home, '.local', 'share'); + const cache = process.env['XDG_CACHE_HOME'] || join(home, '.cache'); + add(join(config, 'Plectrify'), 'user data'); + // WebKitGTK's own storage, kept against the program name. + add(join(data, 'Plectrify'), 'web view data'); + add(join(cache, 'Plectrify'), 'web view cache'); + } + if (!values['keep-packages']) { + targets.push(...managedPluginTargets(join(home, '.vst3'))); + add('/var/lib/Plectrify', 'installed content', true); + } } else { add('/Applications/Plectrify.app', 'application', true); add('/Library/Audio/Plug-Ins/VST3/Plectrify.vst3', 'VST3 plug-in', true); @@ -270,7 +319,7 @@ if (windows) { } } if (!values['keep-packages']) { - targets.push(...macManagedPluginTargets()); + targets.push(...managedPluginTargets(join(home, 'Library', 'Audio', 'Plug-Ins', 'VST3'))); add('/Users/Shared/Plectrify', 'installed content', true); } } @@ -288,7 +337,9 @@ function elevatedOnWindows(): boolean { const receipts = presentReceipts(); const hives = installedHives(); -if (targets.length === 0 && receipts.length === 0 && hives.length === 0) { +const deb = debInstalled(); + +if (targets.length === 0 && receipts.length === 0 && hives.length === 0 && !deb) { console.log('No Plectrify installation or leftover data found.'); process.exit(0); } @@ -300,6 +351,7 @@ for (const { path, what, elevated } of targets) { } if (hives.length > 0) console.log(' the Add/Remove Programs entry'); if (receipts.length > 0) console.log(` the installer receipts (${receipts.join(', ')})`); +if (deb) console.log(` the ${DEB_PACKAGE} package (dpkg --purge)`); if (values['keep-user-data']) console.log('\nKeeping rigs, patches and settings (--keep-user-data).'); if (values['keep-packages']) console.log('Keeping plugins and content installed from the Packages panel (--keep-packages).'); @@ -317,6 +369,7 @@ if (windows && targets.some(({ elevated }) => elevated) && !elevatedOnWindows()) stopPlectrify(); if (windows) await runInnoUninstaller(); +if (linux) purgeDeb(); const failures: string[] = []; let denied = false; @@ -324,8 +377,8 @@ for (const { path, what, elevated } of targets) { if (!existsSync(path)) continue; // the uninstaller may already have taken it console.log(`==> Removing ${path} (${what})`); try { - if (macos && elevated && process.getuid?.() !== 0) { - // Written as root by the pkg; sudo prompts on the inherited terminal. + if ((macos || linux) && elevated && process.getuid?.() !== 0) { + // Written as root by the installer; sudo prompts on the inherited terminal. if (runInteractive('sudo', ['rm', '-rf', path]) !== 0) throw new Error('sudo rm failed'); } else { rmSync(path, { recursive: true, force: true }); diff --git a/site/src/app.html b/site/src/app.html index d279991..122e51f 100644 --- a/site/src/app.html +++ b/site/src/app.html @@ -23,7 +23,7 @@ buttons a beat after the page appears. Only a confident desktop answer is stamped. A phone, a tablet or a - Linux box leaves the attribute unset, and the buttons stay evenly + non-x86 Linux box (an Android phone says "Linux" too) leaves it unset, and the buttons stay evenly weighted, the honest state when we cannot tell, since a reader on a phone is choosing for a machine that is somewhere else. -->