Skip to content

Commit 0e16327

Browse files
committed
v0.3.0
1 parent b4138a9 commit 0e16327

14 files changed

Lines changed: 348 additions & 10 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66

77
## [Unreleased]
88

9+
## [0.3.0]
10+
911
### Added
1012

13+
- **Web Audio runtime buffer loading** -- The `webaudio` backend can now fill gen~ buffers with sample data at runtime. New Emscripten exports (`wa_load_buffer`, `wa_get_num_buffers`, `wa_get_buffer_name`) are backed by a genlib-side `wrapper_load_buffer()` that copies interleaved float samples into the `WebaudioBuffer` instances. The AudioWorklet (`processor.js`) handles a `load-buffer` message (queued until the WASM module is ready, then applied between render quanta), and the generated `index.html` adds a file input per buffer that decodes an audio file via `decodeAudioData()` and posts the samples to the worklet. Previously buffers were allocated but unfillable, so buffer-backed patches (sample players, wavetables) ran silent; effects and parameter-driven generators were unaffected. Covered by a Node.js round-trip integration test (`test_buffer_loading_rampleplayer`).
14+
1115
- **GDSP DSL `split()` / `merge()` composition** -- The fan-out/fan-in combinators are now callable from `.gdsp` source (previously available only via the Python `gen_dsp.graph.algebra` API). They accept two graph operands (graph references, partially-applied graph calls, or nested compositions), compose with `>>` and `//`, and lower to a `Subgraph` wrapping the composed graph. `split(a, b)` distributes `a`'s outputs cyclically across `b`'s inputs (requires `len(b.inputs) % len(a.outputs) == 0`); `merge(a, b)` sums groups of `a`'s outputs into `b`'s inputs (requires `len(a.outputs) % len(b.inputs) == 0`).
1216

1317
- **GDSP DSL external file imports** -- `name = import "file.gdsp":graph(input=..., param=...)` references a graph defined in another `.gdsp` file and instantiates it as a `Subgraph`, with the same keyword-only argument rules as an in-source subgraph call. The graph name may be omitted when the target file defines exactly one graph. Relative paths resolve against the importing file's directory (the current working directory for string sources); each file is parsed and compiled at most once per top-level compilation (shared module cache, so diamond imports reuse the result); and import cycles -- direct (`a -> a`) or transitive (`a -> b -> a`) -- raise a `GDSPCompileError` reporting the chain instead of recursing without bound. This supersedes the deliberate "external imports are not supported" error added in 0.2.0.

