diff --git a/.eleventyignore b/.eleventyignore
index 1f9e52b62..f733f1a87 100644
--- a/.eleventyignore
+++ b/.eleventyignore
@@ -1,3 +1,3 @@
node_modules/
./src/archive/
-**/README.md
\ No newline at end of file
+**/README.md
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 6db7f3114..a1aa2f8b5 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -19,7 +19,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v3
with:
- node-version: 16
+ node-version: 18
- name: npm install and build
run: |
npm install
diff --git a/package.json b/package.json
index ad4bad2bc..6f3f85677 100644
--- a/package.json
+++ b/package.json
@@ -8,6 +8,7 @@
"build": "npm run clean && run-p build:*",
"build:eleventy": "ELEVENTY_ENV=production eleventy",
"build:postcss": "ELEVENTY_ENV=production postcss src/styles/*.css --dir _site",
+ "build:rainfly": "cd src/rainfly && npm run build",
"start": "run-p start:*",
"start:eleventy": "eleventy --serve",
"start:postcss": "postcss src/styles/*.css --dir _site --watch",
diff --git a/src/_data/build_info.json b/src/_data/build_info.json
index a90588ff9..cd8ea9859 100644
--- a/src/_data/build_info.json
+++ b/src/_data/build_info.json
@@ -1 +1 @@
-{"version":"3.2.0","revision":"e45e5d0","lastUpdated":"2024-08-07","copyrightYear":2024}
\ No newline at end of file
+{"version":"3.2.0","revision":"7cb0fdf","lastUpdated":"2024-10-17","copyrightYear":2024}
\ No newline at end of file
diff --git a/src/_data/landing_data.yaml b/src/_data/landing_data.yaml
index a97ab75e7..78800b349 100644
--- a/src/_data/landing_data.yaml
+++ b/src/_data/landing_data.yaml
@@ -18,9 +18,9 @@
- title: Canopy
description: Web Audio code editor and visual debugger
href: https://hoch.github.io/canopy/
-# - title: Audio Worklets Playground
-# description: Audio Worklet playground
-# href: \#
+ - title: Rainfly
+ description: AudioWorklet DSP Playground and Visualizer
+ href: rainfly/
- title: Demos
entries:
diff --git a/src/rainfly/.eslintrc.json b/src/rainfly/.eslintrc.json
new file mode 100644
index 000000000..29f9114dd
--- /dev/null
+++ b/src/rainfly/.eslintrc.json
@@ -0,0 +1,27 @@
+{
+ "env": {
+ "es6": true,
+ "browser": true,
+ "node": true
+ },
+ "extends": [
+ "eslint:recommended",
+ "plugin:svelte/recommended",
+ "google"
+ ],
+ "ignorePatterns": [
+ "build/",
+ ".svelte-kit/",
+ "dist/",
+ "static/"
+ ],
+ "globals": {
+ "window": "readonly",
+ "document": "readonly"
+ },
+ "parserOptions": {
+ "ecmaVersion": 12,
+ "sourceType": "module"
+ },
+ "rules": {}
+}
\ No newline at end of file
diff --git a/src/rainfly/.gitignore b/src/rainfly/.gitignore
new file mode 100644
index 000000000..1fd341645
--- /dev/null
+++ b/src/rainfly/.gitignore
@@ -0,0 +1,29 @@
+node_modules
+
+# Output
+.output
+.vercel
+/.svelte-kit
+/build
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Env
+.env
+.env.*
+!.env.example
+!.env.test
+
+# Vite
+vite.config.js.timestamp-*
+vite.config.ts.timestamp-*
+
+# IDE
+.idea
+.vscode
+
+# Lockfiles
+package-lock.json
+bun.lockb
diff --git a/src/rainfly/.npmrc b/src/rainfly/.npmrc
new file mode 100644
index 000000000..b6f27f135
--- /dev/null
+++ b/src/rainfly/.npmrc
@@ -0,0 +1 @@
+engine-strict=true
diff --git a/src/rainfly/README.md b/src/rainfly/README.md
new file mode 100644
index 000000000..522a1b63e
--- /dev/null
+++ b/src/rainfly/README.md
@@ -0,0 +1,26 @@
+# Rainfly
+
+
+
+
+**An AudioWorklet DSP Playground for Chromium Web Audio API Project (2024)**
+
+## Developing
+Install dependencies with `npm install` (or `pnpm install` or `yarn`), and then start a development server:
+
+```bash
+npm run dev
+
+# or start the server and open the app in a new browser tab
+npm run dev -- --open
+```
+
+## Building
+
+To create a production version of Rainfly:
+
+```bash
+npm run build
+```
+
+You can preview the production build with `npm run preview`.
diff --git a/src/rainfly/jsconfig.json b/src/rainfly/jsconfig.json
new file mode 100644
index 000000000..fc93cbd94
--- /dev/null
+++ b/src/rainfly/jsconfig.json
@@ -0,0 +1,19 @@
+{
+ "extends": "./.svelte-kit/tsconfig.json",
+ "compilerOptions": {
+ "allowJs": true,
+ "checkJs": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "strict": true,
+ "moduleResolution": "bundler"
+ }
+ // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
+ // except $lib which is handled by https://kit.svelte.dev/docs/configuration#files
+ //
+ // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
+ // from the referenced tsconfig.json - TypeScript does not merge them in
+}
diff --git a/src/rainfly/package.json b/src/rainfly/package.json
new file mode 100644
index 000000000..367e75630
--- /dev/null
+++ b/src/rainfly/package.json
@@ -0,0 +1,38 @@
+{
+ "name": "rainfly",
+ "version": "1.0.0-beta",
+ "private": true,
+ "scripts": {
+ "dev": "vite dev",
+ "prebuild": "npm i",
+ "build": "vite build",
+ "postbuild": "rm -rf ../../_site/rainfly/* && cp -r build/* ../../_site/rainfly/ && rm -rf build/",
+ "preview": "vite preview",
+ "check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
+ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch",
+ "lint": "eslint --fix ."
+ },
+ "devDependencies": {
+ "@sveltejs/kit": "^2.0.0",
+ "@sveltejs/vite-plugin-svelte": "^3.0.0",
+ "@types/eslint": "^9.6.0",
+ "autoprefixer": "^10.4.19",
+ "eslint": "^8.57.0",
+ "eslint-config-google": "^0.14.0",
+ "eslint-plugin-svelte": "^2.36.0",
+ "globals": "^15.0.0",
+ "monaco-editor": "^0.50.0",
+ "postcss": "^8.4.40",
+ "svelte": "^4.2.7",
+ "svelte-check": "^3.6.0",
+ "tailwindcss": "^3.4.7",
+ "typescript": "^5.0.0",
+ "vite": "^5.0.3"
+ },
+ "type": "module",
+ "dependencies": {
+ "@sveltejs/adapter-static": "^3.0.4",
+ "jszip": "^3.10.1",
+ "monaco-vim": "^0.4.1"
+ }
+}
diff --git a/src/rainfly/postcss.config.js b/src/rainfly/postcss.config.js
new file mode 100644
index 000000000..ab16b20e1
--- /dev/null
+++ b/src/rainfly/postcss.config.js
@@ -0,0 +1,7 @@
+// noinspection JSUnusedGlobalSymbols
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/src/rainfly/src/app.css b/src/rainfly/src/app.css
new file mode 100644
index 000000000..f069c597f
--- /dev/null
+++ b/src/rainfly/src/app.css
@@ -0,0 +1,173 @@
+@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@100..900&family=Anonymous+Pro:ital,wght@0,400;0,700;1,400;1,700&display=swap');
+
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@keyframes left-bounce-expand-in {
+ 0% {
+ transform: translateX(-100%) scaleY(0.5);
+ opacity: 0;
+ }
+
+ 80% {
+ transform: translateX(40%) scaleY(1);
+ opacity: 1;
+ }
+
+ 100% {
+ transform: translateX(0.25rem);
+ }
+}
+
+@keyframes left-bounce-shrink-out {
+ 0% {
+ transform: translateX(0.25rem);
+ }
+
+ 20% {
+ transform: translateX(40%) scaleY(1);
+ opacity: 1;
+ }
+
+ 100% {
+ transform: translateX(-100%) scaleY(0.5);
+ opacity: 0;
+ }
+}
+
+@keyframes right-bounce-in {
+ 0% {
+ transform: translateX(100%) scaleY(0.5);
+ opacity: 0;
+ }
+
+ 80% {
+ transform: translateX(-5rem) scaleY(1);
+ opacity: 1;
+ }
+
+ 100% {
+ transform: translateX(0);
+ }
+}
+
+@keyframes right-bounce-out {
+ 0% {
+ transform: translateX(0);
+ }
+
+ 20% {
+ transform: translateX(-5rem) scaleY(1);
+ opacity: 1;
+ }
+
+ 100% {
+ transform: translateX(100%) scaleY(0.5);
+ opacity: 0;
+ }
+}
+
+@keyframes unfold {
+ 0% {
+ max-height: 0;
+ opacity: 0;
+ }
+
+ 50% {
+ opacity: 1;
+ }
+
+ 100% {
+ max-height: 10rem;
+ }
+}
+
+@keyframes fold {
+ 0% {
+ max-height: 10rem;
+ }
+
+ 50% {
+ opacity: 1;
+ }
+
+ 100% {
+ max-height: 0;
+ opacity: 0;
+ }
+}
+
+@keyframes appear {
+ 0% {
+ opacity: 0;
+ transform: scale(.9);
+ }
+
+ 100% {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+@keyframes disappear {
+ 0% {
+ opacity: 1;
+ transform: scale(1);
+ }
+
+ 100% {
+ opacity: 0;
+ transform: scale(0.9);
+ }
+}
+
+body, html {
+ height: 100vh;
+ margin: 0;
+ overflow: hidden;
+ width: 100vw;
+}
+
+.fab-in {
+ animation: left-bounce-expand-in 0.25s ease-in-out forwards;
+}
+
+.fab-out {
+ animation: left-bounce-shrink-out 0.25s ease-in-out forwards;
+}
+
+.fold {
+ @apply overflow-hidden;
+ animation: fold 0.2s ease-in-out forwards;
+}
+
+.unfold {
+ @apply overflow-hidden;
+ animation: unfold 0.2s ease-in-out forwards;
+}
+
+.toast-in {
+ animation: right-bounce-in 0.25s ease-in-out forwards;
+}
+
+.toast-out {
+ animation: right-bounce-out 0.25s ease-in-out forwards;
+}
+
+.tooltip-in {
+ animation: appear 0.05s ease-in-out forwards;
+}
+
+.tooltip-out {
+ animation: disappear 0.05s ease-in-out forwards;
+}
+
+.vimBar {
+ @apply w-full h-6 px-1 border-none bg-gray-200 text-sm items-center;
+ display: flex !important;
+}
+
+.vimBar input {
+ @apply outline-none bg-transparent border-none;
+}
diff --git a/src/rainfly/src/app.d.ts b/src/rainfly/src/app.d.ts
new file mode 100644
index 000000000..743f07b2e
--- /dev/null
+++ b/src/rainfly/src/app.d.ts
@@ -0,0 +1,13 @@
+// See https://kit.svelte.dev/docs/types#app
+// for information about these interfaces
+declare global {
+ namespace App {
+ // interface Error {}
+ // interface Locals {}
+ // interface PageData {}
+ // interface PageState {}
+ // interface Platform {}
+ }
+}
+
+export {};
diff --git a/src/rainfly/src/app.html b/src/rainfly/src/app.html
new file mode 100644
index 000000000..1391f8848
--- /dev/null
+++ b/src/rainfly/src/app.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+ %sveltekit.head%
+
+
+ %sveltekit.body%
+
+
diff --git a/src/rainfly/src/lib/actions/click-outside.js b/src/rainfly/src/lib/actions/click-outside.js
new file mode 100644
index 000000000..522008dad
--- /dev/null
+++ b/src/rainfly/src/lib/actions/click-outside.js
@@ -0,0 +1,13 @@
+export const nodes = new Map();
+
+/**
+ * Listen for clicks outside of the node and call the callback.
+ * @param {HTMLElement} node - The node to listen for clicks outside of
+ * @param {Function} callback - callback when click outside occurs
+ * @return {Object.} Object with destroy method to remove
+ */
+export const clickOutside = (node, callback) => {
+ nodes.set(node, callback);
+
+ return {destroy: () => nodes.delete(node)};
+};
diff --git a/src/rainfly/src/lib/actions/portal.js b/src/rainfly/src/lib/actions/portal.js
new file mode 100644
index 000000000..ebe30847a
--- /dev/null
+++ b/src/rainfly/src/lib/actions/portal.js
@@ -0,0 +1,69 @@
+// REF: https://github.com/romkor/svelte-portal/blob/a650e7b762344a1bb0ad9e218660ed1ee66e3f90/src/Portal.svelte
+/**
+ * MIT License
+ *
+ * Copyright (c) 2019 Roman Rodych
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+import {tick} from 'svelte';
+
+/**
+ * Creates a portal to render an element at a target location.
+ *
+ * @param {HTMLElement} el - The element to render in the portal.
+ * @param {string|HTMLElement} target - The target location where the element
+ * ``will be rendered. Can be a CSS selector string or an HTMLElement.
+ * @return {Object} An object containing the update and destroy methods for
+ * ``the portal.
+ */
+export const portal = (el, target = 'div') => {
+ let targetEl;
+ const update = async (/** @type {string|HTMLElement} */ newTarget) => {
+ target = newTarget;
+ if (typeof target === 'string') {
+ targetEl = document.querySelector(target);
+ if (targetEl === null) {
+ await tick();
+ targetEl = document.querySelector(target);
+ }
+ if (targetEl === null) {
+ throw new Error(`No element found matching css selector: "${target}"`);
+ }
+ } else if (target instanceof HTMLElement) {
+ targetEl = target;
+ } else {
+ // eslint-disable-next-line max-len
+ throw new TypeError(`Unknown portal target type: ${target === null ? 'null' : typeof target}. Allowed types: string (CSS selector) or HTMLElement.`);
+ }
+ targetEl.appendChild(el);
+ el.hidden = false;
+ };
+
+ const destroy = () => el.parentNode && el.parentNode.removeChild(el);
+
+ // noinspection JSIgnoredPromiseFromCall
+ update(target);
+
+ return {
+ update,
+ destroy,
+ };
+};
diff --git a/src/rainfly/src/lib/assets/logo.svg b/src/rainfly/src/lib/assets/logo.svg
new file mode 100644
index 000000000..9982303b6
--- /dev/null
+++ b/src/rainfly/src/lib/assets/logo.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/src/rainfly/src/lib/assets/player-pause.svg b/src/rainfly/src/lib/assets/player-pause.svg
new file mode 100644
index 000000000..7f0cfffe9
--- /dev/null
+++ b/src/rainfly/src/lib/assets/player-pause.svg
@@ -0,0 +1 @@
+
diff --git a/src/rainfly/src/lib/assets/player-play.svg b/src/rainfly/src/lib/assets/player-play.svg
new file mode 100644
index 000000000..51f12e460
--- /dev/null
+++ b/src/rainfly/src/lib/assets/player-play.svg
@@ -0,0 +1 @@
+
diff --git a/src/rainfly/src/lib/assets/player-run-play.svg b/src/rainfly/src/lib/assets/player-run-play.svg
new file mode 100644
index 000000000..719c4a09e
--- /dev/null
+++ b/src/rainfly/src/lib/assets/player-run-play.svg
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/rainfly/src/lib/assets/player-stop.svg b/src/rainfly/src/lib/assets/player-stop.svg
new file mode 100644
index 000000000..b9b29d292
--- /dev/null
+++ b/src/rainfly/src/lib/assets/player-stop.svg
@@ -0,0 +1 @@
+
diff --git a/src/rainfly/src/lib/assets/vim.svg b/src/rainfly/src/lib/assets/vim.svg
new file mode 100644
index 000000000..4e8730f97
--- /dev/null
+++ b/src/rainfly/src/lib/assets/vim.svg
@@ -0,0 +1,8 @@
+
+
+
+
+ VIm
+
+
+
\ No newline at end of file
diff --git a/src/rainfly/src/lib/components/ActionButton.svelte b/src/rainfly/src/lib/components/ActionButton.svelte
new file mode 100644
index 000000000..59a0d3d6c
--- /dev/null
+++ b/src/rainfly/src/lib/components/ActionButton.svelte
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/rainfly/src/lib/components/Editor.svelte b/src/rainfly/src/lib/components/Editor.svelte
new file mode 100644
index 000000000..598108c2c
--- /dev/null
+++ b/src/rainfly/src/lib/components/Editor.svelte
@@ -0,0 +1,256 @@
+
+
+
+
+
+{errorMsg}
+
+{#if editorType === EditorTypes.main}
+
+ Toggle Vim mode
+
+{/if}
+
+
diff --git a/src/rainfly/src/lib/components/Modal.svelte b/src/rainfly/src/lib/components/Modal.svelte
new file mode 100644
index 000000000..2b43c6e90
--- /dev/null
+++ b/src/rainfly/src/lib/components/Modal.svelte
@@ -0,0 +1,35 @@
+
+
+
+ showModal(false)}>
+
+
+
+
+
diff --git a/src/rainfly/src/lib/components/Toast.svelte b/src/rainfly/src/lib/components/Toast.svelte
new file mode 100644
index 000000000..6d4ddcd68
--- /dev/null
+++ b/src/rainfly/src/lib/components/Toast.svelte
@@ -0,0 +1,20 @@
+
+
+
+ handleToggle(false)}>
+
+
+
diff --git a/src/rainfly/src/lib/components/Tooltip.svelte b/src/rainfly/src/lib/components/Tooltip.svelte
new file mode 100644
index 000000000..66f009c6c
--- /dev/null
+++ b/src/rainfly/src/lib/components/Tooltip.svelte
@@ -0,0 +1,91 @@
+
+
+
+
+
+
diff --git a/src/rainfly/src/lib/components/Visualizer.svelte b/src/rainfly/src/lib/components/Visualizer.svelte
new file mode 100644
index 000000000..a2f5180b1
--- /dev/null
+++ b/src/rainfly/src/lib/components/Visualizer.svelte
@@ -0,0 +1,234 @@
+
+
+
+
+
+
+
diff --git a/src/rainfly/src/lib/components/nav/Nav.svelte b/src/rainfly/src/lib/components/nav/Nav.svelte
new file mode 100644
index 000000000..9f441bcbd
--- /dev/null
+++ b/src/rainfly/src/lib/components/nav/Nav.svelte
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/rainfly/src/lib/components/nav/NavDropdownItem.svelte b/src/rainfly/src/lib/components/nav/NavDropdownItem.svelte
new file mode 100644
index 000000000..47feab2d7
--- /dev/null
+++ b/src/rainfly/src/lib/components/nav/NavDropdownItem.svelte
@@ -0,0 +1,8 @@
+
+
+
+
diff --git a/src/rainfly/src/lib/components/nav/NavItem.svelte b/src/rainfly/src/lib/components/nav/NavItem.svelte
new file mode 100644
index 000000000..46dad1fb4
--- /dev/null
+++ b/src/rainfly/src/lib/components/nav/NavItem.svelte
@@ -0,0 +1,46 @@
+
+
+
+ { name }
+
+
+
+
+
+
diff --git a/src/rainfly/src/lib/components/nav/items/NavExamples.svelte b/src/rainfly/src/lib/components/nav/items/NavExamples.svelte
new file mode 100644
index 000000000..f555cad8a
--- /dev/null
+++ b/src/rainfly/src/lib/components/nav/items/NavExamples.svelte
@@ -0,0 +1,62 @@
+
+
+
+ {#each examples as example}
+ {
+ loadExample(example.mainCodeUrl, example.processorCodeUrl);
+ }}
+ >
+ {example.name}
+
+ {/each}
+
diff --git a/src/rainfly/src/lib/components/nav/items/NavFile.svelte b/src/rainfly/src/lib/components/nav/items/NavFile.svelte
new file mode 100644
index 000000000..568e8eac9
--- /dev/null
+++ b/src/rainfly/src/lib/components/nav/items/NavFile.svelte
@@ -0,0 +1,92 @@
+
+
+
+
+ Save code
+
+
+ Export to .wav
+
+
+
+{errorMsg}
diff --git a/src/rainfly/src/lib/components/nav/items/NavHelp.svelte b/src/rainfly/src/lib/components/nav/items/NavHelp.svelte
new file mode 100644
index 000000000..0478dd9e8
--- /dev/null
+++ b/src/rainfly/src/lib/components/nav/items/NavHelp.svelte
@@ -0,0 +1,45 @@
+
+
+
+ showAbout(true)}>About
+
+
+
+
+
+
+
Rainfly
+
An AudioWorklet DSP Playground for Chromium Web Audio API Project
+ (2024)
+
+
Created by @terryzfeng and @kizjkre
+
+
+
See
+ GitHub
+ for more information or to submit an issue
+
+
+
+
+
+
+
+
+
ver {meta.version}
+
+
+
+
+
diff --git a/src/rainfly/src/lib/stores/status.js b/src/rainfly/src/lib/stores/status.js
new file mode 100644
index 000000000..6acf0d662
--- /dev/null
+++ b/src/rainfly/src/lib/stores/status.js
@@ -0,0 +1,13 @@
+import {writable} from 'svelte/store';
+
+export const Status = Object.freeze({
+ stop: 0,
+ play: 1,
+ running: 2,
+ pause: 3,
+});
+
+/**
+ * @type {import('svelte/store').Writable}
+ */
+export const status = writable(Status.stop);
diff --git a/src/rainfly/src/lib/stores/vim-status.js b/src/rainfly/src/lib/stores/vim-status.js
new file mode 100644
index 000000000..e090013ff
--- /dev/null
+++ b/src/rainfly/src/lib/stores/vim-status.js
@@ -0,0 +1,8 @@
+import {writable} from 'svelte/store';
+
+const defaultVim = false; // TODO: load from local storage
+
+/**
+ * @type {import('svelte/store').Writable}
+ */
+export const vimStatus = writable(defaultVim);
diff --git a/src/rainfly/src/lib/utils/audio-buffer-to-wav.js b/src/rainfly/src/lib/utils/audio-buffer-to-wav.js
new file mode 100644
index 000000000..418bdfcb6
--- /dev/null
+++ b/src/rainfly/src/lib/utils/audio-buffer-to-wav.js
@@ -0,0 +1,160 @@
+// REF: https://github.com/hoch/canopy/blob/master/docs/js/canopy-exporter.js
+
+/**
+ * Writes a string to an array starting at a specified offset.
+ *
+ * @param {string} aString - The string to write to the array.
+ * @param {Uint8Array} targetArray - The array to write to.
+ * @param {number} offset - The offset in the array to start writing at.
+ */
+const _writeStringToArray = (aString, targetArray, offset) => {
+ for (let i = 0; i < aString.length; ++i) {
+ targetArray[offset + i] = aString.charCodeAt(i);
+ }
+};
+
+/**
+ * Writes a 16-bit integer to an array at the specified offset.
+ *
+ * @param {number} aNumber - The 16-bit integer to be written.
+ * @param {Uint8Array} targetArray - The array to write the integer to.
+ * @param {number} offset - The offset at which to write the integer in the
+ * array.
+ */
+const _writeInt16ToArray = (aNumber, targetArray, offset) => {
+ aNumber = Math.floor(aNumber);
+ targetArray[offset] = aNumber & 255; // byte 1
+ targetArray[offset + 1] = (aNumber >> 8) & 255; // byte 2
+};
+
+/**
+ * Writes a 32-bit integer to a target array at the specified offset.
+ *
+ * @param {number} aNumber - The number to be written.
+ * @param {Uint8Array} targetArray - The array to write the number to.
+ * @param {number} offset - The offset at which to start writing.
+ */
+const _writeInt32ToArray = (aNumber, targetArray, offset) => {
+ aNumber = Math.floor(aNumber);
+ targetArray[offset] = aNumber & 255; // byte 1
+ targetArray[offset + 1] = (aNumber >> 8) & 255; // byte 2
+ targetArray[offset + 2] = (aNumber >> 16) & 255; // byte 3
+ targetArray[offset + 3] = (aNumber >> 24) & 255; // byte 4
+};
+
+// Return the bits of the float as a 32-bit integer value. This
+// produces the raw bits; no intepretation of the value is done.
+const _floatBits = (f) => {
+ const buf = new ArrayBuffer(4);
+ (new Float32Array(buf))[0] = f;
+ const bits = (new Uint32Array(buf))[0];
+ // Return as a signed integer.
+ return bits | 0;
+};
+
+/**
+ * Converts an audio buffer to an array with the specified bit depth.
+ *
+ * @param {AudioBuffer} audioBuffer - The audio buffer to convert.
+ * @param {Uint8Array} targetArray - The array to store the converted samples.
+ * @param {number} offset - The offset in the targetArray to start writing the
+ * converted samples.
+ * @param {number} bitDepth - The desired bit depth of the converted samples
+ * (16 or 32).
+ */
+const _writeAudioBufferToArray =
+ (audioBuffer, targetArray, offset, bitDepth) => {
+ let index; let channel = 0;
+ const length = audioBuffer.length;
+ const channels = audioBuffer.numberOfChannels;
+ let channelData; let sample;
+
+ // Clamping samples onto the 16-bit resolution.
+ for (index = 0; index < length; ++index) {
+ for (channel = 0; channel < channels; ++channel) {
+ channelData = audioBuffer.getChannelData(channel);
+
+ // Branches upon the requested bit depth
+ if (bitDepth === 16) {
+ sample = channelData[index] * 32768.0;
+ if (sample < -32768) {
+ sample = -32768;
+ } else if (sample > 32767) {
+ sample = 32767;
+ }
+ _writeInt16ToArray(sample, targetArray, offset);
+ offset += 2;
+ } else if (bitDepth === 32) {
+ // This assumes we're going to out 32-float, not 32-bit linear.
+ sample = _floatBits(channelData[index]);
+ _writeInt32ToArray(sample, targetArray, offset);
+ offset += 4;
+ } else {
+ console.error('Invalid bit depth for PCM encoding.');
+ return;
+ }
+ }
+ }
+ };
+
+/**
+ * Converts an AudioBuffer object into a WAV file in the form of a binary blob.
+ * The resulting WAV file can be used for audio playback or further processing.
+ * The function takes two parameters: audioBuffer which represents the audio
+ * data, and as 32BitFloat which indicates whether the WAV file should be
+ * encoded as 32-bit float or 16-bit integer PCM. The function performs various
+ * calculations and writes the necessary headers and data to create the WAV
+ * file. Finally, it returns the WAV file as a Blob object with the MIME type
+ * audio/wave.
+ *
+ * @param {AudioBuffer} audioBuffer
+ * @param {Boolean} as32BitFloat
+ * @return {Blob} Resulting binary blob.
+ */
+export const audioBufferToWav = (audioBuffer, as32BitFloat) => {
+ // Encoding setup.
+ const frameLength = audioBuffer.length;
+ const numberOfChannels = audioBuffer.numberOfChannels;
+ const sampleRate = audioBuffer.sampleRate;
+ const bitsPerSample = as32BitFloat ? 32 : 16;
+ const bytesPerSample = bitsPerSample / 8;
+ const byteRate = sampleRate * numberOfChannels * bitsPerSample / 8;
+ const blockAlign = numberOfChannels * bitsPerSample / 8;
+ const wavDataByteLength = frameLength * numberOfChannels * bytesPerSample;
+ const headerByteLength = 44;
+ const totalLength = headerByteLength + wavDataByteLength;
+ const waveFileData = new Uint8Array(totalLength);
+ const subChunk1Size = 16;
+ const subChunk2Size = wavDataByteLength;
+ const chunkSize = 4 + (8 + subChunk1Size) + (8 + subChunk2Size);
+
+ _writeStringToArray('RIFF', waveFileData, 0);
+ _writeInt32ToArray(chunkSize, waveFileData, 4);
+ _writeStringToArray('WAVE', waveFileData, 8);
+ _writeStringToArray('fmt ', waveFileData, 12);
+
+ // SubChunk1Size (4)
+ _writeInt32ToArray(subChunk1Size, waveFileData, 16);
+ // AudioFormat (2): 3 means 32-bit float, 1 means integer PCM.
+ _writeInt16ToArray(as32BitFloat ? 3 : 1, waveFileData, 20);
+ // NumChannels (2)
+ _writeInt16ToArray(numberOfChannels, waveFileData, 22);
+ // SampleRate (4)
+ _writeInt32ToArray(sampleRate, waveFileData, 24);
+ // ByteRate (4)
+ _writeInt32ToArray(byteRate, waveFileData, 28);
+ // BlockAlign (2)
+ _writeInt16ToArray(blockAlign, waveFileData, 32);
+ // BitsPerSample (4)
+ _writeInt32ToArray(bitsPerSample, waveFileData, 34);
+ _writeStringToArray('data', waveFileData, 36);
+ // SubChunk2Size (4)
+ _writeInt32ToArray(subChunk2Size, waveFileData, 40);
+
+ // Write actual audio data starting at offset 44.
+ _writeAudioBufferToArray(audioBuffer, waveFileData, 44, bitsPerSample);
+
+ return new Blob([waveFileData], {
+ type: 'audio/wav',
+ });
+};
diff --git a/src/rainfly/src/lib/utils/audio-host.js b/src/rainfly/src/lib/utils/audio-host.js
new file mode 100644
index 000000000..5f7efc675
--- /dev/null
+++ b/src/rainfly/src/lib/utils/audio-host.js
@@ -0,0 +1,145 @@
+// import AsyncFunction
+const AsyncFunction = Object.getPrototypeOf(async function() {}).constructor;
+
+/** @type {Array>} */
+export let recordedSamples = [[]];
+
+/** @type {AudioContext | undefined} */
+let context;
+/** @type {AudioWorkletNode | undefined} */
+let recorder;
+let sampleRate = 48000;
+let _blobUrl = '';
+
+// -----------------------------------------------------------------------------
+// EDITOR CODE HANDLING
+// -----------------------------------------------------------------------------
+/**
+ * Replace addModule url to be blobUrl for AudioWorkletNode
+ * @param {string} code - code containing addModule function
+ * @return {string} code with addModule url replaced to blobUrl
+ */
+function transformWorkletModuleUrl(code) {
+ if (_blobUrl === '') {
+ return code;
+ }
+ return code.replace(/addModule\(["'].*?["']\)/, `addModule('${_blobUrl}')`);
+}
+
+/**
+ * Find and replace all instances of a string in a code block
+ * @param {string} code - code block to search
+ * @param {string} find - string to find
+ * @param {string} replace - string to replace
+ * @return {string} transformed code block
+ */
+function findReplace(code, find, replace) {
+ return code.replace(new RegExp(find, 'g'), replace);
+}
+
+/**
+ * Parse parameter from header code comments
+ * @param {string} code - code to parse
+ * @param {string} paramName - syntax: // @paramName = value
+ * @return {number | null} parsed value or null
+ */
+function parseParam(code, paramName) {
+ const regex = new RegExp(`@${paramName}\\s*=\\s*(\\d+)`);
+ const match = code.match(regex);
+ return match ? parseInt(match[1], 10) : null;
+}
+
+/**
+ * Run AudioWorkletProcessor code to build an AudioWorkletNode
+ * @param {string} code - Processor code to run
+ */
+export function runProcessorCode(code) {
+ _blobUrl = window.URL.createObjectURL(
+ new Blob([code], {type: 'text/javascript'}),
+ );
+}
+
+/**
+ * Run AudioContext code to execute Web Audio Code. If this code contains
+ * AudioWorklet instantiation, `runProcessorCode` must be run first.
+ * @param {string} code - AudioContext Graph code to run
+ */
+export async function runMainCode(code) {
+ await context?.close();
+
+ const tryParseSampleRate = parseParam(code, 'sampleRate');
+ sampleRate = tryParseSampleRate ? tryParseSampleRate : sampleRate;
+
+ await createContext();
+
+ let transformCode = transformWorkletModuleUrl(code);
+ transformCode = findReplace(transformCode,
+ 'context.destination',
+ 'recorder).connect(context.destination');
+
+ const evalFunction = new AsyncFunction('context', 'sampleRate', 'recorder',
+ transformCode);
+ await evalFunction(context, sampleRate, recorder);
+}
+
+// -----------------------------------------------------------------------------
+// AUDIO CONTEXT
+// -----------------------------------------------------------------------------
+/**
+ * Create an AudioContext and all the essentials for Rainfly audio processing
+ */
+async function createContext() {
+ context = new AudioContext({sampleRate});
+ await context.audioWorklet.addModule('processor/recorder-processor.js');
+ recorder = new AudioWorkletNode(context, 'recorder-processor');
+ recorder.port.onmessage = (event) => {
+ // if channel doesn't exist, create it with empty array
+ if (!(event.data.channel in recordedSamples)) {
+ recordedSamples[event.data.channel] = [];
+ }
+ recordedSamples[event.data.channel].push(...event.data.data);
+ };
+}
+
+/**
+ * Resume the AudioContext
+ */
+export function resumeContext() {
+ context?.resume();
+}
+
+/**
+ * Suspend the AudioContext
+ */
+export function suspendContext() {
+ context?.suspend();
+}
+
+/**
+ * Destroy the AudioContext
+ */
+export function stopContext() {
+ context?.close();
+ context = undefined;
+ recorder = undefined;
+ recordedSamples = [[]];
+}
+
+// -----------------------------------------------------------------------------
+// RECORDER
+// -----------------------------------------------------------------------------
+/**
+ * Get current recorded samples
+ * @return {Array>} - The recorded samples
+ */
+export function getRecordedSamples() {
+ return recordedSamples;
+}
+
+/**
+ * Returns the current sample rate of the AudioContext
+ * @return {number} - The current sample rate
+ */
+export function getSampleRate() {
+ return sampleRate;
+}
diff --git a/src/rainfly/src/lib/utils/click-outside.js b/src/rainfly/src/lib/utils/click-outside.js
new file mode 100644
index 000000000..4fb6a888b
--- /dev/null
+++ b/src/rainfly/src/lib/utils/click-outside.js
@@ -0,0 +1,11 @@
+import {nodes} from '$lib/actions/click-outside';
+
+const clickOutsideListener = (/** @type {Event} */ e) =>
+ [...nodes.entries()].forEach(([node, callback]) =>
+ node !== e.target && callback({
+ ...e,
+ currentTarget: node,
+ }),
+ );
+
+export default clickOutsideListener;
diff --git a/src/rainfly/src/lib/utils/file-utils.js b/src/rainfly/src/lib/utils/file-utils.js
new file mode 100644
index 000000000..8d0422afa
--- /dev/null
+++ b/src/rainfly/src/lib/utils/file-utils.js
@@ -0,0 +1,43 @@
+/**
+ * @fileoverview Common utility function for working with files
+ */
+
+import JSZip from 'jszip';
+
+/**
+ * Fetch a file from the given URL and return name and text data
+ * @param {string} url - URL of the file to fetch
+ * @return {Promise<{name: string, data: string}>} - Promise that resolves to
+ * an object with the name and data of the file
+ */
+export async function fetchTextFile(url) {
+ let filename = url.split('/').pop();
+ if (filename === undefined || filename === '') {
+ filename = url;
+ }
+
+ try {
+ const response = await fetch(url);
+ if (!response.ok) {
+ throw new Error(`Failed to fetch ${url}`);
+ }
+ const data = await response.text();
+ return {name: filename, data};
+ } catch (/** @type any */ error) {
+ return {name: filename, data: 'error: '+ error.message};
+ }
+}
+
+/**
+ * Zip multiple text files into a single blob
+ * @param {Object.} files - JSON object of
+ * filenames and data
+ * @return {Promise} - Promise to blob of zipped files
+ */
+export async function zipTextFiles(files) {
+ const zip = new JSZip();
+ for (const [filename, data] of Object.entries(files)) {
+ zip.file(filename, data);
+ }
+ return zip.generateAsync({type: 'blob'});
+}
diff --git a/src/rainfly/src/lib/utils/monaco.js b/src/rainfly/src/lib/utils/monaco.js
new file mode 100644
index 000000000..bc0659fca
--- /dev/null
+++ b/src/rainfly/src/lib/utils/monaco.js
@@ -0,0 +1,24 @@
+/**
+ * @fileoverview Prepare Monaco Editor API for Vite usage
+ */
+import * as monaco from 'monaco-editor';
+
+import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
+import tsWorker
+ from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker';
+
+self.MonacoEnvironment = {
+ getWorker: function(_id, label) {
+ switch (label) {
+ case 'typescript':
+ case 'javascript':
+ // eslint-disable-next-line new-cap
+ return new tsWorker();
+ default:
+ // eslint-disable-next-line new-cap
+ return new editorWorker();
+ }
+ },
+};
+
+export default monaco;
diff --git a/src/rainfly/src/routes/+layout.js b/src/rainfly/src/routes/+layout.js
new file mode 100644
index 000000000..189f71e2e
--- /dev/null
+++ b/src/rainfly/src/routes/+layout.js
@@ -0,0 +1 @@
+export const prerender = true;
diff --git a/src/rainfly/src/routes/+layout.svelte b/src/rainfly/src/routes/+layout.svelte
new file mode 100644
index 000000000..fbe5576ff
--- /dev/null
+++ b/src/rainfly/src/routes/+layout.svelte
@@ -0,0 +1,9 @@
+
+
+
+ Rainfly
+
+
+
diff --git a/src/rainfly/src/routes/+page.svelte b/src/rainfly/src/routes/+page.svelte
new file mode 100644
index 000000000..f893769e6
--- /dev/null
+++ b/src/rainfly/src/routes/+page.svelte
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/rainfly/static/examples/bypass/main.js b/src/rainfly/static/examples/bypass/main.js
new file mode 100644
index 000000000..1ce274dcd
--- /dev/null
+++ b/src/rainfly/static/examples/bypass/main.js
@@ -0,0 +1,9 @@
+// Use `context` as the AudioContext
+// @sampleRate = 48000
+
+const oscillator = new OscillatorNode(context);
+await context.audioWorklet.addModule('processor.js');
+const workletNode = new AudioWorkletNode(context, 'bypass-processor');
+
+oscillator.connect(workletNode).connect(context.destination);
+oscillator.start();
diff --git a/src/rainfly/static/examples/bypass/processor.js b/src/rainfly/static/examples/bypass/processor.js
new file mode 100644
index 000000000..ec91dd34d
--- /dev/null
+++ b/src/rainfly/static/examples/bypass/processor.js
@@ -0,0 +1,15 @@
+class BypassProcessor extends AudioWorkletProcessor {
+ process(inputs, outputs) {
+ const input = inputs[0];
+ const output = outputs[0];
+ for (let channel = 0; channel < input.length; ++channel) {
+ for (let i = 0; i < input[0].length; ++i) {
+ output[channel][i] = input[channel][i];
+ }
+ }
+
+ return true;
+ }
+}
+
+registerProcessor('bypass-processor', BypassProcessor);
diff --git a/src/rainfly/static/examples/examples.json b/src/rainfly/static/examples/examples.json
new file mode 100644
index 000000000..06fb2b58d
--- /dev/null
+++ b/src/rainfly/static/examples/examples.json
@@ -0,0 +1,14 @@
+{
+ "examples": [
+ {
+ "name": "Hello Bypass",
+ "mainCodeUrl": "examples/bypass/main.js",
+ "processorCodeUrl": "examples/bypass/processor.js"
+ },
+ {
+ "name": "Hello Sine",
+ "mainCodeUrl": "examples/sine/main.js",
+ "processorCodeUrl": "examples/sine/processor.js"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/rainfly/static/examples/sine/main.js b/src/rainfly/static/examples/sine/main.js
new file mode 100644
index 000000000..9bfe14358
--- /dev/null
+++ b/src/rainfly/static/examples/sine/main.js
@@ -0,0 +1,7 @@
+// Use `context` as the AudioContext
+// @sampleRate = 48000
+
+await context.audioWorklet.addModule('processor.js');
+const sineWorkletNode = new AudioWorkletNode(context, 'sine-processor');
+sineWorkletNode.parameters.get('frequency').value = 440;
+sineWorkletNode.connect(context.destination);
diff --git a/src/rainfly/static/examples/sine/processor.js b/src/rainfly/static/examples/sine/processor.js
new file mode 100644
index 000000000..1dd79ecad
--- /dev/null
+++ b/src/rainfly/static/examples/sine/processor.js
@@ -0,0 +1,31 @@
+class SineProcessor extends AudioWorkletProcessor {
+ static get parameterDescriptors() {
+ return [
+ { name: 'frequency', defaultValue: 440, },
+ ];
+ }
+
+ constructor() {
+ super();
+ this.phase = 0;
+ this.inverseSampleRate = 1 / sampleRate;
+ }
+
+ process(inputs, outputs, parameters) {
+ const output = outputs[0];
+ const frequency = parameters.frequency[0];
+
+ for (let i = 0; i < output[0].length; ++i) {
+ output[0][i] = Math.sin(2 * Math.PI * frequency * this.phase);
+ this.phase += this.inverseSampleRate;
+ }
+
+ for (let channel = 1; channel < output.length; ++channel) {
+ output[channel].set(output[0]);
+ }
+
+ return true;
+ }
+}
+
+registerProcessor('sine-processor', SineProcessor);
diff --git a/src/rainfly/static/favicon.svg b/src/rainfly/static/favicon.svg
new file mode 100644
index 000000000..b7794c8d5
--- /dev/null
+++ b/src/rainfly/static/favicon.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/src/rainfly/static/processor/recorder-processor.js b/src/rainfly/static/processor/recorder-processor.js
new file mode 100644
index 000000000..d61693915
--- /dev/null
+++ b/src/rainfly/static/processor/recorder-processor.js
@@ -0,0 +1,18 @@
+/**
+ * RecorderProcessor records samples on the fly and streams them to the main thread.
+ */
+class RecorderProcessor extends AudioWorkletProcessor {
+ process(inputs, outputs) {
+ const input = inputs[0];
+ const output = outputs[0];
+
+ for (let channel = 0; channel < input.length; channel++) {
+ output[channel].set(input[channel]);
+ this.port.postMessage({ channel, data: input[channel] });
+ }
+
+ return true;
+ }
+}
+
+registerProcessor('recorder-processor', RecorderProcessor);
\ No newline at end of file
diff --git a/src/rainfly/static/splash.svg b/src/rainfly/static/splash.svg
new file mode 100644
index 000000000..ef86e5c99
--- /dev/null
+++ b/src/rainfly/static/splash.svg
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/rainfly/svelte.config.js b/src/rainfly/svelte.config.js
new file mode 100644
index 000000000..3b353f900
--- /dev/null
+++ b/src/rainfly/svelte.config.js
@@ -0,0 +1,24 @@
+import adapter from '@sveltejs/adapter-static';
+import {vitePreprocess} from '@sveltejs/vite-plugin-svelte';
+
+const BUILD_DIR = '/rainfly';
+
+/** @type {import('@sveltejs/kit').Config} */
+const config = {
+ kit: {
+ // adapter-auto only supports some environments, see
+ // https://kit.svelte.dev/docs/adapter-auto for a list.
+ // If your environment is not supported, or you settled
+ // on a specific environment, switch out the adapter. See
+ // https://kit.svelte.dev/docs/adapters for more information
+ // about adapters.
+ adapter: adapter(),
+ appDir: 'app',
+ paths: {
+ base: process.argv.includes('dev') ? '' : BUILD_DIR,
+ },
+ },
+ preprocess: vitePreprocess(),
+};
+
+export default config;
diff --git a/src/rainfly/tailwind.config.js b/src/rainfly/tailwind.config.js
new file mode 100644
index 000000000..3c2b13439
--- /dev/null
+++ b/src/rainfly/tailwind.config.js
@@ -0,0 +1,22 @@
+// noinspection JSUnusedGlobalSymbols
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: ['./src/**/*.{html,js,svelte,ts}'],
+ theme: {
+ extend: {
+ colors: {
+ primary: '#94E170',
+ secondary: '#FFFBD3',
+ accent: '#FD9494',
+ },
+ fontFamily: {
+ sans: ['"Outfit"', 'sans-serif'],
+ mono: ['"Anonymous Pro"', 'monospace'],
+ },
+ gridTemplateRows: {
+ main: '3rem minmax(0, 4fr) minmax(0, 6fr)',
+ },
+ },
+ },
+ plugins: [],
+};
diff --git a/src/rainfly/vite.config.js b/src/rainfly/vite.config.js
new file mode 100644
index 000000000..8aa53da74
--- /dev/null
+++ b/src/rainfly/vite.config.js
@@ -0,0 +1,11 @@
+import {sveltekit} from '@sveltejs/kit/vite';
+import {defineConfig} from 'vite';
+import {readFileSync} from 'node:fs';
+
+export default defineConfig({
+ plugins: [sveltekit()],
+ // REF: https://stackoverflow.com/a/72141502
+ define: {
+ meta: {version: JSON.parse(readFileSync('package.json', 'utf8')).version},
+ },
+});