├── .github └── workflows │ └── main.yml ├── .gitignore ├── .gitmodules ├── LICENSE.md ├── README.md ├── dsp ├── RefMap.js ├── main.js └── srvb.js ├── index.html ├── native ├── CMakeLists.txt ├── PluginProcessor.cpp ├── PluginProcessor.h ├── WebViewEditor.cpp └── WebViewEditor.h ├── package-lock.json ├── package.json ├── postcss.config.js ├── public └── manifest.json ├── scripts └── build-native.mjs ├── src ├── DragBehavior.jsx ├── Interface.jsx ├── Knob.jsx ├── Lockup.svg ├── index.css └── main.jsx ├── tailwind.config.js └── vite.config.js /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | push: 7 | branches: 8 | - '**' 9 | 10 | jobs: 11 | native: 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | # Disabling the linux build for now 16 | # os: [ubuntu-latest, macos-latest, windows-latest] 17 | os: [macos-latest, windows-latest] 18 | runs-on: ${{ matrix.os }} 19 | steps: 20 | - uses: actions/checkout@v3 21 | with: 22 | submodules: true 23 | 24 | - uses: actions/setup-node@v3 25 | with: 26 | node-version: 18 27 | 28 | - name: JUCE Linux Dependencies 29 | shell: bash 30 | if: ${{ matrix.os == 'ubuntu-latest' }} 31 | run: | 32 | sudo apt-get update 33 | sudo apt-get install -y freeglut3-dev 34 | sudo apt-get install -y g++ 35 | sudo apt-get install -y libasound2-dev 36 | sudo apt-get install -y libcurl4-openssl-dev 37 | sudo apt-get install -y libfreetype6-dev 38 | sudo apt-get install -y libjack-jackd2-dev 39 | sudo apt-get install -y libx11-dev 40 | sudo apt-get install -y libxcomposite-dev 41 | sudo apt-get install -y libxcursor-dev 42 | sudo apt-get install -y libxinerama-dev 43 | sudo apt-get install -y libxrandr-dev 44 | sudo apt-get install -y mesa-common-dev 45 | 46 | - name: Build 47 | shell: bash 48 | run: | 49 | set -x 50 | set -e 51 | 52 | npm install 53 | npm run build 54 | 55 | - name: Artifact naming 56 | shell: bash 57 | run: | 58 | echo "ARTIFACT_DATESTRING=$(date +'%Y-%m-%d')" >> $GITHUB_ENV 59 | echo "ARTIFACT_OS=$(echo $RUNNER_OS | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV 60 | 61 | - uses: actions/upload-artifact@v3 62 | with: 63 | name: srvb-${{ env.ARTIFACT_OS }}-${{ env.ARTIFACT_DATESTRING }} 64 | path: | 65 | native/build/scripted/SRVB_artefacts/Release/VST3/ 66 | native/build/scripted/SRVB_artefacts/Release/AU/ 67 | !native/build/scripted/SRVB_artefacts/Release/VST3/*.lib 68 | !native/build/scripted/SRVB_artefacts/Release/VST3/*.exp 69 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | # Builds 11 | node_modules 12 | build 13 | dist 14 | *.local 15 | public/dsp.main.js 16 | 17 | # Editor directories and files 18 | .vscode 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | *.swp 27 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "elementary"] 2 | path = native/elementary 3 | url = https://github.com/elemaudio/elementary.git 4 | [submodule "juce"] 5 | path = native/juce 6 | url = https://github.com/juce-framework/JUCE.git 7 | [submodule "choc"] 8 | path = native/choc 9 | url = https://github.com/nick-thompson/choc.git 10 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Nick Thompson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SRVB 2 | 3 | SRVB is a small digital reverb audio plugin (VST3/AU) for MacOS and Windows. 4 | 5 | This project demonstrates one way to write an audio plugin using JavaScript and 6 | familiar web technologies, and while there are several variants on this approach, 7 | it is meant to be both a compelling example and a good starting point for audio 8 | plugins made with a similar architecture. 9 | 10 | ## Overview 11 | 12 | The software architecture in this plugin is much like [Tauri](https://tauri.app/) and similar to 13 | [Electron](https://www.electronjs.org/). The user interface is a simple Vite, React, and Tailwind app 14 | at the root of the repository, which is packaged into the plugin app bundle and loaded into a native 15 | webview instance owned by the plugin at runtime. 16 | 17 | The audio processing algorithm in the `dsp/` directory is also written in 18 | JavaScript using [Elementary](https://elementary.audio), and is run in a separate 19 | engine which directs the underlying native plugin audio processing. The native 20 | plugin itself provides the harness for these two frontend JavaScript bundles, 21 | and interfaces with the plugin host (typically a DAW) to coordinate the user 22 | interface and the audio processing loop. 23 | 24 | ## Elementary 25 | 26 | If you're new to Elementary Audio, [Elementary](https://elementary.audio) is a JavaScript/C++ library for building audio applications. 27 | 28 | * **Declarative:** Elementary makes it simple to create interactive audio processes through functional, declarative programming. Describe your audio process as a function of your application state, and Elementary will efficiently update the underlying audio engine as necessary. 29 | * **Dynamic:** Most audio processing frameworks and tools facilitate building static processes. But what happens as your audio requirements change throughout the user journey? Elementary is designed to facilitate and adapt to the dynamic nature of modern audio applications. 30 | * **Portable:** By decoupling the JavaScript API from the underlying audio engine (the "what" from the "how"), Elementary enables writing portable applications. Whether the underlying engine is running in the browser, an audio plugin, or an embedded device, the JavaScript layer remains the same. 31 | 32 | Find more in the [Elementary repository on GitHub](https://github.com/elemaudio/elementary) and the documentation [on the website](https://elementary.audio/). 33 | 34 | ## Getting Started 35 | 36 | ### Dependencies 37 | 38 | Before running the following steps, please make sure you have the following dependencies installed and 39 | available at the command line: 40 | 41 | * [CMake](https://cmake.org/) 42 | * [Node.js](https://nodejs.org/en) 43 | * Bash: the build steps below expect to run scripts in a Bash environment. For Windows machines, consider running the following steps in a Git Bash environment, or with WSL. 44 | 45 | Next, we fetch the SRVB project and its dependencies, 46 | 47 | ```bash 48 | # Clone the project with its submodules 49 | git clone --recurse-submodules https://github.com/elemaudio/srvb.git 50 | cd srvb 51 | 52 | # Install npm dependencies 53 | npm install 54 | ``` 55 | 56 | ### Develop 57 | ```bash 58 | npm run dev 59 | ``` 60 | 61 | In develop mode, the native plugin is compiled to fetch its JavaScript assets from localhost, where subsequently we 62 | run the Vite dev server to serve those assets. This arrangement enables Vite's hot reloading behavior for developing 63 | the plugin while it's running inside a host. 64 | 65 | ### Release 66 | ```bash 67 | npm run build 68 | ``` 69 | 70 | In release builds, the JavaScript bundles are packaged into the plugin app bundle so that the resulting bundle 71 | is relocatable, thereby enabling distribution to end users. 72 | 73 | ### Troubleshooting 74 | 75 | * After a successful build with either `npm run dev` or `npm run build`, you 76 | should have local plugin binaries built and copied into the correct 77 | audio plugin directories on your machine. If you don't see them, look in 78 | `./native/build/scripted/SRVB_artefacts` and copy them manually 79 | * **Note**: the CMake build on Windows attempts to copy the VST3 plugin binary 80 | into `C:\Program Files`, a step that requires admin permissions. Therefore 81 | you should either run your build as an admin, or disable the copy plugin step 82 | in `native/CMakeLists.txt` and manually copy the plugin binary after build. 83 | * **Note**: especially on MacOS, certain plugin hosts such as Ableton Live have 84 | strict security settings that prevent them from recognizing local unsigned 85 | binaries. You'll want to either add a codesign step to your build, or 86 | configure the security settings of your host to address this. 87 | 88 | ## License 89 | 90 | [MIT](./LICENSE.md) 91 | 92 | This project also uses [JUCE](https://juce.com/), which is licensed GPLv3. Please consult JUCE's license 93 | agreement if you intend to distribute your own plugin based on this template. 94 | -------------------------------------------------------------------------------- /dsp/RefMap.js: -------------------------------------------------------------------------------- 1 | import invariant from 'invariant'; 2 | 3 | 4 | export class RefMap { 5 | constructor(core) { 6 | this._map = new Map(); 7 | this._core = core; 8 | } 9 | 10 | getOrCreate(name, type, props, children) { 11 | if (!this._map.has(name)) { 12 | let ref = this._core.createRef(type, props, children); 13 | this._map.set(name, ref); 14 | } 15 | 16 | return this._map.get(name)[0]; 17 | } 18 | 19 | update(name, props) { 20 | invariant(this._map.has(name), "Oops, trying to update a ref that doesn't exist"); 21 | 22 | let [node, setter] = this._map.get(name); 23 | setter(props); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /dsp/main.js: -------------------------------------------------------------------------------- 1 | import {Renderer, el} from '@elemaudio/core'; 2 | import {RefMap} from './RefMap'; 3 | import srvb from './srvb'; 4 | 5 | 6 | // This project demonstrates writing a small FDN reverb effect in Elementary. 7 | // 8 | // First, we initialize a custom Renderer instance that marshals our instruction 9 | // batches through the __postNativeMessage__ function to direct the underlying native 10 | // engine. 11 | let core = new Renderer((batch) => { 12 | __postNativeMessage__(JSON.stringify(batch)); 13 | }); 14 | 15 | // Next, a RefMap for coordinating our refs 16 | let refs = new RefMap(core); 17 | 18 | // Holding onto the previous state allows us a quick way to differentiate 19 | // when we need to fully re-render versus when we can just update refs 20 | let prevState = null; 21 | 22 | function shouldRender(prevState, nextState) { 23 | return (prevState === null) || (prevState.sampleRate !== nextState.sampleRate); 24 | } 25 | 26 | // The important piece: here we register a state change callback with the native 27 | // side. This callback will be hit with the current processor state any time that 28 | // state changes. 29 | // 30 | // Given the new state, we simply update our refs or perform a full render depending 31 | // on the result of our `shouldRender` check. 32 | globalThis.__receiveStateChange__ = (serializedState) => { 33 | const state = JSON.parse(serializedState); 34 | 35 | if (shouldRender(prevState, state)) { 36 | let stats = core.render(...srvb({ 37 | key: 'srvb', 38 | sampleRate: state.sampleRate, 39 | size: refs.getOrCreate('size', 'const', {value: state.size}, []), 40 | decay: refs.getOrCreate('decay', 'const', {value: state.decay}, []), 41 | mod: refs.getOrCreate('mod', 'const', {value: state.mod}, []), 42 | mix: refs.getOrCreate('mix', 'const', {value: state.mix}, []), 43 | }, el.in({channel: 0}), el.in({channel: 1}))); 44 | 45 | console.log(stats); 46 | } else { 47 | console.log('Updating refs'); 48 | refs.update('size', {value: state.size}); 49 | refs.update('decay', {value: state.decay}); 50 | refs.update('mod', {value: state.mod}); 51 | refs.update('mix', {value: state.mix}); 52 | } 53 | 54 | prevState = state; 55 | }; 56 | 57 | // NOTE: This is highly experimental and should not yet be relied on 58 | // as a consistent feature. 59 | // 60 | // This hook allows the native side to inject serialized graph state from 61 | // the running elem::Runtime instance so that we can throw away and reinitialize 62 | // the JavaScript engine and then inject necessary state for coordinating with 63 | // the underlying engine. 64 | globalThis.__receiveHydrationData__ = (data) => { 65 | const payload = JSON.parse(data); 66 | const nodeMap = core._delegate.nodeMap; 67 | 68 | for (let [k, v] of Object.entries(payload)) { 69 | nodeMap.set(parseInt(k, 16), { 70 | symbol: '__ELEM_NODE__', 71 | kind: '__HYDRATED__', 72 | hash: parseInt(k, 16), 73 | props: v, 74 | generation: { 75 | current: 0, 76 | }, 77 | }); 78 | } 79 | }; 80 | 81 | // Finally, an error callback which just logs back to native 82 | globalThis.__receiveError__ = (err) => { 83 | console.log(`[Error: ${err.name}] ${err.message}`); 84 | }; 85 | -------------------------------------------------------------------------------- /dsp/srvb.js: -------------------------------------------------------------------------------- 1 | import invariant from 'invariant'; 2 | import {el} from '@elemaudio/core'; 3 | 4 | 5 | // A size 8 Hadamard matrix constructed using Numpy and Scipy. 6 | // 7 | // The Hadamard matrix satisfies the property H*H^T = nI, where n is the size 8 | // of the matrix, I the identity, and H^T the transpose of H. Therefore, we have 9 | // orthogonality and stability in the feedback path if we scale according to (1 / n) 10 | // along the diagonal, which we do internally by multiplying each matrix element 11 | // by Math.sqrt(1 / n), which yields the identity as above. 12 | // 13 | // @see https://docs.scipy.org/doc/scipy-0.14.0/reference/generated/scipy.linalg.hadamard.html 14 | // @see https://nhigham.com/2020/04/10/what-is-a-hadamard-matrix/ 15 | const H8 = [[ 1, 1, 1, 1, 1, 1, 1, 1], 16 | [ 1, -1, 1, -1, 1, -1, 1, -1], 17 | [ 1, 1, -1, -1, 1, 1, -1, -1], 18 | [ 1, -1, -1, 1, 1, -1, -1, 1], 19 | [ 1, 1, 1, 1, -1, -1, -1, -1], 20 | [ 1, -1, 1, -1, -1, 1, -1, 1], 21 | [ 1, 1, -1, -1, -1, -1, 1, 1], 22 | [ 1, -1, -1, 1, -1, 1, 1, -1]]; 23 | 24 | // A diffusion step expecting exactly 8 input channels with 25 | // a maximum diffusion time of 500ms 26 | function diffuse(size, ...ins) { 27 | const len = ins.length; 28 | const scale = Math.sqrt(1 / len); 29 | 30 | invariant(len === 8, "Invalid diffusion step!"); 31 | invariant(typeof size === 'number', "Diffusion step size must be a number"); 32 | 33 | const dels = ins.map(function(input, i) { 34 | const lineSize = size * ((i + 1) / len); 35 | return el.sdelay({size: lineSize}, input); 36 | }); 37 | 38 | return H8.map(function(row, i) { 39 | return el.add(...row.map(function(col, j) { 40 | return el.mul(col * scale, dels[j]); 41 | })); 42 | }); 43 | } 44 | 45 | // An eight channel feedback delay network with a one-pole lowpass filter in 46 | // the feedback loop for damping the high frequencies faster than the low. 47 | // 48 | // @param {string} name for the tap structures 49 | // @param {el.const} size in the range [0, 1] 50 | // @param {el.const} decay in the range [0, 1] 51 | // @param {el.const} modDepth in the range [0, 1] 52 | // @param {...core.Node} ...ins eight input channels 53 | function dampFDN(name, sampleRate, size, decay, modDepth, ...ins) { 54 | const len = ins.length; 55 | const scale = Math.sqrt(1 / len); 56 | const md = el.mul(modDepth, 0.02); 57 | 58 | if (len !== 8) 59 | throw new Error("Invalid FDN step!"); 60 | 61 | // The unity-gain one pole lowpass here is tuned to taste along 62 | // the range [0.001, 0.5]. Towards the top of the range, we get into the region 63 | // of killing the decay time too quickly. Towards the bottom, not much damping. 64 | const dels = ins.map(function(input, i) { 65 | return el.add( 66 | input, 67 | el.mul( 68 | decay, 69 | el.smooth( 70 | 0.105, 71 | el.tapIn({name: `${name}:fdn${i}`}), 72 | ), 73 | ), 74 | ); 75 | }); 76 | 77 | let mix = H8.map(function(row, i) { 78 | return el.add(...row.map(function(col, j) { 79 | return el.mul(col * scale, dels[j]); 80 | })); 81 | }); 82 | 83 | return mix.map(function(mm, i) { 84 | const modulate = (x, rate, amt) => el.add(x, el.mul(amt, el.cycle(rate))); 85 | const ms2samps = (ms) => sampleRate * (ms / 1000.0); 86 | 87 | // Each delay line here will be ((i + 1) * 17)ms long, multiplied by [1, 4] 88 | // depending on the size parameter. So at size = 0, delay lines are 17, 34, 51, ..., 89 | // and at size = 1 we have 68, 136, ..., all in ms here. 90 | const delaySize = el.mul(el.add(1.00, el.mul(3, size)), ms2samps((i + 1) * 17)); 91 | 92 | // Then we modulate the read position for each tap to add some chorus in the 93 | // delay network. 94 | const readPos = modulate(delaySize, el.add(0.1, el.mul(i, md)), ms2samps(2.5)); 95 | 96 | return el.tapOut( 97 | {name: `${name}:fdn${i}`}, 98 | el.delay( 99 | {size: ms2samps(750)}, 100 | readPos, 101 | 0, 102 | mm 103 | ), 104 | ); 105 | }); 106 | } 107 | 108 | // Our main stereo reverb. 109 | // 110 | // Upmixes the stereo input into an 8-channel diffusion network and 111 | // feedback delay network. Must supply a `key` prop to uniquely identify the 112 | // feedback taps in here. 113 | // 114 | // @param {object} props 115 | // @param {number} props.size in [0, 1] 116 | // @param {number} props.decay in [0, 1] 117 | // @param {number} props.mod in [0, 1] 118 | // @param {number} props.mix in [0, 1] 119 | // @param {core.Node} xl input 120 | // @param {core.Node} xr input 121 | export default function srvb(props, xl, xr) { 122 | invariant(typeof props === 'object', 'Unexpected props object'); 123 | 124 | const key = props.key; 125 | const sampleRate = props.sampleRate; 126 | const size = el.sm(props.size); 127 | const decay = el.sm(props.decay); 128 | const modDepth = el.sm(props.mod); 129 | const mix = el.sm(props.mix); 130 | 131 | // Upmix to eight channels 132 | const mid = el.mul(0.5, el.add(xl, xr)); 133 | const side = el.mul(0.5, el.sub(xl, xr)); 134 | const four = [xl, xr, mid, side]; 135 | const eight = [...four, ...four.map(x => el.mul(-1, x))]; 136 | 137 | // Diffusion 138 | const ms2samps = (ms) => sampleRate * (ms / 1000.0); 139 | 140 | const d1 = diffuse(ms2samps(43), ...eight); 141 | const d2 = diffuse(ms2samps(97), ...d1); 142 | const d3 = diffuse(ms2samps(117), ...d2); 143 | 144 | // Reverb network 145 | const d4 = dampFDN(`${key}:d4`, sampleRate, size, 0.004, modDepth, ...d3) 146 | const r0 = dampFDN(`${key}:r0`, sampleRate, size, decay, modDepth, ...d4); 147 | 148 | // Downmix 149 | // 150 | // It's important here to interleave the output channels because the way that 151 | // the multi-channel delay lines are written above tends to correlate the delay 152 | // length with the current index in the 8-channel array. That means the smaller 153 | // the index, the shorter the delay line. The mix matrix will mostly address this, 154 | // but if you sum index 0-3 into the left and 4-7 into the right you can definitely 155 | // hear the energy in the left channel build before the energy in the right. 156 | const yl = el.mul(0.25, el.add(r0[0], r0[2], r0[4], r0[6])); 157 | const yr = el.mul(0.25, el.add(r0[1], r0[3], r0[5], r0[7])); 158 | 159 | // Wet dry mixing 160 | return [ 161 | el.select(mix, yl, xl), 162 | el.select(mix, yr, xr), 163 | ]; 164 | } 165 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Elementary Effects Plugin 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /native/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.15) 2 | project(Elem_SRVB VERSION 0.1.0) 3 | 4 | 5 | set(TARGET_NAME SRVB) 6 | set(ASSETS_DIR ${CMAKE_SOURCE_DIR}/../dist/) 7 | 8 | option(JUCE_ENABLE_MODULE_SOURCE_GROUPS "Enable Module Source Groups" ON) 9 | option(JUCE_BUILD_EXTRAS "Build JUCE Extras" OFF) 10 | option(ELEM_DEV_LOCALHOST "Run against localhost for static assets" OFF) 11 | 12 | add_subdirectory(juce) 13 | add_subdirectory(elementary/runtime) 14 | 15 | juce_add_plugin(${TARGET_NAME} 16 | BUNDLE_ID "audio.elementary.srvb" 17 | COMPANY_NAME "Elementary Audio" 18 | COMPANY_WEBSITE "https://www.elementary.audio" 19 | COMPANY_EMAIL "nick@elementary.audio" 20 | PLUGIN_MANUFACTURER_CODE Elem # A four-character manufacturer id with at least one upper-case character 21 | PLUGIN_CODE Srvb # A unique four-character plugin id with at least one upper-case character 22 | COPY_PLUGIN_AFTER_BUILD FALSE # We enable this manually below after adding a copy step 23 | APP_SANDBOX_ENABLED TRUE 24 | APP_SANDBOX_OPTIONS com.apple.security.network.client com.apple.security.files.user-selected.read-write 25 | FORMATS AU VST3 # The formats to build. Other valid formats are: AAX Unity VST AU AUv3 26 | PRODUCT_NAME ${TARGET_NAME}) # The name of the final executable, which can differ from the target name 27 | 28 | # Copy static assets post build 29 | if (NOT ELEM_DEV_LOCALHOST) 30 | get_target_property(ACTIVE_TARGETS ${TARGET_NAME} JUCE_ACTIVE_PLUGIN_TARGETS) 31 | foreach(ACTIVE_TARGET IN LISTS ACTIVE_TARGETS) 32 | message(STATUS "Adding resource copy step from ${ASSETS_DIR} for ${ACTIVE_TARGET}") 33 | 34 | get_target_property(ARTIFACT_FILE ${ACTIVE_TARGET} JUCE_PLUGIN_ARTEFACT_FILE) 35 | set(RESOURCE_DIR "${ARTIFACT_FILE}/Contents/Resources/") 36 | 37 | add_custom_command(TARGET ${ACTIVE_TARGET} POST_BUILD 38 | COMMAND ${CMAKE_COMMAND} -E rm -rf "${RESOURCE_DIR}/dist" 39 | COMMAND ${CMAKE_COMMAND} -E make_directory "${RESOURCE_DIR}/dist" 40 | COMMAND ${CMAKE_COMMAND} -E copy_directory "${ASSETS_DIR}" "${RESOURCE_DIR}/dist" 41 | VERBATIM) 42 | endforeach() 43 | endif() 44 | 45 | # Enable copy step 46 | if (NOT DEFINED ENV{CI}) 47 | juce_enable_copy_plugin_step(${TARGET_NAME}) 48 | endif() 49 | 50 | target_sources(${TARGET_NAME} 51 | PRIVATE 52 | PluginProcessor.cpp 53 | WebViewEditor.cpp) 54 | 55 | target_include_directories(${TARGET_NAME} 56 | PRIVATE 57 | ${CMAKE_CURRENT_SOURCE_DIR}/choc/gui 58 | ${CMAKE_CURRENT_SOURCE_DIR}/choc/javascript) 59 | 60 | target_compile_features(${TARGET_NAME} 61 | PRIVATE 62 | cxx_std_17) 63 | 64 | target_compile_definitions(${TARGET_NAME} 65 | PRIVATE 66 | ELEM_DEV_LOCALHOST=${ELEM_DEV_LOCALHOST} 67 | JUCE_VST3_CAN_REPLACE_VST2=0 68 | JUCE_USE_CURL=0) 69 | 70 | target_link_libraries(${TARGET_NAME} 71 | PRIVATE 72 | juce::juce_audio_basics 73 | juce::juce_audio_devices 74 | juce::juce_audio_plugin_client 75 | juce::juce_audio_processors 76 | juce::juce_audio_utils 77 | juce::juce_core 78 | juce::juce_data_structures 79 | juce::juce_dsp 80 | juce::juce_events 81 | juce::juce_graphics 82 | juce::juce_gui_basics 83 | juce::juce_gui_extra 84 | runtime) 85 | -------------------------------------------------------------------------------- /native/PluginProcessor.cpp: -------------------------------------------------------------------------------- 1 | #include "PluginProcessor.h" 2 | #include "WebViewEditor.h" 3 | 4 | #include 5 | 6 | 7 | //============================================================================== 8 | // A quick helper for locating bundled asset files 9 | juce::File getAssetsDirectory() 10 | { 11 | #if JUCE_MAC 12 | auto assetsDir = juce::File::getSpecialLocation(juce::File::SpecialLocationType::currentApplicationFile) 13 | .getChildFile("Contents/Resources/dist"); 14 | #elif JUCE_WINDOWS 15 | auto assetsDir = juce::File::getSpecialLocation(juce::File::SpecialLocationType::currentExecutableFile) // Plugin.vst3/Contents//Plugin.vst3 16 | .getParentDirectory() // Plugin.vst3/Contents// 17 | .getParentDirectory() // Plugin.vst3/Contents/ 18 | .getChildFile("Resources/dist"); 19 | #else 20 | #error "We only support Mac and Windows here yet." 21 | #endif 22 | 23 | return assetsDir; 24 | } 25 | 26 | //============================================================================== 27 | EffectsPluginProcessor::EffectsPluginProcessor() 28 | : AudioProcessor (BusesProperties() 29 | .withInput ("Input", juce::AudioChannelSet::stereo(), true) 30 | .withOutput ("Output", juce::AudioChannelSet::stereo(), true)) 31 | , jsContext(choc::javascript::createQuickJSContext()) 32 | { 33 | // Initialize parameters from the manifest file 34 | #if ELEM_DEV_LOCALHOST 35 | auto manifestFile = juce::URL("http://localhost:5173/manifest.json"); 36 | auto manifestFileContents = manifestFile.readEntireTextStream().toStdString(); 37 | #else 38 | auto manifestFile = getAssetsDirectory().getChildFile("manifest.json"); 39 | 40 | if (!manifestFile.existsAsFile()) 41 | return; 42 | 43 | auto manifestFileContents = manifestFile.loadFileAsString().toStdString(); 44 | #endif 45 | 46 | auto manifest = elem::js::parseJSON(manifestFileContents); 47 | 48 | if (!manifest.isObject()) 49 | return; 50 | 51 | auto parameters = manifest.getWithDefault("parameters", elem::js::Array()); 52 | 53 | for (size_t i = 0; i < parameters.size(); ++i) { 54 | auto descrip = parameters[i]; 55 | 56 | if (!descrip.isObject()) 57 | continue; 58 | 59 | auto paramId = descrip.getWithDefault("paramId", elem::js::String("unknown")); 60 | auto name = descrip.getWithDefault("name", elem::js::String("Unknown")); 61 | auto minValue = descrip.getWithDefault("min", elem::js::Number(0)); 62 | auto maxValue = descrip.getWithDefault("max", elem::js::Number(1)); 63 | auto defValue = descrip.getWithDefault("defaultValue", elem::js::Number(0)); 64 | 65 | auto* p = new juce::AudioParameterFloat( 66 | juce::ParameterID(paramId, 1), 67 | name, 68 | {static_cast(minValue), static_cast(maxValue)}, 69 | defValue 70 | ); 71 | 72 | p->addListener(this); 73 | addParameter(p); 74 | 75 | // Push a new ParameterReadout onto the list to represent this parameter 76 | paramReadouts.emplace_back(ParameterReadout { static_cast(defValue), false }); 77 | 78 | // Update our state object with the default parameter value 79 | state.insert_or_assign(paramId, defValue); 80 | } 81 | } 82 | 83 | EffectsPluginProcessor::~EffectsPluginProcessor() 84 | { 85 | for (auto& p : getParameters()) 86 | { 87 | p->removeListener(this); 88 | } 89 | } 90 | 91 | //============================================================================== 92 | juce::AudioProcessorEditor* EffectsPluginProcessor::createEditor() 93 | { 94 | return new WebViewEditor(this, getAssetsDirectory(), 800, 704); 95 | } 96 | 97 | bool EffectsPluginProcessor::hasEditor() const 98 | { 99 | return true; 100 | } 101 | 102 | //============================================================================== 103 | const juce::String EffectsPluginProcessor::getName() const 104 | { 105 | return JucePlugin_Name; 106 | } 107 | 108 | bool EffectsPluginProcessor::acceptsMidi() const 109 | { 110 | return false; 111 | } 112 | 113 | bool EffectsPluginProcessor::producesMidi() const 114 | { 115 | return false; 116 | } 117 | 118 | bool EffectsPluginProcessor::isMidiEffect() const 119 | { 120 | return false; 121 | } 122 | 123 | double EffectsPluginProcessor::getTailLengthSeconds() const 124 | { 125 | return 0.0; 126 | } 127 | 128 | //============================================================================== 129 | int EffectsPluginProcessor::getNumPrograms() 130 | { 131 | return 1; // NB: some hosts don't cope very well if you tell them there are 0 programs, 132 | // so this should be at least 1, even if you're not really implementing programs. 133 | } 134 | 135 | int EffectsPluginProcessor::getCurrentProgram() 136 | { 137 | return 0; 138 | } 139 | 140 | void EffectsPluginProcessor::setCurrentProgram (int /* index */) {} 141 | const juce::String EffectsPluginProcessor::getProgramName (int /* index */) { return {}; } 142 | void EffectsPluginProcessor::changeProgramName (int /* index */, const juce::String& /* newName */) {} 143 | 144 | //============================================================================== 145 | void EffectsPluginProcessor::prepareToPlay (double sampleRate, int samplesPerBlock) 146 | { 147 | // Some hosts call `prepareToPlay` on the real-time thread, some call it on the main thread. 148 | // To address the discrepancy, we check whether anything has changed since our last known 149 | // call. If it has, we flag for initialization of the Elementary engine and runtime, then 150 | // trigger an async update. 151 | // 152 | // JUCE will synchronously handle the async update if it understands 153 | // that we're already on the main thread. 154 | if (sampleRate != lastKnownSampleRate || samplesPerBlock != lastKnownBlockSize) { 155 | lastKnownSampleRate = sampleRate; 156 | lastKnownBlockSize = samplesPerBlock; 157 | 158 | shouldInitialize.store(true); 159 | } 160 | 161 | // Now that the environment is set up, push our current state 162 | triggerAsyncUpdate(); 163 | } 164 | 165 | void EffectsPluginProcessor::releaseResources() 166 | { 167 | // When playback stops, you can use this as an opportunity to free up any 168 | // spare memory, etc. 169 | } 170 | 171 | bool EffectsPluginProcessor::isBusesLayoutSupported (const AudioProcessor::BusesLayout& layouts) const 172 | { 173 | return true; 174 | } 175 | 176 | void EffectsPluginProcessor::processBlock (juce::AudioBuffer& buffer, juce::MidiBuffer& /* midiMessages */) 177 | { 178 | // Copy the input so that our input and output buffers are distinct 179 | scratchBuffer.makeCopyOf(buffer, true); 180 | 181 | // Clear the output buffer to prevent any garbage if our runtime isn't ready 182 | buffer.clear(); 183 | 184 | // Process the elementary runtime 185 | if (runtime != nullptr) { 186 | runtime->process( 187 | const_cast(scratchBuffer.getArrayOfWritePointers()), 188 | getTotalNumInputChannels(), 189 | const_cast(buffer.getArrayOfWritePointers()), 190 | buffer.getNumChannels(), 191 | buffer.getNumSamples(), 192 | nullptr 193 | ); 194 | } 195 | } 196 | 197 | void EffectsPluginProcessor::parameterValueChanged (int parameterIndex, float newValue) 198 | { 199 | // Mark the updated parameter value in the dirty list 200 | auto& pr = *std::next(paramReadouts.begin(), parameterIndex); 201 | 202 | pr.store({ newValue, true }); 203 | triggerAsyncUpdate(); 204 | } 205 | 206 | void EffectsPluginProcessor::parameterGestureChanged (int, bool) 207 | { 208 | // Not implemented 209 | } 210 | 211 | //============================================================================== 212 | void EffectsPluginProcessor::handleAsyncUpdate() 213 | { 214 | // First things first, we check the flag to identify if we should initialize the Elementary 215 | // runtime and engine. 216 | if (shouldInitialize.exchange(false)) { 217 | // TODO: This is definitely not thread-safe! It could delete a Runtime instance while 218 | // the real-time thread is using it. Depends on when the host will call prepareToPlay. 219 | runtime = std::make_unique>(lastKnownSampleRate, lastKnownBlockSize); 220 | initJavaScriptEngine(); 221 | } 222 | 223 | // Next we iterate over the current parameter values to update our local state 224 | // object, which we in turn dispatch into the JavaScript engine 225 | auto& params = getParameters(); 226 | 227 | // Reduce over the changed parameters to resolve our updated processor state 228 | for (size_t i = 0; i < paramReadouts.size(); ++i) 229 | { 230 | // We atomically exchange an arbitrary value with a dirty flag false, because 231 | // we know that the next time we exchange, if the dirty flag is still false, the 232 | // value can be considered arbitrary. Only when we exchange and find the dirty flag 233 | // true do we consider the value as having been written by the processor since 234 | // we last looked. 235 | auto& current = *std::next(paramReadouts.begin(), i); 236 | auto pr = current.exchange({0.0f, false}); 237 | 238 | if (pr.dirty) 239 | { 240 | if (auto* pf = dynamic_cast(params[i])) { 241 | auto paramId = pf->paramID.toStdString(); 242 | state.insert_or_assign(paramId, elem::js::Number(pr.value)); 243 | } 244 | } 245 | } 246 | 247 | dispatchStateChange(); 248 | } 249 | 250 | void EffectsPluginProcessor::initJavaScriptEngine() 251 | { 252 | jsContext = choc::javascript::createQuickJSContext(); 253 | 254 | // Install some native interop functions in our JavaScript environment 255 | jsContext.registerFunction("__postNativeMessage__", [this](choc::javascript::ArgumentList args) { 256 | auto const batch = elem::js::parseJSON(args[0]->toString()); 257 | auto const rc = runtime->applyInstructions(batch); 258 | 259 | if (rc != elem::ReturnCode::Ok()) { 260 | dispatchError("Runtime Error", elem::ReturnCode::describe(rc)); 261 | } 262 | 263 | return choc::value::Value(); 264 | }); 265 | 266 | jsContext.registerFunction("__log__", [this](choc::javascript::ArgumentList args) { 267 | const auto* kDispatchScript = R"script( 268 | (function() { 269 | console.log(...JSON.parse(%)); 270 | return true; 271 | })(); 272 | )script"; 273 | 274 | // Forward logs to the editor if it's available; then logs show up in one place. 275 | // 276 | // If not available, we fall back to std out. 277 | if (auto* editor = static_cast(getActiveEditor())) { 278 | auto v = choc::value::createEmptyArray(); 279 | 280 | for (size_t i = 0; i < args.numArgs; ++i) { 281 | v.addArrayElement(*args[i]); 282 | } 283 | 284 | auto expr = juce::String(kDispatchScript).replace("%", elem::js::serialize(choc::json::toString(v))).toStdString(); 285 | editor->getWebViewPtr()->evaluateJavascript(expr); 286 | } else { 287 | for (size_t i = 0; i < args.numArgs; ++i) { 288 | DBG(choc::json::toString(*args[i])); 289 | } 290 | } 291 | 292 | return choc::value::Value(); 293 | }); 294 | 295 | // A simple shim to write various console operations to our native __log__ handler 296 | jsContext.evaluate(R"shim( 297 | (function() { 298 | if (typeof globalThis.console === 'undefined') { 299 | globalThis.console = { 300 | log(...args) { 301 | __log__('[embedded:log]', ...args); 302 | }, 303 | warn(...args) { 304 | __log__('[embedded:warn]', ...args); 305 | }, 306 | error(...args) { 307 | __log__('[embedded:error]', ...args); 308 | } 309 | }; 310 | } 311 | })(); 312 | )shim"); 313 | 314 | // Load and evaluate our Elementary js main file 315 | #if ELEM_DEV_LOCALHOST 316 | auto dspEntryFile = juce::URL("http://localhost:5173/dsp.main.js"); 317 | auto dspEntryFileContents = dspEntryFile.readEntireTextStream().toStdString(); 318 | #else 319 | auto dspEntryFile = getAssetsDirectory().getChildFile("dsp.main.js"); 320 | 321 | if (!dspEntryFile.existsAsFile()) 322 | return; 323 | 324 | auto dspEntryFileContents = dspEntryFile.loadFileAsString().toStdString(); 325 | #endif 326 | jsContext.evaluate(dspEntryFileContents); 327 | 328 | // Re-hydrate from current state 329 | const auto* kHydrateScript = R"script( 330 | (function() { 331 | if (typeof globalThis.__receiveHydrationData__ !== 'function') 332 | return false; 333 | 334 | globalThis.__receiveHydrationData__(%); 335 | return true; 336 | })(); 337 | )script"; 338 | 339 | auto expr = juce::String(kHydrateScript).replace("%", elem::js::serialize(elem::js::serialize(runtime->snapshot()))).toStdString(); 340 | jsContext.evaluate(expr); 341 | } 342 | 343 | void EffectsPluginProcessor::dispatchStateChange() 344 | { 345 | const auto* kDispatchScript = R"script( 346 | (function() { 347 | if (typeof globalThis.__receiveStateChange__ !== 'function') 348 | return false; 349 | 350 | globalThis.__receiveStateChange__(%); 351 | return true; 352 | })(); 353 | )script"; 354 | 355 | // Need the double serialize here to correctly form the string script. The first 356 | // serialize produces the payload we want, the second serialize ensures we can replace 357 | // the % character in the above block and produce a valid javascript expression. 358 | auto localState = state; 359 | localState.insert_or_assign("sampleRate", lastKnownSampleRate); 360 | 361 | auto expr = juce::String(kDispatchScript).replace("%", elem::js::serialize(elem::js::serialize(localState))).toStdString(); 362 | 363 | // First we try to dispatch to the UI if it's available, because running this step will 364 | // just involve placing a message in a queue. 365 | if (auto* editor = static_cast(getActiveEditor())) { 366 | editor->getWebViewPtr()->evaluateJavascript(expr); 367 | } 368 | 369 | // Next we dispatch to the local engine which will evaluate any necessary JavaScript synchronously 370 | // here on the main thread 371 | jsContext.evaluate(expr); 372 | } 373 | 374 | void EffectsPluginProcessor::dispatchError(std::string const& name, std::string const& message) 375 | { 376 | const auto* kDispatchScript = R"script( 377 | (function() { 378 | if (typeof globalThis.__receiveError__ !== 'function') 379 | return false; 380 | 381 | let e = new Error(%); 382 | e.name = @; 383 | 384 | globalThis.__receiveError__(e); 385 | return true; 386 | })(); 387 | )script"; 388 | 389 | // Need the serialize here to correctly form the string script. 390 | auto expr = juce::String(kDispatchScript).replace("@", elem::js::serialize(name)).replace("%", elem::js::serialize(message)).toStdString(); 391 | 392 | // First we try to dispatch to the UI if it's available, because running this step will 393 | // just involve placing a message in a queue. 394 | if (auto* editor = static_cast(getActiveEditor())) { 395 | editor->getWebViewPtr()->evaluateJavascript(expr); 396 | } 397 | 398 | // Next we dispatch to the local engine which will evaluate any necessary JavaScript synchronously 399 | // here on the main thread 400 | jsContext.evaluate(expr); 401 | } 402 | 403 | //============================================================================== 404 | void EffectsPluginProcessor::getStateInformation (juce::MemoryBlock& destData) 405 | { 406 | auto serialized = elem::js::serialize(state); 407 | destData.replaceAll((void *) serialized.c_str(), serialized.size()); 408 | } 409 | 410 | void EffectsPluginProcessor::setStateInformation (const void* data, int sizeInBytes) 411 | { 412 | try { 413 | auto str = std::string(static_cast(data), sizeInBytes); 414 | auto parsed = elem::js::parseJSON(str); 415 | auto o = parsed.getObject(); 416 | for (auto &i: o) { 417 | std::map::iterator it; 418 | it = state.find(i.first); 419 | if (it != state.end()) { 420 | state.insert_or_assign(i.first, i.second); 421 | } 422 | } 423 | } catch(...) { 424 | // Failed to parse the incoming state, or the state we did parse was not actually 425 | // an object type. How you handle it is up to you, here we just ignore it 426 | } 427 | } 428 | 429 | //============================================================================== 430 | // This creates new instances of the plugin.. 431 | juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter() 432 | { 433 | return new EffectsPluginProcessor(); 434 | } 435 | -------------------------------------------------------------------------------- /native/PluginProcessor.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | 6 | #include 7 | #include 8 | 9 | 10 | //============================================================================== 11 | class EffectsPluginProcessor 12 | : public juce::AudioProcessor, 13 | public juce::AudioProcessorParameter::Listener, 14 | private juce::AsyncUpdater 15 | { 16 | public: 17 | //============================================================================== 18 | EffectsPluginProcessor(); 19 | ~EffectsPluginProcessor() override; 20 | 21 | //============================================================================== 22 | juce::AudioProcessorEditor* createEditor() override; 23 | bool hasEditor() const override; 24 | 25 | //============================================================================== 26 | void prepareToPlay (double sampleRate, int samplesPerBlock) override; 27 | void releaseResources() override; 28 | 29 | bool isBusesLayoutSupported (const juce::AudioProcessor::BusesLayout& layouts) const override; 30 | 31 | void processBlock (juce::AudioBuffer&, juce::MidiBuffer&) override; 32 | 33 | //============================================================================== 34 | const juce::String getName() const override; 35 | 36 | bool acceptsMidi() const override; 37 | bool producesMidi() const override; 38 | bool isMidiEffect() const override; 39 | double getTailLengthSeconds() const override; 40 | 41 | //============================================================================== 42 | int getNumPrograms() override; 43 | int getCurrentProgram() override; 44 | void setCurrentProgram (int index) override; 45 | const juce::String getProgramName (int index) override; 46 | void changeProgramName (int index, const juce::String& newName) override; 47 | 48 | //============================================================================== 49 | void getStateInformation (juce::MemoryBlock& destData) override; 50 | void setStateInformation (const void* data, int sizeInBytes) override; 51 | 52 | //============================================================================== 53 | /** Implement the AudioProcessorParameter::Listener interface. */ 54 | void parameterValueChanged (int parameterIndex, float newValue) override; 55 | void parameterGestureChanged (int parameterIndex, bool gestureIsStarting) override; 56 | 57 | //============================================================================== 58 | /** Implement the AsyncUpdater interface. */ 59 | void handleAsyncUpdate() override; 60 | 61 | //============================================================================== 62 | /** Internal helper for initializing the embedded JS engine. */ 63 | void initJavaScriptEngine(); 64 | 65 | /** Internal helper for propagating processor state changes. */ 66 | void dispatchStateChange(); 67 | void dispatchError(std::string const& name, std::string const& message); 68 | 69 | private: 70 | //============================================================================== 71 | std::atomic shouldInitialize { false }; 72 | double lastKnownSampleRate = 0; 73 | int lastKnownBlockSize = 0; 74 | 75 | elem::js::Object state; 76 | choc::javascript::Context jsContext; 77 | 78 | juce::AudioBuffer scratchBuffer; 79 | 80 | std::unique_ptr> runtime; 81 | 82 | //============================================================================== 83 | // A simple "dirty list" abstraction here for propagating realtime parameter 84 | // value changes 85 | struct ParameterReadout { 86 | float value = 0; 87 | bool dirty = false; 88 | }; 89 | 90 | std::list> paramReadouts; 91 | static_assert(std::atomic::is_always_lock_free); 92 | 93 | //============================================================================== 94 | JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (EffectsPluginProcessor) 95 | }; 96 | -------------------------------------------------------------------------------- /native/WebViewEditor.cpp: -------------------------------------------------------------------------------- 1 | #include "PluginProcessor.h" 2 | #include "WebViewEditor.h" 3 | 4 | 5 | // A helper for reading numbers from a choc::Value, which seems to opportunistically parse 6 | // JSON numbers into ints or 32-bit floats whenever it wants. 7 | double numberFromChocValue(const choc::value::ValueView& v) { 8 | return ( 9 | v.isFloat32() ? (double) v.getFloat32() 10 | : (v.isFloat64() ? v.getFloat64() 11 | : (v.isInt32() ? (double) v.getInt32() 12 | : (double) v.getInt64()))); 13 | } 14 | 15 | std::string getMimeType(std::string const& ext) { 16 | static std::unordered_map mimeTypes { 17 | { ".html", "text/html" }, 18 | { ".js", "application/javascript" }, 19 | { ".css", "text/css" }, 20 | }; 21 | 22 | if (mimeTypes.count(ext) > 0) 23 | return mimeTypes.at(ext); 24 | 25 | return "application/octet-stream"; 26 | } 27 | 28 | //============================================================================== 29 | WebViewEditor::WebViewEditor(juce::AudioProcessor* proc, juce::File const& assetDirectory, int width, int height) 30 | : juce::AudioProcessorEditor(proc) 31 | { 32 | setSize(720, 444); 33 | 34 | choc::ui::WebView::Options opts; 35 | 36 | #if JUCE_DEBUG 37 | opts.enableDebugMode = true; 38 | #endif 39 | 40 | #if ! ELEM_DEV_LOCALHOST 41 | opts.fetchResource = [=](const choc::ui::WebView::Options::Path& p) -> std::optional { 42 | auto relPath = "." + (p == "/" ? "/index.html" : p); 43 | auto f = assetDirectory.getChildFile(relPath); 44 | juce::MemoryBlock mb; 45 | 46 | if (!f.existsAsFile() || !f.loadFileAsData(mb)) 47 | return {}; 48 | 49 | return choc::ui::WebView::Options::Resource { 50 | std::vector(mb.begin(), mb.end()), 51 | getMimeType(f.getFileExtension().toStdString()) 52 | }; 53 | }; 54 | #endif 55 | 56 | webView = std::make_unique(opts); 57 | 58 | #if JUCE_MAC 59 | viewContainer.setView(webView->getViewHandle()); 60 | #elif JUCE_WINDOWS 61 | viewContainer.setHWND(webView->getViewHandle()); 62 | #else 63 | #error "We only support MacOS and Windows here yet." 64 | #endif 65 | 66 | addAndMakeVisible(viewContainer); 67 | viewContainer.setBounds({0, 0, 720, 440}); 68 | 69 | // Install message passing handlers 70 | webView->bind("__postNativeMessage__", [=](const choc::value::ValueView& args) -> choc::value::Value { 71 | if (args.isArray()) { 72 | auto eventName = args[0].getString(); 73 | 74 | // When the webView loads it should send a message telling us that it has established 75 | // its message-passing hooks and is ready for a state dispatch 76 | if (eventName == "ready") { 77 | if (auto* ptr = dynamic_cast(getAudioProcessor())) { 78 | ptr->dispatchStateChange(); 79 | } 80 | } 81 | 82 | #if ELEM_DEV_LOCALHOST 83 | if (eventName == "reload") { 84 | if (auto* ptr = dynamic_cast(getAudioProcessor())) { 85 | ptr->initJavaScriptEngine(); 86 | ptr->dispatchStateChange(); 87 | } 88 | } 89 | #endif 90 | 91 | if (eventName == "setParameterValue" && args.size() > 1) { 92 | return handleSetParameterValueEvent(args[1]); 93 | } 94 | } 95 | 96 | return {}; 97 | }); 98 | 99 | #if ELEM_DEV_LOCALHOST 100 | webView->navigate("http://localhost:5173"); 101 | #endif 102 | } 103 | 104 | choc::ui::WebView* WebViewEditor::getWebViewPtr() 105 | { 106 | return webView.get(); 107 | } 108 | 109 | void WebViewEditor::paint (juce::Graphics& g) 110 | { 111 | } 112 | 113 | void WebViewEditor::resized() 114 | { 115 | viewContainer.setBounds(getLocalBounds()); 116 | } 117 | 118 | //============================================================================== 119 | choc::value::Value WebViewEditor::handleSetParameterValueEvent(const choc::value::ValueView& e) { 120 | // When setting a parameter value, we simply tell the host. This will in turn fire 121 | // a parameterValueChanged event, which will catch and propagate through dispatching 122 | // a state change event 123 | if (e.isObject() && e.hasObjectMember("paramId") && e.hasObjectMember("value")) { 124 | auto const& paramId = e["paramId"].getString(); 125 | double const v = numberFromChocValue(e["value"]); 126 | 127 | for (auto& p : getAudioProcessor()->getParameters()) { 128 | if (auto* pf = dynamic_cast(p)) { 129 | if (pf->paramID.toStdString() == paramId) { 130 | pf->setValueNotifyingHost(v); 131 | break; 132 | } 133 | } 134 | } 135 | } 136 | 137 | return choc::value::Value(); 138 | } 139 | -------------------------------------------------------------------------------- /native/WebViewEditor.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | 6 | #include 7 | 8 | 9 | //============================================================================== 10 | // A simple juce::AudioProcessorEditor that holds a choc::WebView and sets the 11 | // WebView instance to cover the entire region of the editor. 12 | class WebViewEditor : public juce::AudioProcessorEditor 13 | { 14 | public: 15 | //============================================================================== 16 | WebViewEditor(juce::AudioProcessor* proc, juce::File const& assetDirectory, int width, int height); 17 | 18 | //============================================================================== 19 | choc::ui::WebView* getWebViewPtr(); 20 | 21 | //============================================================================== 22 | void paint (juce::Graphics& g) override; 23 | void resized() override; 24 | 25 | private: 26 | //============================================================================== 27 | choc::value::Value handleSetParameterValueEvent(const choc::value::ValueView& e); 28 | 29 | //============================================================================== 30 | std::unique_ptr webView; 31 | 32 | #if JUCE_MAC 33 | juce::NSViewComponent viewContainer; 34 | #elif JUCE_WINDOWS 35 | juce::HWNDComponent viewContainer; 36 | #else 37 | #error "We only support MacOS and Windows here yet." 38 | #endif 39 | }; 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ui", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "clean": "rimraf dist", 8 | "predev": "npm run clean", 9 | "prebuild": "npm run clean", 10 | "dev": "npm run dev-native && concurrently \"npm run dev-dsp\" \"vite\"", 11 | "dev-native": "zx scripts/build-native.mjs --dev", 12 | "dev-dsp": "esbuild dsp/main.js --watch --bundle --outfile=public/dsp.main.js", 13 | "build-native": "zx scripts/build-native.mjs", 14 | "build-dsp": "esbuild dsp/main.js --bundle --outfile=public/dsp.main.js", 15 | "build-ui": "vite build", 16 | "build": "npm run build-dsp && npm run build-ui && npm run build-native", 17 | "preview": "vite preview" 18 | }, 19 | "dependencies": { 20 | "@elemaudio/core": "^3.0.0", 21 | "@heroicons/react": "^2.0.18", 22 | "@use-gesture/react": "^10.2.27", 23 | "cpy-cli": "^4.2.0", 24 | "esbuild": "^0.17.8", 25 | "invariant": "^2.2.4", 26 | "react": "^18.2.0", 27 | "react-dom": "^18.2.0", 28 | "resize-observer-polyfill": "^1.5.1", 29 | "rimraf": "^5.0.0", 30 | "zustand": "^4.3.8" 31 | }, 32 | "devDependencies": { 33 | "@types/react": "^18.0.37", 34 | "@types/react-dom": "^18.0.11", 35 | "@vitejs/plugin-react": "^4.0.0", 36 | "autoprefixer": "^10.4.14", 37 | "concurrently": "^8.2.2", 38 | "postcss": "^8.4.24", 39 | "tailwindcss": "^3.3.2", 40 | "vite": "^4.3.9", 41 | "zx": "^7.2.3" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "window": { 3 | "width": 753, 4 | "height": 373 5 | }, 6 | "parameters": [ 7 | { "paramId": "size", "name": "Size", "min": 0.0, "max": 1.0, "defaultValue": 0.5 }, 8 | { "paramId": "decay", "name": "Decay", "min": 0.0, "max": 1.0, "defaultValue": 0.5 }, 9 | { "paramId": "mod", "name": "Mod", "min": 0.0, "max": 1.0, "defaultValue": 0.5 }, 10 | { "paramId": "mix", "name": "Mix", "min": 0.0, "max": 1.0, "defaultValue": 0.5 } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /scripts/build-native.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env zx 2 | 3 | 4 | let rootDir = await path.resolve(__dirname, '..'); 5 | let buildDir = await path.join(rootDir, 'native', 'build', 'scripted'); 6 | 7 | echo(`Root directory: ${rootDir}`); 8 | echo(`Build directory: ${buildDir}`); 9 | 10 | // Clean the build directory before we build 11 | await fs.remove(buildDir); 12 | await fs.ensureDir(buildDir); 13 | 14 | cd(buildDir); 15 | 16 | let buildType = argv.dev ? 'Debug' : 'Release'; 17 | let devFlag = argv.dev ? '-DELEM_DEV_LOCALHOST=1' : ''; 18 | 19 | await $`cmake -DCMAKE_BUILD_TYPE=${buildType} -DCMAKE_INSTALL_PREFIX=./out/ -DCMAKE_OSX_DEPLOYMENT_TARGET=10.15 ${devFlag} ../..`; 20 | await $`cmake --build . --config ${buildType} -j 4`; 21 | -------------------------------------------------------------------------------- /src/DragBehavior.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react'; 2 | import { useDrag } from '@use-gesture/react' 3 | 4 | 5 | export default function DragBehavior(props) { 6 | const nodeRef = useRef(); 7 | const valueAtDragStartRef = useRef(props.value || 0); 8 | 9 | const {snapToMouseLinearHorizontal, value, onChange, ...other} = props; 10 | 11 | const bindDragHandlers = useDrag((state) => { 12 | if (state.first && typeof value === 'number') { 13 | valueAtDragStartRef.current = value; 14 | 15 | if (snapToMouseLinearHorizontal) { 16 | let [x, y] = state.xy; 17 | let posInScreen = nodeRef.current.getBoundingClientRect(); 18 | 19 | let dx = x - posInScreen.left; 20 | let dv = dx / posInScreen.width; 21 | 22 | valueAtDragStartRef.current = Math.max(0, Math.min(1, dv)); 23 | 24 | if (typeof onChange === 'function') { 25 | onChange(Math.max(0, Math.min(1, dv))); 26 | } 27 | } 28 | 29 | return; 30 | } 31 | 32 | let [dx, dy] = state.movement; 33 | let dv = (dx - dy) / 200; 34 | 35 | if (typeof onChange === 'function') { 36 | onChange(Math.max(0, Math.min(1, valueAtDragStartRef.current + dv))); 37 | } 38 | }); 39 | 40 | return ( 41 |
42 | {props.children} 43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/Interface.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { XCircleIcon, XMarkIcon } from '@heroicons/react/20/solid' 3 | 4 | import Knob from './Knob.jsx'; 5 | 6 | import manifest from '../public/manifest.json'; 7 | 8 | 9 | // Generated from Lockup.svg using svgr, and then I changed the generated code 10 | // a bit to use a currentColor fill on the text path, and to move the strokeLinejoin/cap 11 | // style properties to actual dom attributes because somehow that was causing problems 12 | const Logo = (props) => ( 13 | 24 | 31 | 39 | 45 | 51 | 59 | 67 | 68 | ); 69 | 70 | function ErrorAlert({message, reset}) { 71 | return ( 72 |
73 |
74 |
75 |
77 |
78 |

