assemblyai 4.33.3 → 4.34.4

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.
Files changed (39) hide show
  1. package/README.md +22 -0
  2. package/dist/assemblyai.streaming.umd.js +1291 -3
  3. package/dist/assemblyai.streaming.umd.min.js +1 -1
  4. package/dist/assemblyai.umd.js +802 -7
  5. package/dist/assemblyai.umd.min.js +1 -1
  6. package/dist/browser.mjs +775 -5
  7. package/dist/bun.mjs +775 -5
  8. package/dist/deno.mjs +775 -5
  9. package/dist/exports/streaming.d.ts +7 -0
  10. package/dist/index.cjs +802 -7
  11. package/dist/index.mjs +794 -8
  12. package/dist/node.cjs +783 -4
  13. package/dist/node.mjs +775 -5
  14. package/dist/services/index.d.ts +2 -2
  15. package/dist/services/streaming/browser/dual-channel-capture.d.ts +66 -0
  16. package/dist/services/streaming/browser/worklets/pcm16-encoder.d.ts +19 -0
  17. package/dist/services/streaming/energy-vad.d.ts +35 -0
  18. package/dist/services/streaming/index.d.ts +4 -0
  19. package/dist/services/streaming/label-mapper.d.ts +44 -0
  20. package/dist/services/streaming/resampler.d.ts +22 -0
  21. package/dist/services/streaming/service.d.ts +71 -2
  22. package/dist/streaming.browser.mjs +1247 -4
  23. package/dist/streaming.cjs +1287 -3
  24. package/dist/streaming.mjs +1276 -4
  25. package/dist/types/streaming/dual-channel.d.ts +48 -0
  26. package/dist/types/streaming/index.d.ts +140 -4
  27. package/dist/workerd.mjs +775 -5
  28. package/package.json +1 -1
  29. package/src/exports/streaming.ts +7 -0
  30. package/src/services/index.ts +20 -1
  31. package/src/services/streaming/browser/dual-channel-capture.ts +177 -0
  32. package/src/services/streaming/browser/worklets/pcm16-encoder.ts +70 -0
  33. package/src/services/streaming/energy-vad.ts +75 -0
  34. package/src/services/streaming/index.ts +4 -0
  35. package/src/services/streaming/label-mapper.ts +128 -0
  36. package/src/services/streaming/resampler.ts +69 -0
  37. package/src/services/streaming/service.ts +405 -3
  38. package/src/types/streaming/dual-channel.ts +57 -0
  39. package/src/types/streaming/index.ts +144 -1
@@ -4,6 +4,19 @@
4
4
  (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.assemblyai = {}));
