supersonic-scsynth 0.2.2 → 0.3.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/README.md +55 -14
- package/dist/supersonic.js +20 -14
- package/dist/wasm/manifest.json +3 -3
- package/dist/wasm/scsynth-nrt.wasm +0 -0
- package/dist/workers/osc_out_prescheduler_worker.js +132 -12
- package/package.json +1 -1
- package/dist/workers/osc_out_worker.js +0 -382
- package/dist/workers/ring_buffer_worker_base.js +0 -305
- package/dist/workers/system_worker.js +0 -64
package/README.md
CHANGED
|
@@ -8,11 +8,13 @@ A WebAssembly port of SuperCollider's scsynth audio synthesis engine for the bro
|
|
|
8
8
|
|
|
9
9
|
```html
|
|
10
10
|
<script type="module">
|
|
11
|
-
import { SuperSonic } from '
|
|
11
|
+
import { SuperSonic } from './dist/supersonic.js';
|
|
12
12
|
|
|
13
13
|
const sonic = new SuperSonic({
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
workerBaseURL: './dist/workers/',
|
|
15
|
+
wasmBaseURL: './dist/wasm/',
|
|
16
|
+
sampleBaseURL: './dist/samples/',
|
|
17
|
+
synthdefBaseURL: './dist/synthdefs/'
|
|
16
18
|
});
|
|
17
19
|
|
|
18
20
|
await sonic.init();
|
|
@@ -29,11 +31,11 @@ A WebAssembly port of SuperCollider's scsynth audio synthesis engine for the bro
|
|
|
29
31
|
</script>
|
|
30
32
|
```
|
|
31
33
|
|
|
32
|
-
**
|
|
34
|
+
**Important:** SuperSonic requires self-hosting (cannot load from CDN). See [CDN Usage](#cdn-usage) below.
|
|
33
35
|
|
|
34
36
|
## Installation
|
|
35
37
|
|
|
36
|
-
**Via npm:**
|
|
38
|
+
**Via npm (for local bundling):**
|
|
37
39
|
```bash
|
|
38
40
|
# Core engine only (~450KB)
|
|
39
41
|
npm install supersonic-scsynth
|
|
@@ -42,15 +44,15 @@ npm install supersonic-scsynth
|
|
|
42
44
|
npm install supersonic-scsynth-bundle
|
|
43
45
|
```
|
|
44
46
|
|
|
45
|
-
**
|
|
47
|
+
**Pre-built distribution (recommended):**
|
|
48
|
+
Download the pre-built package (~35MB with all synthdefs and samples) and serve from your own domain:
|
|
49
|
+
https://samaaron.github.io/supersonic/supersonic-dist.zip
|
|
50
|
+
|
|
51
|
+
Extract to your web server and import as:
|
|
46
52
|
```javascript
|
|
47
|
-
import { SuperSonic } from '
|
|
53
|
+
import { SuperSonic } from './dist/supersonic.js';
|
|
48
54
|
```
|
|
49
55
|
|
|
50
|
-
**Pre-built distribution:**
|
|
51
|
-
Download the 'nightly' build (~35MB with all synthdefs and samples):
|
|
52
|
-
https://samaaron.github.io/supersonic/supersonic-dist.zip
|
|
53
|
-
|
|
54
56
|
## Packages
|
|
55
57
|
|
|
56
58
|
SuperSonic is split into multiple packages:
|
|
@@ -69,8 +71,10 @@ All synthdefs and samples are from [Sonic Pi](https://github.com/sonic-pi-net/so
|
|
|
69
71
|
**Creating an instance:**
|
|
70
72
|
```javascript
|
|
71
73
|
const sonic = new SuperSonic({
|
|
72
|
-
|
|
73
|
-
|
|
74
|
+
workerBaseURL: './dist/workers/', // Required: Path to worker files
|
|
75
|
+
wasmBaseURL: './dist/wasm/', // Required: Path to WASM files
|
|
76
|
+
sampleBaseURL: './dist/samples/', // Optional: Path to audio samples
|
|
77
|
+
synthdefBaseURL: './dist/synthdefs/', // Optional: Path to synthdefs
|
|
74
78
|
audioPathMap: { /* optional custom path mappings */ }
|
|
75
79
|
});
|
|
76
80
|
```
|
|
@@ -121,6 +125,43 @@ Cross-Origin-Resource-Policy: cross-origin
|
|
|
121
125
|
|
|
122
126
|
See `example/server.rb` for a reference implementation.
|
|
123
127
|
|
|
128
|
+
## CDN Usage
|
|
129
|
+
|
|
130
|
+
SuperSonic cannot be loaded from a CDN. The core library must be self-hosted on your domain.
|
|
131
|
+
|
|
132
|
+
### Why Self-Hosting is Required
|
|
133
|
+
|
|
134
|
+
SuperSonic uses `SharedArrayBuffer` for real-time audio performance. Browsers require workers that use `SharedArrayBuffer` to come from the same origin as the page. Even with proper COOP/COEP headers, cross-origin workers with shared memory are blocked. This is a fundamental browser security requirement stemming from Spectre attack mitigation.
|
|
135
|
+
|
|
136
|
+
What this means:
|
|
137
|
+
- You cannot use `import { SuperSonic } from 'https://unpkg.com/supersonic/...'`
|
|
138
|
+
- You must download and self-host the core library on your own domain
|
|
139
|
+
- The npm packages exist for convenience but must be bundled and deployed to your server
|
|
140
|
+
|
|
141
|
+
### Synthdefs and Samples Can Use CDN
|
|
142
|
+
|
|
143
|
+
Pre-compiled synthdefs and audio samples can be loaded from CDNs. They're just data files, not workers.
|
|
144
|
+
|
|
145
|
+
```javascript
|
|
146
|
+
// Self-hosted core library
|
|
147
|
+
import { SuperSonic } from './dist/supersonic.js';
|
|
148
|
+
|
|
149
|
+
// CDN-hosted synthdefs and samples work fine
|
|
150
|
+
const sonic = new SuperSonic({
|
|
151
|
+
workerBaseURL: './dist/workers/', // Must be self-hosted
|
|
152
|
+
wasmBaseURL: './dist/wasm/', // Must be self-hosted
|
|
153
|
+
sampleBaseURL: 'https://unpkg.com/supersonic-scsynth-samples@0.1.6/samples/',
|
|
154
|
+
synthdefBaseURL: 'https://unpkg.com/supersonic-scsynth-synthdefs@0.1.6/synthdefs/'
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await sonic.init();
|
|
158
|
+
await sonic.loadSynthDefs(['sonic-pi-beep', 'sonic-pi-tb303']);
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Hybrid Approach
|
|
162
|
+
|
|
163
|
+
Self-host the SuperSonic core (JS, WASM, workers) with COOP/COEP headers. Use CDN for synthdefs and samples to save bandwidth. See `example/simple-cdn.html` for a working example.
|
|
164
|
+
|
|
124
165
|
## Building from Source
|
|
125
166
|
|
|
126
167
|
**Prerequisites:**
|
|
@@ -167,7 +208,7 @@ dist/
|
|
|
167
208
|
└── debug_worker.js # Debug logger
|
|
168
209
|
```
|
|
169
210
|
|
|
170
|
-
|
|
211
|
+
You must specify the paths to `workers/` and `wasm/` directories when creating a SuperSonic instance using the `workerBaseURL` and `wasmBaseURL` options.
|
|
171
212
|
|
|
172
213
|
## License
|
|
173
214
|
|
package/dist/supersonic.js
CHANGED
|
@@ -766,7 +766,8 @@ var { readPacket, writePacket, readMessage, writeMessage, readBundle, writeBundl
|
|
|
766
766
|
|
|
767
767
|
// js/lib/scsynth_osc.js
|
|
768
768
|
var ScsynthOSC = class {
|
|
769
|
-
constructor() {
|
|
769
|
+
constructor(workerBaseURL = null) {
|
|
770
|
+
this.workerBaseURL = workerBaseURL;
|
|
770
771
|
this.workers = {
|
|
771
772
|
oscOut: null,
|
|
772
773
|
// Scheduler worker (now also writes directly to ring buffer)
|
|
@@ -799,9 +800,9 @@ var ScsynthOSC = class {
|
|
|
799
800
|
this.ringBufferBase = ringBufferBase;
|
|
800
801
|
this.bufferConstants = bufferConstants;
|
|
801
802
|
try {
|
|
802
|
-
this.workers.oscOut = new Worker(
|
|
803
|
-
this.workers.oscIn = new Worker(
|
|
804
|
-
this.workers.debug = new Worker(
|
|
803
|
+
this.workers.oscOut = new Worker(this.workerBaseURL + "osc_out_prescheduler_worker.js", { type: "module" });
|
|
804
|
+
this.workers.oscIn = new Worker(this.workerBaseURL + "osc_in_worker.js", { type: "module" });
|
|
805
|
+
this.workers.debug = new Worker(this.workerBaseURL + "debug_worker.js", { type: "module" });
|
|
805
806
|
this.setupWorkerHandlers();
|
|
806
807
|
const initPromises = [
|
|
807
808
|
this.initWorker(this.workers.oscOut, "OSC SCHEDULER+WRITER"),
|
|
@@ -1916,12 +1917,16 @@ var SuperSonic = class _SuperSonic {
|
|
|
1916
1917
|
this.onDebugMessage = null;
|
|
1917
1918
|
this.onInitialized = null;
|
|
1918
1919
|
this.onError = null;
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1920
|
+
if (!options.workerBaseURL || !options.wasmBaseURL) {
|
|
1921
|
+
throw new Error('SuperSonic requires workerBaseURL and wasmBaseURL options. Example:\nnew SuperSonic({\n workerBaseURL: "/supersonic/workers/",\n wasmBaseURL: "/supersonic/wasm/"\n})');
|
|
1922
|
+
}
|
|
1923
|
+
const workerBaseURL = options.workerBaseURL;
|
|
1924
|
+
const wasmBaseURL = options.wasmBaseURL;
|
|
1922
1925
|
this.config = {
|
|
1923
|
-
wasmUrl:
|
|
1924
|
-
workletUrl:
|
|
1926
|
+
wasmUrl: options.wasmUrl || wasmBaseURL + "scsynth-nrt.wasm",
|
|
1927
|
+
workletUrl: options.workletUrl || workerBaseURL + "scsynth_audio_worklet.js",
|
|
1928
|
+
workerBaseURL,
|
|
1929
|
+
// Store for worker creation
|
|
1925
1930
|
development: false,
|
|
1926
1931
|
audioContextOptions: {
|
|
1927
1932
|
latencyHint: "interactive",
|
|
@@ -2031,12 +2036,13 @@ var SuperSonic = class _SuperSonic {
|
|
|
2031
2036
|
*/
|
|
2032
2037
|
async #loadWasmManifest() {
|
|
2033
2038
|
try {
|
|
2034
|
-
const
|
|
2039
|
+
const wasmBaseURL = this.config.workerBaseURL.replace("/workers/", "/wasm/");
|
|
2040
|
+
const manifestUrl = wasmBaseURL + "manifest.json";
|
|
2035
2041
|
const response = await fetch(manifestUrl);
|
|
2036
2042
|
if (response.ok) {
|
|
2037
2043
|
const manifest = await response.json();
|
|
2038
2044
|
const wasmFile = manifest.wasmFile;
|
|
2039
|
-
this.config.wasmUrl =
|
|
2045
|
+
this.config.wasmUrl = wasmBaseURL + wasmFile;
|
|
2040
2046
|
console.log(`[SuperSonic] Using WASM build: ${wasmFile}`);
|
|
2041
2047
|
console.log(`[SuperSonic] Build: ${manifest.buildId} (git: ${manifest.gitHash})`);
|
|
2042
2048
|
}
|
|
@@ -2081,7 +2087,7 @@ var SuperSonic = class _SuperSonic {
|
|
|
2081
2087
|
* Initialize OSC communication layer
|
|
2082
2088
|
*/
|
|
2083
2089
|
async #initializeOSC() {
|
|
2084
|
-
this.osc = new ScsynthOSC();
|
|
2090
|
+
this.osc = new ScsynthOSC(this.config.workerBaseURL);
|
|
2085
2091
|
this.osc.onRawOSC((msg) => {
|
|
2086
2092
|
if (this.onOSC) {
|
|
2087
2093
|
this.onOSC(msg);
|
|
@@ -2324,7 +2330,7 @@ var SuperSonic = class _SuperSonic {
|
|
|
2324
2330
|
}
|
|
2325
2331
|
if (!this.sampleBaseURL) {
|
|
2326
2332
|
throw new Error(
|
|
2327
|
-
'sampleBaseURL not configured. Please set it in SuperSonic constructor options.\nExample: new SuperSonic({ sampleBaseURL: "https://unpkg.com/supersonic-scsynth-samples@latest/samples/" })\nOr install
|
|
2333
|
+
'sampleBaseURL not configured. Please set it in SuperSonic constructor options.\nExample: new SuperSonic({ sampleBaseURL: "./dist/samples/" })\nOr use CDN: new SuperSonic({ sampleBaseURL: "https://unpkg.com/supersonic-scsynth-samples@latest/samples/" })\nOr install: npm install supersonic-scsynth-samples'
|
|
2328
2334
|
);
|
|
2329
2335
|
}
|
|
2330
2336
|
return this.sampleBaseURL + scPath;
|
|
@@ -2525,7 +2531,7 @@ var SuperSonic = class _SuperSonic {
|
|
|
2525
2531
|
}
|
|
2526
2532
|
if (!this.synthdefBaseURL) {
|
|
2527
2533
|
throw new Error(
|
|
2528
|
-
'synthdefBaseURL not configured. Please set it in SuperSonic constructor options.\nExample: new SuperSonic({ synthdefBaseURL: "https://unpkg.com/supersonic-scsynth-synthdefs@latest/synthdefs/" })\nOr install: npm install supersonic-scsynth-synthdefs'
|
|
2534
|
+
'synthdefBaseURL not configured. Please set it in SuperSonic constructor options.\nExample: new SuperSonic({ synthdefBaseURL: "./dist/synthdefs/" })\nOr use CDN: new SuperSonic({ synthdefBaseURL: "https://unpkg.com/supersonic-scsynth-synthdefs@latest/synthdefs/" })\nOr install: npm install supersonic-scsynth-synthdefs'
|
|
2529
2535
|
);
|
|
2530
2536
|
}
|
|
2531
2537
|
const results = {};
|
package/dist/wasm/manifest.json
CHANGED
|
Binary file
|
|
@@ -24,6 +24,11 @@ var periodicTimer = null; // Single periodic timer (25ms interval)
|
|
|
24
24
|
var sequenceCounter = 0;
|
|
25
25
|
var isDispatching = false; // Prevent reentrancy into dispatch loop
|
|
26
26
|
|
|
27
|
+
// Retry queue for failed writes
|
|
28
|
+
var retryQueue = [];
|
|
29
|
+
var MAX_RETRY_QUEUE_SIZE = 100;
|
|
30
|
+
var MAX_RETRIES_PER_MESSAGE = 5;
|
|
31
|
+
|
|
27
32
|
// Statistics
|
|
28
33
|
var stats = {
|
|
29
34
|
bundlesScheduled: 0,
|
|
@@ -38,7 +43,12 @@ var stats = {
|
|
|
38
43
|
maxLateDispatchMs: 0,
|
|
39
44
|
totalSendTasks: 0,
|
|
40
45
|
totalSendProcessMs: 0,
|
|
41
|
-
maxSendProcessMs: 0
|
|
46
|
+
maxSendProcessMs: 0,
|
|
47
|
+
messagesRetried: 0,
|
|
48
|
+
retriesSucceeded: 0,
|
|
49
|
+
retriesFailed: 0,
|
|
50
|
+
retryQueueSize: 0,
|
|
51
|
+
maxRetryQueueSize: 0
|
|
42
52
|
};
|
|
43
53
|
|
|
44
54
|
// Timing constants
|
|
@@ -123,8 +133,9 @@ function initSharedBuffer() {
|
|
|
123
133
|
/**
|
|
124
134
|
* Write OSC message directly to ring buffer (replaces MessagePort to writer worker)
|
|
125
135
|
* This is now the ONLY place that writes to the ring buffer
|
|
136
|
+
* Returns true if successful, false if failed (caller should queue for retry)
|
|
126
137
|
*/
|
|
127
|
-
function writeToRingBuffer(oscMessage) {
|
|
138
|
+
function writeToRingBuffer(oscMessage, isRetry) {
|
|
128
139
|
if (!sharedBuffer || !atomicView) {
|
|
129
140
|
console.error('[PreScheduler] Not initialized for ring buffer writing');
|
|
130
141
|
stats.bundlesDropped++;
|
|
@@ -149,10 +160,14 @@ function writeToRingBuffer(oscMessage) {
|
|
|
149
160
|
var available = (bufferConstants.IN_BUFFER_SIZE - 1 - head + tail) % bufferConstants.IN_BUFFER_SIZE;
|
|
150
161
|
|
|
151
162
|
if (available < totalSize) {
|
|
152
|
-
// Buffer full -
|
|
163
|
+
// Buffer full - return false so caller can queue for retry
|
|
153
164
|
stats.bufferOverruns++;
|
|
154
|
-
|
|
155
|
-
|
|
165
|
+
if (!isRetry) {
|
|
166
|
+
// Only increment bundlesDropped on initial attempt
|
|
167
|
+
// Retries increment different counters
|
|
168
|
+
stats.bundlesDropped++;
|
|
169
|
+
console.warn('[PreScheduler] Ring buffer full, message will be queued for retry');
|
|
170
|
+
}
|
|
156
171
|
return false;
|
|
157
172
|
}
|
|
158
173
|
|
|
@@ -191,6 +206,17 @@ function writeToRingBuffer(oscMessage) {
|
|
|
191
206
|
// Write payload
|
|
192
207
|
uint8View.set(oscMessage, writePos + bufferConstants.MESSAGE_HEADER_SIZE);
|
|
193
208
|
|
|
209
|
+
// Diagnostic: Log first few writes
|
|
210
|
+
if (stats.bundlesWritten < 5) {
|
|
211
|
+
schedulerLog('[PreScheduler] Write:', 'seq=' + stats.bundlesWritten,
|
|
212
|
+
'pos=' + head, 'size=' + totalSize, 'newHead=' + ((head + totalSize) % bufferConstants.IN_BUFFER_SIZE));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// CRITICAL: Ensure memory barrier before publishing head pointer
|
|
216
|
+
// All previous writes (header + payload) must be visible to C++ reader
|
|
217
|
+
// Atomics.load provides necessary memory fence/barrier
|
|
218
|
+
Atomics.load(atomicView, CONTROL_INDICES.IN_HEAD);
|
|
219
|
+
|
|
194
220
|
// Update head pointer (publish message)
|
|
195
221
|
var newHead = (head + totalSize) % bufferConstants.IN_BUFFER_SIZE;
|
|
196
222
|
Atomics.store(atomicView, CONTROL_INDICES.IN_HEAD, newHead);
|
|
@@ -199,6 +225,77 @@ function writeToRingBuffer(oscMessage) {
|
|
|
199
225
|
return true;
|
|
200
226
|
}
|
|
201
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Add a message to the retry queue
|
|
230
|
+
*/
|
|
231
|
+
function queueForRetry(oscData, context) {
|
|
232
|
+
if (retryQueue.length >= MAX_RETRY_QUEUE_SIZE) {
|
|
233
|
+
console.error('[PreScheduler] Retry queue full, dropping message permanently');
|
|
234
|
+
stats.retriesFailed++;
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
retryQueue.push({
|
|
239
|
+
oscData: oscData,
|
|
240
|
+
retryCount: 0,
|
|
241
|
+
context: context || 'unknown',
|
|
242
|
+
queuedAt: performance.now()
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
stats.retryQueueSize = retryQueue.length;
|
|
246
|
+
if (stats.retryQueueSize > stats.maxRetryQueueSize) {
|
|
247
|
+
stats.maxRetryQueueSize = stats.retryQueueSize;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
schedulerLog('[PreScheduler] Queued message for retry:', context, 'queue size:', retryQueue.length);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Attempt to retry queued messages
|
|
255
|
+
* Called periodically from checkAndDispatch
|
|
256
|
+
*/
|
|
257
|
+
function processRetryQueue() {
|
|
258
|
+
if (retryQueue.length === 0) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
var i = 0;
|
|
263
|
+
while (i < retryQueue.length) {
|
|
264
|
+
var item = retryQueue[i];
|
|
265
|
+
|
|
266
|
+
// Try to write
|
|
267
|
+
var success = writeToRingBuffer(item.oscData, true);
|
|
268
|
+
|
|
269
|
+
if (success) {
|
|
270
|
+
// Success - remove from queue
|
|
271
|
+
retryQueue.splice(i, 1);
|
|
272
|
+
stats.retriesSucceeded++;
|
|
273
|
+
stats.messagesRetried++;
|
|
274
|
+
stats.retryQueueSize = retryQueue.length;
|
|
275
|
+
schedulerLog('[PreScheduler] Retry succeeded for:', item.context,
|
|
276
|
+
'after', item.retryCount + 1, 'attempts');
|
|
277
|
+
// Don't increment i - we removed an item
|
|
278
|
+
} else {
|
|
279
|
+
// Failed - increment retry count
|
|
280
|
+
item.retryCount++;
|
|
281
|
+
stats.messagesRetried++;
|
|
282
|
+
|
|
283
|
+
if (item.retryCount >= MAX_RETRIES_PER_MESSAGE) {
|
|
284
|
+
// Give up on this message
|
|
285
|
+
console.error('[PreScheduler] Giving up on message after',
|
|
286
|
+
MAX_RETRIES_PER_MESSAGE, 'retries:', item.context);
|
|
287
|
+
retryQueue.splice(i, 1);
|
|
288
|
+
stats.retriesFailed++;
|
|
289
|
+
stats.retryQueueSize = retryQueue.length;
|
|
290
|
+
// Don't increment i - we removed an item
|
|
291
|
+
} else {
|
|
292
|
+
// Keep in queue, try again next cycle
|
|
293
|
+
i++;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
202
299
|
/**
|
|
203
300
|
* Schedule an OSC bundle by its NTP timestamp
|
|
204
301
|
* Non-bundles or bundles without timestamps are dispatched immediately
|
|
@@ -209,7 +306,11 @@ function scheduleEvent(oscData, editorId, runTag) {
|
|
|
209
306
|
if (ntpTime === null) {
|
|
210
307
|
// Not a bundle - dispatch immediately to ring buffer
|
|
211
308
|
schedulerLog('[PreScheduler] Non-bundle message, dispatching immediately');
|
|
212
|
-
writeToRingBuffer(oscData);
|
|
309
|
+
var success = writeToRingBuffer(oscData, false);
|
|
310
|
+
if (!success) {
|
|
311
|
+
// Queue for retry
|
|
312
|
+
queueForRetry(oscData, 'immediate message');
|
|
313
|
+
}
|
|
213
314
|
return;
|
|
214
315
|
}
|
|
215
316
|
|
|
@@ -338,6 +439,9 @@ function stopPeriodicPolling() {
|
|
|
338
439
|
function checkAndDispatch() {
|
|
339
440
|
isDispatching = true;
|
|
340
441
|
|
|
442
|
+
// First, try to process any queued retries
|
|
443
|
+
processRetryQueue();
|
|
444
|
+
|
|
341
445
|
var currentNTP = getCurrentNTP();
|
|
342
446
|
var lookaheadTime = currentNTP + LOOKAHEAD_S;
|
|
343
447
|
var dispatchCount = 0;
|
|
@@ -361,7 +465,11 @@ function checkAndDispatch() {
|
|
|
361
465
|
'early=' + (timeUntilExec * 1000).toFixed(1) + 'ms',
|
|
362
466
|
'remaining=' + stats.eventsPending);
|
|
363
467
|
|
|
364
|
-
writeToRingBuffer(nextEvent.oscData);
|
|
468
|
+
var success = writeToRingBuffer(nextEvent.oscData, false);
|
|
469
|
+
if (!success) {
|
|
470
|
+
// Queue for retry
|
|
471
|
+
queueForRetry(nextEvent.oscData, 'scheduled bundle NTP=' + nextEvent.ntpTime.toFixed(3));
|
|
472
|
+
}
|
|
365
473
|
dispatchCount++;
|
|
366
474
|
} else {
|
|
367
475
|
// Rest aren't ready yet (heap is sorted)
|
|
@@ -369,10 +477,11 @@ function checkAndDispatch() {
|
|
|
369
477
|
}
|
|
370
478
|
}
|
|
371
479
|
|
|
372
|
-
if (dispatchCount > 0 || eventHeap.length > 0) {
|
|
480
|
+
if (dispatchCount > 0 || eventHeap.length > 0 || retryQueue.length > 0) {
|
|
373
481
|
schedulerLog('[PreScheduler] Dispatch cycle complete:',
|
|
374
482
|
'dispatched=' + dispatchCount,
|
|
375
|
-
'pending=' + eventHeap.length
|
|
483
|
+
'pending=' + eventHeap.length,
|
|
484
|
+
'retrying=' + retryQueue.length);
|
|
376
485
|
}
|
|
377
486
|
|
|
378
487
|
isDispatching = false;
|
|
@@ -474,10 +583,16 @@ function processImmediate(oscData) {
|
|
|
474
583
|
if (isBundle(oscData)) {
|
|
475
584
|
var messages = extractMessagesFromBundle(oscData);
|
|
476
585
|
for (var i = 0; i < messages.length; i++) {
|
|
477
|
-
writeToRingBuffer(messages[i]);
|
|
586
|
+
var success = writeToRingBuffer(messages[i], false);
|
|
587
|
+
if (!success) {
|
|
588
|
+
queueForRetry(messages[i], 'immediate bundle message ' + i);
|
|
589
|
+
}
|
|
478
590
|
}
|
|
479
591
|
} else {
|
|
480
|
-
writeToRingBuffer(oscData);
|
|
592
|
+
var success = writeToRingBuffer(oscData, false);
|
|
593
|
+
if (!success) {
|
|
594
|
+
queueForRetry(oscData, 'immediate message');
|
|
595
|
+
}
|
|
481
596
|
}
|
|
482
597
|
}
|
|
483
598
|
|
|
@@ -555,7 +670,12 @@ self.addEventListener('message', function(event) {
|
|
|
555
670
|
maxLateDispatchMs: stats.maxLateDispatchMs,
|
|
556
671
|
totalSendTasks: stats.totalSendTasks,
|
|
557
672
|
totalSendProcessMs: stats.totalSendProcessMs,
|
|
558
|
-
maxSendProcessMs: stats.maxSendProcessMs
|
|
673
|
+
maxSendProcessMs: stats.maxSendProcessMs,
|
|
674
|
+
messagesRetried: stats.messagesRetried,
|
|
675
|
+
retriesSucceeded: stats.retriesSucceeded,
|
|
676
|
+
retriesFailed: stats.retriesFailed,
|
|
677
|
+
retryQueueSize: stats.retryQueueSize,
|
|
678
|
+
maxRetryQueueSize: stats.maxRetryQueueSize
|
|
559
679
|
}
|
|
560
680
|
});
|
|
561
681
|
break;
|
package/package.json
CHANGED
|
@@ -1,382 +0,0 @@
|
|
|
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 OSC bundles
|
|
11
|
-
* Handles timed bundles and forwards them to the writer worker
|
|
12
|
-
* NO LONGER writes to ring buffer directly - all writes go through osc_writer_worker.js
|
|
13
|
-
* ES5-compatible for Qt WebEngine
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
// Reference to the writer worker
|
|
17
|
-
var writerWorker = null;
|
|
18
|
-
|
|
19
|
-
// Scheduling state
|
|
20
|
-
var scheduledEvents = [];
|
|
21
|
-
var currentTimer = null;
|
|
22
|
-
var cachedTimeDelta = null;
|
|
23
|
-
var minimumScheduleRequirementS = 0.002; // 2ms for audio precision
|
|
24
|
-
var latencyS = 0.05; // 50ms latency compensation for scsynth
|
|
25
|
-
|
|
26
|
-
var DEBUG_SCHED_LOGS = false;
|
|
27
|
-
function schedulerLog() {
|
|
28
|
-
if (DEBUG_SCHED_LOGS) {
|
|
29
|
-
console.log.apply(console, arguments);
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
function schedulerWarn() {
|
|
33
|
-
if (DEBUG_SCHED_LOGS) {
|
|
34
|
-
console.warn.apply(console, arguments);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// Statistics
|
|
39
|
-
var stats = {
|
|
40
|
-
bundlesScheduled: 0,
|
|
41
|
-
bundlesSentToWriter: 0
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Initialize scheduler
|
|
46
|
-
*/
|
|
47
|
-
function init(buffer, base, constants) {
|
|
48
|
-
// We don't need the ring buffer anymore, but keep the params for compatibility
|
|
49
|
-
schedulerLog('[OSCSchedulerWorker] Initialized (scheduler only, no ring buffer access)');
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Send message to writer worker
|
|
54
|
-
*/
|
|
55
|
-
function sendToWriter(oscMessage) {
|
|
56
|
-
if (!writerWorker) {
|
|
57
|
-
console.error('[OSCSchedulerWorker] Writer worker not set');
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Send to writer worker via postMessage
|
|
62
|
-
writerWorker.postMessage({
|
|
63
|
-
type: 'write',
|
|
64
|
-
oscData: oscMessage
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
stats.bundlesSentToWriter++;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Get or set cached time delta for synchronization
|
|
72
|
-
*/
|
|
73
|
-
function getOrSetTimeDelta(delta) {
|
|
74
|
-
if (cachedTimeDelta === null) {
|
|
75
|
-
cachedTimeDelta = delta;
|
|
76
|
-
}
|
|
77
|
-
return cachedTimeDelta;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Check if data is an OSC bundle (starts with "#bundle\0")
|
|
82
|
-
*/
|
|
83
|
-
function isBundle(data) {
|
|
84
|
-
if (data.length < 16) return false;
|
|
85
|
-
var bundleTag = String.fromCharCode.apply(null, data.slice(0, 8));
|
|
86
|
-
return bundleTag === '#bundle\0';
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Parse OSC bundle timestamp from binary data
|
|
91
|
-
* OSC bundles start with "#bundle\0" followed by 8-byte NTP timestamp
|
|
92
|
-
*/
|
|
93
|
-
function parseBundleTimestamp(data) {
|
|
94
|
-
if (!isBundle(data)) return null;
|
|
95
|
-
|
|
96
|
-
// Read NTP timestamp (8 bytes, big-endian)
|
|
97
|
-
var view = new DataView(data.buffer, data.byteOffset + 8, 8);
|
|
98
|
-
var seconds = view.getUint32(0, false); // NTP seconds since 1900
|
|
99
|
-
var fraction = view.getUint32(4, false); // NTP fractional seconds
|
|
100
|
-
|
|
101
|
-
// Convert NTP to JavaScript time
|
|
102
|
-
// NTP epoch is 1900, JS epoch is 1970 (difference: 2208988800 seconds)
|
|
103
|
-
var NTP_TO_UNIX = 2208988800;
|
|
104
|
-
|
|
105
|
-
// Special OSC timestamps
|
|
106
|
-
if (seconds === 0 && fraction === 1) {
|
|
107
|
-
return 0; // Immediate execution
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// Convert to JavaScript timestamp (milliseconds since 1970)
|
|
111
|
-
var unixSeconds = seconds - NTP_TO_UNIX;
|
|
112
|
-
var milliseconds = (fraction / 4294967296) * 1000; // Convert fraction to ms
|
|
113
|
-
|
|
114
|
-
return (unixSeconds * 1000) + milliseconds;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Extract OSC messages from a bundle
|
|
119
|
-
* Returns array of message buffers
|
|
120
|
-
*/
|
|
121
|
-
function extractMessagesFromBundle(data) {
|
|
122
|
-
var messages = [];
|
|
123
|
-
|
|
124
|
-
if (!isBundle(data)) {
|
|
125
|
-
// Not a bundle, return as single message
|
|
126
|
-
return [data];
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// Skip "#bundle\0" (8 bytes) and timestamp (8 bytes)
|
|
130
|
-
var offset = 16;
|
|
131
|
-
|
|
132
|
-
while (offset < data.length) {
|
|
133
|
-
// Read message size (4 bytes, big-endian)
|
|
134
|
-
if (offset + 4 > data.length) break;
|
|
135
|
-
|
|
136
|
-
var view = new DataView(data.buffer, data.byteOffset + offset, 4);
|
|
137
|
-
var messageSize = view.getInt32(0, false);
|
|
138
|
-
offset += 4;
|
|
139
|
-
|
|
140
|
-
if (messageSize <= 0 || offset + messageSize > data.length) break;
|
|
141
|
-
|
|
142
|
-
// Extract message data
|
|
143
|
-
var messageData = data.slice(offset, offset + messageSize);
|
|
144
|
-
|
|
145
|
-
// Check if this is a nested bundle
|
|
146
|
-
if (isBundle(messageData)) {
|
|
147
|
-
// Recursively extract from nested bundle
|
|
148
|
-
var nestedMessages = extractMessagesFromBundle(messageData);
|
|
149
|
-
messages = messages.concat(nestedMessages);
|
|
150
|
-
} else {
|
|
151
|
-
// It's a message, add it
|
|
152
|
-
messages.push(messageData);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
offset += messageSize;
|
|
156
|
-
|
|
157
|
-
// Align to 4-byte boundary if needed
|
|
158
|
-
while (offset % 4 !== 0 && offset < data.length) {
|
|
159
|
-
offset++;
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
return messages;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* Process incoming OSC data (message or bundle)
|
|
168
|
-
* Pre-scheduler: waits for calculated time then sends to writer
|
|
169
|
-
* waitTimeMs is calculated by SuperSonic based on AudioContext time
|
|
170
|
-
*/
|
|
171
|
-
function processOSC(oscData, editorId, runTag, waitTimeMs) {
|
|
172
|
-
stats.bundlesScheduled++;
|
|
173
|
-
|
|
174
|
-
// If no wait time provided, or wait time is 0 or negative, send immediately
|
|
175
|
-
if (waitTimeMs === null || waitTimeMs === undefined || waitTimeMs <= 0) {
|
|
176
|
-
sendToWriter(oscData);
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// Schedule to send after waitTimeMs
|
|
181
|
-
setTimeout(function() {
|
|
182
|
-
sendToWriter(oscData);
|
|
183
|
-
}, waitTimeMs);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* Process immediate send - forces immediate execution by unpacking bundles
|
|
188
|
-
* Bundles are unpacked to individual messages (stripping timestamps)
|
|
189
|
-
* Messages are sent as-is
|
|
190
|
-
* Used when the caller wants immediate execution without scheduling
|
|
191
|
-
*/
|
|
192
|
-
function processImmediate(oscData) {
|
|
193
|
-
if (isBundle(oscData)) {
|
|
194
|
-
// Extract all messages from the bundle (removes timestamp wrapper)
|
|
195
|
-
// Send each message individually for immediate execution
|
|
196
|
-
var messages = extractMessagesFromBundle(oscData);
|
|
197
|
-
for (var i = 0; i < messages.length; i++) {
|
|
198
|
-
sendToWriter(messages[i]);
|
|
199
|
-
}
|
|
200
|
-
} else {
|
|
201
|
-
// Regular message - send as-is
|
|
202
|
-
sendToWriter(oscData);
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Insert event into priority queue
|
|
208
|
-
*/
|
|
209
|
-
function insertEvent(userId, editorId, runId, runTag, adjustedTimeS, oscBundle) {
|
|
210
|
-
var info = { userId: userId, editorId: editorId, runTag: runTag, runId: runId };
|
|
211
|
-
scheduledEvents.push([adjustedTimeS, info, oscBundle]);
|
|
212
|
-
scheduledEvents.sort(function(a, b) { return a[0] - b[0]; });
|
|
213
|
-
scheduleNextEvent();
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Schedule the next event timer
|
|
218
|
-
*/
|
|
219
|
-
function scheduleNextEvent() {
|
|
220
|
-
if (scheduledEvents.length === 0) {
|
|
221
|
-
clearCurrentTimer();
|
|
222
|
-
return;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
var nextEvent = scheduledEvents[0];
|
|
226
|
-
var adjustedTimeS = nextEvent[0];
|
|
227
|
-
|
|
228
|
-
if (!currentTimer || (currentTimer && currentTimer.timeS > adjustedTimeS)) {
|
|
229
|
-
addRunNextEventTimer(adjustedTimeS);
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* Clear current timer
|
|
235
|
-
*/
|
|
236
|
-
function clearCurrentTimer() {
|
|
237
|
-
if (currentTimer) {
|
|
238
|
-
clearTimeout(currentTimer.timerId);
|
|
239
|
-
currentTimer = null;
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/**
|
|
244
|
-
* Add timer for next event
|
|
245
|
-
*/
|
|
246
|
-
function addRunNextEventTimer(adjustedTimeS) {
|
|
247
|
-
clearCurrentTimer();
|
|
248
|
-
|
|
249
|
-
var nowS = Date.now() / 1000;
|
|
250
|
-
var timeDeltaS = adjustedTimeS - nowS;
|
|
251
|
-
|
|
252
|
-
if (timeDeltaS <= minimumScheduleRequirementS) {
|
|
253
|
-
runNextEvent();
|
|
254
|
-
} else {
|
|
255
|
-
var delayMs = (timeDeltaS - minimumScheduleRequirementS) * 1000;
|
|
256
|
-
currentTimer = {
|
|
257
|
-
timeS: adjustedTimeS,
|
|
258
|
-
timerId: setTimeout(function() {
|
|
259
|
-
currentTimer = null;
|
|
260
|
-
runNextEvent();
|
|
261
|
-
}, delayMs)
|
|
262
|
-
};
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
* Run the next scheduled event
|
|
268
|
-
*/
|
|
269
|
-
function runNextEvent() {
|
|
270
|
-
clearCurrentTimer();
|
|
271
|
-
|
|
272
|
-
if (scheduledEvents.length === 0) {
|
|
273
|
-
return;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
var event = scheduledEvents.shift();
|
|
277
|
-
var data = event[2];
|
|
278
|
-
|
|
279
|
-
// Send the complete bundle unchanged (with original timestamp)
|
|
280
|
-
sendToWriter(data);
|
|
281
|
-
|
|
282
|
-
scheduleNextEvent();
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
/**
|
|
286
|
-
* Cancel events by editor and tag
|
|
287
|
-
*/
|
|
288
|
-
function cancelEditorTag(editorId, runTag) {
|
|
289
|
-
scheduledEvents = scheduledEvents.filter(function(e) {
|
|
290
|
-
return e[1].runTag !== runTag || e[1].editorId !== editorId;
|
|
291
|
-
});
|
|
292
|
-
scheduleNextEvent();
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
/**
|
|
296
|
-
* Cancel all events from an editor
|
|
297
|
-
*/
|
|
298
|
-
function cancelEditor(editorId) {
|
|
299
|
-
scheduledEvents = scheduledEvents.filter(function(e) {
|
|
300
|
-
return e[1].editorId !== editorId;
|
|
301
|
-
});
|
|
302
|
-
scheduleNextEvent();
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
/**
|
|
306
|
-
* Cancel all scheduled events
|
|
307
|
-
*/
|
|
308
|
-
function cancelAllTags() {
|
|
309
|
-
scheduledEvents = [];
|
|
310
|
-
clearCurrentTimer();
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
/**
|
|
314
|
-
* Reset time delta for resync
|
|
315
|
-
*/
|
|
316
|
-
function resetTimeDelta() {
|
|
317
|
-
cachedTimeDelta = null;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
/**
|
|
321
|
-
* Handle messages from main thread
|
|
322
|
-
*/
|
|
323
|
-
self.onmessage = function(event) {
|
|
324
|
-
var data = event.data;
|
|
325
|
-
|
|
326
|
-
try {
|
|
327
|
-
switch (data.type) {
|
|
328
|
-
case 'init':
|
|
329
|
-
init(data.sharedBuffer, data.ringBufferBase, data.bufferConstants);
|
|
330
|
-
self.postMessage({ type: 'initialized' });
|
|
331
|
-
break;
|
|
332
|
-
|
|
333
|
-
case 'setWriterWorker':
|
|
334
|
-
// Set reference to writer worker (passed as MessagePort)
|
|
335
|
-
writerWorker = data.port;
|
|
336
|
-
schedulerLog('[OSCSchedulerWorker] Writer worker connected');
|
|
337
|
-
break;
|
|
338
|
-
|
|
339
|
-
case 'send':
|
|
340
|
-
// Single send method for both messages and bundles
|
|
341
|
-
// waitTimeMs is calculated by SuperSonic based on AudioContext time
|
|
342
|
-
processOSC(data.oscData, data.editorId, data.runTag, data.waitTimeMs);
|
|
343
|
-
break;
|
|
344
|
-
|
|
345
|
-
case 'sendImmediate':
|
|
346
|
-
// Force immediate send, extracting all messages from bundles
|
|
347
|
-
// Ignores timestamps - for apps that don't expect scheduling
|
|
348
|
-
processImmediate(data.oscData);
|
|
349
|
-
break;
|
|
350
|
-
|
|
351
|
-
case 'cancelEditorTag':
|
|
352
|
-
cancelEditorTag(data.editorId, data.runTag);
|
|
353
|
-
break;
|
|
354
|
-
|
|
355
|
-
case 'cancelEditor':
|
|
356
|
-
cancelEditor(data.editorId);
|
|
357
|
-
break;
|
|
358
|
-
|
|
359
|
-
case 'cancelAll':
|
|
360
|
-
cancelAllTags();
|
|
361
|
-
break;
|
|
362
|
-
|
|
363
|
-
case 'getStats':
|
|
364
|
-
self.postMessage({
|
|
365
|
-
type: 'stats',
|
|
366
|
-
stats: stats
|
|
367
|
-
});
|
|
368
|
-
break;
|
|
369
|
-
|
|
370
|
-
default:
|
|
371
|
-
schedulerWarn('[OSCOutWorker] Unknown message type:', data.type);
|
|
372
|
-
}
|
|
373
|
-
} catch (error) {
|
|
374
|
-
console.error('[OSCOutWorker] Error:', error);
|
|
375
|
-
self.postMessage({
|
|
376
|
-
type: 'error',
|
|
377
|
-
error: error.message
|
|
378
|
-
});
|
|
379
|
-
}
|
|
380
|
-
};
|
|
381
|
-
|
|
382
|
-
schedulerLog('[OSCSchedulerWorker] Script loaded - scheduler only, delegates to writer worker');
|
|
@@ -1,305 +0,0 @@
|
|
|
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
|
-
* Base class for ring buffer workers
|
|
11
|
-
* Provides common functionality for polling ring buffers with Atomics.wait()
|
|
12
|
-
* ES5-compatible for Qt WebEngine
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
// Ring buffer configuration
|
|
16
|
-
var RingBufferWorkerBase = function(bufferType) {
|
|
17
|
-
this.bufferType = bufferType; // 'OUT', 'SYSTEM', etc.
|
|
18
|
-
this.sharedBuffer = null;
|
|
19
|
-
this.ringBufferBase = null;
|
|
20
|
-
this.atomicView = null;
|
|
21
|
-
this.dataView = null;
|
|
22
|
-
this.uint8View = null;
|
|
23
|
-
this.bufferConstants = null;
|
|
24
|
-
this.CONTROL_INDICES = {};
|
|
25
|
-
this.running = false;
|
|
26
|
-
this.loggedCorruptionState = false;
|
|
27
|
-
|
|
28
|
-
// Statistics
|
|
29
|
-
this.stats = {
|
|
30
|
-
messagesReceived: 0,
|
|
31
|
-
lastSequenceReceived: -1,
|
|
32
|
-
droppedMessages: 0,
|
|
33
|
-
wakeups: 0,
|
|
34
|
-
timeouts: 0
|
|
35
|
-
};
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Initialize ring buffer access
|
|
40
|
-
*/
|
|
41
|
-
RingBufferWorkerBase.prototype.initRingBuffer = function(buffer, base, constants) {
|
|
42
|
-
this.sharedBuffer = buffer;
|
|
43
|
-
this.ringBufferBase = base;
|
|
44
|
-
this.bufferConstants = constants;
|
|
45
|
-
this.atomicView = new Int32Array(this.sharedBuffer);
|
|
46
|
-
this.dataView = new DataView(this.sharedBuffer);
|
|
47
|
-
this.uint8View = new Uint8Array(this.sharedBuffer);
|
|
48
|
-
|
|
49
|
-
// Calculate control indices based on buffer type
|
|
50
|
-
var headOffset, tailOffset;
|
|
51
|
-
if (this.bufferType === 'OUT') {
|
|
52
|
-
headOffset = 8;
|
|
53
|
-
tailOffset = 12;
|
|
54
|
-
} else if (this.bufferType === 'SYSTEM') {
|
|
55
|
-
headOffset = 24; // system_head offset
|
|
56
|
-
tailOffset = 28; // system_tail offset
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// Debug logging for SYSTEM worker
|
|
60
|
-
if (this.bufferType === 'SYSTEM') {
|
|
61
|
-
console.log('[' + this.bufferType + 'Worker] Init params:', {
|
|
62
|
-
ringBufferBase: base,
|
|
63
|
-
CONTROL_START: constants.CONTROL_START,
|
|
64
|
-
headOffset: headOffset,
|
|
65
|
-
tailOffset: tailOffset,
|
|
66
|
-
calculatedHeadByteOffset: base + constants.CONTROL_START + headOffset,
|
|
67
|
-
calculatedHeadIndex: (base + constants.CONTROL_START + headOffset) / 4
|
|
68
|
-
});
|
|
69
|
-
console.log('[' + this.bufferType + 'Worker] Buffer constants:', {
|
|
70
|
-
SYSTEM_BUFFER_START: constants.SYSTEM_BUFFER_START,
|
|
71
|
-
SYSTEM_BUFFER_SIZE: constants.SYSTEM_BUFFER_SIZE,
|
|
72
|
-
MESSAGE_MAGIC: constants.MESSAGE_MAGIC,
|
|
73
|
-
PADDING_MAGIC: constants.PADDING_MAGIC
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
this.CONTROL_INDICES = {
|
|
78
|
-
HEAD: (this.ringBufferBase + this.bufferConstants.CONTROL_START + headOffset) / 4,
|
|
79
|
-
TAIL: (this.ringBufferBase + this.bufferConstants.CONTROL_START + tailOffset) / 4
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
// Debug: log buffer info for SYSTEM
|
|
83
|
-
if (this.bufferType === 'SYSTEM') {
|
|
84
|
-
var bufferInfo = this.getBufferInfo();
|
|
85
|
-
console.log('[' + this.bufferType + 'Worker] Buffer info:', bufferInfo);
|
|
86
|
-
console.log('[' + this.bufferType + 'Worker] Control indices:', this.CONTROL_INDICES);
|
|
87
|
-
|
|
88
|
-
// Check initial buffer state - read first 64 bytes
|
|
89
|
-
var startPos = this.ringBufferBase + bufferInfo.start;
|
|
90
|
-
console.log('[' + this.bufferType + 'Worker] First 64 bytes of buffer at position', startPos + ':');
|
|
91
|
-
for (var i = 0; i < 64; i += 4) {
|
|
92
|
-
var value = this.dataView.getUint32(startPos + i, true);
|
|
93
|
-
console.log(' Offset', i + ':', '0x' + value.toString(16).padStart(8, '0'));
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Check current head/tail values
|
|
97
|
-
var currentHead = Atomics.load(this.atomicView, this.CONTROL_INDICES.HEAD);
|
|
98
|
-
var currentTail = Atomics.load(this.atomicView, this.CONTROL_INDICES.TAIL);
|
|
99
|
-
console.log('[' + this.bufferType + 'Worker] Initial head:', currentHead, 'tail:', currentTail);
|
|
100
|
-
}
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Get buffer start and size based on type
|
|
105
|
-
*/
|
|
106
|
-
RingBufferWorkerBase.prototype.getBufferInfo = function() {
|
|
107
|
-
if (this.bufferType === 'OUT') {
|
|
108
|
-
return {
|
|
109
|
-
start: this.bufferConstants.OUT_BUFFER_START,
|
|
110
|
-
size: this.bufferConstants.OUT_BUFFER_SIZE
|
|
111
|
-
};
|
|
112
|
-
} else if (this.bufferType === 'SYSTEM') {
|
|
113
|
-
return {
|
|
114
|
-
start: this.bufferConstants.SYSTEM_BUFFER_START,
|
|
115
|
-
size: this.bufferConstants.SYSTEM_BUFFER_SIZE
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
return null;
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Read all available messages from buffer
|
|
123
|
-
*/
|
|
124
|
-
RingBufferWorkerBase.prototype.readMessages = function() {
|
|
125
|
-
var head = Atomics.load(this.atomicView, this.CONTROL_INDICES.HEAD);
|
|
126
|
-
var tail = Atomics.load(this.atomicView, this.CONTROL_INDICES.TAIL);
|
|
127
|
-
|
|
128
|
-
var messages = [];
|
|
129
|
-
|
|
130
|
-
if (head === tail) {
|
|
131
|
-
return messages; // No messages
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
var bufferInfo = this.getBufferInfo();
|
|
135
|
-
var currentTail = tail;
|
|
136
|
-
var messagesRead = 0;
|
|
137
|
-
var maxMessages = 100;
|
|
138
|
-
|
|
139
|
-
while (currentTail !== head && messagesRead < maxMessages) {
|
|
140
|
-
var readPos = this.ringBufferBase + bufferInfo.start + currentTail;
|
|
141
|
-
|
|
142
|
-
// Read message header (now always contiguous due to padding)
|
|
143
|
-
var magic = this.dataView.getUint32(readPos, true);
|
|
144
|
-
|
|
145
|
-
// Check for padding marker - skip to beginning
|
|
146
|
-
if (magic === this.bufferConstants.PADDING_MAGIC) {
|
|
147
|
-
currentTail = 0;
|
|
148
|
-
continue;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
if (magic !== this.bufferConstants.MESSAGE_MAGIC) {
|
|
152
|
-
// Only log first few corrupted messages to avoid spamming
|
|
153
|
-
if (this.stats.droppedMessages < 5) {
|
|
154
|
-
console.error('[' + this.bufferType + 'Worker] Corrupted message at position', currentTail, 'magic:', magic, 'expected:', this.bufferConstants.MESSAGE_MAGIC);
|
|
155
|
-
}
|
|
156
|
-
this.stats.droppedMessages++;
|
|
157
|
-
// Skip this byte and continue
|
|
158
|
-
currentTail = (currentTail + 1) % bufferInfo.size;
|
|
159
|
-
continue;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
var length = this.dataView.getUint32(readPos + 4, true);
|
|
163
|
-
var sequence = this.dataView.getUint32(readPos + 8, true);
|
|
164
|
-
var padding = this.dataView.getUint32(readPos + 12, true); // unused padding field
|
|
165
|
-
|
|
166
|
-
// Validate message length
|
|
167
|
-
if (length < this.bufferConstants.MESSAGE_HEADER_SIZE || length > bufferInfo.size) {
|
|
168
|
-
// Only log first few invalid lengths to avoid spamming
|
|
169
|
-
if (this.stats.droppedMessages < 5) {
|
|
170
|
-
console.error('[' + this.bufferType + 'Worker] Invalid message length:', length);
|
|
171
|
-
}
|
|
172
|
-
this.stats.droppedMessages++;
|
|
173
|
-
currentTail = (currentTail + 1) % bufferInfo.size;
|
|
174
|
-
continue;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// Check for dropped messages via sequence
|
|
178
|
-
if (this.stats.lastSequenceReceived >= 0) {
|
|
179
|
-
var expectedSeq = (this.stats.lastSequenceReceived + 1) & 0xFFFFFFFF;
|
|
180
|
-
if (sequence !== expectedSeq) {
|
|
181
|
-
var dropped = (sequence - expectedSeq + 0x100000000) & 0xFFFFFFFF;
|
|
182
|
-
if (dropped < 1000) { // Sanity check
|
|
183
|
-
console.warn('[' + this.bufferType + 'Worker] Detected', dropped, 'dropped messages');
|
|
184
|
-
this.stats.droppedMessages += dropped;
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
this.stats.lastSequenceReceived = sequence;
|
|
189
|
-
|
|
190
|
-
// Read payload (OSC binary data) - now contiguous due to padding
|
|
191
|
-
var payloadLength = length - this.bufferConstants.MESSAGE_HEADER_SIZE;
|
|
192
|
-
var payloadStart = readPos + this.bufferConstants.MESSAGE_HEADER_SIZE;
|
|
193
|
-
|
|
194
|
-
// Create a proper copy (not a view into SharedArrayBuffer)
|
|
195
|
-
var payload = new Uint8Array(payloadLength);
|
|
196
|
-
for (var i = 0; i < payloadLength; i++) {
|
|
197
|
-
payload[i] = this.uint8View[payloadStart + i];
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
messages.push({
|
|
201
|
-
oscData: payload,
|
|
202
|
-
sequence: sequence
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
// Move to next message
|
|
206
|
-
currentTail = (currentTail + length) % bufferInfo.size;
|
|
207
|
-
messagesRead++;
|
|
208
|
-
this.stats.messagesReceived++;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// Update tail pointer (consume messages)
|
|
212
|
-
if (messagesRead > 0) {
|
|
213
|
-
Atomics.store(this.atomicView, this.CONTROL_INDICES.TAIL, currentTail);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
return messages;
|
|
217
|
-
};
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* Main wait loop using Atomics.wait for instant wake
|
|
221
|
-
*/
|
|
222
|
-
RingBufferWorkerBase.prototype.waitLoop = function() {
|
|
223
|
-
var self = this;
|
|
224
|
-
while (this.running) {
|
|
225
|
-
try {
|
|
226
|
-
// Get current HEAD value
|
|
227
|
-
var currentHead = Atomics.load(this.atomicView, this.CONTROL_INDICES.HEAD);
|
|
228
|
-
var currentTail = Atomics.load(this.atomicView, this.CONTROL_INDICES.TAIL);
|
|
229
|
-
|
|
230
|
-
// If buffer is empty, wait for AudioWorklet to notify us
|
|
231
|
-
if (currentHead === currentTail) {
|
|
232
|
-
// Wait for up to 100ms (allows checking stop signal)
|
|
233
|
-
var result = Atomics.wait(this.atomicView, this.CONTROL_INDICES.HEAD, currentHead, 100);
|
|
234
|
-
|
|
235
|
-
if (result === 'ok' || result === 'not-equal') {
|
|
236
|
-
// We were notified or value changed!
|
|
237
|
-
this.stats.wakeups++;
|
|
238
|
-
} else if (result === 'timed-out') {
|
|
239
|
-
this.stats.timeouts++;
|
|
240
|
-
continue; // Check running flag
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// Read all available messages
|
|
245
|
-
var messages = this.readMessages();
|
|
246
|
-
|
|
247
|
-
// Debug: log ONCE if we tried to read but got no valid messages due to corruption
|
|
248
|
-
if (messages.length === 0 && currentHead !== currentTail && this.stats.droppedMessages > 0) {
|
|
249
|
-
if (this.bufferType === 'SYSTEM' && !this.loggedCorruptionState) {
|
|
250
|
-
console.log('[' + this.bufferType + 'Worker] Head != tail (head=' + currentHead + ', tail=' + currentTail + ') but all messages corrupted. Dropped:', this.stats.droppedMessages);
|
|
251
|
-
this.loggedCorruptionState = true;
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
if (messages.length > 0) {
|
|
256
|
-
// Send to main thread
|
|
257
|
-
self.postMessage({
|
|
258
|
-
type: 'messages',
|
|
259
|
-
messages: messages,
|
|
260
|
-
stats: {
|
|
261
|
-
wakeups: this.stats.wakeups,
|
|
262
|
-
timeouts: this.stats.timeouts,
|
|
263
|
-
messagesReceived: this.stats.messagesReceived,
|
|
264
|
-
droppedMessages: this.stats.droppedMessages
|
|
265
|
-
}
|
|
266
|
-
});
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
} catch (error) {
|
|
270
|
-
console.error('[' + this.bufferType + 'Worker] Error in wait loop:', error);
|
|
271
|
-
self.postMessage({
|
|
272
|
-
type: 'error',
|
|
273
|
-
error: error.message
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
// Brief pause on error before retrying
|
|
277
|
-
Atomics.wait(this.atomicView, 0, this.atomicView[0], 10);
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
};
|
|
281
|
-
|
|
282
|
-
/**
|
|
283
|
-
* Start the wait loop
|
|
284
|
-
*/
|
|
285
|
-
RingBufferWorkerBase.prototype.start = function() {
|
|
286
|
-
if (!this.sharedBuffer) {
|
|
287
|
-
console.error('[' + this.bufferType + 'Worker] Cannot start - not initialized');
|
|
288
|
-
return;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
if (this.running) {
|
|
292
|
-
console.warn('[' + this.bufferType + 'Worker] Already running');
|
|
293
|
-
return;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
this.running = true;
|
|
297
|
-
this.waitLoop();
|
|
298
|
-
};
|
|
299
|
-
|
|
300
|
-
/**
|
|
301
|
-
* Stop the wait loop
|
|
302
|
-
*/
|
|
303
|
-
RingBufferWorkerBase.prototype.stop = function() {
|
|
304
|
-
this.running = false;
|
|
305
|
-
};
|
|
@@ -1,64 +0,0 @@
|
|
|
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
|
-
* System Message Worker - Handles internal SuperSonic messages
|
|
11
|
-
* Uses the shared ring buffer base class
|
|
12
|
-
* ES5-compatible for Qt WebEngine
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
// Import the base class
|
|
16
|
-
importScripts('ring_buffer_worker_base.js');
|
|
17
|
-
|
|
18
|
-
// Create worker instance for SYSTEM buffer
|
|
19
|
-
var worker = new RingBufferWorkerBase('SYSTEM');
|
|
20
|
-
|
|
21
|
-
// Override postMessage to maintain compatibility
|
|
22
|
-
worker.postMessage = self.postMessage.bind(self);
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Handle messages from main thread
|
|
26
|
-
*/
|
|
27
|
-
self.onmessage = function(event) {
|
|
28
|
-
var data = event.data;
|
|
29
|
-
|
|
30
|
-
try {
|
|
31
|
-
switch (data.type) {
|
|
32
|
-
case 'init':
|
|
33
|
-
worker.initRingBuffer(data.sharedBuffer, data.ringBufferBase, data.bufferConstants);
|
|
34
|
-
self.postMessage({ type: 'initialized' });
|
|
35
|
-
break;
|
|
36
|
-
|
|
37
|
-
case 'start':
|
|
38
|
-
worker.start();
|
|
39
|
-
break;
|
|
40
|
-
|
|
41
|
-
case 'stop':
|
|
42
|
-
worker.stop();
|
|
43
|
-
break;
|
|
44
|
-
|
|
45
|
-
case 'getStats':
|
|
46
|
-
self.postMessage({
|
|
47
|
-
type: 'stats',
|
|
48
|
-
stats: worker.stats
|
|
49
|
-
});
|
|
50
|
-
break;
|
|
51
|
-
|
|
52
|
-
default:
|
|
53
|
-
console.warn('[SystemWorker] Unknown message type:', data.type);
|
|
54
|
-
}
|
|
55
|
-
} catch (error) {
|
|
56
|
-
console.error('[SystemWorker] Error:', error);
|
|
57
|
-
self.postMessage({
|
|
58
|
-
type: 'error',
|
|
59
|
-
error: error.message
|
|
60
|
-
});
|
|
61
|
-
}
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
console.log('[SystemWorker] Script loaded');
|