@libraz/libsonare 1.5.4 → 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 +873 -63
- package/dist/index.js +1037 -265
- 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 +596 -86
- package/dist/worklet.js +1658 -235
- package/dist/worklet.js.map +1 -1
- package/package.json +23 -11
- package/src/_chain_config.ts +5 -23
- package/src/analysis.ts +137 -0
- package/src/analysis_helpers.ts +29 -0
- package/src/audio.ts +5 -3
- package/src/clip_page_streamer.ts +62 -8
- package/src/codes.ts +51 -64
- package/src/effects_mastering.ts +1 -0
- package/src/effects_transform.ts +12 -6
- package/src/errors.ts +1 -0
- package/src/feature_core.ts +159 -3
- package/src/feature_music.ts +35 -16
- package/src/feature_pitch.ts +75 -6
- package/src/feature_spectral.ts +234 -8
- package/src/feature_spectrogram.ts +186 -5
- package/src/features.ts +16 -1
- package/src/index.ts +104 -7
- package/src/mastering_chain.ts +20 -8
- package/src/mastering_core.ts +7 -0
- package/src/mastering_dynamics.ts +7 -1
- package/src/metering.ts +51 -5
- package/src/mixer.ts +14 -1
- package/src/opfs_clip_pages.ts +26 -1
- package/src/project.ts +7 -0
- package/src/project_class.ts +74 -13
- 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 +28 -0
- package/src/public_types_mixing.ts +11 -2
- package/src/public_types_music.ts +13 -0
- package/src/public_types_spectral.ts +43 -3
- package/src/quick_analysis.ts +48 -12
- package/src/realtime_engine.ts +162 -4
- package/src/realtime_voice_changer.ts +51 -77
- package/src/sonare-analysis.js.d.ts +8 -0
- package/src/sonare.js.d.ts +305 -9
- package/src/streaming_processors.ts +22 -1
- package/src/validation.ts +6 -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 +141 -9
- package/src/worklet/protocol.ts +303 -17
- package/src/worklet/voice-changer-processor.ts +12 -15
- package/src/worklet.ts +20 -1
|
@@ -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,7 @@
|
|
|
1
1
|
import type { EngineCaptureStatus, EngineTransportState } from '../index';
|
|
2
2
|
import { engineCapabilities } from '../index';
|
|
3
3
|
import {
|
|
4
|
+
isClipPageRequestMessage,
|
|
4
5
|
isEngineCaptureResponseMessage,
|
|
5
6
|
isEngineTelemetryRecord,
|
|
6
7
|
isEngineTransportResponseMessage,
|
|
@@ -10,6 +11,8 @@ import {
|
|
|
10
11
|
import type {
|
|
11
12
|
SonareEngineCaptureRequestMessage,
|
|
12
13
|
SonareEngineCaptureResponseMessage,
|
|
14
|
+
SonareEngineClipPageRequestMessage,
|
|
15
|
+
SonareEngineSyncErrorMessage,
|
|
13
16
|
SonareEngineTransportResponseMessage,
|
|
14
17
|
SonareRealtimeEngineNodeCapabilities,
|
|
15
18
|
SonareRealtimeEngineNodeOptions,
|
|
@@ -17,26 +20,68 @@ import type {
|
|
|
17
20
|
SonareWorkletExternalMidiEvent,
|
|
18
21
|
} from './messages';
|
|
19
22
|
import {
|
|
23
|
+
createSonareClipPageRequestRingBuffer,
|
|
20
24
|
createSonareEngineCommandRingBuffer,
|
|
21
25
|
createSonareEngineTelemetryRingBuffer,
|
|
26
|
+
createSonareExternalMidiRingBuffer,
|
|
22
27
|
createSonareMeterRingBuffer,
|
|
23
28
|
createSonareScopeRingBuffer,
|
|
24
29
|
isRecord,
|
|
25
30
|
pushSonareEngineCommandRingBuffer,
|
|
31
|
+
readSonareClipPageRequestRingBuffer,
|
|
26
32
|
readSonareEngineTelemetryRingBuffer,
|
|
33
|
+
readSonareExternalMidiRingBuffer,
|
|
27
34
|
readSonareMeterRingBuffer,
|
|
28
35
|
readSonareScopeRingBuffer,
|
|
36
|
+
type SonareClipPageRequestRingBuffer,
|
|
29
37
|
type SonareEngineCommandRecord,
|
|
30
38
|
type SonareEngineCommandRingBuffer,
|
|
31
39
|
SonareEngineCommandType,
|
|
32
40
|
type SonareEngineTelemetryRecord,
|
|
33
41
|
type SonareEngineTelemetryRingBuffer,
|
|
42
|
+
type SonareExternalMidiRingBuffer,
|
|
34
43
|
type SonareMeterRingBuffer,
|
|
35
44
|
type SonareScopeRingBuffer,
|
|
36
45
|
type SonareWorkletMeterSnapshot,
|
|
37
46
|
type SonareWorkletScopeSnapshot,
|
|
38
47
|
} from './protocol';
|
|
39
48
|
|
|
49
|
+
function isFiniteInteger(value: number | bigint | undefined): boolean {
|
|
50
|
+
if (value === undefined) {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
return typeof value === 'bigint' || (Number.isFinite(value) && Number.isSafeInteger(value));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isValidCommandRecord(command: SonareEngineCommandRecord): boolean {
|
|
57
|
+
const type = Number(command.type);
|
|
58
|
+
if (
|
|
59
|
+
!Number.isSafeInteger(type) ||
|
|
60
|
+
type < SonareEngineCommandType.SetParam ||
|
|
61
|
+
type > SonareEngineCommandType.SeekMarker
|
|
62
|
+
) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
return (
|
|
66
|
+
(command.targetId === undefined || Number.isSafeInteger(command.targetId)) &&
|
|
67
|
+
(command.argFloat === undefined || Number.isFinite(command.argFloat)) &&
|
|
68
|
+
isFiniteInteger(command.argInt) &&
|
|
69
|
+
isFiniteInteger(command.sampleTime)
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const AUDIO_WORKLET_RENDER_QUANTUM = 128;
|
|
74
|
+
|
|
75
|
+
function workletBlockSize(blockSize: number | undefined): number {
|
|
76
|
+
const resolved = blockSize ?? AUDIO_WORKLET_RENDER_QUANTUM;
|
|
77
|
+
if (!Number.isSafeInteger(resolved) || resolved < AUDIO_WORKLET_RENDER_QUANTUM) {
|
|
78
|
+
throw new RangeError(
|
|
79
|
+
`blockSize must be an integer of at least ${AUDIO_WORKLET_RENDER_QUANTUM} frames`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return resolved;
|
|
83
|
+
}
|
|
84
|
+
|
|
40
85
|
export class SonareRealtimeEngineNode {
|
|
41
86
|
readonly node: AudioWorkletNode;
|
|
42
87
|
readonly capabilities: SonareRealtimeEngineNodeCapabilities;
|
|
@@ -44,14 +89,22 @@ export class SonareRealtimeEngineNode {
|
|
|
44
89
|
readonly telemetryRing?: SonareEngineTelemetryRingBuffer;
|
|
45
90
|
readonly meterRing?: SonareMeterRingBuffer;
|
|
46
91
|
readonly scopeRing?: SonareScopeRingBuffer;
|
|
92
|
+
readonly clipPageRequestRing?: SonareClipPageRequestRingBuffer;
|
|
93
|
+
readonly externalMidiRing?: SonareExternalMidiRingBuffer;
|
|
47
94
|
readonly ready: Promise<void>;
|
|
48
95
|
private telemetryReadIndex = 0;
|
|
49
96
|
private meterReadIndex = 0;
|
|
50
97
|
private scopeReadIndex = 0;
|
|
98
|
+
private clipPageRequestDroppedRead = 0;
|
|
99
|
+
private ringPollTimer: ReturnType<typeof setInterval> | undefined;
|
|
51
100
|
private telemetryListeners = new Set<(telemetry: SonareEngineTelemetryRecord) => void>();
|
|
52
101
|
private meterListeners = new Set<(meter: SonareWorkletMeterSnapshot) => void>();
|
|
53
102
|
private scopeListeners = new Set<(scope: SonareWorkletScopeSnapshot) => void>();
|
|
54
103
|
private midiOutListeners = new Set<(events: SonareWorkletExternalMidiEvent[]) => void>();
|
|
104
|
+
private clipPageRequestListeners = new Set<
|
|
105
|
+
(message: SonareEngineClipPageRequestMessage) => void
|
|
106
|
+
>();
|
|
107
|
+
private syncErrorListeners = new Set<(message: SonareEngineSyncErrorMessage) => void>();
|
|
55
108
|
private captureRequestId = 1;
|
|
56
109
|
private readonly captureRequests = new Map<
|
|
57
110
|
number,
|
|
@@ -79,6 +132,8 @@ export class SonareRealtimeEngineNode {
|
|
|
79
132
|
telemetryRing?: SonareEngineTelemetryRingBuffer,
|
|
80
133
|
meterRing?: SonareMeterRingBuffer,
|
|
81
134
|
scopeRing?: SonareScopeRingBuffer,
|
|
135
|
+
clipPageRequestRing?: SonareClipPageRequestRingBuffer,
|
|
136
|
+
externalMidiRing?: SonareExternalMidiRingBuffer,
|
|
82
137
|
) {
|
|
83
138
|
this.node = node;
|
|
84
139
|
this.capabilities = capabilities;
|
|
@@ -86,6 +141,8 @@ export class SonareRealtimeEngineNode {
|
|
|
86
141
|
this.telemetryRing = telemetryRing;
|
|
87
142
|
this.meterRing = meterRing;
|
|
88
143
|
this.scopeRing = scopeRing;
|
|
144
|
+
this.clipPageRequestRing = clipPageRequestRing;
|
|
145
|
+
this.externalMidiRing = externalMidiRing;
|
|
89
146
|
this.ready = new Promise((resolve, reject) => {
|
|
90
147
|
this.resolveReady = resolve;
|
|
91
148
|
this.rejectReady = reject;
|
|
@@ -120,6 +177,17 @@ export class SonareRealtimeEngineNode {
|
|
|
120
177
|
this.emitMeter(event.data);
|
|
121
178
|
} else if (isExternalMidiBatchMessage(event.data)) {
|
|
122
179
|
this.emitMidiOut(event.data.events);
|
|
180
|
+
} else if (isClipPageRequestMessage(event.data)) {
|
|
181
|
+
this.emitClipPageRequests(event.data);
|
|
182
|
+
} else if (isRecord(event.data) && event.data.type === 'syncError') {
|
|
183
|
+
const syncError: SonareEngineSyncErrorMessage = {
|
|
184
|
+
type: 'syncError',
|
|
185
|
+
syncType: String(event.data.syncType) as SonareEngineSyncErrorMessage['syncType'],
|
|
186
|
+
message: String(event.data.message ?? 'AudioWorklet sync failed'),
|
|
187
|
+
};
|
|
188
|
+
for (const listener of this.syncErrorListeners) {
|
|
189
|
+
listener(syncError);
|
|
190
|
+
}
|
|
123
191
|
} else if (isRecord(event.data) && event.data.type === 'ready') {
|
|
124
192
|
this.resolveReady();
|
|
125
193
|
} else if (isRecord(event.data) && event.data.type === 'error') {
|
|
@@ -128,10 +196,17 @@ export class SonareRealtimeEngineNode {
|
|
|
128
196
|
};
|
|
129
197
|
}
|
|
130
198
|
|
|
199
|
+
/** Subscribes to control-plane sync rejections reported by the worklet. */
|
|
200
|
+
onSyncError(listener: (message: SonareEngineSyncErrorMessage) => void): () => void {
|
|
201
|
+
this.syncErrorListeners.add(listener);
|
|
202
|
+
return () => this.syncErrorListeners.delete(listener);
|
|
203
|
+
}
|
|
204
|
+
|
|
131
205
|
static async create(
|
|
132
206
|
context: BaseAudioContext,
|
|
133
207
|
options: SonareRealtimeEngineNodeOptions = {},
|
|
134
208
|
): Promise<SonareRealtimeEngineNode> {
|
|
209
|
+
const blockSize = workletBlockSize(options.blockSize);
|
|
135
210
|
const processorName = options.processorName ?? 'sonare-realtime-engine-processor';
|
|
136
211
|
const moduleUrl = options.moduleUrl;
|
|
137
212
|
if (moduleUrl && context.audioWorklet?.addModule) {
|
|
@@ -188,10 +263,21 @@ export class SonareRealtimeEngineNode {
|
|
|
188
263
|
mode === 'sab' && scopeIntervalFrames > 0
|
|
189
264
|
? createSonareScopeRingBuffer(options.scopeRingCapacity ?? 64, options.scopeBands ?? 48)
|
|
190
265
|
: undefined;
|
|
266
|
+
// Paged-clip cache misses are a worklet-to-main-thread control signal. A
|
|
267
|
+
// dedicated SPSC SAB ring keeps process() free of both embind request
|
|
268
|
+
// objects and postMessage structured cloning.
|
|
269
|
+
const clipPageRequestRing =
|
|
270
|
+
mode === 'sab'
|
|
271
|
+
? createSonareClipPageRequestRingBuffer(options.clipPageRequestRingCapacity ?? 128)
|
|
272
|
+
: undefined;
|
|
273
|
+
const externalMidiRing =
|
|
274
|
+
mode === 'sab'
|
|
275
|
+
? createSonareExternalMidiRingBuffer(options.externalMidiRingCapacity ?? 256)
|
|
276
|
+
: undefined;
|
|
191
277
|
const channelCount = Math.max(1, Math.floor(options.channelCount ?? 2));
|
|
192
278
|
const processorOptions: SonareRealtimeEngineWorkletProcessorOptions = {
|
|
193
279
|
sampleRate: options.sampleRate ?? context.sampleRate,
|
|
194
|
-
blockSize
|
|
280
|
+
blockSize,
|
|
195
281
|
channelCount,
|
|
196
282
|
commandSharedBuffer: commandRing?.sharedBuffer,
|
|
197
283
|
commandRingCapacity: commandRing?.capacity,
|
|
@@ -203,6 +289,10 @@ export class SonareRealtimeEngineNode {
|
|
|
203
289
|
scopeRingCapacity: scopeRing?.capacity,
|
|
204
290
|
scopeBands: scopeRing?.bands,
|
|
205
291
|
scopeIntervalFrames: scopeRing ? scopeIntervalFrames : undefined,
|
|
292
|
+
clipPageRequestSharedBuffer: clipPageRequestRing?.sharedBuffer,
|
|
293
|
+
clipPageRequestRingCapacity: clipPageRequestRing?.capacity,
|
|
294
|
+
externalMidiSharedBuffer: externalMidiRing?.sharedBuffer,
|
|
295
|
+
externalMidiRingCapacity: externalMidiRing?.capacity,
|
|
206
296
|
wasmBinary: options.wasmBinary,
|
|
207
297
|
initialSyncMessages: options.initialSyncMessages,
|
|
208
298
|
initialCommands: options.initialCommands,
|
|
@@ -225,16 +315,23 @@ export class SonareRealtimeEngineNode {
|
|
|
225
315
|
sharedArrayBuffer,
|
|
226
316
|
atomics,
|
|
227
317
|
audioWorklet,
|
|
318
|
+
clipPageRequestsRealtimeSafe: mode === 'sab',
|
|
319
|
+
externalMidiRealtimeSafe: mode === 'sab',
|
|
228
320
|
engineAbiVersion: detectedCapabilities?.engineAbiVersion,
|
|
229
321
|
expectedEngineAbiVersion: detectedCapabilities?.expectedEngineAbiVersion,
|
|
230
322
|
abiCompatible: detectedCapabilities?.abiCompatible,
|
|
231
323
|
degradedReason,
|
|
232
|
-
|
|
324
|
+
// A processor posts ready/error irrespective of whether its module was
|
|
325
|
+
// loaded here or registered by the host beforehand. Waiting in both
|
|
326
|
+
// cases is the only way to surface a failed worklet-side WASM init.
|
|
327
|
+
readyMessage: true,
|
|
233
328
|
},
|
|
234
329
|
commandRing,
|
|
235
330
|
telemetryRing,
|
|
236
331
|
meterRing,
|
|
237
332
|
scopeRing,
|
|
333
|
+
clipPageRequestRing,
|
|
334
|
+
externalMidiRing,
|
|
238
335
|
);
|
|
239
336
|
}
|
|
240
337
|
|
|
@@ -255,6 +352,9 @@ export class SonareRealtimeEngineNode {
|
|
|
255
352
|
}
|
|
256
353
|
|
|
257
354
|
seekPpq(ppq: number, sampleTime = -1): boolean {
|
|
355
|
+
if (!Number.isFinite(ppq) || !Number.isSafeInteger(sampleTime)) {
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
258
358
|
return this.sendCommand({
|
|
259
359
|
type: SonareEngineCommandType.TransportSeekPpq,
|
|
260
360
|
sampleTime,
|
|
@@ -263,7 +363,7 @@ export class SonareRealtimeEngineNode {
|
|
|
263
363
|
}
|
|
264
364
|
|
|
265
365
|
sendCommand(command: SonareEngineCommandRecord): boolean {
|
|
266
|
-
if (this.destroyed) {
|
|
366
|
+
if (this.destroyed || !isValidCommandRecord(command)) {
|
|
267
367
|
return false;
|
|
268
368
|
}
|
|
269
369
|
if (this.commandRing) {
|
|
@@ -345,24 +445,77 @@ export class SonareRealtimeEngineNode {
|
|
|
345
445
|
return read.scopes;
|
|
346
446
|
}
|
|
347
447
|
|
|
448
|
+
/** Drain lowered MIDI-1 records from the SAB ring on the main thread. */
|
|
449
|
+
pollMidiOut(): SonareWorkletExternalMidiEvent[] {
|
|
450
|
+
if (!this.externalMidiRing) {
|
|
451
|
+
return [];
|
|
452
|
+
}
|
|
453
|
+
const read = readSonareExternalMidiRingBuffer(this.externalMidiRing);
|
|
454
|
+
const events = read.events.map((event) => {
|
|
455
|
+
const bytes: number[] = [];
|
|
456
|
+
for (let index = 0; index < event.byteCount; index++) {
|
|
457
|
+
bytes.push((event.byteWord >>> (8 * index)) & 0xff);
|
|
458
|
+
}
|
|
459
|
+
return {
|
|
460
|
+
destinationId: event.destinationId,
|
|
461
|
+
renderFrame: event.renderFrame,
|
|
462
|
+
bytes,
|
|
463
|
+
};
|
|
464
|
+
});
|
|
465
|
+
if (events.length > 0) {
|
|
466
|
+
this.emitMidiOut(events);
|
|
467
|
+
}
|
|
468
|
+
return events;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Drains bounded paged-clip misses from the SAB ring and forwards one batch
|
|
473
|
+
* to subscribers. In postMessage mode the legacy handler remains available,
|
|
474
|
+
* but that degraded path is not realtime-safe for OPFS streaming.
|
|
475
|
+
*/
|
|
476
|
+
pollClipPageRequests(): SonareEngineClipPageRequestMessage | undefined {
|
|
477
|
+
if (!this.clipPageRequestRing) {
|
|
478
|
+
return undefined;
|
|
479
|
+
}
|
|
480
|
+
const read = readSonareClipPageRequestRingBuffer(this.clipPageRequestRing);
|
|
481
|
+
const dropped = (read.dropped - this.clipPageRequestDroppedRead) >>> 0;
|
|
482
|
+
this.clipPageRequestDroppedRead = read.dropped;
|
|
483
|
+
if (read.requests.length === 0 && dropped === 0) {
|
|
484
|
+
return undefined;
|
|
485
|
+
}
|
|
486
|
+
const message: SonareEngineClipPageRequestMessage = {
|
|
487
|
+
type: 'clipPageRequest',
|
|
488
|
+
requests: read.requests,
|
|
489
|
+
...(dropped > 0 ? { dropped } : {}),
|
|
490
|
+
};
|
|
491
|
+
this.emitClipPageRequests(message);
|
|
492
|
+
return message;
|
|
493
|
+
}
|
|
494
|
+
|
|
348
495
|
onTelemetry(callback: (telemetry: SonareEngineTelemetryRecord) => void): () => void {
|
|
349
496
|
this.telemetryListeners.add(callback);
|
|
497
|
+
this.startRingPolling();
|
|
350
498
|
return () => {
|
|
351
499
|
this.telemetryListeners.delete(callback);
|
|
500
|
+
this.stopRingPollingIfUnused();
|
|
352
501
|
};
|
|
353
502
|
}
|
|
354
503
|
|
|
355
504
|
onMeter(callback: (meter: SonareWorkletMeterSnapshot) => void): () => void {
|
|
356
505
|
this.meterListeners.add(callback);
|
|
506
|
+
this.startRingPolling();
|
|
357
507
|
return () => {
|
|
358
508
|
this.meterListeners.delete(callback);
|
|
509
|
+
this.stopRingPollingIfUnused();
|
|
359
510
|
};
|
|
360
511
|
}
|
|
361
512
|
|
|
362
513
|
onScope(callback: (scope: SonareWorkletScopeSnapshot) => void): () => void {
|
|
363
514
|
this.scopeListeners.add(callback);
|
|
515
|
+
this.startRingPolling();
|
|
364
516
|
return () => {
|
|
365
517
|
this.scopeListeners.delete(callback);
|
|
518
|
+
this.stopRingPollingIfUnused();
|
|
366
519
|
};
|
|
367
520
|
}
|
|
368
521
|
|
|
@@ -373,17 +526,33 @@ export class SonareRealtimeEngineNode {
|
|
|
373
526
|
*/
|
|
374
527
|
onMidiOut(callback: (events: SonareWorkletExternalMidiEvent[]) => void): () => void {
|
|
375
528
|
this.midiOutListeners.add(callback);
|
|
529
|
+
this.startRingPolling();
|
|
376
530
|
return () => {
|
|
377
531
|
this.midiOutListeners.delete(callback);
|
|
378
532
|
};
|
|
379
533
|
}
|
|
380
534
|
|
|
535
|
+
/**
|
|
536
|
+
* Subscribe to bounded batches of paged-clip misses from the worklet. The
|
|
537
|
+
* callback runs on the main thread and may initiate asynchronous OPFS I/O.
|
|
538
|
+
*/
|
|
539
|
+
onClipPageRequests(callback: (message: SonareEngineClipPageRequestMessage) => void): () => void {
|
|
540
|
+
this.clipPageRequestListeners.add(callback);
|
|
541
|
+
return () => {
|
|
542
|
+
this.clipPageRequestListeners.delete(callback);
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
381
546
|
destroy(): void {
|
|
382
547
|
if (this.destroyed) {
|
|
383
548
|
return;
|
|
384
549
|
}
|
|
385
550
|
this.destroyed = true;
|
|
386
|
-
this.
|
|
551
|
+
if (this.ringPollTimer !== undefined) {
|
|
552
|
+
clearInterval(this.ringPollTimer);
|
|
553
|
+
this.ringPollTimer = undefined;
|
|
554
|
+
}
|
|
555
|
+
this.node.port.postMessage({ type: 'destroy' });
|
|
387
556
|
this.node.disconnect();
|
|
388
557
|
for (const pending of this.captureRequests.values()) {
|
|
389
558
|
pending.reject(new Error('Realtime engine node is destroyed.'));
|
|
@@ -397,6 +566,7 @@ export class SonareRealtimeEngineNode {
|
|
|
397
566
|
this.meterListeners.clear();
|
|
398
567
|
this.scopeListeners.clear();
|
|
399
568
|
this.midiOutListeners.clear();
|
|
569
|
+
this.clipPageRequestListeners.clear();
|
|
400
570
|
}
|
|
401
571
|
|
|
402
572
|
private emitTelemetry(telemetry: SonareEngineTelemetryRecord): void {
|
|
@@ -405,6 +575,38 @@ export class SonareRealtimeEngineNode {
|
|
|
405
575
|
}
|
|
406
576
|
}
|
|
407
577
|
|
|
578
|
+
private startRingPolling(): void {
|
|
579
|
+
if (
|
|
580
|
+
this.ringPollTimer !== undefined ||
|
|
581
|
+
(!this.telemetryRing && !this.meterRing && !this.scopeRing && !this.externalMidiRing)
|
|
582
|
+
) {
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
const poll = () => {
|
|
586
|
+
if (!this.destroyed) {
|
|
587
|
+
this.pollTelemetry();
|
|
588
|
+
this.pollMeters();
|
|
589
|
+
this.pollScope();
|
|
590
|
+
this.pollMidiOut();
|
|
591
|
+
}
|
|
592
|
+
};
|
|
593
|
+
poll();
|
|
594
|
+
this.ringPollTimer = setInterval(poll, 16);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
private stopRingPollingIfUnused(): void {
|
|
598
|
+
if (
|
|
599
|
+
this.ringPollTimer !== undefined &&
|
|
600
|
+
this.telemetryListeners.size === 0 &&
|
|
601
|
+
this.meterListeners.size === 0 &&
|
|
602
|
+
this.scopeListeners.size === 0 &&
|
|
603
|
+
this.midiOutListeners.size === 0
|
|
604
|
+
) {
|
|
605
|
+
clearInterval(this.ringPollTimer);
|
|
606
|
+
this.ringPollTimer = undefined;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
408
610
|
private emitMeter(meter: SonareWorkletMeterSnapshot): void {
|
|
409
611
|
for (const listener of this.meterListeners) {
|
|
410
612
|
listener(meter);
|
|
@@ -417,6 +619,12 @@ export class SonareRealtimeEngineNode {
|
|
|
417
619
|
}
|
|
418
620
|
}
|
|
419
621
|
|
|
622
|
+
private emitClipPageRequests(message: SonareEngineClipPageRequestMessage): void {
|
|
623
|
+
for (const listener of this.clipPageRequestListeners) {
|
|
624
|
+
listener(message);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
420
628
|
private emitScope(scope: SonareWorkletScopeSnapshot): void {
|
|
421
629
|
for (const listener of this.scopeListeners) {
|
|
422
630
|
listener(scope);
|
|
@@ -47,6 +47,7 @@ export interface EngineTransportContext {
|
|
|
47
47
|
seekSample(timelineSample: number, sampleTime?: number): boolean;
|
|
48
48
|
};
|
|
49
49
|
offlineEngine: RealtimeEngine;
|
|
50
|
+
flushOfflineMirror(): void;
|
|
50
51
|
setTransportPlaying(playing: boolean): void;
|
|
51
52
|
flushPendingInstrumentSync(): void;
|
|
52
53
|
setTempo(bpm: number): void;
|
|
@@ -74,12 +75,16 @@ export function buildTransportFacade(ctx: EngineTransportContext): SonareEngineT
|
|
|
74
75
|
},
|
|
75
76
|
seekPpq: (ppq, sampleTime = -1) => {
|
|
76
77
|
ctx.offlineEngine.seekPpq(ppq, sampleTime);
|
|
77
|
-
|
|
78
|
+
const ok = ctx.realtimeNode.seekPpq(ppq, sampleTime);
|
|
79
|
+
ctx.flushOfflineMirror();
|
|
80
|
+
return ok;
|
|
78
81
|
},
|
|
79
82
|
seekSeconds: (seconds, sampleTime = -1) => {
|
|
80
83
|
const timelineSample = Math.max(0, Math.round(seconds * ctx.sampleRate));
|
|
81
84
|
ctx.offlineEngine.seekSample(timelineSample, sampleTime);
|
|
82
|
-
|
|
85
|
+
const ok = ctx.realtimeNode.seekSample(timelineSample, sampleTime);
|
|
86
|
+
ctx.flushOfflineMirror();
|
|
87
|
+
return ok;
|
|
83
88
|
},
|
|
84
89
|
setTempo: (bpm) => ctx.setTempo(bpm),
|
|
85
90
|
setTempoSegments: (segments) => ctx.setTempoSegments(segments),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { EngineParameterInfo, RealtimeEngine } from '../index';
|
|
2
|
-
import type {
|
|
1
|
+
import type { EngineAutomationPoint, EngineParameterInfo, RealtimeEngine } from '../index';
|
|
2
|
+
import type { SonareEngineSyncMessage } from './messages';
|
|
3
3
|
import {
|
|
4
4
|
ENGINE_MIXER_PARAM_FADER_DB,
|
|
5
5
|
ENGINE_MIXER_PARAM_PAN,
|
|
@@ -17,7 +17,15 @@ import {
|
|
|
17
17
|
*/
|
|
18
18
|
export interface EngineParameterContext {
|
|
19
19
|
readonly offlineEngine: RealtimeEngine;
|
|
20
|
-
|
|
20
|
+
sendCommand(command: {
|
|
21
|
+
type: SonareEngineCommandType;
|
|
22
|
+
targetId?: number;
|
|
23
|
+
sampleTime?: number;
|
|
24
|
+
argFloat?: number;
|
|
25
|
+
argInt?: number;
|
|
26
|
+
}): boolean;
|
|
27
|
+
postSync(message: SonareEngineSyncMessage): void;
|
|
28
|
+
readonly automationLanes: Map<number, EngineAutomationPoint[]>;
|
|
21
29
|
readonly trackLaneIds: number[];
|
|
22
30
|
resolveParamId(nodeId: string, param: string | number): number;
|
|
23
31
|
ensureTrackLane(target: string | number): number;
|
|
@@ -35,7 +43,7 @@ export function setParam(
|
|
|
35
43
|
// reflects the live value, then push a sample-accurate command to the
|
|
36
44
|
// realtime runtime (mirrors setTempo/setLoop above).
|
|
37
45
|
ctx.offlineEngine.setParameter(paramId, value);
|
|
38
|
-
return ctx.
|
|
46
|
+
return ctx.sendCommand({
|
|
39
47
|
type: SonareEngineCommandType.SetParam,
|
|
40
48
|
targetId: paramId,
|
|
41
49
|
sampleTime: -1,
|
|
@@ -51,7 +59,7 @@ export function setSoloMute(
|
|
|
51
59
|
): boolean {
|
|
52
60
|
const laneIndex = ctx.ensureTrackLane(target);
|
|
53
61
|
ctx.offlineEngine.setSoloMute(laneIndex, solo, mute);
|
|
54
|
-
return ctx.
|
|
62
|
+
return ctx.sendCommand({
|
|
55
63
|
type: SonareEngineCommandType.SetSoloMute,
|
|
56
64
|
targetId: laneIndex,
|
|
57
65
|
sampleTime: -1,
|
|
@@ -170,3 +178,16 @@ export function listParameters(ctx: EngineParameterContext): EngineParameterInfo
|
|
|
170
178
|
}
|
|
171
179
|
return parameters;
|
|
172
180
|
}
|
|
181
|
+
|
|
182
|
+
/** Registers a parameter on both the offline mirror and live worklet engine. */
|
|
183
|
+
export function addParameter(ctx: EngineParameterContext, info: EngineParameterInfo): void {
|
|
184
|
+
ctx.offlineEngine.addParameter(info);
|
|
185
|
+
ctx.postSync({ type: 'syncParameters', parameters: listParameters(ctx) });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Clears registered parameters and their automation lanes on both engines. */
|
|
189
|
+
export function clearParameters(ctx: EngineParameterContext): void {
|
|
190
|
+
ctx.offlineEngine.clearParameters();
|
|
191
|
+
ctx.automationLanes.clear();
|
|
192
|
+
ctx.postSync({ type: 'syncParameters', parameters: [] });
|
|
193
|
+
}
|