@libraz/libsonare 1.2.0 → 1.2.2

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/src/worklet.ts CHANGED
@@ -7,8 +7,9 @@ import type {
7
7
  EngineParameterInfo,
8
8
  EngineTelemetry,
9
9
  MixerRealtimeBuffer,
10
+ RealtimeVoiceChangerConfigInput,
10
11
  } from './index';
11
- import { engineCapabilities, Mixer, RealtimeEngine } from './index';
12
+ import { engineCapabilities, Mixer, RealtimeEngine, RealtimeVoiceChanger } from './index';
12
13
  import type { AutomationCurve } from './public_types';
13
14
  import type { SonareRtModule } from './sonare-rt';
14
15
 
@@ -47,6 +48,31 @@ export interface SonareRealtimeEngineWorkletProcessorOptions {
47
48
  telemetryRingCapacity?: number;
48
49
  }
49
50
 
51
+ export interface SonareRealtimeVoiceChangerWorkletProcessorOptions {
52
+ preset?: RealtimeVoiceChangerConfigInput;
53
+ sampleRate?: number;
54
+ blockSize?: number;
55
+ channelCount?: number;
56
+ }
57
+
58
+ export interface SonareRealtimeVoiceChangerSetConfigMessage {
59
+ type: 'setConfig';
60
+ preset: RealtimeVoiceChangerConfigInput;
61
+ }
62
+
63
+ export interface SonareRealtimeVoiceChangerResetMessage {
64
+ type: 'reset';
65
+ }
66
+
67
+ export interface SonareRealtimeVoiceChangerDestroyMessage {
68
+ type: 'destroy';
69
+ }
70
+
71
+ export type SonareRealtimeVoiceChangerMessage =
72
+ | SonareRealtimeVoiceChangerSetConfigMessage
73
+ | SonareRealtimeVoiceChangerResetMessage
74
+ | SonareRealtimeVoiceChangerDestroyMessage;
75
+
50
76
  export interface SonareRealtimeEngineNodeCapabilities {
51
77
  mode: 'sab' | 'postMessage';
52
78
  runtimeTarget: 'embind' | 'sonare-rt';
@@ -317,6 +343,13 @@ function isEngineCommandRecord(value: unknown): value is SonareEngineCommandReco
317
343
  return isRecord(value) && typeof value.type === 'number';
318
344
  }
319
345
 
