@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
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal structured-clone protocol for the offline Web Worker bridge.
|
|
3
|
+
*
|
|
4
|
+
* Keep this deliberately limited to one-shot operations. Embind handles own
|
|
5
|
+
* native resources and cannot safely cross a worker boundary.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type OfflineWorkerOperation =
|
|
9
|
+
| 'analyze'
|
|
10
|
+
| 'detectBpm'
|
|
11
|
+
| 'detectKey'
|
|
12
|
+
| 'detectChords'
|
|
13
|
+
| 'masterAudio'
|
|
14
|
+
| 'masterAudioStereo';
|
|
15
|
+
|
|
16
|
+
export interface OfflineWorkerRunMessage {
|
|
17
|
+
type: 'sonare:offline-run';
|
|
18
|
+
id: number;
|
|
19
|
+
operation: OfflineWorkerOperation;
|
|
20
|
+
request: Record<string, unknown>;
|
|
21
|
+
/** A shared flag lets cancellation reach synchronous WASM progress callbacks. */
|
|
22
|
+
cancelBuffer?: SharedArrayBuffer;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface OfflineWorkerCancelMessage {
|
|
26
|
+
type: 'sonare:offline-cancel';
|
|
27
|
+
id: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type OfflineWorkerRequestMessage = OfflineWorkerRunMessage | OfflineWorkerCancelMessage;
|
|
31
|
+
|
|
32
|
+
export interface OfflineWorkerProgressMessage {
|
|
33
|
+
type: 'sonare:offline-progress';
|
|
34
|
+
id: number;
|
|
35
|
+
progress: number;
|
|
36
|
+
stage: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface OfflineWorkerResultMessage {
|
|
40
|
+
type: 'sonare:offline-result';
|
|
41
|
+
id: number;
|
|
42
|
+
result: unknown;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface OfflineWorkerErrorMessage {
|
|
46
|
+
type: 'sonare:offline-error';
|
|
47
|
+
id: number;
|
|
48
|
+
error: {
|
|
49
|
+
name: string;
|
|
50
|
+
message: string;
|
|
51
|
+
code?: number;
|
|
52
|
+
codeName?: string;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type OfflineWorkerResponseMessage =
|
|
57
|
+
| OfflineWorkerProgressMessage
|
|
58
|
+
| OfflineWorkerResultMessage
|
|
59
|
+
| OfflineWorkerErrorMessage;
|
|
@@ -31,7 +31,8 @@ export function scheduleParam(
|
|
|
31
31
|
// Mirror the lane to the live worklet engine so scheduled automation plays
|
|
32
32
|
// back in real time, not just in renderOffline(). Lanes can exceed the
|
|
33
33
|
// fixed-size SAB command record, so they ride an out-of-band 'syncAutomation'
|
|
34
|
-
// message
|
|
34
|
+
// message whose port handler publishes the bounded snapshot consumed by
|
|
35
|
+
// process() (like syncClips/syncMarkers).
|
|
35
36
|
ctx.postSync({ type: 'syncAutomation', paramId, points: lane });
|
|
36
37
|
}
|
|
37
38
|
|
|
@@ -15,11 +15,17 @@ type CaptureConfig = Omit<SonareEngineSyncCaptureMessage, 'type'>;
|
|
|
15
15
|
export interface EngineCaptureContext {
|
|
16
16
|
readonly offlineEngine: RealtimeEngine;
|
|
17
17
|
readonly realtimeNode: SonareRealtimeEngineNode;
|
|
18
|
+
sendCommand(command: {
|
|
19
|
+
type: SonareEngineCommandType;
|
|
20
|
+
targetId?: number;
|
|
21
|
+
sampleTime?: number;
|
|
22
|
+
argFloat?: number;
|
|
23
|
+
argInt?: number;
|
|
24
|
+
}): boolean;
|
|
18
25
|
readonly offlineChannelCount: number;
|
|
19
26
|
postSync(message: SonareEngineSyncMessage): void;
|
|
20
27
|
getCaptureConfig(): CaptureConfig | undefined;
|
|
21
28
|
setCaptureConfig(config: CaptureConfig): void;
|
|
22
|
-
resolveTargetId(target: string | number): number;
|
|
23
29
|
}
|
|
24
30
|
|
|
25
31
|
export function configureCapture(ctx: EngineCaptureContext, options: CaptureOptions): void {
|
|
@@ -37,13 +43,16 @@ export function armRecord(
|
|
|
37
43
|
trackId: string | number,
|
|
38
44
|
enabled: boolean,
|
|
39
45
|
): boolean {
|
|
46
|
+
if (trackId !== 0) {
|
|
47
|
+
throw new RangeError('Capture is global; armRecord only accepts trackId 0');
|
|
48
|
+
}
|
|
40
49
|
if (enabled && !ctx.getCaptureConfig()) {
|
|
41
50
|
throw new Error('Capture buffer is not configured');
|
|
42
51
|
}
|
|
43
52
|
ctx.offlineEngine.armCapture(enabled);
|
|
44
|
-
return ctx.
|
|
53
|
+
return ctx.sendCommand({
|
|
45
54
|
type: SonareEngineCommandType.ArmRecord,
|
|
46
|
-
targetId:
|
|
55
|
+
targetId: 0,
|
|
47
56
|
sampleTime: -1,
|
|
48
57
|
argInt: enabled ? 1 : 0,
|
|
49
58
|
});
|
|
@@ -58,7 +67,7 @@ export function punch(ctx: EngineCaptureContext, inPpq: number, outPpq: number):
|
|
|
58
67
|
// point and let the consumer multiply by sampleRate (treating PPQ as
|
|
59
68
|
// seconds), which ignored tempo and produced a punch-out ~2x too large at
|
|
60
69
|
// 120 BPM. argInt = in sample, argFloat = out sample (full-precision double).
|
|
61
|
-
return ctx.
|
|
70
|
+
return ctx.sendCommand({
|
|
62
71
|
type: SonareEngineCommandType.Punch,
|
|
63
72
|
sampleTime: -1,
|
|
64
73
|
argInt: inSample,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { EngineClip, EngineMidiClipSchedule, RealtimeEngine } from '../index';
|
|
2
|
+
import type { ClipPageProvider } from '../realtime_engine';
|
|
2
3
|
import type { SonareEngineSyncMessage } from './messages';
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -14,6 +15,8 @@ export interface EngineClipContext {
|
|
|
14
15
|
postSync(message: SonareEngineSyncMessage, transfer?: Transferable[]): void;
|
|
15
16
|
resolveTargetId(target: string | number): number;
|
|
16
17
|
ensureTrackLane(target: string | number): number;
|
|
18
|
+
/** Commits a provider already primed through the worklet's OPFS pull bridge. */
|
|
19
|
+
commitWorkletClipPageProvider(clip: EngineClip): boolean;
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
// Keep control-message work bounded even for long tempo-synced clips. The
|
|
@@ -25,15 +28,15 @@ const PREBAKED_CLIP_PAGE_FRAMES = 4_096;
|
|
|
25
28
|
export function addClip(
|
|
26
29
|
ctx: EngineClipContext,
|
|
27
30
|
trackId: string | number,
|
|
28
|
-
buffer: Float32Array[],
|
|
31
|
+
buffer: Float32Array[] | ClipPageProvider,
|
|
29
32
|
startPpq: number,
|
|
30
|
-
opts: Partial<Omit<EngineClip, 'channels' | 'startPpq'>> = {},
|
|
33
|
+
opts: Partial<Omit<EngineClip, 'channels' | 'pageProvider' | 'startPpq'>> = {},
|
|
31
34
|
): number {
|
|
32
35
|
const id = opts.id ?? ctx.allocateClipId();
|
|
33
36
|
const clip: EngineClip = {
|
|
34
37
|
...opts,
|
|
35
38
|
id,
|
|
36
|
-
channels: buffer,
|
|
39
|
+
...(Array.isArray(buffer) ? { channels: buffer } : { pageProvider: buffer }),
|
|
37
40
|
startPpq,
|
|
38
41
|
trackId: ctx.resolveTargetId(trackId),
|
|
39
42
|
};
|
|
@@ -88,6 +91,12 @@ function syncClipsDelta(ctx: EngineClipContext, upserts: EngineClip[], removeIds
|
|
|
88
91
|
for (const clip of upserts) {
|
|
89
92
|
const prepared = clip.id === undefined ? clip : (preparedById.get(clip.id) ?? clip);
|
|
90
93
|
const channels = prepared.channels;
|
|
94
|
+
if (!channels && prepared.pageProvider !== undefined) {
|
|
95
|
+
if (ctx.commitWorkletClipPageProvider(prepared)) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
throw new Error('A pageProvider on SonareEngine must be created by attachOpfsClipStream().');
|
|
99
|
+
}
|
|
91
100
|
if (
|
|
92
101
|
prepared.id === undefined ||
|
|
93
102
|
prepared.warpMode !== 'off' ||
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { EngineCaptureStatus, EngineTransportState } from '../index';
|
|
2
2
|
import { engineCapabilities } from '../index';
|
|
3
3
|
import {
|
|
4
|
+
engineCaptureResponseRequestId,
|
|
5
|
+
isClipPageRequestMessage,
|
|
6
|
+
isEngineCaptureResponseForOperation,
|
|
4
7
|
isEngineCaptureResponseMessage,
|
|
5
8
|
isEngineTelemetryRecord,
|
|
6
9
|
isEngineTransportResponseMessage,
|
|
@@ -9,7 +12,9 @@ import {
|
|
|
9
12
|
} from './guards';
|
|
10
13
|
import type {
|
|
11
14
|
SonareEngineCaptureRequestMessage,
|
|
12
|
-
|
|
15
|
+
SonareEngineCaptureResponseMessageInternal,
|
|
16
|
+
SonareEngineClipPageRequestMessage,
|
|
17
|
+
SonareEngineSyncErrorMessage,
|
|
13
18
|
SonareEngineTransportResponseMessage,
|
|
14
19
|
SonareRealtimeEngineNodeCapabilities,
|
|
15
20
|
SonareRealtimeEngineNodeOptions,
|
|
@@ -17,26 +22,86 @@ import type {
|
|
|
17
22
|
SonareWorkletExternalMidiEvent,
|
|
18
23
|
} from './messages';
|
|
19
24
|
import {
|
|
25
|
+
createSonareClipPageRequestRingBuffer,
|
|
20
26
|
createSonareEngineCommandRingBuffer,
|
|
21
27
|
createSonareEngineTelemetryRingBuffer,
|
|
28
|
+
createSonareExternalMidiRingBuffer,
|
|
22
29
|
createSonareMeterRingBuffer,
|
|
23
30
|
createSonareScopeRingBuffer,
|
|
24
31
|
isRecord,
|
|
25
32
|
pushSonareEngineCommandRingBuffer,
|
|
33
|
+
readSonareClipPageRequestRingBuffer,
|
|
26
34
|
readSonareEngineTelemetryRingBuffer,
|
|
35
|
+
readSonareExternalMidiRingBuffer,
|
|
27
36
|
readSonareMeterRingBuffer,
|
|
28
37
|
readSonareScopeRingBuffer,
|
|
38
|
+
type SonareClipPageRequestRingBuffer,
|
|
29
39
|
type SonareEngineCommandRecord,
|
|
30
40
|
type SonareEngineCommandRingBuffer,
|
|
31
41
|
SonareEngineCommandType,
|
|
32
42
|
type SonareEngineTelemetryRecord,
|
|
33
43
|
type SonareEngineTelemetryRingBuffer,
|
|
44
|
+
type SonareExternalMidiRingBuffer,
|
|
34
45
|
type SonareMeterRingBuffer,
|
|
35
46
|
type SonareScopeRingBuffer,
|
|
36
47
|
type SonareWorkletMeterSnapshot,
|
|
37
48
|
type SonareWorkletScopeSnapshot,
|
|
38
49
|
} from './protocol';
|
|
39
50
|
|
|
51
|
+
function isFiniteInteger(value: number | bigint | undefined): boolean {
|
|
52
|
+
if (value === undefined) {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
return typeof value === 'bigint' || (Number.isFinite(value) && Number.isSafeInteger(value));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isTrackMonitorMode(value: number | bigint | undefined): boolean {
|
|
59
|
+
if (value === undefined) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
const mode = typeof value === 'bigint' ? Number(value) : value;
|
|
63
|
+
return typeof mode === 'number' && Number.isSafeInteger(mode) && mode >= 0 && mode <= 2;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A track-monitor command cannot infer a lane: its target is a uint32 index. */
|
|
67
|
+
function isTrackMonitorLaneIndex(value: number | undefined): boolean {
|
|
68
|
+
return (
|
|
69
|
+
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= 0xffff_ffff
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isValidCommandRecord(command: SonareEngineCommandRecord): boolean {
|
|
74
|
+
const type = Number(command.type);
|
|
75
|
+
if (
|
|
76
|
+
!Number.isSafeInteger(type) ||
|
|
77
|
+
type < SonareEngineCommandType.SetParam ||
|
|
78
|
+
(type > SonareEngineCommandType.SeekMarker &&
|
|
79
|
+
type !== SonareEngineCommandType.SetTrackMonitorMode)
|
|
80
|
+
) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
return (
|
|
84
|
+
(command.targetId === undefined || Number.isSafeInteger(command.targetId)) &&
|
|
85
|
+
(command.argFloat === undefined || Number.isFinite(command.argFloat)) &&
|
|
86
|
+
isFiniteInteger(command.argInt) &&
|
|
87
|
+
isFiniteInteger(command.sampleTime) &&
|
|
88
|
+
(type !== SonareEngineCommandType.SetTrackMonitorMode ||
|
|
89
|
+
(isTrackMonitorLaneIndex(command.targetId) && isTrackMonitorMode(command.argInt)))
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const AUDIO_WORKLET_RENDER_QUANTUM = 128;
|
|
94
|
+
|
|
95
|
+
function workletBlockSize(blockSize: number | undefined): number {
|
|
96
|
+
const resolved = blockSize ?? AUDIO_WORKLET_RENDER_QUANTUM;
|
|
97
|
+
if (!Number.isSafeInteger(resolved) || resolved < AUDIO_WORKLET_RENDER_QUANTUM) {
|
|
98
|
+
throw new RangeError(
|
|
99
|
+
`blockSize must be an integer of at least ${AUDIO_WORKLET_RENDER_QUANTUM} frames`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return resolved;
|
|
103
|
+
}
|
|
104
|
+
|
|
40
105
|
export class SonareRealtimeEngineNode {
|
|
41
106
|
readonly node: AudioWorkletNode;
|
|
42
107
|
readonly capabilities: SonareRealtimeEngineNodeCapabilities;
|
|
@@ -44,19 +109,28 @@ export class SonareRealtimeEngineNode {
|
|
|
44
109
|
readonly telemetryRing?: SonareEngineTelemetryRingBuffer;
|
|
45
110
|
readonly meterRing?: SonareMeterRingBuffer;
|
|
46
111
|
readonly scopeRing?: SonareScopeRingBuffer;
|
|
112
|
+
readonly clipPageRequestRing?: SonareClipPageRequestRingBuffer;
|
|
113
|
+
readonly externalMidiRing?: SonareExternalMidiRingBuffer;
|
|
47
114
|
readonly ready: Promise<void>;
|
|
48
115
|
private telemetryReadIndex = 0;
|
|
49
116
|
private meterReadIndex = 0;
|
|
50
117
|
private scopeReadIndex = 0;
|
|
118
|
+
private clipPageRequestDroppedRead = 0;
|
|
119
|
+
private ringPollTimer: ReturnType<typeof setInterval> | undefined;
|
|
51
120
|
private telemetryListeners = new Set<(telemetry: SonareEngineTelemetryRecord) => void>();
|
|
52
121
|
private meterListeners = new Set<(meter: SonareWorkletMeterSnapshot) => void>();
|
|
53
122
|
private scopeListeners = new Set<(scope: SonareWorkletScopeSnapshot) => void>();
|
|
54
123
|
private midiOutListeners = new Set<(events: SonareWorkletExternalMidiEvent[]) => void>();
|
|
124
|
+
private clipPageRequestListeners = new Set<
|
|
125
|
+
(message: SonareEngineClipPageRequestMessage) => void
|
|
126
|
+
>();
|
|
127
|
+
private syncErrorListeners = new Set<(message: SonareEngineSyncErrorMessage) => void>();
|
|
55
128
|
private captureRequestId = 1;
|
|
56
129
|
private readonly captureRequests = new Map<
|
|
57
130
|
number,
|
|
58
131
|
{
|
|
59
|
-
|
|
132
|
+
op: SonareEngineCaptureRequestMessage['op'];
|
|
133
|
+
resolve: (response: SonareEngineCaptureResponseMessageInternal) => void;
|
|
60
134
|
reject: (reason?: unknown) => void;
|
|
61
135
|
}
|
|
62
136
|
>();
|
|
@@ -79,6 +153,8 @@ export class SonareRealtimeEngineNode {
|
|
|
79
153
|
telemetryRing?: SonareEngineTelemetryRingBuffer,
|
|
80
154
|
meterRing?: SonareMeterRingBuffer,
|
|
81
155
|
scopeRing?: SonareScopeRingBuffer,
|
|
156
|
+
clipPageRequestRing?: SonareClipPageRequestRingBuffer,
|
|
157
|
+
externalMidiRing?: SonareExternalMidiRingBuffer,
|
|
82
158
|
) {
|
|
83
159
|
this.node = node;
|
|
84
160
|
this.capabilities = capabilities;
|
|
@@ -86,6 +162,8 @@ export class SonareRealtimeEngineNode {
|
|
|
86
162
|
this.telemetryRing = telemetryRing;
|
|
87
163
|
this.meterRing = meterRing;
|
|
88
164
|
this.scopeRing = scopeRing;
|
|
165
|
+
this.clipPageRequestRing = clipPageRequestRing;
|
|
166
|
+
this.externalMidiRing = externalMidiRing;
|
|
89
167
|
this.ready = new Promise((resolve, reject) => {
|
|
90
168
|
this.resolveReady = resolve;
|
|
91
169
|
this.rejectReady = reject;
|
|
@@ -94,14 +172,19 @@ export class SonareRealtimeEngineNode {
|
|
|
94
172
|
this.resolveReady();
|
|
95
173
|
}
|
|
96
174
|
this.node.port.onmessage = (event: MessageEvent<unknown>) => {
|
|
97
|
-
|
|
98
|
-
|
|
175
|
+
const captureRequestId = engineCaptureResponseRequestId(event.data);
|
|
176
|
+
if (captureRequestId !== undefined) {
|
|
177
|
+
const pending = this.captureRequests.get(captureRequestId);
|
|
99
178
|
if (pending) {
|
|
100
|
-
this.captureRequests.delete(
|
|
101
|
-
if (event.data
|
|
179
|
+
this.captureRequests.delete(captureRequestId);
|
|
180
|
+
if (!isEngineCaptureResponseMessage(event.data)) {
|
|
181
|
+
pending.reject(new Error('Malformed capture response.'));
|
|
182
|
+
} else if (!isEngineCaptureResponseForOperation(event.data, pending.op)) {
|
|
183
|
+
pending.reject(new Error('Capture response does not match request operation.'));
|
|
184
|
+
} else if (event.data.ok) {
|
|
102
185
|
pending.resolve(event.data);
|
|
103
186
|
} else {
|
|
104
|
-
pending.reject(new Error(event.data.error
|
|
187
|
+
pending.reject(new Error(event.data.error));
|
|
105
188
|
}
|
|
106
189
|
}
|
|
107
190
|
} else if (isEngineTransportResponseMessage(event.data)) {
|
|
@@ -120,6 +203,17 @@ export class SonareRealtimeEngineNode {
|
|
|
120
203
|
this.emitMeter(event.data);
|
|
121
204
|
} else if (isExternalMidiBatchMessage(event.data)) {
|
|
122
205
|
this.emitMidiOut(event.data.events);
|
|
206
|
+
} else if (isClipPageRequestMessage(event.data)) {
|
|
207
|
+
this.emitClipPageRequests(event.data);
|
|
208
|
+
} else if (isRecord(event.data) && event.data.type === 'syncError') {
|
|
209
|
+
const syncError: SonareEngineSyncErrorMessage = {
|
|
210
|
+
type: 'syncError',
|
|
211
|
+
syncType: String(event.data.syncType) as SonareEngineSyncErrorMessage['syncType'],
|
|
212
|
+
message: String(event.data.message ?? 'AudioWorklet sync failed'),
|
|
213
|
+
};
|
|
214
|
+
for (const listener of this.syncErrorListeners) {
|
|
215
|
+
listener(syncError);
|
|
216
|
+
}
|
|
123
217
|
} else if (isRecord(event.data) && event.data.type === 'ready') {
|
|
124
218
|
this.resolveReady();
|
|
125
219
|
} else if (isRecord(event.data) && event.data.type === 'error') {
|
|
@@ -128,10 +222,17 @@ export class SonareRealtimeEngineNode {
|
|
|
128
222
|
};
|
|
129
223
|
}
|
|
130
224
|
|
|
225
|
+
/** Subscribes to control-plane sync rejections reported by the worklet. */
|
|
226
|
+
onSyncError(listener: (message: SonareEngineSyncErrorMessage) => void): () => void {
|
|
227
|
+
this.syncErrorListeners.add(listener);
|
|
228
|
+
return () => this.syncErrorListeners.delete(listener);
|
|
229
|
+
}
|
|
230
|
+
|
|
131
231
|
static async create(
|
|
132
232
|
context: BaseAudioContext,
|
|
133
233
|
options: SonareRealtimeEngineNodeOptions = {},
|
|
134
234
|
): Promise<SonareRealtimeEngineNode> {
|
|
235
|
+
const blockSize = workletBlockSize(options.blockSize);
|
|
135
236
|
const processorName = options.processorName ?? 'sonare-realtime-engine-processor';
|
|
136
237
|
const moduleUrl = options.moduleUrl;
|
|
137
238
|
if (moduleUrl && context.audioWorklet?.addModule) {
|
|
@@ -188,10 +289,21 @@ export class SonareRealtimeEngineNode {
|
|
|
188
289
|
mode === 'sab' && scopeIntervalFrames > 0
|
|
189
290
|
? createSonareScopeRingBuffer(options.scopeRingCapacity ?? 64, options.scopeBands ?? 48)
|
|
190
291
|
: undefined;
|
|
292
|
+
// Paged-clip cache misses are a worklet-to-main-thread control signal. A
|
|
293
|
+
// dedicated SPSC SAB ring keeps process() free of both embind request
|
|
294
|
+
// objects and postMessage structured cloning.
|
|
295
|
+
const clipPageRequestRing =
|
|
296
|
+
mode === 'sab'
|
|
297
|
+
? createSonareClipPageRequestRingBuffer(options.clipPageRequestRingCapacity ?? 128)
|
|
298
|
+
: undefined;
|
|
299
|
+
const externalMidiRing =
|
|
300
|
+
mode === 'sab'
|
|
301
|
+
? createSonareExternalMidiRingBuffer(options.externalMidiRingCapacity ?? 256)
|
|
302
|
+
: undefined;
|
|
191
303
|
const channelCount = Math.max(1, Math.floor(options.channelCount ?? 2));
|
|
192
304
|
const processorOptions: SonareRealtimeEngineWorkletProcessorOptions = {
|
|
193
305
|
sampleRate: options.sampleRate ?? context.sampleRate,
|
|
194
|
-
blockSize
|
|
306
|
+
blockSize,
|
|
195
307
|
channelCount,
|
|
196
308
|
commandSharedBuffer: commandRing?.sharedBuffer,
|
|
197
309
|
commandRingCapacity: commandRing?.capacity,
|
|
@@ -203,6 +315,10 @@ export class SonareRealtimeEngineNode {
|
|
|
203
315
|
scopeRingCapacity: scopeRing?.capacity,
|
|
204
316
|
scopeBands: scopeRing?.bands,
|
|
205
317
|
scopeIntervalFrames: scopeRing ? scopeIntervalFrames : undefined,
|
|
318
|
+
clipPageRequestSharedBuffer: clipPageRequestRing?.sharedBuffer,
|
|
319
|
+
clipPageRequestRingCapacity: clipPageRequestRing?.capacity,
|
|
320
|
+
externalMidiSharedBuffer: externalMidiRing?.sharedBuffer,
|
|
321
|
+
externalMidiRingCapacity: externalMidiRing?.capacity,
|
|
206
322
|
wasmBinary: options.wasmBinary,
|
|
207
323
|
initialSyncMessages: options.initialSyncMessages,
|
|
208
324
|
initialCommands: options.initialCommands,
|
|
@@ -225,16 +341,23 @@ export class SonareRealtimeEngineNode {
|
|
|
225
341
|
sharedArrayBuffer,
|
|
226
342
|
atomics,
|
|
227
343
|
audioWorklet,
|
|
344
|
+
clipPageRequestsRealtimeSafe: mode === 'sab',
|
|
345
|
+
externalMidiRealtimeSafe: mode === 'sab',
|
|
228
346
|
engineAbiVersion: detectedCapabilities?.engineAbiVersion,
|
|
229
347
|
expectedEngineAbiVersion: detectedCapabilities?.expectedEngineAbiVersion,
|
|
230
348
|
abiCompatible: detectedCapabilities?.abiCompatible,
|
|
231
349
|
degradedReason,
|
|
232
|
-
|
|
350
|
+
// A processor posts ready/error irrespective of whether its module was
|
|
351
|
+
// loaded here or registered by the host beforehand. Waiting in both
|
|
352
|
+
// cases is the only way to surface a failed worklet-side WASM init.
|
|
353
|
+
readyMessage: true,
|
|
233
354
|
},
|
|
234
355
|
commandRing,
|
|
235
356
|
telemetryRing,
|
|
236
357
|
meterRing,
|
|
237
358
|
scopeRing,
|
|
359
|
+
clipPageRequestRing,
|
|
360
|
+
externalMidiRing,
|
|
238
361
|
);
|
|
239
362
|
}
|
|
240
363
|
|
|
@@ -255,6 +378,9 @@ export class SonareRealtimeEngineNode {
|
|
|
255
378
|
}
|
|
256
379
|
|
|
257
380
|
seekPpq(ppq: number, sampleTime = -1): boolean {
|
|
381
|
+
if (!Number.isFinite(ppq) || !Number.isSafeInteger(sampleTime)) {
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
258
384
|
return this.sendCommand({
|
|
259
385
|
type: SonareEngineCommandType.TransportSeekPpq,
|
|
260
386
|
sampleTime,
|
|
@@ -263,7 +389,7 @@ export class SonareRealtimeEngineNode {
|
|
|
263
389
|
}
|
|
264
390
|
|
|
265
391
|
sendCommand(command: SonareEngineCommandRecord): boolean {
|
|
266
|
-
if (this.destroyed) {
|
|
392
|
+
if (this.destroyed || !isValidCommandRecord(command)) {
|
|
267
393
|
return false;
|
|
268
394
|
}
|
|
269
395
|
if (this.commandRing) {
|
|
@@ -275,7 +401,7 @@ export class SonareRealtimeEngineNode {
|
|
|
275
401
|
|
|
276
402
|
requestCaptureStatus(): Promise<EngineCaptureStatus> {
|
|
277
403
|
return this.sendCaptureRequest('status').then((response) => {
|
|
278
|
-
if (!response.status) {
|
|
404
|
+
if (!response.ok || !('status' in response)) {
|
|
279
405
|
throw new Error('Capture status response is missing status.');
|
|
280
406
|
}
|
|
281
407
|
return response.status;
|
|
@@ -283,11 +409,12 @@ export class SonareRealtimeEngineNode {
|
|
|
283
409
|
}
|
|
284
410
|
|
|
285
411
|
requestCapturedAudio(): Promise<Float32Array[]> {
|
|
286
|
-
return this.sendCaptureRequest('read').then((response) =>
|
|
287
|
-
(response.channels
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
412
|
+
return this.sendCaptureRequest('read').then((response) => {
|
|
413
|
+
if (!response.ok || !('channels' in response)) {
|
|
414
|
+
throw new Error('Capture read response is missing channels.');
|
|
415
|
+
}
|
|
416
|
+
return response.channels;
|
|
417
|
+
});
|
|
291
418
|
}
|
|
292
419
|
|
|
293
420
|
requestCaptureReset(): Promise<void> {
|
|
@@ -345,24 +472,77 @@ export class SonareRealtimeEngineNode {
|
|
|
345
472
|
return read.scopes;
|
|
346
473
|
}
|
|
347
474
|
|
|
475
|
+
/** Drain lowered MIDI-1 records from the SAB ring on the main thread. */
|
|
476
|
+
pollMidiOut(): SonareWorkletExternalMidiEvent[] {
|
|
477
|
+
if (!this.externalMidiRing) {
|
|
478
|
+
return [];
|
|
479
|
+
}
|
|
480
|
+
const read = readSonareExternalMidiRingBuffer(this.externalMidiRing);
|
|
481
|
+
const events = read.events.map((event) => {
|
|
482
|
+
const bytes: number[] = [];
|
|
483
|
+
for (let index = 0; index < event.byteCount; index++) {
|
|
484
|
+
bytes.push((event.byteWord >>> (8 * index)) & 0xff);
|
|
485
|
+
}
|
|
486
|
+
return {
|
|
487
|
+
destinationId: event.destinationId,
|
|
488
|
+
renderFrame: event.renderFrame,
|
|
489
|
+
bytes,
|
|
490
|
+
};
|
|
491
|
+
});
|
|
492
|
+
if (events.length > 0) {
|
|
493
|
+
this.emitMidiOut(events);
|
|
494
|
+
}
|
|
495
|
+
return events;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Drains bounded paged-clip misses from the SAB ring and forwards one batch
|
|
500
|
+
* to subscribers. In postMessage mode the legacy handler remains available,
|
|
501
|
+
* but that degraded path is not realtime-safe for OPFS streaming.
|
|
502
|
+
*/
|
|
503
|
+
pollClipPageRequests(): SonareEngineClipPageRequestMessage | undefined {
|
|
504
|
+
if (!this.clipPageRequestRing) {
|
|
505
|
+
return undefined;
|
|
506
|
+
}
|
|
507
|
+
const read = readSonareClipPageRequestRingBuffer(this.clipPageRequestRing);
|
|
508
|
+
const dropped = (read.dropped - this.clipPageRequestDroppedRead) >>> 0;
|
|
509
|
+
this.clipPageRequestDroppedRead = read.dropped;
|
|
510
|
+
if (read.requests.length === 0 && dropped === 0) {
|
|
511
|
+
return undefined;
|
|
512
|
+
}
|
|
513
|
+
const message: SonareEngineClipPageRequestMessage = {
|
|
514
|
+
type: 'clipPageRequest',
|
|
515
|
+
requests: read.requests,
|
|
516
|
+
...(dropped > 0 ? { dropped } : {}),
|
|
517
|
+
};
|
|
518
|
+
this.emitClipPageRequests(message);
|
|
519
|
+
return message;
|
|
520
|
+
}
|
|
521
|
+
|
|
348
522
|
onTelemetry(callback: (telemetry: SonareEngineTelemetryRecord) => void): () => void {
|
|
349
523
|
this.telemetryListeners.add(callback);
|
|
524
|
+
this.startRingPolling();
|
|
350
525
|
return () => {
|
|
351
526
|
this.telemetryListeners.delete(callback);
|
|
527
|
+
this.stopRingPollingIfUnused();
|
|
352
528
|
};
|
|
353
529
|
}
|
|
354
530
|
|
|
355
531
|
onMeter(callback: (meter: SonareWorkletMeterSnapshot) => void): () => void {
|
|
356
532
|
this.meterListeners.add(callback);
|
|
533
|
+
this.startRingPolling();
|
|
357
534
|
return () => {
|
|
358
535
|
this.meterListeners.delete(callback);
|
|
536
|
+
this.stopRingPollingIfUnused();
|
|
359
537
|
};
|
|
360
538
|
}
|
|
361
539
|
|
|
362
540
|
onScope(callback: (scope: SonareWorkletScopeSnapshot) => void): () => void {
|
|
363
541
|
this.scopeListeners.add(callback);
|
|
542
|
+
this.startRingPolling();
|
|
364
543
|
return () => {
|
|
365
544
|
this.scopeListeners.delete(callback);
|
|
545
|
+
this.stopRingPollingIfUnused();
|
|
366
546
|
};
|
|
367
547
|
}
|
|
368
548
|
|
|
@@ -373,8 +553,21 @@ export class SonareRealtimeEngineNode {
|
|
|
373
553
|
*/
|
|
374
554
|
onMidiOut(callback: (events: SonareWorkletExternalMidiEvent[]) => void): () => void {
|
|
375
555
|
this.midiOutListeners.add(callback);
|
|
556
|
+
this.startRingPolling();
|
|
376
557
|
return () => {
|
|
377
558
|
this.midiOutListeners.delete(callback);
|
|
559
|
+
this.stopRingPollingIfUnused();
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Subscribe to bounded batches of paged-clip misses from the worklet. The
|
|
565
|
+
* callback runs on the main thread and may initiate asynchronous OPFS I/O.
|
|
566
|
+
*/
|
|
567
|
+
onClipPageRequests(callback: (message: SonareEngineClipPageRequestMessage) => void): () => void {
|
|
568
|
+
this.clipPageRequestListeners.add(callback);
|
|
569
|
+
return () => {
|
|
570
|
+
this.clipPageRequestListeners.delete(callback);
|
|
378
571
|
};
|
|
379
572
|
}
|
|
380
573
|
|
|
@@ -383,7 +576,11 @@ export class SonareRealtimeEngineNode {
|
|
|
383
576
|
return;
|
|
384
577
|
}
|
|
385
578
|
this.destroyed = true;
|
|
386
|
-
this.
|
|
579
|
+
if (this.ringPollTimer !== undefined) {
|
|
580
|
+
clearInterval(this.ringPollTimer);
|
|
581
|
+
this.ringPollTimer = undefined;
|
|
582
|
+
}
|
|
583
|
+
this.node.port.postMessage({ type: 'destroy' });
|
|
387
584
|
this.node.disconnect();
|
|
388
585
|
for (const pending of this.captureRequests.values()) {
|
|
389
586
|
pending.reject(new Error('Realtime engine node is destroyed.'));
|
|
@@ -397,6 +594,7 @@ export class SonareRealtimeEngineNode {
|
|
|
397
594
|
this.meterListeners.clear();
|
|
398
595
|
this.scopeListeners.clear();
|
|
399
596
|
this.midiOutListeners.clear();
|
|
597
|
+
this.clipPageRequestListeners.clear();
|
|
400
598
|
}
|
|
401
599
|
|
|
402
600
|
private emitTelemetry(telemetry: SonareEngineTelemetryRecord): void {
|
|
@@ -405,6 +603,38 @@ export class SonareRealtimeEngineNode {
|
|
|
405
603
|
}
|
|
406
604
|
}
|
|
407
605
|
|
|
606
|
+
private startRingPolling(): void {
|
|
607
|
+
if (
|
|
608
|
+
this.ringPollTimer !== undefined ||
|
|
609
|
+
(!this.telemetryRing && !this.meterRing && !this.scopeRing && !this.externalMidiRing)
|
|
610
|
+
) {
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
const poll = () => {
|
|
614
|
+
if (!this.destroyed) {
|
|
615
|
+
this.pollTelemetry();
|
|
616
|
+
this.pollMeters();
|
|
617
|
+
this.pollScope();
|
|
618
|
+
this.pollMidiOut();
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
poll();
|
|
622
|
+
this.ringPollTimer = setInterval(poll, 16);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
private stopRingPollingIfUnused(): void {
|
|
626
|
+
if (
|
|
627
|
+
this.ringPollTimer !== undefined &&
|
|
628
|
+
this.telemetryListeners.size === 0 &&
|
|
629
|
+
this.meterListeners.size === 0 &&
|
|
630
|
+
this.scopeListeners.size === 0 &&
|
|
631
|
+
this.midiOutListeners.size === 0
|
|
632
|
+
) {
|
|
633
|
+
clearInterval(this.ringPollTimer);
|
|
634
|
+
this.ringPollTimer = undefined;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
408
638
|
private emitMeter(meter: SonareWorkletMeterSnapshot): void {
|
|
409
639
|
for (const listener of this.meterListeners) {
|
|
410
640
|
listener(meter);
|
|
@@ -417,6 +647,12 @@ export class SonareRealtimeEngineNode {
|
|
|
417
647
|
}
|
|
418
648
|
}
|
|
419
649
|
|
|
650
|
+
private emitClipPageRequests(message: SonareEngineClipPageRequestMessage): void {
|
|
651
|
+
for (const listener of this.clipPageRequestListeners) {
|
|
652
|
+
listener(message);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
420
656
|
private emitScope(scope: SonareWorkletScopeSnapshot): void {
|
|
421
657
|
for (const listener of this.scopeListeners) {
|
|
422
658
|
listener(scope);
|
|
@@ -425,15 +661,22 @@ export class SonareRealtimeEngineNode {
|
|
|
425
661
|
|
|
426
662
|
private sendCaptureRequest(
|
|
427
663
|
op: SonareEngineCaptureRequestMessage['op'],
|
|
428
|
-
): Promise<
|
|
664
|
+
): Promise<SonareEngineCaptureResponseMessageInternal> {
|
|
429
665
|
if (this.destroyed) {
|
|
430
666
|
return Promise.reject(new Error('Realtime engine node is destroyed.'));
|
|
431
667
|
}
|
|
432
668
|
const requestId = this.captureRequestId++;
|
|
433
|
-
|
|
434
|
-
|
|
669
|
+
let rejectRequest!: (reason?: unknown) => void;
|
|
670
|
+
const promise = new Promise<SonareEngineCaptureResponseMessageInternal>((resolve, reject) => {
|
|
671
|
+
rejectRequest = reject;
|
|
672
|
+
this.captureRequests.set(requestId, { op, resolve, reject });
|
|
435
673
|
});
|
|
436
|
-
|
|
674
|
+
try {
|
|
675
|
+
this.node.port.postMessage({ type: 'captureRequest', requestId, op });
|
|
676
|
+
} catch (error) {
|
|
677
|
+
this.captureRequests.delete(requestId);
|
|
678
|
+
rejectRequest(error);
|
|
679
|
+
}
|
|
437
680
|
return promise;
|
|
438
681
|
}
|
|
439
682
|
|