@absolutejs/voice 0.0.22-beta.625 → 0.0.22-beta.626

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.
@@ -0,0 +1 @@
1
+ export declare const normalizeSpokenNumbers: (input: string) => string;
@@ -861,6 +861,7 @@ export type VoicePluginConfig<TContext = unknown, TSession extends VoiceSessionR
861
861
  defaultSilentTurnAck?: string;
862
862
  routeOnTurnTimeoutMs?: number;
863
863
  audioConditioning?: VoiceAudioConditioningConfig;
864
+ normalizeNumbers?: boolean;
864
865
  noiseSuppressor?: VoiceNoiseSuppressor;
865
866
  noiseSuppressorFormat?: AudioFormat;
866
867
  logger?: VoiceLogger;
@@ -1131,6 +1132,7 @@ export type CreateVoiceSessionOptions<TContext = unknown, TSession extends Voice
1131
1132
  sttLifecycle: VoiceSTTLifecycle;
1132
1133
  turnDetection: VoiceResolvedTurnDetectionConfig;
1133
1134
  audioConditioning?: VoiceResolvedAudioConditioningConfig;
1135
+ normalizeNumbers?: boolean;
1134
1136
  noiseSuppressor?: VoiceNoiseSuppressor;
1135
1137
  noiseSuppressorFormat?: AudioFormat;
1136
1138
  handoff?: VoiceHandoffConfig<TContext, TSession, TResult>;
package/dist/index.d.ts CHANGED
@@ -77,6 +77,7 @@ export { createVoiceMCPToolset } from "./core/mcpToolset";
77
77
  export type { CreateVoiceMCPToolsetOptions, MCPClientLike, MCPToolCallResult, MCPToolContentBlock, MCPToolDefinition, VoiceMCPToolResult, } from "./core/mcpToolset";
78
78
  export { createAIVoiceModel } from "./core/aiVoiceModel";
79
79
  export type { CreateAIVoiceModelOptions } from "./core/aiVoiceModel";
80
+ export { normalizeSpokenNumbers } from "./core/numberNormalizer";
80
81
  export { createVoiceAIJudgeCompletion, createVoiceLLMJudge, } from "./core/llmJudge";
81
82
  export type { CreateVoiceAIJudgeCompletionOptions, CreateVoiceLLMJudgeOptions, VoiceLLMJudge, VoiceLLMJudgeCompletion, VoiceLLMJudgeCriterionVerdict, VoiceLLMJudgeInput, VoiceLLMJudgeRubric, VoiceLLMJudgeRubricCriterion, VoiceLLMJudgeVerdict, } from "./core/llmJudge";
82
83
  export { DEFAULT_VOICE_REDACTION_PATTERNS, createVoiceTranscriptRedactor, redactVoiceTranscript, } from "./core/redaction";
