├── .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 |
68 | );
69 |
70 | function ErrorAlert({message, reset}) {
71 | return (
72 |
73 |
74 |
75 |
76 |
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 |