@libraz/libsonare 1.4.1 → 1.5.1
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 +51 -20
- package/dist/index.d.ts +5416 -1
- package/dist/index.js +938 -583
- package/dist/index.js.map +1 -1
- package/dist/sonare.js +2 -2
- package/dist/sonare.wasm +0 -0
- package/dist/worklet.d.ts +1083 -5227
- package/dist/worklet.js +2683 -2451
- package/dist/worklet.js.map +1 -1
- package/package.json +4 -9
- package/src/clip_page_streamer.ts +298 -0
- package/src/effects_mastering.ts +85 -1089
- package/src/effects_transform.ts +286 -0
- package/src/effects_voice_change.ts +118 -0
- package/src/feature_music.ts +5 -2
- package/src/feature_spectrogram.ts +42 -2
- package/src/features.ts +1 -0
- package/src/index.ts +23 -0
- package/src/mastering_chain.ts +200 -0
- package/src/mastering_core.ts +248 -0
- package/src/mastering_dynamics.ts +105 -0
- package/src/mastering_repair.ts +161 -0
- package/src/mixer.ts +8 -0
- package/src/mixing_oneshot.ts +54 -0
- package/src/module_state.ts +1 -2
- package/src/project.ts +71 -1712
- package/src/project_class.ts +871 -0
- package/src/project_internal.ts +333 -0
- package/src/project_synth.ts +43 -0
- package/src/project_types.ts +570 -0
- package/src/public_types.ts +6 -1221
- package/src/public_types_acoustic.ts +115 -0
- package/src/public_types_mastering.ts +333 -0
- package/src/public_types_mixing.ts +97 -0
- package/src/public_types_music.ts +352 -0
- package/src/public_types_realtime.ts +163 -0
- package/src/public_types_spectral.ts +194 -0
- package/src/realtime_engine.ts +94 -0
- package/src/sonare.js.d.ts +117 -38
- package/src/stream_analyzer.ts +3 -0
- package/src/stream_types.ts +4 -0
- package/src/worklet/engine-automation.ts +73 -0
- package/src/worklet/engine-capture-facade.ts +80 -0
- package/src/worklet/engine-clips.ts +71 -0
- package/src/worklet/engine-markers.ts +93 -0
- package/src/worklet/engine-mixer-facade.ts +186 -0
- package/src/worklet/engine-node.ts +451 -0
- package/src/worklet/engine-offline.ts +162 -0
- package/src/worklet/engine-options.ts +13 -0
- package/src/worklet/engine-parameter-facade.ts +172 -0
- package/src/worklet/engine-processor.ts +764 -0
- package/src/worklet/engine-register.ts +136 -0
- package/src/worklet/engine-strips.ts +315 -0
- package/src/worklet/engine-sync.ts +94 -0
- package/src/worklet/engine-tempo-facade.ts +141 -0
- package/src/worklet/engine.ts +998 -0
- package/src/worklet/guards.ts +14 -1
- package/src/worklet/messages.ts +60 -20
- package/src/worklet/mixer-processor.ts +368 -0
- package/src/worklet/protocol.ts +3 -0
- package/src/worklet/voice-changer-processor.ts +246 -0
- package/src/worklet.ts +20 -3549
- package/dist/sonare-rt-module.js +0 -2
- package/dist/sonare-rt.js +0 -2
- package/dist/sonare-rt.wasm +0 -0
- package/src/sonare-rt.d.ts +0 -93
|
@@ -0,0 +1,764 @@
|
|
|
1
|
+
import type { EngineClip, EngineScopeTelemetry } from '../index';
|
|
2
|
+
import { RealtimeEngine } from '../index';
|
|
3
|
+
import type { WorkletInput, WorkletOutput } from './audio_types';
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_METRONOME_CONFIG,
|
|
6
|
+
type ResolvedMetronomeConfig,
|
|
7
|
+
resolveMetronomeConfig,
|
|
8
|
+
type SonareEngineCaptureRequestMessage,
|
|
9
|
+
type SonareEngineCaptureResponseMessage,
|
|
10
|
+
type SonareEngineSyncMessage,
|
|
11
|
+
type SonareEngineTransportRequestMessage,
|
|
12
|
+
type SonareEngineTransportResponseMessage,
|
|
13
|
+
type SonareRealtimeEngineWorkletProcessorOptions,
|
|
14
|
+
type WorkletTransport,
|
|
15
|
+
} from './messages';
|
|
16
|
+
import {
|
|
17
|
+
encodeFrameHi,
|
|
18
|
+
encodeFrameLo,
|
|
19
|
+
engineRingFromSharedBuffer,
|
|
20
|
+
meterFromEngine,
|
|
21
|
+
meterRingFromSharedBuffer,
|
|
22
|
+
popSonareEngineCommandRingBuffer,
|
|
23
|
+
type SharedMeterRingWriter,
|
|
24
|
+
type SharedScopeRingWriter,
|
|
25
|
+
SONARE_ENGINE_COMMAND_RECORD_BYTES,
|
|
26
|
+
SONARE_ENGINE_TELEMETRY_RECORD_BYTES,
|
|
27
|
+
SONARE_METER_RING_RECORD_FLOATS,
|
|
28
|
+
SONARE_SCOPE_RING_RECORD_PREFIX_FLOATS,
|
|
29
|
+
type SonareEngineCommandRecord,
|
|
30
|
+
type SonareEngineCommandRingBuffer,
|
|
31
|
+
SonareEngineCommandType,
|
|
32
|
+
SonareEngineTelemetryError,
|
|
33
|
+
type SonareEngineTelemetryRecord,
|
|
34
|
+
type SonareEngineTelemetryRingBuffer,
|
|
35
|
+
SonareEngineTelemetryType,
|
|
36
|
+
type SonareWorkletMeterSnapshot,
|
|
37
|
+
scopeRingFromSharedBuffer,
|
|
38
|
+
telemetryFromEngine,
|
|
39
|
+
writeSonareEngineTelemetryRingBuffer,
|
|
40
|
+
} from './protocol';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* AudioWorklet-style bridge for the DAW realtime engine facade.
|
|
44
|
+
*
|
|
45
|
+
* Backed by the `sonare.wasm` embind facade.
|
|
46
|
+
*/
|
|
47
|
+
export class SonareRealtimeEngineWorkletProcessor {
|
|
48
|
+
private static warnedChannelScratchOverflow = false;
|
|
49
|
+
readonly sampleRate: number;
|
|
50
|
+
readonly blockSize: number;
|
|
51
|
+
readonly channelCount: number;
|
|
52
|
+
private engine: RealtimeEngine;
|
|
53
|
+
private closed = false;
|
|
54
|
+
private commandRing?: SonareEngineCommandRingBuffer;
|
|
55
|
+
private telemetryRing?: SonareEngineTelemetryRingBuffer;
|
|
56
|
+
private meterRing?: SharedMeterRingWriter;
|
|
57
|
+
private scopeRing?: SharedScopeRingWriter;
|
|
58
|
+
private transport?: WorkletTransport;
|
|
59
|
+
private meterIntervalFrames: number;
|
|
60
|
+
private lastMeterFrame = Number.NEGATIVE_INFINITY;
|
|
61
|
+
// Latest metronome gains/click length pushed via 'syncMetronome'. The
|
|
62
|
+
// SetMetronome command only toggles enabled state; the config arrives here.
|
|
63
|
+
private metronomeConfig: ResolvedMetronomeConfig = { ...DEFAULT_METRONOME_CONFIG };
|
|
64
|
+
// Zero-copy prepared realtime path: persistent per-channel views onto the
|
|
65
|
+
// engine's WASM-heap scratch (acquired once on the main thread via
|
|
66
|
+
// getChannelBuffer). process() writes the AudioWorklet input straight into
|
|
67
|
+
// these views, calls engine.processPrepared(frames) which runs the engine IN
|
|
68
|
+
// PLACE, then reads the same views back — no std::vector or JS Float32Array is
|
|
69
|
+
// allocated per render quantum (the old engine.process() round-tripped fresh
|
|
70
|
+
// arrays on both heaps every block, an RT-safety hazard).
|
|
71
|
+
private channelBuffers: Float32Array[];
|
|
72
|
+
private readonly liveClips = new Map<number, EngineClip>();
|
|
73
|
+
|
|
74
|
+
constructor(
|
|
75
|
+
options: SonareRealtimeEngineWorkletProcessorOptions = {},
|
|
76
|
+
transport?: WorkletTransport,
|
|
77
|
+
) {
|
|
78
|
+
this.sampleRate = options.sampleRate ?? 48000;
|
|
79
|
+
this.blockSize = options.blockSize ?? 128;
|
|
80
|
+
this.channelCount = Math.max(1, Math.floor(options.channelCount ?? 2));
|
|
81
|
+
this.transport = transport;
|
|
82
|
+
this.meterIntervalFrames = Math.max(0, Math.floor(options.meterIntervalFrames ?? 2048));
|
|
83
|
+
this.commandRing = options.commandSharedBuffer
|
|
84
|
+
? this.commandRingFromSharedBuffer(options.commandSharedBuffer, options.commandRingCapacity)
|
|
85
|
+
: undefined;
|
|
86
|
+
this.telemetryRing = options.telemetrySharedBuffer
|
|
87
|
+
? this.telemetryRingFromSharedBuffer(
|
|
88
|
+
options.telemetrySharedBuffer,
|
|
89
|
+
options.telemetryRingCapacity,
|
|
90
|
+
)
|
|
91
|
+
: undefined;
|
|
92
|
+
this.meterRing = options.meterSharedBuffer
|
|
93
|
+
? meterRingFromSharedBuffer(options.meterSharedBuffer, options.meterRingCapacity)
|
|
94
|
+
: undefined;
|
|
95
|
+
this.scopeRing = options.scopeSharedBuffer
|
|
96
|
+
? scopeRingFromSharedBuffer(
|
|
97
|
+
options.scopeSharedBuffer,
|
|
98
|
+
options.scopeRingCapacity,
|
|
99
|
+
options.scopeBands,
|
|
100
|
+
)
|
|
101
|
+
: undefined;
|
|
102
|
+
this.engine = new RealtimeEngine(this.sampleRate, this.blockSize);
|
|
103
|
+
// Allocate persistent WASM-heap scratch (worst case: channelCount channels x
|
|
104
|
+
// blockSize frames) and acquire the per-channel heap views once.
|
|
105
|
+
this.engine.prepareChannels(this.channelCount, this.blockSize);
|
|
106
|
+
this.channelBuffers = new Array(this.channelCount);
|
|
107
|
+
for (let ch = 0; ch < this.channelCount; ch++) {
|
|
108
|
+
this.channelBuffers[ch] = this.engine.getChannelBuffer(ch, this.blockSize);
|
|
109
|
+
}
|
|
110
|
+
// Arm the engine's scope producer only when a scope ring was provided. The
|
|
111
|
+
// band count follows the ring's record layout so writeScopeRing never
|
|
112
|
+
// overruns its slot.
|
|
113
|
+
if (this.scopeRing) {
|
|
114
|
+
const interval = Math.max(1, Math.floor(options.scopeIntervalFrames ?? this.blockSize));
|
|
115
|
+
this.engine.configureScopeTelemetry(interval, this.scopeRing.bands);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
process(inputs: WorkletInput, outputs: WorkletOutput): boolean {
|
|
120
|
+
if (this.closed) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
const output = outputs[0];
|
|
124
|
+
const firstOutput = output?.[0];
|
|
125
|
+
if (!firstOutput) {
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
const frames = firstOutput.length;
|
|
129
|
+
if (frames > this.blockSize) {
|
|
130
|
+
for (const channel of output ?? []) {
|
|
131
|
+
channel.fill(0);
|
|
132
|
+
}
|
|
133
|
+
this.publishTelemetry();
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
this.drainCommands();
|
|
138
|
+
|
|
139
|
+
// Clamp `frames` to the pre-allocated scratch capacity. The earlier
|
|
140
|
+
// `frames > this.blockSize` branch already returns early, so this is
|
|
141
|
+
// defensive — but we warn once if it ever fires so the contract violation
|
|
142
|
+
// is visible.
|
|
143
|
+
let usableFrames = frames;
|
|
144
|
+
if (usableFrames > this.blockSize) {
|
|
145
|
+
if (!SonareRealtimeEngineWorkletProcessor.warnedChannelScratchOverflow) {
|
|
146
|
+
SonareRealtimeEngineWorkletProcessor.warnedChannelScratchOverflow = true;
|
|
147
|
+
// biome-ignore lint/suspicious/noConsole: realtime-safety diagnostic.
|
|
148
|
+
console.warn(
|
|
149
|
+
`SonareRealtimeEngineWorkletProcessor: requested ${usableFrames} frames ` +
|
|
150
|
+
`exceeds pre-allocated capacity ${this.blockSize}; clamping.`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
usableFrames = this.blockSize;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Defend against WASM linear-memory growth detaching the cached heap views:
|
|
157
|
+
// if any view's backing ArrayBuffer has been detached (byteLength === 0),
|
|
158
|
+
// re-acquire all of them. This is a control-flow check (no allocation in the
|
|
159
|
+
// common case where memory did not grow).
|
|
160
|
+
if ((this.channelBuffers[0]?.byteLength ?? 0) === 0) {
|
|
161
|
+
this.reacquireChannelBuffers();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const input = inputs[0];
|
|
165
|
+
// Write the AudioWorklet input straight into the engine's WASM-heap views;
|
|
166
|
+
// no per-block heap allocation.
|
|
167
|
+
for (let ch = 0; ch < this.channelCount; ch++) {
|
|
168
|
+
const dst = this.channelBuffers[ch];
|
|
169
|
+
const source = input?.[ch];
|
|
170
|
+
if (source && source.length === usableFrames) {
|
|
171
|
+
dst.set(source.subarray(0, usableFrames));
|
|
172
|
+
} else {
|
|
173
|
+
dst.fill(0, 0, usableFrames);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Run the engine in place over the prepared scratch (allocation-free).
|
|
178
|
+
this.engine.processPrepared(usableFrames);
|
|
179
|
+
|
|
180
|
+
for (let ch = 0; ch < output.length; ch++) {
|
|
181
|
+
const target = output[ch];
|
|
182
|
+
const source = this.channelBuffers[ch] ?? this.channelBuffers[0];
|
|
183
|
+
if (source) {
|
|
184
|
+
target.set(source.subarray(0, Math.min(target.length, usableFrames)));
|
|
185
|
+
if (target.length > usableFrames) {
|
|
186
|
+
target.fill(0, usableFrames);
|
|
187
|
+
}
|
|
188
|
+
} else {
|
|
189
|
+
target.fill(0);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
this.publishTelemetry();
|
|
193
|
+
this.publishMeters();
|
|
194
|
+
this.publishScope();
|
|
195
|
+
this.publishExternalMidi();
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private reacquireChannelBuffers(): void {
|
|
200
|
+
for (let ch = 0; ch < this.channelCount; ch++) {
|
|
201
|
+
this.channelBuffers[ch] = this.engine.getChannelBuffer(ch, this.blockSize);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
receiveCommand(command: SonareEngineCommandRecord): void {
|
|
206
|
+
if (!this.closed) {
|
|
207
|
+
this.applyCommand(command);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Applies an out-of-band control-plane sync message. Runs on the AudioWorklet
|
|
212
|
+
// global scope but OUTSIDE process() (the message-port callback), so the
|
|
213
|
+
// bulk/allocating engine setters (setClips/setMarkers) are safe here — they
|
|
214
|
+
// never run on the realtime render path. This is the audio-thread equivalent
|
|
215
|
+
// of the engine's control-thread RtPublisher setters.
|
|
216
|
+
receiveSync(message: SonareEngineSyncMessage): void {
|
|
217
|
+
if (this.closed) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
switch (message.type) {
|
|
221
|
+
case 'syncClips':
|
|
222
|
+
this.liveClips.clear();
|
|
223
|
+
for (const clip of message.clips) {
|
|
224
|
+
if (clip.id !== undefined) {
|
|
225
|
+
this.liveClips.set(clip.id, clip);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
this.engine.setClips(message.clips);
|
|
229
|
+
break;
|
|
230
|
+
case 'syncClipsDelta':
|
|
231
|
+
for (const clipId of message.removeIds) {
|
|
232
|
+
this.liveClips.delete(clipId);
|
|
233
|
+
}
|
|
234
|
+
for (const clip of message.upserts) {
|
|
235
|
+
if (clip.id !== undefined) {
|
|
236
|
+
this.liveClips.set(clip.id, clip);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
this.engine.setClips(Array.from(this.liveClips.values()));
|
|
240
|
+
break;
|
|
241
|
+
case 'syncMidiClips':
|
|
242
|
+
this.engine.setMidiClips(message.clips);
|
|
243
|
+
break;
|
|
244
|
+
case 'syncMarkers':
|
|
245
|
+
this.engine.setMarkers(message.markers);
|
|
246
|
+
break;
|
|
247
|
+
case 'syncMetronome':
|
|
248
|
+
this.metronomeConfig = resolveMetronomeConfig(message.config);
|
|
249
|
+
this.engine.setMetronome(message.config);
|
|
250
|
+
break;
|
|
251
|
+
case 'syncAutomation':
|
|
252
|
+
this.engine.setAutomationLane(message.paramId, message.points);
|
|
253
|
+
break;
|
|
254
|
+
case 'syncTempo':
|
|
255
|
+
if (message.tempoSegments) {
|
|
256
|
+
this.engine.setTempoSegments(message.tempoSegments);
|
|
257
|
+
} else {
|
|
258
|
+
this.engine.setTempo(message.bpm);
|
|
259
|
+
}
|
|
260
|
+
if (message.timeSignatureSegments) {
|
|
261
|
+
this.engine.setTimeSignatureSegments(message.timeSignatureSegments);
|
|
262
|
+
} else {
|
|
263
|
+
this.engine.setTimeSignature(
|
|
264
|
+
message.timeSignature.numerator,
|
|
265
|
+
message.timeSignature.denominator,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
break;
|
|
269
|
+
case 'syncMixer':
|
|
270
|
+
if (message.buses) {
|
|
271
|
+
this.engine.setTrackBuses(message.buses);
|
|
272
|
+
}
|
|
273
|
+
this.engine.setTrackLanes(message.lanes);
|
|
274
|
+
for (const strip of message.trackStrips ?? []) {
|
|
275
|
+
this.engine.setTrackStripJson(strip.trackId, strip.sceneJson);
|
|
276
|
+
}
|
|
277
|
+
for (const strip of message.busStrips ?? []) {
|
|
278
|
+
this.engine.setBusStripJson(strip.busId, strip.sceneJson);
|
|
279
|
+
}
|
|
280
|
+
if (message.masterStripJson) {
|
|
281
|
+
this.engine.setMasterStripJson(message.masterStripJson);
|
|
282
|
+
}
|
|
283
|
+
for (const binding of message.laneSidechains ?? []) {
|
|
284
|
+
this.engine.setLaneSidechain(binding.trackId, binding.insertIndex, binding.sourceTrackId);
|
|
285
|
+
}
|
|
286
|
+
break;
|
|
287
|
+
case 'syncCapture':
|
|
288
|
+
this.engine.setCaptureBuffer(message.channels, message.bufferFrames);
|
|
289
|
+
this.engine.setCaptureSource(message.source);
|
|
290
|
+
this.engine.setRecordOffsetSamples(message.recordOffsetSamples);
|
|
291
|
+
this.engine.setInputMonitor(message.inputMonitor.enabled, message.inputMonitor.gain);
|
|
292
|
+
break;
|
|
293
|
+
case 'syncTrackStripEqBand':
|
|
294
|
+
this.engine.setTrackStripEqBandJson(message.trackId, message.bandIndex, message.bandJson);
|
|
295
|
+
break;
|
|
296
|
+
case 'syncMasterStripEqBand':
|
|
297
|
+
this.engine.setMasterStripEqBandJson(message.bandIndex, message.bandJson);
|
|
298
|
+
break;
|
|
299
|
+
case 'syncTrackStripInsertBypassed':
|
|
300
|
+
this.engine.setTrackStripInsertBypassed(
|
|
301
|
+
message.trackId,
|
|
302
|
+
message.insertIndex,
|
|
303
|
+
message.bypassed,
|
|
304
|
+
message.resetOnBypass,
|
|
305
|
+
);
|
|
306
|
+
break;
|
|
307
|
+
case 'syncMasterStripInsertBypassed':
|
|
308
|
+
this.engine.setMasterStripInsertBypassed(
|
|
309
|
+
message.insertIndex,
|
|
310
|
+
message.bypassed,
|
|
311
|
+
message.resetOnBypass,
|
|
312
|
+
);
|
|
313
|
+
break;
|
|
314
|
+
case 'syncTrackStripInsertParamByName':
|
|
315
|
+
this.engine.setTrackStripInsertParamByName(
|
|
316
|
+
message.trackId,
|
|
317
|
+
message.insertIndex,
|
|
318
|
+
message.paramName,
|
|
319
|
+
message.value,
|
|
320
|
+
);
|
|
321
|
+
break;
|
|
322
|
+
case 'syncMasterStripInsertParamByName':
|
|
323
|
+
this.engine.setMasterStripInsertParamByName(
|
|
324
|
+
message.insertIndex,
|
|
325
|
+
message.paramName,
|
|
326
|
+
message.value,
|
|
327
|
+
);
|
|
328
|
+
break;
|
|
329
|
+
case 'syncBusStripInsertParamByName':
|
|
330
|
+
this.engine.setBusStripInsertParamByName(
|
|
331
|
+
message.busId,
|
|
332
|
+
message.insertIndex,
|
|
333
|
+
message.paramName,
|
|
334
|
+
message.value,
|
|
335
|
+
);
|
|
336
|
+
break;
|
|
337
|
+
case 'syncTrackStripPan':
|
|
338
|
+
this.engine.setTrackStripPan(message.trackId, message.pan);
|
|
339
|
+
break;
|
|
340
|
+
case 'syncTrackStripPanLaw':
|
|
341
|
+
this.engine.setTrackStripPanLaw(message.trackId, message.panLaw);
|
|
342
|
+
break;
|
|
343
|
+
case 'syncTrackStripPanMode':
|
|
344
|
+
this.engine.setTrackStripPanMode(message.trackId, message.panMode);
|
|
345
|
+
break;
|
|
346
|
+
case 'syncTrackStripDualPan':
|
|
347
|
+
this.engine.setTrackStripDualPan(message.trackId, message.leftPan, message.rightPan);
|
|
348
|
+
break;
|
|
349
|
+
case 'syncTrackStripChannelDelaySamples':
|
|
350
|
+
this.engine.setTrackStripChannelDelaySamples(message.trackId, message.delaySamples);
|
|
351
|
+
break;
|
|
352
|
+
case 'syncBuiltinInstrument':
|
|
353
|
+
this.engine.setBuiltinInstrument(message.config, message.destinationId);
|
|
354
|
+
break;
|
|
355
|
+
case 'syncSynthInstrument':
|
|
356
|
+
this.engine.setSynthInstrument(message.patch, message.destinationId);
|
|
357
|
+
break;
|
|
358
|
+
case 'syncLoadSoundFont':
|
|
359
|
+
this.engine.loadSoundFont(message.data);
|
|
360
|
+
break;
|
|
361
|
+
case 'syncSf2Instrument':
|
|
362
|
+
this.engine.setSf2Instrument(message.config, message.destinationId);
|
|
363
|
+
break;
|
|
364
|
+
case 'syncMidiFx':
|
|
365
|
+
this.engine.setMidiFx(message.destinationId, message.configJson ?? '');
|
|
366
|
+
break;
|
|
367
|
+
case 'syncClearMidiFx':
|
|
368
|
+
this.engine.clearMidiFx(message.destinationId);
|
|
369
|
+
break;
|
|
370
|
+
case 'syncMidiNoteOn':
|
|
371
|
+
this.engine.pushMidiNoteOn(
|
|
372
|
+
message.destinationId,
|
|
373
|
+
message.group,
|
|
374
|
+
message.channel,
|
|
375
|
+
message.note,
|
|
376
|
+
message.velocity,
|
|
377
|
+
message.renderFrame,
|
|
378
|
+
);
|
|
379
|
+
break;
|
|
380
|
+
case 'syncMidiNoteOff':
|
|
381
|
+
this.engine.pushMidiNoteOff(
|
|
382
|
+
message.destinationId,
|
|
383
|
+
message.group,
|
|
384
|
+
message.channel,
|
|
385
|
+
message.note,
|
|
386
|
+
message.velocity,
|
|
387
|
+
message.renderFrame,
|
|
388
|
+
);
|
|
389
|
+
break;
|
|
390
|
+
case 'syncMidiCc':
|
|
391
|
+
this.engine.pushMidiCc(
|
|
392
|
+
message.destinationId,
|
|
393
|
+
message.group,
|
|
394
|
+
message.channel,
|
|
395
|
+
message.controller,
|
|
396
|
+
message.value,
|
|
397
|
+
message.renderFrame,
|
|
398
|
+
);
|
|
399
|
+
break;
|
|
400
|
+
case 'syncMidiSysex':
|
|
401
|
+
this.engine.pushMidiSysex(message.destinationId, message.data, message.renderFrame);
|
|
402
|
+
break;
|
|
403
|
+
case 'syncMidiPanic':
|
|
404
|
+
this.engine.pushMidiPanic(message.renderFrame);
|
|
405
|
+
break;
|
|
406
|
+
case 'syncMidiDestinationExternal':
|
|
407
|
+
this.engine.setMidiDestinationExternal(message.destinationId, message.external);
|
|
408
|
+
break;
|
|
409
|
+
case 'syncExternalMidiClock':
|
|
410
|
+
this.engine.setExternalMidiClockEnabled(message.enabled);
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
receiveCaptureRequest(message: SonareEngineCaptureRequestMessage): void {
|
|
416
|
+
if (this.closed) {
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
try {
|
|
420
|
+
if (message.op === 'status') {
|
|
421
|
+
const status = this.engine.captureStatus();
|
|
422
|
+
this.transport?.postMessage?.({
|
|
423
|
+
type: 'captureResponse',
|
|
424
|
+
requestId: message.requestId,
|
|
425
|
+
ok: true,
|
|
426
|
+
status: {
|
|
427
|
+
capturedFrames: status.capturedFrames,
|
|
428
|
+
overflowCount: status.overflowCount,
|
|
429
|
+
armed: status.armed,
|
|
430
|
+
punchEnabled: status.punchEnabled,
|
|
431
|
+
source: status.source,
|
|
432
|
+
recordOffsetSamples: status.recordOffsetSamples,
|
|
433
|
+
},
|
|
434
|
+
} satisfies SonareEngineCaptureResponseMessage);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
if (message.op === 'read') {
|
|
438
|
+
const captured = this.engine.capturedAudio();
|
|
439
|
+
const channels: number[][] = [];
|
|
440
|
+
for (let ch = 0; ch < captured.length; ch++) {
|
|
441
|
+
const source = captured[ch];
|
|
442
|
+
const copy: number[] = [];
|
|
443
|
+
for (let i = 0; i < source.length; i++) {
|
|
444
|
+
copy.push(Number(source[i]));
|
|
445
|
+
}
|
|
446
|
+
channels.push(copy);
|
|
447
|
+
}
|
|
448
|
+
this.transport?.postMessage?.({
|
|
449
|
+
type: 'captureResponse',
|
|
450
|
+
requestId: message.requestId,
|
|
451
|
+
ok: true,
|
|
452
|
+
channels,
|
|
453
|
+
} satisfies SonareEngineCaptureResponseMessage);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
this.engine.resetCapture();
|
|
457
|
+
this.transport?.postMessage?.({
|
|
458
|
+
type: 'captureResponse',
|
|
459
|
+
requestId: message.requestId,
|
|
460
|
+
ok: true,
|
|
461
|
+
} satisfies SonareEngineCaptureResponseMessage);
|
|
462
|
+
} catch (error) {
|
|
463
|
+
this.transport?.postMessage?.({
|
|
464
|
+
type: 'captureResponse',
|
|
465
|
+
requestId: message.requestId,
|
|
466
|
+
ok: false,
|
|
467
|
+
error: error instanceof Error ? error.message : String(error),
|
|
468
|
+
} satisfies SonareEngineCaptureResponseMessage);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
receiveTransportRequest(message: SonareEngineTransportRequestMessage): void {
|
|
473
|
+
if (this.closed) {
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
try {
|
|
477
|
+
this.transport?.postMessage?.({
|
|
478
|
+
type: 'transportResponse',
|
|
479
|
+
requestId: message.requestId,
|
|
480
|
+
ok: true,
|
|
481
|
+
state: this.engine.getTransportState(),
|
|
482
|
+
} satisfies SonareEngineTransportResponseMessage);
|
|
483
|
+
} catch (error) {
|
|
484
|
+
this.transport?.postMessage?.({
|
|
485
|
+
type: 'transportResponse',
|
|
486
|
+
requestId: message.requestId,
|
|
487
|
+
ok: false,
|
|
488
|
+
error: error instanceof Error ? error.message : String(error),
|
|
489
|
+
} satisfies SonareEngineTransportResponseMessage);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
destroy(): void {
|
|
494
|
+
if (!this.closed) {
|
|
495
|
+
this.engine.destroy();
|
|
496
|
+
this.closed = true;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
private drainCommands(): void {
|
|
501
|
+
if (!this.commandRing) {
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
for (let i = 0; i < 64; i++) {
|
|
505
|
+
const command = popSonareEngineCommandRingBuffer(this.commandRing);
|
|
506
|
+
if (!command) {
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
this.applyCommand(command);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
private applyCommand(command: SonareEngineCommandRecord): void {
|
|
514
|
+
const sampleTime = Number(command.sampleTime ?? -1);
|
|
515
|
+
switch (command.type) {
|
|
516
|
+
case SonareEngineCommandType.SetParam:
|
|
517
|
+
// paramId is carried in targetId, the new value in argFloat (matches the
|
|
518
|
+
// SonareEngine.setParam producer). sampleTime is the render frame.
|
|
519
|
+
this.engine.setParameter(
|
|
520
|
+
Math.trunc(Number(command.targetId ?? 0)),
|
|
521
|
+
Number(command.argFloat ?? 0),
|
|
522
|
+
sampleTime,
|
|
523
|
+
);
|
|
524
|
+
break;
|
|
525
|
+
case SonareEngineCommandType.SetParamSmoothed:
|
|
526
|
+
this.engine.setParameterSmoothed(
|
|
527
|
+
Math.trunc(Number(command.targetId ?? 0)),
|
|
528
|
+
Number(command.argFloat ?? 0),
|
|
529
|
+
sampleTime,
|
|
530
|
+
);
|
|
531
|
+
break;
|
|
532
|
+
case SonareEngineCommandType.TransportPlay:
|
|
533
|
+
this.engine.play(sampleTime);
|
|
534
|
+
break;
|
|
535
|
+
case SonareEngineCommandType.TransportStop:
|
|
536
|
+
this.engine.stop(sampleTime);
|
|
537
|
+
break;
|
|
538
|
+
case SonareEngineCommandType.TransportSeekSample:
|
|
539
|
+
this.engine.seekSample(Number(command.argInt ?? 0), sampleTime);
|
|
540
|
+
break;
|
|
541
|
+
case SonareEngineCommandType.TransportSeekPpq:
|
|
542
|
+
this.engine.seekPpq(Number(command.argFloat ?? 0), sampleTime);
|
|
543
|
+
break;
|
|
544
|
+
case SonareEngineCommandType.SetTempoMap:
|
|
545
|
+
this.engine.setTempo(Number(command.argFloat ?? 120));
|
|
546
|
+
break;
|
|
547
|
+
case SonareEngineCommandType.SetLoop:
|
|
548
|
+
this.engine.setLoop(
|
|
549
|
+
Number(command.argFloat ?? 0),
|
|
550
|
+
Number(command.argInt ?? 0) / 1_000_000,
|
|
551
|
+
command.targetId !== 0,
|
|
552
|
+
);
|
|
553
|
+
break;
|
|
554
|
+
case SonareEngineCommandType.ArmRecord:
|
|
555
|
+
this.engine.armCapture(Boolean(command.argInt));
|
|
556
|
+
break;
|
|
557
|
+
case SonareEngineCommandType.Punch:
|
|
558
|
+
// Both endpoints already arrive as samples (see SonareEngine.punch);
|
|
559
|
+
// do NOT re-scale by sampleRate.
|
|
560
|
+
this.engine.setCapturePunch(
|
|
561
|
+
Number(command.argInt ?? 0),
|
|
562
|
+
Math.max(0, Math.round(Number(command.argFloat ?? 0))),
|
|
563
|
+
true,
|
|
564
|
+
);
|
|
565
|
+
break;
|
|
566
|
+
case SonareEngineCommandType.SetMetronome:
|
|
567
|
+
// Metronome config (beatGain/accentGain/clickSamples/clickSeconds) is
|
|
568
|
+
// delivered out-of-band via the 'syncMetronome' message so it carries
|
|
569
|
+
// the caller's full config; the command only toggles enabled state as a
|
|
570
|
+
// sample-aligned fallback.
|
|
571
|
+
this.engine.setMetronome({
|
|
572
|
+
enabled: Boolean(command.argInt),
|
|
573
|
+
beatGain: this.metronomeConfig.beatGain,
|
|
574
|
+
accentGain: this.metronomeConfig.accentGain,
|
|
575
|
+
clickSamples: this.metronomeConfig.clickSamples,
|
|
576
|
+
});
|
|
577
|
+
break;
|
|
578
|
+
case SonareEngineCommandType.SeekMarker:
|
|
579
|
+
// The realtime engine's markers are kept in sync via 'syncMarkers'
|
|
580
|
+
// (RtPublisher-style swap), so a queued kSeekMarker resolves correctly.
|
|
581
|
+
this.engine.seekMarker(Math.trunc(Number(command.targetId ?? 0)), sampleTime);
|
|
582
|
+
break;
|
|
583
|
+
case SonareEngineCommandType.SetSoloMute:
|
|
584
|
+
this.engine.setSoloMute(
|
|
585
|
+
Math.trunc(Number(command.targetId ?? 0)),
|
|
586
|
+
Boolean((Number(command.argInt ?? 0) & 0x2) !== 0),
|
|
587
|
+
Boolean((Number(command.argInt ?? 0) & 0x1) !== 0),
|
|
588
|
+
sampleTime,
|
|
589
|
+
);
|
|
590
|
+
break;
|
|
591
|
+
default:
|
|
592
|
+
this.publishTelemetryRecord({
|
|
593
|
+
type: SonareEngineTelemetryType.Error,
|
|
594
|
+
error: SonareEngineTelemetryError.UnknownTarget,
|
|
595
|
+
renderFrame: 0,
|
|
596
|
+
timelineSample: 0,
|
|
597
|
+
audibleTimelineSample: 0,
|
|
598
|
+
graphLatencySamplesQ8: 0,
|
|
599
|
+
value: Number(command.type),
|
|
600
|
+
});
|
|
601
|
+
break;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
private publishTelemetry(): void {
|
|
606
|
+
for (const item of this.engine.drainTelemetry(64)) {
|
|
607
|
+
this.publishTelemetryRecord(telemetryFromEngine(item));
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
private publishTelemetryRecord(record: SonareEngineTelemetryRecord): void {
|
|
612
|
+
if (this.telemetryRing) {
|
|
613
|
+
writeSonareEngineTelemetryRingBuffer(this.telemetryRing, record);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
this.transport?.postMessage?.(record);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Drains the engine meter telemetry queue into the stereo meter ring / transport.
|
|
620
|
+
//
|
|
621
|
+
// Shared-queue contract: `drainMeterTelemetry` and `drainMeterTelemetryWide`
|
|
622
|
+
// pop the SAME single-consumer telemetry queue, so exactly ONE of them may run
|
|
623
|
+
// per engine. The live worklet path owns the queue via the stereo drain below;
|
|
624
|
+
// the worklet meter ring (SONARE_METER_RING_RECORD_FLOATS) is a fixed stereo
|
|
625
|
+
// layout carrying planes 0/1 plus the correlation/LUFS summary. Per-plane
|
|
626
|
+
// surround meters are NOT delivered over the live worklet ring — a host that
|
|
627
|
+
// needs them must use the offline `drainMeterTelemetryWide()` API on a
|
|
628
|
+
// non-worklet engine instance (do not also call it on a worklet-driven engine,
|
|
629
|
+
// or the two drains will starve each other).
|
|
630
|
+
private publishMeters(): void {
|
|
631
|
+
if (this.meterIntervalFrames <= 0 || (!this.transport && !this.meterRing)) {
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
for (const item of this.engine.drainMeterTelemetry(64)) {
|
|
635
|
+
const meter = meterFromEngine(item);
|
|
636
|
+
if (
|
|
637
|
+
meter.frame !== this.lastMeterFrame &&
|
|
638
|
+
meter.frame - this.lastMeterFrame < this.meterIntervalFrames
|
|
639
|
+
) {
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
if (meter.frame !== this.lastMeterFrame) {
|
|
643
|
+
this.lastMeterFrame = meter.frame;
|
|
644
|
+
}
|
|
645
|
+
// Prefer the lock-free SAB meter ring (matching the telemetry path and
|
|
646
|
+
// SonareWorkletProcessor); only fall back to structured-clone postMessage
|
|
647
|
+
// when no ring was provided, so we do not allocate/post from the audio
|
|
648
|
+
// render callback in SAB mode.
|
|
649
|
+
if (this.meterRing) {
|
|
650
|
+
this.writeMeterRing(meter);
|
|
651
|
+
} else {
|
|
652
|
+
this.transport?.onMeter?.(meter);
|
|
653
|
+
this.transport?.postMessage?.(meter);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
private writeMeterRing(meter: SonareWorkletMeterSnapshot): void {
|
|
659
|
+
const ring = this.meterRing;
|
|
660
|
+
if (!ring) {
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
const writeIndex = Atomics.load(ring.header, 0);
|
|
664
|
+
const offset = (writeIndex % ring.capacity) * SONARE_METER_RING_RECORD_FLOATS;
|
|
665
|
+
ring.records[offset] = encodeFrameLo(meter.frame);
|
|
666
|
+
ring.records[offset + 1] = encodeFrameHi(meter.frame);
|
|
667
|
+
ring.records[offset + 2] = meter.targetId;
|
|
668
|
+
ring.records[offset + 3] = meter.peakDbL;
|
|
669
|
+
ring.records[offset + 4] = meter.peakDbR;
|
|
670
|
+
ring.records[offset + 5] = meter.rmsDbL;
|
|
671
|
+
ring.records[offset + 6] = meter.rmsDbR;
|
|
672
|
+
ring.records[offset + 7] = meter.correlation;
|
|
673
|
+
ring.records[offset + 8] = meter.truePeakDbL;
|
|
674
|
+
ring.records[offset + 9] = meter.truePeakDbR;
|
|
675
|
+
ring.records[offset + 10] = meter.momentaryLufs;
|
|
676
|
+
ring.records[offset + 11] = meter.shortTermLufs;
|
|
677
|
+
ring.records[offset + 12] = meter.integratedLufs;
|
|
678
|
+
ring.records[offset + 13] = meter.gainReductionDb;
|
|
679
|
+
Atomics.store(ring.header, 0, writeIndex + 1);
|
|
680
|
+
// writeIndex is a free-running monotonic counter, so an overflow guard here
|
|
681
|
+
// would fire on essentially every write past the first `capacity` records
|
|
682
|
+
// and store an ever-growing value, not a dropped-record count. Readers
|
|
683
|
+
// already detect silent overrun via firstReadable = max(readIndex,
|
|
684
|
+
// writeIndex - capacity), so header slot 3 is left at its initial 0.
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Drains the engine's scope producer (FFT spectrum + goniometer points) into
|
|
688
|
+
// the lock-free SAB scope ring. No allocation on the render path: records are
|
|
689
|
+
// written field-by-field into the ring.
|
|
690
|
+
private publishScope(): void {
|
|
691
|
+
const ring = this.scopeRing;
|
|
692
|
+
if (!ring) {
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
for (const item of this.engine.drainScopeTelemetry(64)) {
|
|
696
|
+
this.writeScopeRing(ring, item);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Drains queued external-MIDI events (already lowered to MIDI 1.0 bytes) and
|
|
701
|
+
// forwards them to the main thread for delivery to Web MIDI output ports.
|
|
702
|
+
// One batch per render block; skipped entirely when nothing is queued, so an
|
|
703
|
+
// all-internal project never allocates or posts here.
|
|
704
|
+
private publishExternalMidi(): void {
|
|
705
|
+
if (!this.transport?.postMessage) {
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
const events = this.engine.drainExternalMidi(256);
|
|
709
|
+
if (events.length === 0) {
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
this.transport.postMessage({ type: 'externalMidi', events });
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
private writeScopeRing(ring: SharedScopeRingWriter, record: EngineScopeTelemetry): void {
|
|
716
|
+
const writeIndex = Atomics.load(ring.header, 0);
|
|
717
|
+
const base = (writeIndex % ring.capacity) * ring.recordFloats;
|
|
718
|
+
ring.records[base] = encodeFrameLo(record.renderFrame);
|
|
719
|
+
ring.records[base + 1] = encodeFrameHi(record.renderFrame);
|
|
720
|
+
ring.records[base + 2] = record.targetId;
|
|
721
|
+
const bandCount = Math.min(ring.bands, record.bands.length);
|
|
722
|
+
ring.records[base + 3] = bandCount;
|
|
723
|
+
const pointCount = Math.min(ring.maxPoints, record.points.length);
|
|
724
|
+
ring.records[base + 4] = pointCount;
|
|
725
|
+
const bandsBase = base + SONARE_SCOPE_RING_RECORD_PREFIX_FLOATS;
|
|
726
|
+
for (let i = 0; i < bandCount; i++) {
|
|
727
|
+
ring.records[bandsBase + i] = record.bands[i];
|
|
728
|
+
}
|
|
729
|
+
const pointsBase = bandsBase + ring.bands;
|
|
730
|
+
for (let i = 0; i < pointCount; i++) {
|
|
731
|
+
const point = record.points[i];
|
|
732
|
+
ring.records[pointsBase + 2 * i] = point.left;
|
|
733
|
+
ring.records[pointsBase + 2 * i + 1] = point.right;
|
|
734
|
+
}
|
|
735
|
+
Atomics.store(ring.header, 0, writeIndex + 1);
|
|
736
|
+
// Like writeMeterRing, writeIndex is a free-running monotonic counter; the
|
|
737
|
+
// reader detects silent overrun via firstReadable, so the overflow slot
|
|
738
|
+
// (header[5]) stays at its initial 0.
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
private commandRingFromSharedBuffer(
|
|
742
|
+
sharedBuffer: SharedArrayBuffer,
|
|
743
|
+
fallbackCapacity?: number,
|
|
744
|
+
): SonareEngineCommandRingBuffer {
|
|
745
|
+
const ring = engineRingFromSharedBuffer(
|
|
746
|
+
sharedBuffer,
|
|
747
|
+
SONARE_ENGINE_COMMAND_RECORD_BYTES,
|
|
748
|
+
fallbackCapacity,
|
|
749
|
+
);
|
|
750
|
+
return { sharedBuffer, header: ring.header, view: ring.view, capacity: ring.capacity };
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
private telemetryRingFromSharedBuffer(
|
|
754
|
+
sharedBuffer: SharedArrayBuffer,
|
|
755
|
+
fallbackCapacity?: number,
|
|
756
|
+
): SonareEngineTelemetryRingBuffer {
|
|
757
|
+
const ring = engineRingFromSharedBuffer(
|
|
758
|
+
sharedBuffer,
|
|
759
|
+
SONARE_ENGINE_TELEMETRY_RECORD_BYTES,
|
|
760
|
+
fallbackCapacity,
|
|
761
|
+
);
|
|
762
|
+
return { sharedBuffer, header: ring.header, view: ring.view, capacity: ring.capacity };
|
|
763
|
+
}
|
|
764
|
+
}
|