346
+ function isRealtimeVoiceChangerMessage(value: unknown): value is SonareRealtimeVoiceChangerMessage {
347
+ if (!isRecord(value) || typeof value.type !== 'string') {
348
+ return false;
349
+ }
350
+ return value.type === 'setConfig' || value.type === 'reset' || value.type === 'destroy';
351
+ }
352
+
320
353
  function isEngineTelemetryRecord(value: unknown): value is SonareEngineTelemetryRecord {
321
354
  return (
322
355
  isRecord(value) &&
@@ -981,6 +1014,7 @@ export class SonareWorkletProcessor {
981
1014
  * load the dedicated Emscripten AudioWorklet module.
982
1015
  */
983
1016
  export class SonareRealtimeEngineWorkletProcessor {
1017
+ private static warnedChannelScratchOverflow = false;
984
1018
  readonly sampleRate: number;
985
1019
  readonly blockSize: number;
986
1020
  readonly channelCount: number;
@@ -992,6 +1026,14 @@ export class SonareRealtimeEngineWorkletProcessor {
992
1026
  private transport?: WorkletTransport;
993
1027
  private meterIntervalFrames: number;
994
1028
  private lastMeterFrame = Number.NEGATIVE_INFINITY;
1029
+ // Pre-allocated worst-case input scratch buffers. The main thread allocates
1030
+ // these in the constructor; process() reuses them via subarray() so it never
1031
+ // touches the V8 heap allocator (which would risk GC stalls on the audio
1032
+ // thread). One scratch buffer per channel sized to blockSize.
1033
+ private readonly channelScratch: Float32Array[];
1034
+ // Reused array of subarray views passed to engine.process() each block.
1035
+ // Pre-allocating this array avoids growing-array allocations from .push().
1036
+ private readonly channelScratchViews: Float32Array[];
995
1037
 
996
1038
  constructor(
997
1039
  options: SonareRealtimeEngineWorkletProcessorOptions = {},
@@ -1018,6 +1060,13 @@ export class SonareRealtimeEngineWorkletProcessor {
1018
1060
  )
1019
1061
  : undefined;
1020
1062
  this.engine = new RealtimeEngine(this.sampleRate, this.blockSize);
1063
+ // Worst-case allocation: channelCount full-blockSize Float32Arrays.
1064
+ this.channelScratch = new Array(this.channelCount);
1065
+ this.channelScratchViews = new Array(this.channelCount);
1066
+ for (let ch = 0; ch < this.channelCount; ch++) {
1067
+ this.channelScratch[ch] = new Float32Array(this.blockSize);
1068
+ this.channelScratchViews[ch] = this.channelScratch[ch];
1069
+ }
1021
1070
  }
1022
1071
 
1023
1072
  process(inputs: WorkletInput, outputs: WorkletOutput): boolean {
@@ -1040,18 +1089,38 @@ export class SonareRealtimeEngineWorkletProcessor {
1040
1089
 
1041
1090
  this.drainCommands();
1042
1091
 
1043
- const channels: Float32Array[] = [];
1092
+ // Clamp `frames` to the pre-allocated scratch capacity. The earlier
1093
+ // `frames > this.blockSize` branch already returns early, so this is
1094
+ // defensive — but we warn once if it ever fires so the contract violation
1095
+ // is visible.
1096
+ const scratchCapacity = this.channelScratch[0]?.length ?? 0;
1097
+ let usableFrames = frames;
1098
+ if (usableFrames > scratchCapacity) {
1099
+ if (!SonareRealtimeEngineWorkletProcessor.warnedChannelScratchOverflow) {
1100
+ SonareRealtimeEngineWorkletProcessor.warnedChannelScratchOverflow = true;
1101
+ // biome-ignore lint/suspicious/noConsole: realtime-safety diagnostic.
1102
+ console.warn(
1103
+ `SonareRealtimeEngineWorkletProcessor: requested ${usableFrames} frames ` +
1104
+ `exceeds pre-allocated capacity ${scratchCapacity}; clamping.`,
1105
+ );
1106
+ }
1107
+ usableFrames = scratchCapacity;
1108
+ }
1044
1109
  const input = inputs[0];
1110
+ // Reuse the scratch buffers via subarray() — these are views over the
1111
+ // pre-allocated Float32Array storage and do not allocate on the heap.
1045
1112
  for (let ch = 0; ch < this.channelCount; ch++) {
1113
+ const scratch = this.channelScratch[ch];
1046
1114
  const source = input?.[ch];
1047
- const channel = new Float32Array(frames);
1048
- if (source && source.length === frames) {
1049
- channel.set(source);
1115
+ if (source && source.length === usableFrames) {
1116
+ scratch.set(source, 0);
1117
+ } else {
1118
+ scratch.fill(0, 0, usableFrames);
1050
1119
  }
1051
- channels.push(channel);
1120
+ this.channelScratchViews[ch] = scratch.subarray(0, usableFrames);
1052
1121
  }
1053
1122
 
1054
- const processed = this.engine.process(channels);
1123
+ const processed = this.engine.process(this.channelScratchViews);
1055
1124
  for (let ch = 0; ch < output.length; ch++) {
1056
1125
  const target = output[ch];
1057
1126
  const source = processed[ch] ?? processed[0];
@@ -1990,6 +2059,181 @@ export class SonareEngine {
1990
2059
  }
1991
2060
  }
1992
2061
 
2062
+ export class SonareRealtimeVoiceChangerWorkletProcessor {
2063
+ private static warnedMonoOverflow = false;
2064
+ private static warnedInterleavedOverflow = false;
2065
+ private changer: RealtimeVoiceChanger;
2066
+ private readonly sampleRate: number;
2067
+ private readonly blockSize: number;
2068
+ private readonly channelCount: number;
2069
+ // WASM-heap typed-memory views, sized to the worst case (blockSize *
2070
+ // channelCount). Acquired on the main thread (constructor) so the
2071
+ // audio-thread process() never crosses an allocation boundary.
2072
+ private monoInput: Float32Array;
2073
+ private monoOutput: Float32Array;
2074
+ // Planar heap-backed views (one Float32Array per channel) used by the
2075
+ // multi-channel path. AudioWorklet inputs/outputs are already planar
2076
+ // Float32Arrays, so this avoids the per-sample interleave/deinterleave
2077
+ // passes that the older interleaved path needed.
2078
+ private planarChannels: Float32Array[];
2079
+ private destroyed = false;
2080
+
2081
+ constructor(options: SonareRealtimeVoiceChangerWorkletProcessorOptions = {}) {
2082
+ this.sampleRate = options.sampleRate ?? 48000;
2083
+ this.blockSize = options.blockSize ?? 128;
2084
+ this.channelCount = Math.max(1, Math.floor(options.channelCount ?? 1));
2085
+ this.changer = new RealtimeVoiceChanger(options.preset ?? 'neutral-monitor');
2086
+ this.changer.prepare(this.sampleRate, this.blockSize, this.channelCount);
2087
+ // Acquire WASM-heap views once, sized to the worst case. These are alive
2088
+ // for the lifetime of the changer; if the host requests more frames per
2089
+ // process() than blockSize, we clamp (see ensure*Capacity).
2090
+ this.monoInput = this.changer.getMonoInputBuffer(this.blockSize);
2091
+ this.monoOutput = this.changer.getMonoOutputBuffer(this.blockSize);
2092
+ this.planarChannels = [];
2093
+ if (this.channelCount > 1) {
2094
+ for (let ch = 0; ch < this.channelCount; ch++) {
2095
+ this.planarChannels.push(this.changer.getPlanarChannelBuffer(ch, this.blockSize));
2096
+ }
2097
+ }
2098
+ }
2099
+
2100
+ /**
2101
+ * Handles a control-plane message from the main thread. Runs on the
2102
+ * AudioWorklet global scope but OUTSIDE of `process()` (i.e. outside the
2103
+ * realtime audio callback), so it is safe to perform JSON parsing and
2104
+ * DSP coefficient recomputation here. `setConfig` MUST NOT be deferred
2105
+ * into `process()` because that would block the audio thread for longer
2106
+ * than one render quantum (e.g. 128 samples / 44.1 kHz = ~2.9 ms).
2107
+ */
2108
+ receiveMessage(message: SonareRealtimeVoiceChangerMessage): void {
2109
+ if (this.destroyed) {
2110
+ return;
2111
+ }
2112
+ if (message.type === 'setConfig') {
2113
+ // Apply synchronously on the message-handler thread. `setConfig` may
2114
+ // allocate and parse JSON internally; doing it here keeps `process()`
2115
+ // realtime-safe.
2116
+ this.changer.setConfig(message.preset);
2117
+ } else if (message.type === 'reset') {
2118
+ this.changer.reset();
2119
+ } else if (message.type === 'destroy') {
2120
+ this.destroy();
2121
+ }
2122
+ }
2123
+
2124
+ process(inputs: WorkletInput, outputs: WorkletOutput): boolean {
2125
+ const output = outputs[0];
2126
+ if (this.destroyed || !output || output.length === 0) {
2127
+ return !this.destroyed;
2128
+ }
2129
+
2130
+ const input = inputs[0];
2131
+ const requestedFrames = output[0]?.length ?? 0;
2132
+ const requestedChannels = Math.min(this.channelCount, output.length);
2133
+ if (requestedFrames === 0 || requestedChannels === 0) {
2134
+ return true;
2135
+ }
2136
+
2137
+ if (requestedChannels === 1) {
2138
+ // Clamp to the pre-allocated capacity; warn (once) if the host violated
2139
+ // the contract. We never reallocate on the audio thread.
2140
+ const frames = this.ensureMonoCapacity(requestedFrames);
2141
+ const source = input?.[0];
2142
+ if (source) {
2143
+ this.monoInput.set(source.subarray(0, frames));
2144
+ } else {
2145
+ this.monoInput.fill(0, 0, frames);
2146
+ }
2147
+ this.changer.processMonoInto(
2148
+ this.monoInput.subarray(0, frames),
2149
+ this.monoOutput.subarray(0, frames),
2150
+ );
2151
+ output[0].set(this.monoOutput.subarray(0, frames));
2152
+ return true;
2153
+ }
2154
+
2155
+ const frames = this.ensureInterleavedCapacity(requestedFrames, requestedChannels);
2156
+ const channels = requestedChannels;
2157
+ // Planar zero-copy path: AudioWorklet's input[ch] is already a
2158
+ // Float32Array per channel, so we set() straight into the heap-backed
2159
+ // planar view and processPreparedPlanar runs in place.
2160
+ for (let ch = 0; ch < channels; ch++) {
2161
+ const src = input?.[ch];
2162
+ const dst = this.planarChannels[ch];
2163
+ if (!dst) {
2164
+ continue;
2165
+ }
2166
+ if (src) {
2167
+ dst.set(src.subarray(0, frames));
2168
+ } else {
2169
+ dst.fill(0, 0, frames);
2170
+ }
2171
+ }
2172
+ this.changer.processPreparedPlanar(frames);
2173
+ for (let ch = 0; ch < channels; ch++) {
2174
+ const src = this.planarChannels[ch];
2175
+ if (src) {
2176
+ output[ch].set(src.subarray(0, frames));
2177
+ }
2178
+ // No `for frame` inner loop needed; output[ch] is a Float32Array.
2179
+ }
2180
+ return true;
2181
+ }
2182
+
2183
+ destroy(): void {
2184
+ if (this.destroyed) {
2185
+ return;
2186
+ }
2187
+ this.destroyed = true;
2188
+ this.changer.delete();
2189
+ }
2190
+
2191
+ /**
2192
+ * Returns the number of frames we can actually process given the
2193
+ * pre-allocated capacity. If the host requests more frames than the
2194
+ * worst-case block size declared at construction time, we clamp to the
2195
+ * available capacity and warn once — we MUST NOT reallocate on the
2196
+ * realtime audio thread.
2197
+ */
2198
+ private ensureMonoCapacity(frames: number): number {
2199
+ const capacity = this.monoInput.length;
2200
+ if (frames <= capacity) {
2201
+ return frames;
2202
+ }
2203
+ if (!SonareRealtimeVoiceChangerWorkletProcessor.warnedMonoOverflow) {
2204
+ SonareRealtimeVoiceChangerWorkletProcessor.warnedMonoOverflow = true;
2205
+ // biome-ignore lint/suspicious/noConsole: realtime-safety diagnostic.
2206
+ console.warn(
2207
+ `SonareRealtimeVoiceChangerWorkletProcessor: requested ${frames} mono frames ` +
2208
+ `exceeds pre-allocated capacity ${capacity}; clamping. ` +
2209
+ 'Increase blockSize at construction time to avoid this.',
2210
+ );
2211
+ }
2212
+ return capacity;
2213
+ }
2214
+
2215
+ /**
2216
+ * Same contract as ensureMonoCapacity but for the planar per-channel
2217
+ * scratch. Returns the number of frames that fit in the available capacity.
2218
+ */
2219
+ private ensureInterleavedCapacity(frames: number, channels: number): number {
2220
+ const capacity = this.planarChannels[0]?.length ?? 0;
2221
+ if (frames <= capacity) {
2222
+ return frames;
2223
+ }
2224
+ if (!SonareRealtimeVoiceChangerWorkletProcessor.warnedInterleavedOverflow) {
2225
+ SonareRealtimeVoiceChangerWorkletProcessor.warnedInterleavedOverflow = true;
2226
+ // biome-ignore lint/suspicious/noConsole: realtime-safety diagnostic.
2227
+ console.warn(
2228
+ `SonareRealtimeVoiceChangerWorkletProcessor: requested ${frames}x${channels} ` +
2229
+ `planar frames exceeds pre-allocated capacity ${capacity}; clamping. ` +
2230
+ 'Increase blockSize or channelCount at construction time to avoid this.',
2231
+ );
2232
+ }
2233
+ return capacity;
2234
+ }
2235
+ }
2236
+
1993
2237
  export function registerSonareWorkletProcessor(name = 'sonare-worklet-processor'): void {
1994
2238
  const scope = globalThis as unknown as {
1995
2239
  AudioWorkletProcessor?: new () => object;
@@ -2029,6 +2273,47 @@ export function registerSonareWorkletProcessor(name = 'sonare-worklet-processor'
2029
2273
  scope.registerProcessor(name, RegisteredSonareWorkletProcessor);
2030
2274
  }
2031
2275
 
2276
+ export function registerSonareRealtimeVoiceChangerWorkletProcessor(
2277
+ name = 'sonare-realtime-voice-changer-processor',
2278
+ ): void {
2279
+ const scope = globalThis as unknown as {
2280
+ AudioWorkletProcessor?: new () => object;
2281
+ registerProcessor?: (processorName: string, processorCtor: unknown) => void;
2282
+ };
2283
+ if (!scope.AudioWorkletProcessor || !scope.registerProcessor) {
2284
+ throw new Error('AudioWorkletProcessor is not available in this context.');
2285
+ }
2286
+ const Base = scope.AudioWorkletProcessor;
2287
+ class RegisteredSonareRealtimeVoiceChangerWorkletProcessor extends Base {
2288
+ private bridge: SonareRealtimeVoiceChangerWorkletProcessor;
2289
+ readonly port?: WorkletPort;
2290
+
2291
+ constructor(options?: {
2292
+ processorOptions?: SonareRealtimeVoiceChangerWorkletProcessorOptions;
2293
+ }) {
2294
+ super();
2295
+ const port = this.port;
2296
+ this.bridge = new SonareRealtimeVoiceChangerWorkletProcessor(options?.processorOptions ?? {});
2297
+ const onMessage = (event: { data: unknown }) => {
2298
+ if (isRealtimeVoiceChangerMessage(event.data)) {
2299
+ this.bridge.receiveMessage(event.data);
2300
+ }
2301
+ };
2302
+ if (port?.addEventListener) {
2303
+ port.addEventListener('message', onMessage);
2304
+ port.start?.();
2305
+ } else if (port) {
2306
+ port.onmessage = onMessage;
2307
+ }
2308
+ }
2309
+
2310
+ process(inputs: WorkletInput, outputs: WorkletOutput): boolean {
2311
+ return this.bridge.process(inputs, outputs);
2312
+ }
2313
+ }
2314
+ scope.registerProcessor(name, RegisteredSonareRealtimeVoiceChangerWorkletProcessor);
2315
+ }
2316
+
2032
2317
  export function registerSonareRealtimeEngineWorkletProcessor(
2033
2318
  name = 'sonare-realtime-engine-processor',
2034
2319
  ): void {
@@ -2092,6 +2377,7 @@ export function registerSonareRealtimeEngineWorkletProcessor(
2092
2377
  if (!options.rtModuleUrl) {
2093
2378
  throw new Error('rtModuleUrl is required for sonare-rt AudioWorklet runtime.');
2094
2379
  }
2380
+ const rtModuleUrl = options.rtModuleUrl;
2095
2381
  const memory = new WebAssembly.Memory({ initial: 1024, maximum: 1024, shared: true });
2096
2382
  const globalFactory = (
2097
2383
  globalThis as typeof globalThis & {
@@ -2104,7 +2390,7 @@ export function registerSonareRealtimeEngineWorkletProcessor(
2104
2390
  ).SonareRtModuleFactory;
2105
2391
  const moduleFactory = globalFactory
2106
2392
  ? { default: globalFactory }
2107
- : ((await import(options.rtModuleUrl)) as {
2393
+ : ((await import(rtModuleUrl)) as {
2108
2394
  default: (options?: {
2109
2395
  wasmMemory?: WebAssembly.Memory;
2110
2396
  wasmBinary?: ArrayBuffer | Uint8Array;
@@ -2114,7 +2400,7 @@ export function registerSonareRealtimeEngineWorkletProcessor(
2114
2400
  const module = await moduleFactory.default({
2115
2401
  wasmMemory: memory,
2116
2402
  wasmBinary: options.rtWasmBinary,
2117
- locateFile: (path) => options.rtModuleUrl!.replace(/[^/]*$/, path),
2403
+ locateFile: (path) => rtModuleUrl.replace(/[^/]*$/, path),
2118
2404
  });
2119
2405
  this.rtBridge = new SonareRtRealtimeEngineRuntime({
2120
2406
  module,