diff --git a/.claude/skills/build-cmake/SKILL.md b/.claude/skills/build-cmake/SKILL.md index 1ba9bb53..9de64945 100644 --- a/.claude/skills/build-cmake/SKILL.md +++ b/.claude/skills/build-cmake/SKILL.md @@ -18,6 +18,17 @@ Use this skill when the user wants to build their plugin project using CMake. The project uses CMake presets for standardized builds. Prefer `--preset` instead of manual `-G` flags. +`CMakePresets.json` lives at the repo root, so presets are only visible when +configuring the root — `--preset` and `-S [ProjectName]` cannot be combined. The +root `CMakeLists.txt` decides which project is built (`add_subdirectory`). To +build a project it doesn't reference, configure that directory directly with an +explicit generator, e.g.: + +```bash +cmake -S TemplateProjectWebView -B build/webview -G "Visual Studio 17 2022" -A x64 +cmake --build build/webview --config Release +``` + ### List Available Presets ```bash @@ -28,17 +39,15 @@ cmake --list-presets ```bash # Configure with Ninja (fast builds) -cmake --preset macos-ninja -S [ProjectName] - -# Build +cmake --preset macos-ninja cmake --build build/macos-ninja --target [ProjectName]-app # Or with Xcode (for debugging) -cmake --preset macos-xcode -S [ProjectName] +cmake --preset macos-xcode cmake --build build/macos-xcode --config Release --target [ProjectName]-app # Universal binary (arm64 + x86_64) -cmake --preset macos-xcode-universal -S [ProjectName] +cmake --preset macos-xcode-universal cmake --build build/macos-xcode-universal --config Release ``` @@ -46,11 +55,11 @@ cmake --build build/macos-xcode-universal --config Release ```bash # iOS Device -cmake --preset ios-xcode -S [ProjectName] +cmake --preset ios-xcode cmake --build build/ios-xcode --config Release # iOS Simulator -cmake --preset ios-sim-xcode -S [ProjectName] +cmake --preset ios-sim-xcode cmake --build build/ios-sim-xcode --config Release ``` @@ -60,40 +69,44 @@ For running in Simulator, use the `run-ios-simulator` skill. ```bash # visionOS Device -cmake --preset visionos-xcode -S [ProjectName] +cmake --preset visionos-xcode cmake --build build/visionos-xcode --config Release # visionOS Simulator -cmake --preset visionos-sim-xcode -S [ProjectName] +cmake --preset visionos-sim-xcode cmake --build build/visionos-sim-xcode --config Release ``` ### Windows Builds ```bash -# Visual Studio 2022 (x64) -cmake --preset windows-vs2022 -S [ProjectName] -cmake --build build/windows-vs2022 --config Release +# Newest installed Visual Studio (x64) +cmake --preset windows +cmake --build build/windows --config Release # ARM64EC -cmake --preset windows-vs2022-arm64ec -S [ProjectName] -cmake --build build/windows-vs2022-arm64ec --config Release +cmake --preset windows-arm64ec +cmake --build build/windows-arm64ec --config Release ``` -### Web/WAM Build (Emscripten) +`windows` / `windows-arm64ec` do not pin a generator, so they pick up whichever +Visual Studio is installed (2022, 2026, …). Use `windows-vs2022` to pin VS2022 +explicitly. + +### Web Builds (Emscripten) ```bash # Configure (requires EMSDK environment) -cmake --preset wam -S [ProjectName] +cmake --preset web -# Build -cmake --build build/wam --target [ProjectName]-wam +# WAM (AudioWorklet) distribution +cmake --build build/web --target [ProjectName]-wam-dist -# Create distribution -cmake --build build/wam --target [ProjectName]-wam-dist +# WASM (ScriptProcessor) distribution +cmake --build build/web --target [ProjectName]-wasm-dist ``` -Output in `build/wam/out/[ProjectName]-wam/` +Output in `build/web/out/[ProjectName]/` ## Available Configure Presets @@ -107,10 +120,12 @@ Output in `build/wam/out/[ProjectName]-wam/` | `ios-sim-xcode` | iOS | Xcode | iOS Simulator builds | | `visionos-xcode` | visionOS | Xcode | visionOS device builds | | `visionos-sim-xcode` | visionOS | Xcode | visionOS Simulator builds | +| `windows` | Windows | newest VS | Windows x64 builds (default) | +| `windows-arm64ec` | Windows | newest VS | Windows ARM64EC builds | | `windows-ninja` | Windows | Ninja | Fast Windows command-line builds | -| `windows-vs2022` | Windows | VS2022 | Windows x64 builds | -| `windows-vs2022-arm64ec` | Windows | VS2022 | Windows ARM64EC builds | -| `wam` | Web | Ninja | Emscripten/WebAssembly builds | +| `windows-vs2022` | Windows | VS2022 | Windows x64 builds, VS2022 pinned | +| `windows-vs2022-arm64ec` | Windows | VS2022 | Windows ARM64EC builds, VS2022 pinned | +| `web` | Web | Ninja | Emscripten/WebAssembly builds | ## Available Targets @@ -130,7 +145,7 @@ Default configuration uses NanoVG with Metal (macOS/iOS) or GL2 (Windows). To use Skia backend, add cache variables: ```bash -cmake --preset macos-ninja -S [ProjectName] \ +cmake --preset macos-ninja \ -DIGRAPHICS_BACKEND=SKIA \ -DIGRAPHICS_RENDERER=METAL ``` @@ -156,20 +171,20 @@ Valid combinations: ```bash # Remove build directory and reconfigure rm -rf build/macos-ninja -cmake --preset macos-ninja -S [ProjectName] +cmake --preset macos-ninja ``` ## Example ```bash # Quick build of standalone app -cmake --preset macos-ninja -S MySynth +cmake --preset macos-ninja cmake --build build/macos-ninja --target MySynth-app # Open the built app open ~/Applications/MySynth.app # Build for iOS Simulator -cmake --preset ios-sim-xcode -S MySynth +cmake --preset ios-sim-xcode cmake --build build/ios-sim-xcode --config Release ``` diff --git a/.claude/skills/new-plugin/SKILL.md b/.claude/skills/new-plugin/SKILL.md index 5f5b36a7..0078000d 100644 --- a/.claude/skills/new-plugin/SKILL.md +++ b/.claude/skills/new-plugin/SKILL.md @@ -16,28 +16,40 @@ Use this skill when the user wants to create a new plugin project in this out-of 2. **Check for existing projects:** - If other project folders exist besides TemplateProject, ask if user wants to continue with existing work or create new -3. **Run duplicate script:** +3. **Choose a template and run the duplicate script:** + + | Template | Use for | + |---|---| + | `TemplateProject` | A plugin with an IGraphics (C++) UI | + | `TemplateProjectWebView` | A plugin with a WebView (HTML/CSS/JS) UI | + | `TemplateProjectReaperExtensionWebView` | A REAPER extension with a WebView UI | + ```bash - ./duplicate.py TemplateProject [PluginName] [ManufacturerName] + ./duplicate.py [Template] [PluginName] [ManufacturerName] ``` + This touches only the new project. Add `--rewrite-root` to also repoint the repo's + `bump_version.py`, `CMakeLists.txt`, `.vscode/` and `.github/` at it — appropriate when + this repo is becoming the plugin's own repo. Add a fourth positional argument to create + the project outside this repo. + 4. **Offer config.h customization:** - - `PLUG_UNIQUE_ID` - Verify the auto-generated 4-char ID + - `PLUG_UNIQUE_ID` - Verify the auto-generated 4-char ID (not present for a REAPER extension) - `PLUG_MFR_ID` - Set manufacturer's 4-char ID - Copyright, URLs - allow skipping for later 5. **Ask about committing:** ```bash git add [PluginName]/* - git add .vscode/* - git add .github/* - git add bump_version.py - git commit -m "created [PluginName] based on TemplateProject" + git commit -m "created [PluginName] based on [Template]" ``` + Also stage `.vscode/*`, `.github/*` and `bump_version.py` if `--rewrite-root` was used; + otherwise those files are unchanged. 6. **Next steps:** - - Open in Xcode: `open [PluginName]/[PluginName].xcworkspace` - - Build: Use the `build` skill from iPlug2 + - Open in Xcode: `open [PluginName]/[PluginName].xcworkspace` (IGraphics template only — + the CMake-only templates have no xcworkspace; configure with CMake instead) + - Build: Use the `build` skill, or `build-cmake` for the CMake-only templates - Setup SDKs: Use the `setup-deps` skill if needed ## Example @@ -46,4 +58,5 @@ Use this skill when the user wants to create a new plugin project in this out-of ./duplicate.py TemplateProject MySynth MyBrand ``` -Creates `MySynth/` with all project files configured and ready to build. +Creates `MySynth/` with all project files configured and ready to build. Run `./duplicate.py` +with no arguments to be prompted for the template, name and manufacturer instead. diff --git a/.github/workflows/build-cmake.yml b/.github/workflows/build-cmake.yml index d533318d..61c205d3 100644 --- a/.github/workflows/build-cmake.yml +++ b/.github/workflows/build-cmake.yml @@ -189,10 +189,10 @@ jobs: if-no-files-found: warn # ========================================================================== - # Windows - Visual Studio 2022 (Default) + # Windows - Visual Studio (Default) # ========================================================================== - windows-vs2022: - name: Windows (VS2022) + windows: + name: Windows (Visual Studio) needs: parse-commands if: needs.parse-commands.outputs.should_run == 'true' runs-on: windows-latest @@ -221,28 +221,85 @@ jobs: ./download-iplug-sdks.sh - name: CMake Configure - run: cmake --preset=windows-vs2022 + run: cmake --preset=windows - name: Build - run: cmake --build --preset=windows-vs2022 + run: cmake --build --preset=windows - name: Verify Bundles shell: pwsh run: | - if (!(Test-Path "build\windows-vs2022\out\${{env.PROJECT_NAME}}.exe")) { exit 1 } - if (!(Test-Path "build\windows-vs2022\out\${{env.PROJECT_NAME}}.vst3")) { exit 1 } - if (!(Test-Path "build\windows-vs2022\out\${{env.PROJECT_NAME}}.clap")) { exit 1 } + if (!(Test-Path "build\windows\out\${{env.PROJECT_NAME}}.exe")) { exit 1 } + if (!(Test-Path "build\windows\out\${{env.PROJECT_NAME}}.vst3")) { exit 1 } + if (!(Test-Path "build\windows\out\${{env.PROJECT_NAME}}.clap")) { exit 1 } echo "All bundles exist" - name: Upload Artifacts uses: actions/upload-artifact@v4 with: - name: windows-vs2022-artifacts - path: build/windows-vs2022/out/ + name: windows-artifacts + path: build/windows/out/ if-no-files-found: warn # ========================================================================== - # Windows - Visual Studio 2022 ARM64EC - Optional: /ci arm64ec + # Windows - the other templates + # + # The root CMakeLists.txt only references PROJECT_NAME, so these are + # configured directly. No --preset: CMakePresets.json lives at the repo root + # and presets are read from the source directory. Omitting -G picks the + # newest installed Visual Studio. + # ========================================================================== + windows-templates: + name: Windows (${{ matrix.template }}) + needs: parse-commands + if: needs.parse-commands.outputs.should_run == 'true' + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + include: + - template: TemplateProjectWebView + artifact: TemplateProjectWebView.exe + - template: TemplateProjectReaperExtensionWebView + artifact: reaper_TemplateProjectReaperExtensionWebView.dll + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ needs.parse-commands.outputs.pr_sha || github.sha }} + submodules: recursive + + - name: Cache IPlug SDKs + id: cache-sdks + uses: actions/cache@v4 + with: + path: | + iPlug2/Dependencies/IPlug/VST3_SDK + iPlug2/Dependencies/IPlug/CLAP_SDK + iPlug2/Dependencies/IPlug/CLAP_HELPERS + key: iplug-sdks-${{ hashFiles('iPlug2/Dependencies/IPlug/download-iplug-sdks.sh') }} + + - name: Get SDKs + if: steps.cache-sdks.outputs.cache-hit != 'true' + shell: bash + run: | + cd iPlug2/Dependencies/IPlug + ./download-iplug-sdks.sh + + - name: CMake Configure + run: cmake -S ${{ matrix.template }} -B build/${{ matrix.template }} -A x64 + + - name: Build + run: cmake --build build/${{ matrix.template }} --config Release + + - name: Verify Output + shell: pwsh + run: | + if (!(Test-Path "build\${{ matrix.template }}\out\${{ matrix.artifact }}")) { exit 1 } + echo "${{ matrix.artifact }} exists" + + # ========================================================================== + # Windows - Visual Studio ARM64EC - Optional: /ci arm64ec # ========================================================================== windows-arm64ec: name: Windows (ARM64EC) @@ -274,24 +331,24 @@ jobs: ./download-iplug-sdks.sh - name: CMake Configure - run: cmake --preset=windows-vs2022-arm64ec + run: cmake --preset=windows-arm64ec - name: Build - run: cmake --build --preset=windows-vs2022-arm64ec + run: cmake --build --preset=windows-arm64ec - name: Verify Bundles shell: pwsh run: | - if (!(Test-Path "build\windows-vs2022-arm64ec\out\${{env.PROJECT_NAME}}.exe")) { exit 1 } - if (!(Test-Path "build\windows-vs2022-arm64ec\out\${{env.PROJECT_NAME}}.vst3")) { exit 1 } - if (!(Test-Path "build\windows-vs2022-arm64ec\out\${{env.PROJECT_NAME}}.clap")) { exit 1 } + if (!(Test-Path "build\windows-arm64ec\out\${{env.PROJECT_NAME}}.exe")) { exit 1 } + if (!(Test-Path "build\windows-arm64ec\out\${{env.PROJECT_NAME}}.vst3")) { exit 1 } + if (!(Test-Path "build\windows-arm64ec\out\${{env.PROJECT_NAME}}.clap")) { exit 1 } echo "All bundles exist" - name: Upload Artifacts uses: actions/upload-artifact@v4 with: name: windows-arm64ec-artifacts - path: build/windows-vs2022-arm64ec/out/ + path: build/windows-arm64ec/out/ if-no-files-found: warn # ========================================================================== @@ -601,17 +658,17 @@ jobs: - name: CMake Configure run: | - cmake --preset=windows-vs2022 ` + cmake --preset=windows ` -DIGRAPHICS_BACKEND=${{ matrix.backend }} ` -DIGRAPHICS_RENDERER=${{ matrix.renderer }} - name: Build - run: cmake --build build/windows-vs2022 --config Release --target ${{env.PROJECT_NAME}}-app + run: cmake --build build/windows --config Release --target ${{env.PROJECT_NAME}}-app - name: Verify App shell: pwsh run: | - if (!(Test-Path "build\windows-vs2022\out\${{env.PROJECT_NAME}}.exe")) { exit 1 } + if (!(Test-Path "build\windows\out\${{env.PROJECT_NAME}}.exe")) { exit 1 } echo "${{ matrix.backend }}/${{ matrix.renderer }} build successful" # ========================================================================== diff --git a/.gitignore b/.gitignore index 6782f5ca..86bd63b6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ xcuserdata *.RPP-bak */build-*/ +# CMake build trees (CMakePresets.json puts them under build/) +/build/ *.ipch *.db *.suo diff --git a/CLAUDE.md b/CLAUDE.md index 20cff88b..32f39133 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,9 @@ This repo keeps plugin projects separate from the iPlug2 framework. Structure: - @iPlug2 - Framework as git submodule -- TemplateProject - Base template for new plugins +- TemplateProject - Base template for new plugins (IGraphics UI) +- TemplateProjectWebView - Plugin template with a WebView UI (HTML/CSS/JS), CMake only +- TemplateProjectReaperExtensionWebView - REAPER extension (not a plugin) with a WebView UI, CMake only, macOS/Windows - duplicate.py - Script to create new projects by duplicating a template ## Workflow @@ -16,6 +18,15 @@ To create a new plugin, duplicate @TemplateProject using the `new-plugin` skill ./duplicate.py TemplateProject [PluginName] [ManufacturerName] ``` +Substitute another template for `TemplateProject` to start from a WebView UI or a REAPER +extension. Run `./duplicate.py` with no arguments to be prompted instead. + +Pass a fourth argument to create the project outside this repo; the iPlug2 paths in its +`CMakeLists.txt` and xcconfigs are rewritten to suit. By default nothing outside the new +project is modified — pass `--rewrite-root` to also repoint this repo's `bump_version.py`, +`CMakeLists.txt`, `.vscode/` and `.github/` at it, which is what you want when the repo is +becoming your plugin's repo rather than gaining another project alongside the templates. + After duplication, optionally commit: ```bash git add [PluginName]/* .vscode/* .github/* bump_version.py diff --git a/CMakePresets.json b/CMakePresets.json index 0436b833..6a585c5f 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -136,6 +136,26 @@ "CMAKE_OSX_SYSROOT": "xrsimulator" } }, + { + "name": "windows", + "displayName": "Windows (Visual Studio)", + "description": "Windows x64 build with the newest installed Visual Studio (NanoVG/GL2)", + "inherits": "base-windows", + "architecture": { + "value": "x64", + "strategy": "set" + } + }, + { + "name": "windows-arm64ec", + "displayName": "Windows ARM64EC (Visual Studio)", + "description": "Windows ARM64EC build with the newest installed Visual Studio (NanoVG/GL2)", + "inherits": "base-windows", + "architecture": { + "value": "ARM64EC", + "strategy": "set" + } + }, { "name": "windows-ninja", "displayName": "Windows (Ninja)", @@ -232,6 +252,16 @@ "CODE_SIGNING_ALLOWED=NO" ] }, + { + "name": "windows", + "configurePreset": "windows", + "configuration": "Release" + }, + { + "name": "windows-arm64ec", + "configurePreset": "windows-arm64ec", + "configuration": "Release" + }, { "name": "windows-ninja", "configurePreset": "windows-ninja" diff --git a/TemplateProject/projects/TemplateProject-aax.vcxproj b/TemplateProject/projects/TemplateProject-aax.vcxproj index 709a384c..42ec5561 100644 --- a/TemplateProject/projects/TemplateProject-aax.vcxproj +++ b/TemplateProject/projects/TemplateProject-aax.vcxproj @@ -36,27 +36,27 @@ DynamicLibrary - v143 + $(DefaultPlatformToolset) DynamicLibrary - v143 + $(DefaultPlatformToolset) DynamicLibrary - v143 + $(DefaultPlatformToolset) DynamicLibrary - v143 + $(DefaultPlatformToolset) DynamicLibrary - v143 + $(DefaultPlatformToolset) DynamicLibrary - v143 + $(DefaultPlatformToolset) diff --git a/TemplateProject/projects/TemplateProject-app.vcxproj b/TemplateProject/projects/TemplateProject-app.vcxproj index b4ed2d47..c777894f 100644 --- a/TemplateProject/projects/TemplateProject-app.vcxproj +++ b/TemplateProject/projects/TemplateProject-app.vcxproj @@ -38,41 +38,41 @@ Application true MultiByte - v143 + $(DefaultPlatformToolset) Application true MultiByte - v143 + $(DefaultPlatformToolset) Application false true MultiByte - v143 + $(DefaultPlatformToolset) Application false true MultiByte - v143 + $(DefaultPlatformToolset) Application false true MultiByte - v143 + $(DefaultPlatformToolset) Application false true MultiByte - v143 + $(DefaultPlatformToolset) diff --git a/TemplateProject/projects/TemplateProject-clap.vcxproj b/TemplateProject/projects/TemplateProject-clap.vcxproj index 048321d7..e87650f1 100644 --- a/TemplateProject/projects/TemplateProject-clap.vcxproj +++ b/TemplateProject/projects/TemplateProject-clap.vcxproj @@ -38,41 +38,41 @@ DynamicLibrary true MultiByte - v143 + $(DefaultPlatformToolset) DynamicLibrary true MultiByte - v143 + $(DefaultPlatformToolset) DynamicLibrary false true MultiByte - v143 + $(DefaultPlatformToolset) DynamicLibrary false true MultiByte - v143 + $(DefaultPlatformToolset) DynamicLibrary false true MultiByte - v143 + $(DefaultPlatformToolset) DynamicLibrary false true MultiByte - v143 + $(DefaultPlatformToolset) diff --git a/TemplateProject/projects/TemplateProject-vst2.vcxproj b/TemplateProject/projects/TemplateProject-vst2.vcxproj index b195eeb8..956bc3d7 100644 --- a/TemplateProject/projects/TemplateProject-vst2.vcxproj +++ b/TemplateProject/projects/TemplateProject-vst2.vcxproj @@ -38,41 +38,41 @@ DynamicLibrary true MultiByte - v143 + $(DefaultPlatformToolset) DynamicLibrary true MultiByte - v143 + $(DefaultPlatformToolset) DynamicLibrary false true MultiByte - v143 + $(DefaultPlatformToolset) DynamicLibrary false true MultiByte - v143 + $(DefaultPlatformToolset) DynamicLibrary false true MultiByte - v143 + $(DefaultPlatformToolset) DynamicLibrary false true MultiByte - v143 + $(DefaultPlatformToolset) diff --git a/TemplateProject/projects/TemplateProject-vst3.vcxproj b/TemplateProject/projects/TemplateProject-vst3.vcxproj index d44c0716..00f5059a 100644 --- a/TemplateProject/projects/TemplateProject-vst3.vcxproj +++ b/TemplateProject/projects/TemplateProject-vst3.vcxproj @@ -38,41 +38,41 @@ DynamicLibrary true MultiByte - v143 + $(DefaultPlatformToolset) DynamicLibrary true MultiByte - v143 + $(DefaultPlatformToolset) DynamicLibrary false true MultiByte - v143 + $(DefaultPlatformToolset) DynamicLibrary false true MultiByte - v143 + $(DefaultPlatformToolset) DynamicLibrary false true MultiByte - v143 + $(DefaultPlatformToolset) DynamicLibrary false true MultiByte - v143 + $(DefaultPlatformToolset) diff --git a/TemplateProjectReaperExtensionWebView/.gitignore b/TemplateProjectReaperExtensionWebView/.gitignore new file mode 100644 index 00000000..1f95e9dc --- /dev/null +++ b/TemplateProjectReaperExtensionWebView/.gitignore @@ -0,0 +1,31 @@ +*.vs +*.exe +*.sdf +*.opensdf +*.zip +*.suo +*.ncb +*.vcproj.* +*.pkg +*.dmg +*.depend +*.layout +*.mode1v3 +*.db +*.LSOverride +*.xcuserdata +*.xcschememanagement.plist +build-* +ipch/* +gui/* + +Icon? +.DS_Stor* + +# The bundled .RPP has no media directory configured, so audio processed by the extension +# is written here, alongside REAPER's peak caches for it +*.wav +peaks/ + +# CMake build directory +build/ diff --git a/TemplateProjectReaperExtensionWebView/.vscode/c_cpp_properties.json b/TemplateProjectReaperExtensionWebView/.vscode/c_cpp_properties.json new file mode 100644 index 00000000..c2ac4c32 --- /dev/null +++ b/TemplateProjectReaperExtensionWebView/.vscode/c_cpp_properties.json @@ -0,0 +1,48 @@ +{ + "env": { + "commonIncludePaths": [ + "${workspaceFolder}/**", + "${workspaceFolder}/../iPlug2/WDL/**", + "${workspaceFolder}/../iPlug2/IPlug/**", + "${workspaceFolder}/../iPlug2/Dependencies/IPlug/REAPER_SDK/**" + ], + "commonDefs": [ + "REAPER_EXT_API", + "IPLUG_DSP=1", + "IPLUG_EDITOR=1", + "WEBVIEW_EDITOR_DELEGATE", + "NO_IGRAPHICS", + "NOMINMAX" + ] + }, + "configurations": [ + { + "name": "Mac", + "includePath": [ + "${commonIncludePaths}" + ], + "defines": [ + "${commonDefs}", + "OS_MAC", + "SWELL_PROVIDED_BY_APP" + ], + "macFrameworkPath": [ + "/System/Library/Frameworks", + "/Library/Frameworks" + ], + "cppStandard": "c++17" + }, + { + "name": "Win32", + "includePath": [ + "${commonIncludePaths}" + ], + "defines": [ + "${commonDefs}", + "OS_WIN" + ], + "cppStandard": "c++17" + } + ], + "version": 4 +} diff --git a/TemplateProjectReaperExtensionWebView/CLAUDE.md b/TemplateProjectReaperExtensionWebView/CLAUDE.md new file mode 100644 index 00000000..31049447 --- /dev/null +++ b/TemplateProjectReaperExtensionWebView/CLAUDE.md @@ -0,0 +1,191 @@ +# CLAUDE.md + +A **REAPER extension** with a **WebView UI**. This is not an audio plugin: it loads into REAPER +itself, calls the REAPER API, and puts up a dockable window whose UI is authored in HTML/CSS/JS +under `resources/web/`. + +> This file is duplicated by `../duplicate.py` along with the rest of the project, and the +> project-name string-rewrite pass runs over it — so `TemplateProjectReaperExtensionWebView.cpp` +> etc. resolve to the new project's filenames after duplication. + +## How this differs from a plugin project + +Coming from `../TemplateProject` or `../TemplateProjectWebView`, most of the plugin scaffolding is +simply absent, and it is worth knowing what is gone before looking for it: + +- **No `iplug::Plugin`.** The class derives from `ReaperExtBase`, via + `ReaperExt_include_in_plug_hdr.h` / `ReaperExt_include_in_plug_src.h`. +- **No `ProcessBlock`, no realtime thread.** Audio work here is offline: you pull samples out of + the project with an audio accessor and write a file. Nothing you write runs on the audio thread, + so the usual realtime-safety rules don't apply. +- **No parameters.** There is no host to expose them to, so there is no `EParams`, no + `GetParam()`, and no automation. `SPVFD`/`SPVFUI` and the rest of the parameter bridge exist in + `script.js` but go unused — state lives in plain C++ members and is pushed to the UI by hand + (see *Talking to the UI* below). +- **No plugin formats, no presets, no `PLUG_CHANNEL_IO`.** `config.h` is correspondingly tiny. +- **macOS and Windows only.** No iOS, no WASM/Emscripten, so there is no `IPLUG_EDITOR` / + `IPLUG_DSP` split to maintain — `ReaperExt.cmake` defines both to `1` and the whole extension is + a single binary. The `CMakeLists.txt` bails out early on iOS and Emscripten. +- **No `iplug_add_plugin()`.** The target is a plain `add_library(... MODULE ...)` configured by + `iplug_configure_reaperext()`, which names the binary `reaper_` (REAPER only loads + extensions with that prefix), restricts the exported symbols to `ReaperPluginEntry` and + `SWELL_dllMain`, and deploys to REAPER's `UserPlugins` directory. + +On macOS the extension links only SWELL's *module stub* (`swell-modstub.mm`), because REAPER +already provides SWELL. `ReaperExt.cmake` handles this; compiling the full SWELL sources into the +extension would leave every SWELL symbol undefined and the extension would silently fail to load. + +## Calling the REAPER API: IMPAPI + +REAPER's API is not linked — it is handed to the extension as function pointers at load time. Each +function you want must be imported by name in the constructor: + +```cpp +IMPAPI(CountTracks); +IMPAPI(InsertTrackAtIndex); +``` + +`IMPAPI` declares a global of the right signature (from `reaper_plugin_functions.h`) and binds it. +**A function you forget to `IMPAPI` compiles fine and is a null pointer at runtime**, so a crash on +first use of a new REAPER call is almost always a missing `IMPAPI` line. Add the import next to the +others at the top of the constructor. + +## Registering actions + +`RegisterAction(name, lambda, addToMenu, toggleStatePtr, contextMenuId, menuLabel)` puts a lambda in +REAPER's action list. The template registers four: + +- Two demo actions (a message box, and one that inserts a track). +- Show/Hide UI and Toggle dock, which pass `GetWindowTogglePtr()` / `GetDockTogglePtr()` as the + toggle-state pointer so REAPER draws a checkmark next to the menu item. +- `Process selected item`, registered into the `"Media item context"` menu rather than the + Extensions submenu. That menu is shared with every other installed extension, so its label names + the extension explicitly. + +## The UI + +`mEditorInitFunc` runs when the window opens, and it loads the UI differently per configuration +because a REAPER extension is a bare dylib/DLL with no bundle to carry resources: + +- **Debug** calls `LoadIndexHtml(__FILE__, "")`, which resolves `resources/web/index.html` in the + source tree, so edits to the HTML/JS show up on reopen. The bundle id is empty because + `ReaperExtBase` doesn't inherit `GetBundleID()` (that lives on `IPluginBase`) and this branch + never consults it. +- **Release** has neither a source tree nor a bundle, so it loads from REAPER's own resource + directory: `GetResourcePath()/UserPlugins/SHARED_RESOURCES_SUBPATH/index.html`. The + `WEB_RESOURCES_DIR` argument to `iplug_configure_reaperext()` in `CMakeLists.txt` is what puts + the files there at build time. The two must agree — `SHARED_RESOURCES_SUBPATH` in `config.h` + defaults to the project name, which is what CMake uses for the directory. + +`SetEnableDevTools(true)` is on in `_DEBUG`, so right-click → Inspect opens the Web Inspector. + +### Talking to the UI + +Because there are no parameters, the two directions are asymmetric: + +- **UI → C++**: JS calls `SAMFUI(msgTag, ctrlTag, data)` from `script.js`, which arrives at the + `SendArbitraryMsgFromUI(int msgTag, int ctrlTag, int dataSize, const void* pData)` override. This + is *not* the `bool OnMessage(...)` you override on `iplug::Plugin` — different name, no return + value. `EMsgTags` in the header must stay in sync with the `kMsgTag*` constants at the top of + `index.html`. The template smuggles the gain percentage through the `ctrlTag` int rather than + packing a data payload. +- **C++ → UI**: there is no delegate push for non-parameter state, so the template calls + `EvaluateJavaScript("OnTrackCount(3)")` and friends, and `index.html` defines matching global + functions (`OnTrackCount`, `OnSetGain`, `OnDockState`). + +The gain slider sends `kMsgTagSetGain` on every move, not just when *Process* is clicked. `mGain` +is read by the "Media item context" action and written to the project by `SaveProjectState()`, so +if it only updated on Process, right-clicking an item — or simply saving — would use a stale value +while the UI showed the new one. Any UI state C++ owns needs pushing at the point it changes. + +Note the direction split, which mirrors the knob in `../TemplateProjectWebView`: `OnGainInput()` +is the *user* moving the slider and calls `SAMFUI`; `OnSetGain()` is *C++* setting it and only +redraws. Routing the C++ path through `OnGainInput()` would echo the value straight back. + +`OnUIOpen()` is the hook for pushing current state into a freshly-loaded page. Call it for anything +the UI needs to render correctly on open — the window is destroyed and recreated when docked, so +this runs again after `ToggleDocking()`. + +`index.html` also defines empty `OnParamChange` / `OnControlChange` / `OnMessage` stubs. Don't +delete them: `OnWebContentLoaded` sends a parameter-list message regardless, and `script.js` +dispatches into them unconditionally. + +## Reacting to project changes + +Two hooks, and the template uses both deliberately: + +- `OnActionRun(commandId, flag)` — REAPER's `hookpostcommand`, fired after any main-section action. + This catches user edits (insert track, undo) with no polling lag. +- `OnIdle()` — a safety net for changes that no action caused, such as our own *Add Track* button + calling `InsertTrackAtIndex()` directly. + +`PushTrackCount(force)` only calls into the webview when the count actually changed, so the idle +tick is cheap. + +## Per-project state + +An extension has no plugin state chunk. To persist per-project data, REAPER offers `projectconfig`, +wired up here as three overrides: + +- `SaveProjectState(ctx)` — write lines into the `.RPP`. Prefix keys with the extension name so they + can't collide with another extension's. +- `LoadProjectStateLine(line)` — REAPER offers every unrecognised line to every extension; parse the + ones that are yours and **return `true` only for those**, `false` otherwise. +- `OnBeginLoadProjectState(isUndo)` — reset to defaults before the lines arrive, since a project + without your key must not inherit the previous project's value. + +This also gives you undo for free: REAPER replays project state on undo, so the same three hooks +restore the value. + +## Offline processing a media item + +`ProcessSelectedItem(gain)` is a worked example of the read-modify-write cycle: it reads the +selected item through a take audio accessor, applies gain, writes a new `.wav` and inserts it into +the project. Most of its length is the awkward parts, which are commented in place. The ones worth +knowing before writing your own: + +- Output goes to the project's media directory (`GetProjectPathEx`), not next to the source file, + which may be on read-only or shared storage. It falls back to the source directory for an unsaved + project. +- The filename is uniquified (`-001`, `-002`, …) rather than overwritten — the existing file is + usually already open in the project. +- **Section sources** (reversed, looped or trimmed takes) report an empty filename but wrap a real + source; resolve the name through `GetMediaSourceParent()`. Takes with no media file at all + (in-project MIDI) must be rejected. +- WDL's `WaveWriter` writes mono or stereo only, so the channel count is clamped for the writer, the + accessor and the interleaved buffer alike. Feeding it the source count produces a header that + disagrees with the data. +- The accessor leaves gaps untouched, so each block must be zeroed before `GetAudioAccessorSamples`. +- `WaveWriter` finalises the header in its destructor — scope it so the file is complete before + `InsertMedia()` reads it. +- `InsertMedia()` drops the file at the edit cursor, so move the cursor to the source item's + position and restore it afterwards. + +## Adding a new UI control — checklist + +1. **C++:** add a tag to `EMsgTags` and handle it in `SendArbitraryMsgFromUI`. `IMPAPI` any new + REAPER function you call. +2. **JS:** mirror the tag as a `const kMsgTag* = n` at the top of `index.html` and call + `SAMFUI(kMsgTagYours)` from the element. +3. **State back to the UI:** if C++ owns the value, add a `Push*()` that calls + `EvaluateJavaScript("OnYourThing(...)")`, define the matching global in `index.html`, and call it + from `OnUIOpen()` so the control is correct when the window (re)opens. + +The common mistake is step 3: the button works, but the UI shows stale state after a dock toggle, +project load or undo. + +## Files of interest + +- `TemplateProjectReaperExtensionWebView.cpp` / `.h` — the extension class: `IMPAPI` imports, action + registration, message tags, the project-state hooks, and the offline process. +- `resources/web/index.html` — the UI: markup, CSS, the `kMsgTag*` constants, and the global + functions C++ calls via `EvaluateJavaScript`. +- `resources/web/script.js` — the bridge function definitions. Framework glue; extend, don't rename. +- `config.h` — just a class name, window size and FPS. No formats, no channel I/O. +- `CMakeLists.txt` — `ReaperExt.cmake` + `WebView.cmake`, a `MODULE` library, and + `iplug_configure_reaperext()`. +- `resources/main.rc`, `resource.h`, `main.rc_mac_dlg` — the empty host dialog the webview is + parented into. Windows uses `main.rc`; macOS uses the SWELL-generated `main.rc_mac_dlg`. +- `TemplateProjectReaperExtensionWebView.RPP` — a scratch REAPER project for testing. It has no + media directory configured, so processed audio and REAPER's peak caches land in this directory; + both are gitignored. diff --git a/TemplateProjectReaperExtensionWebView/CMakeLists.txt b/TemplateProjectReaperExtensionWebView/CMakeLists.txt new file mode 100644 index 00000000..29d0ab96 --- /dev/null +++ b/TemplateProjectReaperExtensionWebView/CMakeLists.txt @@ -0,0 +1,62 @@ +cmake_minimum_required(VERSION 3.25) +project(TemplateProjectReaperExtensionWebView VERSION 1.0.0) + +# REAPER Extensions are macOS and Windows only (no iOS, no Emscripten) +if(IOS OR CMAKE_SYSTEM_NAME STREQUAL "Emscripten") + message(STATUS "TemplateProjectReaperExtensionWebView is not supported on ${CMAKE_SYSTEM_NAME}. Skipping.") + return() +endif() + +# The WebView editor delegate uses std::filesystem, which requires macOS 10.15+ +# (iPlug2.cmake otherwise defaults to 10.13). Set before including iPlug2.cmake. +if(APPLE AND NOT CMAKE_OSX_DEPLOYMENT_TARGET) + set(CMAKE_OSX_DEPLOYMENT_TARGET "10.15" CACHE STRING "Minimum macOS version") +endif() + +if(NOT DEFINED IPLUG2_DIR) + set(IPLUG2_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../iPlug2" CACHE PATH "iPlug2 root directory") +endif() + +# Include iPlug2 CMake configuration +include(${IPLUG2_DIR}/iPlug2.cmake) +find_package(iPlug2 REQUIRED) + +# REAPER extensions are not plugins, so they do not go through iplug_add_plugin() +include(${IPLUG2_CMAKE_DIR}/ReaperExt.cmake) + +# WebViewEditorDelegate instead of IGraphics +include(${IPLUG2_CMAKE_DIR}/WebView.cmake) + +# Standard plugin setup +set(PROJECT_DIR ${CMAKE_CURRENT_SOURCE_DIR}) +set(PLUG_RESOURCES_DIR ${PROJECT_DIR}/resources) + +set(SOURCE_FILES + TemplateProjectReaperExtensionWebView.cpp + TemplateProjectReaperExtensionWebView.h + resources/resource.h + resources/web/index.html + resources/web/script.js +) + +# Add Windows resources +if(WIN32) + list(APPEND SOURCE_FILES ${PLUG_RESOURCES_DIR}/main.rc) +endif() + +# Build as a shared library (MODULE for dynamic loading) +add_library(${PROJECT_NAME} MODULE ${SOURCE_FILES}) + +# Base configuration - use the WebView UI instead of IGraphics +iplug_add_target(${PROJECT_NAME} PUBLIC + INCLUDE ${PROJECT_DIR} ${PROJECT_DIR}/resources + LINK iPlug2::WebView +) + +# Names the binary reaper_, restricts exported symbols, and deploys to +# REAPER's UserPlugins directory when IPLUG_DEPLOY_PLUGINS is set. +# WEB_RESOURCES_DIR puts the UI in UserPlugins/${PROJECT_NAME}/, which is where +# release builds load index.html from - see the LoadFile call in the .cpp. +iplug_configure_reaperext(${PROJECT_NAME} ${PROJECT_NAME} + WEB_RESOURCES_DIR ${PLUG_RESOURCES_DIR}/web +) diff --git a/TemplateProjectReaperExtensionWebView/README.md b/TemplateProjectReaperExtensionWebView/README.md new file mode 100644 index 00000000..0b702aa5 --- /dev/null +++ b/TemplateProjectReaperExtensionWebView/README.md @@ -0,0 +1,17 @@ +# TemplateProjectReaperExtensionWebView readme + +A **REAPER extension** with a **WebView UI**, rather than an audio plugin. It loads into REAPER +itself, registers actions, and opens a dockable window whose UI is authored in HTML/CSS/JS under +`resources/web/` (entry point `index.html`). + +macOS and Windows only. Build with CMake: + +```bash +cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug +cmake --build build +``` + +`IPLUG_DEPLOY_PLUGINS` defaults to `ON`, so the build copies +`reaper_TemplateProjectReaperExtensionWebView.dylib`/`.dll` into REAPER's `UserPlugins` directory. +Restart REAPER and look under Extensions. Build `Debug` while developing: that is what enables +loading the web UI from the source tree and the devtools inspector. diff --git a/TemplateProjectReaperExtensionWebView/TemplateProjectReaperExtensionWebView.RPP b/TemplateProjectReaperExtensionWebView/TemplateProjectReaperExtensionWebView.RPP new file mode 100644 index 00000000..fe6533ec --- /dev/null +++ b/TemplateProjectReaperExtensionWebView/TemplateProjectReaperExtensionWebView.RPP @@ -0,0 +1,86 @@ + + + RENDER_FILE "" + RENDER_PATTERN "" + RENDER_FMT 0 2 0 + RENDER_1X 0 + RENDER_RANGE 1 0 0 18 1000 + RENDER_RESAMPLE 3 0 1 + RENDER_ADDTOPROJ 0 + RENDER_STEMS 0 + RENDER_DITHER 0 + TIMELOCKMODE 1 + TEMPOENVLOCKMODE 1 + ITEMMIX 0 + DEFPITCHMODE 589824 + TAKELANE 1 + SAMPLERATE 44100 0 0 + + LOCK 1 + + GLOBAL_AUTO -1 + TEMPO 120 4 4 + PLAYRATE 1 0 0.25 4 + SELECTION 0 0 + SELECTION2 0 0 + MASTERAUTOMODE 0 + MASTERTRACKHEIGHT 0 0 + MASTERPEAKCOL 16576 + MASTERMUTESOLO 0 + MASTERTRACKVIEW 0 0.6667 0.5 0.5 0 0 0 + MASTERHWOUT 0 0 1 0 0 0 0 -1 + MASTER_NCH 2 2 + MASTER_VOLUME 1 0 -1 -1 1 + MASTER_FX 1 + MASTER_SEL 0 + + + +> diff --git a/TemplateProjectReaperExtensionWebView/TemplateProjectReaperExtensionWebView.code-workspace b/TemplateProjectReaperExtensionWebView/TemplateProjectReaperExtensionWebView.code-workspace new file mode 100644 index 00000000..a7482c32 --- /dev/null +++ b/TemplateProjectReaperExtensionWebView/TemplateProjectReaperExtensionWebView.code-workspace @@ -0,0 +1,12 @@ +{ + "folders": [ + { + "path": "." + } + ], + "settings": { + "files.associations": { + "algorithm": "cpp" + } + } +} diff --git a/TemplateProjectReaperExtensionWebView/TemplateProjectReaperExtensionWebView.cpp b/TemplateProjectReaperExtensionWebView/TemplateProjectReaperExtensionWebView.cpp new file mode 100644 index 00000000..4fe46ede --- /dev/null +++ b/TemplateProjectReaperExtensionWebView/TemplateProjectReaperExtensionWebView.cpp @@ -0,0 +1,405 @@ +#include "TemplateProjectReaperExtensionWebView.h" +#include "ReaperExt_include_in_plug_src.h" + +#include "wavwrite.h" + +#include +#include +#include + +TemplateProjectReaperExtensionWebView::TemplateProjectReaperExtensionWebView(reaper_plugin_info_t* pRec) +: ReaperExtBase(pRec) +{ + //Use IMPAPI to register any Reaper APIs that you need to use + IMPAPI(GetNumTracks); + IMPAPI(CountTracks); + IMPAPI(InsertTrackAtIndex); + IMPAPI(ShowConsoleMsg); + IMPAPI(UpdateArrange); + + // APIs for offline processing of a selected media item + IMPAPI(CountSelectedMediaItems); + IMPAPI(GetSelectedMediaItem); + IMPAPI(GetActiveTake); + IMPAPI(GetMediaItemTake_Source); + IMPAPI(GetMediaSourceNumChannels); + IMPAPI(GetMediaSourceSampleRate); + IMPAPI(GetMediaSourceFileName); + IMPAPI(GetMediaSourceParent); + IMPAPI(CreateTakeAudioAccessor); + IMPAPI(GetAudioAccessorStartTime); + IMPAPI(GetAudioAccessorEndTime); + IMPAPI(GetAudioAccessorSamples); + IMPAPI(DestroyAudioAccessor); + IMPAPI(InsertMedia); + IMPAPI(GetMediaItemInfo_Value); + IMPAPI(GetCursorPosition); + IMPAPI(SetEditCurPos); + + // APIs for choosing where to write the processed file + IMPAPI(GetProjectPathEx); + IMPAPI(RecursiveCreateDirectory); + IMPAPI(file_exists); + +#ifdef _DEBUG + SetEnableDevTools(true); +#endif + + // The WebView loads its UI from resources/web/index.html. + mEditorInitFunc = [&]() { +#ifdef _DEBUG + // Debug: load straight from the source tree so edits to the HTML/JS hot-reload on reopen. + // NB. ReaperExtBase does not inherit GetBundleID() (that's on IPluginBase), and the empty + // bundle id is harmless here because this branch never consults it. + LoadIndexHtml(__FILE__, ""); +#else + // Release: there is no source tree and no bundle to fall back on. CMake deploys + // resources/web into REAPER's UserPlugins under SHARED_RESOURCES_SUBPATH + // (see WEB_RESOURCES_DIR in CMakeLists.txt); load index.html from there. + WDL_String indexPath; + indexPath.SetFormatted(2048, "%s/UserPlugins/%s/index.html", GetResourcePath(), SHARED_RESOURCES_SUBPATH); + LoadFile(indexPath.Get(), nullptr); +#endif + EnableScroll(false); + }; + + //Define some lambdas that can be called from either GUI widgets or in response to commands + auto action1 = [](){ + MessageBox(gParent, "Action 1!", "Reaper extension test", MB_OK); //gParent + }; + + auto action2 = [](){ + InsertTrackAtIndex(GetNumTracks(), false); + }; + + //Register an action. args: name, lambda, add to Extensions submenu, toggle ptr, context menu id, menu label + RegisterAction("TemplateProjectReaperExtensionWebView: Action 1 - MsgBox", action1, true, nullptr, nullptr, "Action 1 - MsgBox"); + RegisterAction("TemplateProjectReaperExtensionWebView: Action 2 - AddTrack", action2); + RegisterAction("TemplateProjectReaperExtensionWebView: Action 3 - Show/Hide UI", [&]() { ShowHideMainWindow(); }, true, GetWindowTogglePtr(), nullptr, "Show/Hide UI"); + RegisterAction("TemplateProjectReaperExtensionWebView: Toggle dock UI", [&]() { ToggleDocking(); }, true, GetDockTogglePtr(), nullptr, "Toggle dock UI"); + + // Also expose the offline process on the media item right-click menu. It uses the + // last gain set from the UI (defaults to 100%). Unlike our Extensions submenu, that menu is + // shared with every other extension, so the label names us explicitly. + RegisterAction("TemplateProjectReaperExtensionWebView: Process selected item", [&]() { ProcessSelectedItem(mGain); }, false, nullptr, "Media item context", + "Process selected item with " IPLUG_STRINGIFY(PLUG_CLASS_NAME)); +} + +void TemplateProjectReaperExtensionWebView::SendArbitraryMsgFromUI(int msgTag, int ctrlTag, int dataSize, const void* pData) +{ + switch (msgTag) + { + case kMsgTagAddTrack: + InsertTrackAtIndex(GetNumTracks(), false); + break; + case kMsgTagSetGain: + // ctrlTag carries the gain as a 0..100 percentage. Stored as the slider moves so + // the "Media item context" action and SaveProjectState() see the current value, + // not the one from the last Process click. + mGain = static_cast(ctrlTag) / 100.0; + break; + case kMsgTagProcessItem: + mGain = static_cast(ctrlTag) / 100.0; + ProcessSelectedItem(mGain); + break; + case kMsgTagToggleDock: + ToggleDocking(); // recreates the window; OnUIOpen() re-pushes state afterwards + break; + default: + break; + } +} + +namespace +{ + +#ifdef OS_WIN +constexpr char kPathSep = '\\'; +#else +constexpr char kPathSep = '/'; +#endif + +/** Splits a full path into its directory (no trailing separator) and the file's stem (the + * filename with any extension removed). Both separators are accepted when splitting, because + * REAPER hands back backslashes on Windows. The extension is stripped from the filename only, + * so a dot in a directory name doesn't truncate the path. */ +void SplitPath(const std::string& path, std::string& dirOut, std::string& stemOut) +{ + const auto sepPos = path.find_last_of("/\\"); + const std::string fileName = (sepPos == std::string::npos) ? path : path.substr(sepPos + 1); + dirOut = (sepPos == std::string::npos) ? std::string() : path.substr(0, sepPos); + + const auto dotPos = fileName.find_last_of('.'); + stemOut = (dotPos == std::string::npos) ? fileName : fileName.substr(0, dotPos); +} + +/** Finds the media file behind a take's source. Section sources - reversed, looped or trimmed + * takes - report an empty filename but wrap a real source, reachable via GetMediaSourceParent(). + * Returns "" for sources with no file at all, e.g. in-project MIDI. + * NB. the name comes from the parent file, while the audio accessor renders the section as the + * take actually sounds (reversed, trimmed). That is what we want. */ +std::string ResolveSourceFileName(PCM_source* pSrc) +{ + char buf[2048]; + + while (pSrc) + { + buf[0] = '\0'; + GetMediaSourceFileName(pSrc, buf, sizeof(buf)); + + if (buf[0] != '\0') + return std::string(buf); + + pSrc = GetMediaSourceParent(pSrc); + } + + return std::string(); +} + +/** The project's media/recording directory, which is where REAPER's own glue and apply-FX + * actions write. Comes back empty for an unsaved project, in which case we fall back to sitting + * next to the source file. */ +std::string ChooseOutputDir(const std::string& srcDir) +{ + char projPath[2048] = {}; + GetProjectPathEx(nullptr, projPath, sizeof(projPath)); + + return (projPath[0] != '\0') ? std::string(projPath) : srcDir; +} + +/** Builds a path that doesn't already exist: /-.wav, then -001, -002... + * Returns "" if every candidate is taken, so we never overwrite an existing file - it is + * usually already open in the project. */ +std::string MakeUniqueOutputPath(const std::string& dir, const std::string& stem, const std::string& extName) +{ + const std::string base = dir + kPathSep + stem + "-" + extName; + + std::string candidate = base + ".wav"; + + if (!file_exists(candidate.c_str())) + return candidate; + + for (int i = 1; i <= 999; i++) + { + char suffix[16]; + snprintf(suffix, sizeof(suffix), "-%03d", i); + candidate = base + suffix + ".wav"; + + if (!file_exists(candidate.c_str())) + return candidate; + } + + return std::string(); +} + +} // namespace + +void TemplateProjectReaperExtensionWebView::ProcessSelectedItem(double gain) +{ + if (CountSelectedMediaItems(nullptr) < 1) + { + ShowConsoleMsg("TemplateProjectReaperExtensionWebView: no media item selected\n"); + return; + } + + MediaItem* item = GetSelectedMediaItem(nullptr, 0); + MediaItem_Take* take = item ? GetActiveTake(item) : nullptr; + PCM_source* src = take ? GetMediaItemTake_Source(take) : nullptr; + + if (!src) + { + ShowConsoleMsg("TemplateProjectReaperExtensionWebView: selected item has no audio take\n"); + return; + } + + const int nch = GetMediaSourceNumChannels(src); + const int srate = GetMediaSourceSampleRate(src); + + if (nch < 1 || srate < 1) + { + ShowConsoleMsg("TemplateProjectReaperExtensionWebView: unsupported source (no PCM audio?)\n"); + return; + } + + // WaveWriter only writes mono or stereo, so clamp the whole pipeline rather than let the + // header disagree with the interleaved data we write + const int outChannels = std::min(nch, 2); + + if (nch > outChannels) + { + WDL_String msg; + msg.SetFormatted(256, "TemplateProjectReaperExtensionWebView: source has %i channels, writing the first %i\n", nch, outChannels); + ShowConsoleMsg(msg.Get()); + } + + const std::string srcFileName = ResolveSourceFileName(src); + + if (srcFileName.empty()) + { + ShowConsoleMsg("TemplateProjectReaperExtensionWebView: selected take has no media file (in-project MIDI?)\n"); + return; + } + + std::string srcDir, srcStem; + SplitPath(srcFileName, srcDir, srcStem); + + const std::string outDir = ChooseOutputDir(srcDir); + + if (outDir.empty()) + { + ShowConsoleMsg("TemplateProjectReaperExtensionWebView: could not determine an output directory (save the project first)\n"); + return; + } + + // The project's recording path may name a subfolder that doesn't exist yet + RecursiveCreateDirectory(outDir.c_str(), 0); + + const std::string outPath = MakeUniqueOutputPath(outDir, srcStem, IPLUG_STRINGIFY(PLUG_CLASS_NAME)); + + if (outPath.empty()) + { + ShowConsoleMsg("TemplateProjectReaperExtensionWebView: could not find a free output filename\n"); + return; + } + + AudioAccessor* acc = CreateTakeAudioAccessor(take); + if (!acc) + { + ShowConsoleMsg("TemplateProjectReaperExtensionWebView: failed to create audio accessor\n"); + return; + } + + const double startTime = GetAudioAccessorStartTime(acc); + const double endTime = GetAudioAccessorEndTime(acc); + const int totalFrames = static_cast((endTime - startTime) * srate + 0.5); + + bool ok = false; + + { + // WaveWriter finalises the file header in its destructor, so scope it so the file + // is complete before we InsertMedia() it below. + WaveWriter writer(outPath.c_str(), 24 /* 24-bit PCM */, outChannels, srate, 0 /* no append */); + + if (writer.Status()) + { + const int kBlockFrames = 4096; + std::vector buf(static_cast(kBlockFrames) * outChannels); + + int framesDone = 0; + while (framesDone < totalFrames) + { + const int n = std::min(kBlockFrames, totalFrames - framesDone); + const double t = startTime + static_cast(framesDone) / srate; + + // Zero first: the accessor leaves gaps (silence) untouched + std::fill(buf.begin(), buf.begin() + static_cast(n) * outChannels, 0.0); + GetAudioAccessorSamples(acc, srate, outChannels, t, n, buf.data()); + + for (int i = 0; i < n * outChannels; i++) + buf[i] *= gain; + + writer.WriteDoubles(buf.data(), n * outChannels); // interleaved + framesDone += n; + } + ok = true; + } + else + { + ShowConsoleMsg("TemplateProjectReaperExtensionWebView: could not open output file for writing\n"); + } + } + + DestroyAudioAccessor(acc); + + if (ok) + { + WDL_String msg; + msg.SetFormatted(2100, "TemplateProjectReaperExtensionWebView: wrote %s (gain %.0f%%), inserting...\n", outPath.c_str(), gain * 100.0); + ShowConsoleMsg(msg.Get()); + + // InsertMedia() drops the file at the edit cursor, so move the cursor to the source item's + // position first to line the two up in the arrangement, then put the cursor back where the + // user had it (InsertMedia leaves it at the end of what it inserted). + const double cursorPos = GetCursorPosition(); + SetEditCurPos(GetMediaItemInfo_Value(item, "D_POSITION"), false /* moveview */, false /* seekplay */); + + InsertMedia(outPath.c_str(), 1 /* add on a new track */); + + SetEditCurPos(cursorPos, false, false); + UpdateArrange(); + } +} + +void TemplateProjectReaperExtensionWebView::OnIdle() +{ + // Safety-net poll: catches track changes not triggered by an action (e.g. our own + // "Add Track" button, which calls the API directly). Action-driven changes are + // handled immediately in OnActionRun(). + PushTrackCount(false); +} + +void TemplateProjectReaperExtensionWebView::OnActionRun(int commandId, int flag) +{ + // A main-section action ran (insert/remove track, undo, etc.) - refresh right away + // instead of waiting for the next idle tick. + PushTrackCount(false); +} + +void TemplateProjectReaperExtensionWebView::OnUIOpen() +{ + ReaperExtBase::OnUIOpen(); // base sends current parameter values (none here) + + // Push current state to the freshly-loaded UI so the slider / count / dock button are in sync + PushGain(); + PushTrackCount(true); + PushDockState(); +} + +void TemplateProjectReaperExtensionWebView::PushTrackCount(bool force) +{ + int tracks = CountTracks(0); + + if (force || tracks != mPrevTrackCount) { + mPrevTrackCount = tracks; + WDL_String js; + js.SetFormatted(64, "OnTrackCount(%i)", tracks); + EvaluateJavaScript(js.Get()); + } +} + +void TemplateProjectReaperExtensionWebView::PushGain() +{ + WDL_String js; + js.SetFormatted(64, "OnSetGain(%i)", static_cast(mGain * 100.0 + 0.5)); + EvaluateJavaScript(js.Get()); +} + +void TemplateProjectReaperExtensionWebView::PushDockState() +{ + WDL_String js; + js.SetFormatted(64, "OnDockState(%i)", IsDocked() ? 1 : 0); + EvaluateJavaScript(js.Get()); +} + +void TemplateProjectReaperExtensionWebView::SaveProjectState(ProjectStateContext* ctx) +{ + // Persist the gain into the .RPP so it survives save/load and undo + ctx->AddLine("TEMPLATEPROJECTREAPEREXTENSIONWEBVIEW_GAIN %f", mGain); +} + +bool TemplateProjectReaperExtensionWebView::LoadProjectStateLine(const char* line) +{ + double g = 0.0; + if (sscanf(line, " TEMPLATEPROJECTREAPEREXTENSIONWEBVIEW_GAIN %lf", &g) == 1) { + mGain = g; + PushGain(); // reflect it in the UI if the window is open + return true; // this line belonged to us + } + return false; +} + +void TemplateProjectReaperExtensionWebView::OnBeginLoadProjectState(bool isUndo) +{ + // Reset to default; LoadProjectStateLine() restores the value if the project stored one + mGain = 1.0; +} diff --git a/TemplateProjectReaperExtensionWebView/TemplateProjectReaperExtensionWebView.h b/TemplateProjectReaperExtensionWebView/TemplateProjectReaperExtensionWebView.h new file mode 100644 index 00000000..1f47b512 --- /dev/null +++ b/TemplateProjectReaperExtensionWebView/TemplateProjectReaperExtensionWebView.h @@ -0,0 +1,47 @@ +#pragma once + +#include "ReaperExt_include_in_plug_hdr.h" + +enum EMsgTags +{ + kMsgTagAddTrack = 0, + kMsgTagProcessItem = 1, + kMsgTagToggleDock = 2, + kMsgTagSetGain = 3, + kNumMsgTags +}; + +using namespace iplug; + +class TemplateProjectReaperExtensionWebView : public ReaperExtBase +{ +public: + TemplateProjectReaperExtensionWebView(reaper_plugin_info_t* pRec); + void OnIdle() override; + + // Messages sent from the WebView UI arrive here + void SendArbitraryMsgFromUI(int msgTag, int ctrlTag, int dataSize, const void* pData) override; + + // Called once the WebView content has loaded - push current state to the fresh UI + void OnUIOpen() override; + + // hookpostcommand: react to project edits immediately (no polling lag) + void OnActionRun(int commandId, int flag) override; + + // projectconfig: persist the gain in the .RPP + void SaveProjectState(ProjectStateContext* ctx) override; + bool LoadProjectStateLine(const char* line) override; + void OnBeginLoadProjectState(bool isUndo) override; + +private: + // Offline-process the first selected media item, applying gain (0..1), writing a + // new file next to the original and inserting it into the project. + void ProcessSelectedItem(double gain); + + void PushTrackCount(bool force); // send track count to the WebView (if changed or forced) + void PushGain(); // send the current gain to the WebView slider + void PushDockState(); // tell the WebView whether the window is docked + + int mPrevTrackCount = -1; + double mGain = 1.0; // last gain set from the UI; persisted per-project; used by the right-click action +}; diff --git a/TemplateProjectReaperExtensionWebView/config.h b/TemplateProjectReaperExtensionWebView/config.h new file mode 100644 index 00000000..6a9115f7 --- /dev/null +++ b/TemplateProjectReaperExtensionWebView/config.h @@ -0,0 +1,17 @@ +// A REAPER extension needs far fewer of these than a plugin does: there is no host to +// declare formats, channel I/O or parameters to. PLUG_CLASS_NAME names the class and the +// reaper_ binary; PLUG_WIDTH/HEIGHT size the extension window. + +#define PLUG_NAME "TemplateProjectReaperExtensionWebView" +#define PLUG_MFR "AcmeInc" +#define PLUG_VERSION_HEX 0x00000000 +#define PLUG_VERSION_STR "0.0.0" +#define PLUG_COPYRIGHT_STR "Copyright 2026 Acme Inc" +#define PLUG_CLASS_NAME TemplateProjectReaperExtensionWebView + +#define PLUG_WIDTH 300 +#define PLUG_HEIGHT 300 +#define PLUG_FPS 60 +#define PLUG_SHARED_RESOURCES 0 + +#define SHARED_RESOURCES_SUBPATH "TemplateProjectReaperExtensionWebView" diff --git a/TemplateProjectReaperExtensionWebView/resources/main.rc b/TemplateProjectReaperExtensionWebView/resources/main.rc new file mode 100755 index 00000000..f47fe56f --- /dev/null +++ b/TemplateProjectReaperExtensionWebView/resources/main.rc @@ -0,0 +1,86 @@ +// Microsoft Visual C++ generated resource script. +// +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US +#pragma code_page(1252) + +///////////////////////////////////////////////////////////////////////////// +// +// Dialog +// + +IDD_DIALOG_MAIN DIALOG 0, 0, 300, 300 +STYLE DS_SETFONT | DS_CENTER | WS_MINIMIZEBOX | WS_POPUP | WS_CAPTION | WS_SYSMENU | WS_THICKFRAME +CAPTION "TemplateProjectReaperExtensionWebView Window" +FONT 8, "MS Sans Serif" +BEGIN +END + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "#include ""..\\config.h""\r\n" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// DESIGNINFO +// + +#ifdef APSTUDIO_INVOKED +GUIDELINES DESIGNINFO +BEGIN + IDD_DIALOG_MAIN, DIALOG + BEGIN + END +END +#endif // APSTUDIO_INVOKED + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// +#include "..\config.h" +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED + diff --git a/TemplateProjectReaperExtensionWebView/resources/main.rc_mac_dlg b/TemplateProjectReaperExtensionWebView/resources/main.rc_mac_dlg new file mode 100644 index 00000000..1e4c3795 --- /dev/null +++ b/TemplateProjectReaperExtensionWebView/resources/main.rc_mac_dlg @@ -0,0 +1,24 @@ +#ifndef SWELL_DLG_FLAGS_AUTOGEN +#define SWELL_DLG_FLAGS_AUTOGEN SWELL_DLG_WS_FLIPPED|SWELL_DLG_WS_NOAUTOSIZE +#endif + +#ifndef SET_IDD_DIALOG_MAIN_STYLE +#define SET_IDD_DIALOG_MAIN_STYLE SWELL_DLG_FLAGS_AUTOGEN|SWELL_DLG_WS_RESIZABLE|SWELL_DLG_WS_OPAQUE +#endif +#ifdef SET_IDD_DIALOG_MAIN_SCALE +SWELL_DEFINE_DIALOG_RESOURCE_BEGIN(IDD_DIALOG_MAIN,SET_IDD_DIALOG_MAIN_STYLE,"TemplateProjectReaperExtensionWebView Window",300,300,SET_IDD_DIALOG_MAIN_SCALE) +#else +SWELL_DEFINE_DIALOG_RESOURCE_BEGIN2(IDD_DIALOG_MAIN,SET_IDD_DIALOG_MAIN_STYLE,"TemplateProjectReaperExtensionWebView Window",300,300) +#endif +BEGIN +END +#ifdef SET_IDD_DIALOG_MAIN_SCALE +SWELL_DEFINE_DIALOG_RESOURCE_END(IDD_DIALOG_MAIN) +#else +SWELL_DEFINE_DIALOG_RESOURCE_END2(IDD_DIALOG_MAIN) +#endif + + + +//EOF + diff --git a/TemplateProjectReaperExtensionWebView/resources/main.rc_mac_menu b/TemplateProjectReaperExtensionWebView/resources/main.rc_mac_menu new file mode 100644 index 00000000..1aaf7dbc --- /dev/null +++ b/TemplateProjectReaperExtensionWebView/resources/main.rc_mac_menu @@ -0,0 +1,3 @@ + +//EOF + diff --git a/TemplateProjectReaperExtensionWebView/resources/resource.h b/TemplateProjectReaperExtensionWebView/resources/resource.h new file mode 100755 index 00000000..7d4f35ab --- /dev/null +++ b/TemplateProjectReaperExtensionWebView/resources/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by main.rc +// +#define IDD_DIALOG_MAIN 40001 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 103 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1004 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/TemplateProjectReaperExtensionWebView/resources/web/index.html b/TemplateProjectReaperExtensionWebView/resources/web/index.html new file mode 100644 index 00000000..cbcb7c87 --- /dev/null +++ b/TemplateProjectReaperExtensionWebView/resources/web/index.html @@ -0,0 +1,143 @@ + + + + + + + + + + + +

