@libraz/libsonare 1.5.5 → 1.6.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 +5730 -0
- package/dist/analysis.js +2464 -0
- package/dist/analysis.js.map +1 -0
- package/dist/index.d.ts +759 -35
- package/dist/index.js +904 -209
- 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 +707 -0
- package/dist/worker.js.map +1 -0
- package/dist/worklet.d.ts +574 -86
- package/dist/worklet.js +1634 -235
- 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 +6 -0
- package/src/clip_page_streamer.ts +62 -8
- package/src/codes.ts +51 -64
- package/src/effects_mastering.ts +1 -0
- package/src/errors.ts +1 -0
- package/src/feature_core.ts +140 -2
- package/src/feature_music.ts +17 -2
- package/src/feature_pitch.ts +70 -1
- package/src/feature_spectral.ts +231 -5
- package/src/feature_spectrogram.ts +159 -0
- package/src/features.ts +16 -1
- package/src/index.ts +101 -7
- 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/opfs_clip_pages.ts +26 -1
- package/src/project.ts +7 -0
- package/src/project_class.ts +62 -8
- package/src/project_internal.ts +24 -29
- package/src/project_types.ts +69 -10
- package/src/public_types.ts +68 -0
- package/src/public_types_mastering.ts +22 -0
- package/src/public_types_mixing.ts +11 -2
- package/src/public_types_music.ts +9 -0
- package/src/public_types_spectral.ts +33 -0
- package/src/quick_analysis.ts +25 -7
- package/src/realtime_engine.ts +153 -3
- package/src/realtime_voice_changer.ts +51 -77
- package/src/sonare-analysis.js.d.ts +8 -0
- package/src/sonare.js.d.ts +276 -6
- package/src/streaming_processors.ts +15 -0
- package/src/web_midi.ts +36 -4
- 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 +212 -4
- package/src/worklet/engine-offline.ts +7 -2
- package/src/worklet/engine-parameter-facade.ts +26 -5
- package/src/worklet/engine-processor.ts +383 -42
- package/src/worklet/engine-register.ts +8 -0
- package/src/worklet/engine-strips.ts +17 -1
- package/src/worklet/engine-sync.ts +2 -6
- package/src/worklet/engine-tempo-facade.ts +8 -6
- package/src/worklet/engine.ts +380 -13
- package/src/worklet/guards.ts +22 -1
- package/src/worklet/messages.ts +125 -7
- package/src/worklet/protocol.ts +301 -17
- package/src/worklet/voice-changer-processor.ts +12 -15
- package/src/worklet.ts +20 -1
|
@@ -65,11 +65,7 @@ export function resolveTargetId(target: string | number): number {
|
|
|
65
65
|
return Number.isFinite(parsed) ? parsed : 0;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
/**
|
|
69
|
-
* Resolves a parameter id from a name or numeric id. Numeric params pass
|
|
70
|
-
* through; named params are matched against the parameter list, then fall back
|
|
71
|
-
* to a target-id resolution of the name (or the node id).
|
|
72
|
-
*/
|
|
68
|
+
/** Resolves a registered parameter name or passes through a numeric id. */
|
|
73
69
|
export function resolveParamId(
|
|
74
70
|
parameters: readonly EngineParameterInfo[],
|
|
75
71
|
nodeId: string,
|
|
@@ -82,7 +78,7 @@ export function resolveParamId(
|
|
|
82
78
|
if (byName) {
|
|
83
79
|
return byName.id;
|
|
84
80
|
}
|
|
85
|
-
|
|
81
|
+
throw new RangeError(`Unknown engine parameter ${JSON.stringify(param)} for node ${nodeId}`);
|
|
86
82
|
}
|
|
87
83
|
|
|
88
84
|
/** Encodes an automation curve descriptor to the engine's numeric curve code. */
|
|
@@ -24,6 +24,13 @@ interface TimeSignature {
|
|
|
24
24
|
export interface EngineTempoContext {
|
|
25
25
|
readonly offlineEngine: RealtimeEngine;
|
|
26
26
|
readonly realtimeNode: SonareRealtimeEngineNode;
|
|
27
|
+
sendCommand(command: {
|
|
28
|
+
type: SonareEngineCommandType;
|
|
29
|
+
targetId?: number;
|
|
30
|
+
sampleTime?: number;
|
|
31
|
+
argFloat?: number;
|
|
32
|
+
argInt?: number;
|
|
33
|
+
}): boolean;
|
|
27
34
|
postSync(message: SonareEngineSyncMessage): void;
|
|
28
35
|
getTempoBpm(): number;
|
|
29
36
|
setTempoBpm(bpm: number): void;
|
|
@@ -54,11 +61,6 @@ export function setTempo(ctx: EngineTempoContext, bpm: number): void {
|
|
|
54
61
|
ctx.setTempoSegments([{ startPpq: 0, bpm }]);
|
|
55
62
|
ctx.offlineEngine.setTempo(bpm);
|
|
56
63
|
postTempoSync(ctx);
|
|
57
|
-
ctx.realtimeNode.sendCommand({
|
|
58
|
-
type: SonareEngineCommandType.SetTempoMap,
|
|
59
|
-
sampleTime: -1,
|
|
60
|
-
argFloat: bpm,
|
|
61
|
-
});
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
export function setTempoSegments(
|
|
@@ -113,7 +115,7 @@ export function setLoop(
|
|
|
113
115
|
// while loop STARTS and the offline path stay exact. This is intentional:
|
|
114
116
|
// the record has no second free Float64 lane, and a micro-PPQ grid on the
|
|
115
117
|
// loop end is well below audible/sample-accurate resolution at any tempo.
|
|
116
|
-
return ctx.
|
|
118
|
+
return ctx.sendCommand({
|
|
117
119
|
type: SonareEngineCommandType.SetLoop,
|
|
118
120
|
targetId: enabled ? 1 : 0,
|
|
119
121
|
sampleTime: -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,
|
|
@@ -13,10 +18,13 @@ import type {
|
|
|
13
18
|
EngineTrackSend,
|
|
14
19
|
EngineTransportState,
|
|
15
20
|
EqBand,
|
|
21
|
+
MidiCcBindOptions,
|
|
16
22
|
PanLaw,
|
|
17
23
|
PanMode,
|
|
18
24
|
} from '../index';
|
|
19
25
|
import { RealtimeEngine } from '../index';
|
|
26
|
+
import { createOpfsClipPageProvider, type OpfsClipPageProviderBinding } from '../opfs_clip_pages';
|
|
27
|
+
import type { ClipPageProvider } from '../realtime_engine';
|
|
20
28
|
import type { EngineAutomationContext } from './engine-automation';
|
|
21
29
|
import * as automation from './engine-automation';
|
|
22
30
|
import type { EngineCaptureContext } from './engine-capture-facade';
|
|
@@ -56,6 +64,24 @@ import {
|
|
|
56
64
|
type SonareWorkletScopeSnapshot,
|
|
57
65
|
} from './protocol';
|
|
58
66
|
|
|
67
|
+
const MAX_PENDING_WORKLET_CLIP_PAGE_REQUESTS = 256;
|
|
68
|
+
|
|
69
|
+
function transferableAudioBuffers(channels: readonly Float32Array[]): Transferable[] {
|
|
70
|
+
const transfers: ArrayBuffer[] = [];
|
|
71
|
+
const seen = new Set<ArrayBuffer>();
|
|
72
|
+
for (const channel of channels) {
|
|
73
|
+
const buffer = channel.buffer;
|
|
74
|
+
// SharedArrayBuffer is cloneable but cannot appear in a transfer list. A
|
|
75
|
+
// worker may intentionally return SAB-backed pages, so transfer only plain,
|
|
76
|
+
// distinct ArrayBuffers and let structured clone share SABs by reference.
|
|
77
|
+
if (buffer instanceof ArrayBuffer && !seen.has(buffer)) {
|
|
78
|
+
seen.add(buffer);
|
|
79
|
+
transfers.push(buffer);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return transfers;
|
|
83
|
+
}
|
|
84
|
+
|
|
59
85
|
export class SonareEngine {
|
|
60
86
|
readonly node: AudioWorkletNode;
|
|
61
87
|
readonly capabilities: SonareRealtimeEngineNodeCapabilities;
|
|
@@ -93,6 +119,15 @@ export class SonareEngine {
|
|
|
93
119
|
private nextMarkerId = 1;
|
|
94
120
|
private transportPlaying = false;
|
|
95
121
|
private readonly pendingInstrumentSync: SonareEngineInstrumentSyncMessage[] = [];
|
|
122
|
+
// One latest frontier per clip is sufficient: ClipPageStreamer expands it to
|
|
123
|
+
// the bounded read window. A Map both coalesces repeated page misses and
|
|
124
|
+
// places a hard cap on work queued while OPFS I/O is stalled.
|
|
125
|
+
private readonly workletClipPageRequests = new Map<number, ClipPageStreamerRequest>();
|
|
126
|
+
private readonly workletPageProviderClipIds = new Map<number, number>();
|
|
127
|
+
private workletClipStreamer: ClipPageStreamer | undefined;
|
|
128
|
+
private workletClipPump: Promise<void> | undefined;
|
|
129
|
+
private workletClipPagePollTimer: ReturnType<typeof setInterval> | undefined;
|
|
130
|
+
private unsubscribeWorkletClipRequests: (() => void) | undefined;
|
|
96
131
|
private destroyed = false;
|
|
97
132
|
|
|
98
133
|
private constructor(
|
|
@@ -111,10 +146,17 @@ export class SonareEngine {
|
|
|
111
146
|
this.sampleRate = sampleRate;
|
|
112
147
|
this.offlineBlockSize = offlineBlockSize;
|
|
113
148
|
this.offlineChannelCount = offlineChannelCount;
|
|
149
|
+
this.unsubscribeWorkletClipRequests = this.realtimeNode.onClipPageRequests((message) => {
|
|
150
|
+
for (const request of message.requests) {
|
|
151
|
+
this.enqueueWorkletClipPageRequest(request);
|
|
152
|
+
}
|
|
153
|
+
this.pumpWorkletClipPages();
|
|
154
|
+
});
|
|
114
155
|
this.transport = buildTransportFacade({
|
|
115
156
|
sampleRate: this.sampleRate,
|
|
116
157
|
realtimeNode: this.realtimeNode,
|
|
117
158
|
offlineEngine: this.offlineEngine,
|
|
159
|
+
flushOfflineMirror: () => this.flushOfflineMirror(),
|
|
118
160
|
setTransportPlaying: (playing) => {
|
|
119
161
|
this.transportPlaying = playing;
|
|
120
162
|
},
|
|
@@ -136,8 +178,17 @@ export class SonareEngine {
|
|
|
136
178
|
Math.floor(options.offlineChannelCount ?? options.channelCount ?? 2),
|
|
137
179
|
);
|
|
138
180
|
const realtimeNode = await SonareRealtimeEngineNode.create(context, options);
|
|
181
|
+
try {
|
|
182
|
+
// Do not expose the facade while the registered processor is still
|
|
183
|
+
// buffering messages. Long clip sync must either begin after the bridge
|
|
184
|
+
// is ready or fail explicitly; it may never be silently truncated.
|
|
185
|
+
await realtimeNode.ready;
|
|
186
|
+
} catch (error) {
|
|
187
|
+
realtimeNode.destroy();
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
139
190
|
const offlineEngine = options.offlineEngine ?? new RealtimeEngine(sampleRate, blockSize);
|
|
140
|
-
|
|
191
|
+
const engine = new SonareEngine(
|
|
141
192
|
context,
|
|
142
193
|
realtimeNode,
|
|
143
194
|
offlineEngine,
|
|
@@ -145,6 +196,8 @@ export class SonareEngine {
|
|
|
145
196
|
blockSize,
|
|
146
197
|
channelCount,
|
|
147
198
|
);
|
|
199
|
+
engine.syncParameters();
|
|
200
|
+
return engine;
|
|
148
201
|
}
|
|
149
202
|
|
|
150
203
|
async suspend(): Promise<void> {
|
|
@@ -319,6 +372,16 @@ export class SonareEngine {
|
|
|
319
372
|
return parameter.listParameters(this.parameterContext);
|
|
320
373
|
}
|
|
321
374
|
|
|
375
|
+
/** Registers a custom parameter on the offline mirror and worklet engine. */
|
|
376
|
+
addParameter(info: EngineParameterInfo): void {
|
|
377
|
+
parameter.addParameter(this.parameterContext, info);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Clears custom parameters and their automation lanes on both engines. */
|
|
381
|
+
clearParameters(): void {
|
|
382
|
+
parameter.clearParameters(this.parameterContext);
|
|
383
|
+
}
|
|
384
|
+
|
|
322
385
|
setSoloMute(target: string | number, solo: boolean, mute: boolean): boolean {
|
|
323
386
|
return parameter.setSoloMute(this.parameterContext, target, solo, mute);
|
|
324
387
|
}
|
|
@@ -516,11 +579,86 @@ export class SonareEngine {
|
|
|
516
579
|
this.setMasterStripJson(sceneJson);
|
|
517
580
|
}
|
|
518
581
|
|
|
582
|
+
/**
|
|
583
|
+
* Creates and primes an OPFS-backed page provider for this live worklet
|
|
584
|
+
* engine. Pass the returned `provider` to {@link addClip} in place of a
|
|
585
|
+
* `Float32Array[]`; the `clipId` in `options` must equal that clip's explicit
|
|
586
|
+
* `opts.id`. Subsequent cache misses are fetched on the main thread and
|
|
587
|
+
* supplied back to the worklet through its bounded pull protocol.
|
|
588
|
+
*/
|
|
589
|
+
async attachOpfsClipStream(
|
|
590
|
+
options: OpfsClipStreamOptions,
|
|
591
|
+
): Promise<{ binding: OpfsClipPageProviderBinding; provider: ClipPageProvider }> {
|
|
592
|
+
if (this.destroyed) {
|
|
593
|
+
throw new Error('SonareEngine is destroyed.');
|
|
594
|
+
}
|
|
595
|
+
if (!this.capabilities.clipPageRequestsRealtimeSafe) {
|
|
596
|
+
throw new Error(
|
|
597
|
+
'OPFS clip streaming requires SharedArrayBuffer clip-page requests; the postMessage fallback is not realtime-safe.',
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
const { clipId, primePages = 1, ...providerOptions } = options;
|
|
601
|
+
if ([...this.workletPageProviderClipIds.values()].includes(clipId)) {
|
|
602
|
+
throw new Error(`An OPFS stream is already attached for clip ${clipId}.`);
|
|
603
|
+
}
|
|
604
|
+
const streamer = this.ensureWorkletClipStreamer();
|
|
605
|
+
// Allocate the worklet-owned provider before priming: port message order
|
|
606
|
+
// ensures every prime page is adopted before addClip commits its schedule.
|
|
607
|
+
this.postSync({
|
|
608
|
+
type: 'syncClipPageProvider',
|
|
609
|
+
clipId,
|
|
610
|
+
numChannels: providerOptions.numChannels,
|
|
611
|
+
numSamples: providerOptions.numSamples,
|
|
612
|
+
pageFrames: providerOptions.pageFrames,
|
|
613
|
+
});
|
|
614
|
+
let binding: OpfsClipPageProviderBinding;
|
|
615
|
+
binding = createOpfsClipPageProvider(this.offlineEngine, {
|
|
616
|
+
...providerOptions,
|
|
617
|
+
onPageSupplied: (pageIndex, channels) => {
|
|
618
|
+
this.postSync(
|
|
619
|
+
{ type: 'syncClipPage', clipId, pageIndex, channels },
|
|
620
|
+
transferableAudioBuffers(channels),
|
|
621
|
+
);
|
|
622
|
+
},
|
|
623
|
+
onPageCleared: (pageIndex) => {
|
|
624
|
+
this.postSync({ type: 'syncClipPageClear', clipId, pageIndex });
|
|
625
|
+
},
|
|
626
|
+
onClose: () => {
|
|
627
|
+
this.workletPageProviderClipIds.delete(binding.provider.id);
|
|
628
|
+
this.postSync({ type: 'syncClipPageDestroy', clipId });
|
|
629
|
+
},
|
|
630
|
+
});
|
|
631
|
+
this.workletPageProviderClipIds.set(binding.provider.id, clipId);
|
|
632
|
+
const lastPage = Math.ceil(providerOptions.numSamples / providerOptions.pageFrames) - 1;
|
|
633
|
+
const primed: number[] = [];
|
|
634
|
+
try {
|
|
635
|
+
for (let page = 0; page < primePages && page <= lastPage; ++page) {
|
|
636
|
+
if (await binding.supplyPage(page)) {
|
|
637
|
+
primed.push(page);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
streamer.addSource(
|
|
641
|
+
{
|
|
642
|
+
clipId,
|
|
643
|
+
binding,
|
|
644
|
+
pageFrames: providerOptions.pageFrames,
|
|
645
|
+
numSamples: providerOptions.numSamples,
|
|
646
|
+
},
|
|
647
|
+
primed,
|
|
648
|
+
);
|
|
649
|
+
this.startWorkletClipPagePolling();
|
|
650
|
+
} catch (error) {
|
|
651
|
+
binding.close();
|
|
652
|
+
throw error;
|
|
653
|
+
}
|
|
654
|
+
return { binding, provider: binding.provider };
|
|
655
|
+
}
|
|
656
|
+
|
|
519
657
|
addClip(
|
|
520
658
|
trackId: string | number,
|
|
521
|
-
buffer: Float32Array[],
|
|
659
|
+
buffer: Float32Array[] | ClipPageProvider,
|
|
522
660
|
startPpq: number,
|
|
523
|
-
opts: Partial<Omit<EngineClip, 'channels' | 'startPpq'>> = {},
|
|
661
|
+
opts: Partial<Omit<EngineClip, 'channels' | 'pageProvider' | 'startPpq'>> = {},
|
|
524
662
|
): number {
|
|
525
663
|
return clips.addClip(this.clipContext, trackId, buffer, startPpq, opts);
|
|
526
664
|
}
|
|
@@ -550,7 +688,12 @@ export class SonareEngine {
|
|
|
550
688
|
|
|
551
689
|
setSf2Instrument(
|
|
552
690
|
trackId: string | number,
|
|
553
|
-
config: {
|
|
691
|
+
config: {
|
|
692
|
+
destinationId?: number;
|
|
693
|
+
gain?: number;
|
|
694
|
+
polyphony?: number;
|
|
695
|
+
preferModelForModeledFamilies?: boolean;
|
|
696
|
+
} = {},
|
|
554
697
|
): void {
|
|
555
698
|
strips.setSf2Instrument(this.stripContext, trackId, config);
|
|
556
699
|
}
|
|
@@ -623,10 +766,90 @@ export class SonareEngine {
|
|
|
623
766
|
strips.pushMidiCc(this.stripContext, trackId, group, channel, controller, value, renderFrame);
|
|
624
767
|
}
|
|
625
768
|
|
|
769
|
+
pushMidiUmp(trackId: string | number, word0: number, renderFrame = -1): void {
|
|
770
|
+
strips.pushMidiUmp(this.stripContext, trackId, word0, renderFrame);
|
|
771
|
+
}
|
|
772
|
+
|
|
626
773
|
pushMidiSysex(trackId: string | number, data: Uint8Array, renderFrame = -1): void {
|
|
627
774
|
strips.pushMidiSysex(this.stripContext, trackId, data, renderFrame);
|
|
628
775
|
}
|
|
629
776
|
|
|
777
|
+
bindMidiCc(
|
|
778
|
+
channel: number,
|
|
779
|
+
controller: number,
|
|
780
|
+
paramId: number,
|
|
781
|
+
options: MidiCcBindOptions = {},
|
|
782
|
+
): void {
|
|
783
|
+
const minValue = options.minValue ?? 0;
|
|
784
|
+
const maxValue = options.maxValue ?? 1;
|
|
785
|
+
this.offlineEngine.bindMidiCc(channel, controller, paramId, { minValue, maxValue });
|
|
786
|
+
this.postSync({ type: 'syncMidiCcBinding', channel, controller, paramId, minValue, maxValue });
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
setMidiInputSource(destinationId = 0): void {
|
|
790
|
+
this.offlineEngine.setMidiInputSource(destinationId);
|
|
791
|
+
this.postSync({ type: 'syncMidiInputSource', destinationId });
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
clearMidiInputSource(): void {
|
|
795
|
+
this.offlineEngine.clearMidiInputSource();
|
|
796
|
+
this.postSync({ type: 'syncClearMidiInputSource' });
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
pushMidiInputNoteOn(
|
|
800
|
+
group: number,
|
|
801
|
+
channel: number,
|
|
802
|
+
note: number,
|
|
803
|
+
velocity: number,
|
|
804
|
+
portTimeSamples = 0,
|
|
805
|
+
): void {
|
|
806
|
+
this.offlineEngine.pushMidiInputNoteOn(group, channel, note, velocity, portTimeSamples);
|
|
807
|
+
this.postSync({
|
|
808
|
+
type: 'syncMidiInputNoteOn',
|
|
809
|
+
group,
|
|
810
|
+
channel,
|
|
811
|
+
data0: note,
|
|
812
|
+
data1: velocity,
|
|
813
|
+
portTimeSamples,
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
pushMidiInputNoteOff(
|
|
818
|
+
group: number,
|
|
819
|
+
channel: number,
|
|
820
|
+
note: number,
|
|
821
|
+
velocity = 0,
|
|
822
|
+
portTimeSamples = 0,
|
|
823
|
+
): void {
|
|
824
|
+
this.offlineEngine.pushMidiInputNoteOff(group, channel, note, velocity, portTimeSamples);
|
|
825
|
+
this.postSync({
|
|
826
|
+
type: 'syncMidiInputNoteOff',
|
|
827
|
+
group,
|
|
828
|
+
channel,
|
|
829
|
+
data0: note,
|
|
830
|
+
data1: velocity,
|
|
831
|
+
portTimeSamples,
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
pushMidiInputCc(
|
|
836
|
+
group: number,
|
|
837
|
+
channel: number,
|
|
838
|
+
controller: number,
|
|
839
|
+
value: number,
|
|
840
|
+
portTimeSamples = 0,
|
|
841
|
+
): void {
|
|
842
|
+
this.offlineEngine.pushMidiInputCc(group, channel, controller, value, portTimeSamples);
|
|
843
|
+
this.postSync({
|
|
844
|
+
type: 'syncMidiInputCc',
|
|
845
|
+
group,
|
|
846
|
+
channel,
|
|
847
|
+
data0: controller,
|
|
848
|
+
data1: value,
|
|
849
|
+
portTimeSamples,
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
|
|
630
853
|
pushMidiPanic(renderFrame = -1): void {
|
|
631
854
|
this.offlineEngine.pushMidiPanic(renderFrame);
|
|
632
855
|
this.postSync({ type: 'syncMidiPanic', renderFrame });
|
|
@@ -636,6 +859,10 @@ export class SonareEngine {
|
|
|
636
859
|
capture.configureCapture(this.captureContext, options);
|
|
637
860
|
}
|
|
638
861
|
|
|
862
|
+
/**
|
|
863
|
+
* Arms the engine-global capture path. `trackId` is retained for source
|
|
864
|
+
* compatibility and must be `0`; per-track capture is not implemented.
|
|
865
|
+
*/
|
|
639
866
|
armRecord(trackId: string | number, enabled: boolean): boolean {
|
|
640
867
|
return capture.armRecord(this.captureContext, trackId, enabled);
|
|
641
868
|
}
|
|
@@ -662,7 +889,7 @@ export class SonareEngine {
|
|
|
662
889
|
// the fixed-size SAB command record, so it is delivered out-of-band; the
|
|
663
890
|
// SetMetronome command then toggles enabled state on the audio thread.
|
|
664
891
|
this.postSync({ type: 'syncMetronome', config: opts });
|
|
665
|
-
this.
|
|
892
|
+
this.sendMirroredCommand({
|
|
666
893
|
type: SonareEngineCommandType.SetMetronome,
|
|
667
894
|
sampleTime: -1,
|
|
668
895
|
argInt: opts.enabled ? 1 : 0,
|
|
@@ -748,6 +975,15 @@ export class SonareEngine {
|
|
|
748
975
|
if (this.destroyed) {
|
|
749
976
|
return;
|
|
750
977
|
}
|
|
978
|
+
this.unsubscribeWorkletClipRequests?.();
|
|
979
|
+
this.unsubscribeWorkletClipRequests = undefined;
|
|
980
|
+
this.workletClipStreamer?.close();
|
|
981
|
+
this.workletClipStreamer = undefined;
|
|
982
|
+
if (this.workletClipPagePollTimer !== undefined) {
|
|
983
|
+
clearInterval(this.workletClipPagePollTimer);
|
|
984
|
+
this.workletClipPagePollTimer = undefined;
|
|
985
|
+
}
|
|
986
|
+
this.workletClipPageRequests.clear();
|
|
751
987
|
this.destroyed = true;
|
|
752
988
|
this.transport.stop();
|
|
753
989
|
this.realtimeNode.pollTelemetry();
|
|
@@ -755,6 +991,107 @@ export class SonareEngine {
|
|
|
755
991
|
this.offlineEngine.destroy();
|
|
756
992
|
}
|
|
757
993
|
|
|
994
|
+
private ensureWorkletClipStreamer(): ClipPageStreamer {
|
|
995
|
+
if (!this.workletClipStreamer) {
|
|
996
|
+
this.workletClipStreamer = new ClipPageStreamer({
|
|
997
|
+
popClipPageRequest: () => this.popWorkletClipPageRequest(),
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
return this.workletClipStreamer;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* SAB requests have no postMessage wake-up by design. Polling on the main
|
|
1005
|
+
* thread is therefore intentionally outside the audio callback; 8 ms keeps
|
|
1006
|
+
* the bounded OPFS prefetch frontier responsive without adding worklet GC.
|
|
1007
|
+
*/
|
|
1008
|
+
private startWorkletClipPagePolling(): void {
|
|
1009
|
+
if (this.workletClipPagePollTimer !== undefined) {
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
const poll = () => {
|
|
1013
|
+
if (!this.destroyed) {
|
|
1014
|
+
this.realtimeNode.pollClipPageRequests();
|
|
1015
|
+
}
|
|
1016
|
+
};
|
|
1017
|
+
poll();
|
|
1018
|
+
this.workletClipPagePollTimer = setInterval(poll, 8);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
private pumpWorkletClipPages(): void {
|
|
1022
|
+
const streamer = this.workletClipStreamer;
|
|
1023
|
+
if (!streamer || this.workletClipPump) {
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
this.workletClipPump = streamer
|
|
1027
|
+
.pump()
|
|
1028
|
+
.catch((error: unknown) => {
|
|
1029
|
+
// The worklet keeps rendering silence for an unavailable page. Surface
|
|
1030
|
+
// I/O failures without converting them into an AudioWorklet exception.
|
|
1031
|
+
// biome-ignore lint/suspicious/noConsole: asynchronous OPFS diagnostic.
|
|
1032
|
+
console.error('Sonare OPFS clip-page supply failed:', error);
|
|
1033
|
+
})
|
|
1034
|
+
.finally(() => {
|
|
1035
|
+
this.workletClipPump = undefined;
|
|
1036
|
+
if (this.workletClipPageRequests.size > 0) {
|
|
1037
|
+
this.pumpWorkletClipPages();
|
|
1038
|
+
}
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
private enqueueWorkletClipPageRequest(request: ClipPageStreamerRequest): void {
|
|
1043
|
+
if (
|
|
1044
|
+
!Number.isInteger(request.clipId) ||
|
|
1045
|
+
request.clipId < 0 ||
|
|
1046
|
+
!Number.isInteger(request.pageIndex) ||
|
|
1047
|
+
(request.pageIndex ?? -1) < 0
|
|
1048
|
+
) {
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
// Reinsert an existing id so iteration order represents newest frontiers.
|
|
1052
|
+
this.workletClipPageRequests.delete(request.clipId);
|
|
1053
|
+
if (this.workletClipPageRequests.size >= MAX_PENDING_WORKLET_CLIP_PAGE_REQUESTS) {
|
|
1054
|
+
const oldest = this.workletClipPageRequests.keys().next().value;
|
|
1055
|
+
if (oldest !== undefined) {
|
|
1056
|
+
this.workletClipPageRequests.delete(oldest);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
this.workletClipPageRequests.set(request.clipId, request);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
private popWorkletClipPageRequest(): ClipPageStreamerRequest | null {
|
|
1063
|
+
const entry = this.workletClipPageRequests.entries().next().value;
|
|
1064
|
+
if (!entry) {
|
|
1065
|
+
return null;
|
|
1066
|
+
}
|
|
1067
|
+
const [clipId, request] = entry;
|
|
1068
|
+
this.workletClipPageRequests.delete(clipId);
|
|
1069
|
+
return request;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
private commitWorkletClipPageProvider(clip: EngineClip): boolean {
|
|
1073
|
+
const providerId =
|
|
1074
|
+
typeof clip.pageProvider === 'object' && clip.pageProvider !== null
|
|
1075
|
+
? clip.pageProvider.id
|
|
1076
|
+
: clip.pageProvider;
|
|
1077
|
+
if (providerId === undefined) {
|
|
1078
|
+
return false;
|
|
1079
|
+
}
|
|
1080
|
+
const clipId = this.workletPageProviderClipIds.get(providerId);
|
|
1081
|
+
if (clipId === undefined) {
|
|
1082
|
+
return false;
|
|
1083
|
+
}
|
|
1084
|
+
if (clip.id !== clipId) {
|
|
1085
|
+
throw new Error(`OPFS stream clipId ${clipId} must match addClip(..., { id: ${clipId} }).`);
|
|
1086
|
+
}
|
|
1087
|
+
this.postSync({
|
|
1088
|
+
type: 'syncClipPageCommit',
|
|
1089
|
+
clipId,
|
|
1090
|
+
clip: { ...clip, channels: undefined, pageProvider: undefined },
|
|
1091
|
+
});
|
|
1092
|
+
return true;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
758
1095
|
private mixerLanes(): EngineTrackLane[] {
|
|
759
1096
|
return mixer.mixerLanes(this.mixerContext);
|
|
760
1097
|
}
|
|
@@ -791,13 +1128,32 @@ export class SonareEngine {
|
|
|
791
1128
|
if (this.destroyed) {
|
|
792
1129
|
return;
|
|
793
1130
|
}
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
1131
|
+
try {
|
|
1132
|
+
if (transfer && transfer.length > 0) {
|
|
1133
|
+
this.realtimeNode.node.port.postMessage(message, transfer);
|
|
1134
|
+
} else {
|
|
1135
|
+
this.realtimeNode.node.port.postMessage(message);
|
|
1136
|
+
}
|
|
1137
|
+
} finally {
|
|
1138
|
+
this.flushOfflineMirror();
|
|
798
1139
|
}
|
|
799
1140
|
}
|
|
800
1141
|
|
|
1142
|
+
// The offline engine is a control-thread mirror. It has no render loop to
|
|
1143
|
+
// drain its command ring, so drain it after each control operation. Never
|
|
1144
|
+
// call this from the worklet/audio path: it is intentionally control-only.
|
|
1145
|
+
private flushOfflineMirror(): void {
|
|
1146
|
+
this.offlineEngine.flushControlCommands();
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
private sendMirroredCommand(
|
|
1150
|
+
command: Parameters<SonareRealtimeEngineNode['sendCommand']>[0],
|
|
1151
|
+
): boolean {
|
|
1152
|
+
const accepted = this.realtimeNode.sendCommand(command);
|
|
1153
|
+
this.flushOfflineMirror();
|
|
1154
|
+
return accepted;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
801
1157
|
// Collaborator surface handed to the mixer/routing free functions so they can
|
|
802
1158
|
// mutate the routing stores (held by reference), mirror into the offline
|
|
803
1159
|
// engine, post mixer-sync messages, and declare lanes/buses without a
|
|
@@ -855,13 +1211,13 @@ export class SonareEngine {
|
|
|
855
1211
|
return {
|
|
856
1212
|
offlineEngine: this.offlineEngine,
|
|
857
1213
|
realtimeNode: this.realtimeNode,
|
|
1214
|
+
sendCommand: (command) => this.sendMirroredCommand(command),
|
|
858
1215
|
offlineChannelCount: this.offlineChannelCount,
|
|
859
1216
|
postSync: (message) => this.postSync(message),
|
|
860
1217
|
getCaptureConfig: () => this.captureConfig,
|
|
861
1218
|
setCaptureConfig: (config) => {
|
|
862
1219
|
this.captureConfig = config;
|
|
863
1220
|
},
|
|
864
|
-
resolveTargetId: (target) => this.resolveTargetId(target),
|
|
865
1221
|
};
|
|
866
1222
|
}
|
|
867
1223
|
|
|
@@ -872,7 +1228,9 @@ export class SonareEngine {
|
|
|
872
1228
|
private get parameterContext(): EngineParameterContext {
|
|
873
1229
|
return {
|
|
874
1230
|
offlineEngine: this.offlineEngine,
|
|
875
|
-
|
|
1231
|
+
sendCommand: (command) => this.sendMirroredCommand(command),
|
|
1232
|
+
postSync: (message) => this.postSync(message),
|
|
1233
|
+
automationLanes: this.automationLanes,
|
|
876
1234
|
trackLaneIds: this.trackLaneIds,
|
|
877
1235
|
resolveParamId: (nodeId, param) => this.resolveParamId(nodeId, param),
|
|
878
1236
|
ensureTrackLane: (target) => this.ensureTrackLane(target),
|
|
@@ -887,6 +1245,7 @@ export class SonareEngine {
|
|
|
887
1245
|
return {
|
|
888
1246
|
offlineEngine: this.offlineEngine,
|
|
889
1247
|
realtimeNode: this.realtimeNode,
|
|
1248
|
+
sendCommand: (command) => this.sendMirroredCommand(command),
|
|
890
1249
|
postSync: (message) => this.postSync(message),
|
|
891
1250
|
getTempoBpm: () => this.tempoBpm,
|
|
892
1251
|
setTempoBpm: (bpm) => {
|
|
@@ -923,6 +1282,7 @@ export class SonareEngine {
|
|
|
923
1282
|
postSync: (message, transfer) => this.postSync(message, transfer),
|
|
924
1283
|
ensureTrackLane: (target) => this.ensureTrackLane(target),
|
|
925
1284
|
resolveTargetId: (target) => this.resolveTargetId(target),
|
|
1285
|
+
commitWorkletClipPageProvider: (clip) => this.commitWorkletClipPageProvider(clip),
|
|
926
1286
|
};
|
|
927
1287
|
}
|
|
928
1288
|
|
|
@@ -938,7 +1298,7 @@ export class SonareEngine {
|
|
|
938
1298
|
this.nextMarkerId = value;
|
|
939
1299
|
},
|
|
940
1300
|
postSync: (message) => this.postSync(message),
|
|
941
|
-
sendCommand: (command) => this.
|
|
1301
|
+
sendCommand: (command) => this.sendMirroredCommand(command),
|
|
942
1302
|
setLoop: (startPpq, endPpq, enabled) => this.setLoop(startPpq, endPpq, enabled),
|
|
943
1303
|
};
|
|
944
1304
|
}
|
|
@@ -956,7 +1316,7 @@ export class SonareEngine {
|
|
|
956
1316
|
// sample-accurate smoothed-param command to the realtime runtime.
|
|
957
1317
|
private sendSmoothedParam(paramId: number, value: number): boolean {
|
|
958
1318
|
this.offlineEngine.setParameter(paramId, value);
|
|
959
|
-
return this.
|
|
1319
|
+
return this.sendMirroredCommand({
|
|
960
1320
|
type: SonareEngineCommandType.SetParamSmoothed,
|
|
961
1321
|
targetId: paramId,
|
|
962
1322
|
sampleTime: -1,
|
|
@@ -968,6 +1328,13 @@ export class SonareEngine {
|
|
|
968
1328
|
return resolveParamId(this.listParameters(), nodeId, param);
|
|
969
1329
|
}
|
|
970
1330
|
|
|
1331
|
+
private syncParameters(): void {
|
|
1332
|
+
const parameters = this.listParameters();
|
|
1333
|
+
if (parameters.length > 0) {
|
|
1334
|
+
this.postSync({ type: 'syncParameters', parameters });
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
|
|
971
1338
|
private resolveTargetId(target: string | number): number {
|
|
972
1339
|
return resolveTargetId(target);
|
|
973
1340
|
}
|
package/src/worklet/guards.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
SonareEngineCaptureRequestMessage,
|
|
3
3
|
SonareEngineCaptureResponseMessage,
|
|
4
|
+
SonareEngineClipPageRequestMessage,
|
|
4
5
|
SonareEngineSyncMessage,
|
|
5
6
|
SonareEngineTransportRequestMessage,
|
|
6
7
|
SonareEngineTransportResponseMessage,
|
|
@@ -39,7 +40,9 @@ export function isEngineSyncMessage(value: unknown): value is SonareEngineSyncMe
|
|
|
39
40
|
value.type === 'syncClipsDelta' ||
|
|
40
41
|
value.type === 'syncClipPageProvider' ||
|
|
41
42
|
value.type === 'syncClipPage' ||
|
|
43
|
+
value.type === 'syncClipPageClear' ||
|
|
42
44
|
value.type === 'syncClipPageCommit' ||
|
|
45
|
+
value.type === 'syncClipPageDestroy' ||
|
|
43
46
|
value.type === 'syncMidiClips' ||
|
|
44
47
|
value.type === 'syncMarkers' ||
|
|
45
48
|
value.type === 'syncMetronome' ||
|
|
@@ -68,10 +71,12 @@ export function isEngineSyncMessage(value: unknown): value is SonareEngineSyncMe
|
|
|
68
71
|
value.type === 'syncMidiNoteOn' ||
|
|
69
72
|
value.type === 'syncMidiNoteOff' ||
|
|
70
73
|
value.type === 'syncMidiCc' ||
|
|
74
|
+
value.type === 'syncMidiUmp' ||
|
|
71
75
|
value.type === 'syncMidiSysex' ||
|
|
72
76
|
value.type === 'syncMidiPanic' ||
|
|
73
77
|
value.type === 'syncMidiDestinationExternal' ||
|
|
74
|
-
value.type === 'syncExternalMidiClock'
|
|
78
|
+
value.type === 'syncExternalMidiClock' ||
|
|
79
|
+
value.type === 'destroy'
|
|
75
80
|
);
|
|
76
81
|
}
|
|
77
82
|
|
|
@@ -147,6 +152,22 @@ export function isExternalMidiBatchMessage(
|
|
|
147
152
|
return isRecord(value) && value.type === 'externalMidi' && Array.isArray(value.events);
|
|
148
153
|
}
|
|
149
154
|
|
|
155
|
+
export function isClipPageRequestMessage(
|
|
156
|
+
value: unknown,
|
|
157
|
+
): value is SonareEngineClipPageRequestMessage {
|
|
158
|
+
return (
|
|
159
|
+
isRecord(value) &&
|
|
160
|
+
value.type === 'clipPageRequest' &&
|
|
161
|
+
Array.isArray(value.requests) &&
|
|
162
|
+
value.requests.every(
|
|
163
|
+
(request) =>
|
|
164
|
+
isRecord(request) &&
|
|
165
|
+
typeof request.clipId === 'number' &&
|
|
166
|
+
typeof request.pageIndex === 'number',
|
|
167
|
+
)
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
150
171
|
export function isMeterSnapshot(value: unknown): value is SonareWorkletMeterSnapshot {
|
|
151
172
|
return (
|
|
152
173
|
isRecord(value) &&
|