Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
0618aa2
feat: automatically reset inactive nodes when fade finishes
ncblair Jan 10, 2025
b5f01dd
Use BaseAudioContext for WebRenderer initialize
b00lduck May 14, 2025
949e9e3
Return array from createRef as const
potrepka Mar 8, 2025
c38f4e7
Fix windows includes for native build (#70)
cannc4 Aug 27, 2025
21a784f
Remove HostContext type, use BlockContext instead
nick-thompson Sep 3, 2025
b8d62dd
BlockEvents in BlockContext for realtime events
nick-thompson Sep 3, 2025
0f1713d
Add choc dep
nick-thompson Sep 4, 2025
bd91f1f
Rewrite buffer allocation strategy
nick-thompson Sep 4, 2025
fe63ce9
Moving stuff around
nick-thompson Sep 5, 2025
7c283db
Block events buffer pool strategy
nick-thompson Sep 8, 2025
ef3793d
Wire up midi events into offline-renderer, tests
nick-thompson Sep 9, 2025
e9f0c69
Placement new instead of std::any, guarantees stack alloc
nick-thompson Sep 10, 2025
e07c13e
Wire midi events into web-renderer
nick-thompson Sep 11, 2025
4dfee05
MIDI note allocation
nick-thompson Sep 12, 2025
cc30bc3
LRU voice allocation
nick-thompson Sep 15, 2025
1d91f17
Filter by channel prop, not voice, in midiunpack
nick-thompson Sep 15, 2025
febf149
Midi note shift node, cleanup
nick-thompson Sep 15, 2025
d2d527c
Merging block events; trivially copyable block event subtypes; wasm n…
nick-thompson Sep 16, 2025
0de8575
Bump choc for include fix
nick-thompson Sep 16, 2025
e909780
Update core package stdlib with midi utilities
nick-thompson Sep 16, 2025
c95d155
Support for param value events with an el.param node
nick-thompson Sep 16, 2025
9849815
Quick nudges
nick-thompson Sep 17, 2025
225e63d
Bump package versions
nick-thompson Sep 17, 2025
8d0ab46
Bugfix for blockevents buffer reuse
nick-thompson Sep 18, 2025
b74e069
Bump wasm builds
nick-thompson Sep 18, 2025
9fe1d26
Publish
nick-thompson Sep 18, 2025
83cee0f
fix: add null-pointer guards to Math.h audio processing nodes
txbrown Mar 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,6 @@
[submodule "runtime/elem/third-party/signalsmith-stretch"]
path = runtime/elem/third-party/signalsmith-stretch
url = https://github.com/Signalsmith-Audio/signalsmith-stretch.git
[submodule "runtime/elem/third-party/choc"]
path = runtime/elem/third-party/choc
url = https://github.com/Tracktion/choc.git
100 changes: 59 additions & 41 deletions js/packages/core/index.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,35 @@
import {
renderWithDelegate,
} from './src/Reconciler.gen';
import { renderWithDelegate } from "./src/Reconciler.gen";

import { updateNodeProps } from './src/Hash';
import { updateNodeProps } from "./src/Hash";

import {
createNode,
unpack,
isNode,
resolve,
NodeRepr_t,
} from './nodeUtils';
import { createNode, isNode, resolve, unpack } from "./nodeUtils";

import * as co from "./lib/core";
import * as dy from "./lib/dynamics";
import * as en from "./lib/envelopes";
import * as ev from "./lib/event-nodes";
import * as fi from "./lib/filters";
import * as ma from "./lib/math";
import * as mc from "./lib/mc";
import * as os from "./lib/oscillators";
import * as si from "./lib/signals";

import * as co from './lib/core';
import * as dy from './lib/dynamics';
import * as en from './lib/envelopes';
import * as ma from './lib/math';
import * as mc from './lib/mc';
import * as fi from './lib/filters';
import * as os from './lib/oscillators';
import * as si from './lib/signals';

export type { ElemNode, NodeRepr_t } from './nodeUtils';
export { default as EventEmitter } from './src/Events';

export type { ElemNode, NodeRepr_t } from "./nodeUtils";
export { default as EventEmitter } from "./src/Events";

const stdlib = {
...co,
...dy,
...en,
...ev,
...fi,
...ma,
...os,
...si,
mc,
// Aliases for reserved keyword conflicts
"const": co.constant,
"in": ma.identity,
const: co.constant,
in: ma.identity,
};

const InstructionTypes = {
Expand Down Expand Up @@ -83,7 +75,9 @@ class Delegate {
};
}

getNodeMap() { return this.nodeMap; }
getNodeMap() {
return this.nodeMap;
}

createNode(hash, type) {
this.nodesAdded++;
Expand All @@ -92,12 +86,22 @@ class Delegate {

appendChild(parentHash, childHash, childOutputChannel) {
this.edgesAdded++;
this.batch.appendChild.push([InstructionTypes.APPEND_CHILD, parentHash, childHash, childOutputChannel]);
this.batch.appendChild.push([
InstructionTypes.APPEND_CHILD,
parentHash,
childHash,
childOutputChannel,
]);
}

setProperty(hash, key, value) {
this.propsWritten++;
this.batch.setProperty.push([InstructionTypes.SET_PROPERTY, hash, key, value]);
this.batch.setProperty.push([
InstructionTypes.SET_PROPERTY,
hash,
key,
value,
]);
}

activateRoots(roots) {
Expand All @@ -106,7 +110,8 @@ class Delegate {
// because it may be that we're asked to activate a subset of the current
// active roots, in which case we need the instruction to prompt the engine
// to deactivate the now excluded roots.
let alreadyActive = roots.length === this.currentActiveRoots.size &&
let alreadyActive =
roots.length === this.currentActiveRoots.size &&
roots.every((root) => this.currentActiveRoots.has(root));

if (!alreadyActive) {
Expand All @@ -132,7 +137,7 @@ class Delegate {

// A quick shim for platforms which do not support the `performance` global
function now() {
if (typeof performance === 'undefined') {
if (typeof performance === "undefined") {
return Date.now();
}

Expand Down Expand Up @@ -176,11 +181,13 @@ class Renderer {
// In other words, don't share refs between different renderer instances.
createRef(kind, props, children) {
let key = `__refKey:${this._nextRefId++}`;
let node = createNode(kind, Object.assign({key}, props), children);
let node = createNode(kind, Object.assign({ key }, props), children);

let setter = (newProps) => {
if (!this._delegate.nodeMap.has(node.hash)) {
throw new Error('Cannot update a ref that has not been mounted; make sure you render your node first')
throw new Error(
"Cannot update a ref that has not been mounted; make sure you render your node first",
);
}

const nodeMapCopy = this._delegate.nodeMap.get(node.hash);
Expand All @@ -195,18 +202,29 @@ class Renderer {
return Promise.resolve(this._sendMessage(instructions));
};

return [node, setter];
return [node, setter] as const;
}

render(...args) {
return this.renderWithOptions({ rootFadeInMs: 20, rootFadeOutMs: 20 }, ...args);
return this.renderWithOptions(
{ rootFadeInMs: 20, rootFadeOutMs: 20 },
...args,
);
}

renderWithOptions(options: { rootFadeInMs: number, rootFadeOutMs: number }, ...args) {
renderWithOptions(
options: { rootFadeInMs: number; rootFadeOutMs: number },
...args
) {
const t0 = now();

this._delegate.clear();
renderWithDelegate(this._delegate as any, args.map(resolve), options.rootFadeInMs, options.rootFadeOutMs);
renderWithDelegate(
this._delegate as any,
args.map(resolve),
options.rootFadeInMs,
options.rootFadeOutMs,
);

const t1 = now();

Expand All @@ -233,13 +251,13 @@ class Renderer {
}

export {
Delegate,
Renderer,
createNode,
unpack,
Delegate,
stdlib as el,
isNode,
resolve,
Renderer,
renderWithDelegate,
resolve,
stdlib,
stdlib as el,
unpack,
};
94 changes: 94 additions & 0 deletions js/packages/core/lib/event-nodes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import {
createNode,
resolve,
ElemNode,
NodeRepr_t,
unpack,
} from "../nodeUtils";

/**
* A simple identity function for realtime block events.
*
* Expects no props and no children, though it may accept children as
* a way to merge and propagate other event streams.

* @returns {NodeRepr_t}
*/
export function midinotein(...args: Array<ElemNode>): NodeRepr_t {
return createNode("midinotein", {}, args.map(resolve));
}

/**
* Polyphonic note allocation node with LRU voice allocation and stealing.
*
* This uses MPE style voice allocation, remapping the incoming note events
* onto channels 0-15, corresponding to (up to) 16 voices.
*
* @param {Object} props
* @param {string} [props.key] - An optional unique identifier for the node
* @param {number} [props.voices] - Number of voices to allocate (defaults to 16)
* @param {...ElemNode} children - Child nodes to process
* @returns {NodeRepr_t}
*/
export function midinoteallocate(
props: { key?: string; voices?: number },
...children: Array<ElemNode>
): NodeRepr_t {
return createNode("midinoteallocate", props, children.map(resolve));
}

/**
* Emits audio signals reflecting the frequency and velocity information
* of the incoming MIDI events.
*
* Unpacks the incoming MIDI note event stream into two separate outputs:
* - Channel 0: Frequency (Hz)
* - Channel 1: Velocity (normalized 0-1)
*
* @param {Object} props
* @param {string} [props.key] - An optional unique identifier for the node
* @param {number} [props.channel] - Filter incoming events, react only to those that match the channel number
* @param {...ElemNode} children - MIDI note event stream(s) to unpack
* @returns {Array<NodeRepr_t>} An array of two nodes: [frequency, velocity]
*/
export function midinoteunpack(
props: { key?: string; channel?: number },
...children: Array<ElemNode>
): [NodeRepr_t, NodeRepr_t] {
return unpack(
createNode("midinoteunpack", props, children.map(resolve)),
2,
) as [NodeRepr_t, NodeRepr_t];
}

/**
* Shifts MIDI note values by a specified amount.
*
* Transposes incoming MIDI note events by adding or subtracting a fixed offset
* to the note number. Useful for octave shifting or transposition effects.
*
* @param {Object} props
* @param {string} [props.key] - An optional unique identifier for the node
* @param {number} props.steps - Number of steps (semitones) to shift the MIDI note numbers
* @param {...ElemNode} children - MIDI note event stream(s) to shift
* @returns {NodeRepr_t}
*/
export function midinoteshift(
props: { key?: string; steps: number },
...children: Array<ElemNode>
): NodeRepr_t {
return createNode("midinoteshift", props, children.map(resolve));
}

/**
* Emits audio rate signals carrying the current value of the parameter
* identified by the given parameter index.
*
* @param {Object} props
* @param {string} [props.key] - An optional unique identifier for the node
* @param {number} props.index - Parameter index for which to follow events
* @returns {NodeRepr_t}
*/
export function param(props: { key?: string; index: number }): NodeRepr_t {
return createNode("param", props, []);
}
4 changes: 2 additions & 2 deletions js/packages/core/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion js/packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@elemaudio/core",
"version": "4.0.1",
"version": "4.0.3",
"type": "module",
"description": "Official Elementary Audio core package",
"keywords": [
Expand Down
74 changes: 74 additions & 0 deletions js/packages/offline-renderer/__tests__/block-events.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import OfflineRenderer from "..";
import { el } from "@elemaudio/core";

const repeat = (n, x) => Array.from({ length: n }).fill(x);
const take = (x, n) => x.slice(0, n);
const round = (x) => [...x.map(Math.round)];

test("midi events", async function () {
let core = new OfflineRenderer();

await core.initialize({
numInputChannels: 0,
numOutputChannels: 2,
sampleRate: 44100,
blockSize: 128,
});

// Graph
core.render(...el.midinoteunpack({}));

// Ten blocks of data
let inps = [];
let outs = [new Float32Array(128), new Float32Array(128)];

// Get past the fade-in
for (let i = 0; i < 20; ++i) {
core.process(inps, outs);
}

// Now we push some events and study the outputs
core.pushMidiEvent(0, new Uint8Array([0x90, 60, 127]));
core.process(inps, outs);

expect(round(take(outs[0], 8))).toMatchObject(repeat(8, 262));
expect(round(take(outs[1], 8))).toMatchObject(repeat(8, 1));

// On the next block we should see that the note is still held
core.process(inps, outs);
expect(round(take(outs[0], 8))).toMatchObject(repeat(8, 262));
expect(round(take(outs[1], 8))).toMatchObject(repeat(8, 1));

// Now we'll push a note-off 4 samples into the next block
core.pushMidiEvent(4, new Uint8Array([0x80, 60, 0]));
core.process(inps, outs);
expect(round(take(outs[0], 8))).toMatchObject(repeat(8, 262));
expect(round(take(outs[1], 8))).toMatchObject([1, 1, 1, 1, 0, 0, 0, 0]);
});

test("param value events", async function () {
let core = new OfflineRenderer();

await core.initialize({
numInputChannels: 0,
numOutputChannels: 1,
sampleRate: 44100,
blockSize: 128,
});

// Graph
core.render(el.param({ index: 0 }));

// Data
let inps = [];
let outs = [new Float32Array(128)];

// Get past the fade-in
for (let i = 0; i < 20; ++i) {
core.process(inps, outs);
}

core.pushParamValueEvent(4, 0, 15.0);
core.process(inps, outs);
expect([...take(outs[0], 8)]).toMatchObject([0, 0, 0, 0, ...repeat(4, 15)]);
});
2 changes: 1 addition & 1 deletion js/packages/offline-renderer/elementary-wasm.cjs

Large diffs are not rendered by default.

Loading