5
5
  })(this, (function (exports) { 'use strict';
6
6
 
7
+ /**
8
+ * Thrown when `DualChannelCapture` is constructed in a non-browser environment
9
+ * (no `globalThis.AudioContext`). The helper is intentionally surfaced from the
10
+ * main entrypoint so the import path is uniform across runtimes; the runtime
11
+ * guard moves to construction time.
12
+ */
13
+ class BrowserOnlyError extends Error {
14
+ constructor(message = "DualChannelCapture requires a browser environment (AudioContext is undefined).") {
15
+ super(message);
16
+ this.name = "BrowserOnlyError";
17
+ }
18
+ }
19
+
7
20
  /******************************************************************************
8
21
  Copyright (c) Microsoft Corporation.
9
22
 
@@ -65,7 +78,7 @@
65
78
  defaultUserAgentString += navigator.userAgent;
66
79
  }
67
80
  const defaultUserAgent = {
68
- sdk: { name: "JavaScript", version: "4.33.3" },
81
+ sdk: { name: "JavaScript", version: "4.34.4" },
69
82
  };
70
83
  if (typeof process !== "undefined") {
71
84
  if (process.versions.node && defaultUserAgentString.indexOf("Node") === -1) {
@@ -888,11 +901,220 @@ Learn more at https://github.com/AssemblyAI/assemblyai-node-sdk/blob/main/docs/c
888
901
  return new Blob([u8arr], { type: mime });
889
902
  }
890
903
 
904
+ /**
905
+ * Energy-based VAD with adaptive noise-floor tracking and hangover. Pure JS,
906
+ * no dependencies. Suitable for the "which physical channel is speaking" task
907
+ * because the channels are already physically separated at capture — the harder
908
+ * problem (speech vs. non-speech in the wild) is one a customer can swap in a
909
+ * DNN VAD for via the `createVad` parameter.
910
+ *
911
+ * Tuning notes:
912
+ * - thresholdRatio below 2 will treat anything above noise as speech (too sensitive).
913
+ * - thresholdRatio above 6 will miss quiet utterance onsets/offsets.
914
+ * - noiseFloorAlpha above 0.1 makes the floor track quickly (good for non-stationary
915
+ * background) but risks slowly adapting *up* to a sustained low voice.
916
+ */
917
+ class EnergyVad {
918
+ constructor(params = {}) {
919
+ var _a, _b, _c, _d;
920
+ this.hangoverRemaining = 0;
921
+ this.thresholdRatio = (_a = params.thresholdRatio) !== null && _a !== void 0 ? _a : 3.0;
922
+ this.noiseFloorAlpha = (_b = params.noiseFloorAlpha) !== null && _b !== void 0 ? _b : 0.05;
923
+ this.hangoverFrames = (_c = params.hangoverFrames) !== null && _c !== void 0 ? _c : 10;
924
+ this.initialNoiseFloor = (_d = params.initialNoiseFloor) !== null && _d !== void 0 ? _d : 1e-4;
925
+ this.noiseFloor = this.initialNoiseFloor;
926
+ }
927
+ process(frame) {
928
+ let sumSq = 0;
929
+ for (let i = 0; i < frame.length; i++) {
930
+ sumSq += frame[i] * frame[i];
931
+ }
932
+ const rms = frame.length > 0 ? Math.sqrt(sumSq / frame.length) : 0;
933
+ const threshold = this.noiseFloor * this.thresholdRatio;
934
+ let active = rms > threshold;
935
+ if (active) {
936
+ this.hangoverRemaining = this.hangoverFrames;
937
+ }
938
+ else if (this.hangoverRemaining > 0) {
939
+ this.hangoverRemaining--;
940
+ active = true;
941
+ // While in hangover, do not update noise floor — RMS may still reflect tail energy.
942
+ }
943
+ else {
944
+ this.noiseFloor =
945
+ this.noiseFloor * (1 - this.noiseFloorAlpha) +
946
+ rms * this.noiseFloorAlpha;
947
+ }
948
+ return { active, energy: rms };
949
+ }
950
+ reset() {
951
+ this.noiseFloor = this.initialNoiseFloor;
952
+ this.hangoverRemaining = 0;
953
+ }
954
+ }
955
+
956
+ /**
957
+ * Append-only ring buffer of VAD frames in stream-relative ms order.
958
+ * `pushFrame` is O(1) amortized; `framesInWindow` is O(n) over kept frames,
959
+ * which is fine for the per-word lookups we do (a 30 s window at 50 frames/s
960
+ * per channel × 2 channels = 3000 entries, scanned once per word).
961
+ *
962
+ * Runtime-agnostic — no DOM or Web Audio dependencies.
963
+ */
964
+ class VadTimeline {
965
+ constructor(windowMs) {
966
+ this.windowMs = windowMs;
967
+ this.frames = [];
968
+ this.head = 0;
969
+ }
970
+ pushFrame(frame) {
971
+ this.frames.push(frame);
972
+ const cutoff = frame.ts - this.windowMs;
973
+ while (this.head < this.frames.length &&
974
+ this.frames[this.head].ts < cutoff) {
975
+ this.head++;
976
+ }
977
+ if (this.head > 1024 && this.head * 2 > this.frames.length) {
978
+ this.frames = this.frames.slice(this.head);
979
+ this.head = 0;
980
+ }
981
+ }
982
+ framesInWindow(startMs, endMs) {
983
+ const out = [];
984
+ for (let i = this.head; i < this.frames.length; i++) {
985
+ const f = this.frames[i];
986
+ if (f.ts < startMs)
987
+ continue;
988
+ if (f.ts > endMs)
989
+ break;
990
+ out.push(f);
991
+ }
992
+ return out;
993
+ }
994
+ clear() {
995
+ this.frames = [];
996
+ this.head = 0;
997
+ }
998
+ }
999
+ /**
1000
+ * Sum per-channel active RMS over a window. Returns a Map from channel name
1001
+ * to total score. Channels with zero score are omitted.
1002
+ */
1003
+ function scoreChannels(frames) {
1004
+ var _a;
1005
+ const scores = new Map();
1006
+ for (const f of frames) {
1007
+ if (!f.active)
1008
+ continue;
1009
+ scores.set(f.channel, ((_a = scores.get(f.channel)) !== null && _a !== void 0 ? _a : 0) + f.rms);
1010
+ }
1011
+ return scores;
1012
+ }
1013
+ /**
1014
+ * Decide which channel was dominant during a word's `[start, end]` window.
1015
+ *
1016
+ * - If no channel has any active VAD energy → `"unknown"`.
1017
+ * - If the top channel beats the runner-up by at least `dominanceRatio` → top channel.
1018
+ * - Else: top channel wins on absolute score; exact ties → `"unknown"`.
1019
+ */
1020
+ function attributeWord(word, timeline, params) {
1021
+ const scores = scoreChannels(timeline.framesInWindow(word.start, word.end));
1022
+ if (scores.size === 0)
1023
+ return "unknown";
1024
+ const sorted = [...scores.entries()].sort((a, b) => b[1] - a[1]);
1025
+ if (sorted.length === 1)
1026
+ return sorted[0][0];
1027
+ const [topName, topScore] = sorted[0];
1028
+ const [runnerName, runnerScore] = sorted[1];
1029
+ if (topScore >= params.dominanceRatio * runnerScore)
1030
+ return topName;
1031
+ if (topScore > runnerScore)
1032
+ return topName;
1033
+ if (runnerScore > topScore)
1034
+ return runnerName;
1035
+ return "unknown";
1036
+ }
1037
+ /**
1038
+ * Duration-weighted majority of word channels. `"unknown"` if there are no
1039
+ * words, every word resolved to `"unknown"`, or two channels tie exactly.
1040
+ */
1041
+ function rollUpTurnChannel(words) {
1042
+ var _a;
1043
+ const totals = new Map();
1044
+ for (const w of words) {
1045
+ if (!w.channel || w.channel === "unknown")
1046
+ continue;
1047
+ const dur = Math.max(0, w.end - w.start);
1048
+ totals.set(w.channel, ((_a = totals.get(w.channel)) !== null && _a !== void 0 ? _a : 0) + dur);
1049
+ }
1050
+ if (totals.size === 0)
1051
+ return "unknown";
1052
+ const sorted = [...totals.entries()].sort((a, b) => b[1] - a[1]);
1053
+ if (sorted.length === 1)
1054
+ return sorted[0][0];
1055
+ const [topName, topMs] = sorted[0];
1056
+ const [, runnerMs] = sorted[1];
1057
+ if (topMs === runnerMs)
1058
+ return "unknown";
1059
+ return topName;
1060
+ }
1061
+ /**
1062
+ * Mutate `turn` in place: write `turn.words[i].channel` for every word and set
1063
+ * `turn.channel` to the duration-weighted rollup.
1064
+ *
1065
+ * Returns `void` because the transcriber owns the `TurnEvent` ref and forwards
1066
+ * the same object to the customer listener — no need to allocate a copy.
1067
+ */
1068
+ function attributeTurn(turn, timeline, params) {
1069
+ for (const w of turn.words) {
1070
+ w.channel = attributeWord(w, timeline, params);
1071
+ }
1072
+ turn.channel = rollUpTurnChannel(turn.words);
1073
+ }
1074
+
1075
+ /**
1076
+ * View any `AudioData` (ArrayBuffer / ArrayBufferView / typed array) as a
1077
+ * little-endian Int16 sample sequence without copying. Callers must guarantee
1078
+ * the underlying byte length is even.
1079
+ */
1080
+ function toInt16View(audio) {
1081
+ // AudioData is ArrayBufferLike per the public type, but in practice callers
1082
+ // pass ArrayBuffer or a typed-array view. Handle both without copying.
1083
+ if (audio instanceof Int16Array)
1084
+ return audio;
1085
+ if (ArrayBuffer.isView(audio)) {
1086
+ const view = audio;
1087
+ return new Int16Array(view.buffer, view.byteOffset, Math.floor(view.byteLength / 2));
1088
+ }
1089
+ return new Int16Array(audio);
1090
+ }
891
1091
  const defaultStreamingUrl$1 = "wss://streaming.assemblyai.com/v3/ws";
892
1092
  const terminateSessionMessage = `{"type":"Terminate"}`;
1093
+ /**
1094
+ * Per-send chunk cap in milliseconds for the dual-channel mixer. The streaming
1095
+ * server rejects audio messages longer than 1000 ms (`Input Duration Error`).
1096
+ * If a backlog accumulates (e.g. when a browser tab is backgrounded and
1097
+ * `setInterval` is throttled to ~1 Hz), `flushMix` loops and emits multiple
1098
+ * sends each ≤ this cap until the buffers drain.
1099
+ */
1100
+ const MAX_CHUNK_MS = 200;
1101
+ /**
1102
+ * Per-send minimum chunk size in milliseconds. The streaming server also
1103
+ * rejects audio messages shorter than 50 ms with the same
1104
+ * `Input Duration Error`, so the mixer waits until both per-channel buffers
1105
+ * have at least this much accumulated before emitting. Final-flush (close
1106
+ * path) bypasses this floor so the trailing partial buffer still gets sent.
1107
+ */
1108
+ const MIN_CHUNK_MS = 50;
893
1109
  class StreamingTranscriber {
894
1110
  constructor(params) {
1111
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j;
895
1112
  this.listeners = {};
1113
+ // Dual-channel mode state (allocated only when params.channels is set).
1114
+ this.isDualChannel = false;
1115
+ this.vadFrameSamples = 0;
1116
+ this.minChunkSamples = 0;
1117
+ this.maxChunkSamples = 0;
896
1118
  this.params = Object.assign(Object.assign({}, params), { websocketBaseUrl: params.websocketBaseUrl || defaultStreamingUrl$1 });
897
1119
  if ("token" in params && params.token)
898
1120
  this.token = params.token;
@@ -901,6 +1123,42 @@ Learn more at https://github.com/AssemblyAI/assemblyai-node-sdk/blob/main/docs/c
901
1123
  if (!(this.token || this.apiKey)) {
902
1124
  throw new Error("API key or temporary token is required.");
903
1125
  }
1126
+ if (params.channels) {
1127
+ if (params.channels.length !== 2) {
1128
+ throw new Error("StreamingTranscriber.channels must have exactly 2 entries.");
1129
+ }
1130
+ const names = params.channels.map((c) => c.name);
1131
+ if (new Set(names).size !== names.length) {
1132
+ throw new Error("StreamingTranscriber.channels names must be unique.");
1133
+ }
1134
+ this.isDualChannel = true;
1135
+ this.channelNames = names;
1136
+ const att = (_a = params.channelAttribution) !== null && _a !== void 0 ? _a : {};
1137
+ this.attributionParams = {
1138
+ dominanceRatio: (_b = att.dominanceRatio) !== null && _b !== void 0 ? _b : 4,
1139
+ timelineWindowMs: (_c = att.timelineWindowMs) !== null && _c !== void 0 ? _c : 30000,
1140
+ createVad: (_d = att.createVad) !== null && _d !== void 0 ? _d : (() => new EnergyVad()),
1141
+ flushIntervalMs: (_e = att.flushIntervalMs) !== null && _e !== void 0 ? _e : 50,
1142
+ resolveUnknownChannelsMethod: (_f = att.resolveUnknownChannelsMethod) !== null && _f !== void 0 ? _f : "window",
1143
+ resolutionWindowWords: (_g = att.resolutionWindowWords) !== null && _g !== void 0 ? _g : 2,
1144
+ speakerHistoryMinRmsEvidence: (_h = att.speakerHistoryMinRmsEvidence) !== null && _h !== void 0 ? _h : 0.5,
1145
+ speakerHistoryDominanceRatio: (_j = att.speakerHistoryDominanceRatio) !== null && _j !== void 0 ? _j : 3,
1146
+ };
1147
+ if (this.attributionParams.resolveUnknownChannelsMethod ===
1148
+ "speaker-history") {
1149
+ this.speakerHistory = new Map();
1150
+ }
1151
+ // 20 ms VAD frames at the transcriber's target sample rate.
1152
+ this.vadFrameSamples = Math.max(1, Math.round(params.sampleRate * 0.02));
1153
+ this.minChunkSamples = Math.max(1, Math.round(params.sampleRate * (MIN_CHUNK_MS / 1000)));
1154
+ this.maxChunkSamples = Math.max(this.minChunkSamples, Math.round(params.sampleRate * (MAX_CHUNK_MS / 1000)));
1155
+ this.channelBuffers = new Map(names.map((n) => [n, []]));
1156
+ this.channelSamplesReceived = new Map(names.map((n) => [n, 0]));
1157
+ this.channelVadFloatBuffers = new Map(names.map((n) => [n, new Float32Array(this.vadFrameSamples)]));
1158
+ this.channelVadBufferIdx = new Map(names.map((n) => [n, 0]));
1159
+ this.channelVads = new Map(names.map((n) => [n, this.attributionParams.createVad(n)]));
1160
+ this.timeline = new VadTimeline(this.attributionParams.timelineWindowMs);
1161
+ }
904
1162
  }
905
1163
  connectionUrl() {
906
1164
  var _a, _b;
@@ -950,13 +1208,18 @@ Learn more at https://github.com/AssemblyAI/assemblyai-node-sdk/blob/main/docs/c
950
1208
  if (this.params.prompt) {
951
1209
  searchParams.set("prompt", this.params.prompt);
952
1210
  }
1211
+ if (this.params.agentContext) {
1212
+ searchParams.set("agent_context", this.params.agentContext);
1213
+ }
953
1214
  if (this.params.filterProfanity) {
954
1215
  searchParams.set("filter_profanity", this.params.filterProfanity.toString());
955
1216
  }
956
1217
  if (this.params.speechModel === "u3-pro") {
957
1218
  console.warn("[Deprecation Warning] The speech model `u3-pro` is deprecated and will be removed in a future release. Please use `u3-rt-pro` instead.");
958
1219
  }
959
- searchParams.set("speech_model", this.params.speechModel.toString());
1220
+ if (this.params.speechModel !== undefined) {
1221
+ searchParams.set("speech_model", this.params.speechModel.toString());
1222
+ }
960
1223
  if (this.params.languageDetection !== undefined) {
961
1224
  searchParams.set("language_detection", this.params.languageDetection.toString());
962
1225
  }
@@ -1017,6 +1280,9 @@ Learn more at https://github.com/AssemblyAI/assemblyai-node-sdk/blob/main/docs/c
1017
1280
  if (this.params.redactPiiSub !== undefined) {
1018
1281
  searchParams.set("redact_pii_sub", this.params.redactPiiSub);
1019
1282
  }
1283
+ if (this.params.mode !== undefined) {
1284
+ searchParams.set("mode", this.params.mode);
1285
+ }
1020
1286
  if (this.params.llmGateway !== undefined) {
1021
1287
  searchParams.set("llm_gateway", JSON.stringify(this.params.llmGateway));
1022
1288
  }
@@ -1054,6 +1320,13 @@ Learn more at https://github.com/AssemblyAI/assemblyai-node-sdk/blob/main/docs/c
1054
1320
  reason = StreamingErrorMessages[code];
1055
1321
  }
1056
1322
  }
1323
+ // Stop the flush timer when the socket is gone (server-initiated close,
1324
+ // network drop, etc.) — otherwise subsequent ticks call send() on a
1325
+ // closed socket and spam the error listener.
1326
+ if (this.flushTimer) {
1327
+ clearInterval(this.flushTimer);
1328
+ this.flushTimer = undefined;
1329
+ }
1057
1330
  (_b = (_a = this.listeners).close) === null || _b === void 0 ? void 0 : _b.call(_a, code, reason);
1058
1331
  };
1059
1332
  this.socket.onerror = (event) => {
@@ -1064,7 +1337,7 @@ Learn more at https://github.com/AssemblyAI/assemblyai-node-sdk/blob/main/docs/c
1064
1337
  (_d = (_c = this.listeners).error) === null || _d === void 0 ? void 0 : _d.call(_c, new Error(event.message));
1065
1338
  };
1066
1339
  this.socket.onmessage = ({ data }) => {
1067
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o;
1340
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q;
1068
1341
  const message = JSON.parse(data.toString());
1069
1342
  if ("error" in message) {
1070
1343
  const err = new StreamingError(message.error);
@@ -1082,6 +1355,19 @@ Learn more at https://github.com/AssemblyAI/assemblyai-node-sdk/blob/main/docs/c
1082
1355
  break;
1083
1356
  }
1084
1357
  case "Turn": {
1358
+ if (this.isDualChannel && this.timeline && this.attributionParams) {
1359
+ attributeTurn(message, this.timeline, {
1360
+ dominanceRatio: this.attributionParams.dominanceRatio,
1361
+ });
1362
+ switch (this.attributionParams.resolveUnknownChannelsMethod) {
1363
+ case "window":
1364
+ this.resolveUnknownChannelsByWindow(message);
1365
+ break;
1366
+ case "speaker-history":
1367
+ this.resolveUnknownChannelsBySpeakerHistory(message);
1368
+ break;
1369
+ }
1370
+ }
1085
1371
  (_f = (_e = this.listeners).turn) === null || _f === void 0 ? void 0 : _f.call(_e, message);
1086
1372
  break;
1087
1373
  }
@@ -1093,20 +1379,29 @@ Learn more at https://github.com/AssemblyAI/assemblyai-node-sdk/blob/main/docs/c
1093
1379
  (_k = (_j = this.listeners).llmGatewayResponse) === null || _k === void 0 ? void 0 : _k.call(_j, message);
1094
1380
  break;
1095
1381
  }
1382
+ case "SpeakerRevision": {
1383
+ (_m = (_l = this.listeners).speakerRevision) === null || _m === void 0 ? void 0 : _m.call(_l, message);
1384
+ break;
1385
+ }
1096
1386
  case "Warning": {
1097
1387
  const warning = message;
1098
1388
  console.warn(`Streaming warning (code=${warning.warning_code}): ${warning.warning}`);
1099
- (_m = (_l = this.listeners).warning) === null || _m === void 0 ? void 0 : _m.call(_l, warning);
1389
+ (_p = (_o = this.listeners).warning) === null || _p === void 0 ? void 0 : _p.call(_o, warning);
1100
1390
  break;
1101
1391
  }
1102
1392
  case "Termination": {
1103
- (_o = this.sessionTerminatedResolve) === null || _o === void 0 ? void 0 : _o.call(this);
1393
+ (_q = this.sessionTerminatedResolve) === null || _q === void 0 ? void 0 : _q.call(this);
1104
1394
  break;
1105
1395
  }
1106
1396
  }
1107
1397
  };