package/dist/index.js CHANGED
@@ -3096,6 +3096,191 @@ var toVoiceSessionSummary = (session) => ({
3096
3096
  // src/core/session.ts
3097
3097
  import { Buffer as Buffer2 } from "buffer";
3098
3098
 
3099
+ // src/core/numberNormalizer.ts
3100
+ var ONES = {
3101
+ eight: 8,
3102
+ eighteen: 18,
3103
+ eleven: 11,
3104
+ fifteen: 15,
3105
+ five: 5,
3106
+ four: 4,
3107
+ fourteen: 14,
3108
+ nine: 9,
3109
+ nineteen: 19,
3110
+ one: 1,
3111
+ seven: 7,
3112
+ seventeen: 17,
3113
+ six: 6,
3114
+ sixteen: 16,
3115
+ ten: 10,
3116
+ thirteen: 13,
3117
+ three: 3,
3118
+ twelve: 12,
3119
+ two: 2,
3120
+ zero: 0
3121
+ };
3122
+ var TENS = {
3123
+ eighty: 80,
3124
+ fifty: 50,
3125
+ forty: 40,
3126
+ ninety: 90,
3127
+ seventy: 70,
3128
+ sixty: 60,
3129
+ thirty: 30,
3130
+ twenty: 20
3131
+ };
3132
+ var SCALES = {
3133
+ billion: 1e9,
3134
+ million: 1e6,
3135
+ thousand: 1000,
3136
+ trillion: 1000000000000
3137
+ };
3138
+ var MAGNITUDE_WORDS = [
3139
+ [1000000000000, "trillion"],
3140
+ [1e9, "billion"],
3141
+ [1e6, "million"]
3142
+ ];
3143
+ var FILLER = new Set(["and", "a", "an"]);
3144
+ var DECIMAL_PLACES = 3;
3145
+ var isValueWord = (word) => (word in ONES) || (word in TENS) || word === "hundred";
3146
+ var isScaleWord = (word) => (word in SCALES);
3147
+ var isNumberWord = (word) => isValueWord(word) || isScaleWord(word) || word === "point" || FILLER.has(word);
3148
+ var trimNumber = (value) => {
3149
+ if (Number.isInteger(value))
3150
+ return value.toLocaleString("en-US");
3151
+ return String(Number(value.toFixed(DECIMAL_PLACES)));
3152
+ };
3153
+ var renderValue = (value, usedMagnitude) => {
3154
+ if (usedMagnitude) {
3155
+ for (const [scale, word] of MAGNITUDE_WORDS) {
3156
+ if (value >= scale) {
3157
+ const scaled = value / scale;
3158
+ if (Number(scaled.toFixed(DECIMAL_PLACES)) === scaled) {
3159
+ return `${trimNumber(scaled)} ${word}`;
3160
+ }
3161
+ }
3162
+ }
3163
+ }
3164
+ return trimNumber(value);
3165
+ };
3166
+ var parseNumberWords = (words) => {
3167
+ let total = 0;
3168
+ let current = 0;
3169
+ let usedMagnitude = false;
3170
+ let sawNumber = false;
3171
+ let decimal = null;
3172
+ const foldDecimal = () => {
3173
+ if (decimal && decimal.length > 0)
3174
+ current += Number(`0.${decimal}`);
3175
+ decimal = null;
3176
+ };
3177
+ for (const word of words) {
3178
+ if (word === "point") {
3179
+ decimal = "";
3180
+ continue;
3181
+ }
3182
+ const one = ONES[word];
3183
+ const ten = TENS[word];
3184
+ const scale = SCALES[word];
3185
+ if (decimal !== null) {
3186
+ if (one !== undefined && one <= 9) {
3187
+ decimal += String(one);
3188
+ sawNumber = true;
3189
+ continue;
3190
+ }
3191
+ foldDecimal();
3192
+ }
3193
+ if (FILLER.has(word))
3194
+ continue;
3195
+ if (one !== undefined) {
3196
+ current += one;
3197
+ sawNumber = true;
3198
+ } else if (ten !== undefined) {
3199
+ current += ten;
3200
+ sawNumber = true;
3201
+ } else if (word === "hundred") {
3202
+ current = (current === 0 ? 1 : current) * 100;
3203
+ sawNumber = true;
3204
+ } else if (scale !== undefined) {
3205
+ total += (current === 0 ? 1 : current) * scale;
3206
+ current = 0;
3207
+ sawNumber = true;
3208
+ usedMagnitude = true;
3209
+ }
3210
+ }
3211
+ foldDecimal();
3212
+ if (!sawNumber)
3213
+ return null;
3214
+ return { usedMagnitude, value: total + current };
3215
+ };
3216
+ var PERCENT_RE = /^(per ?cent|percent|percentage)$/;
3217
+ var CURRENCY_RE = /^(dollars?|bucks?|usd)$/;
3218
+ var WORD_RE = /^[A-Za-z]+(?:-[A-Za-z]+)*$/;
3219
+ var wordsOf = (token) => token.toLowerCase().split("-");
3220
+ var isNumberToken = (token) => WORD_RE.test(token) && wordsOf(token).every(isNumberWord);
3221
+ var startsNumber = (token) => WORD_RE.test(token) && wordsOf(token).some(isValueWord);
3222
+ var isSpace = (token) => /^\s+$/.test(token);
3223
+ var normalizeSpokenNumbers = (input) => {
3224
+ if (!input)
3225
+ return input;
3226
+ const parts = input.match(/[A-Za-z]+(?:-[A-Za-z]+)*|[^A-Za-z]+/g);
3227
+ if (!parts)
3228
+ return input;
3229
+ const at = (idx) => parts[idx] ?? "";
3230
+ const out = [];
3231
+ let i = 0;
3232
+ while (i < parts.length) {
3233
+ const token = at(i);
3234
+ const lower = token.toLowerCase();
3235
+ if (lower === "a" || lower === "an") {
3236
+ const nextWord = at(i + 2);
3237
+ const nextHead = wordsOf(nextWord)[0] ?? "";
3238
+ const nextIsScale = isSpace(at(i + 1)) && WORD_RE.test(nextWord) && (nextHead === "hundred" || isScaleWord(nextHead));
3239
+ if (!nextIsScale) {
3240
+ out.push(token);
3241
+ i += 1;
3242
+ continue;
3243
+ }
3244
+ } else if (!startsNumber(token)) {
3245
+ out.push(token);
3246
+ i += 1;
3247
+ continue;
3248
+ }
3249
+ const spanIdx = [i];
3250
+ let j = i + 1;
3251
+ while (isSpace(at(j)) && isNumberToken(at(j + 1))) {
3252
+ spanIdx.push(j + 1);
3253
+ j += 2;
3254
+ }
3255
+ const words = spanIdx.flatMap((k) => wordsOf(at(k)));
3256
+ while (words.length > 0 && FILLER.has(words[words.length - 1] ?? "")) {
3257
+ words.pop();
3258
+ }
3259
+ const parsed = parseNumberWords(words);
3260
+ if (!parsed) {
3261
+ out.push(token);
3262
+ i += 1;
3263
+ continue;
3264
+ }
3265
+ let rendered = renderValue(parsed.value, parsed.usedMagnitude);
3266
+ let lastIdx = spanIdx[spanIdx.length - 1] ?? i;
3267
+ const unitWord = at(lastIdx + 2);
3268
+ if (isSpace(at(lastIdx + 1)) && WORD_RE.test(unitWord)) {
3269
+ const unit = unitWord.toLowerCase();
3270
+ if (PERCENT_RE.test(unit)) {
3271
+ rendered = `${rendered}%`;
3272
+ lastIdx += 2;
3273
+ } else if (CURRENCY_RE.test(unit)) {
3274
+ rendered = rendered.startsWith("$") ? rendered : `$${rendered}`;
3275
+ lastIdx += 2;
3276
+ }
3277
+ }
3278
+ out.push(rendered);
3279
+ i = lastIdx + 1;
3280
+ }
3281
+ return out.join("");
3282
+ };
3283
+
3099
3284
  // src/core/debugTiming.ts
3100
3285
  var timingEnabled = () => process.env.ABSOLUTEJS_VOICE_TIMING === "1" || process.env.ABSOLUTEJS_VOICE_TIMING === "true";
3101
3286
  var emitTiming = (sessionId, stage, elapsedMs, detail) => {
@@ -5834,6 +6019,9 @@ var createVoiceSession = (options) => {
5834
6019
  };
5835
6020
  const completeTurn = async (session, turn) => {
5836
6021
  console.error(`[voice] completeTurn ENTER session=${options.id} turn=${turn.id} textLen=${turn.text?.length ?? 0}`);
6022
+ if (options.normalizeNumbers && turn.text) {
6023
+ turn.text = normalizeSpokenNumbers(turn.text);
6024
+ }
5837
6025
  kickStuckCloseWatchdog();
5838
6026
  kickIdleRepromptWatchdog(true);
5839
6027
  const liveOpsControl = await options.liveOps?.getControl(options.id);
@@ -25468,6 +25656,7 @@ var createTwilioMediaStreamBridge = (socket, options) => {
25468
25656
  const voiceSocket = createTwilioSocketAdapter(socket, () => bridgeState);
25469
25657
  sessionHandle = createVoiceSession({
25470
25658
  audioConditioning,
25659
+ normalizeNumbers: options.normalizeNumbers,
25471
25660
  context: options.context,
25472
25661
  costAccountant: options.costAccountant,
25473
25662
  costTelemetry: options.costTelemetry,
@@ -39586,6 +39775,7 @@ var resolveSessionOptions = (config) => {
39586
39775
  const preset = resolveVoiceRuntimePreset(config.preset);
39587
39776
  return {
39588
39777
  audioConditioning: config.audioConditioning !== undefined ? resolveAudioConditioningConfig(config.audioConditioning) : preset.audioConditioning,
39778
+ normalizeNumbers: config.normalizeNumbers,
39589
39779
  noiseSuppressor: config.noiseSuppressor,
39590
39780
  noiseSuppressorFormat: config.noiseSuppressorFormat,
39591
39781
  costTelemetry: config.costTelemetry,
@@ -39799,6 +39989,7 @@ var voice = (config) => {
39799
39989
  });
39800
39990
  return createVoiceSession({
39801
39991
  audioConditioning: sessionOptions.audioConditioning,
39992
+ normalizeNumbers: sessionOptions.normalizeNumbers,
39802
39993
  noiseSuppressor: sessionOptions.noiseSuppressor,
39803
39994
  noiseSuppressorFormat: sessionOptions.noiseSuppressorFormat,
39804
39995
  context,
@@ -50973,7 +51164,7 @@ var DEFAULT_VOICE_CAMPAIGN_TEMPLATE_FILTERS = {
50973
51164
  ssml: (value) => escapeSpeech(String(value ?? "")),
50974
51165
  upper: (value) => String(value ?? "").toUpperCase()
50975
51166
  };
50976
- var renderValue = (value, filters, filterChain) => {
51167
+ var renderValue2 = (value, filters, filterChain) => {
50977
51168
  let cursor = value;
50978
51169
  for (const segment of filterChain) {
50979
51170
  const parts2 = segment.split(":");
@@ -51018,7 +51209,7 @@ var resolveVoiceCampaignTemplate = (template, options) => {
51018
51209
  }
51019
51210
  return options.fallback ?? "";
51020
51211
  }
51021
- return renderValue(value, filters, segments.slice(1));
51212
+ return renderValue2(value, filters, segments.slice(1));
51022
51213
  });
51023
51214
  return { missingVariables: Array.from(missing), output };
51024
51215
  };
@@ -53651,6 +53842,7 @@ export {
53651
53842
  parseVoiceSessionSnapshot,
53652
53843
  parseVoiceAIScorecardResponse,
53653
53844
  normalizeVoiceProofTrendReport,
53845
+ normalizeSpokenNumbers,
53654
53846
  normalizePhoneNumber,
53655
53847
  muteVoiceMonitorIssue,
53656
53848
  matchesVoiceOpsTaskAssignmentRule,
@@ -5536,6 +5536,191 @@ var createVoiceMemoryStore = () => {
5536
5536
  // src/core/session.ts
5537
5537
  import { Buffer as Buffer2 } from "buffer";
5538
5538
 
5539
+ // src/core/numberNormalizer.ts
5540
+ var ONES = {
5541
+ eight: 8,
5542
+ eighteen: 18,
5543
+ eleven: 11,
5544
+ fifteen: 15,
5545
+ five: 5,
5546
+ four: 4,
5547
+ fourteen: 14,
5548
+ nine: 9,
5549
+ nineteen: 19,
5550
+ one: 1,
5551
+ seven: 7,
5552
+ seventeen: 17,
5553
+ six: 6,
5554
+ sixteen: 16,
5555
+ ten: 10,
5556
+ thirteen: 13,
5557
+ three: 3,
5558
+ twelve: 12,
5559
+ two: 2,
5560
+ zero: 0
5561
+ };
5562
+ var TENS = {
5563
+ eighty: 80,
5564
+ fifty: 50,
5565
+ forty: 40,
5566
+ ninety: 90,
5567
+ seventy: 70,
5568
+ sixty: 60,
5569
+ thirty: 30,
5570
+ twenty: 20
5571
+ };
5572
+ var SCALES = {
5573
+ billion: 1e9,
5574
+ million: 1e6,
5575
+ thousand: 1000,
5576
+ trillion: 1000000000000
5577
+ };
5578
+ var MAGNITUDE_WORDS = [
5579
+ [1000000000000, "trillion"],
5580
+ [1e9, "billion"],
5581
+ [1e6, "million"]
5582
+ ];
5583
+ var FILLER = new Set(["and", "a", "an"]);
5584
+ var DECIMAL_PLACES = 3;
5585
+ var isValueWord = (word) => (word in ONES) || (word in TENS) || word === "hundred";
5586
+ var isScaleWord = (word) => (word in SCALES);
5587
+ var isNumberWord = (word) => isValueWord(word) || isScaleWord(word) || word === "point" || FILLER.has(word);
5588
+ var trimNumber = (value) => {
5589
+ if (Number.isInteger(value))
5590
+ return value.toLocaleString("en-US");
5591
+ return String(Number(value.toFixed(DECIMAL_PLACES)));
5592
+ };
5593
+ var renderValue = (value, usedMagnitude) => {
5594
+ if (usedMagnitude) {
5595
+ for (const [scale, word] of MAGNITUDE_WORDS) {
5596
+ if (value >= scale) {
5597
+ const scaled = value / scale;
5598
+ if (Number(scaled.toFixed(DECIMAL_PLACES)) === scaled) {
5599
+ return `${trimNumber(scaled)} ${word}`;
5600
+ }
5601
+ }
5602
+ }
5603
+ }
5604
+ return trimNumber(value);
5605
+ };
5606
+ var parseNumberWords = (words) => {
5607
+ let total = 0;
5608
+ let current = 0;
5609
+ let usedMagnitude = false;
5610
+ let sawNumber = false;
5611
+ let decimal = null;
5612
+ const foldDecimal = () => {
5613
+ if (decimal && decimal.length > 0)
5614
+ current += Number(`0.${decimal}`);
5615
+ decimal = null;
5616
+ };
5617
+ for (const word of words) {
5618
+ if (word === "point") {
5619
+ decimal = "";
5620
+ continue;
5621
+ }
5622
+ const one = ONES[word];
5623
+ const ten = TENS[word];
5624
+ const scale = SCALES[word];
5625
+ if (decimal !== null) {
5626
+ if (one !== undefined && one <= 9) {
5627
+ decimal += String(one);
5628
+ sawNumber = true;
5629
+ continue;
5630
+ }
5631
+ foldDecimal();
5632
+ }
5633
+ if (FILLER.has(word))
5634
+ continue;
5635
+ if (one !== undefined) {
5636
+ current += one;
5637
+ sawNumber = true;
5638
+ } else if (ten !== undefined) {
5639
+ current += ten;
5640
+ sawNumber = true;
5641
+ } else if (word === "hundred") {
5642
+ current = (current === 0 ? 1 : current) * 100;
5643
+ sawNumber = true;
5644
+ } else if (scale !== undefined) {
5645
+ total += (current === 0 ? 1 : current) * scale;
5646
+ current = 0;
5647
+ sawNumber = true;
5648
+ usedMagnitude = true;
5649
+ }
5650
+ }
5651
+ foldDecimal();
5652
+ if (!sawNumber)
5653
+ return null;
5654
+ return { usedMagnitude, value: total + current };
5655
+ };
5656
+ var PERCENT_RE = /^(per ?cent|percent|percentage)$/;
5657
+ var CURRENCY_RE = /^(dollars?|bucks?|usd)$/;
5658
+ var WORD_RE = /^[A-Za-z]+(?:-[A-Za-z]+)*$/;
5659
+ var wordsOf = (token) => token.toLowerCase().split("-");
5660
+ var isNumberToken = (token) => WORD_RE.test(token) && wordsOf(token).every(isNumberWord);
5661
+ var startsNumber = (token) => WORD_RE.test(token) && wordsOf(token).some(isValueWord);
5662
+ var isSpace = (token) => /^\s+$/.test(token);
5663
+ var normalizeSpokenNumbers = (input) => {
5664
+ if (!input)
5665
+ return input;
5666
+ const parts = input.match(/[A-Za-z]+(?:-[A-Za-z]+)*|[^A-Za-z]+/g);
5667
+ if (!parts)
5668
+ return input;
5669
+ const at = (idx) => parts[idx] ?? "";
5670
+ const out = [];
5671
+ let i = 0;
5672
+ while (i < parts.length) {
5673
+ const token = at(i);
5674
+ const lower = token.toLowerCase();
5675
+ if (lower === "a" || lower === "an") {
5676
+ const nextWord = at(i + 2);
5677
+ const nextHead = wordsOf(nextWord)[0] ?? "";
5678
+ const nextIsScale = isSpace(at(i + 1)) && WORD_RE.test(nextWord) && (nextHead === "hundred" || isScaleWord(nextHead));
5679
+ if (!nextIsScale) {
5680
+ out.push(token);
5681
+ i += 1;
5682
+ continue;
5683
+ }
5684
+ } else if (!startsNumber(token)) {
5685
+ out.push(token);
5686
+ i += 1;
5687
+ continue;
5688
+ }
5689
+ const spanIdx = [i];
5690
+ let j = i + 1;
5691
+ while (isSpace(at(j)) && isNumberToken(at(j + 1))) {
5692
+ spanIdx.push(j + 1);
5693
+ j += 2;
5694
+ }
5695
+ const words = spanIdx.flatMap((k) => wordsOf(at(k)));
5696
+ while (words.length > 0 && FILLER.has(words[words.length - 1] ?? "")) {
5697
+ words.pop();
5698
+ }
5699
+ const parsed = parseNumberWords(words);
5700
+ if (!parsed) {
5701
+ out.push(token);
5702
+ i += 1;
5703
+ continue;
5704
+ }
5705
+ let rendered = renderValue(parsed.value, parsed.usedMagnitude);
5706
+ let lastIdx = spanIdx[spanIdx.length - 1] ?? i;
5707
+ const unitWord = at(lastIdx + 2);
5708
+ if (isSpace(at(lastIdx + 1)) && WORD_RE.test(unitWord)) {
5709
+ const unit = unitWord.toLowerCase();
5710
+ if (PERCENT_RE.test(unit)) {
5711
+ rendered = `${rendered}%`;
5712
+ lastIdx += 2;
5713
+ } else if (CURRENCY_RE.test(unit)) {
5714
+ rendered = rendered.startsWith("$") ? rendered : `$${rendered}`;
5715
+ lastIdx += 2;
5716
+ }
5717
+ }
5718
+ out.push(rendered);
5719
+ i = lastIdx + 1;
5720
+ }
5721
+ return out.join("");
5722
+ };
5723
+
5539
5724
  // src/core/backchannel.ts
5540
5725
  var DEFAULT_CUES = [
5541
5726
  { text: "mm-hmm" },
@@ -8154,6 +8339,9 @@ var createVoiceSession = (options) => {
8154
8339
  };
8155
8340
  const completeTurn = async (session, turn) => {
8156
8341
  console.error(`[voice] completeTurn ENTER session=${options.id} turn=${turn.id} textLen=${turn.text?.length ?? 0}`);
8342
+ if (options.normalizeNumbers && turn.text) {
8343
+ turn.text = normalizeSpokenNumbers(turn.text);
8344
+ }
8157
8345
  kickStuckCloseWatchdog();
8158
8346
  kickIdleRepromptWatchdog(true);
8159
8347
  const liveOpsControl = await options.liveOps?.getControl(options.id);
@@ -14434,6 +14622,7 @@ var createTwilioMediaStreamBridge = (socket, options) => {
14434
14622
  const voiceSocket = createTwilioSocketAdapter(socket, () => bridgeState);
14435
14623
  sessionHandle = createVoiceSession({
14436
14624
  audioConditioning,
14625
+ normalizeNumbers: options.normalizeNumbers,
14437
14626
  context: options.context,
14438
14627
  costAccountant: options.costAccountant,
14439
14628
  costTelemetry: options.costTelemetry,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@absolutejs/voice",
3
- "version": "0.0.22-beta.625",
3
+ "version": "0.0.22-beta.626",
4
4
  "description": "Voice primitives and Elysia plugin for AbsoluteJS",
5
5
  "repository": {
6
6
  "type": "git",