TODO.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,13 @@ gen-dsp can be consumed as a library by [dsp-graph](https://github.com/shakfu/ds
88

99
### Web Audio backend follow-ups
1010

11-
- [ ] **Web Audio runtime buffer loading** -- The `webaudio` backend now generates
12-
`gen_buffer.h` from `manifest.buffers` (header wiring done), but has no runtime path to
13-
fill those buffers. Browser file loading is async and browser-specific; needs a
14-
`wa_load_buffer()` Emscripten export + JS-side `fetch()` + `decodeAudioData()` bridge.
11+
- [x] **Web Audio runtime buffer loading** -- Done: added `wa_load_buffer` /
12+
`wa_get_num_buffers` / `wa_get_buffer_name` Emscripten exports backed by a genlib-side
13+
`wrapper_load_buffer()` (writes interleaved samples into the `WebaudioBuffer` instances).
14+
The worklet (`processor.js`) handles a `load-buffer` message (queued until the WASM is
15+
ready), and `index.html` provides a per-buffer file input that decodes audio via
16+
`decodeAudioData()` and posts the samples to the worklet. Verified end-to-end (emcc build +
17+
Node round-trip) by `test_buffer_loading_rampleplayer`.
1518

1619
- [ ] **Web Audio build integration tests** -- Currently gated by `emcc` availability (skipped
1720
in CI). Consider adding Emscripten to CI or a lightweight WASM validation step.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "gen-dsp"
3-
version = "0.2.0"
3+
version = "0.3.0"
44
description = "Generate multiple dsp plugin formats from Max gen~ exports"
55
readme = "README.md"
66
license = "MIT"

src/gen_dsp/platforms/webaudio.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ def generate_project(
123123
num_params=str(len(manifest.params)),
124124
param_descriptors=json.dumps(param_descriptors, indent=4),
125125
num_outputs_array=num_outputs_array,
126+
buffer_names=json.dumps(manifest.buffers),
126127
)
127128

128129
@staticmethod
@@ -236,6 +237,7 @@ def _write_graph_platform_files(
236237
num_params=str(len(manifest.params)),
237238
param_descriptors=json.dumps(param_descriptors, indent=4),
238239
num_outputs_array=num_outputs_array,
240+
buffer_names=json.dumps(manifest.buffers),
239241
)
240242

241243
def build(

src/gen_dsp/templates/webaudio/Makefile.template

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ EMFLAGS = -O2 \
3333
-s MODULARIZE=1 \
3434
-s EXPORT_NAME='$export_name' \
3535
-s ENVIRONMENT='web,worker,node' \
36-
-s EXPORTED_FUNCTIONS='["_wa_create","_wa_destroy","_wa_perform","_wa_get_num_inputs","_wa_get_num_outputs","_wa_get_num_params","_wa_set_param","_wa_get_param","_wa_get_param_name","_wa_get_param_min","_wa_get_param_max","_wa_get_param_default","_malloc","_free"]' \
36+
-s EXPORTED_FUNCTIONS='["_wa_create","_wa_destroy","_wa_perform","_wa_get_num_inputs","_wa_get_num_outputs","_wa_get_num_params","_wa_set_param","_wa_get_param","_wa_get_param_name","_wa_get_param_min","_wa_get_param_max","_wa_get_param_default","_wa_get_num_buffers","_wa_get_buffer_name","_wa_load_buffer","_malloc","_free"]' \
3737
-s EXPORTED_RUNTIME_METHODS='["cwrap","UTF8ToString","HEAPU32","HEAPF32"]'
3838

3939
BUILD_DIR = build

src/gen_dsp/templates/webaudio/_ext_webaudio.cpp

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,4 +230,57 @@ const char* wrapper_buffer_name(int index) {
230230
return nullptr;
231231
}
232232

233+
// Pointers to the buffer instances, parallel to buffer_names[] above so that
234+
// wrapper_load_buffer() can address a buffer by the same index the JS side
235+
// discovers via wrapper_num_buffers()/wrapper_buffer_name().
236+
static WebaudioBuffer* buffer_ptrs[] = {
237+
#ifdef WRAPPER_BUFFER_NAME_0
238+
&WRAPPER_BUFFER_NAME_0,
239+
#endif
240+
#ifdef WRAPPER_BUFFER_NAME_1
241+
&WRAPPER_BUFFER_NAME_1,
242+
#endif
243+
#ifdef WRAPPER_BUFFER_NAME_2
244+
&WRAPPER_BUFFER_NAME_2,
245+
#endif
246+
#ifdef WRAPPER_BUFFER_NAME_3
247+
&WRAPPER_BUFFER_NAME_3,
248+
#endif
249+
#ifdef WRAPPER_BUFFER_NAME_4
250+
&WRAPPER_BUFFER_NAME_4,
251+
#endif
252+
#ifdef WRAPPER_BUFFER_NAME_5
253+
&WRAPPER_BUFFER_NAME_5,
254+
#endif
255+
#ifdef WRAPPER_BUFFER_NAME_6
256+
&WRAPPER_BUFFER_NAME_6,
257+
#endif
258+
#ifdef WRAPPER_BUFFER_NAME_7
259+
&WRAPPER_BUFFER_NAME_7,
260+
#endif
261+
nullptr
262+
};
263+
264+
// Fill buffer `index` with `frames * channels` interleaved (frame-major) float
265+
// samples. Reallocates the buffer to the given size; runs on the audio thread
266+
// between render quanta (the Worklet serializes port messages with process()),
267+
// so no locking is needed.
268+
void wrapper_load_buffer(int index, const float* data, long frames, long channels) {
269+
if (index < 0 || index >= WRAPPER_BUFFER_COUNT || data == nullptr) {
270+
return;
271+
}
272+
if (frames < 0) frames = 0;
273+
if (channels < 1) channels = 1;
274+
WebaudioBuffer* buf = buffer_ptrs[index];
275+
if (buf == nullptr) {
276+
return;
277+
}
278+
buf->allocate(frames, channels);
279+
for (long i = 0; i < frames; i++) {
280+
for (long c = 0; c < channels; c++) {
281+
buf->write(data[i * channels + c], i, c);
282+
}
283+
}
284+
}
285+
233286
} // namespace WRAPPER_NAMESPACE

src/gen_dsp/templates/webaudio/gen_ext_common_webaudio.h

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,12 @@
1717
// Namespace for wrapper functions (isolates genlib from Emscripten bridge)
1818
#define WRAPPER_NAMESPACE WRAPPER_FUN2(WEBAUDIO_EXT_NAME, _webaudio)
1919

20+
// Web Audio buffer loading: defined on the genlib side in _ext_webaudio.cpp and
21+
// called from the Emscripten bridge. Copies `frames * channels` interleaved
22+
// float32 samples (frame-major) into the buffer at `index`. Declared here so it
23+
// is visible to both sides without exposing genlib's WebaudioBuffer type.
24+
namespace WRAPPER_NAMESPACE {
25+
void wrapper_load_buffer(int index, const float* data, long frames, long channels);
26+
}
27+
2028
#endif // GEN_EXT_COMMON_WEBAUDIO_H

src/gen_dsp/templates/webaudio/gen_ext_webaudio.cpp.template

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,4 +76,20 @@ float wa_get_param_default(int state_ptr, int index) {
7676
return wrapper_get_param((GenState*)(intptr_t)state_ptr, index);
7777
}
7878

79+
EMSCRIPTEN_KEEPALIVE
80+
int wa_get_num_buffers() {
81+
return wrapper_num_buffers();
82+
}
83+
84+
EMSCRIPTEN_KEEPALIVE
85+
const char* wa_get_buffer_name(int index) {
86+
return wrapper_buffer_name(index);
87+
}
88+
89+
EMSCRIPTEN_KEEPALIVE
90+
void wa_load_buffer(int index, int data_ptr, int frames, int channels) {
91+
const float* data = (const float*)(intptr_t)data_ptr;
92+
wrapper_load_buffer(index, data, (long)frames, (long)channels);
93+
}
94+
7995
} // extern "C"

