supersonic-scsynth 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +27 -0
- package/README.md +320 -0
- package/dist/README.md +21 -0
- package/dist/supersonic.js +2411 -0
- package/dist/wasm/manifest.json +8 -0
- package/dist/wasm/scsynth-nrt.wasm +0 -0
- package/dist/workers/debug_worker.js +274 -0
- package/dist/workers/osc_in_worker.js +274 -0
- package/dist/workers/osc_out_worker.js +519 -0
- package/dist/workers/scsynth_audio_worklet.js +531 -0
- package/package.json +51 -0
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
/*
|
|
2
|
+
SuperSonic - SuperCollider AudioWorklet WebAssembly port
|
|
3
|
+
Copyright (c) 2025 Sam Aaron
|
|
4
|
+
|
|
5
|
+
Based on SuperCollider by James McCartney and community
|
|
6
|
+
GPL v3 or later
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* AudioWorklet Processor for scsynth WASM
|
|
11
|
+
* Runs in AudioWorkletGlobalScope with real-time priority
|
|
12
|
+
* VERSION: 16 - Reduced atomic operation frequency
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
class ScsynthProcessor extends AudioWorkletProcessor {
|
|
16
|
+
constructor() {
|
|
17
|
+
super();
|
|
18
|
+
|
|
19
|
+
this.sharedBuffer = null;
|
|
20
|
+
this.wasmModule = null;
|
|
21
|
+
this.wasmInstance = null;
|
|
22
|
+
this.isInitialized = false;
|
|
23
|
+
this.processCallCount = 0;
|
|
24
|
+
this.lastStatusCheck = 0;
|
|
25
|
+
this.ringBufferBase = null;
|
|
26
|
+
|
|
27
|
+
// Pre-allocated audio view to avoid per-frame allocations
|
|
28
|
+
this.audioView = null;
|
|
29
|
+
this.lastAudioBufferPtr = 0;
|
|
30
|
+
this.lastWasmBufferSize = 0;
|
|
31
|
+
|
|
32
|
+
// Views into SharedArrayBuffer
|
|
33
|
+
this.atomicView = null;
|
|
34
|
+
this.uint8View = null;
|
|
35
|
+
this.dataView = null;
|
|
36
|
+
|
|
37
|
+
// Buffer constants (loaded from WASM at initialization)
|
|
38
|
+
this.bufferConstants = null;
|
|
39
|
+
|
|
40
|
+
// Control region indices (Int32Array indices) - will be calculated dynamically
|
|
41
|
+
this.CONTROL_INDICES = null;
|
|
42
|
+
|
|
43
|
+
// Metrics indices - will be calculated dynamically
|
|
44
|
+
this.METRICS_INDICES = null;
|
|
45
|
+
|
|
46
|
+
// Status flag masks
|
|
47
|
+
this.STATUS_FLAGS = {
|
|
48
|
+
OK: 0,
|
|
49
|
+
BUFFER_FULL: 1 << 0,
|
|
50
|
+
OVERRUN: 1 << 1,
|
|
51
|
+
WASM_ERROR: 1 << 2,
|
|
52
|
+
FRAGMENTED_MSG: 1 << 3
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Listen for messages from main thread
|
|
56
|
+
this.port.onmessage = this.handleMessage.bind(this);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Load buffer constants from WASM module
|
|
60
|
+
// Reads the BufferLayout struct exported by C++
|
|
61
|
+
loadBufferConstants() {
|
|
62
|
+
if (!this.wasmInstance || !this.wasmInstance.exports.get_buffer_layout) {
|
|
63
|
+
throw new Error('WASM instance does not export get_buffer_layout');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Get pointer to BufferLayout struct
|
|
67
|
+
const layoutPtr = this.wasmInstance.exports.get_buffer_layout();
|
|
68
|
+
|
|
69
|
+
// Get WASM memory (imported, not exported - stored in this.wasmMemory)
|
|
70
|
+
const memory = this.wasmMemory;
|
|
71
|
+
if (!memory) {
|
|
72
|
+
throw new Error('WASM memory not available');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Read the struct (14 uint32_t fields + 1 uint8_t + 3 padding bytes)
|
|
76
|
+
const uint32View = new Uint32Array(memory.buffer, layoutPtr, 15);
|
|
77
|
+
const uint8View = new Uint8Array(memory.buffer, layoutPtr, 60);
|
|
78
|
+
|
|
79
|
+
// Extract constants (order matches BufferLayout struct in shared_memory.h)
|
|
80
|
+
this.bufferConstants = {
|
|
81
|
+
IN_BUFFER_START: uint32View[0],
|
|
82
|
+
IN_BUFFER_SIZE: uint32View[1],
|
|
83
|
+
OUT_BUFFER_START: uint32View[2],
|
|
84
|
+
OUT_BUFFER_SIZE: uint32View[3],
|
|
85
|
+
DEBUG_BUFFER_START: uint32View[4],
|
|
86
|
+
DEBUG_BUFFER_SIZE: uint32View[5],
|
|
87
|
+
CONTROL_START: uint32View[6],
|
|
88
|
+
CONTROL_SIZE: uint32View[7],
|
|
89
|
+
METRICS_START: uint32View[8],
|
|
90
|
+
METRICS_SIZE: uint32View[9],
|
|
91
|
+
TOTAL_BUFFER_SIZE: uint32View[10],
|
|
92
|
+
MAX_MESSAGE_SIZE: uint32View[11],
|
|
93
|
+
MESSAGE_MAGIC: uint32View[12],
|
|
94
|
+
PADDING_MAGIC: uint32View[13],
|
|
95
|
+
DEBUG_PADDING_MARKER: uint8View[56],
|
|
96
|
+
MESSAGE_HEADER_SIZE: 16 // sizeof(Message) - 4 x uint32_t (magic, length, sequence, padding)
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// Validate
|
|
100
|
+
if (this.bufferConstants.MESSAGE_MAGIC !== 0xDEADBEEF) {
|
|
101
|
+
throw new Error('Invalid buffer constants from WASM');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Calculate buffer indices based on dynamic ring buffer base address
|
|
106
|
+
// Uses constants loaded from WASM via loadBufferConstants()
|
|
107
|
+
calculateBufferIndices(ringBufferBase) {
|
|
108
|
+
if (!this.bufferConstants) {
|
|
109
|
+
throw new Error('Buffer constants not loaded. Call loadBufferConstants() first.');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const CONTROL_START = this.bufferConstants.CONTROL_START;
|
|
113
|
+
const METRICS_START = this.bufferConstants.METRICS_START;
|
|
114
|
+
|
|
115
|
+
// Calculate Int32Array indices (divide byte offsets by 4)
|
|
116
|
+
this.CONTROL_INDICES = {
|
|
117
|
+
IN_HEAD: (ringBufferBase + CONTROL_START + 0) / 4,
|
|
118
|
+
IN_TAIL: (ringBufferBase + CONTROL_START + 4) / 4,
|
|
119
|
+
OUT_HEAD: (ringBufferBase + CONTROL_START + 8) / 4,
|
|
120
|
+
OUT_TAIL: (ringBufferBase + CONTROL_START + 12) / 4,
|
|
121
|
+
DEBUG_HEAD: (ringBufferBase + CONTROL_START + 16) / 4,
|
|
122
|
+
DEBUG_TAIL: (ringBufferBase + CONTROL_START + 20) / 4,
|
|
123
|
+
SEQUENCE: (ringBufferBase + CONTROL_START + 24) / 4,
|
|
124
|
+
STATUS_FLAGS: (ringBufferBase + CONTROL_START + 28) / 4
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
this.METRICS_INDICES = {
|
|
128
|
+
PROCESS_COUNT: (ringBufferBase + METRICS_START + 0) / 4,
|
|
129
|
+
BUFFER_OVERRUNS: (ringBufferBase + METRICS_START + 4) / 4,
|
|
130
|
+
MESSAGES_PROCESSED: (ringBufferBase + METRICS_START + 8) / 4,
|
|
131
|
+
MESSAGES_DROPPED: (ringBufferBase + METRICS_START + 12) / 4
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Write debug message to SharedArrayBuffer DEBUG ring buffer
|
|
136
|
+
js_debug(message) {
|
|
137
|
+
if (!this.uint8View || !this.atomicView || !this.CONTROL_INDICES || !this.ringBufferBase) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
// Use constants from WASM
|
|
143
|
+
const DEBUG_BUFFER_START = this.bufferConstants.DEBUG_BUFFER_START;
|
|
144
|
+
const DEBUG_BUFFER_SIZE = this.bufferConstants.DEBUG_BUFFER_SIZE;
|
|
145
|
+
const DEBUG_PADDING_MARKER = this.bufferConstants.DEBUG_PADDING_MARKER;
|
|
146
|
+
|
|
147
|
+
const prefixedMessage = '[JS] ' + message + '\n';
|
|
148
|
+
const encoder = new TextEncoder();
|
|
149
|
+
const bytes = encoder.encode(prefixedMessage);
|
|
150
|
+
|
|
151
|
+
// Drop message if too large for buffer
|
|
152
|
+
if (bytes.length > DEBUG_BUFFER_SIZE) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const debugHeadIndex = this.CONTROL_INDICES.DEBUG_HEAD;
|
|
157
|
+
const currentHead = Atomics.load(this.atomicView, debugHeadIndex);
|
|
158
|
+
const spaceToEnd = DEBUG_BUFFER_SIZE - currentHead;
|
|
159
|
+
|
|
160
|
+
let writePos = currentHead;
|
|
161
|
+
if (bytes.length > spaceToEnd) {
|
|
162
|
+
// Message won't fit - write padding marker and wrap to beginning
|
|
163
|
+
this.uint8View[this.ringBufferBase + DEBUG_BUFFER_START + currentHead] = DEBUG_PADDING_MARKER;
|
|
164
|
+
writePos = 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Write message (now guaranteed to fit contiguously)
|
|
168
|
+
const debugBufferStart = this.ringBufferBase + DEBUG_BUFFER_START;
|
|
169
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
170
|
+
this.uint8View[debugBufferStart + writePos + i] = bytes[i];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Update head pointer (publish message)
|
|
174
|
+
const newHead = writePos + bytes.length;
|
|
175
|
+
Atomics.store(this.atomicView, debugHeadIndex, newHead);
|
|
176
|
+
} catch (err) {
|
|
177
|
+
// Silently fail in real-time audio context
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async handleMessage(event) {
|
|
182
|
+
const { data } = event;
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
if (data.type === 'init' && data.sharedBuffer) {
|
|
186
|
+
// Receive SharedArrayBuffer
|
|
187
|
+
this.sharedBuffer = data.sharedBuffer;
|
|
188
|
+
this.atomicView = new Int32Array(this.sharedBuffer);
|
|
189
|
+
this.uint8View = new Uint8Array(this.sharedBuffer);
|
|
190
|
+
this.dataView = new DataView(this.sharedBuffer);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (data.type === 'loadWasm') {
|
|
194
|
+
// Load WASM module (standalone version)
|
|
195
|
+
if (data.wasmBytes) {
|
|
196
|
+
// Use the memory passed from orchestrator (already created)
|
|
197
|
+
const memory = data.wasmMemory;
|
|
198
|
+
if (!memory) {
|
|
199
|
+
this.port.postMessage({
|
|
200
|
+
type: 'error',
|
|
201
|
+
error: 'No WASM memory provided!'
|
|
202
|
+
});
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Save memory reference for later use (WASM imports memory, doesn't export it)
|
|
207
|
+
this.wasmMemory = memory;
|
|
208
|
+
|
|
209
|
+
// Import object for WASM
|
|
210
|
+
// scsynth with pthread support requires these imports
|
|
211
|
+
// (pthread stubs are no-ops - AudioWorklet is single-threaded)
|
|
212
|
+
const imports = {
|
|
213
|
+
env: {
|
|
214
|
+
memory: memory,
|
|
215
|
+
// Time
|
|
216
|
+
emscripten_asm_const_double: () => Date.now() * 1000,
|
|
217
|
+
// Filesystem syscalls
|
|
218
|
+
__syscall_getdents64: () => 0,
|
|
219
|
+
__syscall_unlinkat: () => 0,
|
|
220
|
+
// pthread stubs (no-ops - AudioWorklet doesn't support threading)
|
|
221
|
+
_emscripten_init_main_thread_js: () => {},
|
|
222
|
+
_emscripten_thread_mailbox_await: () => {},
|
|
223
|
+
_emscripten_thread_set_strongref: () => {},
|
|
224
|
+
emscripten_exit_with_live_runtime: () => {},
|
|
225
|
+
_emscripten_receive_on_main_thread_js: () => {},
|
|
226
|
+
emscripten_check_blocking_allowed: () => {},
|
|
227
|
+
_emscripten_thread_cleanup: () => {},
|
|
228
|
+
emscripten_num_logical_cores: () => 1, // Report 1 core
|
|
229
|
+
_emscripten_notify_mailbox_postmessage: () => {}
|
|
230
|
+
},
|
|
231
|
+
wasi_snapshot_preview1: {
|
|
232
|
+
clock_time_get: (clockid, precision, timestamp_ptr) => {
|
|
233
|
+
const view = new DataView(memory.buffer);
|
|
234
|
+
const nanos = BigInt(Math.floor(Date.now() * 1000000));
|
|
235
|
+
view.setBigUint64(timestamp_ptr, nanos, true);
|
|
236
|
+
return 0;
|
|
237
|
+
},
|
|
238
|
+
environ_sizes_get: () => 0,
|
|
239
|
+
environ_get: () => 0,
|
|
240
|
+
fd_close: () => 0,
|
|
241
|
+
fd_write: () => 0,
|
|
242
|
+
fd_seek: () => 0,
|
|
243
|
+
fd_read: () => 0,
|
|
244
|
+
proc_exit: (code) => {
|
|
245
|
+
console.error('[AudioWorklet] WASM tried to exit with code:', code);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
// Compile and instantiate WASM
|
|
251
|
+
const module = await WebAssembly.compile(data.wasmBytes);
|
|
252
|
+
this.wasmInstance = await WebAssembly.instantiate(module, imports);
|
|
253
|
+
|
|
254
|
+
// Get the ring buffer base address from WASM
|
|
255
|
+
if (this.wasmInstance.exports.get_ring_buffer_base) {
|
|
256
|
+
this.ringBufferBase = this.wasmInstance.exports.get_ring_buffer_base();
|
|
257
|
+
|
|
258
|
+
// Load buffer constants from WASM (single source of truth)
|
|
259
|
+
this.loadBufferConstants();
|
|
260
|
+
|
|
261
|
+
this.calculateBufferIndices(this.ringBufferBase);
|
|
262
|
+
|
|
263
|
+
// Initialize WASM memory
|
|
264
|
+
if (this.wasmInstance.exports.init_memory) {
|
|
265
|
+
this.wasmInstance.exports.init_memory(48000.0);
|
|
266
|
+
|
|
267
|
+
// Set time offset from JavaScript
|
|
268
|
+
if (this.wasmInstance.exports.set_time_offset && data.timeOffset) {
|
|
269
|
+
this.wasmInstance.exports.set_time_offset(data.timeOffset);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
this.isInitialized = true;
|
|
273
|
+
|
|
274
|
+
this.port.postMessage({
|
|
275
|
+
type: 'initialized',
|
|
276
|
+
success: true,
|
|
277
|
+
ringBufferBase: this.ringBufferBase,
|
|
278
|
+
bufferConstants: this.bufferConstants,
|
|
279
|
+
exports: Object.keys(this.wasmInstance.exports)
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
} else if (data.wasmInstance) {
|
|
284
|
+
// Pre-instantiated WASM (from Emscripten)
|
|
285
|
+
this.wasmInstance = data.wasmInstance;
|
|
286
|
+
|
|
287
|
+
// Get the ring buffer base address from WASM
|
|
288
|
+
if (this.wasmInstance.exports.get_ring_buffer_base) {
|
|
289
|
+
this.ringBufferBase = this.wasmInstance.exports.get_ring_buffer_base();
|
|
290
|
+
|
|
291
|
+
// Load buffer constants from WASM (single source of truth)
|
|
292
|
+
this.loadBufferConstants();
|
|
293
|
+
|
|
294
|
+
this.calculateBufferIndices(this.ringBufferBase);
|
|
295
|
+
|
|
296
|
+
// Initialize WASM memory
|
|
297
|
+
if (this.wasmInstance.exports.init_memory) {
|
|
298
|
+
this.wasmInstance.exports.init_memory(48000.0);
|
|
299
|
+
|
|
300
|
+
// Set time offset from JavaScript
|
|
301
|
+
if (this.wasmInstance.exports.set_time_offset && data.timeOffset) {
|
|
302
|
+
this.wasmInstance.exports.set_time_offset(data.timeOffset);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
this.isInitialized = true;
|
|
306
|
+
|
|
307
|
+
this.port.postMessage({
|
|
308
|
+
type: 'initialized',
|
|
309
|
+
success: true,
|
|
310
|
+
ringBufferBase: this.ringBufferBase,
|
|
311
|
+
bufferConstants: this.bufferConstants,
|
|
312
|
+
exports: Object.keys(this.wasmInstance.exports)
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (data.type === 'getMetrics') {
|
|
320
|
+
// Return current metrics (only if initialized)
|
|
321
|
+
if (this.atomicView && this.METRICS_INDICES && this.CONTROL_INDICES && this.bufferConstants) {
|
|
322
|
+
// Calculate buffer usage percentages (use constants from WASM)
|
|
323
|
+
const IN_BUFFER_SIZE = this.bufferConstants.IN_BUFFER_SIZE;
|
|
324
|
+
const OUT_BUFFER_SIZE = this.bufferConstants.OUT_BUFFER_SIZE;
|
|
325
|
+
const DEBUG_BUFFER_SIZE = this.bufferConstants.DEBUG_BUFFER_SIZE;
|
|
326
|
+
|
|
327
|
+
const inHead = Atomics.load(this.atomicView, this.CONTROL_INDICES.IN_HEAD);
|
|
328
|
+
const inTail = Atomics.load(this.atomicView, this.CONTROL_INDICES.IN_TAIL);
|
|
329
|
+
const inUsed = (inHead - inTail + IN_BUFFER_SIZE) % IN_BUFFER_SIZE;
|
|
330
|
+
const inPercentage = Math.round((inUsed / IN_BUFFER_SIZE) * 100);
|
|
331
|
+
|
|
332
|
+
const outHead = Atomics.load(this.atomicView, this.CONTROL_INDICES.OUT_HEAD);
|
|
333
|
+
const outTail = Atomics.load(this.atomicView, this.CONTROL_INDICES.OUT_TAIL);
|
|
334
|
+
const outUsed = (outHead - outTail + OUT_BUFFER_SIZE) % OUT_BUFFER_SIZE;
|
|
335
|
+
const outPercentage = Math.round((outUsed / OUT_BUFFER_SIZE) * 100);
|
|
336
|
+
|
|
337
|
+
const debugHead = Atomics.load(this.atomicView, this.CONTROL_INDICES.DEBUG_HEAD);
|
|
338
|
+
const debugTail = Atomics.load(this.atomicView, this.CONTROL_INDICES.DEBUG_TAIL);
|
|
339
|
+
const debugUsed = (debugHead - debugTail + DEBUG_BUFFER_SIZE) % DEBUG_BUFFER_SIZE;
|
|
340
|
+
const debugPercentage = Math.round((debugUsed / DEBUG_BUFFER_SIZE) * 100);
|
|
341
|
+
|
|
342
|
+
const metrics = {
|
|
343
|
+
processCount: Atomics.load(this.atomicView, this.METRICS_INDICES.PROCESS_COUNT),
|
|
344
|
+
bufferOverruns: Atomics.load(this.atomicView, this.METRICS_INDICES.BUFFER_OVERRUNS),
|
|
345
|
+
messagesProcessed: Atomics.load(this.atomicView, this.METRICS_INDICES.MESSAGES_PROCESSED),
|
|
346
|
+
messagesDropped: Atomics.load(this.atomicView, this.METRICS_INDICES.MESSAGES_DROPPED),
|
|
347
|
+
statusFlags: Atomics.load(this.atomicView, this.CONTROL_INDICES.STATUS_FLAGS),
|
|
348
|
+
inBufferUsed: {
|
|
349
|
+
bytes: inUsed,
|
|
350
|
+
percentage: inPercentage
|
|
351
|
+
},
|
|
352
|
+
outBufferUsed: {
|
|
353
|
+
bytes: outUsed,
|
|
354
|
+
percentage: outPercentage
|
|
355
|
+
},
|
|
356
|
+
debugBufferUsed: {
|
|
357
|
+
bytes: debugUsed,
|
|
358
|
+
percentage: debugPercentage
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
this.port.postMessage({
|
|
363
|
+
type: 'metrics',
|
|
364
|
+
metrics: metrics
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (data.type === 'getVersion') {
|
|
370
|
+
// Return Supersonic/SuperCollider version string
|
|
371
|
+
if (this.wasmInstance && this.wasmInstance.exports.get_supersonic_version_string) {
|
|
372
|
+
const versionPtr = this.wasmInstance.exports.get_supersonic_version_string();
|
|
373
|
+
const memory = new Uint8Array(this.wasmMemory.buffer);
|
|
374
|
+
// Read null-terminated C string
|
|
375
|
+
let version = '';
|
|
376
|
+
for (let i = versionPtr; memory[i] !== 0; i++) {
|
|
377
|
+
version += String.fromCharCode(memory[i]);
|
|
378
|
+
}
|
|
379
|
+
this.port.postMessage({
|
|
380
|
+
type: 'version',
|
|
381
|
+
version: version
|
|
382
|
+
});
|
|
383
|
+
} else {
|
|
384
|
+
this.port.postMessage({
|
|
385
|
+
type: 'version',
|
|
386
|
+
version: 'unknown'
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (data.type === 'getTimeOffset') {
|
|
392
|
+
// Return time offset (NTP seconds when AudioContext was at 0)
|
|
393
|
+
if (this.wasmInstance && this.wasmInstance.exports.get_time_offset) {
|
|
394
|
+
const offset = this.wasmInstance.exports.get_time_offset();
|
|
395
|
+
this.port.postMessage({
|
|
396
|
+
type: 'timeOffset',
|
|
397
|
+
offset: offset
|
|
398
|
+
});
|
|
399
|
+
} else {
|
|
400
|
+
console.error('[AudioWorklet] get_time_offset not available! wasmInstance:', !!this.wasmInstance);
|
|
401
|
+
this.port.postMessage({
|
|
402
|
+
type: 'error',
|
|
403
|
+
error: 'get_time_offset function not available in WASM exports'
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
} catch (error) {
|
|
409
|
+
console.error('[AudioWorklet] Error handling message:', error);
|
|
410
|
+
this.port.postMessage({
|
|
411
|
+
type: 'error',
|
|
412
|
+
error: error.message,
|
|
413
|
+
stack: error.stack
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
process(inputs, outputs, parameters) {
|
|
419
|
+
if (!this.isInitialized) {
|
|
420
|
+
return true;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
try {
|
|
424
|
+
if (this.wasmInstance && this.wasmInstance.exports.process_audio) {
|
|
425
|
+
// CRITICAL: Access AudioContext currentTime correctly
|
|
426
|
+
// In AudioWorkletGlobalScope, currentTime is a bare global variable (not on globalThis)
|
|
427
|
+
// We use a different variable name to avoid shadowing
|
|
428
|
+
const audioContextTime = currentTime; // Access the global currentTime directly
|
|
429
|
+
const unixSeconds = Date.now() / 1000; // Current Unix time in seconds
|
|
430
|
+
const keepAlive = this.wasmInstance.exports.process_audio(audioContextTime, unixSeconds);
|
|
431
|
+
|
|
432
|
+
// Copy scsynth audio output to AudioWorklet outputs
|
|
433
|
+
if (this.wasmInstance.exports.get_audio_output_bus && outputs[0] && outputs[0].length >= 2) {
|
|
434
|
+
try {
|
|
435
|
+
const audioBufferPtr = this.wasmInstance.exports.get_audio_output_bus();
|
|
436
|
+
const numSamples = this.wasmInstance.exports.get_audio_buffer_samples();
|
|
437
|
+
|
|
438
|
+
if (audioBufferPtr && audioBufferPtr > 0) {
|
|
439
|
+
const wasmMemory = this.wasmInstance.exports.memory || this.wasmMemory;
|
|
440
|
+
|
|
441
|
+
if (!wasmMemory || !wasmMemory.buffer) {
|
|
442
|
+
return true;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const currentBuffer = wasmMemory.buffer;
|
|
446
|
+
const bufferSize = currentBuffer.byteLength;
|
|
447
|
+
const requiredBytes = audioBufferPtr + (numSamples * 2 * 4);
|
|
448
|
+
|
|
449
|
+
if (audioBufferPtr < 0 || audioBufferPtr > bufferSize || requiredBytes > bufferSize) {
|
|
450
|
+
return true;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Reuse Float32Array view if possible (avoid allocation in hot path)
|
|
454
|
+
if (!this.audioView ||
|
|
455
|
+
this.lastAudioBufferPtr !== audioBufferPtr ||
|
|
456
|
+
this.lastWasmBufferSize !== bufferSize ||
|
|
457
|
+
currentBuffer !== this.audioView.buffer) {
|
|
458
|
+
this.audioView = new Float32Array(currentBuffer, audioBufferPtr, numSamples * 2);
|
|
459
|
+
this.lastAudioBufferPtr = audioBufferPtr;
|
|
460
|
+
this.lastWasmBufferSize = bufferSize;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Direct copy using pre-allocated view
|
|
464
|
+
outputs[0][0].set(this.audioView.subarray(0, numSamples));
|
|
465
|
+
outputs[0][1].set(this.audioView.subarray(numSamples, numSamples * 2));
|
|
466
|
+
}
|
|
467
|
+
} catch (err) {
|
|
468
|
+
// Silently fail in real-time audio context
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Notify waiting worker only occasionally to reduce overhead
|
|
473
|
+
// Most of the time the worker isn't waiting anyway
|
|
474
|
+
if (this.atomicView && (this.processCallCount % 16 === 0)) {
|
|
475
|
+
Atomics.notify(this.atomicView, this.CONTROL_INDICES.OUT_HEAD, 1);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Periodic status check - reduced frequency
|
|
479
|
+
this.processCallCount++;
|
|
480
|
+
if (this.processCallCount % 3750 === 0) { // Every ~10 seconds instead of 1
|
|
481
|
+
this.checkStatus();
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return keepAlive !== 0;
|
|
485
|
+
}
|
|
486
|
+
} catch (error) {
|
|
487
|
+
if (this.atomicView) {
|
|
488
|
+
Atomics.or(this.atomicView, this.CONTROL_INDICES.STATUS_FLAGS, this.STATUS_FLAGS.WASM_ERROR);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return true;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
checkStatus() {
|
|
496
|
+
if (!this.atomicView) return;
|
|
497
|
+
|
|
498
|
+
const statusFlags = Atomics.load(this.atomicView, this.CONTROL_INDICES.STATUS_FLAGS);
|
|
499
|
+
|
|
500
|
+
if (statusFlags !== this.STATUS_FLAGS.OK) {
|
|
501
|
+
const status = {
|
|
502
|
+
bufferFull: !!(statusFlags & this.STATUS_FLAGS.BUFFER_FULL),
|
|
503
|
+
overrun: !!(statusFlags & this.STATUS_FLAGS.OVERRUN),
|
|
504
|
+
wasmError: !!(statusFlags & this.STATUS_FLAGS.WASM_ERROR),
|
|
505
|
+
fragmented: !!(statusFlags & this.STATUS_FLAGS.FRAGMENTED_MSG)
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
// Get current metrics
|
|
509
|
+
const metrics = {
|
|
510
|
+
processCount: Atomics.load(this.atomicView, this.METRICS_INDICES.PROCESS_COUNT),
|
|
511
|
+
messagesProcessed: Atomics.load(this.atomicView, this.METRICS_INDICES.MESSAGES_PROCESSED),
|
|
512
|
+
messagesDropped: Atomics.load(this.atomicView, this.METRICS_INDICES.MESSAGES_DROPPED),
|
|
513
|
+
bufferOverruns: Atomics.load(this.atomicView, this.METRICS_INDICES.BUFFER_OVERRUNS)
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
this.port.postMessage({
|
|
517
|
+
type: 'status',
|
|
518
|
+
flags: statusFlags,
|
|
519
|
+
status: status,
|
|
520
|
+
metrics: metrics
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
// Clear non-persistent flags
|
|
524
|
+
const persistentFlags = statusFlags & (this.STATUS_FLAGS.BUFFER_FULL);
|
|
525
|
+
Atomics.store(this.atomicView, this.CONTROL_INDICES.STATUS_FLAGS, persistentFlags);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Register the processor
|
|
531
|
+
registerProcessor('scsynth-processor', ScsynthProcessor);
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "supersonic-scsynth",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "SuperCollider scsynth WebAssembly port for AudioWorklet - Run SuperCollider synthesis in the browser",
|
|
5
|
+
"main": "dist/supersonic.js",
|
|
6
|
+
"unpkg": "dist/supersonic.js",
|
|
7
|
+
"jsdelivr": "dist/supersonic.js",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "./build.sh",
|
|
11
|
+
"clean": "./clean.sh",
|
|
12
|
+
"dev": "cd example && ruby server.rb"
|
|
13
|
+
},
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/samaaron/supersonic.git"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"supercollider",
|
|
20
|
+
"scsynth",
|
|
21
|
+
"audio",
|
|
22
|
+
"synthesis",
|
|
23
|
+
"webassembly",
|
|
24
|
+
"audioworklet",
|
|
25
|
+
"wasm",
|
|
26
|
+
"music",
|
|
27
|
+
"sound"
|
|
28
|
+
],
|
|
29
|
+
"author": "Sam Aaron",
|
|
30
|
+
"license": "GPL-3.0-or-later",
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/samaaron/supersonic/issues"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://github.com/samaaron/supersonic#readme",
|
|
35
|
+
"files": [
|
|
36
|
+
"dist/supersonic.js",
|
|
37
|
+
"dist/wasm/",
|
|
38
|
+
"dist/workers/",
|
|
39
|
+
"README.md",
|
|
40
|
+
"LICENSE"
|
|
41
|
+
],
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@thi.ng/malloc": "^6.1.128"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"esbuild": "^0.24.0"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=18.0.0"
|
|
50
|
+
}
|
|
51
|
+
}
|