1108
1398
  });
1109
1399
  }
1400
+ /**
1401
+ * Returns a WritableStream that pumps PCM chunks into `sendAudio`. Single-channel
1402
+ * only — in dual-channel mode use `sendAudio(pcm, { channel })` directly, since
1403
+ * `WritableStream` has no place to carry a channel tag.
1404
+ */
1110
1405
  stream() {
1111
1406
  return new WritableStream({
1112
1407
  write: (chunk) => {
@@ -1114,8 +1409,239 @@ Learn more at https://github.com/AssemblyAI/assemblyai-node-sdk/blob/main/docs/c
1114
1409
  },
1115
1410
  });
1116
1411
  }
1117
- sendAudio(audio) {
1118
- this.send(audio);
1412
+ /**
1413
+ * Send PCM audio.
1414
+ *
1415
+ * In single-channel mode, `audio` is forwarded directly to the WebSocket and
1416
+ * `options` is ignored.
1417
+ *
1418
+ * In dual-channel mode (when `channels` is configured), `options.channel` is
1419
+ * REQUIRED and must match one of the declared channel names. Per-channel PCM is
1420
+ * fed into that channel's VAD, accumulated into a per-channel ring buffer, and
1421
+ * a scheduled flush (`channelAttribution.flushIntervalMs`, default 50ms) mixes
1422
+ * the buffers into mono before sending to the WebSocket.
1423
+ */
1424
+ sendAudio(audio, options) {
1425
+ if (!this.isDualChannel) {
1426
+ this.send(audio);
1427
+ return;
1428
+ }
1429
+ if (!(options === null || options === void 0 ? void 0 : options.channel)) {
1430
+ throw new Error("StreamingTranscriber is in dual-channel mode; sendAudio requires { channel }.");
1431
+ }
1432
+ if (!this.channelNames.includes(options.channel)) {
1433
+ throw new Error(`Unknown channel "${options.channel}"; declared channels: ${this.channelNames.join(", ")}.`);
1434
+ }
1435
+ this.ingestChannelAudio(options.channel, audio);
1436
+ }
1437
+ ingestChannelAudio(name, audio) {
1438
+ var _a, _b;
1439
+ const samples = toInt16View(audio);
1440
+ const buf = this.channelBuffers.get(name);
1441
+ const vadBuf = this.channelVadFloatBuffers.get(name);
1442
+ let vadIdx = this.channelVadBufferIdx.get(name);
1443
+ let received = this.channelSamplesReceived.get(name);
1444
+ const vad = this.channelVads.get(name);
1445
+ const sampleRate = this.params.sampleRate;
1446
+ const frameSize = this.vadFrameSamples;
1447
+ for (let i = 0; i < samples.length; i++) {
1448
+ const s = samples[i];
1449
+ buf.push(s);
1450
+ vadBuf[vadIdx++] = s / 0x8000;
1451
+ received++;
1452
+ if (vadIdx === frameSize) {
1453
+ const result = vad.process(vadBuf);
1454
+ const frame = {
1455
+ ts: (received / sampleRate) * 1000,
1456
+ channel: name,
1457
+ active: result.active,
1458
+ rms: result.energy,
1459
+ };
1460
+ this.timeline.pushFrame(frame);
1461
+ (_b = (_a = this.listeners).vad) === null || _b === void 0 ? void 0 : _b.call(_a, frame);
1462
+ vadIdx = 0;
1463
+ }
1464
+ }
1465
+ this.channelVadBufferIdx.set(name, vadIdx);
1466
+ this.channelSamplesReceived.set(name, received);
1467
+ if (!this.flushTimer)
1468
+ this.startFlushTimer();
1469
+ }
1470
+ startFlushTimer() {
1471
+ this.flushTimer = setInterval(() => this.flushMix(), this.attributionParams.flushIntervalMs);
1472
+ }
1473
+ flushMix(force = false) {
1474
+ var _a, _b;
1475
+ if (!this.channelNames || !this.channelBuffers)
1476
+ return;
1477
+ const bufs = this.channelNames.map((n) => this.channelBuffers.get(n));
1478
+ const divisor = bufs.length;
1479
+ // Loop so a backlog (e.g. accumulated while a browser tab was throttled in
1480
+ // the background) drains as multiple sends, each capped at MAX_CHUNK_MS.
1481
+ // Without the cap a single message could exceed the server's 1000 ms input
1482
+ // duration limit and be rejected with code 3007.
1483
+ for (;;) {
1484
+ let mixLen = Infinity;
1485
+ for (const b of bufs)
1486
+ if (b.length < mixLen)
1487
+ mixLen = b.length;
1488
+ if (!Number.isFinite(mixLen) || mixLen === 0)
1489
+ return;
1490
+ // The streaming server rejects audio messages shorter than 50 ms with
1491
+ // `Input Duration Error`. Wait until both per-channel buffers have at
1492
+ // least minChunkSamples worth queued before emitting. The `force` path
1493
+ // (final flush on close) bypasses this so the trailing partial buffer
1494
+ // still gets through.
1495
+ if (!force && mixLen < this.minChunkSamples)
1496
+ return;
1497
+ if (mixLen > this.maxChunkSamples)
1498
+ mixLen = this.maxChunkSamples;
1499
+ const out = new Int16Array(mixLen);
1500
+ for (let i = 0; i < mixLen; i++) {
1501
+ let sum = 0;
1502
+ for (let c = 0; c < divisor; c++)
1503
+ sum += bufs[c][i];
1504
+ const avg = Math.round(sum / divisor);
1505
+ out[i] = avg < -32768 ? -32768 : avg > 32767 ? 32767 : avg;
1506
+ }
1507
+ for (const b of bufs)
1508
+ b.splice(0, mixLen);
1509
+ try {
1510
+ this.send(out.buffer);
1511
+ }
1512
+ catch (err) {
1513
+ (_b = (_a = this.listeners).error) === null || _b === void 0 ? void 0 : _b.call(_a, err);
1514
+ return;
1515
+ }
1516
+ }
1517
+ }
1518
+ /**
1519
+ * Fill in words whose per-word VAD attribution was `"unknown"` by looking
1520
+ * at the dominant non-`"unknown"` channel among ±N neighbors in the same
1521
+ * turn. Words with no non-`"unknown"` neighbors stay `"unknown"`. Confident
1522
+ * per-word VAD decisions are never modified.
1523
+ *
1524
+ * Local temporal heuristic — ignores `speaker_label`, so it works even when
1525
+ * AAI's diarization re-uses the same label for two physically distinct
1526
+ * voices. Each resolved word gets `channelResolved: true` so downstream
1527
+ * renderers can distinguish inferred channels from directly-measured ones.
1528
+ */
1529
+ resolveUnknownChannelsByWindow(turn) {
1530
+ var _a;
1531
+ if (!this.attributionParams)
1532
+ return;
1533
+ const window = this.attributionParams.resolutionWindowWords;
1534
+ const words = turn.words;
1535
+ let mutated = false;
1536
+ for (let i = 0; i < words.length; i++) {
1537
+ if (words[i].channel !== "unknown")
1538
+ continue;
1539
+ const tally = new Map();
1540
+ const lo = Math.max(0, i - window);
1541
+ const hi = Math.min(words.length - 1, i + window);
1542
+ for (let j = lo; j <= hi; j++) {
1543
+ if (j === i)
1544
+ continue;
1545
+ const ch = words[j].channel;
1546
+ if (!ch || ch === "unknown")
1547
+ continue;
1548
+ tally.set(ch, ((_a = tally.get(ch)) !== null && _a !== void 0 ? _a : 0) + 1);
1549
+ }
1550
+ if (tally.size === 0)
1551
+ continue;
1552
+ // Pick the dominant neighbor channel. Ties → leave `"unknown"` (rare;
1553
+ // would require an equal count of mic and system neighbors).
1554
+ let top;
1555
+ let topCount = 0;
1556
+ let tied = false;
1557
+ for (const [name, count] of tally) {
1558
+ if (count > topCount) {
1559
+ top = name;
1560
+ topCount = count;
1561
+ tied = false;
1562
+ }
1563
+ else if (count === topCount) {
1564
+ tied = true;
1565
+ }
1566
+ }
1567
+ if (top && !tied) {
1568
+ words[i].channel = top;
1569
+ words[i].channelResolved = true;
1570
+ mutated = true;
1571
+ }
1572
+ }
1573
+ // Recompute the rollup only if any per-word channel changed.
1574
+ if (mutated)
1575
+ turn.channel = rollUpTurnChannel(words);
1576
+ }
1577
+ /**
1578
+ * Fill `"unknown"` words by looking up the speaker's session-wide channel
1579
+ * evidence. For each `speaker_label`, sums active VAD frame RMS per channel
1580
+ * across every word the speaker has uttered to date. A speaker is
1581
+ * "resolvable" if their total evidence clears
1582
+ * `speakerHistoryMinRmsEvidence` and their top channel exceeds the
1583
+ * runner-up by `speakerHistoryDominanceRatio`.
1584
+ *
1585
+ * Only touches `"unknown"` words. Confident per-word VAD decisions are
1586
+ * never modified. `speaker_label` is never modified.
1587
+ */
1588
+ resolveUnknownChannelsBySpeakerHistory(turn) {
1589
+ var _a;
1590
+ if (!this.timeline || !this.attributionParams || !this.speakerHistory)
1591
+ return;
1592
+ const minEvidence = this.attributionParams.speakerHistoryMinRmsEvidence;
1593
+ const dominanceRatio = this.attributionParams.speakerHistoryDominanceRatio;
1594
+ // 1. Accumulate evidence from this turn's words.
1595
+ for (const w of turn.words) {
1596
+ if (!w.speaker)
1597
+ continue;
1598
+ const frames = this.timeline.framesInWindow(w.start, w.end);
1599
+ let entry = this.speakerHistory.get(w.speaker);
1600
+ if (!entry) {
1601
+ entry = new Map();
1602
+ this.speakerHistory.set(w.speaker, entry);
1603
+ }
1604
+ for (const f of frames) {
1605
+ if (!f.active)
1606
+ continue;
1607
+ entry.set(f.channel, ((_a = entry.get(f.channel)) !== null && _a !== void 0 ? _a : 0) + f.rms);
1608
+ }
1609
+ }
1610
+ // 2. Fill unknown words whose speakers have dominant evidence.
1611
+ let mutated = false;
1612
+ for (const w of turn.words) {
1613
+ if (w.channel !== "unknown" || !w.speaker)
1614
+ continue;
1615
+ const entry = this.speakerHistory.get(w.speaker);
1616
+ if (!entry || entry.size === 0)
1617
+ continue;
1618
+ let total = 0;
1619
+ let topName;
1620
+ let topScore = 0;
1621
+ let runnerScore = 0;
1622
+ for (const [name, score] of entry) {
1623
+ total += score;
1624
+ if (score > topScore) {
1625
+ runnerScore = topScore;
1626
+ topScore = score;
1627
+ topName = name;
1628
+ }
1629
+ else if (score > runnerScore) {
1630
+ runnerScore = score;
1631
+ }
1632
+ }
1633
+ if (total < minEvidence)
1634
+ continue;
1635
+ if (runnerScore > 0 && topScore < dominanceRatio * runnerScore)
1636
+ continue;
1637
+ if (topName) {
1638
+ w.channel = topName;
1639
+ w.channelResolved = true;
1640
+ mutated = true;
1641
+ }
1642
+ }
1643
+ if (mutated)
1644
+ turn.channel = rollUpTurnChannel(turn.words);
1119
1645
  }
1120
1646
  /**
1121
1647
  * Update the streaming configuration mid-stream.
@@ -1153,6 +1679,15 @@ Learn more at https://github.com/AssemblyAI/assemblyai-node-sdk/blob/main/docs/c
1153
1679
  close() {
1154
1680
  return __awaiter(this, arguments, void 0, function* (waitForSessionTermination = true) {
1155
1681
  var _a;
1682
+ if (this.flushTimer) {
1683
+ clearInterval(this.flushTimer);
1684
+ this.flushTimer = undefined;
1685
+ // Best-effort: drain any final partial mix so the server gets the tail.
1686
+ // Bypass the 50ms floor here since this is the last flush; if the tail
1687
+ // is <50ms the server will reject that single message, but we'd lose
1688
+ // the audio either way.
1689
+ this.flushMix(true);
1690
+ }
1156
1691
  if (this.socket) {
1157
1692
  if (this.socket.readyState === this.socket.OPEN) {
1158
1693
  if (waitForSessionTermination) {
@@ -1207,6 +1742,257 @@ Learn more at https://github.com/AssemblyAI/assemblyai-node-sdk/blob/main/docs/c
1207
1742
  }
1208
1743
  }
1209
1744
 
1745
+ /**
1746
+ * AudioWorklet processor that ingests mono Float32 audio at the AudioContext's
1747
+ * native sample rate, resamples to `targetRate` (linear interpolation, stateful
1748
+ * across `process()` calls), packs to little-endian Int16 PCM, and posts
1749
+ * fixed-size chunks via `port.postMessage` with a running `samplesSent` counter.
1750
+ *
1751
+ * `samplesSent` is in **target-rate samples**, so the main thread can derive a
1752
+ * stream-relative timestamp = `samplesSent / targetRate * 1000` (ms) — the same
1753
+ * frame AAI uses for `StreamingWord.start` / `.end`.
1754
+ *
1755
+ * Defined as a string so it can be registered via a Blob URL — the SDK ships as
1756
+ * a single ESM file, so a separate `.js` worklet asset isn't viable.
1757
+ */
1758
+ const pcm16EncoderWorkletSource = `
1759
+ class Pcm16EncoderProcessor extends AudioWorkletProcessor {
1760
+ constructor(options) {
1761
+ super();
1762
+ const opts = (options && options.processorOptions) || {};
1763
+ this.targetRate = opts.targetRate || 16000;
1764
+ this.chunkMs = opts.chunkMs || 50;
1765
+ this.ratio = sampleRate / this.targetRate;
1766
+ this.chunkSize = Math.round(this.targetRate * this.chunkMs / 1000);
1767
+ this.buffer = new Int16Array(this.chunkSize);
1768
+ this.bufferIdx = 0;
1769
+ this.samplesSent = 0;
1770
+ this.lastSample = 0;
1771
+ this.fractional = 0;
1772
+ }
1773
+
1774
+ process(inputs) {
1775
+ const input = inputs[0];
1776
+ if (!input || input.length === 0 || !input[0] || input[0].length === 0) {
1777
+ return true;
1778
+ }
1779
+ const mono = input[0];
1780
+ let pos = this.fractional;
1781
+ while (pos < mono.length) {
1782
+ const i = Math.floor(pos);
1783
+ const frac = pos - i;
1784
+ const a = i === 0 ? this.lastSample : mono[i - 1];
1785
+ const b = mono[i];
1786
+ const sample = a + (b - a) * frac;
1787
+ const clamped = sample < -1 ? -1 : sample > 1 ? 1 : sample;
1788
+ this.buffer[this.bufferIdx++] = clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff;
1789
+ if (this.bufferIdx === this.chunkSize) {
1790
+ const out = new Int16Array(this.chunkSize);
1791
+ out.set(this.buffer);
1792
+ this.samplesSent += this.chunkSize;
1793
+ this.port.postMessage(
1794
+ { pcm: out.buffer, samplesSent: this.samplesSent },
1795
+ [out.buffer],
1796
+ );
1797
+ this.bufferIdx = 0;
1798
+ }
1799
+ pos += this.ratio;
1800
+ }
1801
+ this.lastSample = mono[mono.length - 1];
1802
+ this.fractional = pos - mono.length;
1803
+ return true;
1804
+ }
1805
+ }
1806
+ registerProcessor("aai-pcm16-encoder", Pcm16EncoderProcessor);
1807
+ `;
1808
+ const PCM16_ENCODER_PROCESSOR_NAME = "aai-pcm16-encoder";
1809
+
1810
+ const DEFAULT_TARGET_RATE = 16000;
1811
+ const DEFAULT_CHUNK_MS = 50;
1812
+ const MIC_CHANNEL = "mic";
1813
+ const SYSTEM_CHANNEL = "system";
1814
+ /**
1815
+ * Browser-only adapter that pumps two `MediaStream`s into a `StreamingTranscriber`
1816
+ * configured for dual-channel mode. Each `MediaStream` runs through its own
1817
+ * `pcm16-encoder` AudioWorklet (resample to `targetSampleRate`, encode to Int16
1818
+ * PCM); each PCM chunk is forwarded via `transcriber.sendAudio(pcm, { channel })`.
1819
+ *
1820
+ * All dual-channel orchestration (mixing, VAD, per-word attribution) lives inside
1821
+ * `StreamingTranscriber` — this class is a pure I/O adapter. Non-browser runtimes
1822
+ * can replicate its job by pushing tagged PCM into `transcriber.sendAudio` directly.
1823
+ *
1824
+ * Caller responsibilities:
1825
+ * - **Echo cancellation** is set at `getUserMedia` time (`audio: { echoCancellation: true }`).
1826
+ * - **System-audio capture** is platform-dependent. Chrome's `getDisplayMedia({ audio: true })`
1827
+ * captures tab audio (and on Windows, full system audio when sharing the whole screen).
1828
+ * macOS requires a virtual loopback driver (e.g. BlackHole) to expose system audio at all.
1829
+ * - **Token auth.** Construct the transcriber with `token` — API-key auth is unsupported in browsers.
1830
+ * - **Stream ownership.** `stop()` tears down the AudioContext but does NOT stop the
1831
+ * `MediaStreamTrack`s passed in — callers own those.
1832
+ */
1833
+ class DualChannelCapture {
1834
+ constructor(params) {
1835
+ var _a;
1836
+ this.running = false;
1837
+ if (typeof globalThis.AudioContext === "undefined") {
1838
+ throw new BrowserOnlyError();
1839
+ }
1840
+ this.params = {
1841
+ micStream: params.micStream,
1842
+ systemStream: params.systemStream,
1843
+ transcriber: params.transcriber,
1844
+ targetSampleRate: (_a = params.targetSampleRate) !== null && _a !== void 0 ? _a : DEFAULT_TARGET_RATE,
1845
+ };
1846
+ }
1847
+ on(event, listener) {
1848
+ if (event === "error")
1849
+ this.errorListener = listener;
1850
+ }
1851
+ /**
1852
+ * Wire the capture pipeline and start pumping tagged PCM into the transcriber.
1853
+ * The transcriber must already be connected. Returns once the worklet is
1854
+ * registered and the audio graph is live.
1855
+ */
1856
+ start() {
1857
+ return __awaiter(this, void 0, void 0, function* () {
1858
+ if (this.running) {
1859
+ throw new Error("DualChannelCapture already started");
1860
+ }
1861
+ this.context = new AudioContext();
1862
+ const blob = new Blob([pcm16EncoderWorkletSource], {
1863
+ type: "application/javascript",
1864
+ });
1865
+ const url = URL.createObjectURL(blob);
1866
+ try {
1867
+ yield this.context.audioWorklet.addModule(url);
1868
+ }
1869
+ finally {
1870
+ URL.revokeObjectURL(url);
1871
+ }
1872
+ this.micSource = this.context.createMediaStreamSource(this.params.micStream);
1873
+ this.sysSource = this.context.createMediaStreamSource(this.params.systemStream);
1874
+ this.micEncoder = this.makeEncoder(MIC_CHANNEL);
1875
+ this.sysEncoder = this.makeEncoder(SYSTEM_CHANNEL);
1876
+ this.micSource.connect(this.micEncoder);
1877
+ this.sysSource.connect(this.sysEncoder);
1878
+ this.running = true;
1879
+ });
1880
+ }
1881
+ makeEncoder(channel) {
1882
+ const node = new AudioWorkletNode(this.context, PCM16_ENCODER_PROCESSOR_NAME, {
1883
+ numberOfInputs: 1,
1884
+ numberOfOutputs: 0,
1885
+ channelCount: 1,
1886
+ channelCountMode: "explicit",
1887
+ channelInterpretation: "speakers",
1888
+ processorOptions: {
1889
+ targetRate: this.params.targetSampleRate,
1890
+ chunkMs: DEFAULT_CHUNK_MS,
1891
+ },
1892
+ });
1893
+ node.port.onmessage = (e) => {
1894
+ var _a;
1895
+ try {
1896
+ this.params.transcriber.sendAudio(e.data.pcm, { channel });
1897
+ }
1898
+ catch (err) {
1899
+ (_a = this.errorListener) === null || _a === void 0 ? void 0 : _a.call(this, err);
1900
+ }
1901
+ };
1902
+ return node;
1903
+ }
1904
+ /**
1905
+ * Tear down internal nodes and close the AudioContext. Does NOT stop the
1906
+ * caller-provided MediaStream tracks — they remain available for preview UI,
1907
+ * recording, etc. Idempotent.
1908
+ */
1909
+ stop() {
1910
+ return __awaiter(this, void 0, void 0, function* () {
1911
+ var _a, _b, _c, _d, _e, _f;
1912
+ if (!this.running)
1913
+ return;
1914
+ this.running = false;
1915
+ try {
1916
+ (_a = this.micEncoder) === null || _a === void 0 ? void 0 : _a.port.close();
1917
+ (_b = this.sysEncoder) === null || _b === void 0 ? void 0 : _b.port.close();
1918
+ (_c = this.micEncoder) === null || _c === void 0 ? void 0 : _c.disconnect();
1919
+ (_d = this.sysEncoder) === null || _d === void 0 ? void 0 : _d.disconnect();
1920
+ (_e = this.micSource) === null || _e === void 0 ? void 0 : _e.disconnect();
1921
+ (_f = this.sysSource) === null || _f === void 0 ? void 0 : _f.disconnect();
1922
+ }
1923
+ catch (_g) {
1924
+ // Disconnecting already-disconnected nodes throws in some browsers; ignore.
1925
+ }
1926
+ if (this.context && this.context.state !== "closed") {
1927
+ yield this.context.close();
1928
+ }
1929
+ this.context = undefined;
1930
+ this.micSource = undefined;
1931
+ this.sysSource = undefined;
1932
+ this.micEncoder = undefined;
1933
+ this.sysEncoder = undefined;
1934
+ });
1935
+ }
1936
+ }
1937
+
1938
+ /**
1939
+ * Linear-interpolation resampler for streaming Float32 audio. Stateful across
1940
+ * `process()` calls so chunk boundaries don't introduce phase discontinuities:
1941
+ * the last input sample and a fractional read position are carried over.
1942
+ *
1943
+ * Linear interpolation is good enough for ASR ingest — the downstream
1944
+ * StreamingTranscriber band-limits at the target rate anyway, and a polyphase
1945
+ * filter would be overkill in the AudioWorklet hot path. If a customer needs
1946
+ * higher quality they can supply their own VadDetector + bypass the encoder.
1947
+ */
1948
+ class LinearResampler {
1949
+ constructor(sourceRate, targetRate) {
1950
+ this.sourceRate = sourceRate;
1951
+ this.targetRate = targetRate;
1952
+ this.lastSample = 0;
1953
+ this.fractional = 0;
1954
+ if (sourceRate <= 0 || targetRate <= 0) {
1955
+ throw new Error("sourceRate and targetRate must be positive");
1956
+ }
1957
+ this.ratio = sourceRate / targetRate;
1958
+ }
1959
+ process(input) {
1960
+ var _a;
1961
+ if (this.sourceRate === this.targetRate) {
1962
+ return input;
1963
+ }
1964
+ // Worst-case output length; we'll slice to actual.
1965
+ const out = new Float32Array(Math.ceil(input.length / this.ratio) + 1);
1966
+ let outIdx = 0;
1967
+ let pos = this.fractional;
1968
+ while (pos < input.length) {
1969
+ const i = Math.floor(pos);
1970
+ const frac = pos - i;
1971
+ const a = i === 0 ? this.lastSample : input[i - 1];
1972
+ const b = input[i];
1973
+ out[outIdx++] = a + (b - a) * frac;
1974
+ pos += this.ratio;
1975
+ }
1976
+ this.lastSample = (_a = input[input.length - 1]) !== null && _a !== void 0 ? _a : this.lastSample;
1977
+ this.fractional = pos - input.length;
1978
+ return out.subarray(0, outIdx);
1979
+ }
1980
+ reset() {
1981
+ this.lastSample = 0;
1982
+ this.fractional = 0;
1983
+ }
1984
+ }
1985
+ /** Convert Float32 PCM (-1..1) to little-endian Int16 PCM. */
1986
+ function float32ToPcm16(input) {
1987
+ const out = new ArrayBuffer(input.length * 2);
1988
+ const view = new DataView(out);
1989
+ for (let i = 0; i < input.length; i++) {
1990
+ const clamped = Math.max(-1, Math.min(1, input[i]));
1991
+ view.setInt16(i * 2, clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff, true);
1992
+ }
1993
+ return out;
1994
+ }
1995
+
1210
1996
  const defaultBaseUrl = "https://api.assemblyai.com";
1211
1997
  const defaultStreamingUrl = "https://streaming.assemblyai.com";
1212
1998
  class AssemblyAI {
@@ -1228,13 +2014,22 @@ Learn more at https://github.com/AssemblyAI/assemblyai-node-sdk/blob/main/docs/c
1228
2014
  }
1229
2015
 
1230
2016
  exports.AssemblyAI = AssemblyAI;
2017
+ exports.BrowserOnlyError = BrowserOnlyError;
2018
+ exports.DualChannelCapture = DualChannelCapture;
2019
+ exports.EnergyVad = EnergyVad;
1231
2020
  exports.FileService = FileService;
1232
2021
  exports.LemurService = LemurService;
2022
+ exports.LinearResampler = LinearResampler;
1233
2023
  exports.RealtimeService = RealtimeService;
1234
2024
  exports.RealtimeServiceFactory = RealtimeServiceFactory;
1235
2025
  exports.RealtimeTranscriber = RealtimeTranscriber;
1236
2026
  exports.RealtimeTranscriberFactory = RealtimeTranscriberFactory;
1237
2027
  exports.StreamingTranscriber = StreamingTranscriber;
1238
2028
  exports.TranscriptService = TranscriptService;
2029
+ exports.VadTimeline = VadTimeline;
2030
+ exports.attributeTurn = attributeTurn;
2031
+ exports.attributeWord = attributeWord;
2032
+ exports.float32ToPcm16 = float32ToPcm16;
2033
+ exports.rollUpTurnChannel = rollUpTurnChannel;
1239
2034
 
1240
2035
  }));