src/gen_dsp/templates/webaudio/index.html.template

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,18 +58,53 @@ button.active { background: #e94560; border-color: #e94560; color: #fff; }
5858
<div class="subtitle">$num_inputs in / $num_outputs out | $num_params params | gen-dsp webaudio</div>
5959
<button id="toggle" onclick="toggleAudio()">Start Audio</button>
6060
<div id="params"></div>
61+
<div id="buffers"></div>
6162
<div id="status">Click Start to initialize Web Audio.</div>
6263
</div>
6364
<script>
6465
const LIB_NAME = '$lib_name';
6566
const NUM_INPUTS = $num_inputs;
6667
const NUM_OUTPUTS = $num_outputs;
6768
const PARAMS = $param_descriptors;
69+
const BUFFER_NAMES = $buffer_names;
6870

6971
let ctx = null;
7072
let workletNode = null;
7173
let running = false;
7274
let sourceNode = null;
75+
// Buffer files chosen before audio starts; applied once the worklet exists.
76+
const pendingBufferFiles = {};
77+
78+
// Decode an audio file and load it into gen~ buffer `index`. decodeAudioData
79+
// is main-thread only, so we decode here and post the samples to the worklet.
80+
async function loadBufferIntoNode(index, file) {
81+
if (!ctx || !workletNode) {
82+
pendingBufferFiles[index] = file;
83+
document.getElementById('status').textContent =
84+
'Buffer "' + BUFFER_NAMES[index] + '" queued; loads when audio starts.';
85+
return;
86+
}
87+
const arrayBuf = await file.arrayBuffer();
88+
const audioBuf = await ctx.decodeAudioData(arrayBuf);
89+
const frames = audioBuf.length;
90+
const channels = audioBuf.numberOfChannels;
91+
// Interleave (frame-major) to match the gen~ buffer storage layout.
92+
const interleaved = new Float32Array(frames * channels);
93+
for (let c = 0; c < channels; c++) {
94+
const cd = audioBuf.getChannelData(c);
95+
for (let i = 0; i < frames; i++) {
96+
interleaved[i * channels + c] = cd[i];
97+
}
98+
}
99+
workletNode.port.postMessage(
100+
{ type: 'load-buffer', index: index, frames: frames,
101+
channels: channels, data: interleaved },
102+
[interleaved.buffer]
103+
);
104+
document.getElementById('status').textContent =
105+
'Loaded "' + BUFFER_NAMES[index] + '": ' + frames + ' frames, ' +
106+
channels + ' ch.';
107+
}
73108

74109
async function toggleAudio() {
75110
const btn = document.getElementById('toggle');
@@ -154,6 +189,13 @@ async function toggleAudio() {
154189
});
155190
}
156191
}
192+
193+
// Apply any buffer files chosen before audio started.
194+
for (const idx of Object.keys(pendingBufferFiles)) {
195+
const f = pendingBufferFiles[idx];
196+
delete pendingBufferFiles[idx];
197+
await loadBufferIntoNode(parseInt(idx, 10), f);
198+
}
157199
} catch (err) {
158200
document.getElementById('status').textContent = 'Error: ' + err.message;
159201
console.error(err);
@@ -174,6 +216,27 @@ for (const p of PARAMS) {
174216
p.defaultValue.toFixed(3) + '</span>';
175217
paramsDiv.appendChild(div);
176218
}
219+
220+
// Build buffer file-input UI (one per gen~ buffer)
221+
const buffersDiv = document.getElementById('buffers');
222+
for (let i = 0; i < BUFFER_NAMES.length; i++) {
223+
const div = document.createElement('div');
224+
div.className = 'param';
225+
div.innerHTML =
226+
'<label>' + BUFFER_NAMES[i] + '</label>' +
227+
'<input type="file" accept="audio/*" id="buf-' + i + '">';
228+
buffersDiv.appendChild(div);
229+
(function(idx) {
230+
document.getElementById('buf-' + idx).addEventListener('change', (e) => {
231+
if (e.target.files && e.target.files[0]) {
232+
loadBufferIntoNode(idx, e.target.files[0]).catch((err) => {
233+
document.getElementById('status').textContent =
234+
'Buffer load error: ' + err.message;
235+
});
236+
}
237+
});
238+
})(i);
239+
}
177240
</script>
178241
</body>
179242
</html>

src/gen_dsp/templates/webaudio/processor.js.template

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,14 @@ class $processor_class extends AudioWorkletProcessor {
3232
this._inBufs = [];
3333
this._outBufs = [];
3434
this._mod = null;
35+
this._pendingBuffers = [];
3536

36-
// Wait for the main thread to send the WASM binary
37+
// Wait for the main thread to send the WASM binary and buffer data.
3738
this.port.onmessage = (e) => {
3839
if (e.data.type === 'wasm-binary') {
3940
this._loadWasm(e.data.binary);
41+
} else if (e.data.type === 'load-buffer') {
42+
this._loadBuffer(e.data);
4043
}
4144
};
4245
}
@@ -49,12 +52,35 @@ class $processor_class extends AudioWorkletProcessor {
4952
this._mod = await $export_name({ wasmBinary: wasmBinary });
5053
this._initDsp();
5154
this._ready = true;
55+
// Apply any buffers that arrived before the module finished loading.
56+
const pending = this._pendingBuffers;
57+
this._pendingBuffers = [];
58+
for (const msg of pending) {
59+
this._loadBuffer(msg);
60+
}
5261
this.port.postMessage({ type: 'ready' });
5362
} catch (err) {
5463
this.port.postMessage({ type: 'error', message: err.message });
5564
}
5665
}
5766

67+
// Copy interleaved (frame-major) float32 sample data into a gen~ buffer.
68+
// Runs on the audio thread between render quanta, so it is safe to
69+
// reallocate the buffer here without locking.
70+
_loadBuffer(msg) {
71+
if (!this._ready) {
72+
this._pendingBuffers.push(msg);
73+
return;
74+
}
75+
const m = this._mod;
76+
const data = msg.data;
77+
if (!data || data.length === 0) return;
78+
const ptr = m._malloc(data.length * 4);
79+
m.HEAPF32.set(data, ptr / 4);
80+
m._wa_load_buffer(msg.index, ptr, msg.frames, msg.channels);
81+
m._free(ptr);
82+
}
83+
5884
_initDsp() {
5985
const m = this._mod;
6086
const sr = sampleRate;

0 commit comments

Comments
 (0)