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 +
+ Rainfly logo +
+ +**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 @@ + + + +
+
+
+ {editorType === EditorTypes.processor ? 'AudioWorkletProcessor' : 'Main'} +
+ {#if editorType === EditorTypes.main} + + {/if} +
+
+
+
+
+
+ +{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 @@ + + + + + + + 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 @@ + + + + + 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 @@ + + + + +rainfly logo 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

+
+
+ Rainfly logo +
+
+
+ + +

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}, + }, +});