TemplateProjectReaperExtensionWebView

+ + + + +
Number of tracks: –
+ +
+

Offline processing

+
+ + + 100% +
+ +
+ + diff --git a/TemplateProjectReaperExtensionWebView/resources/web/script.js b/TemplateProjectReaperExtensionWebView/resources/web/script.js new file mode 100644 index 00000000..3ea33f9b --- /dev/null +++ b/TemplateProjectReaperExtensionWebView/resources/web/script.js @@ -0,0 +1,105 @@ +// FROM DELEGATE + +function SPVFD(paramIdx, val) { + console.log("paramIdx: " + paramIdx + " value:" + val); + OnParamChange(paramIdx, val); +} + +function SCVFD(ctrlTag, val) { + OnControlChange(ctrlTag, val); +// console.log("SCVFD ctrlTag: " + ctrlTag + " value:" + val); +} + +function SCMFD(ctrlTag, msgTag, msg) { +// var decodedData = window.atob(msg); + console.log("SCMFD ctrlTag: " + ctrlTag + " msgTag:" + msgTag + "msg:" + msg); +} + +function SAMFD(msgTag, dataSize, msg) { + OnMessage(msgTag, dataSize, msg); +} + +function SMMFD(statusByte, dataByte1, dataByte2) { + console.log("Got MIDI Message" + status + ":" + dataByte1 + ":" + dataByte2); +} + +function SSMFD(offset, size, msg) { + console.log("Got Sysex Message"); +} + +// FROM UI +// data should be a base64 encoded string +function SAMFUI(msgTag, ctrlTag = -1, data = 0) { + var message = { + "msg": "SAMFUI", + "msgTag": msgTag, + "ctrlTag": ctrlTag, + "data": data + }; + + IPlugSendMsg(message); +} + +function SMMFUI(statusByte, dataByte1, dataByte2) { + var message = { + "msg": "SMMFUI", + "statusByte": statusByte, + "dataByte1": dataByte1, + "dataByte2": dataByte2 + }; + + IPlugSendMsg(message); +} + +// data should be a base64 encoded string +function SSMFUI(data = 0) { + var message = { + "msg": "SSMFUI", + "data": data + }; + + IPlugSendMsg(message); +} + +function EPCFUI(paramIdx) { + if (paramIdx < 0) { + console.log("EPCFUI paramIdx must be >= 0") + return; + } + + var message = { + "msg": "EPCFUI", + "paramIdx": parseInt(paramIdx), + }; + + IPlugSendMsg(message); +} + +function BPCFUI(paramIdx) { + if (paramIdx < 0) { + console.log("BPCFUI paramIdx must be >= 0") + return; + } + + var message = { + "msg": "BPCFUI", + "paramIdx": parseInt(paramIdx), + }; + + IPlugSendMsg(message); +} + +function SPVFUI(paramIdx, value) { + if (paramIdx < 0) { + console.log("SPVFUI paramIdx must be >= 0") + return; + } + + var message = { + "msg": "SPVFUI", + "paramIdx": parseInt(paramIdx), + "value": value + }; + + IPlugSendMsg(message); +} diff --git a/TemplateProjectWebView/.vscode/c_cpp_properties.json b/TemplateProjectWebView/.vscode/c_cpp_properties.json new file mode 100644 index 00000000..1d4217f2 --- /dev/null +++ b/TemplateProjectWebView/.vscode/c_cpp_properties.json @@ -0,0 +1,49 @@ +{ + "env": { + "commonIncludePaths": [ + "${workspaceFolder}/**", + "${workspaceFolder}/../../WDL/**", + "${workspaceFolder}/../../IPlug/**", + "${workspaceFolder}/../../IGraphics/**", + "${workspaceFolder}/../../Dependencies/**" + ], + "commonDefs": [ + "APP_API", + "IPLUG_DSP=1", + "IPLUG_EDITOR=1", + "IGRAPHICS_NANOVG", + "NOMINMAX" + ] + }, + "configurations": [ + { + "name": "Mac", + "includePath": [ + "${commonIncludePaths}", + "${workspaceFolder}/../../Dependencies/Build/mac/include/**" + ], + "defines": [ + "${commonDefs}", + "OS_MAC", + "IGRAPHICS_METAL" + ], + "macFrameworkPath": [ + "/System/Library/Frameworks", + "/Library/Frameworks" + ], + "cppStandard": "c++14" + }, + { + "name": "Win32", + "includePath": [ + "${commonIncludePaths}" + ], + "defines": [ + "${commonDefs}", + "OS_WIN", + "IGRAPHICS_GL2" + ] + } + ], + "version": 4 +} \ No newline at end of file diff --git a/TemplateProjectWebView/CLAUDE.md b/TemplateProjectWebView/CLAUDE.md new file mode 100644 index 00000000..9cb5221b --- /dev/null +++ b/TemplateProjectWebView/CLAUDE.md @@ -0,0 +1,87 @@ +# CLAUDE.md + +iPlug2 plugin/app project with a **WebView UI** — the editor is authored in HTML/CSS/JS under `resources/web/`, not in IGraphics/C++. + +> This file is duplicated by `../duplicate.py` along with the rest of the project, and the project-name string-rewrite pass runs over it — so `TemplateProjectWebView.cpp` etc. resolve to the new project's filenames after duplication. + +## Distributed plugins + +iPlug2 has a mechanism to split DSP and UI builds in two, in order that the plugin can run across procees or sandboxed boundaries, e.g. AudioWorklet in the WASM target. Typically this is only used in WASM builds. Code that should be part of the UI build is guarded with IPLUG_EDITOR and code that is part of the DSP build is guarded with IPLUG_DSP. The build system includes graphics related code only in the UI build etc. + +IT IS VERY IMPORTANT TO MAKE SURE CODE AND INCLUDES ARE CORRECTLY GUARDED FOR WASM BUILDS - WHICH ARE USED BY HOT RELOADING IDEs - EVEN IF THE USER ULTIMATELY BUILDS A NATIVE PLUGIN/APP. + +## UI: WebView, no IGraphics + +The plugin derives from `iplug::Plugin` and, is built with `WEBVIEW_EDITOR_DELEGATE` defined in order to swap in the right base class. It hosts a platform webview (WKWebView on macOS, WebView2 on Windows) that renders `resources/web/index.html`. Layout is CSS, controls are HTML custom elements. The webview also functions in the browser via the WASM target. + +Key UI pieces in the C++ code (`TemplateProjectWebView.cpp` / `.h`), all gated behind `#ifdef IPLUG_EDITOR`: + +- `mEditorInitFunc` runs when the editor opens. It calls `LoadIndexHtml(__FILE__, GetBundleID())` — which loads `resources/web/index.html` **from the source tree in debug builds and from the app bundle in release builds** — then `EnableScroll(false)`. +- `SetEnableDevTools(true)` is enabled in `DEBUG` builds: right-click → Inspect (macOS) opens Web Inspector against the live UI. + +## DSP: C++ + +- `ProcessBlock` is plain DSP and is independent of the UI backend. + +## The JS ↔ C++ message bridge + +The webview and the plugin talk through a fixed set of bridge functions defined in `resources/web/script.js`. Do not rename them — iPlug2 calls them by name. + +**Delegate → UI** (native calls these JS functions; you implement the `On*` handlers in `index.html`): + +- `SPVFD(paramIdx, val)` — Set Parameter Value From Delegate → calls `OnParamChange(paramIdx, value)`. This is how host/automation parameter changes reach the UI. +- `SCVFD` / `SCMFD` — control value / control message from delegate. +- `SAMFD(msgTag, dataSize, msg)` — arbitrary message from delegate → calls `OnMessage(msgTag, dataSize, data)` (data is base64). The plugin pushes the parameter list as JSON this way once the page loads. +- `SMMFD` / `SSMFD` — MIDI / sysex from delegate. + +**UI → Delegate** (your JS calls these; they marshal to `IPlugSendMsg` and surface on the C++ side): + +- `SPVFUI(paramIdx, normValue)` — Set Parameter Value From UI. `normValue` is **normalized 0–1**. Call on every value change. +- `BPCFUI(paramIdx)` / `EPCFUI(paramIdx)` — Begin / End Parameter Change From UI. Wrap a drag gesture so the host records a single automation gesture and can undo it atomically. +- `SAMFUI(msgTag, ctrlTag = -1, data = 0)` — arbitrary message from UI → arrives at C++ `OnMessage(msgTag, ctrlTag, dataSize, pData)`. +- `SMMFUI` / `SSMFUI` — send MIDI / sysex from UI. + +On the C++ side, override `OnMessage(int msgTag, int ctrlTag, int dataSize, const void* pData)` to receive `SAMFUI` calls. The template uses it for the editor-size buttons: `EMsgTags` in the header (`kMsgTagButtonSmall/Medium/Large`) maps to `Resize(...)` calls, with matching `kMsgTag*` constants at the top of `index.html` — the two lists must stay in sync, since the wire format is a bare int. Return `true` if you handled the message. + +`OnMessage`'s resize block is guarded by `WEBVIEW_EDITOR_DELEGATE` rather than `IPLUG_EDITOR`, because `Resize()` comes from the webview delegate. + +## Parameters + +- Parameters are declared the usual iPlug2 way: add to `EParams` in the header and `GetParam(...)->InitDouble(...)` (etc.) in the constructor. +- The UI does **not** hard-code parameter ranges. After the page loads, the plugin sends the full parameter list as JSON via `SAMFD` (msgTag `-1`); `OnMessage` in `index.html` parses it and configures each `knob-control` (`min`, `max`, `label`, `default-value`) by matching its `param-id` attribute. So adding/retitling a parameter in C++ flows to the UI automatically — you only bind a control to a `param-id`. + +## Web controls — custom elements + +Controls are HTML Custom Elements (Web Components), each in its own module: + +- `resources/web/knob-control.js` — ``. On drag it calls `BPCFUI` (start), `SPVFUI` (each move, normalized), `EPCFUI` (end). Host-driven changes arrive via `updateValueFromHost(normalizedValue)`, dispatched from `OnParamChange`. `observedAttributes` includes `param-id`, `min`, `max`, `label`, `default-value`. +- `resources/web/button-control.js` — ``, a styled shadow-DOM button. The template wires `onclick="SAMFUI(kMsgTagButton*)"` directly in `index.html` to trigger the resize messages. + +### Don't echo host values back to the host + +`updateValue()` in `knob-control.js` takes a `notifyHost` flag. Drag and wheel leave it at +its default of `true`; `updateValueFromHost()` passes `false`, so a host-originated change +redraws the knob without calling `SPVFUI`. Without that split, automation, preset recall and +initialisation all bounce back to the host as UI edits — and outside any `BPCFUI`/`EPCFUI` +gesture, so the host records parameter writes nobody asked for. Any new control needs the +same separation between "the user did this" and "the host told us this". + +### Adding a new control — checklist + +1. **C++:** add the parameter to `EParams` and `Init*` it in the constructor (for a parameter-bound control). For a non-parameter action, define a tag in `EMsgTags` and handle it in `OnMessage`. +2. **HTML:** add the element in `index.html` with its `param-id` (for a knob) or an `onclick`/event wiring (for a button/action). +3. **Bind to the host:** a parameter control must call `BPCFUI`→`SPVFUI`(normalized)→`EPCFUI` on user input, and implement an update path from `OnParamChange` (e.g. `updateValueFromHost`) so host automation moves it. +4. **Styling:** layout via CSS in the `index.html` ` + + `; + + this.button = this.shadowRoot.querySelector('button'); + this.button.addEventListener('click', () => { + this.dispatchEvent(new CustomEvent('button-click')); + }); + } +} + +customElements.define('button-control', ButtonControl); diff --git a/TemplateProjectWebView/resources/web/index.html b/TemplateProjectWebView/resources/web/index.html new file mode 100644 index 00000000..e7b86645 --- /dev/null +++ b/TemplateProjectWebView/resources/web/index.html @@ -0,0 +1,142 @@ + + + + + + + + + + + + + +

