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.
@@ -0,0 +1,519 @@
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
+ * OSC OUT Worker - Scheduler for sending OSC bundles to scsynth
11
+ * Handles timed bundles with priority queue scheduling
12
+ * Writes directly to SharedArrayBuffer ring buffer
13
+ * ES5-compatible for Qt WebEngine
14
+ */
15
+
16
+ // Ring buffer configuration
17
+ var sharedBuffer = null;
18
+ var ringBufferBase = null;
19
+ var atomicView = null;
20
+ var dataView = null;
21
+ var uint8View = null;
22
+
23
+ // Ring buffer layout constants (loaded from WASM at initialization)
24
+ var bufferConstants = null;
25
+
26
+ // Control indices (calculated after init)
27
+ var CONTROL_INDICES = {};
28
+
29
+ // Scheduling state
30
+ var scheduledEvents = [];
31
+ var currentTimer = null;
32
+ var cachedTimeDelta = null;
33
+ var minimumScheduleRequirementS = 0.002; // 2ms for audio precision
34
+ var latencyS = 0.05; // 50ms latency compensation for scsynth
35
+
36
+ // Message queue for handling backpressure
37
+ var immediateQueue = []; // Queue of messages waiting to be written
38
+ var isWriting = false; // Flag to prevent concurrent write attempts
39
+ var writeRetryTimer = null;
40
+
41
+ // Statistics
42
+ var stats = {
43
+ bundlesScheduled: 0,
44
+ bundlesWritten: 0,
45
+ bundlesDropped: 0,
46
+ bufferOverruns: 0,
47
+ retries: 0,
48
+ queueDepth: 0,
49
+ maxQueueDepth: 0
50
+ };
51
+
52
+ /**
53
+ * Initialize ring buffer access
54
+ */
55
+ function initRingBuffer(buffer, base, constants) {
56
+ sharedBuffer = buffer;
57
+ ringBufferBase = base;
58
+ bufferConstants = constants;
59
+ atomicView = new Int32Array(sharedBuffer);
60
+ dataView = new DataView(sharedBuffer);
61
+ uint8View = new Uint8Array(sharedBuffer);
62
+
63
+ // Calculate control indices using constants from WASM
64
+ CONTROL_INDICES = {
65
+ IN_HEAD: (ringBufferBase + bufferConstants.CONTROL_START + 0) / 4,
66
+ IN_TAIL: (ringBufferBase + bufferConstants.CONTROL_START + 4) / 4
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Queue a message for writing (handles backpressure)
72
+ */
73
+ function queueMessage(oscMessage) {
74
+ immediateQueue.push(oscMessage);
75
+ stats.queueDepth = immediateQueue.length;
76
+
77
+ if (stats.queueDepth > stats.maxQueueDepth) {
78
+ stats.maxQueueDepth = stats.queueDepth;
79
+ }
80
+
81
+ // Start processing if not already running
82
+ if (!isWriting) {
83
+ processQueue();
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Process the message queue - blocks until space is available
89
+ */
90
+ function processQueue() {
91
+ if (isWriting || immediateQueue.length === 0) {
92
+ return;
93
+ }
94
+
95
+ isWriting = true;
96
+
97
+ function processNext() {
98
+ if (immediateQueue.length === 0) {
99
+ isWriting = false;
100
+ stats.queueDepth = 0;
101
+ return;
102
+ }
103
+
104
+ var message = immediateQueue[0]; // Peek at first message
105
+
106
+ // Block until there's space, then write
107
+ var success = writeToRingBufferBlocking(message);
108
+
109
+ if (success) {
110
+ // Success! Remove from queue
111
+ immediateQueue.shift();
112
+ stats.queueDepth = immediateQueue.length;
113
+
114
+ // Process next message
115
+ if (immediateQueue.length > 0) {
116
+ setTimeout(processNext, 0);
117
+ } else {
118
+ isWriting = false;
119
+ }
120
+ } else {
121
+ // Fatal error (message too large or not initialized)
122
+ console.error('[OSCOutWorker] Fatal error, dropping message');
123
+ immediateQueue.shift(); // Remove bad message
124
+ stats.bundlesDropped++;
125
+ stats.queueDepth = immediateQueue.length;
126
+
127
+ // Continue with next message
128
+ setTimeout(processNext, 0);
129
+ }
130
+ }
131
+
132
+ processNext();
133
+ }
134
+
135
+ /**
136
+ * Write OSC message to ring buffer - blocks until space available
137
+ * Returns true on success, false on fatal error (message too large)
138
+ */
139
+ function writeToRingBufferBlocking(oscMessage) {
140
+ if (!sharedBuffer) {
141
+ console.error('[OSCOutWorker] Not initialized');
142
+ return false;
143
+ }
144
+
145
+ var payloadSize = oscMessage.length;
146
+ var totalSize = bufferConstants.MESSAGE_HEADER_SIZE + payloadSize;
147
+
148
+ // Check if message fits in buffer at all (account for padding at wrap)
149
+ if (totalSize > bufferConstants.IN_BUFFER_SIZE - bufferConstants.MESSAGE_HEADER_SIZE) {
150
+ console.error('[OSCOutWorker] Message too large:', totalSize, 'max:', bufferConstants.IN_BUFFER_SIZE - bufferConstants.MESSAGE_HEADER_SIZE);
151
+ return false;
152
+ }
153
+
154
+ // Keep trying until we have space
155
+ while (true) {
156
+ var head = Atomics.load(atomicView, CONTROL_INDICES.IN_HEAD);
157
+ var tail = Atomics.load(atomicView, CONTROL_INDICES.IN_TAIL);
158
+
159
+ // Check available space
160
+ var available = (bufferConstants.IN_BUFFER_SIZE - 1 - head + tail) % bufferConstants.IN_BUFFER_SIZE;
161
+
162
+ if (available >= totalSize) {
163
+ // Check if message fits contiguously, otherwise write padding and wrap
164
+ var spaceToEnd = bufferConstants.IN_BUFFER_SIZE - head;
165
+
166
+ if (totalSize > spaceToEnd) {
167
+ // Message won't fit at end - write padding marker and wrap to beginning
168
+ var paddingPos = ringBufferBase + bufferConstants.IN_BUFFER_START + head;
169
+ dataView.setUint32(paddingPos, bufferConstants.PADDING_MAGIC, true);
170
+ dataView.setUint32(paddingPos + 4, 0, true);
171
+ dataView.setUint32(paddingPos + 8, 0, true);
172
+ dataView.setUint32(paddingPos + 12, 0, true);
173
+
174
+ // Wrap head to beginning
175
+ head = 0;
176
+ }
177
+
178
+ // We have space! Write the message (now guaranteed contiguous)
179
+ var writePos = ringBufferBase + bufferConstants.IN_BUFFER_START + head;
180
+
181
+ // Write message header
182
+ dataView.setUint32(writePos, bufferConstants.MESSAGE_MAGIC, true);
183
+ dataView.setUint32(writePos + 4, totalSize, true);
184
+ dataView.setUint32(writePos + 8, stats.bundlesWritten, true); // sequence
185
+ dataView.setUint32(writePos + 12, 0, true); // padding
186
+
187
+ // Write payload
188
+ uint8View.set(oscMessage, writePos + bufferConstants.MESSAGE_HEADER_SIZE);
189
+
190
+ // Update head pointer (publish message)
191
+ var newHead = (head + totalSize) % bufferConstants.IN_BUFFER_SIZE;
192
+ Atomics.store(atomicView, CONTROL_INDICES.IN_HEAD, newHead);
193
+
194
+ stats.bundlesWritten++;
195
+ return true;
196
+ }
197
+
198
+ // Buffer is full - wait for tail to move (scsynth to consume)
199
+ stats.bufferOverruns++;
200
+
201
+ // Wait on the tail pointer - will wake when scsynth consumes data
202
+ // Timeout after 100ms to check if worker should stop
203
+ var result = Atomics.wait(atomicView, CONTROL_INDICES.IN_TAIL, tail, 100);
204
+
205
+ if (result === 'ok' || result === 'not-equal') {
206
+ // Tail moved! Loop will retry
207
+ stats.retries++;
208
+ }
209
+ // On timeout, loop continues to retry (allows checking for stop signal)
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Get or set cached time delta for synchronization
215
+ */
216
+ function getOrSetTimeDelta(delta) {
217
+ if (cachedTimeDelta === null) {
218
+ cachedTimeDelta = delta;
219
+ }
220
+ return cachedTimeDelta;
221
+ }
222
+
223
+ /**
224
+ * Check if data is an OSC bundle (starts with "#bundle\0")
225
+ */
226
+ function isBundle(data) {
227
+ if (data.length < 16) return false;
228
+ var bundleTag = String.fromCharCode.apply(null, data.slice(0, 8));
229
+ return bundleTag === '#bundle\0';
230
+ }
231
+
232
+ /**
233
+ * Parse OSC bundle timestamp from binary data
234
+ * OSC bundles start with "#bundle\0" followed by 8-byte NTP timestamp
235
+ */
236
+ function parseBundleTimestamp(data) {
237
+ if (!isBundle(data)) return null;
238
+
239
+ // Read NTP timestamp (8 bytes, big-endian)
240
+ var view = new DataView(data.buffer, data.byteOffset + 8, 8);
241
+ var seconds = view.getUint32(0, false); // NTP seconds since 1900
242
+ var fraction = view.getUint32(4, false); // NTP fractional seconds
243
+
244
+ // Convert NTP to JavaScript time
245
+ // NTP epoch is 1900, JS epoch is 1970 (difference: 2208988800 seconds)
246
+ var NTP_TO_UNIX = 2208988800;
247
+
248
+ // Special OSC timestamps
249
+ if (seconds === 0 && fraction === 1) {
250
+ return 0; // Immediate execution
251
+ }
252
+
253
+ // Convert to JavaScript timestamp (milliseconds since 1970)
254
+ var unixSeconds = seconds - NTP_TO_UNIX;
255
+ var milliseconds = (fraction / 4294967296) * 1000; // Convert fraction to ms
256
+
257
+ return (unixSeconds * 1000) + milliseconds;
258
+ }
259
+
260
+ /**
261
+ * Extract OSC messages from a bundle
262
+ * Returns array of message buffers
263
+ */
264
+ function extractMessagesFromBundle(data) {
265
+ var messages = [];
266
+
267
+ if (!isBundle(data)) {
268
+ // Not a bundle, return as single message
269
+ return [data];
270
+ }
271
+
272
+ // Skip "#bundle\0" (8 bytes) and timestamp (8 bytes)
273
+ var offset = 16;
274
+
275
+ while (offset < data.length) {
276
+ // Read message size (4 bytes, big-endian)
277
+ if (offset + 4 > data.length) break;
278
+
279
+ var view = new DataView(data.buffer, data.byteOffset + offset, 4);
280
+ var messageSize = view.getInt32(0, false);
281
+ offset += 4;
282
+
283
+ if (messageSize <= 0 || offset + messageSize > data.length) break;
284
+
285
+ // Extract message data
286
+ var messageData = data.slice(offset, offset + messageSize);
287
+
288
+ // Check if this is a nested bundle
289
+ if (isBundle(messageData)) {
290
+ // Recursively extract from nested bundle
291
+ var nestedMessages = extractMessagesFromBundle(messageData);
292
+ messages = messages.concat(nestedMessages);
293
+ } else {
294
+ // It's a message, add it
295
+ messages.push(messageData);
296
+ }
297
+
298
+ offset += messageSize;
299
+
300
+ // Align to 4-byte boundary if needed
301
+ while (offset % 4 !== 0 && offset < data.length) {
302
+ offset++;
303
+ }
304
+ }
305
+
306
+ return messages;
307
+ }
308
+
309
+ /**
310
+ * Process incoming OSC data (message or bundle)
311
+ * Pre-scheduler: waits for calculated time then sends to ring buffer
312
+ * waitTimeMs is calculated by SuperSonic based on AudioContext time
313
+ */
314
+ function processOSC(oscData, editorId, runTag, waitTimeMs) {
315
+ stats.bundlesScheduled++;
316
+
317
+ // If no wait time provided, or wait time is 0 or negative, send immediately
318
+ if (waitTimeMs === null || waitTimeMs === undefined || waitTimeMs <= 0) {
319
+ queueMessage(oscData);
320
+ return;
321
+ }
322
+
323
+ // Schedule to send after waitTimeMs
324
+ setTimeout(function() {
325
+ queueMessage(oscData);
326
+ }, waitTimeMs);
327
+ }
328
+
329
+ /**
330
+ * Process immediate send - forces immediate execution by unpacking bundles
331
+ * Bundles are unpacked to individual messages (stripping timestamps)
332
+ * Messages are sent as-is
333
+ * Used when the caller wants immediate execution without scheduling
334
+ */
335
+ function processImmediate(oscData) {
336
+ if (isBundle(oscData)) {
337
+ // Extract all messages from the bundle (removes timestamp wrapper)
338
+ // Send each message individually for immediate execution
339
+ var messages = extractMessagesFromBundle(oscData);
340
+ for (var i = 0; i < messages.length; i++) {
341
+ queueMessage(messages[i]);
342
+ }
343
+ } else {
344
+ // Regular message - send as-is
345
+ queueMessage(oscData);
346
+ }
347
+ }
348
+
349
+ /**
350
+ * Insert event into priority queue
351
+ */
352
+ function insertEvent(userId, editorId, runId, runTag, adjustedTimeS, oscBundle) {
353
+ var info = { userId: userId, editorId: editorId, runTag: runTag, runId: runId };
354
+ scheduledEvents.push([adjustedTimeS, info, oscBundle]);
355
+ scheduledEvents.sort(function(a, b) { return a[0] - b[0]; });
356
+ scheduleNextEvent();
357
+ }
358
+
359
+ /**
360
+ * Schedule the next event timer
361
+ */
362
+ function scheduleNextEvent() {
363
+ if (scheduledEvents.length === 0) {
364
+ clearCurrentTimer();
365
+ return;
366
+ }
367
+
368
+ var nextEvent = scheduledEvents[0];
369
+ var adjustedTimeS = nextEvent[0];
370
+
371
+ if (!currentTimer || (currentTimer && currentTimer.timeS > adjustedTimeS)) {
372
+ addRunNextEventTimer(adjustedTimeS);
373
+ }
374
+ }
375
+
376
+ /**
377
+ * Clear current timer
378
+ */
379
+ function clearCurrentTimer() {
380
+ if (currentTimer) {
381
+ clearTimeout(currentTimer.timerId);
382
+ currentTimer = null;
383
+ }
384
+ }
385
+
386
+ /**
387
+ * Add timer for next event
388
+ */
389
+ function addRunNextEventTimer(adjustedTimeS) {
390
+ clearCurrentTimer();
391
+
392
+ var nowS = Date.now() / 1000;
393
+ var timeDeltaS = adjustedTimeS - nowS;
394
+
395
+ if (timeDeltaS <= minimumScheduleRequirementS) {
396
+ runNextEvent();
397
+ } else {
398
+ var delayMs = (timeDeltaS - minimumScheduleRequirementS) * 1000;
399
+ currentTimer = {
400
+ timeS: adjustedTimeS,
401
+ timerId: setTimeout(function() {
402
+ currentTimer = null;
403
+ runNextEvent();
404
+ }, delayMs)
405
+ };
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Run the next scheduled event
411
+ */
412
+ function runNextEvent() {
413
+ clearCurrentTimer();
414
+
415
+ if (scheduledEvents.length === 0) {
416
+ return;
417
+ }
418
+
419
+ var event = scheduledEvents.shift();
420
+ var data = event[2];
421
+
422
+ // Send the complete bundle unchanged (with original timestamp)
423
+ queueMessage(data);
424
+
425
+ scheduleNextEvent();
426
+ }
427
+
428
+ /**
429
+ * Cancel events by editor and tag
430
+ */
431
+ function cancelEditorTag(editorId, runTag) {
432
+ scheduledEvents = scheduledEvents.filter(function(e) {
433
+ return e[1].runTag !== runTag || e[1].editorId !== editorId;
434
+ });
435
+ scheduleNextEvent();
436
+ }
437
+
438
+ /**
439
+ * Cancel all events from an editor
440
+ */
441
+ function cancelEditor(editorId) {
442
+ scheduledEvents = scheduledEvents.filter(function(e) {
443
+ return e[1].editorId !== editorId;
444
+ });
445
+ scheduleNextEvent();
446
+ }
447
+
448
+ /**
449
+ * Cancel all scheduled events
450
+ */
451
+ function cancelAllTags() {
452
+ scheduledEvents = [];
453
+ clearCurrentTimer();
454
+ }
455
+
456
+ /**
457
+ * Reset time delta for resync
458
+ */
459
+ function resetTimeDelta() {
460
+ cachedTimeDelta = null;
461
+ }
462
+
463
+ /**
464
+ * Handle messages from main thread
465
+ */
466
+ self.onmessage = function(event) {
467
+ var data = event.data;
468
+
469
+ try {
470
+ switch (data.type) {
471
+ case 'init':
472
+ initRingBuffer(data.sharedBuffer, data.ringBufferBase, data.bufferConstants);
473
+ self.postMessage({ type: 'initialized' });
474
+ break;
475
+
476
+ case 'send':
477
+ // Single send method for both messages and bundles
478
+ // waitTimeMs is calculated by SuperSonic based on AudioContext time
479
+ processOSC(data.oscData, data.editorId, data.runTag, data.waitTimeMs);
480
+ break;
481
+
482
+ case 'sendImmediate':
483
+ // Force immediate send, extracting all messages from bundles
484
+ // Ignores timestamps - for apps that don't expect scheduling
485
+ processImmediate(data.oscData);
486
+ break;
487
+
488
+ case 'cancelEditorTag':
489
+ cancelEditorTag(data.editorId, data.runTag);
490
+ break;
491
+
492
+ case 'cancelEditor':
493
+ cancelEditor(data.editorId);
494
+ break;
495
+
496
+ case 'cancelAll':
497
+ cancelAllTags();
498
+ break;
499
+
500
+ case 'getStats':
501
+ self.postMessage({
502
+ type: 'stats',
503
+ stats: stats
504
+ });
505
+ break;
506
+
507
+ default:
508
+ console.warn('[OSCOutWorker] Unknown message type:', data.type);
509
+ }
510
+ } catch (error) {
511
+ console.error('[OSCOutWorker] Error:', error);
512
+ self.postMessage({
513
+ type: 'error',
514
+ error: error.message
515
+ });
516
+ }
517
+ };
518
+
519
+ console.log('[OSCOutWorker] Script loaded');