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

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;
@@ -46,6 +46,11 @@ export type VoiceLanguageStrategy = {
46
46
  primaryLanguage?: string;
47
47
  secondaryLanguages: string[];
48
48
  };
49
+ export type VoiceLanguageStrategyResolver<TContext = unknown> = (input: {
50
+ context: TContext;
51
+ scenarioId?: string;
52
+ sessionId: string;
53
+ }) => Promise<VoiceLanguageStrategy | void> | VoiceLanguageStrategy | void;
49
54
  export type VoicePhraseHint = {
50
55
  text: string;
51
56
  aliases?: string[];
@@ -825,7 +830,7 @@ export type VoicePluginConfig<TContext = unknown, TSession extends VoiceSessionR
825
830
  }) => string | Promise<string>);
826
831
  maxReprompts?: number;
827
832
  };
828
- languageStrategy?: VoiceLanguageStrategy;
833
+ languageStrategy?: VoiceLanguageStrategy | VoiceLanguageStrategyResolver<TContext>;
829
834
  lexicon?: VoiceLexiconEntry[] | VoiceLexiconResolver<TContext>;
830
835
  phraseHints?: VoicePhraseHint[] | VoicePhraseHintResolver<TContext>;
831
836
  preset?: VoiceRuntimePreset;
@@ -861,6 +866,7 @@ export type VoicePluginConfig<TContext = unknown, TSession extends VoiceSessionR
861
866
  defaultSilentTurnAck?: string;
862
867
  routeOnTurnTimeoutMs?: number;
863
868
  audioConditioning?: VoiceAudioConditioningConfig;
869
+ normalizeNumbers?: boolean;
864
870
  noiseSuppressor?: VoiceNoiseSuppressor;
865
871
  noiseSuppressorFormat?: AudioFormat;
866
872
  logger?: VoiceLogger;
@@ -1131,6 +1137,7 @@ export type CreateVoiceSessionOptions<TContext = unknown, TSession extends Voice
1131
1137
  sttLifecycle: VoiceSTTLifecycle;
1132
1138
  turnDetection: VoiceResolvedTurnDetectionConfig;
1133
1139
  audioConditioning?: VoiceResolvedAudioConditioningConfig;
1140
+ normalizeNumbers?: boolean;
1134
1141
  noiseSuppressor?: VoiceNoiseSuppressor;
1135
1142
  noiseSuppressorFormat?: AudioFormat;
1136
1143
  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);
@@ -25430,6 +25618,16 @@ var createTwilioMediaStreamBridge = (socket, options) => {
25430
25618
  }
25431
25619
  return normalizePhraseHints(options.phraseHints);
25432
25620
  };
