@libraz/libsonare 1.5.5 → 1.7.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 +109 -20
- package/dist/analysis.d.ts +5809 -0
- package/dist/analysis.js +2574 -0
- package/dist/analysis.js.map +1 -0
- package/dist/index.d.ts +884 -55
- package/dist/index.js +1302 -292
- package/dist/index.js.map +1 -1
- package/dist/schemas/realtime-voice-changer-preset-pack.schema.json +27 -0
- package/dist/schemas/realtime-voice-changer-preset.schema.json +140 -0
- package/dist/sonare-analysis.js +2 -0
- package/dist/sonare-analysis.wasm +0 -0
- package/dist/sonare.js +1 -1
- package/dist/sonare.wasm +0 -0
- package/dist/worker.d.ts +65 -0
- package/dist/worker.js +734 -0
- package/dist/worker.js.map +1 -0
- package/dist/worklet.d.ts +628 -88
- package/dist/worklet.js +1816 -266
- package/dist/worklet.js.map +1 -1
- package/package.json +23 -11
- package/src/_chain_config.ts +5 -3
- package/src/analysis.ts +137 -0
- package/src/analysis_helpers.ts +40 -29
- package/src/audio.ts +7 -2
- package/src/clip_page_streamer.ts +62 -8
- package/src/codes.ts +87 -64
- package/src/effects_mastering.ts +1 -0
- package/src/effects_transform.ts +179 -10
- package/src/errors.ts +1 -0
- package/src/feature_core.ts +140 -2
- package/src/feature_music.ts +25 -3
- package/src/feature_pitch.ts +70 -1
- package/src/feature_spectral.ts +295 -7
- package/src/feature_spectrogram.ts +203 -3
- package/src/features.ts +16 -1
- package/src/index.ts +120 -8
- package/src/mastering_chain.ts +20 -8
- package/src/mastering_core.ts +5 -0
- package/src/mastering_dynamics.ts +7 -1
- package/src/metering.ts +6 -1
- package/src/mixer.ts +3 -3
- package/src/opfs_clip_pages.ts +26 -1
- package/src/project.ts +13 -0
- package/src/project_class.ts +204 -23
- package/src/project_internal.ts +38 -34
- package/src/project_types.ts +114 -11
- package/src/public_types.ts +68 -0
- package/src/public_types_mastering.ts +22 -0
- package/src/public_types_mixing.ts +27 -2
- package/src/public_types_music.ts +9 -0
- package/src/public_types_realtime.ts +23 -5
- package/src/public_types_spectral.ts +33 -0
- package/src/quick_analysis.ts +46 -10
- package/src/realtime_engine.ts +167 -6
- package/src/realtime_voice_changer.ts +51 -77
- package/src/sonare-analysis.js.d.ts +8 -0
- package/src/sonare.js.d.ts +340 -6
- package/src/stream_analyzer.ts +6 -1
- package/src/streaming_processors.ts +15 -0
- package/src/web_midi.ts +46 -11
- package/src/worker.ts +196 -0
- package/src/worker_client.ts +352 -0
- package/src/worker_protocol.ts +59 -0
- package/src/worklet/engine-automation.ts +2 -1
- package/src/worklet/engine-capture-facade.ts +13 -4
- package/src/worklet/engine-clips.ts +12 -3
- package/src/worklet/engine-node.ts +264 -21
- package/src/worklet/engine-offline.ts +7 -2
- package/src/worklet/engine-parameter-facade.ts +55 -5
- package/src/worklet/engine-processor.ts +435 -62
- package/src/worklet/engine-register.ts +9 -1
- package/src/worklet/engine-strips.ts +19 -3
- package/src/worklet/engine-sync.ts +2 -6
- package/src/worklet/engine-tempo-facade.ts +8 -6
- package/src/worklet/engine.ts +390 -13
- package/src/worklet/guards.ts +121 -8
- package/src/worklet/messages.ts +166 -8
- package/src/worklet/protocol.ts +305 -17
- package/src/worklet/voice-changer-processor.ts +12 -15
- package/src/worklet.ts +20 -1
package/src/worklet/engine.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ClipPageStreamer,
|
|
3
|
+
type ClipPageStreamerRequest,
|
|
4
|
+
type OpfsClipStreamOptions,
|
|
5
|
+
} from '../clip_page_streamer';
|
|
1
6
|
import type {
|
|
2
7
|
EngineAutomationPoint,
|
|
3
8
|
EngineBus,
|
|
@@ -10,13 +15,17 @@ import type {
|
|
|
10
15
|
EngineTempoSegment,
|
|
11
16
|
EngineTimeSignatureSegment,
|
|
12
17
|
EngineTrackLane,
|
|
18
|
+
EngineTrackMonitorMode,
|
|
13
19
|
EngineTrackSend,
|
|
14
20
|
EngineTransportState,
|
|
15
21
|
EqBand,
|
|
22
|
+
MidiCcBindOptions,
|
|
16
23
|
PanLaw,
|
|
17
24
|
PanMode,
|
|
18
25
|
} from '../index';
|
|
19
26
|
import { RealtimeEngine } from '../index';
|
|
27
|
+
import { createOpfsClipPageProvider, type OpfsClipPageProviderBinding } from '../opfs_clip_pages';
|
|
28
|
+
import type { ClipPageProvider } from '../realtime_engine';
|
|
20
29
|
import type { EngineAutomationContext } from './engine-automation';
|
|
21
30
|
import * as automation from './engine-automation';
|
|
22
31
|
import type { EngineCaptureContext } from './engine-capture-facade';
|
|
@@ -56,6 +65,24 @@ import {
|
|
|
56
65
|
type SonareWorkletScopeSnapshot,
|
|
57
66
|
} from './protocol';
|
|
58
67
|
|
|
68
|
+
const MAX_PENDING_WORKLET_CLIP_PAGE_REQUESTS = 256;
|
|
69
|
+
|
|
70
|
+
function transferableAudioBuffers(channels: readonly Float32Array[]): Transferable[] {
|
|
71
|
+
const transfers: ArrayBuffer[] = [];
|
|
72
|
+
const seen = new Set<ArrayBuffer>();
|
|
73
|
+
for (const channel of channels) {
|
|
74
|
+
const buffer = channel.buffer;
|
|
75
|
+
// SharedArrayBuffer is cloneable but cannot appear in a transfer list. A
|
|
76
|
+
// worker may intentionally return SAB-backed pages, so transfer only plain,
|
|
77
|
+
// distinct ArrayBuffers and let structured clone share SABs by reference.
|
|
78
|
+
if (buffer instanceof ArrayBuffer && !seen.has(buffer)) {
|
|
79
|
+
seen.add(buffer);
|
|
80
|
+
transfers.push(buffer);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return transfers;
|
|
84
|
+
}
|
|
85
|
+
|
|
59
86
|
export class SonareEngine {
|
|
60
87
|
readonly node: AudioWorkletNode;
|
|
61
88
|
readonly capabilities: SonareRealtimeEngineNodeCapabilities;
|
|
@@ -93,6 +120,15 @@ export class SonareEngine {
|
|
|
93
120
|
private nextMarkerId = 1;
|
|
94
121
|
private transportPlaying = false;
|
|
95
122
|
private readonly pendingInstrumentSync: SonareEngineInstrumentSyncMessage[] = [];
|
|
123
|
+
// One latest frontier per clip is sufficient: ClipPageStreamer expands it to
|
|
124
|
+
// the bounded read window. A Map both coalesces repeated page misses and
|
|
125
|
+
// places a hard cap on work queued while OPFS I/O is stalled.
|
|
126
|
+
private readonly workletClipPageRequests = new Map<number, ClipPageStreamerRequest>();
|
|
127
|
+
private readonly workletPageProviderClipIds = new Map<number, number>();
|
|
128
|
+
private workletClipStreamer: ClipPageStreamer | undefined;
|
|
129
|
+
private workletClipPump: Promise<void> | undefined;
|
|
130
|
+
private workletClipPagePollTimer: ReturnType<typeof setInterval> | undefined;
|
|
131
|
+
private unsubscribeWorkletClipRequests: (() => void) | undefined;
|
|
96
132
|
private destroyed = false;
|
|
97
133
|
|
|
98
134
|
private constructor(
|
|
@@ -111,10 +147,17 @@ export class SonareEngine {
|
|
|
111
147
|
this.sampleRate = sampleRate;
|
|
112
148
|
this.offlineBlockSize = offlineBlockSize;
|
|
113
149
|
this.offlineChannelCount = offlineChannelCount;
|
|
150
|
+
this.unsubscribeWorkletClipRequests = this.realtimeNode.onClipPageRequests((message) => {
|
|
151
|
+
for (const request of message.requests) {
|
|
152
|
+
this.enqueueWorkletClipPageRequest(request);
|
|
153
|
+
}
|
|
154
|
+
this.pumpWorkletClipPages();
|
|
155
|
+
});
|
|
114
156
|
this.transport = buildTransportFacade({
|
|
115
157
|
sampleRate: this.sampleRate,
|
|
116
158
|
realtimeNode: this.realtimeNode,
|
|
117
159
|
offlineEngine: this.offlineEngine,
|
|
160
|
+
flushOfflineMirror: () => this.flushOfflineMirror(),
|
|
118
161
|
setTransportPlaying: (playing) => {
|
|
119
162
|
this.transportPlaying = playing;
|
|
120
163
|
},
|
|
@@ -136,8 +179,17 @@ export class SonareEngine {
|
|
|
136
179
|
Math.floor(options.offlineChannelCount ?? options.channelCount ?? 2),
|
|
137
180
|
);
|
|
138
181
|
const realtimeNode = await SonareRealtimeEngineNode.create(context, options);
|
|
182
|
+
try {
|
|
183
|
+
// Do not expose the facade while the registered processor is still
|
|
184
|
+
// buffering messages. Long clip sync must either begin after the bridge
|
|
185
|
+
// is ready or fail explicitly; it may never be silently truncated.
|
|
186
|
+
await realtimeNode.ready;
|
|
187
|
+
} catch (error) {
|
|
188
|
+
realtimeNode.destroy();
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
139
191
|
const offlineEngine = options.offlineEngine ?? new RealtimeEngine(sampleRate, blockSize);
|
|
140
|
-
|
|
192
|
+
const engine = new SonareEngine(
|
|
141
193
|
context,
|
|
142
194
|
realtimeNode,
|
|
143
195
|
offlineEngine,
|
|
@@ -145,6 +197,8 @@ export class SonareEngine {
|
|
|
145
197
|
blockSize,
|
|
146
198
|
channelCount,
|
|
147
199
|
);
|
|
200
|
+
engine.syncParameters();
|
|
201
|
+
return engine;
|
|
148
202
|
}
|
|
149
203
|
|
|
150
204
|
async suspend(): Promise<void> {
|
|
@@ -319,10 +373,29 @@ export class SonareEngine {
|
|
|
319
373
|
return parameter.listParameters(this.parameterContext);
|
|
320
374
|
}
|
|
321
375
|
|
|
376
|
+
/** Registers a custom parameter on the offline mirror and worklet engine. */
|
|
377
|
+
addParameter(info: EngineParameterInfo): void {
|
|
378
|
+
parameter.addParameter(this.parameterContext, info);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Clears custom parameters and their automation lanes on both engines. */
|
|
382
|
+
clearParameters(): void {
|
|
383
|
+
parameter.clearParameters(this.parameterContext);
|
|
384
|
+
}
|
|
385
|
+
|
|
322
386
|
setSoloMute(target: string | number, solo: boolean, mute: boolean): boolean {
|
|
323
387
|
return parameter.setSoloMute(this.parameterContext, target, solo, mute);
|
|
324
388
|
}
|
|
325
389
|
|
|
390
|
+
/** Queues a per-track PFL/AFL monitor tap mode change. */
|
|
391
|
+
setTrackMonitorMode(
|
|
392
|
+
target: string | number,
|
|
393
|
+
mode: EngineTrackMonitorMode,
|
|
394
|
+
renderFrame = -1,
|
|
395
|
+
): boolean {
|
|
396
|
+
return parameter.setTrackMonitorMode(this.parameterContext, target, mode, renderFrame);
|
|
397
|
+
}
|
|
398
|
+
|
|
326
399
|
setStripGain(target: string | number, db: number): boolean {
|
|
327
400
|
return this.sendSmoothedParam(this.stripParamId(target, ENGINE_MIXER_PARAM_FADER_DB), db);
|
|
328
401
|
}
|
|
@@ -516,11 +589,86 @@ export class SonareEngine {
|
|
|
516
589
|
this.setMasterStripJson(sceneJson);
|
|
517
590
|
}
|
|
518
591
|
|
|
592
|
+
/**
|
|
593
|
+
* Creates and primes an OPFS-backed page provider for this live worklet
|
|
594
|
+
* engine. Pass the returned `provider` to {@link addClip} in place of a
|
|
595
|
+
* `Float32Array[]`; the `clipId` in `options` must equal that clip's explicit
|
|
596
|
+
* `opts.id`. Subsequent cache misses are fetched on the main thread and
|
|
597
|
+
* supplied back to the worklet through its bounded pull protocol.
|
|
598
|
+
*/
|
|
599
|
+
async attachOpfsClipStream(
|
|
600
|
+
options: OpfsClipStreamOptions,
|
|
601
|
+
): Promise<{ binding: OpfsClipPageProviderBinding; provider: ClipPageProvider }> {
|
|
602
|
+
if (this.destroyed) {
|
|
603
|
+
throw new Error('SonareEngine is destroyed.');
|
|
604
|
+
}
|
|
605
|
+
if (!this.capabilities.clipPageRequestsRealtimeSafe) {
|
|
606
|
+
throw new Error(
|
|
607
|
+
'OPFS clip streaming requires SharedArrayBuffer clip-page requests; the postMessage fallback is not realtime-safe.',
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
const { clipId, primePages = 1, ...providerOptions } = options;
|
|
611
|
+
if ([...this.workletPageProviderClipIds.values()].includes(clipId)) {
|
|
612
|
+
throw new Error(`An OPFS stream is already attached for clip ${clipId}.`);
|
|
613
|
+
}
|
|
614
|
+
const streamer = this.ensureWorkletClipStreamer();
|
|
615
|
+
// Allocate the worklet-owned provider before priming: port message order
|
|
616
|
+
// ensures every prime page is adopted before addClip commits its schedule.
|
|
617
|
+
this.postSync({
|
|
618
|
+
type: 'syncClipPageProvider',
|
|
619
|
+
clipId,
|
|
620
|
+
numChannels: providerOptions.numChannels,
|
|
621
|
+
numSamples: providerOptions.numSamples,
|
|
622
|
+
pageFrames: providerOptions.pageFrames,
|
|
623
|
+
});
|
|
624
|
+
let binding: OpfsClipPageProviderBinding;
|
|
625
|
+
binding = createOpfsClipPageProvider(this.offlineEngine, {
|
|
626
|
+
...providerOptions,
|
|
627
|
+
onPageSupplied: (pageIndex, channels) => {
|
|
628
|
+
this.postSync(
|
|
629
|
+
{ type: 'syncClipPage', clipId, pageIndex, channels },
|
|
630
|
+
transferableAudioBuffers(channels),
|
|
631
|
+
);
|
|
632
|
+
},
|
|
633
|
+
onPageCleared: (pageIndex) => {
|
|
634
|
+
this.postSync({ type: 'syncClipPageClear', clipId, pageIndex });
|
|
635
|
+
},
|
|
636
|
+
onClose: () => {
|
|
637
|
+
this.workletPageProviderClipIds.delete(binding.provider.id);
|
|
638
|
+
this.postSync({ type: 'syncClipPageDestroy', clipId });
|
|
639
|
+
},
|
|
640
|
+
});
|
|
641
|
+
this.workletPageProviderClipIds.set(binding.provider.id, clipId);
|
|
642
|
+
const lastPage = Math.ceil(providerOptions.numSamples / providerOptions.pageFrames) - 1;
|
|
643
|
+
const primed: number[] = [];
|
|
644
|
+
try {
|
|
645
|
+
for (let page = 0; page < primePages && page <= lastPage; ++page) {
|
|
646
|
+
if (await binding.supplyPage(page)) {
|
|
647
|
+
primed.push(page);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
streamer.addSource(
|
|
651
|
+
{
|
|
652
|
+
clipId,
|
|
653
|
+
binding,
|
|
654
|
+
pageFrames: providerOptions.pageFrames,
|
|
655
|
+
numSamples: providerOptions.numSamples,
|
|
656
|
+
},
|
|
657
|
+
primed,
|
|
658
|
+
);
|
|
659
|
+
this.startWorkletClipPagePolling();
|
|
660
|
+
} catch (error) {
|
|
661
|
+
binding.close();
|
|
662
|
+
throw error;
|
|
663
|
+
}
|
|
664
|
+
return { binding, provider: binding.provider };
|
|
665
|
+
}
|
|
666
|
+
|
|
519
667
|
addClip(
|
|
520
668
|
trackId: string | number,
|
|
521
|
-
buffer: Float32Array[],
|
|
669
|
+
buffer: Float32Array[] | ClipPageProvider,
|
|
522
670
|
startPpq: number,
|
|
523
|
-
opts: Partial<Omit<EngineClip, 'channels' | 'startPpq'>> = {},
|
|
671
|
+
opts: Partial<Omit<EngineClip, 'channels' | 'pageProvider' | 'startPpq'>> = {},
|
|
524
672
|
): number {
|
|
525
673
|
return clips.addClip(this.clipContext, trackId, buffer, startPpq, opts);
|
|
526
674
|
}
|
|
@@ -550,7 +698,12 @@ export class SonareEngine {
|
|
|
550
698
|
|
|
551
699
|
setSf2Instrument(
|
|
552
700
|
trackId: string | number,
|
|
553
|
-
config: {
|
|
701
|
+
config: {
|
|
702
|
+
destinationId?: number;
|
|
703
|
+
gain?: number;
|
|
704
|
+
polyphony?: number;
|
|
705
|
+
preferModelForModeledFamilies?: boolean;
|
|
706
|
+
} = {},
|
|
554
707
|
): void {
|
|
555
708
|
strips.setSf2Instrument(this.stripContext, trackId, config);
|
|
556
709
|
}
|
|
@@ -623,10 +776,90 @@ export class SonareEngine {
|
|
|
623
776
|
strips.pushMidiCc(this.stripContext, trackId, group, channel, controller, value, renderFrame);
|
|
624
777
|
}
|
|
625
778
|
|
|
779
|
+
pushMidiUmp(trackId: string | number, word0: number, renderFrame = -1): void {
|
|
780
|
+
strips.pushMidiUmp(this.stripContext, trackId, word0, renderFrame);
|
|
781
|
+
}
|
|
782
|
+
|
|
626
783
|
pushMidiSysex(trackId: string | number, data: Uint8Array, renderFrame = -1): void {
|
|
627
784
|
strips.pushMidiSysex(this.stripContext, trackId, data, renderFrame);
|
|
628
785
|
}
|
|
629
786
|
|
|
787
|
+
bindMidiCc(
|
|
788
|
+
channel: number,
|
|
789
|
+
controller: number,
|
|
790
|
+
paramId: number,
|
|
791
|
+
options: MidiCcBindOptions = {},
|
|
792
|
+
): void {
|
|
793
|
+
const minValue = options.minValue ?? 0;
|
|
794
|
+
const maxValue = options.maxValue ?? 1;
|
|
795
|
+
this.offlineEngine.bindMidiCc(channel, controller, paramId, { minValue, maxValue });
|
|
796
|
+
this.postSync({ type: 'syncMidiCcBinding', channel, controller, paramId, minValue, maxValue });
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
setMidiInputSource(destinationId = 0): void {
|
|
800
|
+
this.offlineEngine.setMidiInputSource(destinationId);
|
|
801
|
+
this.postSync({ type: 'syncMidiInputSource', destinationId });
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
clearMidiInputSource(): void {
|
|
805
|
+
this.offlineEngine.clearMidiInputSource();
|
|
806
|
+
this.postSync({ type: 'syncClearMidiInputSource' });
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
pushMidiInputNoteOn(
|
|
810
|
+
group: number,
|
|
811
|
+
channel: number,
|
|
812
|
+
note: number,
|
|
813
|
+
velocity: number,
|
|
814
|
+
portTimeSamples = 0,
|
|
815
|
+
): void {
|
|
816
|
+
this.offlineEngine.pushMidiInputNoteOn(group, channel, note, velocity, portTimeSamples);
|
|
817
|
+
this.postSync({
|
|
818
|
+
type: 'syncMidiInputNoteOn',
|
|
819
|
+
group,
|
|
820
|
+
channel,
|
|
821
|
+
data0: note,
|
|
822
|
+
data1: velocity,
|
|
823
|
+
portTimeSamples,
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
pushMidiInputNoteOff(
|
|
828
|
+
group: number,
|
|
829
|
+
channel: number,
|
|
830
|
+
note: number,
|
|
831
|
+
velocity = 0,
|
|
832
|
+
portTimeSamples = 0,
|
|
833
|
+
): void {
|
|
834
|
+
this.offlineEngine.pushMidiInputNoteOff(group, channel, note, velocity, portTimeSamples);
|
|
835
|
+
this.postSync({
|
|
836
|
+
type: 'syncMidiInputNoteOff',
|
|
837
|
+
group,
|
|
838
|
+
channel,
|
|
839
|
+
data0: note,
|
|
840
|
+
data1: velocity,
|
|
841
|
+
portTimeSamples,
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
pushMidiInputCc(
|
|
846
|
+
group: number,
|
|
847
|
+
channel: number,
|
|
848
|
+
controller: number,
|
|
849
|
+
value: number,
|
|
850
|
+
portTimeSamples = 0,
|
|
851
|
+
): void {
|
|
852
|
+
this.offlineEngine.pushMidiInputCc(group, channel, controller, value, portTimeSamples);
|
|
853
|
+
this.postSync({
|
|
854
|
+
type: 'syncMidiInputCc',
|
|
855
|
+
group,
|
|
856
|
+
channel,
|
|
857
|
+
data0: controller,
|
|
858
|
+
data1: value,
|
|
859
|
+
portTimeSamples,
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
|
|
630
863
|
pushMidiPanic(renderFrame = -1): void {
|
|
631
864
|
this.offlineEngine.pushMidiPanic(renderFrame);
|
|
632
865
|
this.postSync({ type: 'syncMidiPanic', renderFrame });
|
|
@@ -636,6 +869,10 @@ export class SonareEngine {
|
|
|
636
869
|
capture.configureCapture(this.captureContext, options);
|
|
637
870
|
}
|
|
638
871
|
|
|
872
|
+
/**
|
|
873
|
+
* Arms the engine-global capture path. `trackId` is retained for source
|
|
874
|
+
* compatibility and must be `0`; per-track capture is not implemented.
|
|
875
|
+
*/
|
|
639
876
|
armRecord(trackId: string | number, enabled: boolean): boolean {
|
|
640
877
|
return capture.armRecord(this.captureContext, trackId, enabled);
|
|
641
878
|
}
|
|
@@ -662,7 +899,7 @@ export class SonareEngine {
|
|
|
662
899
|
// the fixed-size SAB command record, so it is delivered out-of-band; the
|
|
663
900
|
// SetMetronome command then toggles enabled state on the audio thread.
|
|
664
901
|
this.postSync({ type: 'syncMetronome', config: opts });
|
|
665
|
-
this.
|
|
902
|
+
this.sendMirroredCommand({
|
|
666
903
|
type: SonareEngineCommandType.SetMetronome,
|
|
667
904
|
sampleTime: -1,
|
|
668
905
|
argInt: opts.enabled ? 1 : 0,
|
|
@@ -748,6 +985,15 @@ export class SonareEngine {
|
|
|
748
985
|
if (this.destroyed) {
|
|
749
986
|
return;
|
|
750
987
|
}
|
|
988
|
+
this.unsubscribeWorkletClipRequests?.();
|
|
989
|
+
this.unsubscribeWorkletClipRequests = undefined;
|
|
990
|
+
this.workletClipStreamer?.close();
|
|
991
|
+
this.workletClipStreamer = undefined;
|
|
992
|
+
if (this.workletClipPagePollTimer !== undefined) {
|
|
993
|
+
clearInterval(this.workletClipPagePollTimer);
|
|
994
|
+
this.workletClipPagePollTimer = undefined;
|
|
995
|
+
}
|
|
996
|
+
this.workletClipPageRequests.clear();
|
|
751
997
|
this.destroyed = true;
|
|
752
998
|
this.transport.stop();
|
|
753
999
|
this.realtimeNode.pollTelemetry();
|
|
@@ -755,6 +1001,107 @@ export class SonareEngine {
|
|
|
755
1001
|
this.offlineEngine.destroy();
|
|
756
1002
|
}
|
|
757
1003
|
|
|
1004
|
+
private ensureWorkletClipStreamer(): ClipPageStreamer {
|
|
1005
|
+
if (!this.workletClipStreamer) {
|
|
1006
|
+
this.workletClipStreamer = new ClipPageStreamer({
|
|
1007
|
+
popClipPageRequest: () => this.popWorkletClipPageRequest(),
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
return this.workletClipStreamer;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* SAB requests have no postMessage wake-up by design. Polling on the main
|
|
1015
|
+
* thread is therefore intentionally outside the audio callback; 8 ms keeps
|
|
1016
|
+
* the bounded OPFS prefetch frontier responsive without adding worklet GC.
|
|
1017
|
+
*/
|
|
1018
|
+
private startWorkletClipPagePolling(): void {
|
|
1019
|
+
if (this.workletClipPagePollTimer !== undefined) {
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
const poll = () => {
|
|
1023
|
+
if (!this.destroyed) {
|
|
1024
|
+
this.realtimeNode.pollClipPageRequests();
|
|
1025
|
+
}
|
|
1026
|
+
};
|
|
1027
|
+
poll();
|
|
1028
|
+
this.workletClipPagePollTimer = setInterval(poll, 8);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
private pumpWorkletClipPages(): void {
|
|
1032
|
+
const streamer = this.workletClipStreamer;
|
|
1033
|
+
if (!streamer || this.workletClipPump) {
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
this.workletClipPump = streamer
|
|
1037
|
+
.pump()
|
|
1038
|
+
.catch((error: unknown) => {
|
|
1039
|
+
// The worklet keeps rendering silence for an unavailable page. Surface
|
|
1040
|
+
// I/O failures without converting them into an AudioWorklet exception.
|
|
1041
|
+
// biome-ignore lint/suspicious/noConsole: asynchronous OPFS diagnostic.
|
|
1042
|
+
console.error('Sonare OPFS clip-page supply failed:', error);
|
|
1043
|
+
})
|
|
1044
|
+
.finally(() => {
|
|
1045
|
+
this.workletClipPump = undefined;
|
|
1046
|
+
if (this.workletClipPageRequests.size > 0) {
|
|
1047
|
+
this.pumpWorkletClipPages();
|
|
1048
|
+
}
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
private enqueueWorkletClipPageRequest(request: ClipPageStreamerRequest): void {
|
|
1053
|
+
if (
|
|
1054
|
+
!Number.isInteger(request.clipId) ||
|
|
1055
|
+
request.clipId < 0 ||
|
|
1056
|
+
!Number.isInteger(request.pageIndex) ||
|
|
1057
|
+
(request.pageIndex ?? -1) < 0
|
|
1058
|
+
) {
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
// Reinsert an existing id so iteration order represents newest frontiers.
|
|
1062
|
+
this.workletClipPageRequests.delete(request.clipId);
|
|
1063
|
+
if (this.workletClipPageRequests.size >= MAX_PENDING_WORKLET_CLIP_PAGE_REQUESTS) {
|
|
1064
|
+
const oldest = this.workletClipPageRequests.keys().next().value;
|
|
1065
|
+
if (oldest !== undefined) {
|
|
1066
|
+
this.workletClipPageRequests.delete(oldest);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
this.workletClipPageRequests.set(request.clipId, request);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
private popWorkletClipPageRequest(): ClipPageStreamerRequest | null {
|
|
1073
|
+
const entry = this.workletClipPageRequests.entries().next().value;
|
|
1074
|
+
if (!entry) {
|
|
1075
|
+
return null;
|
|
1076
|
+
}
|
|
1077
|
+
const [clipId, request] = entry;
|
|
1078
|
+
this.workletClipPageRequests.delete(clipId);
|
|
1079
|
+
return request;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
private commitWorkletClipPageProvider(clip: EngineClip): boolean {
|
|
1083
|
+
const providerId =
|
|
1084
|
+
typeof clip.pageProvider === 'object' && clip.pageProvider !== null
|
|
1085
|
+
? clip.pageProvider.id
|
|
1086
|
+
: clip.pageProvider;
|
|
1087
|
+
if (providerId === undefined) {
|
|
1088
|
+
return false;
|
|
1089
|
+
}
|
|
1090
|
+
const clipId = this.workletPageProviderClipIds.get(providerId);
|
|
1091
|
+
if (clipId === undefined) {
|
|
1092
|
+
return false;
|
|
1093
|
+
}
|
|
1094
|
+
if (clip.id !== clipId) {
|
|
1095
|
+
throw new Error(`OPFS stream clipId ${clipId} must match addClip(..., { id: ${clipId} }).`);
|
|
1096
|
+
}
|
|
1097
|
+
this.postSync({
|
|
1098
|
+
type: 'syncClipPageCommit',
|
|
1099
|
+
clipId,
|
|
1100
|
+
clip: { ...clip, channels: undefined, pageProvider: undefined },
|
|
1101
|
+
});
|
|
1102
|
+
return true;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
758
1105
|
private mixerLanes(): EngineTrackLane[] {
|
|
759
1106
|
return mixer.mixerLanes(this.mixerContext);
|
|
760
1107
|
}
|
|
@@ -791,13 +1138,32 @@ export class SonareEngine {
|
|
|
791
1138
|
if (this.destroyed) {
|
|
792
1139
|
return;
|
|
793
1140
|
}
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
1141
|
+
try {
|
|
1142
|
+
if (transfer && transfer.length > 0) {
|
|
1143
|
+
this.realtimeNode.node.port.postMessage(message, transfer);
|
|
1144
|
+
} else {
|
|
1145
|
+
this.realtimeNode.node.port.postMessage(message);
|
|
1146
|
+
}
|
|
1147
|
+
} finally {
|
|
1148
|
+
this.flushOfflineMirror();
|
|
798
1149
|
}
|
|
799
1150
|
}
|
|
800
1151
|
|
|
1152
|
+
// The offline engine is a control-thread mirror. It has no render loop to
|
|
1153
|
+
// drain its command ring, so drain it after each control operation. Never
|
|
1154
|
+
// call this from the worklet/audio path: it is intentionally control-only.
|
|
1155
|
+
private flushOfflineMirror(): void {
|
|
1156
|
+
this.offlineEngine.flushControlCommands();
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
private sendMirroredCommand(
|
|
1160
|
+
command: Parameters<SonareRealtimeEngineNode['sendCommand']>[0],
|
|
1161
|
+
): boolean {
|
|
1162
|
+
const accepted = this.realtimeNode.sendCommand(command);
|
|
1163
|
+
this.flushOfflineMirror();
|
|
1164
|
+
return accepted;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
801
1167
|
// Collaborator surface handed to the mixer/routing free functions so they can
|
|
802
1168
|
// mutate the routing stores (held by reference), mirror into the offline
|
|
803
1169
|
// engine, post mixer-sync messages, and declare lanes/buses without a
|
|
@@ -855,13 +1221,13 @@ export class SonareEngine {
|
|
|
855
1221
|
return {
|
|
856
1222
|
offlineEngine: this.offlineEngine,
|
|
857
1223
|
realtimeNode: this.realtimeNode,
|
|
1224
|
+
sendCommand: (command) => this.sendMirroredCommand(command),
|
|
858
1225
|
offlineChannelCount: this.offlineChannelCount,
|
|
859
1226
|
postSync: (message) => this.postSync(message),
|
|
860
1227
|
getCaptureConfig: () => this.captureConfig,
|
|
861
1228
|
setCaptureConfig: (config) => {
|
|
862
1229
|
this.captureConfig = config;
|
|
863
1230
|
},
|
|
864
|
-
resolveTargetId: (target) => this.resolveTargetId(target),
|
|
865
1231
|
};
|
|
866
1232
|
}
|
|
867
1233
|
|
|
@@ -872,7 +1238,9 @@ export class SonareEngine {
|
|
|
872
1238
|
private get parameterContext(): EngineParameterContext {
|
|
873
1239
|
return {
|
|
874
1240
|
offlineEngine: this.offlineEngine,
|
|
875
|
-
|
|
1241
|
+
sendCommand: (command) => this.sendMirroredCommand(command),
|
|
1242
|
+
postSync: (message) => this.postSync(message),
|
|
1243
|
+
automationLanes: this.automationLanes,
|
|
876
1244
|
trackLaneIds: this.trackLaneIds,
|
|
877
1245
|
resolveParamId: (nodeId, param) => this.resolveParamId(nodeId, param),
|
|
878
1246
|
ensureTrackLane: (target) => this.ensureTrackLane(target),
|
|
@@ -887,6 +1255,7 @@ export class SonareEngine {
|
|
|
887
1255
|
return {
|
|
888
1256
|
offlineEngine: this.offlineEngine,
|
|
889
1257
|
realtimeNode: this.realtimeNode,
|
|
1258
|
+
sendCommand: (command) => this.sendMirroredCommand(command),
|
|
890
1259
|
postSync: (message) => this.postSync(message),
|
|
891
1260
|
getTempoBpm: () => this.tempoBpm,
|
|
892
1261
|
setTempoBpm: (bpm) => {
|
|
@@ -923,6 +1292,7 @@ export class SonareEngine {
|
|
|
923
1292
|
postSync: (message, transfer) => this.postSync(message, transfer),
|
|
924
1293
|
ensureTrackLane: (target) => this.ensureTrackLane(target),
|
|
925
1294
|
resolveTargetId: (target) => this.resolveTargetId(target),
|
|
1295
|
+
commitWorkletClipPageProvider: (clip) => this.commitWorkletClipPageProvider(clip),
|
|
926
1296
|
};
|
|
927
1297
|
}
|
|
928
1298
|
|
|
@@ -938,7 +1308,7 @@ export class SonareEngine {
|
|
|
938
1308
|
this.nextMarkerId = value;
|
|
939
1309
|
},
|
|
940
1310
|
postSync: (message) => this.postSync(message),
|
|
941
|
-
sendCommand: (command) => this.
|
|
1311
|
+
sendCommand: (command) => this.sendMirroredCommand(command),
|
|
942
1312
|
setLoop: (startPpq, endPpq, enabled) => this.setLoop(startPpq, endPpq, enabled),
|
|
943
1313
|
};
|
|
944
1314
|
}
|
|
@@ -956,7 +1326,7 @@ export class SonareEngine {
|
|
|
956
1326
|
// sample-accurate smoothed-param command to the realtime runtime.
|
|
957
1327
|
private sendSmoothedParam(paramId: number, value: number): boolean {
|
|
958
1328
|
this.offlineEngine.setParameter(paramId, value);
|
|
959
|
-
return this.
|
|
1329
|
+
return this.sendMirroredCommand({
|
|
960
1330
|
type: SonareEngineCommandType.SetParamSmoothed,
|
|
961
1331
|
targetId: paramId,
|
|
962
1332
|
sampleTime: -1,
|
|
@@ -968,6 +1338,13 @@ export class SonareEngine {
|
|
|
968
1338
|
return resolveParamId(this.listParameters(), nodeId, param);
|
|
969
1339
|
}
|
|
970
1340
|
|
|
1341
|
+
private syncParameters(): void {
|
|
1342
|
+
const parameters = this.listParameters();
|
|
1343
|
+
if (parameters.length > 0) {
|
|
1344
|
+
this.postSync({ type: 'syncParameters', parameters });
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
|
|
971
1348
|
private resolveTargetId(target: string | number): number {
|
|
972
1349
|
return resolveTargetId(target);
|
|
973
1350
|
}
|