├── js ├── packages │ ├── core │ │ ├── src │ │ │ ├── Symbol.ts │ │ │ ├── shims │ │ │ │ └── RescriptPervasives.shim.ts │ │ │ ├── Events.ts │ │ │ ├── HashUtils.gen.ts │ │ │ ├── Reconciler.gen.ts │ │ │ ├── Hash.ts │ │ │ ├── HashUtils.bs.js │ │ │ ├── NodeRepr.bs.js │ │ │ ├── NodeRepr.gen.ts │ │ │ ├── HashUtils.res │ │ │ ├── Reconciler.bs.js │ │ │ ├── NodeRepr.res │ │ │ └── Reconciler.res │ │ ├── babel.config.cjs │ │ ├── tsconfig.json │ │ ├── __tests__ │ │ │ ├── lib.test.js │ │ │ ├── mc.test.js │ │ │ └── hashing.test.js │ │ ├── bsconfig.json │ │ ├── LICENSE.md │ │ ├── README.md │ │ ├── nodeUtils.ts │ │ ├── package.json │ │ └── lib │ │ │ ├── envelopes.ts │ │ │ ├── signals.ts │ │ │ ├── mc.ts │ │ │ ├── math.ts │ │ │ ├── dynamics.ts │ │ │ └── oscillators.ts │ ├── offline-renderer │ │ ├── babel.config.cjs │ │ ├── __tests__ │ │ │ ├── __snapshots__ │ │ │ │ ├── vfs.test.js.snap │ │ │ │ ├── delays.test.js.snap │ │ │ │ ├── time.test.js.snap │ │ │ │ ├── maxhold.test.js.snap │ │ │ │ ├── events.test.js.snap │ │ │ │ ├── sparseq2.test.js.snap │ │ │ │ ├── sampleseq.test.js.snap │ │ │ │ └── mc.test.js.snap │ │ │ ├── ref.test.js │ │ │ ├── events.test.js │ │ │ ├── gc.test.js │ │ │ ├── time.test.js │ │ │ ├── maxhold.test.js │ │ │ ├── delays.test.js │ │ │ ├── vfs.test.js │ │ │ ├── tap.test.js │ │ │ ├── sampleseq.test.js │ │ │ ├── offline-renderer.test.js │ │ │ └── sparseq2.test.js │ │ ├── scripts │ │ │ └── prebuild.sh │ │ ├── tsconfig.json │ │ ├── LICENSE.md │ │ ├── package.json │ │ └── README.md │ └── web-renderer │ │ ├── scripts │ │ └── prebuild.sh │ │ ├── tsconfig.json │ │ ├── test │ │ ├── vite.config.js │ │ ├── index.html │ │ ├── stdlib.test.js │ │ ├── mount.test.js │ │ └── vfs.test.js │ │ ├── tsup.config.js │ │ ├── LICENSE.md │ │ ├── package.json │ │ └── README.md ├── lerna.json ├── package.json └── README.md ├── cli ├── RealtimeMain.cpp ├── examples │ ├── package.json │ ├── 00_HelloSine.js │ ├── 02_StrangerThings.js │ └── 01_FMArp.js ├── BenchmarkMain.cpp ├── Benchmark.h ├── Realtime.h ├── CMakeLists.txt ├── README.md ├── Benchmark.cpp └── Realtime.cpp ├── .gitignore ├── runtime ├── CMakeLists.txt └── elem │ ├── builtins │ ├── helpers │ │ ├── FloatUtils.h │ │ ├── BufferUtils.h │ │ ├── BitUtils.h │ │ ├── Change.h │ │ ├── ValueHelpers.h │ │ ├── RefCountedPool.h │ │ └── GainFade.h │ ├── Noise.h │ ├── Oscillators.h │ ├── Table.h │ ├── mc │ │ └── Table.h │ ├── Capture.h │ ├── filters │ │ ├── MultiMode1p.h │ │ ├── SVF.h │ │ └── SVFShelf.h │ └── SparSeq2.h │ ├── ElemAssert.h │ ├── AudioBufferResource.h │ ├── SharedResource.h │ ├── SingleWriterSingleReaderQueue.h │ └── MultiChannelRingBuffer.h ├── CMakeLists.txt ├── .gitmodules ├── wasm ├── SampleTime.h ├── pre.js ├── CMakeLists.txt ├── Metro.h ├── Convolve.h └── FFT.h ├── LICENSE.md ├── .github └── workflows │ └── main.yml └── scripts └── build-wasm.sh /js/packages/core/src/Symbol.ts: -------------------------------------------------------------------------------- 1 | export type NodeRepr_symbol = Symbol; 2 | -------------------------------------------------------------------------------- /js/lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": [ 3 | "packages/*" 4 | ], 5 | "version": "independent" 6 | } 7 | -------------------------------------------------------------------------------- /js/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "root", 3 | "private": true, 4 | "devDependencies": { 5 | "lerna": "^6.6.0" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /js/packages/core/babel.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [['@babel/preset-env', {targets: {node: 'current'}}]], 3 | }; 4 | -------------------------------------------------------------------------------- /cli/RealtimeMain.cpp: -------------------------------------------------------------------------------- 1 | #include "Realtime.h" 2 | 3 | 4 | int main(int argc, char **argv) 5 | { 6 | return RealtimeMain(argc, argv, [](auto&) {}); 7 | } 8 | -------------------------------------------------------------------------------- /js/packages/offline-renderer/babel.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', {targets: {node: 'current'}}], 4 | '@babel/preset-typescript', 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | dist 3 | node_modules 4 | 5 | .merlin 6 | .cljs_node_repl 7 | .cpcache 8 | .cache 9 | .DS_Store 10 | 11 | *.swp 12 | 13 | js/**/lib/bs 14 | js/packages/**/binaries/ 15 | js/packages/**/*.min.js 16 | -------------------------------------------------------------------------------- /js/packages/offline-renderer/__tests__/__snapshots__/vfs.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`vfs sample 1`] = ` 4 | Float32Array [ 5 | 1, 6 | 2, 7 | 3, 8 | 4, 9 | 5, 10 | ] 11 | `; 12 | -------------------------------------------------------------------------------- /cli/examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "scripts": { 4 | "build": "esbuild *.js --bundle --outdir=dist" 5 | }, 6 | "dependencies": { 7 | "@elemaudio/core": "^3.2", 8 | "esbuild": "^0.17.8" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /js/packages/core/src/shims/RescriptPervasives.shim.ts: -------------------------------------------------------------------------------- 1 | export abstract class EmptyList { 2 | protected opaque: any; 3 | } 4 | 5 | export abstract class Cons { 6 | protected opaque!: T; 7 | } 8 | 9 | export type list = Cons | EmptyList; 10 | -------------------------------------------------------------------------------- /js/packages/web-renderer/scripts/prebuild.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -x 4 | set -e 5 | 6 | ROOT_DIR="$(git rev-parse --show-toplevel)" 7 | CURRENT_DIR="$(pwd)" 8 | 9 | 10 | pushd "$ROOT_DIR" 11 | ./scripts/build-wasm.sh -o "$CURRENT_DIR/raw/elementary-wasm.js" 12 | popd 13 | -------------------------------------------------------------------------------- /js/packages/offline-renderer/scripts/prebuild.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -x 4 | set -e 5 | 6 | ROOT_DIR="$(git rev-parse --show-toplevel)" 7 | CURRENT_DIR="$(pwd)" 8 | 9 | 10 | pushd "$ROOT_DIR" 11 | ./scripts/build-wasm.sh -a -o "$CURRENT_DIR/elementary-wasm.cjs" 12 | popd 13 | -------------------------------------------------------------------------------- /js/packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "declaration": true, 5 | "target": "es6", 6 | "module": "es6", 7 | "moduleResolution": "node", 8 | "allowSyntheticDefaultImports": true 9 | }, 10 | "files": [ 11 | "index.ts", 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /runtime/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.15) 2 | project(runtime VERSION 0.11.5) 3 | 4 | set (TargetName runtime) 5 | 6 | add_library(${TargetName} INTERFACE) 7 | 8 | target_include_directories(${TargetName} INTERFACE 9 | ${CMAKE_CURRENT_SOURCE_DIR}) 10 | 11 | add_library(elem::${TargetName} ALIAS ${TargetName}) 12 | -------------------------------------------------------------------------------- /js/packages/web-renderer/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist", 4 | "allowJs": true, 5 | "declaration": true, 6 | "target": "es6", 7 | "module": "es6", 8 | "moduleResolution": "node", 9 | "allowSyntheticDefaultImports": true 10 | }, 11 | "files": [ 12 | "index.ts", 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /js/packages/offline-renderer/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist", 4 | "allowJs": true, 5 | "declaration": true, 6 | "target": "es6", 7 | "module": "es2020", 8 | "moduleResolution": "node", 9 | "allowSyntheticDefaultImports": true 10 | }, 11 | "files": [ 12 | "index.ts", 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /js/packages/core/__tests__/lib.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | el, 3 | } from '..'; 4 | 5 | 6 | test('errors on graph construction', function() { 7 | expect(() => { 8 | // Missing second argument, should throw 9 | let p = el.seq({}, 1); 10 | }).toThrow(); 11 | 12 | expect(() => { 13 | // Invalid node types 14 | let p = el.mul(1, 2, '4'); 15 | }).toThrow(); 16 | }); 17 | -------------------------------------------------------------------------------- /CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.15) 2 | project(elementary VERSION 0.11.5) 3 | 4 | 5 | option(ONLY_BUILD_WASM "Only build the wasm subdirectory" OFF) 6 | set(CMAKE_EXPORT_COMPILE_COMMANDS TRUE) 7 | 8 | if(${ONLY_BUILD_WASM}) 9 | add_subdirectory(runtime) 10 | add_subdirectory(wasm) 11 | else() 12 | add_subdirectory(runtime) 13 | add_subdirectory(cli) 14 | endif() 15 | -------------------------------------------------------------------------------- /js/packages/web-renderer/test/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | 3 | export default defineConfig({ 4 | optimizeDeps: { 5 | include: ['@elemaudio/core'], 6 | force: true, 7 | }, 8 | test: { 9 | browser: { 10 | enabled: true, 11 | name: 'firefox', // There's a bug with webdriver downloading chrome right now 12 | headless: true, 13 | }, 14 | }, 15 | }) 16 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "deps/FFTConvolver"] 2 | path = wasm/FFTConvolver 3 | url = https://github.com/HiFi-LoFi/FFTConvolver.git 4 | [submodule "choc"] 5 | path = cli/choc 6 | url = https://github.com/nick-thompson/choc.git 7 | [submodule "runtime/elem/third-party/signalsmith-stretch"] 8 | path = runtime/elem/third-party/signalsmith-stretch 9 | url = https://github.com/Signalsmith-Audio/signalsmith-stretch.git 10 | -------------------------------------------------------------------------------- /runtime/elem/builtins/helpers/FloatUtils.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | 4 | namespace elem 5 | { 6 | 7 | template 8 | FloatType lerp (FloatType alpha, FloatType x, FloatType y) { 9 | return x + alpha * (y - x); 10 | } 11 | 12 | template 13 | FloatType fpEqual (FloatType x, FloatType y) { 14 | return std::abs(x - y) <= FloatType(1e-6); 15 | } 16 | 17 | } // namespace elem 18 | -------------------------------------------------------------------------------- /runtime/elem/builtins/helpers/BufferUtils.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | 4 | namespace elem 5 | { 6 | 7 | namespace util 8 | { 9 | template 10 | void copy_cast_n(FromType const* input, size_t numSamples, ToType* output) 11 | { 12 | for (size_t i = 0; i < numSamples; ++i) { 13 | output[i] = static_cast(input[i]); 14 | } 15 | } 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /runtime/elem/ElemAssert.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | 4 | #ifndef ELEM_BLOCK 5 | #define ELEM_BLOCK(x) do { x } while (false) 6 | #endif 7 | 8 | #ifndef ELEM_ASSERT 9 | #include 10 | #define ELEM_ASSERT(x) ELEM_BLOCK(assert(x);) 11 | #endif 12 | 13 | #ifndef ELEM_ASSERT_FALSE 14 | #define ELEM_ASSERT_FALSE ELEM_ASSERT(false) 15 | #endif 16 | 17 | #ifndef ELEM_RETURN_IF 18 | #define ELEM_RETURN_IF(cond, val) ELEM_BLOCK( if (cond) { return (val); } ) 19 | #endif 20 | -------------------------------------------------------------------------------- /runtime/elem/builtins/helpers/BitUtils.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | 4 | namespace elem 5 | { 6 | 7 | // Returns the smallest power of two that is greater than or equal to 8 | // the given integer `n` 9 | inline int bitceil (int n) { 10 | if ((n & (n - 1)) == 0) 11 | return n; 12 | 13 | int o = 1; 14 | 15 | while (o < n) { 16 | o = o << 1; 17 | } 18 | 19 | return o; 20 | } 21 | 22 | } // namespace elem 23 | -------------------------------------------------------------------------------- /js/packages/offline-renderer/__tests__/__snapshots__/delays.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`delay basics 1`] = ` 4 | Float32Array [ 5 | 0, 6 | 0.5, 7 | 1, 8 | 1.5, 9 | ] 10 | `; 11 | 12 | exports[`sdelay basics 1`] = ` 13 | Float32Array [ 14 | 0, 15 | 0, 16 | 0, 17 | 0, 18 | 0, 19 | 0, 20 | 0, 21 | 0, 22 | 0, 23 | 0, 24 | 1, 25 | 2, 26 | 3, 27 | 4, 28 | 4, 29 | 3, 30 | 2, 31 | 1, 32 | 0, 33 | 0, 34 | 0, 35 | 0, 36 | 0, 37 | 0, 38 | ] 39 | `; 40 | -------------------------------------------------------------------------------- /cli/BenchmarkMain.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #include "Benchmark.h" 5 | 6 | 7 | int main(int argc, char **argv) 8 | { 9 | // Read the input file from disk 10 | if (argc < 2) { 11 | std::cout << "Missing argument: what file do you want to run?" << std::endl; 12 | return 1; 13 | } 14 | 15 | auto inputFileName = std::string(argv[1]); 16 | 17 | runBenchmark("Float", inputFileName, [](auto&) {}); 18 | runBenchmark("Double", inputFileName, [](auto&) {}); 19 | 20 | return 0; 21 | } 22 | -------------------------------------------------------------------------------- /js/packages/offline-renderer/__tests__/__snapshots__/time.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`time node 1`] = ` 4 | Float32Array [ 5 | 5120, 6 | 5121, 7 | 5122, 8 | 5123, 9 | 5124, 10 | 5125, 11 | 5126, 12 | 5127, 13 | 5128, 14 | 5129, 15 | 5130, 16 | 5131, 17 | 5132, 18 | 5133, 19 | 5134, 20 | 5135, 21 | 5136, 22 | 5137, 23 | 5138, 24 | 5139, 25 | 5140, 26 | 5141, 27 | 5142, 28 | 5143, 29 | 5144, 30 | 5145, 31 | 5146, 32 | 5147, 33 | 5148, 34 | 5149, 35 | 5150, 36 | 5151, 37 | ] 38 | `; 39 | -------------------------------------------------------------------------------- /cli/Benchmark.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | #include 6 | 7 | 8 | /* 9 | * Your main can call this function to run a complete benchmark test for either 10 | * float or double processing. Before the benchmark starts, your initCallback 11 | * will be called with a reference to the runtime for additional initialization, 12 | * like adding a custom node type or filling the shared resource map. 13 | */ 14 | template 15 | void runBenchmark(std::string const& name, std::string const& inputFileName, std::function&)>&& initCallback); 16 | -------------------------------------------------------------------------------- /js/packages/core/bsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "elementary-core", 3 | "sources": [ 4 | { 5 | "dir": "src", 6 | "subdirs": true 7 | } 8 | ], 9 | "package-specs": [ 10 | { 11 | "module": "es6", 12 | "in-source": true 13 | } 14 | ], 15 | "suffix": ".bs.js", 16 | "bs-dependencies": [], 17 | "gentypeconfig": { 18 | "language": "typescript", 19 | "shims": { 20 | "RescriptPervasives": "RescriptPervasives" 21 | }, 22 | "generatedFileExtension": ".gen.ts", 23 | "module": "es6", 24 | "debug": { 25 | "all": false, 26 | "basic": false 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /js/packages/web-renderer/tsup.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | import * as esbuild from 'esbuild'; 3 | import fs from 'node:fs'; 4 | import pkg from './package.json'; 5 | 6 | const LoadTextPlugin = { 7 | name: 'Load Raw Text', 8 | setup(build) { 9 | build.onLoad({ filter: /\/raw\/.*\.js/ }, async (args) => { 10 | let text = await fs.promises.readFile(args.path, 'utf8') 11 | return { 12 | contents: text.replace("__PKG_VERSION__", JSON.stringify(pkg.version)), 13 | loader: 'text', 14 | } 15 | }) 16 | }, 17 | }; 18 | 19 | export default defineConfig({ 20 | esbuildPlugins: [LoadTextPlugin], 21 | }) 22 | -------------------------------------------------------------------------------- /js/README.md: -------------------------------------------------------------------------------- 1 | # npm packages 2 | 3 | This directory manages the main `@elemaudio` packages available on npm via Lerna. 4 | We use Lerna's idea of versioning and publishing to manage our packages, with versioning 5 | done locally and publishing automatically from CI via Github Actions. 6 | 7 | To mark a new version: 8 | 9 | ```bash 10 | npx lerna version -m 'Publish v2.0.0' --no-push 11 | ``` 12 | 13 | Finally, from Github Actions we have a publish action which runs lerna's `from-package` feature 14 | to push any package versions to the registry that are not already there. 15 | 16 | ```bash 17 | # See our Github Actions workflow file 18 | npx lerna publish from-package 19 | ``` 20 | -------------------------------------------------------------------------------- /js/packages/core/src/Events.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'eventemitter3'; 2 | 3 | 4 | type EventTypes = { 5 | capture: (data: { source?: string, data: Float32Array }) => void; 6 | error: (error: Error) => void; 7 | fft: (data: { source?: string, data: { real: Float32Array, imag: Float32Array } }) => void; 8 | load: () => void; 9 | meter: (data: { source?: string, min: number; max: number, }) => void; 10 | scope: (data: { source?: string, data: Float32Array[] }) => void; 11 | snapshot: (data: { source?: string, data: number }) => void; 12 | }; 13 | 14 | export default class extends EventEmitter { 15 | constructor() { 16 | super(); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /cli/Realtime.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | #include 6 | 7 | /* 8 | * Your main can call this function to execute the realtime command line loop. Before audio playback 9 | * starts, your initCallback will be called with a reference to the elem::Runtime instance 10 | * for your own initialization needs (i.e. registering new node types or adding shared resources). 11 | * 12 | * The Realtime loop runs only on float runtimes for now, so to avoid a template explosion 13 | * use a specialized runtime float in this callback. 14 | */ 15 | extern int RealtimeMain(int argc, char **argv, std::function &)> initCallback); 16 | -------------------------------------------------------------------------------- /js/packages/core/src/HashUtils.gen.ts: -------------------------------------------------------------------------------- 1 | /* TypeScript file generated from HashUtils.res by genType. */ 2 | /* eslint-disable import/first */ 3 | 4 | 5 | // @ts-ignore: Implicit any on import 6 | import * as HashUtilsBS__Es6Import from './HashUtils.bs'; 7 | const HashUtilsBS: any = HashUtilsBS__Es6Import; 8 | 9 | import type {Dict_t as Js_Dict_t} from './Js.gen'; 10 | 11 | import type {list} from '../src/shims/RescriptPervasives.shim'; 12 | 13 | export const hashString: (_1:number, _2:string) => number = HashUtilsBS.hashString; 14 | 15 | export const hashNode: (_1:string, _2:Js_Dict_t, _3:list) => number = HashUtilsBS.hashNode; 16 | 17 | export const hashMemoInputs: (_1:{ readonly memoKey: string }, _2:list) => number = HashUtilsBS.hashMemoInputs; 18 | -------------------------------------------------------------------------------- /js/packages/offline-renderer/__tests__/__snapshots__/maxhold.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`maxhold basics 1`] = ` 4 | Float32Array [ 5 | 1, 6 | 2, 7 | 3, 8 | 4, 9 | 4, 10 | 4, 11 | 4, 12 | ] 13 | `; 14 | 15 | exports[`maxhold hold time 1`] = ` 16 | Float32Array [ 17 | 1, 18 | 2, 19 | 3, 20 | 4, 21 | 4, 22 | 4, 23 | 4, 24 | 4, 25 | 4, 26 | 4, 27 | 4, 28 | 4, 29 | 4, 30 | 4, 31 | 4, 32 | 4, 33 | 4, 34 | 4, 35 | 4, 36 | 4, 37 | 4, 38 | 4, 39 | 4, 40 | 4, 41 | 4, 42 | 4, 43 | 4, 44 | 4, 45 | 4, 46 | 4, 47 | 4, 48 | 4, 49 | 4, 50 | 4, 51 | 4, 52 | 4, 53 | 4, 54 | 4, 55 | 4, 56 | 4, 57 | 4, 58 | 4, 59 | 4, 60 | 4, 61 | 4, 62 | 4, 63 | 4, 64 | 1, 65 | ] 66 | `; 67 | -------------------------------------------------------------------------------- /wasm/SampleTime.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | 6 | namespace elem 7 | { 8 | 9 | // A simple node which just emits the current sample time as a continuous 10 | // signal. 11 | template 12 | struct SampleTimeNode : public GraphNode { 13 | using GraphNode::GraphNode; 14 | 15 | void process (BlockContext const& ctx) override { 16 | auto* outputData = ctx.outputData[0]; 17 | auto numSamples = ctx.numSamples; 18 | auto sampleTime = *static_cast(ctx.userData); 19 | 20 | for (size_t i = 0; i < numSamples; ++i) { 21 | outputData[i] = static_cast(sampleTime + i); 22 | } 23 | } 24 | }; 25 | 26 | } // namespace elem 27 | -------------------------------------------------------------------------------- /js/packages/web-renderer/test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Mocha Tests 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 19 | 20 | 21 | 22 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /js/packages/web-renderer/test/stdlib.test.js: -------------------------------------------------------------------------------- 1 | import WebRenderer from '..'; 2 | import { el } from '@elemaudio/core'; 3 | import { expect, test } from 'vitest'; 4 | 5 | 6 | const audioContext = (typeof window !== 'undefined') && new (window.AudioContext || window.webkitAudioContext)(); 7 | 8 | 9 | test('std lib should have sparseq2', async function() { 10 | const core = new WebRenderer(); 11 | const node = await core.initialize(audioContext, { 12 | numberOfInputs: 0, 13 | numberOfOutputs: 1, 14 | outputChannelCount: [2], 15 | }); 16 | 17 | node.connect(audioContext.destination); 18 | let stats = await core.render(el.sparseq2({seq: [{ value: 0, time: 0 }]}, 1)); 19 | 20 | expect(stats.nodesAdded).toEqual(3); // root, sparseq, const 21 | expect(stats.edgesAdded).toEqual(2); 22 | expect(stats.propsWritten).toEqual(5); // root channel, fadeIn, fadeOut, sparseq seq, const value 23 | }); 24 | -------------------------------------------------------------------------------- /js/packages/offline-renderer/__tests__/__snapshots__/events.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`event propagation 1`] = ` 4 | [ 5 | { 6 | "max": 0, 7 | "min": 0, 8 | "source": undefined, 9 | }, 10 | { 11 | "max": 0, 12 | "min": 0, 13 | "source": undefined, 14 | }, 15 | { 16 | "max": 0, 17 | "min": 0, 18 | "source": undefined, 19 | }, 20 | { 21 | "max": 0, 22 | "min": 0, 23 | "source": undefined, 24 | }, 25 | ] 26 | `; 27 | 28 | exports[`event propagation 2`] = ` 29 | [ 30 | { 31 | "max": 1, 32 | "min": 1, 33 | "source": undefined, 34 | }, 35 | { 36 | "max": 1, 37 | "min": 1, 38 | "source": undefined, 39 | }, 40 | { 41 | "max": 1, 42 | "min": 1, 43 | "source": undefined, 44 | }, 45 | { 46 | "max": 1, 47 | "min": 1, 48 | "source": undefined, 49 | }, 50 | ] 51 | `; 52 | -------------------------------------------------------------------------------- /runtime/elem/builtins/helpers/Change.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | 4 | namespace elem 5 | { 6 | 7 | // A port of Max/MSP gen~'s change object 8 | // 9 | // Will report a value of 1 when the change from the most recent sample 10 | // to the current sample is increasing, a -1 whendecreasing, and a 0 11 | // when it holds the same. 12 | template 13 | struct Change { 14 | FloatType lastIn = 0; 15 | 16 | FloatType operator()(FloatType xn) { 17 | return tick(xn); 18 | } 19 | 20 | FloatType tick(FloatType xn) { 21 | FloatType dt = xn - lastIn; 22 | lastIn = xn; 23 | 24 | if (dt > FloatType(0)) 25 | return FloatType(1); 26 | 27 | if (dt < FloatType(0)) 28 | return FloatType(-1); 29 | 30 | return FloatType(0); 31 | } 32 | }; 33 | 34 | } // namespace elem 35 | -------------------------------------------------------------------------------- /cli/examples/00_HelloSine.js: -------------------------------------------------------------------------------- 1 | import {Renderer, el} from '@elemaudio/core'; 2 | 3 | 4 | // This example is the "Hello, world!" of writing audio processes in Elementary, and is 5 | // intended to be run by the simple cli tool provided in the repository. 6 | // 7 | // Because we know that our cli will open the audio device with a sample rate of 44.1kHz, 8 | // we can simply create a generic Renderer straight away and ask it to render our basic 9 | // example. 10 | // 11 | // The signal we're generating here is a simple sine tone via `el.cycle` at 440Hz in the left 12 | // channel and 441Hz in the right, creating some interesting binaural beating. Each sine tone is 13 | // then multiplied by 0.3 to apply some simple gain before going to the output. That's it! 14 | let core = new Renderer((batch) => { 15 | __postNativeMessage__(JSON.stringify(batch)); 16 | }); 17 | 18 | let stats = core.render( 19 | el.mul(0.3, el.cycle(440)), 20 | el.mul(0.3, el.cycle(441)), 21 | ); 22 | 23 | console.log(stats); 24 | -------------------------------------------------------------------------------- /js/packages/core/src/Reconciler.gen.ts: -------------------------------------------------------------------------------- 1 | /* TypeScript file generated from Reconciler.res by genType. */ 2 | /* eslint-disable import/first */ 3 | 4 | 5 | // @ts-ignore: Implicit any on import 6 | import * as Curry__Es6Import from 'rescript/lib/es6/curry.js'; 7 | const Curry: any = Curry__Es6Import; 8 | 9 | // @ts-ignore: Implicit any on import 10 | import * as ReconcilerBS__Es6Import from './Reconciler.bs'; 11 | const ReconcilerBS: any = ReconcilerBS__Es6Import; 12 | 13 | import type {t as NodeRepr_t} from './NodeRepr.gen'; 14 | 15 | // tslint:disable-next-line:max-classes-per-file 16 | export abstract class RenderDelegate_t { protected opaque!: any }; /* simulate opaque types */ 17 | 18 | export const renderWithDelegate: (delegate:RenderDelegate_t, graphs:NodeRepr_t[], rootFadeInMs:T1, rootFadeOutMs:T2) => void = function (Arg1: any, Arg2: any, Arg3: any, Arg4: any) { 19 | const result = Curry._4(ReconcilerBS.renderWithDelegate, Arg1, Arg2, Arg3, Arg4); 20 | return result 21 | }; 22 | -------------------------------------------------------------------------------- /cli/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.15) 2 | project(cli VERSION 0.11.0) 3 | 4 | add_library(elemcli_core STATIC Realtime.cpp Benchmark.cpp) 5 | 6 | target_include_directories(elemcli_core PUBLIC 7 | ${CMAKE_CURRENT_SOURCE_DIR} 8 | ${CMAKE_CURRENT_SOURCE_DIR}/choc/javascript 9 | ${CMAKE_CURRENT_SOURCE_DIR}/choc/text) 10 | 11 | target_compile_features(elemcli_core PUBLIC 12 | cxx_std_17) 13 | 14 | target_link_libraries(elemcli_core PUBLIC 15 | elem::runtime) 16 | 17 | if(MSVC) 18 | target_compile_options(elemcli_core PRIVATE /W4) 19 | else() 20 | target_compile_options(elemcli_core PRIVATE -Wall -Wextra) 21 | endif() 22 | 23 | add_executable(elemcli RealtimeMain.cpp) 24 | add_executable(elembench BenchmarkMain.cpp) 25 | 26 | target_link_libraries(elemcli PRIVATE elemcli_core) 27 | target_link_libraries(elembench PRIVATE elemcli_core) 28 | 29 | if(UNIX AND NOT APPLE) 30 | find_package(Threads REQUIRED) 31 | target_link_libraries(elemcli PRIVATE 32 | Threads::Threads 33 | ${CMAKE_DL_LIBS}) 34 | endif() 35 | 36 | -------------------------------------------------------------------------------- /wasm/pre.js: -------------------------------------------------------------------------------- 1 | // We use this to shim a crypto library in the WebAudioWorklet environment, which 2 | // is necessary for Emscripten's implementation of std::random_device, which we need 3 | // after pulling in signalsmith-stretch. 4 | // 5 | // I've seen some issues on esmcripten that seem to suggest an insecure polyfill like 6 | // this should already be provided, but I'm getting runtime assertion errors that it's 7 | // not available. An alternative solution would be to upstream a change to the stretch 8 | // library to rely on an injected random device in C++ territory, so that for our case 9 | // we could inject some simple PRNG. 10 | if (typeof globalThis?.crypto?.getRandomValues !== 'function') { 11 | function _shimGetRandomValues(array) { 12 | for (var i = 0; i < array.length; i++) { 13 | array[i] = (Math.random() * 256) | 0; 14 | } 15 | } 16 | 17 | if (typeof globalThis.crypto === 'object') { 18 | globalThis.crypto.getRandomValues = _shimGetRandomValues; 19 | } else { 20 | globalThis.crypto = { 21 | getRandomValues: _shimGetRandomValues, 22 | }; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /cli/examples/02_StrangerThings.js: -------------------------------------------------------------------------------- 1 | import {Renderer, el} from '@elemaudio/core'; 2 | 3 | 4 | // Much like the FM Arp example, this example demonstrates another case of building a simple 5 | // synth arp to recreate the classic Stranger Things synth line. 6 | let core = new Renderer((batch) => { 7 | __postNativeMessage__(JSON.stringify(batch)); 8 | }); 9 | 10 | let synthVoice = (hz) => el.mul( 11 | 0.25, 12 | el.add( 13 | el.blepsaw(el.mul(hz, 1.001)), 14 | el.blepsquare(el.mul(hz, 0.994)), 15 | el.blepsquare(el.mul(hz, 0.501)), 16 | el.blepsaw(el.mul(hz, 0.496)), 17 | ), 18 | ); 19 | 20 | let train = el.train(4.8); 21 | let arp = [0, 4, 7, 11, 12, 11, 7, 4].map(x => 261.63 * 0.5 * Math.pow(2, x / 12)); 22 | 23 | let modulate = (x, rate, amt) => el.add(x, el.mul(amt, el.cycle(rate))); 24 | let env = el.adsr(0.01, 0.5, 0, 0.4, train); 25 | let filt = (x) => el.lowpass( 26 | el.add(40, el.mul(modulate(1840, 0.05, 1800), env)), 27 | 1, 28 | x 29 | ); 30 | 31 | let out = el.mul(0.25, filt(synthVoice(el.seq({seq: arp, hold: true}, train, 0)))); 32 | let stats = core.render(out, out); 33 | -------------------------------------------------------------------------------- /js/packages/web-renderer/test/mount.test.js: -------------------------------------------------------------------------------- 1 | import WebRenderer from '..'; 2 | import { el } from '@elemaudio/core'; 3 | import { expect, test } from 'vitest'; 4 | 5 | 6 | const audioContext = (typeof window !== 'undefined') && new (window.AudioContext || window.webkitAudioContext)(); 7 | 8 | 9 | test('can mount sampleseq2', async function() { 10 | const core = new WebRenderer(); 11 | const node = await core.initialize(audioContext, { 12 | numberOfInputs: 0, 13 | numberOfOutputs: 1, 14 | outputChannelCount: [2], 15 | }); 16 | 17 | node.connect(audioContext.destination); 18 | expect(await core.render(el.sampleseq2({}, el.time()))).toMatchObject({ 19 | nodesAdded: 3, 20 | }); 21 | }); 22 | 23 | test('can mount sampleseq', async function() { 24 | const core = new WebRenderer(); 25 | const node = await core.initialize(audioContext, { 26 | numberOfInputs: 0, 27 | numberOfOutputs: 1, 28 | outputChannelCount: [2], 29 | }); 30 | 31 | node.connect(audioContext.destination); 32 | expect(await core.render(el.sampleseq({}, el.time()))).toMatchObject({ 33 | nodesAdded: 3, 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /js/packages/core/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 | -------------------------------------------------------------------------------- /js/packages/web-renderer/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 | -------------------------------------------------------------------------------- /js/packages/offline-renderer/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 | -------------------------------------------------------------------------------- /js/packages/offline-renderer/__tests__/ref.test.js: -------------------------------------------------------------------------------- 1 | import OfflineRenderer from '..'; 2 | import { el } from '@elemaudio/core'; 3 | 4 | 5 | test('refs', async function() { 6 | let core = new OfflineRenderer(); 7 | 8 | await core.initialize({ 9 | numInputChannels: 0, 10 | numOutputChannels: 1, 11 | }); 12 | 13 | // Graph 14 | let [cutoffFreq, setCutoffFreq] = core.createRef("const", {value: 500}, []); 15 | core.render(cutoffFreq); 16 | 17 | // Ten blocks of data 18 | let inps = []; 19 | let outs = [new Float32Array(512 * 10)]; 20 | 21 | // Get past the fade-in 22 | core.process(inps, outs); 23 | 24 | // Push another small block. We should see an incrementing output beginning 25 | // at 512 * 10 because of the blocks we've already pushed through 26 | inps = []; 27 | outs = [new Float32Array(8)]; 28 | 29 | core.process(inps, outs); 30 | expect(outs[0]).toEqual(Float32Array.from([500, 500, 500, 500, 500, 500, 500, 500])); 31 | 32 | // Now we can set the ref without doing a render pass 33 | setCutoffFreq({value: 800}); 34 | 35 | core.process(inps, outs); 36 | expect(outs[0]).toEqual(Float32Array.from([800, 800, 800, 800, 800, 800, 800, 800])); 37 | }); 38 | -------------------------------------------------------------------------------- /js/packages/core/src/Hash.ts: -------------------------------------------------------------------------------- 1 | import shallowEqual from 'shallowequal'; 2 | 3 | 4 | export function updateNodeProps(renderer, hash, prevProps, nextProps) { 5 | for (let key in nextProps) { 6 | if (nextProps.hasOwnProperty(key)) { 7 | const value = nextProps[key]; 8 | 9 | // We need to shallowEqual here to catch things like a `seq` node's sequence property 10 | // changing. Strict equality will always show two different arrays, even if their contents 11 | // describe the same sequence. Shallow equality solves that 12 | const shouldUpdate = !prevProps.hasOwnProperty(key) || !shallowEqual(prevProps[key], value); 13 | 14 | if (shouldUpdate) { 15 | // A quick helper for end users before we actually write the value to native 16 | const seemsInvalid = typeof value === 'undefined' 17 | || value === null 18 | || (typeof value === 'number' && isNaN(value)) 19 | || (typeof value === 'number' && !isFinite(value)); 20 | 21 | if (seemsInvalid) { 22 | console.warn(`Warning: applying a potentially erroneous property value. ${key}: ${value}`) 23 | } 24 | 25 | renderer.setProperty(hash, key, value); 26 | prevProps[key] = value; 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /js/packages/core/README.md: -------------------------------------------------------------------------------- 1 | # @elemaudio/core 2 | 3 | Elementary is a JavaScript/C++ library for building audio applications. 4 | 5 | The `@elemaudio/core` package provides the standard library for composing 6 | audio processing nodes, as well as the core graph reconciling and rendering utilities. Often this 7 | will be used with one of the provided renderers, `@elemaudio/web-renderer` or `@elemaudio/offline-renderer`. 8 | 9 | Please see the full documentation at [https://www.elementary.audio/](https://www.elementary.audio/) 10 | 11 | ## Installation 12 | 13 | ```js 14 | npm install --save @elemaudio/core 15 | ``` 16 | 17 | ## Usage 18 | 19 | ```js 20 | import { el, Renderer } from '@elemaudio/core'; 21 | 22 | 23 | // Here we're using a default Renderer instance, so it's our responsibility to 24 | // send the instruction batches to the underlying engine 25 | let core = new Renderer((batch) => { 26 | // Send the instruction batch somewhere: you can set up whatever message 27 | // passing channel you want! 28 | console.log(batch); 29 | }); 30 | 31 | // Now we can write and render audio. How about some binaural beating 32 | // with two detuned sine tones in the left and right channel: 33 | core.render(el.cycle(440), el.cycle(441)); 34 | ``` 35 | 36 | ## License 37 | 38 | MIT 39 | -------------------------------------------------------------------------------- /js/packages/offline-renderer/__tests__/events.test.js: -------------------------------------------------------------------------------- 1 | import OfflineRenderer from '..'; 2 | import { el } from '@elemaudio/core'; 3 | 4 | 5 | test('event propagation', async function() { 6 | let core = new OfflineRenderer(); 7 | 8 | await core.initialize({ 9 | numInputChannels: 0, 10 | numOutputChannels: 1, 11 | blockSize: 512, 12 | }); 13 | 14 | // Event handling 15 | let callback = jest.fn(); 16 | core.on('meter', callback); 17 | 18 | // Graph 19 | core.render(el.meter({}, 0)); 20 | 21 | // Processing 22 | let inps = []; 23 | let outs = [new Float32Array(512 * 4)]; 24 | 25 | core.process(inps, outs); 26 | 27 | // We just pushed four blocks of data through, we would expect the meter 28 | // node to fire on each block, thus we should see 4 calls to the meter 29 | // callback 30 | expect(callback.mock.calls).toHaveLength(4); 31 | expect(callback.mock.calls.map(x => x[0])).toMatchSnapshot(); 32 | 33 | callback.mockClear(); 34 | 35 | // New graph 36 | core.render(el.meter({}, 1)); 37 | 38 | // Processing 39 | core.process(inps, outs); 40 | 41 | // We just pushed four more blocks of data, we would expect the same 42 | // as above but this time the meter should be reporting the new value 1 43 | expect(callback.mock.calls).toHaveLength(4); 44 | expect(callback.mock.calls.map(x => x[0])).toMatchSnapshot(); 45 | }); 46 | -------------------------------------------------------------------------------- /js/packages/offline-renderer/__tests__/gc.test.js: -------------------------------------------------------------------------------- 1 | import OfflineRenderer from '..'; 2 | import { el } from '@elemaudio/core'; 3 | 4 | 5 | test('gc basics', async function() { 6 | let core = new OfflineRenderer(); 7 | 8 | await core.initialize({ 9 | numInputChannels: 0, 10 | numOutputChannels: 1, 11 | }); 12 | 13 | // Ten blocks of data 14 | let inps = []; 15 | let outs = [new Float32Array(512 * 10)]; 16 | 17 | await core.render(el.mul(2, 3)); 18 | core.process(inps, outs); 19 | 20 | // After the first render we should see 2 consts, a mul, and a root, 21 | // and the gc should clean up none of them because they're being used. 22 | let earlyMapKeys = Array.from(core._renderer._delegate.nodeMap.keys()); 23 | expect(await core.gc()).toEqual([]); 24 | 25 | // Now if we render and process twice, giving the earliest root enough 26 | // time to fade out and become dormant, we should see the gc pick up the 27 | // dormant nodes 28 | await core.render(el.mul(4, 5)); 29 | core.process(inps, outs); 30 | expect(await core.gc()).toEqual([]); 31 | 32 | await core.render(el.mul(6, 7)); 33 | core.process(inps, outs); 34 | 35 | let pruned = await core.gc(); 36 | expect(pruned.sort()).toEqual(earlyMapKeys.sort()); 37 | 38 | // We also expect that the renderer's nodeMap no longer contains the 39 | // pruned keys 40 | earlyMapKeys.forEach((k) => { 41 | expect(core._renderer._delegate.nodeMap.has(k)).toBe(false); 42 | }); 43 | }); 44 | 45 | -------------------------------------------------------------------------------- /js/packages/offline-renderer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@elemaudio/offline-renderer", 3 | "version": "4.0.3", 4 | "type": "module", 5 | "description": "Official package for rendering Elementary Audio applications offline", 6 | "author": "Nick Thompson ", 7 | "homepage": "https://www.elementary.audio", 8 | "license": "MIT", 9 | "main": "./dist/index.js", 10 | "module": "./dist/index.js", 11 | "types": "./dist/index.d.ts", 12 | "exports": { 13 | ".": { 14 | "types": "./dist/index.d.ts", 15 | "require": "./dist/index.cjs", 16 | "import": "./dist/index.js" 17 | }, 18 | "./package.json": "./package.json" 19 | }, 20 | "files": [ 21 | "dist/index.js", 22 | "dist/index.cjs", 23 | "dist/index.d.ts", 24 | "README.md", 25 | "LICENSE.md" 26 | ], 27 | "engines": { 28 | "node": ">=18" 29 | }, 30 | "scripts": { 31 | "wasm": "./scripts/prebuild.sh", 32 | "build": "tsup index.ts --format cjs,esm --dts", 33 | "snaps": "jest --updateSnapshot", 34 | "test": "jest" 35 | }, 36 | "jest": { 37 | "transformIgnorePatterns": [ 38 | "node_modules/(?!@elemaudio/core)" 39 | ] 40 | }, 41 | "devDependencies": { 42 | "@babel/preset-env": "^7.16.11", 43 | "@babel/preset-typescript": "^7.16.7", 44 | "jest": "^29.7.0", 45 | "tsup": "^8.3.5" 46 | }, 47 | "dependencies": { 48 | "@elemaudio/core": "^4.0.1", 49 | "invariant": "^2.2.4" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /runtime/elem/builtins/helpers/ValueHelpers.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "../../Invariant.h" 4 | 5 | 6 | namespace elem 7 | { 8 | namespace ValueHelpers 9 | { 10 | 11 | // Name says it all 12 | // 13 | // Just a utility for converting a js::Array to a std::vector 14 | template 15 | std::vector arrayToFloatVector (js::Array const& ar) 16 | { 17 | try { 18 | std::vector ret (ar.size()); 19 | 20 | for (size_t i = 0; i < ar.size(); ++i) { 21 | ret[i] = static_cast((js::Number) ar[i]); 22 | } 23 | 24 | return ret; 25 | } catch (std::exception const& e) { 26 | throw InvariantViolation("Failed to convert Array to float vector; invalid array child!"); 27 | } 28 | } 29 | 30 | // Same thing as above, but with in-place mutation 31 | template 32 | void arrayToFloatVector (std::vector& target, js::Array const& ar) 33 | { 34 | try { 35 | target.resize(ar.size()); 36 | 37 | for (size_t i = 0; i < ar.size(); ++i) { 38 | target[i] = static_cast((js::Number) ar[i]); 39 | } 40 | } catch (std::exception const& e) { 41 | throw InvariantViolation("Failed to convert Array to float vector; invalid array child!"); 42 | } 43 | } 44 | 45 | 46 | } // namespace ValueHelpers 47 | } // namespace elem 48 | -------------------------------------------------------------------------------- /js/packages/web-renderer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@elemaudio/web-renderer", 3 | "version": "4.0.3", 4 | "type": "module", 5 | "description": "Official package for rendering Elementary Audio applications to Web Audio", 6 | "keywords": [ 7 | "audio", 8 | "dsp", 9 | "signal", 10 | "processing", 11 | "functional", 12 | "declarative", 13 | "webaudio" 14 | ], 15 | "author": "Nick Thompson ", 16 | "homepage": "https://www.elementary.audio", 17 | "license": "MIT", 18 | "main": "./dist/index.js", 19 | "module": "./dist/index.js", 20 | "types": "./dist/index.d.ts", 21 | "exports": { 22 | ".": { 23 | "types": "./dist/index.d.ts", 24 | "require": "./dist/index.cjs", 25 | "import": "./dist/index.js" 26 | }, 27 | "./package.json": "./package.json" 28 | }, 29 | "files": [ 30 | "dist/index.js", 31 | "dist/index.cjs", 32 | "dist/index.d.ts", 33 | "README.md", 34 | "LICENSE.md" 35 | ], 36 | "scripts": { 37 | "wasm": "./scripts/prebuild.sh", 38 | "build": "tsup index.ts --format cjs,esm --dts --env.PKG_VERSION $npm_package_version", 39 | "test": "vitest run test --config=./test/vite.config.js" 40 | }, 41 | "devDependencies": { 42 | "@vitest/browser": "^1.2.2", 43 | "tsup": "^8.3.5", 44 | "vite": "^4.4.9", 45 | "vitest": "^1.2.2", 46 | "webdriverio": "^8.30.0" 47 | }, 48 | "dependencies": { 49 | "@elemaudio/core": "^4.0.1", 50 | "invariant": "^2.2.4" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /runtime/elem/builtins/Noise.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "../GraphNode.h" 4 | 5 | 6 | namespace elem 7 | { 8 | 9 | template 10 | struct UniformRandomNoiseNode : public GraphNode { 11 | using GraphNode::GraphNode; 12 | 13 | int setProperty(std::string const& key, js::Value const& val) override 14 | { 15 | if (key == "seed") { 16 | if (!val.isNumber()) 17 | return ReturnCode::InvalidPropertyType(); 18 | 19 | seed = static_cast((js::Number) val); 20 | } 21 | 22 | return GraphNode::setProperty(key, val); 23 | } 24 | 25 | // Neat random number generator found here: https://stackoverflow.com/a/3747462/2320243 26 | // Turns out it's almost exactly the same one used in Max/MSP's Gen for its [noise] block 27 | inline int fastRand() 28 | { 29 | seed = 214013 * seed + 2531011; 30 | return (seed >> 16) & 0x7FFF; 31 | } 32 | 33 | void process (BlockContext const& ctx) override { 34 | auto* outputData = ctx.outputData[0]; 35 | auto numSamples = ctx.numSamples; 36 | 37 | for (size_t i = 0; i < numSamples; ++i) { 38 | outputData[i] = (fastRand() / static_cast(0x7FFF)); 39 | } 40 | } 41 | 42 | uint32_t seed = static_cast(std::rand()); 43 | }; 44 | 45 | } // namespace elem 46 | -------------------------------------------------------------------------------- /js/packages/core/nodeUtils.ts: -------------------------------------------------------------------------------- 1 | import { create, isNode as NodeRepr_isNode } from './src/NodeRepr.gen'; 2 | import type { t as NodeRepr_t } from './src/NodeRepr.gen'; 3 | 4 | import invariant from 'invariant'; 5 | 6 | 7 | export type ElemNode = NodeRepr_t | number; 8 | export type { NodeRepr_t }; 9 | 10 | export function resolve(n : ElemNode): NodeRepr_t { 11 | if (typeof n === 'number') 12 | return create("const", {value: n}, []); 13 | 14 | invariant(isNode(n), `Whoops, expecting a Node type here! Got: ${typeof n}`); 15 | return n; 16 | } 17 | 18 | export function isNode(n: unknown): n is NodeRepr_t { 19 | // We cannot pass `unknown` type to the underlying method generated from ReScript, 20 | // but we'd like to keep the signature of this method's API to be more semantically correct (use `unknown` instead of `any`). 21 | // That's why we're using "@ts-expect-error" here. 22 | // Once this resolved, the TS error pops up and we can remove it. 23 | // @ts-expect-error 24 | return NodeRepr_isNode(n); 25 | } 26 | 27 | export function createNode( 28 | kind: string, 29 | props, 30 | children: Array 31 | ): NodeRepr_t { 32 | return create(kind, props, children.map(resolve)); 33 | } 34 | 35 | // Utility function for addressing multiple output channels from a given graph node 36 | export function unpack(node: NodeRepr_t, numChannels: number): Array { 37 | return Array.from({length: numChannels}, (v, i) => { 38 | return { 39 | ...node, 40 | outputChannel: i, 41 | }; 42 | }); 43 | } 44 | -------------------------------------------------------------------------------- /js/packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@elemaudio/core", 3 | "version": "4.0.1", 4 | "type": "module", 5 | "description": "Official Elementary Audio core package", 6 | "keywords": [ 7 | "audio", 8 | "dsp", 9 | "signal", 10 | "processing", 11 | "functional", 12 | "declarative", 13 | "webaudio" 14 | ], 15 | "author": "Nick Thompson ", 16 | "homepage": "https://www.elementary.audio", 17 | "license": "MIT", 18 | "main": "./dist/index.js", 19 | "module": "./dist/index.js", 20 | "types": "./dist/index.d.ts", 21 | "exports": { 22 | ".": { 23 | "require": "./dist/index.cjs", 24 | "import": "./dist/index.js", 25 | "types": "./dist/index.d.ts" 26 | }, 27 | "./package.json": "./package.json" 28 | }, 29 | "files": [ 30 | "dist/index.cjs", 31 | "dist/index.js", 32 | "dist/index.d.ts", 33 | "README.md", 34 | "LICENSE.md" 35 | ], 36 | "scripts": { 37 | "re:build": "rescript", 38 | "re:start": "rescript build -w", 39 | "clean": "rm -rf ./dist/", 40 | "prebuild": "npm run clean", 41 | "build": "npm run re:build && tsup index.ts --format cjs,esm --dts", 42 | "test": "jest", 43 | "snaps": "jest --updateSnapshot" 44 | }, 45 | "jest": { 46 | "verbose": true 47 | }, 48 | "devDependencies": { 49 | "@babel/preset-env": "^7.16.11", 50 | "gentype": "^4.5.0", 51 | "jest": "^29.6.4", 52 | "rescript": "^10.0.0", 53 | "tsup": "^6.0.1" 54 | }, 55 | "dependencies": { 56 | "eventemitter3": "^5.0.1", 57 | "invariant": "^2.2.4", 58 | "shallowequal": "^1.1.0" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /js/packages/offline-renderer/README.md: -------------------------------------------------------------------------------- 1 | # @elemaudio/offline-renderer 2 | 3 | Elementary is a JavaScript/C++ library for building audio applications. 4 | 5 | The `@elemaudio/offline-renderer` package provides a specific `Renderer` implementation 6 | for running Elementary applications in Node.js and web environments using WASM. Offline 7 | rendering is intended for processing data with no associated real-time audio driver, such 8 | as file processing. You'll need to use this package alongside `@elemaudio/core` to build 9 | your application. 10 | 11 | 12 | Please see the full documentation at [https://www.elementary.audio/](https://www.elementary.audio/) 13 | 14 | ## Installation 15 | 16 | ```js 17 | npm install --save @elemaudio/offline-renderer 18 | ``` 19 | 20 | ## Usage 21 | 22 | ```js 23 | import { el } from '@elemaudio/core'; 24 | import OfflineRenderer from '@elemaudio/offline-renderer'; 25 | 26 | (async function main() { 27 | let core = new OfflineRenderer(); 28 | 29 | await core.initialize({ 30 | numInputChannels: 0, 31 | numOutputChannels: 1, 32 | sampleRate: 44100, 33 | }); 34 | 35 | // Our sample data for processing: an empty input and a silent 10s of 36 | // output data to be written into. 37 | let inps = []; 38 | let outs = [new Float32Array(44100 * 10)]; // 10 seconds of output data 39 | 40 | // Render our processing graph 41 | core.render(el.cycle(440)); 42 | 43 | // Pushing samples through the graph. After this call, the buffer in `outs` will 44 | // have the desired output data which can then be saved to file if you like. 45 | core.process(inps, outs); 46 | })(); 47 | ``` 48 | 49 | ## License 50 | 51 | MIT 52 | -------------------------------------------------------------------------------- /js/packages/offline-renderer/__tests__/__snapshots__/sparseq2.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`sparseq2 basics 1`] = ` 4 | Float32Array [ 5 | 0, 6 | 0, 7 | 0, 8 | 0, 9 | 1, 10 | 1, 11 | 1, 12 | 1, 13 | 2, 14 | 2, 15 | 2, 16 | 2, 17 | 3, 18 | 3, 19 | 3, 20 | 3, 21 | 4, 22 | 4, 23 | 4, 24 | 4, 25 | 4, 26 | 4, 27 | 4, 28 | 4, 29 | 4, 30 | 4, 31 | 4, 32 | 4, 33 | 4, 34 | 4, 35 | 4, 36 | 4, 37 | ] 38 | `; 39 | 40 | exports[`sparseq2 interp 1`] = ` 41 | Float32Array [ 42 | 1, 43 | 1.25, 44 | 1.5, 45 | 1.75, 46 | 2, 47 | 2.25, 48 | 2.5, 49 | 2.75, 50 | 3, 51 | 3.25, 52 | 3.5, 53 | 3.75, 54 | 4, 55 | 4, 56 | 4, 57 | 4, 58 | 4, 59 | 4, 60 | 4, 61 | 4, 62 | 4, 63 | 4, 64 | 4, 65 | 4, 66 | 4, 67 | 4, 68 | 4, 69 | 4, 70 | 4, 71 | 4, 72 | 4, 73 | 4, 74 | ] 75 | `; 76 | 77 | exports[`sparseq2 looping 1`] = ` 78 | Float32Array [ 79 | 1, 80 | 1, 81 | 1, 82 | 1, 83 | 2, 84 | 2, 85 | 2, 86 | 2, 87 | 3, 88 | 3, 89 | 3, 90 | 3, 91 | 4, 92 | 4, 93 | 4, 94 | 4, 95 | 1, 96 | 1, 97 | 1, 98 | 1, 99 | 2, 100 | 2, 101 | 2, 102 | 2, 103 | 3, 104 | 3, 105 | 3, 106 | 3, 107 | 4, 108 | 4, 109 | 4, 110 | 4, 111 | ] 112 | `; 113 | 114 | exports[`sparseq2 skip ahead 1`] = ` 115 | Float32Array [ 116 | 1, 117 | 1, 118 | 1, 119 | 1, 120 | 1, 121 | 1, 122 | 1, 123 | 1, 124 | 3, 125 | 3, 126 | 3, 127 | 3, 128 | 3, 129 | 3, 130 | 3, 131 | 3, 132 | ] 133 | `; 134 | -------------------------------------------------------------------------------- /js/packages/web-renderer/test/vfs.test.js: -------------------------------------------------------------------------------- 1 | import WebRenderer from '..'; 2 | import { el } from '@elemaudio/core'; 3 | import { expect, test } from 'vitest'; 4 | 5 | 6 | const audioContext = (typeof window !== 'undefined') && new (window.AudioContext || window.webkitAudioContext)(); 7 | 8 | 9 | test('vfs should show registered entries', async function() { 10 | const core = new WebRenderer(); 11 | const node = await core.initialize(audioContext, { 12 | numberOfInputs: 0, 13 | numberOfOutputs: 1, 14 | outputChannelCount: [2], 15 | processorOptions: { 16 | virtualFileSystem: { 17 | 'test': Float32Array.from([1, 2, 3, 4, 5]), 18 | }, 19 | }, 20 | }); 21 | 22 | node.connect(audioContext.destination); 23 | expect(await core.listVirtualFileSystem()).toEqual(['test']); 24 | 25 | // We haven't rendered anything that holds a reference to the test entry 26 | await core.pruneVirtualFileSystem(); 27 | expect(await core.listVirtualFileSystem()).toEqual([]); 28 | 29 | // Now we put something back in 30 | await core.updateVirtualFileSystem({ 31 | 'test2': Float32Array.from([2, 3, 4, 5]), 32 | }); 33 | 34 | // After we render something referencing the test2 entry, prune shouldn't touch it 35 | expect(await core.listVirtualFileSystem()).toEqual(['test2']); 36 | expect(await core.render(el.table({key: 'a', path: 'test2'}, 0.5))).toMatchObject({ 37 | nodesAdded: 3, 38 | edgesAdded: 2, 39 | // Root node channel, fadeIn, fadeOut, table key, path, const value 40 | propsWritten: 6, 41 | }); 42 | 43 | await core.pruneVirtualFileSystem(); 44 | expect(await core.listVirtualFileSystem()).toEqual(['test2']); 45 | }); 46 | -------------------------------------------------------------------------------- /js/packages/core/src/HashUtils.bs.js: -------------------------------------------------------------------------------- 1 | // Generated by ReScript, PLEASE EDIT WITH CARE 2 | 3 | import * as Hash from "./Hash"; 4 | import * as Js_dict from "rescript/lib/es6/js_dict.js"; 5 | import * as Js_types from "rescript/lib/es6/js_types.js"; 6 | import * as Belt_List from "rescript/lib/es6/belt_List.js"; 7 | import * as Js_option from "rescript/lib/es6/js_option.js"; 8 | 9 | function updateNodeProps(prim0, prim1, prim2, prim3) { 10 | Hash.updateNodeProps(prim0, prim1, prim2, prim3); 11 | } 12 | 13 | function finalize(n) { 14 | return n & 2147483647; 15 | } 16 | 17 | function mixNumber(seed, n) { 18 | return Math.imul(seed ^ n, 16777619); 19 | } 20 | 21 | function hashString(seed, s) { 22 | var r = seed; 23 | for(var i = 0 ,i_finish = s.length; i <= i_finish; ++i){ 24 | r = mixNumber(r, s.charCodeAt(i) | 0); 25 | } 26 | return r; 27 | } 28 | 29 | function hashNode(kind, props, children) { 30 | var r = hashString(-2128831035, kind); 31 | var k = Js_dict.get(props, "key"); 32 | var r2 = k !== undefined && Js_types.test(k, /* String */4) ? hashString(r, k) : hashString(r, Js_option.getExn(JSON.stringify(props))); 33 | return Belt_List.reduceU(children, r2, mixNumber) & 2147483647; 34 | } 35 | 36 | function hashMemoInputs(props, children) { 37 | var r = Js_types.test(props.memoKey, /* String */4) ? hashString(-2128831035, props.memoKey) : hashString(-2128831035, Js_option.getExn(JSON.stringify(props))); 38 | return Belt_List.reduceU(children, r, mixNumber) & 2147483647; 39 | } 40 | 41 | export { 42 | updateNodeProps , 43 | finalize , 44 | mixNumber , 45 | hashString , 46 | hashNode , 47 | hashMemoInputs , 48 | } 49 | /* ./Hash Not a pure module */ 50 | -------------------------------------------------------------------------------- /js/packages/core/src/NodeRepr.bs.js: -------------------------------------------------------------------------------- 1 | // Generated by ReScript, PLEASE EDIT WITH CARE 2 | 3 | import * as Js_types from "rescript/lib/es6/js_types.js"; 4 | import * as Belt_List from "rescript/lib/es6/belt_List.js"; 5 | import * as HashUtils from "./HashUtils.bs.js"; 6 | 7 | var symbol = "__ELEM_NODE__"; 8 | 9 | function create(kind, props, children) { 10 | var childrenList = Belt_List.fromArray(children); 11 | return { 12 | symbol: symbol, 13 | hash: HashUtils.hashNode(kind, props, Belt_List.map(childrenList, (function (n) { 14 | return HashUtils.mixNumber(n.hash, n.outputChannel); 15 | }))), 16 | kind: kind, 17 | props: props, 18 | outputChannel: 0, 19 | children: childrenList 20 | }; 21 | } 22 | 23 | function isNode(a) { 24 | var match = Js_types.classify(a); 25 | if (typeof match === "number") { 26 | return false; 27 | } 28 | if (match.TAG !== /* JSObject */3) { 29 | return false; 30 | } 31 | var s = Js_types.classify(a.symbol); 32 | if (typeof s === "number" || s.TAG !== /* JSString */1) { 33 | return false; 34 | } else { 35 | return s._0 === symbol; 36 | } 37 | } 38 | 39 | function shallowCopy(node) { 40 | return { 41 | symbol: node.symbol, 42 | hash: node.hash, 43 | kind: node.kind, 44 | props: Object.assign({}, node.props), 45 | outputChannel: node.outputChannel, 46 | generation: { 47 | contents: 0 48 | } 49 | }; 50 | } 51 | 52 | export { 53 | symbol , 54 | create , 55 | isNode , 56 | shallowCopy , 57 | } 58 | /* HashUtils Not a pure module */ 59 | -------------------------------------------------------------------------------- /js/packages/web-renderer/README.md: -------------------------------------------------------------------------------- 1 | # @elemaudio/web-renderer 2 | 3 | Elementary is a JavaScript/C++ library for building audio applications. 4 | 5 | The `@elemaudio/web-renderer` package provides a specific `Renderer` implementation 6 | for running Elementary applications in web environments using WASM and Web Audio. You'll 7 | need to use this package alongside `@elemaudio/core` to build your application. 8 | 9 | Please see the full documentation at [https://www.elementary.audio/](https://www.elementary.audio/) 10 | 11 | ## Installation 12 | 13 | ```js 14 | npm install --save @elemaudio/web-renderer 15 | ``` 16 | 17 | ## Usage 18 | 19 | ```js 20 | import { el } from '@elemaudio/core'; 21 | import WebRenderer from '@elemaudio/web-renderer'; 22 | 23 | 24 | // Note that many browsers won't let you start an AudioContext before 25 | // some corresponding user gesture. We're ignoring that in this example for brevity, 26 | // but typically you would add an event callback to make or resume your 27 | // AudioContext instance in order to start making noise. 28 | const ctx = new AudioContext(); 29 | const core = new WebRenderer(); 30 | 31 | (async function main() { 32 | // Here we initialize our WebRenderer instance, returning a promise which resolves 33 | // to the WebAudio node containing the runtime 34 | let node = await core.initialize(ctx, { 35 | numberOfInputs: 0, 36 | numberOfOutputs: 1, 37 | outputChannelCount: [2], 38 | }); 39 | 40 | // And connect the resolved node to the AudioContext destination 41 | node.connect(ctx.destination); 42 | 43 | // Then finally we can render 44 | core.render(el.cycle(440), el.cycle(441)); 45 | })(); 46 | ``` 47 | 48 | ## License 49 | 50 | MIT 51 | -------------------------------------------------------------------------------- /js/packages/core/__tests__/mc.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | el, 3 | Renderer, 4 | } from '..'; 5 | 6 | 7 | function identity(x) { 8 | return x; 9 | } 10 | 11 | test('hashing reflects outputChannel from child nodes', async function() { 12 | const core = new Renderer(identity); 13 | const appendChildInstructionType = 2; 14 | 15 | const graph = el.add(...el.mc.sampleseq2({ 16 | path: '/v/path', 17 | channels: 2, 18 | duration: 2, 19 | seq: [ 20 | {value: 1, time: 0}, 21 | ], 22 | }, 1).map(xn => el.mul(0.5, xn))); 23 | 24 | const { result } = await core.render(graph); 25 | 26 | // Because we used an identity function for the Renderer's batch handling 27 | // callback above, the render result here is actually just the instruction 28 | // batch itself. Given the instruction batch, we're looking to confirm that 29 | // at least one of the appendChild instructions connects the second output 30 | // channel from the sampleseq2 node. 31 | // 32 | // This test is intended to demonstrate that both of the `el.mul` nodes above 33 | // the sampleseq node get visited during the graph traversal, meaning that they 34 | // have different hashes. This is important because they both have the same 35 | // children if we only consider those children's hashes. We must also consider 36 | // those children's output channels. When we do, we will see the correct visit 37 | // and the connection to that second output channel. 38 | expect(result.some((instruction) => { 39 | if (instruction[0] !== appendChildInstructionType) { 40 | return false; 41 | } 42 | 43 | const [type, parent, child, channel] = instruction; 44 | return (channel === 1); 45 | })); 46 | }); 47 | -------------------------------------------------------------------------------- /js/packages/core/src/NodeRepr.gen.ts: -------------------------------------------------------------------------------- 1 | /* TypeScript file generated from NodeRepr.res by genType. */ 2 | /* eslint-disable import/first */ 3 | 4 | 5 | // @ts-ignore: Implicit any on import 6 | import * as Curry__Es6Import from 'rescript/lib/es6/curry.js'; 7 | const Curry: any = Curry__Es6Import; 8 | 9 | // @ts-ignore: Implicit any on import 10 | import * as NodeReprBS__Es6Import from './NodeRepr.bs'; 11 | const NodeReprBS: any = NodeReprBS__Es6Import; 12 | 13 | import type {list} from '../src/shims/RescriptPervasives.shim'; 14 | 15 | // tslint:disable-next-line:max-classes-per-file 16 | // tslint:disable-next-line:class-name 17 | export abstract class props { protected opaque!: any }; /* simulate opaque types */ 18 | 19 | // tslint:disable-next-line:interface-over-type-literal 20 | export type t = { 21 | readonly symbol: string; 22 | readonly hash: number; 23 | readonly kind: string; 24 | readonly props: props; 25 | readonly outputChannel: number; 26 | readonly children: list 27 | }; 28 | 29 | // tslint:disable-next-line:interface-over-type-literal 30 | export type shallow = { 31 | readonly symbol: string; 32 | readonly hash: number; 33 | readonly kind: string; 34 | readonly props: props; 35 | readonly outputChannel: number; 36 | readonly generation: { 37 | contents: number 38 | } 39 | }; 40 | 41 | export const create: (kind:string, props:{}, children:t[]) => t = function (Arg1: any, Arg2: any, Arg3: any) { 42 | const result = Curry._3(NodeReprBS.create, Arg1, Arg2, Arg3); 43 | return result 44 | }; 45 | 46 | export const isNode: (a:{ readonly symbol: T1 }) => boolean = NodeReprBS.isNode; 47 | 48 | export const shallowCopy: (node:t) => shallow = NodeReprBS.shallowCopy; 49 | -------------------------------------------------------------------------------- /runtime/elem/AudioBufferResource.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include "SharedResource.h" 5 | #include "Types.h" 6 | 7 | 8 | namespace elem 9 | { 10 | 11 | class AudioBufferResource : public SharedResource { 12 | public: 13 | AudioBufferResource(float* data, size_t numSamples) 14 | { 15 | channels.push_back(std::vector(data, data + numSamples)); 16 | } 17 | 18 | AudioBufferResource(float** data, size_t numChannels, size_t numSamples) 19 | { 20 | for (size_t i = 0; i < numChannels; ++i) { 21 | channels.push_back(std::vector(data[i], data[i] + numSamples)); 22 | } 23 | } 24 | 25 | AudioBufferResource(size_t numChannels, size_t numSamples) 26 | { 27 | for (size_t i = 0; i < numChannels; ++i) { 28 | channels.push_back(std::vector(numSamples)); 29 | } 30 | } 31 | 32 | BufferView getChannelData(size_t channelIndex) override 33 | { 34 | if (channelIndex < channels.size()) { 35 | auto& chan = channels[channelIndex]; 36 | return BufferView(chan.data(), chan.size()); 37 | } 38 | 39 | return BufferView(nullptr, 0); 40 | } 41 | 42 | size_t numChannels() override { 43 | return channels.size(); 44 | } 45 | 46 | size_t numSamples() override { 47 | if (numChannels() > 0) { 48 | return channels[0].size(); 49 | } 50 | 51 | return 0; 52 | } 53 | 54 | private: 55 | // TODO: make this one contiguous chunk of data 56 | std::vector> channels; 57 | }; 58 | 59 | } // namespace elem 60 | -------------------------------------------------------------------------------- /js/packages/offline-renderer/__tests__/__snapshots__/sampleseq.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`sampleseq basics 1`] = ` 4 | Float32Array [ 5 | 0, 6 | 0, 7 | 0, 8 | 0, 9 | 0, 10 | 0, 11 | 0, 12 | 0, 13 | 0, 14 | 0, 15 | 0, 16 | 0, 17 | 0, 18 | 0, 19 | 0, 20 | 0, 21 | 0, 22 | 0, 23 | 0, 24 | 0, 25 | 0, 26 | 0, 27 | 0, 28 | 0, 29 | 0, 30 | 0, 31 | 0, 32 | 0, 33 | 0, 34 | 0, 35 | 0, 36 | 0, 37 | ] 38 | `; 39 | 40 | exports[`sampleseq basics 2`] = ` 41 | Float32Array [ 42 | 1, 43 | 1, 44 | 1, 45 | 1, 46 | 1, 47 | 1, 48 | 1, 49 | 1, 50 | 1, 51 | 1, 52 | 1, 53 | 1, 54 | 1, 55 | 1, 56 | 1, 57 | 1, 58 | 1, 59 | 1, 60 | 1, 61 | 1, 62 | 1, 63 | 1, 64 | 1, 65 | 1, 66 | 1, 67 | 1, 68 | 1, 69 | 1, 70 | 1, 71 | 1, 72 | 1, 73 | 1, 74 | ] 75 | `; 76 | 77 | exports[`sampleseq basics 3`] = ` 78 | Float32Array [ 79 | 0, 80 | 0, 81 | 0, 82 | 0, 83 | 0, 84 | 0, 85 | 0, 86 | 0, 87 | 0, 88 | 0, 89 | 0, 90 | 0, 91 | 0, 92 | 0, 93 | 0, 94 | 0, 95 | 0, 96 | 0, 97 | 0, 98 | 0, 99 | 0, 100 | 0, 101 | 0, 102 | 0, 103 | 0, 104 | 0, 105 | 0, 106 | 0, 107 | 0, 108 | 0, 109 | 0, 110 | 0, 111 | ] 112 | `; 113 | 114 | exports[`sampleseq basics 4`] = ` 115 | Float32Array [ 116 | 1, 117 | 1, 118 | 1, 119 | 1, 120 | 1, 121 | 1, 122 | 1, 123 | 1, 124 | 1, 125 | 1, 126 | 1, 127 | 1, 128 | 1, 129 | 1, 130 | 1, 131 | 1, 132 | 1, 133 | 1, 134 | 1, 135 | 1, 136 | 1, 137 | 1, 138 | 1, 139 | 1, 140 | 1, 141 | 1, 142 | 1, 143 | 1, 144 | 1, 145 | 1, 146 | 1, 147 | 1, 148 | ] 149 | `; 150 | -------------------------------------------------------------------------------- /js/packages/offline-renderer/__tests__/time.test.js: -------------------------------------------------------------------------------- 1 | import OfflineRenderer from '..'; 2 | import { el } from '@elemaudio/core'; 3 | 4 | 5 | test('time node', async function() { 6 | let core = new OfflineRenderer(); 7 | 8 | await core.initialize({ 9 | numInputChannels: 1, 10 | numOutputChannels: 1, 11 | }); 12 | 13 | // Graph 14 | core.render(el.time()); 15 | 16 | // Ten blocks of data 17 | let inps = [new Float32Array(512 * 10)]; 18 | let outs = [new Float32Array(512 * 10)]; 19 | 20 | // Get past the fade-in 21 | core.process(inps, outs); 22 | 23 | // Push another small block. We should see an incrementing output beginning 24 | // at 512 * 10 because of the blocks we've already pushed through 25 | inps = [new Float32Array(32)]; 26 | outs = [new Float32Array(32)]; 27 | 28 | core.process(inps, outs); 29 | expect(outs[0]).toMatchSnapshot(); 30 | }); 31 | 32 | test('setting time', async function() { 33 | let core = new OfflineRenderer(); 34 | 35 | await core.initialize({ 36 | numInputChannels: 0, 37 | numOutputChannels: 1, 38 | }); 39 | 40 | // Graph 41 | core.render(el.time()); 42 | 43 | // Ten blocks of data 44 | let inps = []; 45 | let outs = [new Float32Array(512 * 10)]; 46 | 47 | // Get past the fade-in 48 | core.process(inps, outs); 49 | 50 | // Set current time, then push another small block 51 | outs = [new Float32Array(8)]; 52 | 53 | core.setCurrentTime(50); 54 | core.process(inps, outs); 55 | expect(Array.from(outs[0])).toMatchObject([50, 51, 52, 53, 54, 55, 56, 57]); 56 | 57 | // Set current time, then push another small block 58 | outs = [new Float32Array(8)]; 59 | 60 | core.setCurrentTimeMs(1000); 61 | core.process(inps, outs); 62 | expect(Array.from(outs[0])).toMatchObject([44100, 44101, 44102, 44103, 44104, 44105, 44106, 44107]); 63 | }); 64 | -------------------------------------------------------------------------------- /js/packages/offline-renderer/__tests__/maxhold.test.js: -------------------------------------------------------------------------------- 1 | import OfflineRenderer from '..'; 2 | import { el } from '@elemaudio/core'; 3 | 4 | 5 | test('maxhold basics', async function() { 6 | let core = new OfflineRenderer(); 7 | 8 | await core.initialize({ 9 | numInputChannels: 1, 10 | numOutputChannels: 1, 11 | }); 12 | 13 | // Graph 14 | core.render(el.maxhold({}, el.in({channel: 0}), 0)); 15 | 16 | // Ten blocks of data 17 | let inps = [new Float32Array(512 * 10)]; 18 | let outs = [new Float32Array(512 * 10)]; 19 | 20 | // Get past the fade-in 21 | core.process(inps, outs); 22 | 23 | // Now we use a very calculated input to step the sparseq 24 | inps = [Float32Array.from([1, 2, 3, 4, 3, 2, 1])]; 25 | outs = [new Float32Array(inps[0].length)]; 26 | 27 | // Drive our sparseq 28 | core.process(inps, outs); 29 | 30 | expect(outs[0]).toMatchSnapshot(); 31 | }); 32 | 33 | test('maxhold hold time', async function() { 34 | let core = new OfflineRenderer(); 35 | 36 | await core.initialize({ 37 | numInputChannels: 1, 38 | numOutputChannels: 1, 39 | }); 40 | 41 | // Graph 42 | core.render(el.maxhold({hold: 1}, el.in({channel: 0}), 0)); 43 | 44 | // Ten blocks of data 45 | let inps = [new Float32Array(512 * 10)]; 46 | let outs = [new Float32Array(512 * 10)]; 47 | 48 | // Get past the fade-in 49 | core.process(inps, outs); 50 | 51 | // Now we use a very calculated input to step the sparseq 52 | inps = [Float32Array.from([ 53 | 1, 2, 3, 4, 3, 2, 1, 1, 54 | 1, 1, 1, 1, 1, 1, 1, 1, 55 | 1, 1, 1, 1, 1, 1, 1, 1, 56 | 1, 1, 1, 1, 1, 1, 1, 1, 57 | 1, 1, 1, 1, 1, 1, 1, 1, 58 | 1, 1, 1, 1, 1, 1, 1, 1, 59 | ])]; 60 | outs = [new Float32Array(inps[0].length)]; 61 | 62 | // Drive our sparseq 63 | core.process(inps, outs); 64 | 65 | expect(outs[0]).toMatchSnapshot(); 66 | }); 67 | -------------------------------------------------------------------------------- /js/packages/core/lib/envelopes.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createNode, 3 | isNode, 4 | resolve, 5 | ElemNode, 6 | NodeRepr_t, 7 | } from "../nodeUtils"; 8 | 9 | import * as co from "./core"; 10 | import * as ma from "./math"; 11 | import * as fi from "./filters"; 12 | import * as si from "./signals"; 13 | 14 | const el = { 15 | ...co, 16 | ...ma, 17 | ...fi, 18 | ...si, 19 | }; 20 | 21 | /** 22 | * An exponential ADSR envelope generator, triggered by the gate signal, g. 23 | * 24 | * When the gate is high (1), this generates the ADS phase. When the gate is 25 | * low (0), the R phase. 26 | * 27 | * @param {ElemNode} a - Attack time in seconds 28 | * @param {ElemNode} d - Decay time in seconds 29 | * @param {ElemNode} s - Sustain level between 0, 1 30 | * @param {ElemNode} r - Release time in seconds 31 | * @param {ElemNode} g - Gate signal 32 | * @returns {NodeRepr_t} 33 | */ 34 | export function adsr( 35 | attackSec: ElemNode, 36 | decaySec: ElemNode, 37 | sustain: ElemNode, 38 | releaseSec: ElemNode, 39 | gate: ElemNode, 40 | ): NodeRepr_t { 41 | let [a, d, s, r, g] = [attackSec, decaySec, sustain, releaseSec, gate]; 42 | let atkSamps = el.mul(a, el.sr()); 43 | let atkGate = el.le(el.counter(g), atkSamps); 44 | 45 | let targetValue = el.select(g, el.select(atkGate, 1.0, s), 0); 46 | 47 | // Clamp the values to a minimum of 0.1ms because a time constant of 0 yields 48 | // a divide-by-zero in the pole calculation 49 | let t60 = el.max(0.0001, el.select(g, el.select(atkGate, a, d), r)); 50 | 51 | // Accelerate the phase time when calculating the pole position to ensure 52 | // we reach closer to the target value before moving to the next phase. 53 | // 54 | // See: https://ccrma.stanford.edu/~jos/mdft/Audio_Decay_Time_T60.html 55 | let p = el.tau2pole(el.div(t60, 6.91)); 56 | 57 | return el.smooth(p, targetValue); 58 | } 59 | -------------------------------------------------------------------------------- /cli/README.md: -------------------------------------------------------------------------------- 1 | # cli 2 | 3 | This directory holds a small command line project which runs Elementary direct to 4 | the default audio device, and next to an embedded Quickjs JavaScript interpreter. 5 | 6 | The cli tool here is not meant to be a fully featured, end-user product, but rather 7 | a concise demonstration of integrating Elementary's native engine, and a simple 8 | environment for testing, benchmarking, and profiling Elementary itself, or any 9 | custom native nodes that you may be interested in building. Still, we have a few 10 | examples here that you can compile and run to get a sense of the cli project. 11 | 12 | ## Building 13 | 14 | ### Native 15 | 16 | To build the cli tool, you can use CMake from the top-level directory. 17 | 18 | ```bash 19 | # Get Elementary 20 | git clone https://github.com/elemaudio/elementary.git --recurse-submodules 21 | cd elementary 22 | 23 | # Initialize the CMake directory 24 | mkdir build/ 25 | cd build/ 26 | 27 | # Choose your favorite CMake generator here 28 | cmake -G Xcode -DCMAKE_OSX_DEPLOYMENT_TARGET=10.15 ../ 29 | 30 | # Build the binaries 31 | cmake --build . 32 | ``` 33 | 34 | ### JavaScript 35 | 36 | Next, in order to run the cli we need to direct it to a JavaScript file to run, 37 | much like one would expect when using Node.js or similar. However, because our cli 38 | runs the JavaScript inside a Quickjs environment, we need to resolve any module 39 | imports into a single bundle before trying to run it. To do that, we have a simple 40 | `esbuild` workflow inside the `examples/` directory. 41 | 42 | ```bash 43 | cd cli/examples/ 44 | npm install 45 | npm run build 46 | ``` 47 | 48 | After these few steps, you'll see bundled files in `examples/dist/`. These files can 49 | then be run with the command line to hear them: 50 | 51 | ```bash 52 | # Your path to the elemcli binary might be different depending on your build 53 | # directory structure 54 | ./build/cli/Debug/elemcli examples/dist/00_HelloSine.js 55 | ``` 56 | -------------------------------------------------------------------------------- /wasm/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.15) 2 | project(wasm VERSION 0.11.0) 3 | 4 | set(TargetName elementary-wasm) 5 | set(CMAKE_VERBOSE_MAKEFILE ON) 6 | 7 | add_executable(${TargetName} 8 | Main.cpp 9 | ${CMAKE_CURRENT_SOURCE_DIR}/FFTConvolver/TwoStageFFTConvolver.cpp 10 | ${CMAKE_CURRENT_SOURCE_DIR}/FFTConvolver/FFTConvolver.cpp 11 | ${CMAKE_CURRENT_SOURCE_DIR}/FFTConvolver/AudioFFT.cpp 12 | ${CMAKE_CURRENT_SOURCE_DIR}/FFTConvolver/Utilities.cpp) 13 | 14 | target_include_directories(${TargetName} PRIVATE 15 | ${CMAKE_CURRENT_SOURCE_DIR}/FFTConvolver) 16 | 17 | target_compile_features(${TargetName} PRIVATE 18 | cxx_std_17) 19 | 20 | target_link_libraries(${TargetName} PRIVATE 21 | elem::runtime) 22 | 23 | set(PRE "${CMAKE_CURRENT_SOURCE_DIR}/pre.js") 24 | 25 | # We use these flags for compiling our wasm bundle such that the exported Module 26 | # factory function asynchronously initializes the module. It's important for the browser 27 | # and node.js contexts, whereas we want synchronous initialization for the webaudio worklet 28 | set(EM_FLAGS_ASYNC "--pre-js ${PRE} -lembind --closure 1 -s WASM=1 -s WASM_ASYNC_COMPILATION=1 -s MODULARIZE=1 -s ENVIRONMENT=shell -s SINGLE_FILE=1 -s ALLOW_MEMORY_GROWTH=1") 29 | 30 | # We use these flags for compiling our wasm bundle such that the exported Module 31 | # factory function _synchronously_ initializes the module. We use this specifically in the 32 | # initialization of the web audio worklet where we don't necessarily have room to wait for 33 | # an asynchronous init 34 | set(EM_FLAGS_SYNC "--pre-js ${PRE} -lembind --closure 1 -s WASM=1 -s WASM_ASYNC_COMPILATION=0 -s MODULARIZE=1 -s ENVIRONMENT=shell -s SINGLE_FILE=1 -s ALLOW_MEMORY_GROWTH=1") 35 | 36 | if ($ENV{ELEM_BUILD_ASYNC}) 37 | set_target_properties(${TargetName} 38 | PROPERTIES 39 | COMPILE_FLAGS "-O3" 40 | LINK_FLAGS ${EM_FLAGS_ASYNC}) 41 | else() 42 | set_target_properties(${TargetName} 43 | PROPERTIES 44 | COMPILE_FLAGS "-O3" 45 | LINK_FLAGS ${EM_FLAGS_SYNC}) 46 | endif() 47 | -------------------------------------------------------------------------------- /js/packages/core/src/HashUtils.res: -------------------------------------------------------------------------------- 1 | // Quick import helpers. 2 | @module("./Hash") external updateNodeProps: ('a, int, 'b, 'c) => () = "updateNodeProps" 3 | 4 | 5 | // We don't need much in the way of finalization here given that we don't have strong 6 | // requirements for avalanche characteristics. Instead here we just force the output to 7 | // positive integers. 8 | let finalize = (n: int) => { 9 | land(n, 0x7fffffff) 10 | } 11 | 12 | // This is just FNV-1a, a very simple non-cryptographic hash algorithm intended to 13 | // be very quick. I chose FNV-1a for its slight avalanche charateristics improvement 14 | // noting that the finalize step we use here just clears the sign bit. 15 | // 16 | // Note too that this implementation uses signed 32-bit integers where the original 17 | // algorithm is intended for unsigned. 18 | // 19 | // See: https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function 20 | let mixNumber = (. seed: int, n: int): int => { 21 | lxor(seed, n) * 0x01000193 22 | } 23 | 24 | @genType 25 | let hashString = (. seed: int, s: string): int => { 26 | let r = ref(seed) 27 | 28 | for i in 0 to Js.String2.length(s) { 29 | r := mixNumber(. r.contents, Belt.Int.fromFloat(Js.String2.charCodeAt(s, i))) 30 | } 31 | 32 | r.contents 33 | } 34 | 35 | @genType 36 | let hashNode = (. kind: string, props: Js.Dict.t<'a>, children: list): int => { 37 | let r = hashString(. 0x811c9dc5, kind) 38 | let r2 = switch Js.Dict.get(props, "key") { 39 | | Some(k) if Js.Types.test(k, String) => hashString(. r, k) 40 | | _ => hashString(. r, Js.Option.getExn(Js.Json.stringifyAny(props))) 41 | } 42 | 43 | finalize(Belt.List.reduceU(children, r2, mixNumber)) 44 | } 45 | 46 | @genType 47 | let hashMemoInputs = (. props: 'a, children: list): int => { 48 | let r = if Js.Types.test(props["memoKey"], String) { 49 | hashString(. 0x811c9dc5, props["memoKey"]) 50 | } else { 51 | hashString(. 0x811c9dc5, Js.Option.getExn(Js.Json.stringifyAny(props))) 52 | } 53 | 54 | finalize(Belt.List.reduceU(children, r, mixNumber)) 55 | } 56 | -------------------------------------------------------------------------------- /js/packages/core/lib/signals.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createNode, 3 | isNode, 4 | resolve, 5 | ElemNode, 6 | NodeRepr_t, 7 | } from "../nodeUtils"; 8 | 9 | import * as co from "./core"; 10 | import * as ma from "./math"; 11 | 12 | const el = { 13 | ...co, 14 | ...ma, 15 | }; 16 | 17 | /** 18 | * Equivalent to (x / 1000) * sampleRate, where x is the input time in milliseconds. 19 | */ 20 | export function ms2samps(t: ElemNode): NodeRepr_t { 21 | return el.mul(el.sr(), el.div(t, 1000.0)); 22 | } 23 | 24 | /** 25 | * Computes a real pole position giving exponential decay over t, where t is the time (in seconds) to decay by 1/e. 26 | */ 27 | export function tau2pole(t: ElemNode): NodeRepr_t { 28 | return el.exp(el.div(-1.0, el.mul(t, el.sr()))); 29 | } 30 | 31 | /** 32 | * Maps a value in Decibels to its corresponding value in amplitude. 33 | */ 34 | export function db2gain(db: ElemNode): NodeRepr_t { 35 | return el.pow(10, el.mul(db, 1 / 20)); 36 | } 37 | 38 | /** 39 | * Maps a value in amplitude to its corresponding value in Decibels. 40 | * 41 | * Implicitly inserts a gain floor at -120dB (i.e. if you give a gain value 42 | * smaller than -120dB this will just return -120dB). 43 | */ 44 | export function gain2db(gain: ElemNode): NodeRepr_t { 45 | return select(el.ge(gain, 0), el.max(-120, el.mul(20, el.log(gain))), -120); 46 | } 47 | 48 | /** 49 | * A simple conditional operator. 50 | * 51 | * Given a gate signal, g, on the range [0, 1], returns the signal a when the gate is high, 52 | * and the signal b when the gate is low. For values of g between (0, 1), performs 53 | * a linear interpolation between a and b. 54 | */ 55 | export function select(g: ElemNode, a: ElemNode, b: ElemNode): NodeRepr_t { 56 | return el.add(el.mul(g, a), el.mul(el.sub(1, g), b)); 57 | } 58 | 59 | /** 60 | * A simple Hann window generator. 61 | * 62 | * The window is generated according to an incoming phasor signal, where a phase 63 | * value of 0 corresponds to the left hand side of the window, and a phase value 64 | * of 1 corresponds to the right hand side of the window. 65 | * 66 | * Expects exactly one child, the incoming phase. 67 | * 68 | * @param {ElemNode} t - Phase signal 69 | * @returns {NodeRepr_t} 70 | */ 71 | export function hann(t: ElemNode): NodeRepr_t { 72 | return el.mul(0.5, el.sub(1, el.cos(el.mul(2.0 * Math.PI, t)))); 73 | } 74 | -------------------------------------------------------------------------------- /cli/examples/01_FMArp.js: -------------------------------------------------------------------------------- 1 | import {Renderer, el} from '@elemaudio/core'; 2 | 3 | 4 | // This example builds upon the HelloSine example to implement some very basic FM synthesis, 5 | // and builds in some simple pattern sequencing to create an interesting arp. 6 | let core = new Renderer((batch) => { 7 | __postNativeMessage__(JSON.stringify(batch)); 8 | }); 9 | 10 | // We start here with a couple simple constants: these map a few common note names 11 | // to their respective frequencies, from which we'll construct a simple arpeggio pattern. 12 | const e4 = 329.63 * 0.5; 13 | const b4 = 493.88 * 0.5; 14 | const e5 = 659.26 * 0.5; 15 | const g5 = 783.99 * 0.5; 16 | 17 | // Here we put together the patterns: each just a simple array of the notes above. We 18 | // have two patterns here each with a slightly different note order, and the latter of 19 | // which is slightly detuned from the original frequencies. 20 | const s1 = [e4, b4, e5, e4, g5]; 21 | const s2 = [e4, b4, g5, b4, e5].map(x => x * 0.999); 22 | 23 | // Now, an FM Synthesis voice where the carrier is a sine wave at the given 24 | // frequency, `fq`, and the modulator is another sine wave at the 3.17 times the given frequency. 25 | // 26 | // The FM amount ratio oscillates between [1, 3] at 0.1Hz. 27 | function voice(fq) { 28 | return el.cycle( 29 | el.add( 30 | fq, 31 | el.mul( 32 | el.mul(el.add(2, el.cycle(0.1)), el.mul(fq, 3.17)), 33 | el.cycle(fq), 34 | ), 35 | ), 36 | ); 37 | } 38 | 39 | // Here we'll construct our arp pattern and apply an ADSR envelope to the synth voice. 40 | // To start, we have a pulse train (gate signal) running at 5Hz. 41 | let gate = el.train(5); 42 | 43 | // Our ADSR envelope, triggered by the gate signal. 44 | let env = el.adsr(0.004, 0.01, 0.2, 0.5, gate); 45 | 46 | // Now we construct the left and right channel signals: in each channel we run our synth 47 | // voice over the two sequences of frequency values we constructed above, using the `hold` property 48 | // on `el.seq` to hold its output value right up until the next rising edge of the gate. 49 | let left = el.mul(env, voice(el.seq({seq: s1, hold: true}, gate, 0))); 50 | let right = el.mul(env, voice(el.seq({seq: s2, hold: true}, gate, 0))); 51 | 52 | // And then render it! 53 | let stats = core.render( 54 | el.mul(0.3, left), 55 | el.mul(0.3, right), 56 | ); 57 | 58 | console.log(stats); 59 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | push: 7 | branches: 8 | - '**' 9 | tags-ignore: 10 | - '**' 11 | 12 | jobs: 13 | native: 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | os: [ubuntu-latest, macos-latest, windows-latest] 18 | runs-on: ${{ matrix.os }} 19 | steps: 20 | - uses: actions/checkout@v3 21 | with: 22 | submodules: true 23 | 24 | - name: Build 25 | shell: bash 26 | run: | 27 | set -x 28 | set -e 29 | 30 | # Build the native binaries 31 | mkdir -p ./build/native/ 32 | pushd ./build/native/ 33 | cmake \ 34 | -DCMAKE_BUILD_TYPE=Release \ 35 | -DCMAKE_INSTALL_PREFIX=./out/ \ 36 | -DCMAKE_OSX_DEPLOYMENT_TARGET=10.15 \ 37 | ../.. 38 | 39 | cmake --build . --config Release -j 8 40 | popd 41 | 42 | wasm: 43 | runs-on: ubuntu-latest 44 | steps: 45 | - uses: actions/checkout@v3 46 | with: 47 | submodules: true 48 | 49 | - uses: actions/setup-node@v3 50 | with: 51 | node-version: 18 52 | registry-url: 'https://registry.npmjs.org' 53 | always-auth: true 54 | 55 | - name: Lerna build and test 56 | shell: bash 57 | working-directory: ./js 58 | run: | 59 | npm install 60 | npx lerna bootstrap 61 | npx lerna run wasm --concurrency=1 62 | npx lerna run build 63 | npx lerna run test 64 | 65 | - name: Publish 66 | shell: bash 67 | working-directory: ./js 68 | if: ${{ github.ref_type == 'branch' && github.ref_name == 'develop' }} 69 | env: 70 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 71 | run: | 72 | lerna publish from-package --yes --dist-tag next 73 | 74 | - name: Tag Latest 75 | shell: bash 76 | working-directory: ./js 77 | if: ${{ github.ref_type == 'branch' && github.ref_name == 'main' }} 78 | env: 79 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 80 | run: | 81 | for PKG in $(npx lerna ls --json | jq -r '.[] | .name + "@" + .version'); 82 | do 83 | npm dist-tag add $PKG latest 84 | done 85 | -------------------------------------------------------------------------------- /js/packages/core/src/Reconciler.bs.js: -------------------------------------------------------------------------------- 1 | // Generated by ReScript, PLEASE EDIT WITH CARE 2 | 3 | import * as NodeRepr from "./NodeRepr.bs.js"; 4 | import * as Belt_List from "rescript/lib/es6/belt_List.js"; 5 | import * as HashUtils from "./HashUtils.bs.js"; 6 | 7 | function valuesArray(m) { 8 | return Array.from(m.values()); 9 | } 10 | 11 | var $$Map = { 12 | valuesArray: valuesArray 13 | }; 14 | 15 | var $$Set = {}; 16 | 17 | var RenderDelegate = {}; 18 | 19 | function mount(delegate, node) { 20 | var nodeMap = delegate.getNodeMap(); 21 | if (nodeMap.has(node.hash)) { 22 | var existing = nodeMap.get(node.hash); 23 | return HashUtils.updateNodeProps(delegate, existing.hash, existing.props, node.props); 24 | } 25 | delegate.createNode(node.hash, node.kind); 26 | HashUtils.updateNodeProps(delegate, node.hash, {}, node.props); 27 | Belt_List.forEach(node.children, (function (child) { 28 | delegate.appendChild(node.hash, child.hash, child.outputChannel); 29 | })); 30 | nodeMap.set(node.hash, NodeRepr.shallowCopy(node)); 31 | } 32 | 33 | function visit(delegate, visitSet, _ns) { 34 | while(true) { 35 | var ns = _ns; 36 | var markVisited = function (n) { 37 | visitSet.add(n.hash); 38 | }; 39 | if (!ns) { 40 | return ; 41 | } 42 | var rest = ns.tl; 43 | var n = ns.hd; 44 | if (visitSet.has(n.hash)) { 45 | _ns = rest; 46 | continue ; 47 | } 48 | markVisited(n); 49 | mount(delegate, n); 50 | _ns = Belt_List.concat(n.children, rest); 51 | continue ; 52 | }; 53 | } 54 | 55 | function renderWithDelegate(delegate, graphs, rootFadeInMs, rootFadeOutMs) { 56 | var visitSet = new Set(); 57 | var roots = Belt_List.mapWithIndex(Belt_List.fromArray(graphs), (function (i, g) { 58 | return NodeRepr.create("root", { 59 | channel: i, 60 | fadeInMs: rootFadeInMs, 61 | fadeOutMs: rootFadeOutMs 62 | }, [g]); 63 | })); 64 | visit(delegate, visitSet, roots); 65 | delegate.activateRoots(Belt_List.toArray(Belt_List.map(roots, (function (r) { 66 | return r.hash; 67 | })))); 68 | delegate.commitUpdates(); 69 | } 70 | 71 | export { 72 | $$Map , 73 | $$Set , 74 | RenderDelegate , 75 | mount , 76 | visit , 77 | renderWithDelegate , 78 | } 79 | /* NodeRepr Not a pure module */ 80 | -------------------------------------------------------------------------------- /scripts/build-wasm.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -x 4 | set -e 5 | 6 | SCRIPT_DIR="$PWD/$(dirname "$0")" 7 | ROOT_DIR="$(dirname "$SCRIPT_DIR")" 8 | 9 | 10 | el__build() { 11 | # We build outside the source dir here because Docker inside of github actions with 12 | # file system mounting gets all weird. No problems locally either way, so, we do it like this 13 | mkdir -p "/elembuild/wasm/" 14 | pushd "/elembuild/wasm/" 15 | 16 | ELEM_BUILD_ASYNC="${ELEM_BUILD_ASYNC:-0}" emcmake cmake \ 17 | -DCMAKE_BUILD_TYPE=Release \ 18 | -DONLY_BUILD_WASM=ON \ 19 | -DCMAKE_CXX_FLAGS="-O3" \ 20 | /src 21 | 22 | emmake make 23 | 24 | # Because we build out of the source dir, copy the resulting file back to the 25 | # source dir so that it exists outside the container 26 | mkdir -p /src/build/out/ 27 | cp /elembuild/wasm/wasm/elementary-wasm.js /src/build/out/elementary-wasm.js 28 | 29 | popd 30 | } 31 | 32 | el__main() { 33 | # If the first positional argument matches one of the namespaced 34 | # subcommands above, pop the first positional argumen off the list 35 | # and invoke the subcommand with the remainder. 36 | if declare -f "el__${1//-/_}" > /dev/null; then 37 | fn="el__${1//-/_}" 38 | shift; 39 | "$fn" "$@" 40 | else 41 | # Else we're running our top-level main, for which we, by default, invoke 42 | # the build command from within an emscripten/emsdk docker container. 43 | local OUTPUT_FILENAME="" 44 | local ELEM_BUILD_ASYNC=0 45 | 46 | while getopts ao: opt; do 47 | case $opt in 48 | o) OUTPUT_FILENAME="$OPTARG";; 49 | a) ELEM_BUILD_ASYNC=1;; 50 | esac 51 | done 52 | 53 | shift "$((OPTIND - 1))" 54 | 55 | if [ -z "$OUTPUT_FILENAME" ]; then 56 | echo "Error: where are we outputting to?" 57 | exit 1 58 | fi 59 | 60 | docker run \ 61 | -v $(pwd):/src \ 62 | --env ELEM_BUILD_ASYNC="$ELEM_BUILD_ASYNC" \ 63 | docker.io/emscripten/emsdk:3.1.52 \ 64 | ./scripts/build-wasm.sh build 65 | 66 | # Then we copy the resulting file over to the website directory where 67 | # we need it 68 | cp $ROOT_DIR/build/out/elementary-wasm.js $OUTPUT_FILENAME 69 | fi 70 | } 71 | 72 | el__main "$@" 73 | -------------------------------------------------------------------------------- /js/packages/core/src/NodeRepr.res: -------------------------------------------------------------------------------- 1 | // Abstract props type which allows us to essentially treat props as any 2 | @genType 3 | type props 4 | 5 | // Explicit casting which maps a generic input to our abstract props type 6 | external asPropsType: 'a => props = "%identity" 7 | external asObjectType: props => Js.t<'a> = "%identity" 8 | external asDict: {..} => Js.Dict.t<'a> = "%identity" 9 | 10 | @genType 11 | type rec t = { 12 | symbol: string, 13 | hash: int, 14 | kind: string, 15 | props: props, 16 | outputChannel: int, 17 | children: list, 18 | } 19 | 20 | @genType 21 | type shallow = { 22 | symbol: string, 23 | hash: int, 24 | kind: string, 25 | props: props, 26 | outputChannel: int, 27 | generation: ref, 28 | } 29 | 30 | // Symbol constant for helping to identify node objects 31 | let symbol = "__ELEM_NODE__" 32 | 33 | @genType 34 | let create = (kind, props: 'a, children: array): t => { 35 | let childrenList = Belt.List.fromArray(children) 36 | 37 | // Here we make sure that a node's hash depends of course on its type, props, and children, 38 | // but importantly also depends on the outputChannel that we're addressing on each of those 39 | // children. Without doing that, two different nodes that reference different outputs of the 40 | // same one child node would hash to the same value even though they represent different signal 41 | // paths. 42 | { 43 | symbol, 44 | kind, 45 | hash: HashUtils.hashNode(. 46 | kind, 47 | asDict(props), 48 | Belt.List.map(childrenList, n => HashUtils.mixNumber(. n.hash, n.outputChannel)), 49 | ), 50 | props: asPropsType(props), 51 | outputChannel: 0, 52 | children: childrenList, 53 | } 54 | } 55 | 56 | @genType 57 | let isNode = (a): bool => { 58 | // We identify Node-type objects from JavaScript/TypeScript by looking 59 | // for the symbol property 60 | switch Js.Types.classify(a) { 61 | | JSObject(_) => 62 | switch Js.Types.classify(a["symbol"]) { 63 | | JSString(s) => s === symbol 64 | | _ => false 65 | } 66 | | _ => false 67 | } 68 | } 69 | 70 | @genType 71 | let shallowCopy = (node: t): shallow => { 72 | { 73 | symbol: node.symbol, 74 | kind: node.kind, 75 | hash: node.hash, 76 | props: asPropsType(Js.Obj.assign(Js.Obj.empty(), asObjectType(node.props))), 77 | outputChannel: node.outputChannel, 78 | generation: ref(0), 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /js/packages/offline-renderer/__tests__/delays.test.js: -------------------------------------------------------------------------------- 1 | import OfflineRenderer from '..'; 2 | import { el } from '@elemaudio/core'; 3 | 4 | 5 | test('delay basics', async function() { 6 | let core = new OfflineRenderer(); 7 | 8 | await core.initialize({ 9 | numInputChannels: 1, 10 | numOutputChannels: 1, 11 | }); 12 | 13 | // Graph 14 | core.render(el.delay({size: 10}, 0.5, 0, el.in({channel: 0}))); 15 | 16 | // Ten blocks of data 17 | let inps = [new Float32Array(512 * 10)]; 18 | let outs = [new Float32Array(512 * 10)]; 19 | 20 | // Get past the fade-in 21 | core.process(inps, outs); 22 | 23 | inps = [Float32Array.from([1, 2, 3, 4])]; 24 | outs = [new Float32Array(inps[0].length)]; 25 | 26 | // Drive our delay 27 | core.process(inps, outs); 28 | 29 | expect(outs[0]).toMatchSnapshot(); 30 | }); 31 | 32 | test('delay with zero delay time', async function() { 33 | let core = new OfflineRenderer(); 34 | 35 | await core.initialize({ 36 | numInputChannels: 1, 37 | numOutputChannels: 1, 38 | }); 39 | 40 | // Graph 41 | core.render(el.delay({size: 10}, 0, 0, el.in({channel: 0}))); 42 | 43 | // Ten blocks of data 44 | let inps = [new Float32Array(512 * 10)]; 45 | let outs = [new Float32Array(512 * 10)]; 46 | 47 | // Get past the fade-in 48 | core.process(inps, outs); 49 | 50 | inps = [Float32Array.from([1, 2, 3, 4])]; 51 | outs = [new Float32Array(inps[0].length)]; 52 | 53 | // Drive our delay 54 | core.process(inps, outs); 55 | 56 | // Expect input == output 57 | expect(outs[0]).toMatchObject(inps[0]); 58 | }); 59 | 60 | test('sdelay basics', async function() { 61 | let core = new OfflineRenderer(); 62 | 63 | await core.initialize({ 64 | numInputChannels: 1, 65 | numOutputChannels: 1, 66 | }); 67 | 68 | // Graph 69 | core.render(el.sdelay({size: 10}, el.in({channel: 0}), 0)); 70 | 71 | // Ten blocks of data 72 | let inps = [new Float32Array(512 * 10)]; 73 | let outs = [new Float32Array(512 * 10)]; 74 | 75 | // Get past the fade-in 76 | core.process(inps, outs); 77 | 78 | // Now we use a very calculated input to step the sparseq 79 | inps = [Float32Array.from([ 80 | 1, 2, 3, 4, 4, 3, 2, 1, 81 | 0, 0, 0, 0, 0, 0, 0, 0, 82 | 0, 0, 0, 0, 0, 0, 0, 0, 83 | ])]; 84 | 85 | outs = [new Float32Array(inps[0].length)]; 86 | 87 | // Drive our sdelay 88 | core.process(inps, outs); 89 | 90 | expect(outs[0]).toMatchSnapshot(); 91 | }); 92 | -------------------------------------------------------------------------------- /js/packages/offline-renderer/__tests__/vfs.test.js: -------------------------------------------------------------------------------- 1 | import OfflineRenderer from '..'; 2 | import { el } from '@elemaudio/core'; 3 | 4 | 5 | test('vfs sample', async function() { 6 | let core = new OfflineRenderer(); 7 | 8 | await core.initialize({ 9 | numInputChannels: 1, 10 | numOutputChannels: 1, 11 | virtualFileSystem: { 12 | '/v/increment': Float32Array.from([1, 2, 3, 4, 5]), 13 | }, 14 | }); 15 | 16 | // Graph 17 | core.render(el.table({path: '/v/increment'}, el.in({channel: 0}))); 18 | 19 | // Ten blocks of data 20 | let inps = [new Float32Array(512 * 10)]; 21 | let outs = [new Float32Array(512 * 10)]; 22 | 23 | // Get past the fade-in 24 | core.process(inps, outs); 25 | 26 | // Now we use a calculated input to trigger the sample 27 | inps = [Float32Array.from([0, 0.25, 0.5, 0.75, 1])]; 28 | outs = [new Float32Array(inps[0].length)]; 29 | 30 | // Drive our sparseq 31 | core.process(inps, outs); 32 | 33 | expect(outs[0]).toMatchSnapshot(); 34 | }); 35 | 36 | test('vfs list', async function() { 37 | let core = new OfflineRenderer(); 38 | 39 | await core.initialize({ 40 | numInputChannels: 1, 41 | numOutputChannels: 1, 42 | virtualFileSystem: { 43 | 'one': Float32Array.from([1, 2, 3, 4, 5]), 44 | 'two': Float32Array.from([2, 2, 3, 4, 5]), 45 | }, 46 | }); 47 | 48 | // Graph 49 | core.render(el.table({path: 'one'}, el.in({channel: 0}))); 50 | 51 | expect(core.listVirtualFileSystem()).toEqual(expect.arrayContaining(['one', 'two'])); 52 | }); 53 | 54 | test('vfs prune', async function() { 55 | let core = new OfflineRenderer(); 56 | 57 | await core.initialize({ 58 | numInputChannels: 1, 59 | numOutputChannels: 1, 60 | virtualFileSystem: { 61 | 'one': Float32Array.from([1, 2, 3, 4, 5]), 62 | 'two': Float32Array.from([2, 2, 3, 4, 5]), 63 | }, 64 | }); 65 | 66 | // Graph 67 | core.render(el.table({key: 'a', path: 'one'}, el.in({channel: 0}))); 68 | 69 | core.pruneVirtualFileSystem(); 70 | expect(core.listVirtualFileSystem()).toEqual(['one']); 71 | 72 | // Add a new entry, update the graph to point to the new entry 73 | core.updateVirtualFileSystem({ 74 | 'three': Float32Array.from([3, 4, 5, 6]), 75 | }); 76 | 77 | core.render(el.table({key: 'a', path: 'three'}, el.in({channel: 0}))); 78 | 79 | // After the new render we have to process a block to get the table node 80 | // to take its new buffer resource 81 | let inps = [new Float32Array(512)]; 82 | let outs = [new Float32Array(512)]; 83 | core.process(inps, outs); 84 | 85 | // Then if we prune and list we should see 'one' has disappeared 86 | core.pruneVirtualFileSystem(); 87 | expect(core.listVirtualFileSystem()).toEqual(['three']); 88 | }); 89 | -------------------------------------------------------------------------------- /js/packages/offline-renderer/__tests__/tap.test.js: -------------------------------------------------------------------------------- 1 | import OfflineRenderer from '..'; 2 | import { el } from '@elemaudio/core'; 3 | 4 | 5 | test('feedback taps', async function() { 6 | let core = new OfflineRenderer(); 7 | 8 | await core.initialize({ 9 | numInputChannels: 1, 10 | numOutputChannels: 1, 11 | }); 12 | 13 | // Graph 14 | core.render( 15 | el.tapOut({name: 'test'}, el.add( 16 | el.tapIn({name: 'test'}), 17 | el.in({channel: 0}), 18 | )), 19 | ); 20 | 21 | // Ten blocks of data 22 | let inps = [new Float32Array(512 * 10)]; 23 | let outs = [new Float32Array(512 * 10)]; 24 | 25 | // Get past the fade-in 26 | core.process(inps, outs); 27 | 28 | // Now we use a calculated input 29 | inps = [new Float32Array(512)]; 30 | outs = [new Float32Array(512)]; 31 | 32 | for (let i = 0; i < inps[0].length; ++i) { 33 | inps[0][i] = 1; 34 | } 35 | 36 | // Now on the first block, we should see just the ones pass through because 37 | // of the implicit block-size delay in the feedback tap structure 38 | core.process(inps, outs); 39 | expect(outs[0]).toMatchSnapshot(); 40 | 41 | // Next, one block later, we should see a bunch of twos (summing the input 1s 42 | // with the feedback 1s) 43 | core.process(inps, outs); 44 | expect(outs[0]).toMatchSnapshot(); 45 | 46 | // Finally, one block later, we should see a bunch of threes (summing the input 1s 47 | // with the feedback 2s) 48 | core.process(inps, outs); 49 | expect(outs[0]).toMatchSnapshot(); 50 | }); 51 | 52 | test('idle feedback taps', async function() { 53 | let core = new OfflineRenderer(); 54 | 55 | await core.initialize({ 56 | numInputChannels: 0, 57 | numOutputChannels: 1, 58 | blockSize: 32, 59 | }); 60 | 61 | // Event handling 62 | let callback = jest.fn(); 63 | core.on('meter', callback); 64 | 65 | // Graph 66 | core.render( 67 | el.tapOut({name: 'test'}, el.add( 68 | el.meter({}, el.tapIn({name: 'test'})), 69 | 1, 70 | )), 71 | ); 72 | 73 | // Render four blocks 74 | let inps = []; 75 | let outs = [new Float32Array(32 * 4)]; 76 | 77 | core.process(inps, outs); 78 | expect(callback.mock.calls).toMatchSnapshot(); 79 | callback.mockClear(); 80 | 81 | 82 | // Render a new graph and we should see the feedback 83 | // path begin winding down. This demonstrates that only 84 | // the updated graph is feeding into the `test` feedback 85 | // path, not the nodes from the deactivated roots that 86 | // will become idle after the root node fade 87 | core.render( 88 | el.tapOut({name: 'test'}, el.add( 89 | el.meter({}, el.tapIn({name: 'test'})), 90 | -1, 91 | )), 92 | ); 93 | 94 | // Render four blocks 95 | core.process(inps, outs); 96 | 97 | // Check 98 | expect(callback.mock.calls).toMatchSnapshot(); 99 | }); 100 | -------------------------------------------------------------------------------- /wasm/Metro.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | 6 | namespace elem 7 | { 8 | 9 | template 10 | struct MetronomeNode : public GraphNode { 11 | MetronomeNode(NodeId id, FloatType const sr, int const bs) 12 | : GraphNode::GraphNode(id, sr, bs) 13 | { 14 | // By default the metro interval is exactly one second 15 | setProperty("interval", js::Value((js::Number) 1000.0)); 16 | } 17 | 18 | int setProperty(std::string const& key, js::Value const& val) override 19 | { 20 | if (key == "interval") { 21 | if (!val.isNumber()) 22 | return elem::ReturnCode::InvalidPropertyType(); 23 | 24 | if (0 >= (js::Number) val) 25 | return elem::ReturnCode::InvalidPropertyValue(); 26 | 27 | // We don't allow an interval smaller than 2 samples because we need at least 28 | // one sample for the train to go high and one sample for the train to go low. 29 | // We can't fire a pulse train any faster and have it still be a pulse train. 30 | auto const is = ((js::Number) val) * 0.001 * GraphNode::getSampleRate(); 31 | intervalSamps.store(static_cast(std::max(2.0, is))); 32 | } 33 | 34 | return GraphNode::setProperty(key, val); 35 | } 36 | 37 | void process (BlockContext const& ctx) override { 38 | auto* outputData = ctx.outputData[0]; 39 | auto numSamples = ctx.numSamples; 40 | auto sampleTime = *static_cast(ctx.userData); 41 | 42 | auto is = (double) intervalSamps.load(); 43 | 44 | for (size_t i = 0; i < numSamples; ++i) { 45 | auto const t = (double) (sampleTime + i) / is; 46 | auto const nextOut = FloatType((t - std::floor(t)) < 0.5); 47 | 48 | if (lastOut < FloatType(0.5) && nextOut >= FloatType(0.5)) { 49 | eventFlag.store(true); 50 | } 51 | 52 | outputData[i] = nextOut; 53 | lastOut = nextOut; 54 | } 55 | } 56 | 57 | void processEvents(std::function& eventHandler) override { 58 | auto flag = eventFlag.exchange(false); 59 | 60 | if (flag) { 61 | eventHandler("metro", js::Object({ 62 | {"source", GraphNode::getPropertyWithDefault("name", js::Value())}, 63 | })); 64 | } 65 | } 66 | 67 | FloatType lastOut = 0; 68 | std::atomic eventFlag = false; 69 | std::atomic intervalSamps = 0; 70 | static_assert(std::atomic::is_always_lock_free); 71 | }; 72 | 73 | } // namespace elem 74 | -------------------------------------------------------------------------------- /js/packages/core/lib/mc.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createNode, 3 | isNode, 4 | resolve, 5 | ElemNode, 6 | NodeRepr_t, 7 | unpack, 8 | } from "../nodeUtils"; 9 | 10 | import invariant from "invariant"; 11 | 12 | export function sample( 13 | props: { 14 | key?: string; 15 | path: string; 16 | channels: number; 17 | mode?: string; 18 | startOffset?: number; 19 | stopOffset?: number; 20 | playbackRate?: number; 21 | }, 22 | gate: ElemNode, 23 | ): Array { 24 | let { channels, ...other } = props; 25 | 26 | invariant( 27 | typeof channels === "number" && channels > 0, 28 | "Must provide a positive number channels prop", 29 | ); 30 | 31 | return unpack(createNode("mc.sample", other, [resolve(gate)]), channels); 32 | } 33 | 34 | export function sampleseq( 35 | props: { 36 | key?: string; 37 | seq: Array<{ value: number; time: number }>; 38 | duration: number; 39 | path: string; 40 | channels: number; 41 | }, 42 | time: ElemNode, 43 | ): Array { 44 | let { channels, ...other } = props; 45 | 46 | invariant( 47 | typeof channels === "number" && channels > 0, 48 | "Must provide a positive number channels prop", 49 | ); 50 | 51 | return unpack(createNode("mc.sampleseq", other, [resolve(time)]), channels); 52 | } 53 | 54 | export function sampleseq2( 55 | props: { 56 | key?: string; 57 | seq: Array<{ value: number; time: number }>; 58 | duration: number; 59 | path: string; 60 | stretch?: number; 61 | shift?: number; 62 | channels: number; 63 | }, 64 | time: ElemNode, 65 | ): Array { 66 | let { channels, ...other } = props; 67 | 68 | invariant( 69 | typeof channels === "number" && channels > 0, 70 | "Must provide a positive number channels prop", 71 | ); 72 | 73 | return unpack(createNode("mc.sampleseq2", other, [resolve(time)]), channels); 74 | } 75 | 76 | export function table( 77 | props: { 78 | key?: string; 79 | path: string; 80 | channels: number; 81 | }, 82 | t: ElemNode, 83 | ): Array { 84 | let { channels, ...other } = props; 85 | 86 | invariant( 87 | typeof channels === "number" && channels > 0, 88 | "Must provide a positive number channels prop", 89 | ); 90 | 91 | return unpack(createNode("mc.table", other, [resolve(t)]), channels); 92 | } 93 | 94 | export function capture( 95 | props: { 96 | name?: string; 97 | channels: number; 98 | }, 99 | g: ElemNode, 100 | ...args: Array 101 | ): Array { 102 | let { channels, ...other } = props; 103 | 104 | invariant( 105 | typeof channels === "number" && channels > 0, 106 | "Must provide a positive number channels prop", 107 | ); 108 | 109 | return unpack( 110 | createNode("mc.capture", other, [resolve(g), ...args.map(resolve)]), 111 | channels, 112 | ); 113 | } 114 | -------------------------------------------------------------------------------- /runtime/elem/builtins/Oscillators.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "../GraphNode.h" 4 | 5 | 6 | namespace elem 7 | { 8 | 9 | namespace detail 10 | { 11 | 12 | enum class BlepMode { 13 | Saw = 0, 14 | Square = 1, 15 | Triangle = 2, 16 | }; 17 | } 18 | 19 | template 20 | struct PolyBlepOscillatorNode : public GraphNode { 21 | using GraphNode::GraphNode; 22 | 23 | inline FloatType blep (FloatType phase, FloatType increment) 24 | { 25 | if (phase < increment) 26 | { 27 | auto p = phase / increment; 28 | return (FloatType(2) - p) * p - FloatType(1); 29 | } 30 | 31 | if (phase > (FloatType(1) - increment)) 32 | { 33 | auto p = (phase - FloatType(1)) / increment; 34 | return (p + FloatType(2)) * p + FloatType(1); 35 | } 36 | 37 | return FloatType(0); 38 | } 39 | 40 | inline FloatType tick (FloatType phase, FloatType increment) 41 | { 42 | if constexpr (Mode == detail::BlepMode::Saw) { 43 | return FloatType(2) * phase - FloatType(1) - blep(phase, increment); 44 | } 45 | 46 | if constexpr (Mode == detail::BlepMode::Square || Mode == detail::BlepMode::Triangle) { 47 | auto const naive = phase < FloatType(0.5) 48 | ? FloatType(1) 49 | : FloatType(-1); 50 | 51 | auto const halfPhase = std::fmod(phase + FloatType(0.5), FloatType(1)); 52 | auto const square = naive + blep(phase, increment) - blep(halfPhase, increment); 53 | 54 | if constexpr (Mode == detail::BlepMode::Square) { 55 | return square; 56 | } 57 | 58 | acc += FloatType(4) * increment * square; 59 | return acc; 60 | } 61 | 62 | return FloatType(0); 63 | } 64 | 65 | void process (BlockContext const& ctx) override { 66 | auto** inputData = ctx.inputData; 67 | auto* outputData = ctx.outputData[0]; 68 | auto numChannels = ctx.numInputChannels; 69 | auto numSamples = ctx.numSamples; 70 | 71 | // If we don't have the inputs we need, bail here and zero the buffer 72 | if (numChannels < 1) 73 | return (void) std::fill_n(outputData, numSamples, FloatType(0)); 74 | 75 | FloatType const sr = elem::GraphNode::getSampleRate(); 76 | 77 | for (size_t i = 0; i < numSamples; ++i) { 78 | auto freq = inputData[0][i]; 79 | auto increment = freq / sr; 80 | 81 | auto out = tick(phase, increment); 82 | 83 | phase += increment; 84 | 85 | if (phase >= FloatType(1)) 86 | phase -= FloatType(1); 87 | 88 | outputData[i] = out; 89 | } 90 | } 91 | 92 | FloatType phase = 0; 93 | FloatType acc = 0; 94 | }; 95 | 96 | } // namespace elem 97 | 98 | -------------------------------------------------------------------------------- /runtime/elem/builtins/Table.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "../GraphNode.h" 4 | #include "../SingleWriterSingleReaderQueue.h" 5 | #include "../Types.h" 6 | 7 | 8 | namespace elem 9 | { 10 | 11 | // TableNode is a core builtin for lookup table behaviors 12 | // 13 | // Can be used for loading sample buffers and reading from them at variable 14 | // playback rates, or as windowed grain readers, or for loading various functions 15 | // as lookup tables, etc. 16 | template 17 | struct TableNode : public GraphNode { 18 | using GraphNode::GraphNode; 19 | 20 | int setProperty(std::string const& key, js::Value const& val, SharedResourceMap& resources) override 21 | { 22 | if (key == "path") { 23 | if (!val.isString()) 24 | return ReturnCode::InvalidPropertyType(); 25 | 26 | if (!resources.has((js::String) val)) 27 | return ReturnCode::InvalidPropertyValue(); 28 | 29 | auto ref = resources.get((js::String) val); 30 | bufferQueue.push(std::move(ref)); 31 | } 32 | 33 | return GraphNode::setProperty(key, val); 34 | } 35 | 36 | void process (BlockContext const& ctx) override { 37 | auto** inputData = ctx.inputData; 38 | auto* outputData = ctx.outputData[0]; 39 | auto numChannels = ctx.numInputChannels; 40 | auto numSamples = ctx.numSamples; 41 | 42 | // First order of business: grab the most recent sample buffer to use if 43 | // there's anything in the queue. This behavior means that changing the buffer 44 | // while playing the sample will cause a discontinuity. 45 | while (bufferQueue.size() > 0) 46 | bufferQueue.pop(activeBuffer); 47 | 48 | if (numChannels == 0 || activeBuffer == nullptr) 49 | return (void) std::fill_n(outputData, numSamples, FloatType(0)); 50 | 51 | auto const bufferView = activeBuffer->getChannelData(0); 52 | auto const bufferSize = static_cast(bufferView.size()); 53 | auto const bufferData = bufferView.data(); 54 | 55 | if (bufferSize == 0) 56 | return (void) std::fill_n(outputData, numSamples, FloatType(0)); 57 | 58 | // Finally, render sample output 59 | for (size_t i = 0; i < numSamples; ++i) { 60 | auto const readPos = std::clamp(inputData[0][i], FloatType(0), FloatType(1)) * FloatType(bufferSize - 1); 61 | auto const readLeft = static_cast(readPos); 62 | auto const readRight = readLeft + 1; 63 | auto const frac = readPos - std::floor(readPos); 64 | 65 | auto const left = bufferData[readLeft % bufferSize]; 66 | auto const right = bufferData[readRight % bufferSize]; 67 | 68 | // Now we can read the next sample out with linear 69 | // interpolation for sub-sample reads. 70 | outputData[i] = left + frac * (right - left); 71 | } 72 | } 73 | 74 | SingleWriterSingleReaderQueue bufferQueue; 75 | SharedResourcePtr activeBuffer; 76 | }; 77 | 78 | } // namespace elem 79 | -------------------------------------------------------------------------------- /js/packages/offline-renderer/__tests__/sampleseq.test.js: -------------------------------------------------------------------------------- 1 | import OfflineRenderer from '..'; 2 | import { el } from '@elemaudio/core'; 3 | 4 | 5 | test('sampleseq basics', async function() { 6 | let core = new OfflineRenderer(); 7 | 8 | await core.initialize({ 9 | numInputChannels: 0, 10 | numOutputChannels: 1, 11 | blockSize: 32, 12 | virtualFileSystem: { 13 | '/v/ones': Float32Array.from(Array.from({length: 128}).fill(1)), 14 | }, 15 | }); 16 | 17 | // Graph 18 | let [time, setTimeProps] = core.createRef("const", {value: 0}, []); 19 | 20 | core.render( 21 | el.sampleseq({ 22 | path: '/v/ones', 23 | duration: 128, 24 | seq: [ 25 | { time: 0, value: 0 }, 26 | { time: 128, value: 1 }, 27 | { time: 256, value: 0 }, 28 | { time: 512, value: 1 }, 29 | ] 30 | }, time) 31 | ); 32 | 33 | // Ten blocks of data to get past the root node fade 34 | let inps = []; 35 | let outs = [new Float32Array(10 * 512)]; 36 | 37 | // Get past the fade-in 38 | core.process(inps, outs); 39 | 40 | // Now we expect to see zeros because we're fixed at time 0 41 | outs = [new Float32Array(32)]; 42 | core.process(inps, outs); 43 | expect(outs[0]).toMatchSnapshot(); 44 | 45 | // Jump to time 129 and we should see 1s fade in 46 | setTimeProps({value: 129}); 47 | core.process(inps, outs); 48 | 49 | for (let i = 1; i < outs[0].length; ++i) { 50 | expect(outs[0][i]).toBeGreaterThanOrEqual(0); 51 | expect(outs[0][i]).toBeLessThan(1); 52 | expect(outs[0][i]).toBeGreaterThan(outs[0][i - 1]); 53 | } 54 | 55 | // Spin for a few blocks, incrementing time, and we should see 56 | // the fade resolve and emit constant ones 57 | // 58 | // If we don't increment time, the sampleseq node will repeatedly 59 | // see an input time different from what it expects, and continue 60 | // re-allocating the readers, so the fade that we're observing would 61 | // lock in between 0 and 1 62 | for (let i = 0; i < 2; ++i) { 63 | setTimeProps({value: 129 + (i + 1) * 32}) 64 | core.process(inps, outs); 65 | } 66 | 67 | expect(outs[0]).toMatchSnapshot(); 68 | 69 | // Jump to time and we should see 1s fade out 70 | setTimeProps({value: 64}); 71 | core.process(inps, outs); 72 | 73 | for (let i = 1; i < outs[0].length; ++i) { 74 | expect(outs[0][i]).toBeGreaterThanOrEqual(0); 75 | expect(outs[0][i]).toBeLessThan(1); 76 | expect(outs[0][i]).toBeLessThan(outs[0][i - 1]); 77 | } 78 | 79 | // Spin for a few blocks and we should see the fade resolve and 80 | // emit constant zeros 81 | for (let i = 0; i < 10; ++i) { 82 | core.process(inps, outs); 83 | } 84 | 85 | expect(outs[0]).toMatchSnapshot(); 86 | 87 | // Jump one more time 88 | setTimeProps({value: 520}); 89 | core.process(inps, outs); 90 | 91 | for (let i = 0; i < outs[0].length; ++i) { 92 | expect(outs[0][i]).toBeGreaterThanOrEqual(0); 93 | expect(outs[0][i]).toBeLessThan(1); 94 | } 95 | 96 | // Spin for a few blocks and we should see the fade resolve and 97 | // emit constant ones 98 | for (let i = 0; i < 2; ++i) { 99 | setTimeProps({value: 520 + (i + 1) * 32}) 100 | core.process(inps, outs); 101 | } 102 | 103 | expect(outs[0]).toMatchSnapshot(); 104 | }); 105 | -------------------------------------------------------------------------------- /runtime/elem/builtins/mc/Table.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "../../GraphNode.h" 4 | #include "../../SingleWriterSingleReaderQueue.h" 5 | #include "../../Types.h" 6 | 7 | 8 | namespace elem 9 | { 10 | 11 | template 12 | struct StereoTableNode : public GraphNode { 13 | using GraphNode::GraphNode; 14 | 15 | int setProperty(std::string const& key, js::Value const& val, SharedResourceMap& resources) override 16 | { 17 | if (key == "path") { 18 | if (!val.isString()) 19 | return ReturnCode::InvalidPropertyType(); 20 | 21 | if (!resources.has((js::String) val)) 22 | return ReturnCode::InvalidPropertyValue(); 23 | 24 | auto ref = resources.get((js::String) val); 25 | bufferQueue.push(std::move(ref)); 26 | } 27 | 28 | return GraphNode::setProperty(key, val); 29 | } 30 | 31 | void process (BlockContext const& ctx) override { 32 | auto** outputData = ctx.outputData; 33 | auto numChannels = ctx.numOutputChannels; 34 | auto numSamples = ctx.numSamples; 35 | 36 | // First order of business: grab the most recent sample buffer to use if 37 | // there's anything in the queue. This behavior means that changing the buffer 38 | // while playing the sample will cause a discontinuity. 39 | while (bufferQueue.size() > 0) 40 | bufferQueue.pop(activeBuffer); 41 | 42 | if (ctx.numInputChannels == 0 || activeBuffer == nullptr) 43 | { 44 | for (size_t j = 0; j < numChannels; ++j) 45 | { 46 | std::fill_n(outputData[j], numSamples, FloatType(0)); 47 | } 48 | 49 | return; 50 | } 51 | 52 | for (size_t j = 0; j < numChannels; ++j) 53 | { 54 | auto const bufferView = activeBuffer->getChannelData(j); 55 | auto const bufferSize = bufferView.size(); 56 | auto const bufferData = bufferView.data(); 57 | auto* inputData = ctx.inputData[0]; 58 | 59 | 60 | if (bufferSize == 0) 61 | { 62 | std::fill_n(outputData[j], numSamples, FloatType(0)); 63 | continue; 64 | } 65 | 66 | for (size_t i = 0; i < numSamples; ++i) 67 | { 68 | auto const readPos = std::clamp(inputData[i], FloatType(0), FloatType(1)) * FloatType(bufferSize - 1); 69 | auto const readLeft = static_cast(readPos); 70 | auto const readRight = readLeft + 1; 71 | auto const frac = readPos - std::floor(readPos); 72 | 73 | auto const left = bufferData[readLeft % bufferSize]; 74 | auto const right = bufferData[readRight % bufferSize]; 75 | 76 | // Now we can read the next sample out with linear 77 | // interpolation for sub-sample reads. 78 | outputData[j][i] = left + frac * (right - left); 79 | } 80 | } 81 | } 82 | 83 | SingleWriterSingleReaderQueue bufferQueue; 84 | SharedResourcePtr activeBuffer; 85 | }; 86 | 87 | } // namespace elem 88 | -------------------------------------------------------------------------------- /runtime/elem/builtins/helpers/RefCountedPool.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | 6 | namespace elem 7 | { 8 | 9 | // A simple object pool which uses ref counting for object availability. 10 | // 11 | // The goal here is primarily for situations where we need to allocate 12 | // objects of a certain type and then hand them over to the realtime thread 13 | // for realtime processing. With the RefCountedPool, the non-realtime thread 14 | // can grab a reference to a previously allocated object, manipulate it, 15 | // then pass it through a lock free queue to the realtime thread. 16 | // 17 | // While the realtime thread is using that object for processing, it will 18 | // retain a reference to it. The RefCountedPool uses std::shared_ptr's atomic 19 | // use count to identify objects which are in use, either on the realtime 20 | // thread or in the queue awaiting the real time thread. 21 | // 22 | // When the realtime is done with it, it simply drops its reference, which 23 | // is an atomic update (so long as it doesn't decrease the ref count to 0), 24 | // setting the use_count back to a value which indicates to RefCountedPool that 25 | // this object is now available for reallocation. 26 | // 27 | // See the SequenceNode for an example of this pattern. 28 | template 29 | class RefCountedPool 30 | { 31 | public: 32 | RefCountedPool(size_t capacity = 4) 33 | { 34 | // Fill the pool with default-constructed shared_ptrs 35 | for (size_t i = 0; i < capacity; ++i) 36 | { 37 | internal.push_back(std::make_shared()); 38 | } 39 | } 40 | 41 | std::shared_ptr allocate() 42 | { 43 | for (size_t i = 0; i < internal.size(); ++i) 44 | { 45 | // If we found a pointer that has only one reference, that ref 46 | // is our pool, so we konw that there are no other users of this 47 | // element and can hand it out. 48 | if (internal[i].use_count() == 1) 49 | { 50 | return internal[i]; 51 | } 52 | } 53 | 54 | // If we exceed the pool size, dynamically allocate another element 55 | auto next = std::make_shared(); 56 | internal.push_back(next); 57 | 58 | return next; 59 | } 60 | 61 | std::shared_ptr allocateAvailableWithDefault(std::shared_ptr&& dv) 62 | { 63 | for (size_t i = 0; i < internal.size(); ++i) 64 | { 65 | // If we found a pointer that has only one reference, that ref 66 | // is our pool, so we konw that there are no other users of this 67 | // element and can hand it out. 68 | if (internal[i].use_count() == 1) 69 | { 70 | return internal[i]; 71 | } 72 | } 73 | 74 | // Else, we return the provided default value without dynamically allocating anything 75 | return std::move(dv); 76 | } 77 | 78 | void forEach (std::function&)>&& fn) 79 | { 80 | for (size_t i = 0; i < internal.size(); ++i) 81 | { 82 | fn(internal[i]); 83 | } 84 | } 85 | 86 | private: 87 | std::vector> internal; 88 | }; 89 | 90 | } // namespace elem 91 | -------------------------------------------------------------------------------- /js/packages/offline-renderer/__tests__/offline-renderer.test.js: -------------------------------------------------------------------------------- 1 | import OfflineRenderer from '..'; 2 | import { createNode, el } from '@elemaudio/core'; 3 | 4 | 5 | test('the basics', async function() { 6 | let core = new OfflineRenderer(); 7 | 8 | await core.initialize({ 9 | numInputChannels: 1, 10 | numOutputChannels: 1, 11 | }); 12 | 13 | // Ten blocks of data 14 | let inps = [new Float32Array(512 * 10)]; 15 | let outs = [new Float32Array(512 * 10)]; 16 | 17 | core.render(el.mul(2, 3)); 18 | core.process(inps, outs); 19 | 20 | // After a couple blocks, we expect to see the output data 21 | // has resolved to a bunch of 6s 22 | expect(outs[0].slice(512 * 8, 512 * 9)).toMatchSnapshot(); 23 | }); 24 | 25 | test('events', async function() { 26 | let core = new OfflineRenderer(); 27 | 28 | await core.initialize({ 29 | numInputChannels: 1, 30 | numOutputChannels: 1, 31 | }); 32 | 33 | // Ten blocks of data 34 | let inps = [new Float32Array(512 * 10)]; 35 | let outs = [new Float32Array(512 * 10)]; 36 | let events = []; 37 | 38 | core.on('meter', function(e) { 39 | events.push(e); 40 | }); 41 | 42 | core.render(el.meter({}, el.mul(2, 3))); 43 | core.process(inps, outs); 44 | 45 | expect(events).toMatchSnapshot(); 46 | }); 47 | 48 | test('switch and switch back', async function() { 49 | let core = new OfflineRenderer(); 50 | 51 | await core.initialize({ 52 | numInputChannels: 0, 53 | numOutputChannels: 1, 54 | }); 55 | 56 | // Ten blocks of data 57 | let inps = []; 58 | let outs = [new Float32Array(512 * 10)]; 59 | 60 | core.render(el.mul(2, 3)); 61 | core.process(inps, outs); 62 | 63 | // After a couple blocks, we render again with a new graph 64 | core.render(el.mul(3, 4)); 65 | core.process(inps, outs); 66 | 67 | // Once this settles, we should see the new output 68 | expect(outs[0].slice(512 * 8, 512 * 8 + 32)).toMatchSnapshot(); 69 | 70 | // Finally we render a third time back to the original graph 71 | // to show that the switch back correctly restored the prior roots 72 | core.render(el.mul(2, 3)); 73 | core.process(inps, outs); 74 | 75 | expect(outs[0].slice(512 * 8, 512 * 8 + 32)).toMatchSnapshot(); 76 | }); 77 | 78 | test('child limit', async function() { 79 | let core = new OfflineRenderer(); 80 | 81 | await core.initialize({ 82 | numInputChannels: 0, 83 | numOutputChannels: 1, 84 | }); 85 | 86 | // Ten blocks of data 87 | let inps = []; 88 | let outs = [new Float32Array(512 * 10)]; 89 | 90 | core.render(createNode("add", {}, Array.from({length: 100}).fill(1))); 91 | core.process(inps, outs); 92 | 93 | // Once this settles, we should see 100s everywhere 94 | expect(outs[0].slice(512 * 8, 512 * 8 + 32)).toMatchSnapshot(); 95 | }); 96 | 97 | test('render stats', async function() { 98 | let core = new OfflineRenderer(); 99 | 100 | await core.initialize({ 101 | numInputChannels: 0, 102 | numOutputChannels: 1, 103 | }); 104 | 105 | // Render a graph and get some valid stats back 106 | let stats = await core.render(el.mul(2, 3)); 107 | 108 | expect(stats).toMatchObject({ 109 | edgesAdded: 3, 110 | nodesAdded: 4, 111 | // Two "value" props on the const nodes, one "channel" prop on root, 112 | // and then the "fadeInMs" and "fadeOutMs" on root 113 | propsWritten: 5, 114 | }); 115 | 116 | // Render with an invalid property and get a failure, rejecting the 117 | // promise returned from core.render 118 | await expect(core.render(el.const({value: 'hi'}))).rejects.toMatchObject({ 119 | success: false, 120 | }); 121 | }); 122 | -------------------------------------------------------------------------------- /wasm/Convolve.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | #include 6 | #include 7 | 8 | 9 | namespace elem 10 | { 11 | 12 | namespace detail 13 | { 14 | template 15 | void copy_cast_n(FromType const* input, size_t numSamples, ToType* output) 16 | { 17 | for (size_t i = 0; i < numSamples; ++i) { 18 | output[i] = static_cast(input[i]); 19 | } 20 | } 21 | } 22 | 23 | template 24 | struct ConvolutionNode : public GraphNode { 25 | ConvolutionNode(NodeId id, FloatType const sr, int const blockSize) 26 | : GraphNode::GraphNode(id, sr, blockSize) 27 | { 28 | if constexpr (std::is_same_v) { 29 | scratchIn.resize(blockSize); 30 | scratchOut.resize(blockSize); 31 | } 32 | } 33 | 34 | int setProperty(std::string const& key, js::Value const& val, SharedResourceMap& resources) override 35 | { 36 | if (key == "path") { 37 | if (!val.isString()) 38 | return elem::ReturnCode::InvalidPropertyType(); 39 | 40 | if (!resources.has((js::String) val)) 41 | return elem::ReturnCode::InvalidPropertyValue(); 42 | 43 | if (auto ref = resources.get((js::String) val)) 44 | { 45 | auto co = std::make_shared(); 46 | auto bufferView = ref->getChannelData(0); 47 | 48 | co->reset(); 49 | co->init(512, 4096, bufferView.data(), bufferView.size()); 50 | 51 | convolverQueue.push(std::move(co)); 52 | } 53 | } 54 | 55 | return GraphNode::setProperty(key, val); 56 | } 57 | 58 | void process (BlockContext const& ctx) override { 59 | auto** inputData = ctx.inputData; 60 | auto* outputData = ctx.outputData[0]; 61 | auto numChannels = ctx.numInputChannels; 62 | auto numSamples = ctx.numSamples; 63 | 64 | // First order of business: grab the most recent convolver to use if 65 | // there's anything in the queue. This behavior means that changing the convolver 66 | // impulse response while playing will cause a discontinuity. 67 | while (convolverQueue.size() > 0) 68 | convolverQueue.pop(convolver); 69 | 70 | if (numChannels == 0 || convolver == nullptr) 71 | return (void) std::fill_n(outputData, numSamples, FloatType(0)); 72 | 73 | if constexpr (std::is_same_v) { 74 | convolver->process(inputData[0], outputData, numSamples); 75 | } 76 | 77 | if constexpr (std::is_same_v) { 78 | auto* scratchDataIn = scratchIn.data(); 79 | auto* scratchDataOut = scratchOut.data(); 80 | 81 | detail::copy_cast_n(inputData[0], numSamples, scratchDataIn); 82 | convolver->process(scratchDataIn, scratchDataOut, numSamples); 83 | detail::copy_cast_n(scratchDataOut, numSamples, outputData); 84 | } 85 | } 86 | 87 | SingleWriterSingleReaderQueue> convolverQueue; 88 | std::shared_ptr convolver; 89 | 90 | std::vector scratchIn; 91 | std::vector scratchOut; 92 | }; 93 | 94 | } // namespace elem 95 | -------------------------------------------------------------------------------- /js/packages/core/__tests__/hashing.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | el, 3 | renderWithDelegate, 4 | } from '..'; 5 | 6 | 7 | const InstructionTypes = { 8 | CREATE_NODE: 0, 9 | DELETE_NODE: 1, 10 | APPEND_CHILD: 2, 11 | SET_PROPERTY: 3, 12 | ACTIVATE_ROOTS: 4, 13 | COMMIT_UPDATES: 5, 14 | }; 15 | 16 | class HashlessRenderer { 17 | constructor(config) { 18 | this.nodeMap = new Map(); 19 | this.memoMap = new Map(); 20 | this.batch = []; 21 | this.roots = []; 22 | 23 | this.nextMaskId = 0; 24 | this.maskTable = new Map(); 25 | } 26 | 27 | getMaskId(hash) { 28 | if (!this.maskTable.has(hash)) { 29 | this.maskTable.set(hash, this.nextMaskId++); 30 | } 31 | 32 | return this.maskTable.get(hash); 33 | } 34 | 35 | getNodeMap() { 36 | return this.nodeMap; 37 | } 38 | 39 | getMemoMap() { 40 | return this.memoMap; 41 | } 42 | 43 | getRenderContext() { 44 | return { sampleRate: 44100 }; 45 | } 46 | 47 | getActiveRoots() { 48 | return this.roots; 49 | } 50 | 51 | getTerminalGeneration() { 52 | return 4; 53 | } 54 | 55 | getBatch() { 56 | return this.batch; 57 | } 58 | 59 | clearBatch() { 60 | this.batch = []; 61 | } 62 | 63 | createNode(hash, type) { 64 | this.batch.push([InstructionTypes.CREATE_NODE, this.getMaskId(hash), type]); 65 | } 66 | 67 | deleteNode(hash) { 68 | this.batch.push([InstructionTypes.DELETE_NODE, this.getMaskId(hash)]); 69 | } 70 | 71 | appendChild(parentHash, childHash) { 72 | this.batch.push([InstructionTypes.APPEND_CHILD, this.getMaskId(parentHash), this.getMaskId(childHash)]); 73 | } 74 | 75 | setProperty(hash, key, val) { 76 | this.batch.push([InstructionTypes.SET_PROPERTY, this.getMaskId(hash), key, val]); 77 | } 78 | 79 | activateRoots(roots) { 80 | this.batch.push([InstructionTypes.ACTIVATE_ROOTS, roots.map(x => this.getMaskId(x))]); 81 | } 82 | 83 | commitUpdates() { 84 | this.batch.push([InstructionTypes.COMMIT_UPDATES]); 85 | } 86 | 87 | render(...args) { 88 | this.roots = renderWithDelegate(this, args, 20, 20); 89 | } 90 | } 91 | 92 | function sortInstructionBatch(x) { 93 | let copy = [...x]; 94 | 95 | return copy.sort((a, b) => { 96 | if (a[0] === b[0]) { 97 | return a[1] < b[1] ? -1 : 1; 98 | } 99 | 100 | return a[0] < b[0] ? -1 : 1; 101 | }); 102 | } 103 | 104 | // To test that our algorithm works even if the hashing function changes. We 105 | // want to see that the same sets of instructions come through 106 | test('instruction set similarity without hash values', function() { 107 | let tr = new HashlessRenderer(); 108 | 109 | tr.render(el.cycle(440)); 110 | expect(sortInstructionBatch(tr.getBatch())).toMatchSnapshot(); 111 | }); 112 | 113 | test('instruction set similarity without hash values 2', function() { 114 | let tr = new HashlessRenderer(); 115 | 116 | let synthVoice = (hz) => el.mul( 117 | 0.25, 118 | el.add( 119 | el.blepsaw(el.mul(hz, 1.001)), 120 | el.blepsquare(el.mul(hz, 0.994)), 121 | el.blepsquare(el.mul(hz, 0.501)), 122 | el.blepsaw(el.mul(hz, 0.496)), 123 | ), 124 | ); 125 | 126 | let train = el.train(4.8); 127 | let arp = [0, 4, 7, 11, 12, 11, 7, 4].map(x => 261.63 * 0.5 * Math.pow(2, x / 12)); 128 | 129 | let modulate = (x, rate, amt) => el.add(x, el.mul(amt, el.cycle(rate))); 130 | let env = el.adsr(0.01, 0.5, 0, 0.4, train); 131 | let filt = (x) => el.lowpass( 132 | el.add(40, el.mul(modulate(1840, 0.05, 1800), env)), 133 | 1, 134 | x 135 | ); 136 | 137 | let out = el.mul(0.25, filt(synthVoice(el.seq({seq: arp, hold: true}, train, 0)))); 138 | tr.render(out, out); 139 | expect(sortInstructionBatch(tr.getBatch())).toMatchSnapshot(); 140 | }); 141 | -------------------------------------------------------------------------------- /js/packages/core/src/Reconciler.res: -------------------------------------------------------------------------------- 1 | // External interface for ES6 Map 2 | module Map = { 3 | type t<'a, 'b> 4 | 5 | @new external make: unit => t<'a, 'b> = "Map" 6 | 7 | @send external has: (t<'a, 'b>, 'a) => bool = "has" 8 | @send external get: (t<'a, 'b>, 'a) => 'b = "get" 9 | @send external delete: (t<'a, 'b>, 'a) => () = "delete" 10 | @send external set: (t<'a, 'b>, 'a, 'b) => () = "set" 11 | @send external values: (t<'a, 'b>) => Js.Array2.array_like<'b> = "values" 12 | 13 | let valuesArray = (m: t<'a, 'b>): array<'b> => { 14 | Js.Array2.from(values(m)) 15 | } 16 | } 17 | 18 | // External interface for ES6 Set 19 | module Set = { 20 | type t<'a> 21 | 22 | @new external make: unit => t<'a> = "Set" 23 | 24 | @send external has: (t<'a>, 'a) => bool = "has" 25 | @send external add: (t<'a>, 'a) => () = "add" 26 | } 27 | 28 | // External interface for the RendererDelegate instance that we receive in renderWithDelegate 29 | module RenderDelegate = { 30 | type t 31 | 32 | // Maps hashes to node objects 33 | @send external getNodeMap: t => Map.t = "getNodeMap" 34 | 35 | @send external getActiveRoots: t => array = "getActiveRoots" 36 | 37 | @send external createNode: (t, int, string) => () = "createNode" 38 | @send external deleteNode: (t, int) => () = "deleteNode" 39 | @send external appendChild: (t, int, int, int) => () = "appendChild" 40 | @send external setProperty: (t, int, string, 'a) => () = "setProperty" 41 | @send external activateRoots: (t, array) => () = "activateRoots" 42 | @send external commitUpdates: t => () = "commitUpdates" 43 | } 44 | 45 | let mount = (delegate: RenderDelegate.t, node: NodeRepr.t) => { 46 | let nodeMap = RenderDelegate.getNodeMap(delegate) 47 | 48 | if !Map.has(nodeMap, node.hash) { 49 | RenderDelegate.createNode(delegate, node.hash, node.kind) 50 | HashUtils.updateNodeProps(delegate, node.hash, Js.Obj.empty(), node.props) 51 | 52 | Belt.List.forEach(node.children, child => { 53 | RenderDelegate.appendChild(delegate, node.hash, child.hash, child.outputChannel) 54 | }) 55 | 56 | Map.set(nodeMap, node.hash, NodeRepr.shallowCopy(node)) 57 | } else { 58 | let existing = Map.get(nodeMap, node.hash) 59 | HashUtils.updateNodeProps(delegate, existing.hash, existing.props, node.props) 60 | } 61 | } 62 | 63 | let rec visit = ( 64 | delegate: RenderDelegate.t, 65 | visitSet: Set.t, 66 | ns: list, 67 | ) => { 68 | let visited = (n: NodeRepr.t) => Set.has(visitSet, n.hash) 69 | let markVisited = (n: NodeRepr.t) => Set.add(visitSet, n.hash) 70 | 71 | // As of v3, every node in the graph is hashed at the time it's created, which 72 | // means that we can perform our reconciliation here in a pre-order visit fashion 73 | // as long as the RenderDelegate makes sure to deliver all createNode instructions 74 | // before it delivers any appendChild instructions. 75 | // 76 | // Pushing that requirement off to the RenderDelegate drastically simplifies this 77 | // graph traversal step 78 | switch ns { 79 | | list{} => () 80 | | list{n, ...rest} if visited(n) => visit(delegate, visitSet, rest) 81 | | list{n, ...rest} => { 82 | markVisited(n) 83 | mount(delegate, n) 84 | visit(delegate, visitSet, Belt.List.concat(n.children, rest)) 85 | } 86 | } 87 | } 88 | 89 | @genType 90 | let renderWithDelegate = (delegate, graphs, rootFadeInMs, rootFadeOutMs) => { 91 | let visitSet = Set.make() 92 | let roots = Belt.List.mapWithIndex(Belt.List.fromArray(graphs), (i, g) => { 93 | NodeRepr.create("root", {"channel": i, "fadeInMs": rootFadeInMs, "fadeOutMs": rootFadeOutMs}, [g]) 94 | }) 95 | 96 | visit(delegate, visitSet, roots) 97 | 98 | RenderDelegate.activateRoots(delegate, Belt.List.toArray(Belt.List.map(roots, r => r.hash))) 99 | RenderDelegate.commitUpdates(delegate) 100 | } 101 | -------------------------------------------------------------------------------- /runtime/elem/SharedResource.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | 6 | #include "Types.h" 7 | 8 | 9 | namespace elem 10 | { 11 | 12 | //============================================================================== 13 | // Base class for shared resources which are made available to GraphNode 14 | // instances during property setting. 15 | class SharedResource { 16 | public: 17 | SharedResource() = default; 18 | virtual ~SharedResource() = default; 19 | 20 | virtual BufferView getChannelData (size_t channelIndex) = 0; 21 | 22 | virtual size_t numChannels() = 0; 23 | virtual size_t numSamples() = 0; 24 | }; 25 | 26 | //============================================================================== 27 | // Utility type definition 28 | using SharedResourcePtr = std::shared_ptr; 29 | 30 | //============================================================================== 31 | // A small wrapper around an unordered_map for holding and interacting with 32 | // shared resources 33 | class SharedResourceMap { 34 | private: 35 | std::unordered_map resources; 36 | 37 | public: 38 | SharedResourceMap() = default; 39 | 40 | using KeyViewType = MapKeyView; 41 | 42 | // Accessor methods for resources 43 | // 44 | // We only allow insertions, not updates. This preserves immutability of existing 45 | // entries which we need in case any active graph nodes hold references to those entries. 46 | bool add(std::string const& name, SharedResourcePtr resource); 47 | bool has(std::string const& name) const; 48 | SharedResourcePtr get(std::string const& name) const; 49 | 50 | // Internal accessor for feedback tap nodes that supports constructing and 51 | // inserting a default entry 52 | SharedResourcePtr getTapResource(std::string const& name, std::function makeDefault); 53 | 54 | // Inspecting and clearing entries 55 | KeyViewType keys(); 56 | void prune(); 57 | }; 58 | 59 | //============================================================================== 60 | // Details... 61 | inline bool SharedResourceMap::add (std::string const& name, SharedResourcePtr resource) { 62 | return resources.emplace(name, resource).second; 63 | } 64 | 65 | inline bool SharedResourceMap::has (std::string const& name) const { 66 | return resources.count(name) > 0; 67 | } 68 | 69 | inline SharedResourcePtr SharedResourceMap::get (std::string const& name) const { 70 | auto it = resources.find(name); 71 | 72 | if (it == resources.end()) 73 | return nullptr; 74 | 75 | return it->second; 76 | } 77 | 78 | inline SharedResourcePtr SharedResourceMap::getTapResource(std::string const& name, std::function makeDefault) { 79 | auto it = resources.find(name); 80 | 81 | if (it != resources.end()) 82 | return it->second; 83 | 84 | SharedResourcePtr resource = makeDefault(); 85 | auto success = resources.emplace(name, resource).second; 86 | 87 | if (!success) 88 | return nullptr; 89 | 90 | return resource; 91 | } 92 | 93 | inline void SharedResourceMap::prune() { 94 | for (auto it = resources.cbegin(); it != resources.cend(); /* no increment */) { 95 | if (it->second.use_count() == 1) { 96 | resources.erase(it++); 97 | } else { 98 | it++; 99 | } 100 | } 101 | } 102 | 103 | inline typename SharedResourceMap::KeyViewType SharedResourceMap::keys() { 104 | return KeyViewType(resources); 105 | } 106 | 107 | } // namespace elem 108 | -------------------------------------------------------------------------------- /js/packages/core/lib/math.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createNode, 3 | isNode, 4 | resolve, 5 | ElemNode, 6 | NodeRepr_t, 7 | } from "../nodeUtils"; 8 | 9 | // Unary nodes 10 | export function identity( 11 | props: { 12 | key?: string; 13 | channel?: number; 14 | }, 15 | x?: ElemNode, 16 | ): NodeRepr_t { 17 | if (isNode(x)) { 18 | return createNode("in", props, [x]); 19 | } 20 | 21 | return createNode("in", props, []); 22 | } 23 | 24 | export function sin(x: ElemNode): NodeRepr_t { 25 | return createNode("sin", {}, [resolve(x)]); 26 | } 27 | 28 | export function cos(x: ElemNode): NodeRepr_t { 29 | return createNode("cos", {}, [resolve(x)]); 30 | } 31 | 32 | export function tan(x: ElemNode): NodeRepr_t { 33 | return createNode("tan", {}, [resolve(x)]); 34 | } 35 | 36 | export function tanh(x: ElemNode): NodeRepr_t { 37 | return createNode("tanh", {}, [resolve(x)]); 38 | } 39 | 40 | export function asinh(x: ElemNode): NodeRepr_t { 41 | return createNode("asinh", {}, [resolve(x)]); 42 | } 43 | 44 | export function ln(x: ElemNode): NodeRepr_t { 45 | return createNode("ln", {}, [resolve(x)]); 46 | } 47 | 48 | export function log(x: ElemNode): NodeRepr_t { 49 | return createNode("log", {}, [resolve(x)]); 50 | } 51 | 52 | export function log2(x: ElemNode): NodeRepr_t { 53 | return createNode("log2", {}, [resolve(x)]); 54 | } 55 | 56 | export function ceil(x: ElemNode): NodeRepr_t { 57 | return createNode("ceil", {}, [resolve(x)]); 58 | } 59 | 60 | export function floor(x: ElemNode): NodeRepr_t { 61 | return createNode("floor", {}, [resolve(x)]); 62 | } 63 | 64 | export function round(x: ElemNode): NodeRepr_t { 65 | return createNode("round", {}, [resolve(x)]); 66 | } 67 | 68 | export function sqrt(x: ElemNode): NodeRepr_t { 69 | return createNode("sqrt", {}, [resolve(x)]); 70 | } 71 | 72 | export function exp(x: ElemNode): NodeRepr_t { 73 | return createNode("exp", {}, [resolve(x)]); 74 | } 75 | 76 | export function abs(x: ElemNode): NodeRepr_t { 77 | return createNode("abs", {}, [resolve(x)]); 78 | } 79 | 80 | // Binary nodes 81 | export function le(a: ElemNode, b: ElemNode): NodeRepr_t { 82 | return createNode("le", {}, [resolve(a), resolve(b)]); 83 | } 84 | 85 | export function leq(a: ElemNode, b: ElemNode): NodeRepr_t { 86 | return createNode("leq", {}, [resolve(a), resolve(b)]); 87 | } 88 | 89 | export function ge(a: ElemNode, b: ElemNode): NodeRepr_t { 90 | return createNode("ge", {}, [resolve(a), resolve(b)]); 91 | } 92 | 93 | export function geq(a: ElemNode, b: ElemNode): NodeRepr_t { 94 | return createNode("geq", {}, [resolve(a), resolve(b)]); 95 | } 96 | 97 | export function pow(a: ElemNode, b: ElemNode): NodeRepr_t { 98 | return createNode("pow", {}, [resolve(a), resolve(b)]); 99 | } 100 | 101 | export function eq(a: ElemNode, b: ElemNode): NodeRepr_t { 102 | return createNode("eq", {}, [resolve(a), resolve(b)]); 103 | } 104 | 105 | export function and(a: ElemNode, b: ElemNode): NodeRepr_t { 106 | return createNode("and", {}, [resolve(a), resolve(b)]); 107 | } 108 | 109 | export function or(a: ElemNode, b: ElemNode): NodeRepr_t { 110 | return createNode("or", {}, [resolve(a), resolve(b)]); 111 | } 112 | 113 | // Binary reducing nodes 114 | export function add(...args: Array): NodeRepr_t { 115 | return createNode("add", {}, args.map(resolve)); 116 | } 117 | 118 | export function sub(...args: Array): NodeRepr_t { 119 | return createNode("sub", {}, args.map(resolve)); 120 | } 121 | 122 | export function mul(...args: Array): NodeRepr_t { 123 | return createNode("mul", {}, args.map(resolve)); 124 | } 125 | 126 | export function div(...args: Array): NodeRepr_t { 127 | return createNode("div", {}, args.map(resolve)); 128 | } 129 | 130 | export function mod(...args: Array): NodeRepr_t { 131 | return createNode("mod", {}, args.map(resolve)); 132 | } 133 | 134 | export function min(...args: Array): NodeRepr_t { 135 | return createNode("min", {}, args.map(resolve)); 136 | } 137 | 138 | export function max(...args: Array): NodeRepr_t { 139 | return createNode("max", {}, args.map(resolve)); 140 | } 141 | -------------------------------------------------------------------------------- /cli/Benchmark.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | 6 | #include 7 | #include 8 | #include 9 | 10 | #include "Benchmark.h" 11 | 12 | 13 | const auto* kConsoleShimScript = R"script( 14 | (function() { 15 | if (typeof globalThis.console === 'undefined') { 16 | globalThis.console = { 17 | log(...args) { 18 | return __log__('[log]', ...args); 19 | }, 20 | warn(...args) { 21 | return __log__('[warn]', ...args); 22 | }, 23 | error(...args) { 24 | return __log__('[error]', ...args); 25 | }, 26 | }; 27 | } 28 | })(); 29 | )script"; 30 | 31 | template 32 | void runBenchmark(std::string const& name, std::string const& inputFileName, std::function&)>&& initCallback) { 33 | elem::Runtime runtime(44100.0, 512); 34 | 35 | // Allow additional user initialization 36 | initCallback(runtime); 37 | 38 | auto ctx = choc::javascript::createQuickJSContext(); 39 | 40 | ctx.registerFunction("__postNativeMessage__", [&](choc::javascript::ArgumentList args) { 41 | runtime.applyInstructions(elem::js::parseJSON(args[0]->toString())); 42 | return choc::value::Value(); 43 | }); 44 | 45 | ctx.registerFunction("__log__", [](choc::javascript::ArgumentList args) { 46 | for (size_t i = 0; i < args.numArgs; ++i) { 47 | std::cout << choc::json::toString(*args[i], true) << std::endl; 48 | } 49 | 50 | return choc::value::Value(); 51 | }); 52 | 53 | // Shim the js environment for console logging 54 | (void) ctx.evaluate(kConsoleShimScript); 55 | 56 | // Execute the input file 57 | auto inputFile = choc::file::loadFileAsString(inputFileName); 58 | auto rv = ctx.evaluate(inputFile); 59 | 60 | // Now we benchmark the realtime processing step 61 | std::vector> scratchBuffers; 62 | std::vector scratchPointers; 63 | 64 | for (int i = 0; i < 2; ++i) { 65 | scratchBuffers.push_back(std::vector(512)); 66 | scratchPointers.push_back(scratchBuffers[i].data()); 67 | } 68 | 69 | // Run the first block to process the events 70 | runtime.process( 71 | nullptr, 72 | 0, 73 | scratchPointers.data(), 74 | 2, 75 | 512, 76 | 0 77 | ); 78 | 79 | // Now we can measure the static render process. We have this sleep 80 | // here to clearly demarcate, in the profiling timeline, which work is 81 | // related to the event processing above and which work is related to this 82 | // work loop here 83 | std::this_thread::sleep_for(std::chrono::seconds(1)); 84 | std::vector deltas; 85 | 86 | for (size_t i = 0; i < 10'000; ++i) { 87 | auto t0 = std::chrono::steady_clock::now(); 88 | 89 | runtime.process( 90 | nullptr, 91 | 0, 92 | scratchPointers.data(), 93 | 2, 94 | 512, 95 | 0 96 | ); 97 | 98 | auto t1 = std::chrono::steady_clock::now(); 99 | auto diffms = std::chrono::duration_cast(t1 - t0).count(); 100 | deltas.push_back(diffms); 101 | } 102 | 103 | // Reporting 104 | std::this_thread::sleep_for(std::chrono::seconds(1)); 105 | auto const sum = std::accumulate(deltas.begin(), deltas.end(), 0.0); 106 | auto const avg = sum / (double) deltas.size(); 107 | 108 | std::cout << "[Running " << name << "]:" << std::endl; 109 | std::cout << "Total run time: " << sum << "us " << "(" << (sum / 1000000) << "s)" << std::endl; 110 | std::cout << "Average iteration time: " << avg << "us" << std::endl; 111 | std::cout << "Done" << std::endl << std::endl; 112 | } 113 | 114 | template void runBenchmark(std::string const& name, std::string const& inputFileName, std::function&)>&& initCallback); 115 | template void runBenchmark(std::string const& name, std::string const& inputFileName, std::function&)>&& initCallback); 116 | -------------------------------------------------------------------------------- /js/packages/offline-renderer/__tests__/sparseq2.test.js: -------------------------------------------------------------------------------- 1 | import OfflineRenderer from '..'; 2 | import { el } from '@elemaudio/core'; 3 | 4 | 5 | test('sparseq2 basics', async function() { 6 | let core = new OfflineRenderer(); 7 | 8 | await core.initialize({ 9 | numInputChannels: 0, 10 | numOutputChannels: 1, 11 | }); 12 | 13 | // Graph 14 | core.render( 15 | el.sparseq2({seq: [ 16 | { time: 512 * 10 + 4, value: 1 }, 17 | { time: 512 * 10 + 8, value: 2 }, 18 | { time: 512 * 10 + 12, value: 3 }, 19 | { time: 512 * 10 + 16, value: 4 }, 20 | ]}, el.time()) 21 | ); 22 | 23 | // Ten blocks of data 24 | let inps = []; 25 | let outs = [new Float32Array(512 * 10)]; 26 | 27 | // Get past the fade-in 28 | core.process(inps, outs); 29 | 30 | // Push another small block. We should see the sequence play out 31 | inps = []; 32 | outs = [new Float32Array(32)]; 33 | 34 | core.process(inps, outs); 35 | expect(outs[0]).toMatchSnapshot(); 36 | }); 37 | 38 | test('sparseq2 interp', async function() { 39 | let core = new OfflineRenderer(); 40 | 41 | await core.initialize({ 42 | numInputChannels: 0, 43 | numOutputChannels: 1, 44 | }); 45 | 46 | // Graph 47 | core.render( 48 | el.sparseq2({ 49 | interpolate: 1, 50 | seq: [ 51 | { time: 512 * 10 + 0, value: 1 }, 52 | { time: 512 * 10 + 4, value: 2 }, 53 | { time: 512 * 10 + 8, value: 3 }, 54 | { time: 512 * 10 + 12, value: 4 }, 55 | ], 56 | }, el.time()) 57 | ); 58 | 59 | // Ten blocks of data 60 | let inps = []; 61 | let outs = [new Float32Array(512 * 10)]; 62 | 63 | // Get past the fade-in 64 | core.process(inps, outs); 65 | 66 | // Push another small block. We should see the sequence play out with 67 | // intermediate interpolated values 68 | inps = []; 69 | outs = [new Float32Array(32)]; 70 | 71 | core.process(inps, outs); 72 | expect(outs[0]).toMatchSnapshot(); 73 | }); 74 | 75 | test('sparseq2 looping', async function() { 76 | let core = new OfflineRenderer(); 77 | 78 | await core.initialize({ 79 | numInputChannels: 0, 80 | numOutputChannels: 1, 81 | }); 82 | 83 | // Graph 84 | let loop = (start, end, t) => el.add(start, el.mod(t, el.sub(end, start))); 85 | 86 | core.render( 87 | el.sparseq2({seq: [ 88 | { time: 512 * 10 + 0, value: 1 }, 89 | { time: 512 * 10 + 4, value: 2 }, 90 | { time: 512 * 10 + 8, value: 3 }, 91 | { time: 512 * 10 + 12, value: 4 }, 92 | ]}, loop(5120, 5120 + 16, el.time())) 93 | ); 94 | 95 | // Ten blocks of data 96 | let inps = []; 97 | let outs = [new Float32Array(512 * 10)]; 98 | 99 | // Get past the fade-in 100 | core.process(inps, outs); 101 | 102 | // Push another small block. We should see a loop through the sequence 103 | // on a 16 sample interval 104 | inps = []; 105 | outs = [new Float32Array(32)]; 106 | 107 | core.process(inps, outs); 108 | expect(outs[0]).toMatchSnapshot(); 109 | }); 110 | 111 | test('sparseq2 skip ahead', async function() { 112 | let core = new OfflineRenderer(); 113 | 114 | await core.initialize({ 115 | numInputChannels: 1, 116 | numOutputChannels: 1, 117 | }); 118 | 119 | // Graph 120 | let loop = (start, end, t) => el.add(start, el.mod(t, el.sub(end, start))); 121 | 122 | core.render( 123 | el.sparseq2({seq: [ 124 | { time: 512 * 10 + 0, value: 1 }, 125 | { time: 512 * 10 + 4, value: 2 }, 126 | { time: 512 * 10 + 8, value: 3 }, 127 | { time: 512 * 10 + 12, value: 4 }, 128 | ]}, el.in({channel: 0})) 129 | ); 130 | 131 | // Ten blocks of data 132 | let inps = [new Float32Array(512 * 10)]; 133 | let outs = [new Float32Array(512 * 10)]; 134 | 135 | // Get past the fade-in 136 | core.process(inps, outs); 137 | 138 | // Push another small block. We should see the sequence skip ahead 139 | // to a future value 140 | inps = [Float32Array.from([ 141 | 5120, 5120, 5120, 5120, 5120, 5120, 5120, 5120, 142 | 5128, 5128, 5128, 5128, 5128, 5128, 5128, 5128, 143 | ])]; 144 | 145 | outs = [new Float32Array(16)]; 146 | 147 | core.process(inps, outs); 148 | expect(outs[0]).toMatchSnapshot(); 149 | }); 150 | -------------------------------------------------------------------------------- /runtime/elem/builtins/Capture.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "../GraphNode.h" 4 | #include "../MultiChannelRingBuffer.h" 5 | 6 | #include "helpers/Change.h" 7 | #include "helpers/BitUtils.h" 8 | 9 | 10 | namespace elem 11 | { 12 | 13 | template 14 | struct CaptureNode : public GraphNode { 15 | CaptureNode(NodeId id, FloatType const sr, int const blockSize) 16 | : GraphNode::GraphNode(id, sr, blockSize), 17 | ringBuffer(1, elem::bitceil(static_cast(sr))) 18 | { 19 | } 20 | 21 | void process (BlockContext const& ctx) override { 22 | auto** inputData = ctx.inputData; 23 | auto* outputData = ctx.outputData[0]; 24 | auto numChannels = ctx.numInputChannels; 25 | auto numSamples = ctx.numSamples; 26 | 27 | // If we don't have the inputs we need, we bail here and zero the buffer 28 | // hoping to prevent unexpected signals. 29 | if (numChannels < 2) 30 | return (void) std::fill_n(outputData, numSamples, FloatType(0)); 31 | 32 | // Propagate input 33 | std::copy_n(inputData[1], numSamples, outputData); 34 | 35 | // Capture 36 | for (size_t i = 0; i < numSamples; ++i) { 37 | auto const g = static_cast(inputData[0][i]); 38 | auto const dg = change(inputData[0][i]); 39 | auto const fallingEdge = (dg < FloatType(-0.5)); 40 | 41 | // If we're at the falling edge of the gate signal or need space in our scratch 42 | // we propagate the data into the ring 43 | if (fallingEdge || scratchSize >= scratchBuffer.size()) { 44 | auto const* writeData = scratchBuffer.data(); 45 | ringBuffer.write(&writeData, 1, scratchSize); 46 | scratchSize = 0; 47 | 48 | // And if it's the falling edge we alert the event processor 49 | if (fallingEdge) { 50 | relayReady.store(true); 51 | } 52 | } 53 | 54 | // Otherwise, if the record signal is on, write to our scratch 55 | if (g) { 56 | scratchBuffer[scratchSize++] = inputData[1][i]; 57 | } 58 | } 59 | } 60 | 61 | void processEvents(std::function& eventHandler) override { 62 | auto const samplesAvailable = ringBuffer.size(); 63 | 64 | if (samplesAvailable > 0) { 65 | auto currentSize = relayBuffer.size(); 66 | relayBuffer.resize(currentSize + samplesAvailable); 67 | 68 | auto relayData = relayBuffer.data() + currentSize; 69 | 70 | if (!ringBuffer.read(&relayData, 1, samplesAvailable)) 71 | return; 72 | } 73 | 74 | if (relayReady.exchange(false)) { 75 | // Now we can go ahead and relay the data 76 | js::Float32Array copy; 77 | 78 | auto relaySize = relayBuffer.size(); 79 | auto relayData = relayBuffer.data(); 80 | 81 | copy.resize(relaySize); 82 | 83 | // Can't use std::copy_n here if FloatType is double, so we do it manually 84 | for (size_t i = 0; i < relaySize; ++i) { 85 | copy[i] = static_cast(relayData[i]); 86 | } 87 | 88 | relayBuffer.clear(); 89 | 90 | eventHandler("capture", js::Object({ 91 | {"source", GraphNode::getPropertyWithDefault("name", js::Value())}, 92 | {"data", copy} 93 | })); 94 | } 95 | } 96 | 97 | Change change; 98 | MultiChannelRingBuffer ringBuffer; 99 | std::array scratchBuffer; 100 | size_t scratchSize = 0; 101 | 102 | std::vector relayBuffer; 103 | std::atomic relayReady = false; 104 | }; 105 | 106 | } // namespace elem 107 | -------------------------------------------------------------------------------- /runtime/elem/builtins/filters/MultiMode1p.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "../../GraphNode.h" 4 | 5 | 6 | namespace elem 7 | { 8 | 9 | template 10 | struct CutoffPrewarpNode : public GraphNode { 11 | using GraphNode::GraphNode; 12 | 13 | void process (BlockContext const& ctx) override { 14 | auto** inputData = ctx.inputData; 15 | auto* outputData = ctx.outputData[0]; 16 | auto numChannels = ctx.numInputChannels; 17 | auto numSamples = ctx.numSamples; 18 | 19 | // If we don't have the inputs we need, bail here and zero the buffer 20 | if (numChannels < 1) 21 | return (void) std::fill_n(outputData, numSamples, FloatType(0)); 22 | 23 | double const T = 1.0 / GraphNode::getSampleRate(); 24 | 25 | for (size_t i = 0; i < numSamples; ++i) { 26 | auto fc = inputData[0][i]; 27 | 28 | // Cutoff prewarping 29 | double const twoPi = 2.0 * 3.141592653589793238; 30 | double const wd = twoPi * static_cast(fc); 31 | double const g = std::tan(wd * T / 2.0); 32 | 33 | outputData[i] = FloatType(g); 34 | } 35 | } 36 | }; 37 | 38 | template 39 | struct MultiMode1p : public GraphNode { 40 | using GraphNode::GraphNode; 41 | 42 | enum class Mode { 43 | Low = 0, 44 | High = 2, 45 | All = 4, 46 | }; 47 | 48 | int setProperty(std::string const& key, js::Value const& val) override 49 | { 50 | if (key == "mode") { 51 | if (!val.isString()) 52 | return ReturnCode::InvalidPropertyType(); 53 | 54 | auto const m = (js::String) val; 55 | 56 | if (m == "lowpass") { _mode.store(Mode::Low); } 57 | if (m == "highpass") { _mode.store(Mode::High); } 58 | if (m == "allpass") { _mode.store(Mode::All); } 59 | } 60 | 61 | return GraphNode::setProperty(key, val); 62 | } 63 | 64 | void process (BlockContext const& ctx) override { 65 | auto** inputData = ctx.inputData; 66 | auto* outputData = ctx.outputData[0]; 67 | auto numChannels = ctx.numInputChannels; 68 | auto numSamples = ctx.numSamples; 69 | 70 | // If we don't have the inputs we need, bail here and zero the buffer 71 | if (numChannels < 2) 72 | return (void) std::fill_n(outputData, numSamples, FloatType(0)); 73 | 74 | // Set up our output derivation 75 | auto const m = _mode.load(); 76 | 77 | // Run the filter 78 | for (size_t i = 0; i < numSamples; ++i) { 79 | auto const g = std::clamp(static_cast(inputData[0][i]), 0.0, 0.9999); 80 | auto xn = inputData[1][i]; 81 | 82 | // Resolve the instantaneous gain 83 | double const G = g / (1.0 + g); 84 | 85 | // Tick the filter 86 | double const v = (static_cast(xn) - z) * G; 87 | double const lp = v + z; 88 | 89 | z = lp + v; 90 | 91 | switch (m) { 92 | case Mode::Low: 93 | outputData[i] = FloatType(lp); 94 | break; 95 | case Mode::High: 96 | outputData[i] = xn - FloatType(lp); 97 | break; 98 | case Mode::All: 99 | outputData[i] = FloatType(lp + lp - xn); 100 | break; 101 | default: 102 | break; 103 | } 104 | } 105 | } 106 | 107 | // Props 108 | std::atomic _mode { Mode::Low }; 109 | static_assert(std::atomic::is_always_lock_free); 110 | 111 | // Coefficients 112 | double z = 0; 113 | }; 114 | 115 | } // namespace elem 116 | -------------------------------------------------------------------------------- /runtime/elem/builtins/helpers/GainFade.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "./FloatUtils.h" 4 | 5 | 6 | namespace elem 7 | { 8 | 9 | namespace detail { 10 | inline double millisecondsToStep(double sampleRate, double ms) { 11 | return ms > 1e-6 ? 1.0 / (sampleRate * ms / 1000.0) : 1.0; 12 | } 13 | } 14 | 15 | template 16 | struct GainFade 17 | { 18 | GainFade(double sampleRate, double fadeInTimeMs, double fadeOutTimeMs, FloatType current = 0.0, FloatType target = 0.0) 19 | : currentGain(current) 20 | , targetGain(target) 21 | { 22 | setFadeInTimeMs(sampleRate, fadeInTimeMs); 23 | setFadeOutTimeMs(sampleRate, fadeOutTimeMs); 24 | } 25 | 26 | GainFade(GainFade const& other) 27 | : currentGain(other.currentGain.load()) 28 | , targetGain(other.targetGain.load()) 29 | , step(other.step.load()) 30 | , inStep(other.inStep) 31 | , outStep(other.outStep) 32 | { 33 | } 34 | 35 | void operator= (GainFade const& other) 36 | { 37 | currentGain.store(other.currentGain.load()); 38 | targetGain.store(other.targetGain.load()); 39 | step.store(other.step.load()); 40 | inStep = other.inStep; 41 | outStep = other.outStep; 42 | } 43 | 44 | FloatType operator() (FloatType x) { 45 | auto const _currentGain = currentGain.load(); 46 | auto const _targetGain = targetGain.load(); 47 | if (fpEqual(_currentGain, _targetGain)) 48 | return (_targetGain * x); 49 | 50 | auto y = x * _currentGain; 51 | currentGain.store(std::clamp(_currentGain + step.load(), FloatType(0), FloatType(1))); 52 | 53 | return y; 54 | } 55 | 56 | void process (const FloatType* input, FloatType* output, int numSamples) { 57 | auto const _currentGain = currentGain.load(); 58 | auto const _targetGain = targetGain.load(); 59 | if (_currentGain == _targetGain) { 60 | for (int i = 0; i < numSamples; ++i) { 61 | output[i] = input[i] * _targetGain; 62 | } 63 | return; 64 | } 65 | 66 | auto const _step = step.load(); 67 | for (int i = 0; i < numSamples; ++i) { 68 | output[i] = input[i] * std::clamp(_currentGain + _step * i, FloatType(0), FloatType(1)); 69 | } 70 | 71 | currentGain.store(std::clamp(_currentGain + _step * numSamples, FloatType(0), FloatType(1))); 72 | } 73 | 74 | void setFadeInTimeMs(double sampleRate, double fadeInTimeMs) { 75 | inStep = detail::millisecondsToStep(sampleRate, fadeInTimeMs); 76 | updateCurrentStep(); 77 | } 78 | 79 | void setFadeOutTimeMs(double sampleRate, double fadeOutTimeMs) { 80 | outStep = FloatType(-1) * detail::millisecondsToStep(sampleRate, fadeOutTimeMs); 81 | updateCurrentStep(); 82 | } 83 | 84 | void fadeIn() { 85 | targetGain.store(FloatType(1)); 86 | updateCurrentStep(); 87 | } 88 | 89 | void fadeOut() { 90 | targetGain.store(FloatType(0)); 91 | updateCurrentStep(); 92 | } 93 | 94 | void setCurrentGain(FloatType gain) { 95 | currentGain.store(gain); 96 | } 97 | 98 | bool on() { 99 | return (targetGain.load() > FloatType(0.5)); 100 | } 101 | 102 | bool settled() { 103 | return fpEqual(targetGain.load(), currentGain.load()); 104 | } 105 | 106 | void reset() { 107 | currentGain.store(FloatType(0)); 108 | targetGain.store(FloatType(0)); 109 | } 110 | 111 | private: 112 | void updateCurrentStep() { 113 | step.store(currentGain.load() > targetGain.load() ? outStep : inStep); 114 | } 115 | 116 | std::atomic currentGain = 0; 117 | std::atomic targetGain = 0; 118 | std::atomic step = 0; 119 | FloatType inStep = 0; 120 | FloatType outStep = 0; 121 | }; 122 | 123 | } // namespace elem 124 | -------------------------------------------------------------------------------- /runtime/elem/SingleWriterSingleReaderQueue.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | #include "ElemAssert.h" 6 | 7 | 8 | namespace elem 9 | { 10 | 11 | //============================================================================== 12 | // This is a simple lock-free single producer, single consumer queue. 13 | template 14 | class SingleWriterSingleReaderQueue 15 | { 16 | public: 17 | //============================================================================== 18 | SingleWriterSingleReaderQueue(size_t capacity = 32) 19 | : maxElements(capacity), indexMask(capacity - 1) 20 | { 21 | // We need the queue length to be non-zero and a power of two so 22 | // that our bit masking trick will work. Enforce that here with 23 | // an assert. 24 | ELEM_ASSERT(capacity > 0 && ((capacity & indexMask) == 0)); 25 | queue.resize(capacity); 26 | } 27 | 28 | ~SingleWriterSingleReaderQueue() = default; 29 | 30 | //============================================================================== 31 | bool push (T && el) 32 | { 33 | auto const w = writePos.load(); 34 | auto const r = readPos.load(); 35 | 36 | if (numFreeSlots(r, w) > 0) 37 | { 38 | queue[w] = std::move(el); 39 | auto const desiredWritePosition = (w + 1u) & indexMask; 40 | writePos.store(desiredWritePosition); 41 | return true; 42 | } 43 | 44 | return false; 45 | } 46 | 47 | bool push (std::vector& els) 48 | { 49 | auto const w = writePos.load(); 50 | auto const r = readPos.load(); 51 | 52 | if (els.size() >= numFreeSlots(r, w)) 53 | return false; 54 | 55 | for (size_t i = 0; i < els.size(); ++i) 56 | queue[(w + i) & indexMask] = std::move(els[i]); 57 | 58 | auto const desiredWritePosition = (w + els.size()) & indexMask; 59 | writePos.store(desiredWritePosition); 60 | return true; 61 | } 62 | 63 | bool pop (T& el) 64 | { 65 | auto const r = readPos.load(); 66 | auto const w = writePos.load(); 67 | 68 | if (numFullSlots(r, w) > 0) 69 | { 70 | el = std::move(queue[r]); 71 | auto const desiredReadPosition = (r + 1u) & indexMask; 72 | readPos.store(desiredReadPosition); 73 | return true; 74 | } 75 | 76 | return false; 77 | } 78 | 79 | size_t size() 80 | { 81 | auto const r = readPos.load(); 82 | auto const w = writePos.load(); 83 | 84 | return numFullSlots(r, w); 85 | } 86 | 87 | private: 88 | size_t numFullSlots(size_t const r, size_t const w) 89 | { 90 | // If the writer is ahead of the reader, then the full slots are 91 | // the ones ahead of the reader and behind the writer. 92 | if (w > r) 93 | return w - r; 94 | 95 | // Else, the writer has already wrapped around, so the free space is 96 | // what's ahead of the writer and behind the reader, and the full space 97 | // is what's left. 98 | return (maxElements - (r - w)) & indexMask; 99 | } 100 | 101 | size_t numFreeSlots(size_t const r, size_t const w) 102 | { 103 | // If the reader is ahead of the writer, then the writer must have 104 | // wrapped around already, so the only space available is whats ahead 105 | // of the writer, behind the reader. 106 | if (r > w) 107 | return r - w; 108 | 109 | // Else, the only full slots are ahead of the reader and behind the 110 | // writer, so the free slots are what's left. 111 | return maxElements - (w - r); 112 | } 113 | 114 | std::atomic maxElements = 0; 115 | std::atomic readPos = 0; 116 | std::atomic writePos = 0; 117 | std::vector queue; 118 | 119 | size_t indexMask = 0; 120 | }; 121 | 122 | } // namespace elem 123 | 124 | -------------------------------------------------------------------------------- /runtime/elem/builtins/filters/SVF.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "../../GraphNode.h" 4 | 5 | 6 | namespace elem 7 | { 8 | 9 | // A linear State Variable Filter based on Andy Simper's work 10 | // 11 | // See: https://cytomic.com/files/dsp/SvfLinearTrapOptimised2.pdf 12 | // 13 | // This filter supports one "mode" property and expects three children: one 14 | // which defines the cutoff frequency, one which defines the filter Q, and finally 15 | // the input signal itself. Accepting these inputs as audio signals allows for 16 | // fast modulation at the processing expense of computing the coefficients on 17 | // every tick of the filter. 18 | template 19 | struct StateVariableFilterNode : public GraphNode { 20 | using GraphNode::GraphNode; 21 | 22 | enum class Mode { 23 | Low = 0, 24 | Band = 1, 25 | High = 2, 26 | Notch = 3, 27 | All = 4, 28 | }; 29 | 30 | int setProperty(std::string const& key, js::Value const& val) override 31 | { 32 | if (key == "mode") { 33 | if (!val.isString()) 34 | return ReturnCode::InvalidPropertyType(); 35 | 36 | auto const m = (js::String) val; 37 | 38 | if (m == "lowpass") { _mode.store(Mode::Low); } 39 | if (m == "bandpass") { _mode.store(Mode::Band); } 40 | if (m == "highpass") { _mode.store(Mode::High); } 41 | if (m == "notch") { _mode.store(Mode::Notch); } 42 | if (m == "allpass") { _mode.store(Mode::All); } 43 | } 44 | 45 | return GraphNode::setProperty(key, val); 46 | } 47 | 48 | inline FloatType tick (Mode m, FloatType v0) { 49 | double v3 = v0 - _ic2eq; 50 | double v1 = _ic1eq * _a1 + v3 * _a2; 51 | double v2 = _ic2eq + _ic1eq * _a2 + v3 * _a3; 52 | 53 | _ic1eq = v1 * 2.0 - _ic1eq; 54 | _ic2eq = v2 * 2.0 - _ic2eq; 55 | 56 | switch (m) { 57 | case Mode::Low: 58 | return FloatType(v2); 59 | case Mode::Band: 60 | return FloatType(v1); 61 | case Mode::High: 62 | return FloatType(v0 - _k * v1 - v2); 63 | case Mode::Notch: 64 | return FloatType(v0 - _k * v1); 65 | case Mode::All: 66 | return FloatType(v0 - 2.0 * _k * v1); 67 | default: 68 | return FloatType(0); 69 | } 70 | } 71 | 72 | inline void updateCoeffs (double fc, double q) { 73 | auto const sr = GraphNode::getSampleRate(); 74 | 75 | _g = std::tan(3.14159265359 * std::clamp(fc, 20.0, sr / 2.0001) / sr); 76 | _k = 1.0 / std::clamp(q, 0.25, 20.0); 77 | _a1 = 1.0 / (1.0 + _g * (_g + _k)); 78 | _a2 = _g * _a1; 79 | _a3 = _g * _a2; 80 | } 81 | 82 | void process (BlockContext const& ctx) override { 83 | auto** inputData = ctx.inputData; 84 | auto* outputData = ctx.outputData[0]; 85 | auto numChannels = ctx.numInputChannels; 86 | auto numSamples = ctx.numSamples; 87 | 88 | auto m = _mode.load(); 89 | 90 | // If we don't have the inputs we need, bail here and zero the buffer 91 | if (numChannels < 3) 92 | return (void) std::fill_n(outputData, numSamples, FloatType(0)); 93 | 94 | for (size_t i = 0; i < numSamples; ++i) { 95 | auto fc = inputData[0][i]; 96 | auto q = inputData[1][i]; 97 | auto xn = inputData[2][i]; 98 | 99 | // Update coeffs at audio rate 100 | updateCoeffs(fc, q); 101 | 102 | // Tick the filter 103 | outputData[i] = tick(m, xn); 104 | } 105 | } 106 | 107 | // Props 108 | std::atomic _mode { Mode::Low }; 109 | static_assert(std::atomic::is_always_lock_free); 110 | 111 | // Coefficients 112 | double _g = 0; 113 | double _k = 0; 114 | double _a1 = 0; 115 | double _a2 = 0; 116 | double _a3 = 0; 117 | 118 | // State 119 | double _ic1eq = 0; 120 | double _ic2eq = 0; 121 | }; 122 | 123 | } // namespace elem 124 | -------------------------------------------------------------------------------- /runtime/elem/builtins/filters/SVFShelf.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "../../GraphNode.h" 4 | 5 | 6 | namespace elem 7 | { 8 | 9 | // A linear State Variable Shelf Filter based on Andy Simper's work 10 | // 11 | // See: https://cytomic.com/files/dsp/SvfLinearTrapOptimised2.pdf 12 | // 13 | // This filter supports one "mode" property and expects four children: one 14 | // which defines the cutoff frequency, one which defines the filter Q, one which 15 | // defines the band gain (in decibels), and finally the input signal itself. 16 | // 17 | // Accepting these inputs as audio signals allows for fast modulation at the 18 | // processing expense of computing the coefficients on every tick of the filter. 19 | template 20 | struct StateVariableShelfFilterNode : public GraphNode { 21 | using GraphNode::GraphNode; 22 | 23 | enum class Mode { 24 | Lowshelf = 0, 25 | Highshelf = 1, 26 | Bell = 2, 27 | }; 28 | 29 | int setProperty(std::string const& key, js::Value const& val) override 30 | { 31 | if (key == "mode") { 32 | if (!val.isString()) 33 | return ReturnCode::InvalidPropertyType(); 34 | 35 | auto const m = (js::String) val; 36 | 37 | if (m == "lowshelf") { _mode.store(Mode::Lowshelf); } 38 | if (m == "highshelf") { _mode.store(Mode::Highshelf); } 39 | 40 | if (m == "bell" || m == "peak") { _mode.store(Mode::Bell); } 41 | } 42 | 43 | return GraphNode::setProperty(key, val); 44 | } 45 | 46 | inline FloatType tick (Mode m, FloatType v0) { 47 | double v3 = v0 - _ic2eq; 48 | double v1 = _ic1eq * _a1 + v3 * _a2; 49 | double v2 = _ic2eq + _ic1eq * _a2 + v3 * _a3; 50 | 51 | _ic1eq = v1 * 2.0 - _ic1eq; 52 | _ic2eq = v2 * 2.0 - _ic2eq; 53 | 54 | switch (m) { 55 | case Mode::Bell: 56 | return FloatType(v0 + _k * (_A * _A - 1.0) * v1); 57 | case Mode::Lowshelf: 58 | return FloatType(v0 + _k * (_A - 1.0) * v1 + (_A * _A - 1.0) * v2); 59 | case Mode::Highshelf: 60 | return FloatType(_A * _A * v0 + _k * (1.0 - _A) * _A * v1 + (1.0 - _A * _A) * v2); 61 | default: 62 | return FloatType(0); 63 | } 64 | } 65 | 66 | inline void updateCoeffs (Mode m, double fc, double q, double gainDecibels) { 67 | auto const sr = GraphNode::getSampleRate(); 68 | 69 | _A = std::pow(10, gainDecibels / 40.0); 70 | _g = std::tan(3.14159265359 * std::clamp(fc, 20.0, sr / 2.0001) / sr); 71 | _k = 1.0 / std::clamp(q, 0.25, 20.0); 72 | 73 | if (m == Mode::Lowshelf) 74 | _g /= _A; 75 | if (m == Mode::Highshelf) 76 | _g *= _A; 77 | if (m == Mode::Bell) 78 | _k /= _A; 79 | 80 | _a1 = 1.0 / (1.0 + _g * (_g + _k)); 81 | _a2 = _g * _a1; 82 | _a3 = _g * _a2; 83 | } 84 | 85 | void process (BlockContext const& ctx) override { 86 | auto** inputData = ctx.inputData; 87 | auto* outputData = ctx.outputData[0]; 88 | auto numChannels = ctx.numInputChannels; 89 | auto numSamples = ctx.numSamples; 90 | 91 | auto m = _mode.load(); 92 | 93 | // If we don't have the inputs we need, bail here and zero the buffer 94 | if (numChannels < 4) 95 | return (void) std::fill_n(outputData, numSamples, FloatType(0)); 96 | 97 | for (size_t i = 0; i < numSamples; ++i) { 98 | auto fc = inputData[0][i]; 99 | auto q = inputData[1][i]; 100 | auto gain = inputData[2][i]; 101 | auto xn = inputData[3][i]; 102 | 103 | // Update coeffs at audio rate 104 | updateCoeffs(m, fc, q, gain); 105 | 106 | // Tick the filter 107 | outputData[i] = tick(m, xn); 108 | } 109 | } 110 | 111 | // Props 112 | std::atomic _mode { Mode::Lowshelf }; 113 | static_assert(std::atomic::is_always_lock_free); 114 | 115 | // Coefficients 116 | double _A = 0; 117 | double _g = 0; 118 | double _k = 0; 119 | double _a1 = 0; 120 | double _a2 = 0; 121 | double _a3 = 0; 122 | 123 | // State 124 | double _ic1eq = 0; 125 | double _ic2eq = 0; 126 | }; 127 | 128 | } // namespace elem 129 | -------------------------------------------------------------------------------- /js/packages/core/lib/dynamics.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createNode, 3 | isNode, 4 | resolve, 5 | ElemNode, 6 | NodeRepr_t, 7 | } from "../nodeUtils"; 8 | 9 | import * as co from "./core"; 10 | import * as ma from "./math"; 11 | import * as si from "./signals"; 12 | 13 | const el = { 14 | ...co, 15 | ...ma, 16 | ...si, 17 | }; 18 | 19 | /* A simple hard knee compressor with parameterized attack and release times, 20 | * threshold, and compression ratio. 21 | * 22 | * Users may drive the compressor with an optional sidechain signal, or send the 23 | * same input both as the input to be compressed and as the sidechain signal itself 24 | * for standard compressor behavior. 25 | * 26 | * @param {ElemNode} atkMs – attack time in milliseconds 27 | * @param {ElemNode} relMs – release time in millseconds 28 | * @param {ElemNode} threshold – decibel value above which the comp kicks in 29 | * @param {ElemNode} ratio – ratio by which we squash signal above the threshold 30 | * @param {ElemNode} sidechain – sidechain signal to drive the compressor 31 | * @param {ElemNode} xn – input signal to filter 32 | */ 33 | export function compress( 34 | attackMs: ElemNode, 35 | releaseMs: ElemNode, 36 | threshold: ElemNode, 37 | ratio: ElemNode, 38 | sidechain: ElemNode, 39 | xn: ElemNode, 40 | ): NodeRepr_t { 41 | const env = el.env( 42 | el.tau2pole(el.mul(0.001, attackMs)), 43 | el.tau2pole(el.mul(0.001, releaseMs)), 44 | sidechain, 45 | ); 46 | 47 | const envDecibels = el.gain2db(env); 48 | 49 | // Calculate gain multiplier from the ratio (1 - 1/ratio) 50 | const adjustedRatio = el.sub(1, el.div(1, ratio)); 51 | 52 | // Calculate gain reduction in dB 53 | const gain = el.mul(adjustedRatio, el.sub(threshold, envDecibels)); // adjustedRatio * (threshold - envDecibels); 54 | 55 | // Ensuring gain is not positive 56 | const cleanGain = el.min(0, gain); 57 | 58 | // Convert the gain reduction in dB to a gain factor 59 | const compressedGain = el.db2gain(cleanGain); 60 | 61 | return el.mul(xn, compressedGain); 62 | } 63 | 64 | /* A simple softknee compressor with parameterized attack and release times, 65 | * threshold, compression ratio and knee width. 66 | * 67 | * Functions like regular el.compress when kneeWidth is 0. 68 | * 69 | * Users may drive the compressor with an optional sidechain signal, or send the 70 | * same input both as the input to be compressed and as the sidechain signal itself 71 | * for standard compressor behavior. 72 | * 73 | * @param {ElemNode} atkMs – attack time in milliseconds 74 | * @param {ElemNode} relMs – release time in millseconds 75 | * @param {ElemNode} threshold – decibel value above which the comp kicks in 76 | * @param {ElemNode} ratio – ratio by which we squash signal above the threshold 77 | * @param {ElemNode} kneeWidth – width of the knee in decibels, 0 for hard knee 78 | * @param {ElemNode} sidechain – sidechain signal to drive the compressor 79 | * @param {ElemNode} xn – input signal to filter 80 | */ 81 | export function skcompress( 82 | attackMs: ElemNode, 83 | releaseMs: ElemNode, 84 | threshold: ElemNode, 85 | ratio: ElemNode, 86 | kneeWidth: ElemNode, 87 | sidechain: ElemNode, 88 | xn: ElemNode, 89 | ): NodeRepr_t { 90 | const env = el.env( 91 | el.tau2pole(el.mul(0.001, attackMs)), 92 | el.tau2pole(el.mul(0.001, releaseMs)), 93 | sidechain, 94 | ); 95 | 96 | const envDecibels = el.gain2db(env); 97 | 98 | // Calculate the soft knee bounds around the threshold 99 | const lowerKneeBound = el.sub(threshold, el.div(kneeWidth, 2)); // threshold - kneeWidth/2 100 | const upperKneeBound = el.add(threshold, el.div(kneeWidth, 2)); // threshold + kneeWidth/2 101 | 102 | // Check if the envelope is in the soft knee range 103 | const isInSoftKneeRange = el.and( 104 | el.geq(envDecibels, lowerKneeBound), // envDecibels >= lowerKneeBound 105 | el.leq(envDecibels, upperKneeBound), // envDecibels <= upperKneeBound 106 | ); 107 | 108 | // Calculate gain multiplier from the ratio (1 - 1/ratio) 109 | const adjustedRatio = el.sub(1, el.div(1, ratio)); 110 | 111 | // Gain calculation 112 | // When in soft knee range, do: 113 | // 0.5 * adjustedRatio * ((envDecibels - lowerKneeBound) / kneeWidth) * (lowerKneeBound - envDecibels) 114 | // Else do: 115 | // adjustedRatio * (threshold - envDecibels) 116 | // 117 | const gain = el.select( 118 | isInSoftKneeRange, 119 | el.mul( 120 | el.div(adjustedRatio, 2), 121 | el.mul( 122 | el.div(el.sub(envDecibels, lowerKneeBound), kneeWidth), 123 | el.sub(lowerKneeBound, envDecibels), 124 | ), 125 | ), 126 | el.mul(adjustedRatio, el.sub(threshold, envDecibels)), 127 | ); 128 | 129 | // Ensuring gain is not positive 130 | const cleanGain = el.min(0, gain); 131 | 132 | // Convert the gain reduction in dB to a gain factor 133 | const compressedGain = el.db2gain(cleanGain); 134 | 135 | return el.mul(xn, compressedGain); 136 | } 137 | -------------------------------------------------------------------------------- /js/packages/offline-renderer/__tests__/__snapshots__/mc.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`mc capture 1`] = ` 4 | [ 5 | Float32Array [ 6 | 1, 7 | 1, 8 | 1, 9 | 1, 10 | 1, 11 | 1, 12 | 1, 13 | 1, 14 | 1, 15 | 1, 16 | 1, 17 | 1, 18 | 1, 19 | 1, 20 | 1, 21 | 1, 22 | 1, 23 | 1, 24 | 1, 25 | 1, 26 | 1, 27 | 1, 28 | 1, 29 | 1, 30 | 1, 31 | 1, 32 | 1, 33 | 1, 34 | 1, 35 | 1, 36 | 1, 37 | 1, 38 | ], 39 | Float32Array [ 40 | 2, 41 | 2, 42 | 2, 43 | 2, 44 | 2, 45 | 2, 46 | 2, 47 | 2, 48 | 2, 49 | 2, 50 | 2, 51 | 2, 52 | 2, 53 | 2, 54 | 2, 55 | 2, 56 | 2, 57 | 2, 58 | 2, 59 | 2, 60 | 2, 61 | 2, 62 | 2, 63 | 2, 64 | 2, 65 | 2, 66 | 2, 67 | 2, 68 | 2, 69 | 2, 70 | 2, 71 | 2, 72 | ], 73 | Float32Array [ 74 | 3, 75 | 3, 76 | 3, 77 | 3, 78 | 3, 79 | 3, 80 | 3, 81 | 3, 82 | 3, 83 | 3, 84 | 3, 85 | 3, 86 | 3, 87 | 3, 88 | 3, 89 | 3, 90 | 3, 91 | 3, 92 | 3, 93 | 3, 94 | 3, 95 | 3, 96 | 3, 97 | 3, 98 | 3, 99 | 3, 100 | 3, 101 | 3, 102 | 3, 103 | 3, 104 | 3, 105 | 3, 106 | ], 107 | Float32Array [ 108 | 4, 109 | 4, 110 | 4, 111 | 4, 112 | 4, 113 | 4, 114 | 4, 115 | 4, 116 | 4, 117 | 4, 118 | 4, 119 | 4, 120 | 4, 121 | 4, 122 | 4, 123 | 4, 124 | 4, 125 | 4, 126 | 4, 127 | 4, 128 | 4, 129 | 4, 130 | 4, 131 | 4, 132 | 4, 133 | 4, 134 | 4, 135 | 4, 136 | 4, 137 | 4, 138 | 4, 139 | 4, 140 | ], 141 | ] 142 | `; 143 | 144 | exports[`mc sampleseq 1`] = ` 145 | Float32Array [ 146 | 0, 147 | 0, 148 | 0, 149 | 0, 150 | 0, 151 | 0, 152 | 0, 153 | 0, 154 | 0, 155 | 0, 156 | 0, 157 | 0, 158 | 0, 159 | 0, 160 | 0, 161 | 0, 162 | 0, 163 | 0, 164 | 0, 165 | 0, 166 | 0, 167 | 0, 168 | 0, 169 | 0, 170 | 0, 171 | 0, 172 | 0, 173 | 0, 174 | 0, 175 | 0, 176 | 0, 177 | 0, 178 | ] 179 | `; 180 | 181 | exports[`mc sampleseq 2`] = ` 182 | Float32Array [ 183 | 42, 184 | 42, 185 | 42, 186 | 42, 187 | 42, 188 | 42, 189 | 42, 190 | 42, 191 | 42, 192 | 42, 193 | 42, 194 | 42, 195 | 42, 196 | 42, 197 | 42, 198 | 42, 199 | 42, 200 | 42, 201 | 42, 202 | 42, 203 | 42, 204 | 42, 205 | 42, 206 | 42, 207 | 42, 208 | 42, 209 | 42, 210 | 42, 211 | 42, 212 | 42, 213 | 42, 214 | 42, 215 | ] 216 | `; 217 | 218 | exports[`mc table 1`] = ` 219 | Float32Array [ 220 | 42, 221 | 42, 222 | 42, 223 | 42, 224 | 42, 225 | 42, 226 | 42, 227 | 42, 228 | 42, 229 | 42, 230 | 42, 231 | 42, 232 | 42, 233 | 42, 234 | 42, 235 | 42, 236 | ] 237 | `; 238 | 239 | exports[`mc table 2`] = ` 240 | Float32Array [ 241 | 42, 242 | 42, 243 | 42, 244 | 42, 245 | 42, 246 | 42, 247 | 42, 248 | 42, 249 | 42, 250 | 42, 251 | 42, 252 | 42, 253 | 42, 254 | 42, 255 | 42, 256 | 42, 257 | ] 258 | `; 259 | 260 | exports[`mc table 3`] = ` 261 | Float32Array [ 262 | 27, 263 | 27, 264 | 27, 265 | 27, 266 | 27, 267 | 27, 268 | 27, 269 | 27, 270 | 27, 271 | 27, 272 | 27, 273 | 27, 274 | 27, 275 | 27, 276 | 27, 277 | 27, 278 | ] 279 | `; 280 | 281 | exports[`mc table 4`] = ` 282 | Float32Array [ 283 | 42, 284 | 42, 285 | 42, 286 | 42, 287 | 42, 288 | 42, 289 | 42, 290 | 42, 291 | 42, 292 | 42, 293 | 42, 294 | 42, 295 | 42, 296 | 42, 297 | 42, 298 | 42, 299 | ] 300 | `; 301 | 302 | exports[`mc.sample again 1`] = ` 303 | Float32Array [ 304 | 2, 305 | 4, 306 | 6, 307 | 8, 308 | 10, 309 | 12, 310 | 14, 311 | 16, 312 | 2, 313 | 4, 314 | 6, 315 | 8, 316 | 10, 317 | 12, 318 | 14, 319 | 16, 320 | 2, 321 | 4, 322 | 6, 323 | 8, 324 | 10, 325 | 12, 326 | 14, 327 | 16, 328 | 2, 329 | 4, 330 | 6, 331 | 8, 332 | 10, 333 | 12, 334 | 14, 335 | 16, 336 | ] 337 | `; 338 | 339 | exports[`mc.sample again 2`] = ` 340 | Float32Array [ 341 | 2, 342 | 6, 343 | 10, 344 | 14, 345 | 2, 346 | 6, 347 | 10, 348 | 14, 349 | 2, 350 | 6, 351 | 10, 352 | 14, 353 | 2, 354 | 6, 355 | 10, 356 | 14, 357 | 2, 358 | 6, 359 | 10, 360 | 14, 361 | 2, 362 | 6, 363 | 10, 364 | 14, 365 | 2, 366 | 6, 367 | 10, 368 | 14, 369 | 2, 370 | 6, 371 | 10, 372 | 14, 373 | ] 374 | `; 375 | 376 | exports[`mc.sample again 3`] = ` 377 | Float32Array [ 378 | 4, 379 | 8, 380 | 12, 381 | 4, 382 | 8, 383 | 12, 384 | 4, 385 | 8, 386 | 12, 387 | 4, 388 | 8, 389 | 12, 390 | 4, 391 | 8, 392 | 12, 393 | 4, 394 | 8, 395 | 12, 396 | 4, 397 | 8, 398 | 12, 399 | 4, 400 | 8, 401 | 12, 402 | 4, 403 | 8, 404 | 12, 405 | 4, 406 | 8, 407 | 12, 408 | 4, 409 | 8, 410 | ] 411 | `; 412 | -------------------------------------------------------------------------------- /js/packages/core/lib/oscillators.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createNode, 3 | isNode, 4 | resolve, 5 | ElemNode, 6 | NodeRepr_t, 7 | } from "../nodeUtils"; 8 | 9 | import * as co from "./core"; 10 | import * as ma from "./math"; 11 | import * as fi from "./filters"; 12 | 13 | const el = { 14 | ...co, 15 | ...ma, 16 | ...fi, 17 | }; 18 | 19 | /** 20 | * Outputs a pulse train alternating between 0 and 1 at the given rate. 21 | * 22 | * Expects exactly one child, providing the train rate in Hz. 23 | * 24 | * @param {ElemNode} rate - Frequency 25 | * @returns {NodeRepr_t} 26 | */ 27 | export function train(rate: ElemNode): NodeRepr_t { 28 | return el.le(el.phasor(rate), 0.5); 29 | } 30 | 31 | /** 32 | * Outputs a periodic sine tone at the given frequency. 33 | * 34 | * Expects exactly one child specifying the cycle frequency in Hz. 35 | * 36 | * @param {ElemNode} rate - Cycle frequency 37 | * @returns {NodeRepr_t} 38 | */ 39 | export function cycle(rate: ElemNode): NodeRepr_t { 40 | return el.sin(el.mul(2.0 * Math.PI, el.phasor(rate))); 41 | } 42 | 43 | /** 44 | * Outputs a naive sawtooth oscillator at the given frequency. 45 | * 46 | * Expects exactly one child specifying the saw frequency in Hz. 47 | * 48 | * Typically, due to the aliasing of the naive sawtooth at audio rates, this oscillator 49 | * is used for low frequency modulation. 50 | * 51 | * @param {ElemNode} rate - Saw frequency 52 | * @returns {NodeRepr_t} 53 | */ 54 | export function saw(rate: ElemNode): NodeRepr_t { 55 | return el.sub(el.mul(2, el.phasor(rate)), 1); 56 | } 57 | 58 | /** 59 | * Outputs a naive square oscillator at the given frequency. 60 | * 61 | * Expects exactly one child specifying the square frequency in Hz. 62 | * 63 | * Typically, due to the aliasing of the naive square at audio rates, this oscillator 64 | * is used for low frequency modulation. 65 | * 66 | * @param {ElemNode} rate - Square frequency 67 | * @returns {NodeRepr_t} 68 | */ 69 | export function square(rate: ElemNode): NodeRepr_t { 70 | return el.sub(el.mul(2, train(rate)), 1); 71 | } 72 | 73 | /** 74 | * Outputs a naive triangle oscillator at the given frequency. 75 | * 76 | * Expects exactly one child specifying the triangle frequency in Hz. 77 | * 78 | * Typically, due to the aliasing of the naive triangle at audio rates, this oscillator 79 | * is used for low frequency modulation. 80 | * 81 | * @param {ElemNode} rate - Triangle frequency 82 | * @returns {NodeRepr_t} 83 | */ 84 | export function triangle(rate: ElemNode): NodeRepr_t { 85 | return el.mul(2, el.sub(0.5, el.abs(saw(rate)))); 86 | } 87 | 88 | /** 89 | * Outputs a band-limited polyblep sawtooth waveform at the given frequency. 90 | * 91 | * Expects exactly one child specifying the saw frequency in Hz. 92 | * 93 | * @param {ElemNode} rate - Saw frequency 94 | * @returns {NodeRepr_t} 95 | */ 96 | export function blepsaw(rate: ElemNode): NodeRepr_t { 97 | return createNode("blepsaw", {}, [resolve(rate)]); 98 | } 99 | 100 | /** 101 | * Outputs a band-limited polyblep square waveform at the given frequency. 102 | * 103 | * Expects exactly one child specifying the square frequency in Hz. 104 | * 105 | * @param {ElemNode} rate - Square frequency 106 | * @returns {NodeRepr_t} 107 | */ 108 | export function blepsquare(rate: ElemNode): NodeRepr_t { 109 | return createNode("blepsquare", {}, [resolve(rate)]); 110 | } 111 | 112 | /** 113 | * Outputs a band-limited polyblep triangle waveform at the given frequency. 114 | * 115 | * Due to the integrator in the signal path, the polyblep triangle oscillator 116 | * may perform poorly (in terms of anti-aliasing) when the oscillator frequency 117 | * changes over time. 118 | * 119 | * Further, integrating a square waveform as we do here will produce a triangle 120 | * waveform with a DC offset. Therefore we use a leaky integrator (coefficient at 0.999) 121 | * which filters out the DC component over time. Note that before the DC component 122 | * is filtered out, the triangle waveform may exceed +/- 1.0, so take appropriate 123 | * care to apply gains where necessary. 124 | * 125 | * Expects exactly one child specifying the triangle frequency in Hz. 126 | * 127 | * @param {ElemNode} rate - Triangle frequency 128 | * @returns {NodeRepr_t} 129 | */ 130 | export function bleptriangle(rate: ElemNode): NodeRepr_t { 131 | return createNode("bleptriangle", {}, [resolve(rate)]); 132 | } 133 | 134 | /** 135 | * A simple white noise generator. 136 | * 137 | * Generates values uniformly distributed on the range [-1, 1] 138 | * 139 | * The seed property may be used to seed the underying random number generator. 140 | * 141 | * @param {Object} props 142 | * @returns {NodeRepr_t} 143 | */ 144 | export function noise(props?: { key?: string; seed?: number }): NodeRepr_t { 145 | return el.sub(el.mul(2, el.rand(props)), 1); 146 | } 147 | 148 | /** 149 | * A simple pink noise generator. 150 | * 151 | * Generates noise with a -3dB/octave rolloff in the frequency response. 152 | * 153 | * The seed property may be used to seed the underying random number generator. 154 | * 155 | * @param {Object} [props] 156 | * @returns {core.Node} 157 | */ 158 | export function pinknoise(props?: { key?: string; seed?: number }): NodeRepr_t { 159 | return el.pink(noise(props)); 160 | } 161 | -------------------------------------------------------------------------------- /cli/Realtime.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #include 5 | #include 6 | #include 7 | 8 | #include "Realtime.h" 9 | 10 | #define MINIAUDIO_IMPLEMENTATION 11 | #include "miniaudio.h" 12 | 13 | 14 | const auto* kConsoleShimScript = R"script( 15 | (function() { 16 | if (typeof globalThis.console === 'undefined') { 17 | globalThis.console = { 18 | log(...args) { 19 | return __log__('[log]', ...args); 20 | }, 21 | warn(...args) { 22 | return __log__('[warn]', ...args); 23 | }, 24 | error(...args) { 25 | return __log__('[error]', ...args); 26 | }, 27 | }; 28 | } 29 | })(); 30 | )script"; 31 | 32 | // A simple struct to proxy between the audio device and the Elementary engine 33 | struct DeviceProxy { 34 | DeviceProxy(double sampleRate, size_t blockSize) 35 | : scratchData(2 * blockSize), runtime(sampleRate, blockSize) 36 | {} 37 | 38 | void process(float* outputData, size_t numChannels, size_t numFrames) 39 | { 40 | // We might hit this the first time around, but after that should be fine 41 | if (scratchData.size() < (numChannels * numFrames)) 42 | scratchData.resize(numChannels * numFrames); 43 | 44 | auto* deinterleaved = scratchData.data(); 45 | std::array ptrs {deinterleaved, deinterleaved + numFrames}; 46 | 47 | // Elementary is happy to accept audio buffer data as an input to be 48 | // processed dynamically, such as applying effects, but here for simplicity 49 | // we're just going to produce output 50 | runtime.process( 51 | nullptr, 52 | 0, 53 | ptrs.data(), 54 | numChannels, 55 | numFrames, 56 | nullptr 57 | ); 58 | 59 | for (size_t i = 0; i < numChannels; ++i) 60 | { 61 | for (size_t j = 0; j < numFrames; ++j) 62 | { 63 | outputData[i + numChannels * j] = deinterleaved[i * numFrames + j]; 64 | } 65 | } 66 | } 67 | 68 | std::vector scratchData; 69 | elem::Runtime runtime; 70 | }; 71 | 72 | // Our main audio processing callback from the miniaudio device 73 | void audioCallback(ma_device* pDevice, void* pOutput, const void* /* pInput */, ma_uint32 frameCount) 74 | { 75 | auto* proxy = static_cast(pDevice->pUserData); 76 | 77 | auto numChannels = static_cast(pDevice->playback.channels); 78 | auto numFrames = static_cast(frameCount); 79 | 80 | proxy->process(static_cast(pOutput), numChannels, numFrames); 81 | } 82 | 83 | int RealtimeMain(int argc, char** argv, std::function&)> initCallback) { 84 | // First, initialize our audio device 85 | ma_result result; 86 | 87 | ma_device_config deviceConfig; 88 | ma_device device; 89 | 90 | // XXX: I don't see a way to ask miniaudio for a specific block size. Let's just allocate 91 | // here for 1024 and resize in the first callback if we need to. 92 | std::unique_ptr proxy = std::make_unique(44100.0, 1024); 93 | 94 | deviceConfig = ma_device_config_init(ma_device_type_playback); 95 | 96 | deviceConfig.playback.pDeviceID = nullptr; 97 | deviceConfig.playback.format = ma_format_f32; 98 | deviceConfig.playback.channels = 2; 99 | deviceConfig.sampleRate = 44100; 100 | deviceConfig.dataCallback = audioCallback; 101 | deviceConfig.pUserData = proxy.get(); 102 | 103 | result = ma_device_init(nullptr, &deviceConfig, &device); 104 | 105 | if (result != MA_SUCCESS) { 106 | std::cout << "Failed to start the audio device! Exiting..." << std::endl; 107 | return 1; 108 | } 109 | 110 | // Next, we'll initialize our JavaScript engine and establish a messaging channel by 111 | // defining a global callback function 112 | auto ctx = choc::javascript::createQuickJSContext(); 113 | 114 | ctx.registerFunction("__postNativeMessage__", [&](choc::javascript::ArgumentList args) { 115 | proxy->runtime.applyInstructions(elem::js::parseJSON(args[0]->toString())); 116 | return choc::value::Value(); 117 | }); 118 | 119 | ctx.registerFunction("__log__", [](choc::javascript::ArgumentList args) { 120 | for (size_t i = 0; i < args.numArgs; ++i) { 121 | std::cout << choc::json::toString(*args[i], true) << std::endl; 122 | } 123 | 124 | return choc::value::Value(); 125 | }); 126 | 127 | initCallback(proxy->runtime); 128 | 129 | // Shim the js environment for console logging 130 | (void) ctx.evaluate(kConsoleShimScript); 131 | 132 | // Then we'll try to read the user's JavaScript file from disk 133 | if (argc < 2) { 134 | std::cout << "Missing argument: what file do you want to run?" << std::endl; 135 | return 1; 136 | } 137 | 138 | auto contents = choc::file::loadFileAsString(argv[1]); 139 | auto rv = ctx.evaluate(contents); 140 | 141 | // Finally, run the audio device 142 | ma_device_start(&device); 143 | 144 | std::cout << "Press Enter to exit..." << std::endl; 145 | getchar(); 146 | 147 | ma_device_uninit(&device); 148 | return 0; 149 | } 150 | -------------------------------------------------------------------------------- /runtime/elem/MultiChannelRingBuffer.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "ElemAssert.h" 4 | 5 | 6 | namespace elem 7 | { 8 | 9 | //============================================================================== 10 | // This is a simple lock-free single producer, single consumer queue for 11 | // buffered multichannel sample data. 12 | template 13 | class MultiChannelRingBuffer 14 | { 15 | public: 16 | //============================================================================== 17 | MultiChannelRingBuffer(size_t numChannels, size_t capacity = 8192) 18 | : maxElements(capacity), indexMask(capacity - 1) 19 | { 20 | // We need the queue length to be non-zero and a power of two so 21 | // that our bit masking trick will work. Enforce that here with 22 | // an assert. 23 | ELEM_ASSERT(capacity > 0 && ((capacity & indexMask) == 0)); 24 | ELEM_ASSERT(numChannels > 0); 25 | 26 | for (size_t i = 0; i < numChannels; ++i) { 27 | buffers.push_back(std::vector(capacity)); 28 | } 29 | } 30 | 31 | ~MultiChannelRingBuffer() = default; 32 | 33 | //============================================================================== 34 | void write (T const** data, size_t numChannels, size_t numSamples, size_t readOffset = 0) 35 | { 36 | auto const w = writePos.load(); 37 | auto const r = readPos.load(); 38 | 39 | // If we're asked to write more than we have room for, we clobber and 40 | // nudge the read pointer up 41 | bool const shouldMoveReadPointer = (numSamples >= numFreeSlots(r, w)); 42 | auto const desiredWritePosition = (w + numSamples) & indexMask; 43 | auto const desiredReadPosition = shouldMoveReadPointer ? ((desiredWritePosition + 1) & indexMask) : r; 44 | 45 | for (size_t i = 0; i < std::min(buffers.size(), numChannels); ++i) { 46 | if (w + numSamples >= maxElements) { 47 | auto const s1 = maxElements - w; 48 | std::copy_n(data[i] + readOffset, s1, buffers[i].data() + w); 49 | std::copy_n(data[i] + readOffset + s1, numSamples - s1, buffers[i].data()); 50 | } else { 51 | std::copy_n(data[i] + readOffset, numSamples, buffers[i].data() + w); 52 | } 53 | } 54 | 55 | writePos.store(desiredWritePosition); 56 | readPos.store(desiredReadPosition); 57 | } 58 | 59 | bool read (T** destination, size_t numChannels, size_t numSamples) 60 | { 61 | auto const r = readPos.load(); 62 | auto const w = writePos.load(); 63 | 64 | if (numFullSlots(r, w) >= numSamples) 65 | { 66 | for (size_t i = 0; i < std::min(buffers.size(), numChannels); ++i) { 67 | if (r + numSamples >= maxElements) { 68 | auto const s1 = maxElements - r; 69 | std::copy_n(buffers[i].data() + r, s1, destination[i]); 70 | std::copy_n(buffers[i].data(), numSamples - s1, destination[i] + s1); 71 | } else { 72 | std::copy_n(buffers[i].data() + r, numSamples, destination[i]); 73 | } 74 | } 75 | 76 | auto const desiredReadPosition = (r + numSamples) & indexMask; 77 | readPos.store(desiredReadPosition); 78 | 79 | return true; 80 | } 81 | 82 | return false; 83 | } 84 | 85 | size_t size() 86 | { 87 | auto const r = readPos.load(); 88 | auto const w = writePos.load(); 89 | 90 | return numFullSlots(r, w); 91 | } 92 | 93 | size_t getNumChannels() 94 | { 95 | return buffers.size(); 96 | } 97 | 98 | private: 99 | size_t numFullSlots(size_t const r, size_t const w) 100 | { 101 | // If the writer is ahead of the reader, then the full slots are 102 | // the ones ahead of the reader and behind the writer. 103 | if (w > r) 104 | return w - r; 105 | 106 | // Else, the writer has already wrapped around, so the free space is 107 | // what's ahead of the writer and behind the reader, and the full space 108 | // is what's left. 109 | return (maxElements - (r - w)) & indexMask; 110 | } 111 | 112 | size_t numFreeSlots(size_t const r, size_t const w) 113 | { 114 | // If the reader is ahead of the writer, then the writer must have 115 | // wrapped around already, so the only space available is whats ahead 116 | // of the writer, behind the reader. 117 | if (r > w) 118 | return r - w; 119 | 120 | // Else, the only full slots are ahead of the reader and behind the 121 | // writer, so the free slots are what's left. 122 | return maxElements - (w - r); 123 | } 124 | 125 | std::atomic maxElements = 0; 126 | std::atomic readPos = 0; 127 | std::atomic writePos = 0; 128 | std::vector> buffers; 129 | 130 | size_t indexMask = 0; 131 | }; 132 | 133 | } // namespace elem 134 | -------------------------------------------------------------------------------- /wasm/FFT.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | #include 6 | #include 7 | 8 | 9 | namespace elem 10 | { 11 | 12 | // An FFT node which reports Float32Array buffers through 13 | // the event processing interface. 14 | // 15 | // Expects exactly one child. 16 | template 17 | struct FFTNode : public GraphNode { 18 | FFTNode(NodeId id, FloatType const sr, int const blockSize) 19 | : GraphNode::GraphNode(id, sr, blockSize) 20 | , ringBuffer(1) 21 | { 22 | // Default size enforced here, this makes sure we have a chance to initialize the fft 23 | // member object and scratch buffers 24 | setProperty("size", (js::Number) 1024); 25 | } 26 | 27 | bool isPowerOfTwo (int x) { 28 | return (x > 0) && ((x & (x - 1)) == 0); 29 | } 30 | 31 | int setProperty(std::string const& key, js::Value const& val) override 32 | { 33 | if (key == "size") { 34 | if (!val.isNumber()) 35 | return elem::ReturnCode::InvalidPropertyType(); 36 | 37 | int const size = static_cast((js::Number) val); 38 | 39 | if (!isPowerOfTwo(size) || size < 256 || size > 8192) 40 | return elem::ReturnCode::InvalidPropertyValue(); 41 | 42 | fft.init(size); 43 | scratchData.resize(size); 44 | window.resize(size); 45 | 46 | // Hann window 47 | // for (size_t i = 0; i < size; ++i) { 48 | // window[i] = FloatType(0.5 * (1.0 - std::cos(2.0 * M_PI * (i / (double) size)))); 49 | // } 50 | 51 | // Blackman harris 52 | for (size_t i = 0; i < size; ++i) { 53 | FloatType const a0 = 0.35875; 54 | FloatType const a1 = 0.48829; 55 | FloatType const a2 = 0.14128; 56 | FloatType const a3 = 0.01168; 57 | 58 | FloatType const pi = 3.1415926535897932385; 59 | 60 | FloatType const t1 = a1 * std::cos(2.0 * pi * (i / (double) (size - 1))); 61 | FloatType const t2 = a2 * std::cos(4.0 * pi * (i / (double) (size - 1))); 62 | FloatType const t3 = a3 * std::cos(6.0 * pi * (i / (double) (size - 1))); 63 | 64 | window[i] = a0 - t1 + t2 - t3; 65 | } 66 | } 67 | 68 | if (key == "name" && !val.isString()) 69 | return elem::ReturnCode::InvalidPropertyType(); 70 | 71 | return GraphNode::setProperty(key, val); 72 | } 73 | 74 | void process (BlockContext const& ctx) override { 75 | auto** inputData = ctx.inputData; 76 | auto* outputData = ctx.outputData[0]; 77 | auto numChannels = ctx.numInputChannels; 78 | auto numSamples = ctx.numSamples; 79 | 80 | // If we don't have the inputs we need, we bail here and zero the buffer 81 | // hoping to prevent unexpected signals. 82 | if (numChannels < 1) 83 | return (void) std::fill_n(outputData, numSamples, FloatType(0)); 84 | 85 | // Copy input to output 86 | std::copy_n(inputData[0], numSamples, outputData); 87 | 88 | // Fill our ring buffer 89 | ringBuffer.write(inputData, 1, numSamples); 90 | } 91 | 92 | void processEvents(std::function& eventHandler) override { 93 | auto const size = static_cast(GraphNode::getPropertyWithDefault("size", js::Number(1024))); 94 | 95 | // If we enough samples, read from the ring buffer into the scratch, then process 96 | if (ringBuffer.size() >= size) 97 | { 98 | std::array data {scratchData.data()}; 99 | ringBuffer.read(data.data(), 1, size); 100 | 101 | // FFT it 102 | js::Float32Array re(audiofft::AudioFFT::ComplexSize(size)); 103 | js::Float32Array im(audiofft::AudioFFT::ComplexSize(size)); 104 | 105 | if constexpr (std::is_same_v) { 106 | // Window the data first... 107 | for (size_t i = 0; i < size; ++i) { 108 | scratchData[i] *= window[i]; 109 | } 110 | 111 | fft.fft(scratchData.data(), re.data(), im.data()); 112 | } 113 | 114 | if constexpr (std::is_same_v) { 115 | std::vector tmp(size); 116 | 117 | // Window the data first... 118 | for (size_t i = 0; i < size; ++i) { 119 | tmp[i] = static_cast(scratchData[i] * window[i]); 120 | } 121 | 122 | fft.fft(tmp.data(), re.data(), im.data()); 123 | } 124 | 125 | eventHandler("fft", js::Object({ 126 | {"source", GraphNode::getPropertyWithDefault("name", js::Value())}, 127 | {"data", js::Object({ 128 | {"real", std::move(re)}, 129 | {"imag", std::move(im)}, 130 | })} 131 | })); 132 | } 133 | } 134 | 135 | audiofft::AudioFFT fft; 136 | std::vector window; 137 | std::vector scratchData; 138 | MultiChannelRingBuffer ringBuffer; 139 | }; 140 | 141 | } // namespace elem 142 | -------------------------------------------------------------------------------- /runtime/elem/builtins/SparSeq2.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "../GraphNode.h" 4 | #include "../SingleWriterSingleReaderQueue.h" 5 | 6 | #include "helpers/Change.h" 7 | #include "helpers/RefCountedPool.h" 8 | 9 | #include 10 | #include 11 | 12 | 13 | namespace elem 14 | { 15 | 16 | template 17 | struct SparSeq2Node : public GraphNode { 18 | using GraphNode::GraphNode; 19 | 20 | int setProperty(std::string const& key, js::Value const& val) override 21 | { 22 | if (key == "seq") { 23 | if (!val.isArray()) 24 | return ReturnCode::InvalidPropertyType(); 25 | 26 | 27 | auto& seq = val.getArray(); 28 | auto data = seqPool.allocate(); 29 | 30 | // The data array that we get from the pool may have been 31 | // previously used to represent a different sequence 32 | data->clear(); 33 | 34 | // We expect from the JavaScript side an array of event objects, where each 35 | // event includes a value to take and a time at which to take that value 36 | for (size_t i = 0; i < seq.size(); ++i) { 37 | auto& event = seq[i].getObject(); 38 | 39 | FloatType value = static_cast((js::Number) event.at("value")); 40 | double time = static_cast((js::Number) event.at("time")); 41 | 42 | data->insert({ time, value }); 43 | } 44 | 45 | seqQueue.push(std::move(data)); 46 | } 47 | 48 | if (key == "interpolate") { 49 | if (!val.isNumber()) 50 | return ReturnCode::InvalidPropertyType(); 51 | 52 | interpOrder.store(static_cast((js::Number) val)); 53 | } 54 | 55 | return GraphNode::setProperty(key, val); 56 | } 57 | 58 | void updateEventBoundaries(double t) { 59 | nextEvent = activeSeq->upper_bound(t); 60 | 61 | // The next event is the first one in the sequence 62 | if (nextEvent == activeSeq->begin()) { 63 | prevEvent = activeSeq->end(); 64 | return; 65 | } 66 | 67 | prevEvent = std::prev(nextEvent); 68 | } 69 | 70 | void process (BlockContext const& ctx) override { 71 | auto** inputData = ctx.inputData; 72 | auto* outputData = ctx.outputData[0]; 73 | auto numChannels = ctx.numInputChannels; 74 | auto numSamples = ctx.numSamples; 75 | auto const interp = interpOrder.load() == 1; 76 | 77 | // Pull newest seq from queue 78 | if (seqQueue.size() > 0) { 79 | while (seqQueue.size() > 0) { 80 | seqQueue.pop(activeSeq); 81 | } 82 | 83 | // New sequence means we'll have to find our new event boundaries given 84 | // the current input time 85 | prevEvent = activeSeq->end(); 86 | nextEvent = activeSeq->end(); 87 | } 88 | 89 | // Next, if we don't have the inputs we need, we bail here and zero the buffer 90 | // hoping to prevent unexpected signals. 91 | if (numChannels < 1 || activeSeq == nullptr || activeSeq->size() == 0) 92 | return (void) std::fill_n(outputData, numSamples, FloatType(0)); 93 | 94 | // We reference this a lot 95 | auto const seqEnd = activeSeq->end(); 96 | 97 | // Helpers to add some tolerance to the time checks 98 | auto const before = [](double t1, double t2) { return t1 <= (t2 + 1e-9); }; 99 | auto const after = [](double t1, double t2) { return t1 >= (t2 - 1e-9); }; 100 | 101 | for (size_t i = 0; i < numSamples; ++i) { 102 | auto const t = static_cast(inputData[0][i]); 103 | auto const shouldUpdateBounds = (prevEvent == seqEnd && nextEvent == seqEnd) 104 | || (prevEvent != seqEnd && before(t, prevEvent->first)) 105 | || (nextEvent != seqEnd && after(t, nextEvent->first)); 106 | 107 | if (shouldUpdateBounds) { 108 | updateEventBoundaries(t); 109 | } 110 | 111 | // Now, if we still don't have a prevEvent, also output 0s 112 | if (prevEvent == seqEnd) { 113 | outputData[i] = FloatType(0); 114 | continue; 115 | } 116 | 117 | // If we don't have a nextEvent but do have a prevEvent, we output the prevEvent value indefinitely 118 | if (nextEvent == seqEnd) { 119 | outputData[i] = prevEvent->second; 120 | continue; 121 | } 122 | 123 | // Finally, here we have both bounds and can output accordingly 124 | double const alpha = interp ? ((t - prevEvent->first) / (nextEvent->first - prevEvent->first)) : 0.0; 125 | auto const out = prevEvent->second + FloatType(alpha) * (nextEvent->second - prevEvent->second); 126 | 127 | outputData[i] = out; 128 | } 129 | } 130 | 131 | using Sequence = std::map>; 132 | 133 | RefCountedPool seqPool; 134 | SingleWriterSingleReaderQueue> seqQueue; 135 | std::shared_ptr activeSeq; 136 | 137 | typename Sequence::iterator prevEvent; 138 | typename Sequence::iterator nextEvent; 139 | 140 | std::atomic interpOrder { 0 }; 141 | }; 142 | 143 | } // namespace elem 144 | --------------------------------------------------------------------------------