25621
+ const resolveLanguageStrategy = async () => {
25622
+ if (typeof options.languageStrategy === "function") {
25623
+ return await options.languageStrategy({
25624
+ context: options.context,
25625
+ scenarioId: bridgeState.scenarioId ?? undefined,
25626
+ sessionId: bridgeState.sessionId ?? ""
25627
+ }) ?? undefined;
25628
+ }
25629
+ return options.languageStrategy;
25630
+ };
25433
25631
  const ensureSession = async () => {
25434
25632
  if (sessionHandle) {
25435
25633
  return sessionHandle;
@@ -25437,6 +25635,7 @@ var createTwilioMediaStreamBridge = (socket, options) => {
25437
25635
  bridgeState.sessionId ??= `phone-${Date.now().toString(36)}`;
25438
25636
  const lexicon = await resolveLexicon();
25439
25637
  const phraseHints = await resolvePhraseHints();
25638
+ const languageStrategy = await resolveLanguageStrategy();
25440
25639
  const normalizedOnTurn = normalizeOnTurn(options.onTurn);
25441
25640
  const route = {
25442
25641
  correctTurn: options.correctTurn,
@@ -25468,13 +25667,14 @@ var createTwilioMediaStreamBridge = (socket, options) => {
25468
25667
  const voiceSocket = createTwilioSocketAdapter(socket, () => bridgeState);
25469
25668
  sessionHandle = createVoiceSession({
25470
25669
  audioConditioning,
25670
+ normalizeNumbers: options.normalizeNumbers,
25471
25671
  context: options.context,
25472
25672
  costAccountant: options.costAccountant,
25473
25673
  costTelemetry: options.costTelemetry,
25474
25674
  costTelephony: options.costTelephony,
25475
25675
  greeting: options.greeting,
25476
25676
  id: bridgeState.sessionId,
25477
- languageStrategy: options.languageStrategy,
25677
+ languageStrategy,
25478
25678
  lexicon,
25479
25679
  logger,
25480
25680
  phraseHints,
@@ -39586,6 +39786,7 @@ var resolveSessionOptions = (config) => {
39586
39786
  const preset = resolveVoiceRuntimePreset(config.preset);
39587
39787
  return {
39588
39788
  audioConditioning: config.audioConditioning !== undefined ? resolveAudioConditioningConfig(config.audioConditioning) : preset.audioConditioning,
39789
+ normalizeNumbers: config.normalizeNumbers,
39589
39790
  noiseSuppressor: config.noiseSuppressor,
39590
39791
  noiseSuppressorFormat: config.noiseSuppressorFormat,
39591
39792
  costTelemetry: config.costTelemetry,
@@ -39633,6 +39834,12 @@ var resolveLexicon = async (config, input) => {
39633
39834
  }
39634
39835
  return normalizeLexicon2(config.lexicon);
39635
39836
  };
39837
+ var resolveLanguageStrategy = async (config, input) => {
39838
+ if (typeof config.languageStrategy === "function") {
39839
+ return await config.languageStrategy(input) ?? undefined;
39840
+ }
39841
+ return config.languageStrategy;
39842
+ };
39636
39843
  var resolveProfileSwitchGuard = async (config, runtime, input) => {
39637
39844
  const guard = config.profileSwitchGuard;
39638
39845
  if (!guard || runtime.profileSwitchGuardedSessions.has(input.sessionId)) {
@@ -39797,8 +40004,14 @@ var voice = (config) => {
39797
40004
  scenarioId,
39798
40005
  sessionId
39799
40006
  });
40007
+ const languageStrategy = await resolveLanguageStrategy(config, {
40008
+ context,
40009
+ scenarioId,
40010
+ sessionId
40011
+ });
39800
40012
  return createVoiceSession({
39801
40013
  audioConditioning: sessionOptions.audioConditioning,
40014
+ normalizeNumbers: sessionOptions.normalizeNumbers,
39802
40015
  noiseSuppressor: sessionOptions.noiseSuppressor,
39803
40016
  noiseSuppressorFormat: sessionOptions.noiseSuppressorFormat,
39804
40017
  context,
@@ -39809,7 +40022,7 @@ var voice = (config) => {
39809
40022
  stuckCallClose: config.stuckCallClose,
39810
40023
  idleReprompt: config.idleReprompt,
39811
40024
  handoff: config.handoff,
39812
- languageStrategy: config.languageStrategy,
40025
+ languageStrategy,
39813
40026
  lexicon,
39814
40027
  liveOps: config.liveOps,
39815
40028
  logger: sessionOptions.logger,
@@ -50973,7 +51186,7 @@ var DEFAULT_VOICE_CAMPAIGN_TEMPLATE_FILTERS = {
50973
51186
  ssml: (value) => escapeSpeech(String(value ?? "")),
50974
51187
  upper: (value) => String(value ?? "").toUpperCase()
50975
51188
  };
50976
- var renderValue = (value, filters, filterChain) => {
51189
+ var renderValue2 = (value, filters, filterChain) => {
50977
51190
  let cursor = value;
50978
51191
  for (const segment of filterChain) {
50979
51192
  const parts2 = segment.split(":");
@@ -51018,7 +51231,7 @@ var resolveVoiceCampaignTemplate = (template, options) => {
51018
51231
  }
51019
51232
  return options.fallback ?? "";
51020
51233
  }
51021
- return renderValue(value, filters, segments.slice(1));
51234
+ return renderValue2(value, filters, segments.slice(1));
51022
51235
  });
51023
51236
  return { missingVariables: Array.from(missing), output };
51024
51237
  };
@@ -53651,6 +53864,7 @@ export {
53651
53864
  parseVoiceSessionSnapshot,
53652
53865
  parseVoiceAIScorecardResponse,
53653
53866
  normalizeVoiceProofTrendReport,
53867
+ normalizeSpokenNumbers,
53654
53868
  normalizePhoneNumber,
53655
53869
  muteVoiceMonitorIssue,
53656
53870
  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);
@@ -14396,6 +14584,16 @@ var createTwilioMediaStreamBridge = (socket, options) => {
14396
14584
  }
14397
14585
  return normalizePhraseHints(options.phraseHints);
14398
14586
  };
14587
+ const resolveLanguageStrategy = async () => {
14588
+ if (typeof options.languageStrategy === "function") {
14589
+ return await options.languageStrategy({
14590
+ context: options.context,
14591
+ scenarioId: bridgeState.scenarioId ?? undefined,
14592
+ sessionId: bridgeState.sessionId ?? ""
14593
+ }) ?? undefined;
14594
+ }
14595
+ return options.languageStrategy;
14596
+ };
14399
14597
  const ensureSession = async () => {
14400
14598
  if (sessionHandle) {
14401
14599
  return sessionHandle;
@@ -14403,6 +14601,7 @@ var createTwilioMediaStreamBridge = (socket, options) => {
14403
14601
  bridgeState.sessionId ??= `phone-${Date.now().toString(36)}`;
14404
14602
  const lexicon = await resolveLexicon();
14405
14603
  const phraseHints = await resolvePhraseHints();
14604
+ const languageStrategy = await resolveLanguageStrategy();
14406
14605
  const normalizedOnTurn = normalizeOnTurn(options.onTurn);
14407
14606
  const route = {
14408
14607
  correctTurn: options.correctTurn,
@@ -14434,13 +14633,14 @@ var createTwilioMediaStreamBridge = (socket, options) => {
14434
14633
  const voiceSocket = createTwilioSocketAdapter(socket, () => bridgeState);
14435
14634
  sessionHandle = createVoiceSession({
14436
14635
  audioConditioning,
14636
+ normalizeNumbers: options.normalizeNumbers,
14437
14637
  context: options.context,
14438
14638
  costAccountant: options.costAccountant,
14439
14639
  costTelemetry: options.costTelemetry,
14440
14640
  costTelephony: options.costTelephony,
14441
14641
  greeting: options.greeting,
14442
14642
  id: bridgeState.sessionId,
14443
- languageStrategy: options.languageStrategy,
14643
+ languageStrategy,
14444
14644
  lexicon,
14445
14645
  logger,
14446
14646
  phraseHints,
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.627",
4
4
  "description": "Voice primitives and Elysia plugin for AbsoluteJS",
5
5
  "repository": {
6
6
  "type": "git",