{message}

79 |
80 |
81 |
82 | 90 |
91 |
92 |
93 |
94 | ) 95 | } 96 | 97 | // The interface of our plugin, exported here as a React.js function 98 | // component. 99 | // 100 | // We use the `props.requestParamValueUpdate` callback provided by the parent 101 | // component to propagate new parameter values to the host. 102 | export default function Interface(props) { 103 | const colorProps = { 104 | meterColor: '#EC4899', 105 | knobColor: '#64748B', 106 | thumbColor: '#F8FAFC', 107 | }; 108 | 109 | let params = manifest.parameters.map(({paramId, name, min, max, defaultValue}) => { 110 | let currentValue = props[paramId] || 0; 111 | 112 | return { 113 | paramId, 114 | name, 115 | value: currentValue, 116 | readout: `${Math.round(currentValue * 100)}%`, 117 | setValue: (v) => props.requestParamValueUpdate(paramId, v), 118 | }; 119 | }); 120 | 121 | return ( 122 |
123 |
124 | 125 |
126 | SRVB · {__BUILD_DATE__} · {__COMMIT_HASH__} 127 |
128 |
129 |
130 | {props.error && ()} 131 |
132 | {params.map(({name, value, readout, setValue}) => ( 133 |
134 | 135 |
136 |
{name}
137 |
{readout}
138 |
139 |
140 | ))} 141 |
142 |
143 |
144 | ); 145 | } 146 | -------------------------------------------------------------------------------- /src/Knob.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react'; 2 | import ResizeObserver from 'resize-observer-polyfill'; 3 | 4 | import DragBehavior from './DragBehavior'; 5 | 6 | 7 | function cx(...classes) { 8 | return classes.filter(Boolean).join(' ') 9 | } 10 | 11 | function draw(ctx, width, height, value, meterColor, knobColor, thumbColor) { 12 | ctx.clearRect(0, 0, width, height); 13 | 14 | const hw = width * 0.5; 15 | const hh = height * 0.5; 16 | const radius = Math.min(hw, hh) * 0.8; 17 | 18 | // Fill 19 | ctx.strokeStyle = meterColor; 20 | ctx.lineWidth = Math.round(width * 0.028); 21 | ctx.lineCap = 'round'; 22 | 23 | const fillStart = 0.75 * Math.PI; 24 | const fillEnd = fillStart + (1.5 * value * Math.PI); 25 | 26 | ctx.beginPath(); 27 | ctx.arc(hw, hh, radius, fillStart, fillEnd, false); 28 | ctx.stroke(); 29 | 30 | // Knob 31 | ctx.strokeStyle = knobColor; 32 | ctx.lineWidth = Math.round(width * 0.028); 33 | ctx.lineCap = 'round'; 34 | 35 | ctx.beginPath(); 36 | ctx.arc(hw, hh, radius * 0.72, 0, 2 * Math.PI, false); 37 | ctx.stroke(); 38 | 39 | // Knob thumb 40 | ctx.fillStyle = thumbColor; 41 | ctx.lineWidth = Math.round(width * 0.036); 42 | ctx.lineCap = 'round'; 43 | 44 | ctx.beginPath(); 45 | ctx.arc(hw + 0.5 * radius * Math.cos(fillEnd), hh + 0.5 * radius * Math.sin(fillEnd), radius * 0.08, 0, 2 * Math.PI, false); 46 | ctx.fill(); 47 | } 48 | 49 | function Knob(props) { 50 | const canvasRef = useRef(); 51 | const observerRef = useRef(); 52 | 53 | const [bounds, setBounds] = useState({ 54 | width: 0, 55 | height: 0, 56 | }); 57 | 58 | let {className, meterColor, knobColor, thumbColor, ...other} = props; 59 | let classes = cx(className, 'relative touch-none'); 60 | 61 | useEffect(function() { 62 | const canvas = canvasRef.current; 63 | 64 | observerRef.current = new ResizeObserver(function(entries) { 65 | for (let entry of entries) { 66 | setBounds({ 67 | width: 2 * entry.contentRect.width, 68 | height: 2 * entry.contentRect.height, 69 | }); 70 | } 71 | }); 72 | 73 | observerRef.current.observe(canvas); 74 | 75 | return function() { 76 | observerRef.current.disconnect(); 77 | }; 78 | }, []); 79 | 80 | useEffect(function() { 81 | const canvas = canvasRef.current; 82 | const ctx = canvas.getContext('2d'); 83 | 84 | canvas.width = bounds.width; 85 | canvas.height = bounds.height; 86 | 87 | draw(ctx, bounds.width, bounds.height, props.value, meterColor, knobColor, thumbColor); 88 | }, [bounds, props.value, meterColor, knobColor, thumbColor]); 89 | 90 | return ( 91 | 92 | 93 | 94 | ); 95 | } 96 | 97 | export default React.memo(Knob); 98 | -------------------------------------------------------------------------------- /src/Lockup.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .bg-mesh { 6 | background-color: #0F172A; 7 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25' viewBox='0 0 800 400'%3E%3Cdefs%3E%3CradialGradient id='a' cx='396' cy='281' r='514' gradientUnits='userSpaceOnUse'%3E%3Cstop offset='0' stop-color='%231E293B'/%3E%3Cstop offset='1' stop-color='%230F172A'/%3E%3C/radialGradient%3E%3ClinearGradient id='b' gradientUnits='userSpaceOnUse' x1='400' y1='148' x2='400' y2='333'%3E%3Cstop offset='0' stop-color='%23334155' stop-opacity='0'/%3E%3Cstop offset='1' stop-color='%23334155' stop-opacity='0.5'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect fill='url(%23a)' width='800' height='400'/%3E%3Cg fill-opacity='0.4'%3E%3Ccircle fill='url(%23b)' cx='267.5' cy='61' r='300'/%3E%3Ccircle fill='url(%23b)' cx='532.5' cy='61' r='300'/%3E%3Ccircle fill='url(%23b)' cx='400' cy='30' r='300'/%3E%3C/g%3E%3C/svg%3E"); 8 | background-attachment: fixed; 9 | background-size: cover; 10 | } 11 | -------------------------------------------------------------------------------- /src/main.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import Interface from './Interface.jsx' 4 | 5 | import createHooks from 'zustand' 6 | import createStore from 'zustand/vanilla' 7 | 8 | import './index.css' 9 | 10 | 11 | // Initial state management 12 | const store = createStore(() => {}); 13 | const useStore = createHooks(store); 14 | 15 | const errorStore = createStore(() => ({ error: null })); 16 | const useErrorStore = createHooks(errorStore); 17 | 18 | // Interop bindings 19 | function requestParamValueUpdate(paramId, value) { 20 | if (typeof globalThis.__postNativeMessage__ === 'function') { 21 | globalThis.__postNativeMessage__("setParameterValue", { 22 | paramId, 23 | value, 24 | }); 25 | } 26 | } 27 | 28 | if (process.env.NODE_ENV !== 'production') { 29 | import.meta.hot.on('reload-dsp', () => { 30 | console.log('Sending reload dsp message'); 31 | 32 | if (typeof globalThis.__postNativeMessage__ === 'function') { 33 | globalThis.__postNativeMessage__('reload'); 34 | } 35 | }); 36 | } 37 | 38 | globalThis.__receiveStateChange__ = function(state) { 39 | store.setState(JSON.parse(state)); 40 | }; 41 | 42 | globalThis.__receiveError__ = (err) => { 43 | errorStore.setState({ error: err }); 44 | }; 45 | 46 | // Mount the interface 47 | function App(props) { 48 | let state = useStore(); 49 | let {error} = useErrorStore(); 50 | 51 | return ( 52 | errorStore.setState({ error: null })} /> 57 | ); 58 | } 59 | 60 | ReactDOM.createRoot(document.getElementById('root')).render( 61 | 62 | 63 | , 64 | ) 65 | 66 | // Request initial processor state 67 | if (typeof globalThis.__postNativeMessage__ === 'function') { 68 | globalThis.__postNativeMessage__("ready"); 69 | } 70 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: [ 4 | "./index.html", 5 | "./src/**/*.{js,ts,jsx,tsx}", 6 | ], 7 | theme: { 8 | extend: {}, 9 | }, 10 | plugins: [], 11 | } -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | import { execSync } from 'node:child_process' 5 | 6 | const currentCommit = execSync("git rev-parse --short HEAD").toString(); 7 | const date = new Date(); 8 | const dateString = `${date.getFullYear()}.${date.getMonth() + 1}.${date.getDate()}`; 9 | 10 | // A helper plugin which specifically watches for changes to public/dsp.main.js, 11 | // which is built in a parallel watch job via esbuild during dev. 12 | // 13 | // We can still use Vite's HMR to send a custom reload-dsp event, which is caught 14 | // inside the webview and propagated to native to reinitialize the embedded js engine. 15 | // 16 | // During production builds, this all gets pruned from the bundle. 17 | function pubDirReloadPlugin() { 18 | return { 19 | name: 'pubDirReload', 20 | handleHotUpdate({file, modules, server}) { 21 | if (file.includes('public/dsp.main.js')) { 22 | server.ws.send({ 23 | type: 'custom', 24 | event: 'reload-dsp', 25 | }); 26 | } 27 | 28 | return modules; 29 | } 30 | }; 31 | } 32 | 33 | // https://vitejs.dev/config/ 34 | export default defineConfig({ 35 | base: './', 36 | define: { 37 | __COMMIT_HASH__: JSON.stringify(currentCommit), 38 | __BUILD_DATE__: JSON.stringify(dateString), 39 | }, 40 | plugins: [react(), pubDirReloadPlugin()], 41 | }) 42 | --------------------------------------------------------------------------------