TemplateProjectWebView

+
+
+ +
+ +
+ Editor size + Small + Medium + Large +
+
+ + diff --git a/TemplateProjectWebView/resources/web/knob-control.js b/TemplateProjectWebView/resources/web/knob-control.js new file mode 100644 index 00000000..a9fb3b63 --- /dev/null +++ b/TemplateProjectWebView/resources/web/knob-control.js @@ -0,0 +1,249 @@ +class KnobControl extends HTMLElement { + constructor() { + super(); + + this.paramId = 0; + this.controlTag = ''; + this.defaultValue = 0.0; + this.value = 0.0; + + this.label = this.getAttribute('label') || ''; + this.minValue = parseFloat(this.getAttribute('min')) || 0; + this.maxValue = parseFloat(this.getAttribute('max')) || 100; + const units = this.getAttribute('units') || ''; + const minAngle = parseFloat(this.getAttribute('min-angle')) || -135; + const maxAngle = parseFloat(this.getAttribute('max-angle')) || 135; + const circleStrokeColor = this.getAttribute('circle-stroke-color') || '#eef1f5'; + const circleStrokeWidth = parseFloat(this.getAttribute('circle-stroke-width')) || 2; + const circleFillColor = this.getAttribute('circle-fill-color') || '#18202d'; + const pointerColor = this.getAttribute('pointer-color') || '#2563eb'; + const pointerWidth = parseFloat(this.getAttribute('pointer-width')) || 4; + const valueArcColor = this.getAttribute('value-arc-color') || '#2563eb'; + const valueArcWidth = parseFloat(this.getAttribute('value-arc-width')) || 3; + const trackBgColor = this.getAttribute('track-bg-color') || '#c9ced6'; + + this.attachShadow({ mode: 'open' }); + this.shadowRoot.innerHTML = ` + +
+
${this.label}
+ + + + + + +
0 ${units}
+
+ `; + + const pointer = this.shadowRoot.querySelector('.pointer'); + const valueElement = this.shadowRoot.querySelector('.value'); + let currentValue = this.defaultValue; + + const valueArc = this.shadowRoot.querySelector('.value-arc'); + const trackBg = this.shadowRoot.querySelector('.track-bg'); + + const polarToCartesian = (centerX, centerY, radius, angleInDegrees) => { + const angleInRadians = (angleInDegrees - 90) * Math.PI / 180.0; + return { + x: centerX + (radius * Math.cos(angleInRadians)), + y: centerY + (radius * Math.sin(angleInRadians)) + }; + }; + + const createTrackBg = () => { + const arcRadius = 48; + const start = polarToCartesian(50, 50, arcRadius, minAngle); + const end = polarToCartesian(50, 50, arcRadius, maxAngle); + + const largeArcFlag = maxAngle - minAngle <= 180 ? 0 : 1; + + const d = [ + 'M', start.x, start.y, + 'A', arcRadius, arcRadius, 0, largeArcFlag, 1, end.x, end.y + ].join(' '); + + trackBg.setAttribute('d', d); + }; + + createTrackBg(); + + const updateValueArc = (angle) => { + const startAngle = minAngle; + const endAngle = angle; + const arcRadius = 48; + + const start = polarToCartesian(50, 50, arcRadius, startAngle); + const end = polarToCartesian(50, 50, arcRadius, endAngle); + + const largeArcFlag = endAngle - startAngle <= 180 ? 0 : 1; + + const d = [ + 'M', start.x, start.y, + 'A', arcRadius, arcRadius, 0, largeArcFlag, 1, end.x, end.y + ].join(' '); + + valueArc.setAttribute('d', d); + }; + + // notifyHost must be false for host-originated changes (automation, preset recall, + // initialisation). Echoing those back through SPVFUI reports them to the host as UI + // edits, outside any BPCFUI/EPCFUI gesture. + const updateValue = (value, normalized = false, notifyHost = true) => { + let finalValue; + if (normalized) { + // If we receive a normalized value (0-1), convert it to our range + finalValue = this.minValue + (value * (this.maxValue - this.minValue)); + } else { + finalValue = value; + } + + currentValue = finalValue; + valueElement.textContent = `${finalValue.toFixed(1)} ${units}`; + const normValue = ((finalValue - this.minValue) / (this.maxValue - this.minValue)); + const angle = minAngle + normValue * (maxAngle - minAngle); + pointer.setAttribute('transform', `rotate(${angle}, 50, 50)`); + updateValueArc(angle); + + if (notifyHost && typeof window['SPVFUI'] === 'function') { + window['SPVFUI'](this.paramId, normValue); + } + }; + + const startDrag = (e) => { + + if (e.button == 2) return; // right click (context menu + + if (typeof window['BPCFUI'] === 'function') { + window['BPCFUI'](this.paramId); + } + + e.preventDefault(); + const svg = e.currentTarget.parentElement; + + let initialY = ('touches' in e) ? e.touches[0].clientY : e.clientY; + let initialValue = currentValue; + + const onMove = (e) => { + const clientY = ('touches' in e) ? e.touches[0].clientY : e.clientY; + const deltaY = initialY - clientY; + const valueChange = deltaY * (this.maxValue - this.minValue) / 100; + const value = Math.min(Math.max(initialValue + valueChange, this.minValue), this.maxValue); + updateValue(value); + }; + + const onEnd = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onEnd); + document.removeEventListener('touchmove', onMove); + document.removeEventListener('touchend', onEnd); + document.body.classList.remove('hidden-cursor'); + document.body.style.cursor = ''; + + if (typeof window['EPCFUI'] === 'function') { + window['EPCFUI'](this.paramId); + } + }; + + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onEnd); + document.addEventListener('touchmove', onMove, { passive: false }); + document.addEventListener('touchend', onEnd); + document.body.classList.add('hidden-cursor'); + document.body.style.cursor = 'none'; + }; + + const onWheel = (e) => { + e.preventDefault(); + const delta = e.deltaY < 0 ? 1 : -1; + const valueChange = delta * (this.maxValue - this.minValue) / 100; + const value = Math.min(Math.max(currentValue + valueChange, this.minValue), this.maxValue); + updateValue(value); + }; + + this.shadowRoot.querySelector('circle').addEventListener('mousedown', startDrag); + this.shadowRoot.querySelector('circle').addEventListener('touchstart', startDrag, { passive: false }); + this.shadowRoot.querySelector('circle').addEventListener('wheel', onWheel); + +// updateValue(currentValue); + + this.shadowRoot.querySelector('.container').__updateValue = updateValue; + + // Expose updateValue method with a different name to avoid conflicts. + // Redraws only - the host already knows this value, so don't send it back. + this.updateValueFromHost = (normalizedValue) => updateValue(normalizedValue, true, false); + } + + connectedCallback() { + // Set the parameter ID, control tag, and initial value + this.paramId = this.getAttribute('param-id') || -1; + this.controlTag = this.getAttribute('control-tag') || ''; + this.defaultValue = parseFloat(this.getAttribute('default-value')) || 0.0; + } + + attributeChangedCallback(attrName, oldVal, newVal) { + if (attrName === 'param-id') { + this.paramId = parseInt(newVal); + } else if (attrName === 'control-tag') { + this.controlTag = parseInt(newVal); + } else if (attrName === 'min') { + this.minValue = parseFloat(newVal); + } else if (attrName === 'max') { + this.maxValue = parseFloat(newVal); + } else if (attrName === 'label') { + this.label = newVal; + } else if (attrName === 'default-value') { + this.defaultValue = parseFloat(newVal); + } + } + + static get observedAttributes() { + return ['param-id', 'default-value', 'control-tag', 'label', 'min', 'max'/*, 'value'*/]; + } + + // Getter and setter for value + get value() { + return this.getAttribute('value'); + } + + set value(newValue) { + this.setAttribute('value', newValue); + } +} + +customElements.define('knob-control', KnobControl); diff --git a/TemplateProjectWebView/resources/web/script.js b/TemplateProjectWebView/resources/web/script.js new file mode 100644 index 00000000..3ea33f9b --- /dev/null +++ b/TemplateProjectWebView/resources/web/script.js @@ -0,0 +1,105 @@ +// FROM DELEGATE + +function SPVFD(paramIdx, val) { + console.log("paramIdx: " + paramIdx + " value:" + val); + OnParamChange(paramIdx, val); +} + +function SCVFD(ctrlTag, val) { + OnControlChange(ctrlTag, val); +// console.log("SCVFD ctrlTag: " + ctrlTag + " value:" + val); +} + +function SCMFD(ctrlTag, msgTag, msg) { +// var decodedData = window.atob(msg); + console.log("SCMFD ctrlTag: " + ctrlTag + " msgTag:" + msgTag + "msg:" + msg); +} + +function SAMFD(msgTag, dataSize, msg) { + OnMessage(msgTag, dataSize, msg); +} + +function SMMFD(statusByte, dataByte1, dataByte2) { + console.log("Got MIDI Message" + status + ":" + dataByte1 + ":" + dataByte2); +} + +function SSMFD(offset, size, msg) { + console.log("Got Sysex Message"); +} + +// FROM UI +// data should be a base64 encoded string +function SAMFUI(msgTag, ctrlTag = -1, data = 0) { + var message = { + "msg": "SAMFUI", + "msgTag": msgTag, + "ctrlTag": ctrlTag, + "data": data + }; + + IPlugSendMsg(message); +} + +function SMMFUI(statusByte, dataByte1, dataByte2) { + var message = { + "msg": "SMMFUI", + "statusByte": statusByte, + "dataByte1": dataByte1, + "dataByte2": dataByte2 + }; + + IPlugSendMsg(message); +} + +// data should be a base64 encoded string +function SSMFUI(data = 0) { + var message = { + "msg": "SSMFUI", + "data": data + }; + + IPlugSendMsg(message); +} + +function EPCFUI(paramIdx) { + if (paramIdx < 0) { + console.log("EPCFUI paramIdx must be >= 0") + return; + } + + var message = { + "msg": "EPCFUI", + "paramIdx": parseInt(paramIdx), + }; + + IPlugSendMsg(message); +} + +function BPCFUI(paramIdx) { + if (paramIdx < 0) { + console.log("BPCFUI paramIdx must be >= 0") + return; + } + + var message = { + "msg": "BPCFUI", + "paramIdx": parseInt(paramIdx), + }; + + IPlugSendMsg(message); +} + +function SPVFUI(paramIdx, value) { + if (paramIdx < 0) { + console.log("SPVFUI paramIdx must be >= 0") + return; + } + + var message = { + "msg": "SPVFUI", + "paramIdx": parseInt(paramIdx), + "value": value + }; + + IPlugSendMsg(message); +} diff --git a/duplicate.py b/duplicate.py index a7e7a37a..2efc64f5 100755 --- a/duplicate.py +++ b/duplicate.py @@ -7,7 +7,7 @@ # Author accepts no responsibilty for wiping your hd # NOTES: -# should work with Python2 or Python3 +# requires Python3 # not designed to be fool proof- think carefully about what you choose for a project name # best to stick to standard characters in your project names - avoid spaces, numbers and dots # windows users need to install python and set it up so you can run it from the command line @@ -16,6 +16,11 @@ # USAGE: # duplicate.py [inputprojectname] [outputprojectname] [manufacturername] (outputpath) +# Run with no arguments to be prompted for each of them. +# +# By default only the duplicated project is touched. Pass --rewrite-root (or answer yes to +# the prompt) to also point this repo's root files - bump_version.py, CMakeLists.txt, +# .vscode/ and .github/ - at the new project. # TODO: # - indentation of directory structure @@ -24,7 +29,7 @@ from __future__ import generators -import fileinput, glob, string, sys, os, re, uuid, pprint, random +import argparse, fileinput, glob, string, sys, os, re, uuid, pprint, random from shutil import copy, copytree, ignore_patterns, rmtree from os.path import join @@ -34,11 +39,17 @@ from parse_config import parse_config, parse_xcconfig, set_uniqueid -VERSION = "0.95" +VERSION = "1.0" # binary files that we don't want to do find and replace inside FILTERED_FILE_EXTENSIONS = [".ico",".icns", ".pdf", ".png", ".zip", ".exe", ".wav", ".aif", ".data", ".wasm", "mkcert"] FILTERED_FILE_NAMES = [".DS_Store", "duplicate.py"] +# additionally skipped when rewriting the repo root: these describe the templates, which still +# exist after duplicating. Rewriting them renames the templates the docs are talking about, and +# a substring match turns e.g. TemplateProjectWebView into MyPluginWebView. +ROOT_FILTERED_FILE_NAMES = FILTERED_FILE_NAMES + ["CLAUDE.md", "README.md"] +# the iPlug2 root as spelled in a template's CMakeLists.txt, relative to the project dir +CMAKE_IPLUG2_DIR = "${CMAKE_CURRENT_SOURCE_DIR}/../iPlug2" # files that we don't want to duplicate DONT_COPY = (".vs", "*.exe", "*.dmg", "*.pkg", "*.mpkg", "*.svn", "*.ncb", "*.suo", "*sdf", "ipch", "*.layout", "*.depend", ".DS_Store", "xcuserdata", "*.aps") @@ -55,7 +66,8 @@ "en-osx.lproj", "project.xcworkspace", "Images.xcassets", -"build-web-wasm" +"build-web-wasm", +"web" ] def randomFourChar(chars=string.ascii_letters + string.digits): @@ -85,7 +97,7 @@ def replacestrsChop(filename, s, r): line = r + "\n" sys.stdout.write(line) -def dirwalk(dir, searchproject, replaceproject, searchman, replaceman, oldroot= "", newroot=""): +def dirwalk(dir, searchproject, replaceproject, searchman, replaceman, oldroot= "", newroot="", filterednames=FILTERED_FILE_NAMES): for f in os.listdir(dir): fullpath = os.path.join(dir, f) @@ -95,21 +107,21 @@ def dirwalk(dir, searchproject, replaceproject, searchman, replaceman, oldroot= fullpath = os.path.join(dir, replaceproject + "-macOS.xcodeproj") print("recursing in macOS xcode project directory: ") - for x in dirwalk(fullpath, searchproject, replaceproject, searchman, replaceman, oldroot, newroot): + for x in dirwalk(fullpath, searchproject, replaceproject, searchman, replaceman, oldroot, newroot, filterednames): yield x elif checkdirname(f, searchproject + "-iOS.xcodeproj"): os.rename(fullpath, os.path.join(dir, replaceproject + "-iOS.xcodeproj")) fullpath = os.path.join(dir, replaceproject + "-iOS.xcodeproj") print("recursing in iOS xcode project directory: ") - for x in dirwalk(fullpath, searchproject, replaceproject, searchman, replaceman, oldroot, newroot): + for x in dirwalk(fullpath, searchproject, replaceproject, searchman, replaceman, oldroot, newroot, filterednames): yield x elif checkdirname(f, searchproject + ".xcworkspace"): os.rename(fullpath, os.path.join(dir, replaceproject + ".xcworkspace")) fullpath = os.path.join(dir, replaceproject + ".xcworkspace") print("recursing in main xcode workspace directory: ") - for x in dirwalk(fullpath, searchproject, replaceproject, searchman, replaceman, oldroot, newroot): + for x in dirwalk(fullpath, searchproject, replaceproject, searchman, replaceman, oldroot, newroot, filterednames): yield x elif checkdirname(f, searchproject + "-iOS.appiconset"): os.rename(fullpath, os.path.join(dir, replaceproject + "-iOS.appiconset")) @@ -121,11 +133,11 @@ def dirwalk(dir, searchproject, replaceproject, searchman, replaceman, oldroot= fullpath = os.path.join(dir, replaceproject + "-macOS.appiconset") print("recursing in -macOS.appiconset directory: ") - for x in dirwalk(fullpath, searchproject, replaceproject, searchman, replaceman, oldroot, newroot): + for x in dirwalk(fullpath, searchproject, replaceproject, searchman, replaceman, oldroot, newroot, filterednames): yield x elif (f in SUBFOLDERS_TO_SEARCH): print('recursing in ' + f + ' directory: ') - for x in dirwalk(fullpath, searchproject, replaceproject, searchman, replaceman, oldroot, newroot): + for x in dirwalk(fullpath, searchproject, replaceproject, searchman, replaceman, oldroot, newroot, filterednames): yield x if os.path.isfile(fullpath): @@ -133,7 +145,7 @@ def dirwalk(dir, searchproject, replaceproject, searchman, replaceman, oldroot= newfilename = filename.replace(searchproject, replaceproject) base, extension = os.path.splitext(filename) - if (not(extension in FILTERED_FILE_EXTENSIONS) and not(filename in FILTERED_FILE_NAMES)): + if (not(extension in FILTERED_FILE_EXTENSIONS) and not(filename in filterednames)): print("Replacing project name strings in file " + filename) replacestrs(fullpath, searchproject, replaceproject) @@ -160,37 +172,127 @@ def dirwalk(dir, searchproject, replaceproject, searchman, replaceman, oldroot= else: yield f, fullpath +def find_projects(): + """Directories next to this script that look like an iPlug2 project. Any of them can be + duplicated, not just the templates, so projects you have already made show up too.""" + projects = [] + + for f in sorted(os.listdir(scriptpath)): + if os.path.isdir(os.path.join(scriptpath, f)) and os.path.isfile(os.path.join(scriptpath, f, "config.h")): + projects.append(f) + + return projects + +def default_project(projects): + "a template makes a better default than whatever project happens to sort first" + for p in projects: + if p.startswith("TemplateProject"): + return p + + return projects[0] if projects else "" + +def prompt(question, default=""): + suffix = " [" + default + "]: " if default else ": " + + try: + answer = input(question + suffix).strip() + except EOFError: + answer = "" + + return answer if answer else default + +def prompt_yes_no(question, default=False): + answer = prompt(question + (" [y/N]" if not default else " [Y/n]")) + + if not answer: + return default + + return answer[0].lower() == "y" + +def find_iplug2_root(inputprojectname): + """Where iPlug2 lives, as an absolute path. Prefer the template's own xcconfig, since a + template may point somewhere other than the submodule, and fall back to the submodule for + CMake-only templates, which have no config/ directory.""" + xcconfigpath = os.path.join(scriptpath, inputprojectname, "config", inputprojectname + "-mac.xcconfig") + + if os.path.isfile(xcconfigpath): + oldroot = parse_xcconfig(xcconfigpath)["IPLUG2_ROOT"] + return oldroot, os.path.abspath(os.path.join(scriptpath, inputprojectname, "config", oldroot)) + + return "", os.path.join(scriptpath, "iPlug2") + +def fix_cmake_iplug2_dir(outputpath, iplug2folder): + """Point the duplicated CMakeLists.txt at iPlug2. Its default is a sibling ../iPlug2, which is + only true for a project sitting in this repo, so an out-of-source duplicate needs rewriting. + The xcconfig equivalent is handled by the oldroot/newroot pass in dirwalk().""" + cmakelists = os.path.join(outputpath, "CMakeLists.txt") + + if not os.path.isfile(cmakelists): + return + + newdir = os.path.relpath(iplug2folder, outputpath).replace(os.sep, "/") + + print("Pointing CMakeLists.txt at iPlug2 via " + newdir) + replacestrs(cmakelists, CMAKE_IPLUG2_DIR, "${CMAKE_CURRENT_SOURCE_DIR}/" + newdir) + def main(): global VERSION print("\nIPlug Project Duplicator v" + VERSION + " by Oli Larkin ------------------------------\n") - numargs = len(sys.argv) - 1 + projects = find_projects() - if not (numargs == 3 or numargs == 4): - print("Usage: duplicate.py inputprojectname outputprojectname manufacturername (outputprojectpath)") - sys.exit(1) - else: - inputprojectname=sys.argv[1] - outputprojectname=sys.argv[2] - manufacturer=sys.argv[3] + parser = argparse.ArgumentParser(description="Duplicate an iPlug2 project. Omit any argument to be prompted for it.") + parser.add_argument("inputprojectname", nargs="?", help="project to copy, one of: " + ", ".join(projects)) + parser.add_argument("outputprojectname", nargs="?", help="name of the new project") + parser.add_argument("manufacturername", nargs="?", help="your company name, replaces AcmeInc") + parser.add_argument("outputprojectpath", nargs="?", help="where to put it (default: this repo)") - if numargs == 4: - outputbasepath=os.path.abspath(sys.argv[4]) - else: - outputbasepath=os.getcwd() + rootgroup = parser.add_mutually_exclusive_group() + rootgroup.add_argument("--rewrite-root", dest="rewriteroot", action="store_true", default=None, + help="also point this repo's bump_version.py, CMakeLists.txt, .vscode/ and .github/ at the new project") + rootgroup.add_argument("--no-rewrite-root", dest="rewriteroot", action="store_false", + help="leave this repo's own files alone (default when not a terminal)") - if not (os.path.isdir(outputbasepath)): - print("error: Output path does not exist") - sys.exit(1) + args = parser.parse_args() - outputpath = os.path.join(outputbasepath, outputprojectname) + interactive = sys.stdin.isatty() - if ' ' in inputprojectname: - print("error: input project name has spaces") + inputprojectname = args.inputprojectname + outputprojectname = args.outputprojectname + manufacturer = args.manufacturername + + if not inputprojectname: + if not interactive: + parser.error("inputprojectname is required when not running interactively") + + print("Projects you can duplicate: " + ", ".join(projects) + "\n") + inputprojectname = prompt("Project to duplicate", default_project(projects)) + + if not outputprojectname: + if not interactive: + parser.error("outputprojectname is required when not running interactively") + + outputprojectname = prompt("Name of the new project") + + if not manufacturer: + if not interactive: + parser.error("manufacturername is required when not running interactively") + + manufacturer = prompt("Manufacturer name", "AcmeInc") + + # remove a trailing slash if it exists + if inputprojectname[-1:] == "/": + inputprojectname = inputprojectname[0:-1] + + if outputprojectname[-1:] == "/": + outputprojectname = outputprojectname[0:-1] + + if not outputprojectname: + print("error: output project name is empty") sys.exit(1) - if inputprojectname not in os.listdir(os.curdir): - print("error: input project " + inputprojectname + " doesn't exist, check spelling/case?") + if ' ' in inputprojectname: + print("error: input project name has spaces") sys.exit(1) if ' ' in outputprojectname: @@ -201,53 +303,76 @@ def main(): print("error: manufacturer name has spaces") sys.exit(1) - # remove a trailing slash if it exists - if inputprojectname[-1:] == "/": - inputprojectname = inputprojectname[0:-1] + #check that the folders are OK + if not os.path.isdir(os.path.join(scriptpath, inputprojectname)): + print("error: input project " + inputprojectname + " doesn't exist, check spelling/case?") + sys.exit(1) - if outputprojectname[-1:] == "/": - outputprojectname = outputprojectname[0:-1] + outofsource = args.outputprojectpath is not None - #check that the folders are OK - if os.path.isdir(inputprojectname) == False: - print("error: input project not found") + if outofsource: + outputbasepath = os.path.abspath(args.outputprojectpath) + else: + outputbasepath = scriptpath + + if not (os.path.isdir(outputbasepath)): + print("error: Output path does not exist") sys.exit(1) + outputpath = os.path.join(outputbasepath, outputprojectname) + if os.path.isdir(outputpath): print("error: output project allready exists") sys.exit(1) # rmtree(output) - print("copying " + inputprojectname + " folder to " + outputpath) - copytree(inputprojectname, outputpath, ignore=ignore_patterns(*DONT_COPY)) + # Rewriting this repo's own files is destructive and usually unwanted, so it is opt-in. + rewriteroot = args.rewriteroot + + if rewriteroot is None: + if interactive: + print("\nThe repo's own bump_version.py, CMakeLists.txt, .vscode/ and .github/ still refer") + print("to " + inputprojectname + ". They can be repointed at " + outputprojectname + ", which is what you want if") + print("this repo is becoming your plugin's repo, and not what you want if you are just") + print("adding another project alongside the templates.") + rewriteroot = prompt_yes_no("Repoint them at " + outputprojectname + "?", False) + else: + rewriteroot = False - oldroot = "" + oldroot, iplug2folder = find_iplug2_root(inputprojectname) newroot = "" - - if numargs == 4: - configpath = os.path.join(inputprojectname, "config") - xcconfig = parse_xcconfig(configpath + "/" + inputprojectname + "-mac.xcconfig") - oldroot = xcconfig["IPLUG2_ROOT"] - iplug2folder = os.path.abspath(os.path.join(configpath, oldroot)) + + # the xcconfig spells the iPlug2 root relative to the project's config/ dir, so it only needs + # rewriting when the project moves out of this repo + if outofsource and oldroot: newroot = os.path.relpath(iplug2folder, os.path.join(outputpath, "config")) - else: - newroot = "" + + print("copying " + inputprojectname + " folder to " + outputpath) + copytree(os.path.join(scriptpath, inputprojectname), outputpath, ignore=ignore_patterns(*DONT_COPY)) #replace manufacturer name strings for dir in dirwalk(outputpath, inputprojectname, outputprojectname, "AcmeInc", manufacturer, oldroot, newroot): pass - #replace project name in root - for dir in dirwalk(scriptpath, inputprojectname, outputprojectname, "AcmeInc", manufacturer, oldroot, newroot): - pass + if outofsource: + fix_cmake_iplug2_dir(outputpath, iplug2folder) - #replace project name in github - for dir in dirwalk(scriptpath + "/.github/workflows", inputprojectname, outputprojectname, "AcmeInc", manufacturer, oldroot, newroot): - pass + if rewriteroot: + #replace project name in root, but not in the docs describing the templates + for dir in dirwalk(scriptpath, inputprojectname, outputprojectname, "AcmeInc", manufacturer, "", "", ROOT_FILTERED_FILE_NAMES): + pass - #replace project name in vscode - for dir in dirwalk(scriptpath + "/.vscode", inputprojectname, outputprojectname, "AcmeInc", manufacturer, oldroot, newroot): - pass + #replace project name in github + if os.path.isdir(scriptpath + "/.github/workflows"): + for dir in dirwalk(scriptpath + "/.github/workflows", inputprojectname, outputprojectname, "AcmeInc", manufacturer): + pass + + #replace project name in vscode + if os.path.isdir(scriptpath + "/.vscode"): + for dir in dirwalk(scriptpath + "/.vscode", inputprojectname, outputprojectname, "AcmeInc", manufacturer): + pass + else: + print("\nleaving this repo's root files alone (pass --rewrite-root to change that)") # print("\ncopying gitignore template into project folder\n") @@ -255,14 +380,15 @@ def main(): config = parse_config(outputpath) - config["PLUG_UNIQUE_ID"] = randomFourChar() - - set_uniqueid(outputpath, config["PLUG_UNIQUE_ID"]) + # a REAPER extension has no plugin id to randomise + if config["PLUG_UNIQUE_ID"]: + config["PLUG_UNIQUE_ID"] = randomFourChar() + set_uniqueid(outputpath, config["PLUG_UNIQUE_ID"]) pp = pprint.PrettyPrinter(indent=4) pp.pprint(config) - print("\ndone - don't forget to change PLUG_MFR_UID in config.h") + print("\ndone - don't forget to change PLUG_MFR_ID in config.h") if __name__ == '__main__': main() diff --git a/iPlug2 b/iPlug2 index b64192fe..5c2df9dc 160000 --- a/iPlug2 +++ b/iPlug2 @@ -1 +1 @@ -Subproject commit b64192fe18afd9bc9a1fe324db5aceb48f4a0eee +Subproject commit 5c2df9dce3f5258acfeff3846a6a9563f382212c