@absolutejs/voice 0.0.22-beta.32 → 0.0.22-beta.321

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 (240) hide show
  1. package/README.md +3354 -55
  2. package/dist/agent.d.ts +62 -0
  3. package/dist/agentSquadContract.d.ts +98 -0
  4. package/dist/angular/index.d.ts +16 -0
  5. package/dist/angular/index.js +3911 -1128
  6. package/dist/angular/voice-agent-squad-status.service.d.ts +12 -0
  7. package/dist/angular/voice-campaign-dialer-proof.service.d.ts +14 -0
  8. package/dist/angular/voice-controller.service.d.ts +1 -0
  9. package/dist/angular/voice-delivery-runtime.component.d.ts +17 -0
  10. package/dist/angular/voice-delivery-runtime.service.d.ts +16 -0
  11. package/dist/angular/voice-live-ops.service.d.ts +11 -0
  12. package/dist/angular/voice-ops-action-center.service.d.ts +13 -0
  13. package/dist/angular/voice-ops-status.component.d.ts +15 -0
  14. package/dist/angular/voice-ops-status.service.d.ts +12 -0
  15. package/dist/angular/voice-platform-coverage.service.d.ts +12 -0
  16. package/dist/angular/voice-proof-trends.service.d.ts +12 -0
  17. package/dist/angular/voice-provider-capabilities.service.d.ts +12 -0
  18. package/dist/angular/voice-provider-contracts.service.d.ts +12 -0
  19. package/dist/angular/voice-readiness-failures.service.d.ts +13 -0
  20. package/dist/angular/voice-routing-status.service.d.ts +11 -0
  21. package/dist/angular/voice-stream.service.d.ts +1 -0
  22. package/dist/angular/voice-trace-timeline.service.d.ts +12 -0
  23. package/dist/angular/voice-turn-latency.service.d.ts +13 -0
  24. package/dist/angular/voice-turn-quality.service.d.ts +12 -0
  25. package/dist/angular/voice-workflow-status.service.d.ts +12 -0
  26. package/dist/audit.d.ts +128 -0
  27. package/dist/auditDeliveryRoutes.d.ts +85 -0
  28. package/dist/auditExport.d.ts +34 -0
  29. package/dist/auditRoutes.d.ts +66 -0
  30. package/dist/auditSinks.d.ts +151 -0
  31. package/dist/bargeInRoutes.d.ts +56 -0
  32. package/dist/browserMediaRoutes.d.ts +61 -0
  33. package/dist/campaign.d.ts +768 -0
  34. package/dist/campaignDialers.d.ts +111 -0
  35. package/dist/client/actions.d.ts +83 -0
  36. package/dist/client/agentSquadStatus.d.ts +37 -0
  37. package/dist/client/agentSquadStatusWidget.d.ts +24 -0
  38. package/dist/client/bargeInMonitor.d.ts +7 -0
  39. package/dist/client/browserMedia.d.ts +8 -0
  40. package/dist/client/campaignDialerProof.d.ts +23 -0
  41. package/dist/client/deliveryRuntime.d.ts +34 -0
  42. package/dist/client/deliveryRuntimeWidget.d.ts +37 -0
  43. package/dist/client/duplex.d.ts +1 -1
  44. package/dist/client/htmxBootstrap.js +875 -14
  45. package/dist/client/index.d.ts +72 -0
  46. package/dist/client/index.js +5671 -19
  47. package/dist/client/liveOps.d.ts +22 -0
  48. package/dist/client/liveOpsWidget.d.ts +23 -0
  49. package/dist/client/liveTurnLatency.d.ts +41 -0
  50. package/dist/client/opsActionCenter.d.ts +54 -0
  51. package/dist/client/opsActionCenterWidget.d.ts +29 -0
  52. package/dist/client/opsActionHistory.d.ts +19 -0
  53. package/dist/client/opsActionHistoryWidget.d.ts +11 -0
  54. package/dist/client/opsStatus.d.ts +19 -0
  55. package/dist/client/opsStatusWidget.d.ts +40 -0
  56. package/dist/client/platformCoverage.d.ts +19 -0
  57. package/dist/client/platformCoverageWidget.d.ts +37 -0
  58. package/dist/client/proofTrends.d.ts +19 -0
  59. package/dist/client/proofTrendsWidget.d.ts +37 -0
  60. package/dist/client/providerCapabilities.d.ts +19 -0
  61. package/dist/client/providerCapabilitiesWidget.d.ts +32 -0
  62. package/dist/client/providerContracts.d.ts +19 -0
  63. package/dist/client/providerContractsWidget.d.ts +37 -0
  64. package/dist/client/providerSimulationControls.d.ts +33 -0
  65. package/dist/client/providerSimulationControlsWidget.d.ts +20 -0
  66. package/dist/client/providerStatusWidget.d.ts +32 -0
  67. package/dist/client/readinessFailures.d.ts +19 -0
  68. package/dist/client/readinessFailuresWidget.d.ts +42 -0
  69. package/dist/client/routingStatus.d.ts +19 -0
  70. package/dist/client/routingStatusWidget.d.ts +28 -0
  71. package/dist/client/traceTimeline.d.ts +19 -0
  72. package/dist/client/traceTimelineWidget.d.ts +36 -0
  73. package/dist/client/turnLatency.d.ts +22 -0
  74. package/dist/client/turnLatencyWidget.d.ts +33 -0
  75. package/dist/client/turnQuality.d.ts +19 -0
  76. package/dist/client/turnQualityWidget.d.ts +32 -0
  77. package/dist/client/workflowStatus.d.ts +19 -0
  78. package/dist/competitiveCoverage.d.ts +141 -0
  79. package/dist/dataControl.d.ts +180 -0
  80. package/dist/deliveryRuntime.d.ts +158 -0
  81. package/dist/deliverySinkRoutes.d.ts +117 -0
  82. package/dist/demoReadyRoutes.d.ts +98 -0
  83. package/dist/diagnosticsRoutes.d.ts +44 -0
  84. package/dist/evalRoutes.d.ts +219 -0
  85. package/dist/fileStore.d.ts +14 -2
  86. package/dist/guardrails.d.ts +128 -0
  87. package/dist/incidentBundle.d.ts +116 -0
  88. package/dist/index.d.ts +146 -13
  89. package/dist/index.js +28615 -5228
  90. package/dist/latencySlo.d.ts +56 -0
  91. package/dist/liveLatency.d.ts +78 -0
  92. package/dist/liveOps.d.ts +190 -0
  93. package/dist/mediaPipelineRoutes.d.ts +117 -0
  94. package/dist/modelAdapters.d.ts +54 -2
  95. package/dist/observabilityExport.d.ts +481 -0
  96. package/dist/openaiTTS.d.ts +18 -0
  97. package/dist/operationsRecord.d.ts +254 -0
  98. package/dist/opsActionAuditRoutes.d.ts +99 -0
  99. package/dist/opsConsoleRoutes.d.ts +80 -0
  100. package/dist/opsRecovery.d.ts +137 -0
  101. package/dist/opsStatus.d.ts +76 -0
  102. package/dist/opsStatusRoutes.d.ts +33 -0
  103. package/dist/outcomeContract.d.ts +146 -0
  104. package/dist/phoneAgent.d.ts +139 -0
  105. package/dist/phoneAgentProductionSmoke.d.ts +115 -0
  106. package/dist/platformCoverage.d.ts +91 -0
  107. package/dist/postCallAnalysis.d.ts +98 -0
  108. package/dist/postgresStore.d.ts +13 -2
  109. package/dist/productionReadiness.d.ts +594 -0
  110. package/dist/proofTrends.d.ts +133 -0
  111. package/dist/providerAdapters.d.ts +48 -0
  112. package/dist/providerCapabilities.d.ts +92 -0
  113. package/dist/providerDecisionTraces.d.ts +130 -0
  114. package/dist/providerOrchestration.d.ts +109 -0
  115. package/dist/providerRoutingContract.d.ts +71 -0
  116. package/dist/providerSlo.d.ts +142 -0
  117. package/dist/providerStackRecommendations.d.ts +187 -0
  118. package/dist/qualityRoutes.d.ts +76 -0
  119. package/dist/queue.d.ts +9 -0
  120. package/dist/react/VoiceAgentSquadStatus.d.ts +5 -0
  121. package/dist/react/VoiceDeliveryRuntime.d.ts +7 -0
  122. package/dist/react/VoiceOpsActionCenter.d.ts +5 -0
  123. package/dist/react/VoiceOpsStatus.d.ts +6 -0
  124. package/dist/react/VoicePlatformCoverage.d.ts +6 -0
  125. package/dist/react/VoiceProofTrends.d.ts +6 -0
  126. package/dist/react/VoiceProviderCapabilities.d.ts +6 -0
  127. package/dist/react/VoiceProviderContracts.d.ts +6 -0
  128. package/dist/react/VoiceProviderSimulationControls.d.ts +5 -0
  129. package/dist/react/VoiceProviderStatus.d.ts +6 -0
  130. package/dist/react/VoiceReadinessFailures.d.ts +6 -0
  131. package/dist/react/VoiceRoutingStatus.d.ts +6 -0
  132. package/dist/react/VoiceTraceTimeline.d.ts +6 -0
  133. package/dist/react/VoiceTurnLatency.d.ts +6 -0
  134. package/dist/react/VoiceTurnQuality.d.ts +6 -0
  135. package/dist/react/index.d.ts +32 -0
  136. package/dist/react/index.js +5472 -31
  137. package/dist/react/useVoiceAgentSquadStatus.d.ts +8 -0
  138. package/dist/react/useVoiceCampaignDialerProof.d.ts +10 -0
  139. package/dist/react/useVoiceController.d.ts +1 -0
  140. package/dist/react/useVoiceDeliveryRuntime.d.ts +13 -0
  141. package/dist/react/useVoiceLiveOps.d.ts +9 -0
  142. package/dist/react/useVoiceOpsActionCenter.d.ts +11 -0
  143. package/dist/react/useVoiceOpsStatus.d.ts +8 -0
  144. package/dist/react/useVoicePlatformCoverage.d.ts +8 -0
  145. package/dist/react/useVoiceProofTrends.d.ts +8 -0
  146. package/dist/react/useVoiceProviderCapabilities.d.ts +8 -0
  147. package/dist/react/useVoiceProviderContracts.d.ts +8 -0
  148. package/dist/react/useVoiceProviderSimulationControls.d.ts +10 -0
  149. package/dist/react/useVoiceReadinessFailures.d.ts +8 -0
  150. package/dist/react/useVoiceRoutingStatus.d.ts +8 -0
  151. package/dist/react/useVoiceStream.d.ts +1 -0
  152. package/dist/react/useVoiceTraceTimeline.d.ts +8 -0
  153. package/dist/react/useVoiceTurnLatency.d.ts +9 -0
  154. package/dist/react/useVoiceTurnQuality.d.ts +8 -0
  155. package/dist/react/useVoiceWorkflowStatus.d.ts +8 -0
  156. package/dist/readinessProfiles.d.ts +38 -0
  157. package/dist/realtimeChannel.d.ts +136 -0
  158. package/dist/realtimeProviderContracts.d.ts +133 -0
  159. package/dist/reconnectContract.d.ts +88 -0
  160. package/dist/resilienceRoutes.d.ts +143 -0
  161. package/dist/sessionReplay.d.ts +12 -0
  162. package/dist/simulationSuite.d.ts +143 -0
  163. package/dist/sloCalibration.d.ts +185 -0
  164. package/dist/sqliteStore.d.ts +13 -2
  165. package/dist/svelte/createVoiceAgentSquadStatus.d.ts +9 -0
  166. package/dist/svelte/createVoiceCampaignDialerProof.d.ts +9 -0
  167. package/dist/svelte/createVoiceDeliveryRuntime.d.ts +11 -0
  168. package/dist/svelte/createVoiceLiveOps.d.ts +13 -0
  169. package/dist/svelte/createVoiceOpsActionCenter.d.ts +10 -0
  170. package/dist/svelte/createVoiceOpsStatus.d.ts +9 -0
  171. package/dist/svelte/createVoicePlatformCoverage.d.ts +7 -0
  172. package/dist/svelte/createVoiceProofTrends.d.ts +7 -0
  173. package/dist/svelte/createVoiceProviderCapabilities.d.ts +10 -0
  174. package/dist/svelte/createVoiceProviderContracts.d.ts +10 -0
  175. package/dist/svelte/createVoiceProviderSimulationControls.d.ts +11 -0
  176. package/dist/svelte/createVoiceProviderStatus.d.ts +4 -2
  177. package/dist/svelte/createVoiceReadinessFailures.d.ts +7 -0
  178. package/dist/svelte/createVoiceRoutingStatus.d.ts +10 -0
  179. package/dist/svelte/createVoiceTraceTimeline.d.ts +10 -0
  180. package/dist/svelte/createVoiceTurnLatency.d.ts +11 -0
  181. package/dist/svelte/createVoiceTurnQuality.d.ts +10 -0
  182. package/dist/svelte/createVoiceWorkflowStatus.d.ts +8 -0
  183. package/dist/svelte/index.d.ts +17 -0
  184. package/dist/svelte/index.js +5337 -420
  185. package/dist/telephony/contract.d.ts +61 -0
  186. package/dist/telephony/matrix.d.ts +97 -0
  187. package/dist/telephony/plivo.d.ts +303 -0
  188. package/dist/telephony/security.d.ts +182 -0
  189. package/dist/telephony/telnyx.d.ts +291 -0
  190. package/dist/telephony/twilio.d.ts +135 -2
  191. package/dist/telephonyOutcome.d.ts +273 -0
  192. package/dist/testing/index.d.ts +1 -0
  193. package/dist/testing/index.js +2172 -76
  194. package/dist/testing/ioProviderSimulator.d.ts +41 -0
  195. package/dist/toolContract.d.ts +161 -0
  196. package/dist/toolRuntime.d.ts +50 -0
  197. package/dist/trace.d.ts +19 -1
  198. package/dist/traceDeliveryRoutes.d.ts +86 -0
  199. package/dist/traceTimeline.d.ts +97 -0
  200. package/dist/turnLatency.d.ts +95 -0
  201. package/dist/turnQuality.d.ts +94 -0
  202. package/dist/types.d.ts +116 -3
  203. package/dist/voiceMonitoring.d.ts +444 -0
  204. package/dist/vue/VoiceDeliveryRuntime.d.ts +30 -0
  205. package/dist/vue/VoiceOpsActionCenter.d.ts +13 -0
  206. package/dist/vue/VoiceOpsStatus.d.ts +30 -0
  207. package/dist/vue/VoicePlatformCoverage.d.ts +23 -0
  208. package/dist/vue/VoiceProofTrends.d.ts +21 -0
  209. package/dist/vue/VoiceProviderCapabilities.d.ts +51 -0
  210. package/dist/vue/VoiceProviderContracts.d.ts +21 -0
  211. package/dist/vue/VoiceProviderSimulationControls.d.ts +88 -0
  212. package/dist/vue/VoiceProviderStatus.d.ts +51 -0
  213. package/dist/vue/VoiceReadinessFailures.d.ts +21 -0
  214. package/dist/vue/VoiceRoutingStatus.d.ts +51 -0
  215. package/dist/vue/VoiceTurnLatency.d.ts +69 -0
  216. package/dist/vue/VoiceTurnQuality.d.ts +51 -0
  217. package/dist/vue/index.d.ts +30 -0
  218. package/dist/vue/index.js +5241 -56
  219. package/dist/vue/useVoiceAgentSquadStatus.d.ts +9 -0
  220. package/dist/vue/useVoiceCampaignDialerProof.d.ts +11 -0
  221. package/dist/vue/useVoiceController.d.ts +2 -1
  222. package/dist/vue/useVoiceDeliveryRuntime.d.ts +13 -0
  223. package/dist/vue/useVoiceLiveOps.d.ts +9 -0
  224. package/dist/vue/useVoiceOpsActionCenter.d.ts +11 -0
  225. package/dist/vue/useVoiceOpsStatus.d.ts +9 -0
  226. package/dist/vue/useVoicePlatformCoverage.d.ts +9 -0
  227. package/dist/vue/useVoiceProofTrends.d.ts +9 -0
  228. package/dist/vue/useVoiceProviderCapabilities.d.ts +9 -0
  229. package/dist/vue/useVoiceProviderContracts.d.ts +9 -0
  230. package/dist/vue/useVoiceProviderSimulationControls.d.ts +24 -0
  231. package/dist/vue/useVoiceProviderStatus.d.ts +1 -1
  232. package/dist/vue/useVoiceReadinessFailures.d.ts +837 -0
  233. package/dist/vue/useVoiceRoutingStatus.d.ts +8 -0
  234. package/dist/vue/useVoiceStream.d.ts +2 -1
  235. package/dist/vue/useVoiceTraceTimeline.d.ts +9 -0
  236. package/dist/vue/useVoiceTurnLatency.d.ts +10 -0
  237. package/dist/vue/useVoiceTurnQuality.d.ts +9 -0
  238. package/dist/vue/useVoiceWorkflowStatus.d.ts +9 -0
  239. package/dist/workflowContract.d.ts +91 -0
  240. package/package.json +4 -1
@@ -2105,6 +2105,11 @@ var serverMessageToAction = (message) => {
2105
2105
  sessionId: message.sessionId,
2106
2106
  type: "complete"
2107
2107
  };
2108
+ case "connection":
2109
+ return {
2110
+ reconnect: message.reconnect,
2111
+ type: "connection"
2112
+ };
2108
2113
  case "call_lifecycle":
2109
2114
  return {
2110
2115
  event: message.event,
@@ -2126,6 +2131,17 @@ var serverMessageToAction = (message) => {
2126
2131
  transcript: message.transcript,
2127
2132
  type: "partial"
2128
2133
  };
2134
+ case "replay":
2135
+ return {
2136
+ assistantTexts: message.assistantTexts,
2137
+ call: message.call,
2138
+ partial: message.partial,
2139
+ scenarioId: message.scenarioId,
2140
+ sessionId: message.sessionId,
2141
+ status: message.status,
2142
+ turns: message.turns,
2143
+ type: "replay"
2144
+ };
2129
2145
  case "session":
2130
2146
  return {
2131
2147
  sessionId: message.sessionId,
@@ -2143,6 +2159,412 @@ var serverMessageToAction = (message) => {
2143
2159
  }
2144
2160
  };
2145
2161
 
2162
+ // node_modules/@absolutejs/media/dist/index.js
2163
+ var formatLabel = (format) => `${format.container}/${format.encoding}/${String(format.sampleRateHz)}hz/${String(format.channels)}ch`;
2164
+ var formatMatches = (actual, expected) => actual.container === expected.container && actual.encoding === expected.encoding && actual.sampleRateHz === expected.sampleRateHz && actual.channels === expected.channels;
2165
+ var pushIssue = (issues, severity, code, message) => {
2166
+ issues.push({ code, message, severity });
2167
+ };
2168
+ var numericMetadata = (frame, key) => {
2169
+ const value = frame.metadata?.[key];
2170
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
2171
+ };
2172
+ var average3 = (values) => values.length === 0 ? undefined : values.reduce((total, value) => total + value, 0) / values.length;
2173
+ var max = (values) => values.length === 0 ? undefined : Math.max(...values);
2174
+ var min = (values) => values.length === 0 ? undefined : Math.min(...values);
2175
+ var numericStat = (stat, key) => {
2176
+ const value = stat[key];
2177
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
2178
+ };
2179
+ var booleanStat = (stat, key) => {
2180
+ const value = stat[key];
2181
+ return typeof value === "boolean" ? value : undefined;
2182
+ };
2183
+ var stringStat = (stat, key) => {
2184
+ const value = stat[key];
2185
+ return typeof value === "string" ? value : undefined;
2186
+ };
2187
+ var secondsToMs = (value) => value === undefined ? undefined : value * 1000;
2188
+ var normalizeWebRTCStat = (stat) => {
2189
+ const sample = {};
2190
+ for (const [key, value] of Object.entries(stat)) {
2191
+ if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
2192
+ sample[key] = value;
2193
+ }
2194
+ }
2195
+ return sample;
2196
+ };
2197
+ var buildMediaResamplingPlan = (input) => {
2198
+ const required = !formatMatches(input.inputFormat, input.outputFormat);
2199
+ return {
2200
+ inputFormat: input.inputFormat,
2201
+ outputFormat: input.outputFormat,
2202
+ ratio: input.outputFormat.sampleRateHz / input.inputFormat.sampleRateHz,
2203
+ required,
2204
+ status: input.inputFormat.container === input.outputFormat.container && input.inputFormat.encoding === input.outputFormat.encoding && input.inputFormat.channels === input.outputFormat.channels ? "pass" : "warn"
2205
+ };
2206
+ };
2207
+ var speechProbability = (frame) => {
2208
+ if (frame.metadata?.isSpeech === true) {
2209
+ return 1;
2210
+ }
2211
+ if (frame.metadata?.isSpeech === false) {
2212
+ return 0;
2213
+ }
2214
+ for (const key of ["speechProbability", "voiceProbability", "rms", "energy"]) {
2215
+ const value = numericMetadata(frame, key);
2216
+ if (value !== undefined) {
2217
+ return value;
2218
+ }
2219
+ }
2220
+ return 0;
2221
+ };
2222
+ var buildMediaVadReport = (input = {}) => {
2223
+ const frames = (input.frames ?? []).filter((frame) => frame.kind === "input-audio");
2224
+ const speechStartThreshold = input.speechStartThreshold ?? 0.6;
2225
+ const speechEndThreshold = input.speechEndThreshold ?? 0.35;
2226
+ const minSpeechFrames = input.minSpeechFrames ?? 1;
2227
+ const maxSilenceFrames = input.maxSilenceFrames ?? 1;
2228
+ const segments = [];
2229
+ let activeFrames = [];
2230
+ let silenceFrames = 0;
2231
+ const closeSegment = () => {
2232
+ if (activeFrames.length < minSpeechFrames) {
2233
+ activeFrames = [];
2234
+ silenceFrames = 0;
2235
+ return;
2236
+ }
2237
+ const first = activeFrames[0];
2238
+ const last = activeFrames.at(-1);
2239
+ if (!first) {
2240
+ return;
2241
+ }
2242
+ segments.push({
2243
+ durationMs: first.at !== undefined && last?.at !== undefined ? last.at - first.at + (last.durationMs ?? 0) : undefined,
2244
+ endAt: last?.at !== undefined ? last.at + (last.durationMs ?? 0) : undefined,
2245
+ frameCount: activeFrames.length,
2246
+ segmentId: `vad:${String(segments.length + 1)}`,
2247
+ sessionId: first.sessionId,
2248
+ startAt: first.at,
2249
+ turnId: first.turnId
2250
+ });
2251
+ activeFrames = [];
2252
+ silenceFrames = 0;
2253
+ };
2254
+ for (const frame of frames) {
2255
+ const probability = speechProbability(frame);
2256
+ if (activeFrames.length === 0) {
2257
+ if (probability >= speechStartThreshold) {
2258
+ activeFrames.push(frame);
2259
+ }
2260
+ continue;
2261
+ }
2262
+ activeFrames.push(frame);
2263
+ if (probability <= speechEndThreshold) {
2264
+ silenceFrames += 1;
2265
+ } else {
2266
+ silenceFrames = 0;
2267
+ }
2268
+ if (silenceFrames > maxSilenceFrames) {
2269
+ closeSegment();
2270
+ }
2271
+ }
2272
+ closeSegment();
2273
+ return {
2274
+ checkedAt: Date.now(),
2275
+ inputAudioFrames: frames.length,
2276
+ segments,
2277
+ status: frames.length === 0 ? "warn" : "pass"
2278
+ };
2279
+ };
2280
+ var buildMediaInterruptionReport = (input = {}) => {
2281
+ const issues = [];
2282
+ const interruptionFrames = (input.frames ?? []).filter((frame) => frame.kind === "interruption");
2283
+ const latenciesMs = interruptionFrames.map((frame) => frame.latencyMs).filter((latency) => typeof latency === "number");
2284
+ const maxInterruptionLatencyMs = input.maxInterruptionLatencyMs;
2285
+ if (interruptionFrames.length === 0) {
2286
+ pushIssue(issues, "warning", "media.interruption_missing", "No interruption frame was observed.");
2287
+ }
2288
+ if (maxInterruptionLatencyMs !== undefined && latenciesMs.some((latency) => latency > maxInterruptionLatencyMs)) {
2289
+ pushIssue(issues, "error", "media.interruption_latency", `Interruption latency exceeded ${String(maxInterruptionLatencyMs)}ms.`);
2290
+ }
2291
+ return {
2292
+ checkedAt: Date.now(),
2293
+ interruptionFrames: interruptionFrames.length,
2294
+ issues,
2295
+ latenciesMs,
2296
+ status: issues.some((issue) => issue.severity === "error") ? "fail" : issues.length > 0 ? "warn" : "pass"
2297
+ };
2298
+ };
2299
+ var buildMediaQualityReport = (input = {}) => {
2300
+ const frames = [...input.frames ?? []].sort((a, b) => (a.at ?? 0) - (b.at ?? 0));
2301
+ const audioFrames = frames.filter((frame) => frame.kind === "input-audio" || frame.kind === "assistant-audio");
2302
+ const inputAudioFrames = frames.filter((frame) => frame.kind === "input-audio");
2303
+ const assistantAudioFrames = frames.filter((frame) => frame.kind === "assistant-audio");
2304
+ const issues = [];
2305
+ const gapsMs = [];
2306
+ for (const [index, frame] of audioFrames.entries()) {
2307
+ const previous = audioFrames[index - 1];
2308
+ if (previous?.at === undefined || frame.at === undefined || previous.durationMs === undefined) {
2309
+ continue;
2310
+ }
2311
+ const gap = frame.at - (previous.at + previous.durationMs);
2312
+ if (gap > 0) {
2313
+ gapsMs.push(gap);
2314
+ }
2315
+ }
2316
+ const jitterMs = audioFrames.map((frame) => numericMetadata(frame, "jitterMs")).filter((value) => value !== undefined).at(-1) ?? max(gapsMs);
2317
+ const first = audioFrames.find((frame) => frame.at !== undefined);
2318
+ const last = audioFrames.toReversed().find((frame) => frame.at !== undefined);
2319
+ const durationMs = first?.at !== undefined && last?.at !== undefined ? last.at - first.at + (last.durationMs ?? 0) : undefined;
2320
+ const expectedDurationMs = audioFrames.length > 0 ? audioFrames.reduce((total, frame) => total + (frame.durationMs ?? 0), 0) : undefined;
2321
+ const timestampDriftMs = durationMs !== undefined && expectedDurationMs !== undefined ? Math.max(0, durationMs - expectedDurationMs) : undefined;
2322
+ const speechScores = inputAudioFrames.map(speechProbability);
2323
+ const speechFrames = speechScores.filter((score) => score >= 0.6).length;
2324
+ const silenceFrames = speechScores.filter((score) => score <= 0.35).length;
2325
+ const unknownSpeechFrames = Math.max(0, inputAudioFrames.length - speechFrames - silenceFrames);
2326
+ const speechRatio = inputAudioFrames.length === 0 ? 0 : speechFrames / inputAudioFrames.length;
2327
+ const silenceRatio = inputAudioFrames.length === 0 ? 0 : silenceFrames / inputAudioFrames.length;
2328
+ const levels = audioFrames.map((frame) => numericMetadata(frame, "level") ?? numericMetadata(frame, "rms") ?? numericMetadata(frame, "energy")).filter((value) => value !== undefined);
2329
+ const backpressureEvents = input.transport?.backpressureEvents ?? 0;
2330
+ const maxGapMs = input.maxGapMs;
2331
+ if (maxGapMs !== undefined && gapsMs.some((gap) => gap > maxGapMs)) {
2332
+ pushIssue(issues, "warning", "media.quality_gap", `Observed media gap above ${String(maxGapMs)}ms.`);
2333
+ }
2334
+ if (input.maxJitterMs !== undefined && jitterMs !== undefined && jitterMs > input.maxJitterMs) {
2335
+ pushIssue(issues, "warning", "media.quality_jitter", `Observed jitter ${String(jitterMs)}ms above ${String(input.maxJitterMs)}ms.`);
2336
+ }
2337
+ if (input.maxTimestampDriftMs !== undefined && timestampDriftMs !== undefined && timestampDriftMs > input.maxTimestampDriftMs) {
2338
+ pushIssue(issues, "warning", "media.quality_timestamp_drift", `Observed timestamp drift ${String(timestampDriftMs)}ms above ${String(input.maxTimestampDriftMs)}ms.`);
2339
+ }
2340
+ if (input.minSpeechRatio !== undefined && inputAudioFrames.length > 0 && speechRatio < input.minSpeechRatio) {
2341
+ pushIssue(issues, "warning", "media.quality_speech_ratio", `Observed speech ratio ${String(speechRatio)} below ${String(input.minSpeechRatio)}.`);
2342
+ }
2343
+ if (input.maxBackpressureEvents !== undefined && backpressureEvents > input.maxBackpressureEvents) {
2344
+ pushIssue(issues, "warning", "media.quality_backpressure", `Observed ${String(backpressureEvents)} backpressure event(s), above ${String(input.maxBackpressureEvents)}.`);
2345
+ }
2346
+ return {
2347
+ assistantAudioFrames: assistantAudioFrames.length,
2348
+ backpressureEvents,
2349
+ checkedAt: Date.now(),
2350
+ durationMs,
2351
+ gapCount: gapsMs.length,
2352
+ gapsMs,
2353
+ inputAudioFrames: inputAudioFrames.length,
2354
+ issues,
2355
+ jitterMs,
2356
+ levelAverage: average3(levels),
2357
+ levelMax: max(levels),
2358
+ levelMin: min(levels),
2359
+ silenceFrames,
2360
+ silenceRatio,
2361
+ speechFrames,
2362
+ speechRatio,
2363
+ status: issues.some((issue) => issue.severity === "error") ? "fail" : issues.length > 0 ? "warn" : "pass",
2364
+ timestampDriftMs,
2365
+ totalFrames: frames.length,
2366
+ unknownSpeechFrames
2367
+ };
2368
+ };
2369
+ var buildMediaWebRTCStatsReport = (input = {}) => {
2370
+ const stats = input.stats ?? [];
2371
+ const issues = [];
2372
+ const inbound = stats.filter((stat) => stat.type === "inbound-rtp" && stringStat(stat, "kind") !== "video");
2373
+ const outbound = stats.filter((stat) => stat.type === "outbound-rtp" && stringStat(stat, "kind") !== "video");
2374
+ const candidatePairs = stats.filter((stat) => stat.type === "candidate-pair");
2375
+ const audioTracks = stats.filter((stat) => (stat.type === "track" || stat.type === "media-source") && stringStat(stat, "kind") === "audio");
2376
+ const activeCandidatePairs = candidatePairs.filter((stat) => booleanStat(stat, "selected") === true || booleanStat(stat, "nominated") === true || stringStat(stat, "state") === "succeeded").length;
2377
+ const liveAudioTracks = audioTracks.filter((stat) => stringStat(stat, "readyState") !== "ended" && stringStat(stat, "trackState") !== "ended" && booleanStat(stat, "ended") !== true).length;
2378
+ const endedAudioTracks = audioTracks.filter((stat) => stringStat(stat, "readyState") === "ended" || stringStat(stat, "trackState") === "ended" || booleanStat(stat, "ended") === true).length;
2379
+ const inboundPackets = inbound.reduce((total, stat) => total + (numericStat(stat, "packetsReceived") ?? 0), 0);
2380
+ const outboundPackets = outbound.reduce((total, stat) => total + (numericStat(stat, "packetsSent") ?? 0), 0);
2381
+ const packetsLost = [...inbound, ...outbound].reduce((total, stat) => total + Math.max(0, numericStat(stat, "packetsLost") ?? 0), 0);
2382
+ const packetLossDenominator = inboundPackets + packetsLost;
2383
+ const packetLossRatio = packetLossDenominator === 0 ? 0 : packetsLost / packetLossDenominator;
2384
+ const bytesReceived = inbound.reduce((total, stat) => total + (numericStat(stat, "bytesReceived") ?? 0), 0);
2385
+ const bytesSent = outbound.reduce((total, stat) => total + (numericStat(stat, "bytesSent") ?? 0), 0);
2386
+ const roundTripTimeMs = max(candidatePairs.map((stat) => secondsToMs(numericStat(stat, "currentRoundTripTime") ?? numericStat(stat, "roundTripTime"))).filter((value) => value !== undefined));
2387
+ const jitterMs = max([...inbound, ...outbound].map((stat) => secondsToMs(numericStat(stat, "jitter"))).filter((value) => value !== undefined));
2388
+ const jitterBufferDelayMs = max(inbound.map((stat) => {
2389
+ const delay = numericStat(stat, "jitterBufferDelay");
2390
+ const emitted = numericStat(stat, "jitterBufferEmittedCount");
2391
+ return delay !== undefined && emitted !== undefined && emitted > 0 ? delay / emitted * 1000 : undefined;
2392
+ }).filter((value) => value !== undefined));
2393
+ const audioLevels = audioTracks.map((stat) => numericStat(stat, "audioLevel")).filter((value) => value !== undefined);
2394
+ if (input.requireConnectedCandidatePair && candidatePairs.length > 0 && activeCandidatePairs === 0) {
2395
+ pushIssue(issues, "error", "media.webrtc_candidate_pair_missing", "No active WebRTC candidate pair was observed.");
2396
+ }
2397
+ if (input.requireLiveAudioTrack && liveAudioTracks === 0) {
2398
+ pushIssue(issues, "error", "media.webrtc_audio_track_missing", "No live WebRTC audio track was observed.");
2399
+ }
2400
+ if (input.maxPacketLossRatio !== undefined && packetLossRatio > input.maxPacketLossRatio) {
2401
+ pushIssue(issues, "warning", "media.webrtc_packet_loss", `Observed WebRTC packet loss ratio ${String(packetLossRatio)} above ${String(input.maxPacketLossRatio)}.`);
2402
+ }
2403
+ if (input.maxRoundTripTimeMs !== undefined && roundTripTimeMs !== undefined && roundTripTimeMs > input.maxRoundTripTimeMs) {
2404
+ pushIssue(issues, "warning", "media.webrtc_round_trip_time", `Observed WebRTC RTT ${String(roundTripTimeMs)}ms above ${String(input.maxRoundTripTimeMs)}ms.`);
2405
+ }
2406
+ if (input.maxJitterMs !== undefined && jitterMs !== undefined && jitterMs > input.maxJitterMs) {
2407
+ pushIssue(issues, "warning", "media.webrtc_jitter", `Observed WebRTC jitter ${String(jitterMs)}ms above ${String(input.maxJitterMs)}ms.`);
2408
+ }
2409
+ return {
2410
+ activeCandidatePairs,
2411
+ audioLevelAverage: average3(audioLevels),
2412
+ bytesReceived,
2413
+ bytesSent,
2414
+ checkedAt: Date.now(),
2415
+ endedAudioTracks,
2416
+ inboundPackets,
2417
+ issues,
2418
+ jitterBufferDelayMs,
2419
+ jitterMs,
2420
+ liveAudioTracks,
2421
+ outboundPackets,
2422
+ packetLossRatio,
2423
+ packetsLost,
2424
+ roundTripTimeMs,
2425
+ status: issues.some((issue) => issue.severity === "error") ? "fail" : issues.length > 0 ? "warn" : "pass",
2426
+ totalStats: stats.length
2427
+ };
2428
+ };
2429
+ var collectMediaWebRTCStats = async (input) => {
2430
+ const report = await input.peerConnection.getStats(input.selector ?? null);
2431
+ return [...report.values()].map(normalizeWebRTCStat);
2432
+ };
2433
+ var collectMediaWebRTCStatsReport = async (input) => {
2434
+ const stats = await collectMediaWebRTCStats(input);
2435
+ return buildMediaWebRTCStatsReport({
2436
+ ...input,
2437
+ stats
2438
+ });
2439
+ };
2440
+ var buildMediaPipelineCalibrationReport = (input = {}) => {
2441
+ const frames = input.frames ?? [];
2442
+ const issues = [];
2443
+ const inputFrames = frames.filter((frame) => frame.kind === "input-audio");
2444
+ const assistantFrames = frames.filter((frame) => frame.kind === "assistant-audio");
2445
+ const turnCommitFrames = frames.filter((frame) => frame.kind === "turn-commit");
2446
+ const interruptionFrameRecords = frames.filter((frame) => frame.kind === "interruption");
2447
+ const traceLinkedFrames = frames.filter((frame) => frame.traceEventId).length;
2448
+ const backpressureFrames = frames.filter((frame) => Boolean(frame.metadata?.backpressure)).length;
2449
+ const audioLatencies = assistantFrames.map((frame) => frame.latencyMs).filter((latency) => typeof latency === "number");
2450
+ const firstAudioLatencyMs = audioLatencies.length > 0 ? Math.min(...audioLatencies) : undefined;
2451
+ const jitterValues = frames.map((frame) => numericMetadata(frame, "jitterMs")).filter((value) => value !== undefined);
2452
+ const jitterMs = jitterValues.length > 0 ? Math.max(...jitterValues) : undefined;
2453
+ const inputFormat = input.inputFormat ?? inputFrames.find((frame) => frame.format)?.format;
2454
+ const outputFormat = input.outputFormat ?? assistantFrames.find((frame) => frame.format)?.format;
2455
+ const resamplingRequired = Boolean(input.expectedInputFormat && inputFormat && inputFormat.sampleRateHz !== input.expectedInputFormat.sampleRateHz) || Boolean(input.expectedOutputFormat && outputFormat && outputFormat.sampleRateHz !== input.expectedOutputFormat.sampleRateHz);
2456
+ const resamplingTargetHz = resamplingRequired && input.expectedInputFormat ? input.expectedInputFormat.sampleRateHz : resamplingRequired ? input.expectedOutputFormat?.sampleRateHz : undefined;
2457
+ if (inputFrames.length === 0) {
2458
+ pushIssue(issues, "warning", "media.input_audio_missing", "No input audio frames were observed.");
2459
+ }
2460
+ if (assistantFrames.length === 0) {
2461
+ pushIssue(issues, "warning", "media.assistant_audio_missing", "No assistant audio frames were observed.");
2462
+ }
2463
+ if (input.expectedInputFormat && inputFormat && !formatMatches(inputFormat, input.expectedInputFormat)) {
2464
+ pushIssue(issues, inputFormat.sampleRateHz === input.expectedInputFormat.sampleRateHz ? "warning" : "error", "media.input_format_mismatch", `Input format ${formatLabel(inputFormat)} does not match expected ${formatLabel(input.expectedInputFormat)}.`);
2465
+ }
2466
+ if (input.expectedOutputFormat && outputFormat && !formatMatches(outputFormat, input.expectedOutputFormat)) {
2467
+ pushIssue(issues, outputFormat.sampleRateHz === input.expectedOutputFormat.sampleRateHz ? "warning" : "error", "media.output_format_mismatch", `Output format ${formatLabel(outputFormat)} does not match expected ${formatLabel(input.expectedOutputFormat)}.`);
2468
+ }
2469
+ if (firstAudioLatencyMs !== undefined && input.maxFirstAudioLatencyMs !== undefined && firstAudioLatencyMs > input.maxFirstAudioLatencyMs) {
2470
+ pushIssue(issues, "error", "media.first_audio_latency", `First audio latency ${String(firstAudioLatencyMs)}ms exceeds budget ${String(input.maxFirstAudioLatencyMs)}ms.`);
2471
+ }
2472
+ if (jitterMs !== undefined && input.maxJitterMs !== undefined && jitterMs > input.maxJitterMs) {
2473
+ pushIssue(issues, "warning", "media.jitter", `Media jitter ${String(jitterMs)}ms exceeds budget ${String(input.maxJitterMs)}ms.`);
2474
+ }
2475
+ if (input.maxBackpressureFrames !== undefined && backpressureFrames > input.maxBackpressureFrames) {
2476
+ pushIssue(issues, "warning", "media.backpressure", `Backpressure frame count ${String(backpressureFrames)} exceeds budget ${String(input.maxBackpressureFrames)}.`);
2477
+ }
2478
+ if (input.requireInterruptionFrame && interruptionFrameRecords.length === 0) {
2479
+ pushIssue(issues, "warning", "media.interruption_missing", "No interruption frame was observed.");
2480
+ }
2481
+ if (input.requireTraceEvidence && traceLinkedFrames === 0) {
2482
+ pushIssue(issues, "warning", "media.trace_evidence_missing", "No media frames were linked to trace evidence.");
2483
+ }
2484
+ return {
2485
+ assistantAudioFrames: assistantFrames.length,
2486
+ backpressureFrames,
2487
+ checkedAt: Date.now(),
2488
+ firstAudioLatencyMs,
2489
+ inputAudioFrames: inputFrames.length,
2490
+ inputFormat,
2491
+ interruptionFrames: interruptionFrameRecords.length,
2492
+ issues,
2493
+ jitterMs,
2494
+ outputFormat,
2495
+ resamplingRequired,
2496
+ resamplingTargetHz,
2497
+ status: issues.some((issue) => issue.severity === "error") ? "fail" : issues.length > 0 ? "warn" : "pass",
2498
+ surface: input.surface ?? "media-pipeline",
2499
+ traceLinkedFrames,
2500
+ turnCommitFrames: turnCommitFrames.length
2501
+ };
2502
+ };
2503
+
2504
+ // src/client/browserMedia.ts
2505
+ var DEFAULT_BROWSER_MEDIA_PATH = "/api/voice/browser-media";
2506
+ var DEFAULT_BROWSER_MEDIA_INTERVAL_MS = 5000;
2507
+ var resolvePeerConnection = async (options) => options.peerConnection ?? await options.getPeerConnection?.() ?? null;
2508
+ var postBrowserMediaReport = async (payload, options) => {
2509
+ const requestFetch = options.fetch ?? globalThis.fetch;
2510
+ if (!requestFetch) {
2511
+ return;
2512
+ }
2513
+ await requestFetch(options.path ?? DEFAULT_BROWSER_MEDIA_PATH, {
2514
+ body: JSON.stringify(payload),
2515
+ headers: {
2516
+ "Content-Type": "application/json"
2517
+ },
2518
+ keepalive: true,
2519
+ method: "POST"
2520
+ });
2521
+ };
2522
+ var createVoiceBrowserMediaReporter = (options) => {
2523
+ let interval = null;
2524
+ const reportOnce = async () => {
2525
+ const peerConnection = await resolvePeerConnection(options);
2526
+ if (!peerConnection) {
2527
+ return;
2528
+ }
2529
+ const report = await collectMediaWebRTCStatsReport({
2530
+ ...options,
2531
+ peerConnection
2532
+ });
2533
+ const payload = {
2534
+ at: Date.now(),
2535
+ report,
2536
+ scenarioId: options.getScenarioId?.() ?? null,
2537
+ sessionId: options.getSessionId?.() ?? null
2538
+ };
2539
+ options.onReport?.(payload);
2540
+ await postBrowserMediaReport(payload, options);
2541
+ return payload;
2542
+ };
2543
+ const run = () => {
2544
+ reportOnce().catch((error) => {
2545
+ options.onError?.(error);
2546
+ });
2547
+ };
2548
+ const stop = () => {
2549
+ if (interval) {
2550
+ clearInterval(interval);
2551
+ interval = null;
2552
+ }
2553
+ };
2554
+ return {
2555
+ close: stop,
2556
+ reportOnce,
2557
+ start: () => {
2558
+ if (interval) {
2559
+ return;
2560
+ }
2561
+ run();
2562
+ interval = setInterval(run, options.intervalMs ?? DEFAULT_BROWSER_MEDIA_INTERVAL_MS);
2563
+ },
2564
+ stop
2565
+ };
2566
+ };
2567
+
2146
2568
  // src/client/connection.ts
2147
2569
  var WS_OPEN = 1;
2148
2570
  var WS_CLOSED = 3;
@@ -2186,10 +2608,12 @@ var isVoiceServerMessage = (value) => {
2186
2608
  case "assistant":
2187
2609
  case "call_lifecycle":
2188
2610
  case "complete":
2611
+ case "connection":
2189
2612
  case "error":
2190
2613
  case "final":
2191
2614
  case "partial":
2192
2615
  case "pong":
2616
+ case "replay":
2193
2617
  case "session":
2194
2618
  case "turn":
2195
2619
  return true;
@@ -2226,6 +2650,9 @@ var createVoiceConnection = (path, options = {}) => {
2226
2650
  sessionId: options.sessionId ?? createSessionId(),
2227
2651
  ws: null
2228
2652
  };
2653
+ const emitConnection = (reconnect) => {
2654
+ listeners.forEach((listener) => listener(reconnect));
2655
+ };
2229
2656
  const clearTimers = () => {
2230
2657
  if (state.pingInterval) {
2231
2658
  clearInterval(state.pingInterval);
@@ -2248,9 +2675,28 @@ var createVoiceConnection = (path, options = {}) => {
2248
2675
  }
2249
2676
  };
2250
2677
  const scheduleReconnect = () => {
2678
+ const nextAttemptAt = Date.now() + RECONNECT_DELAY_MS;
2251
2679
  state.reconnectAttempts += 1;
2680
+ emitConnection({
2681
+ reconnect: {
2682
+ attempts: state.reconnectAttempts,
2683
+ lastDisconnectAt: Date.now(),
2684
+ maxAttempts: maxReconnectAttempts,
2685
+ nextAttemptAt,
2686
+ status: "reconnecting"
2687
+ },
2688
+ type: "connection"
2689
+ });
2252
2690
  state.reconnectTimeout = setTimeout(() => {
2253
2691
  if (state.reconnectAttempts > maxReconnectAttempts) {
2692
+ emitConnection({
2693
+ reconnect: {
2694
+ attempts: state.reconnectAttempts,
2695
+ maxAttempts: maxReconnectAttempts,
2696
+ status: "exhausted"
2697
+ },
2698
+ type: "connection"
2699
+ });
2254
2700
  return;
2255
2701
  }
2256
2702
  connect();
@@ -2260,9 +2706,21 @@ var createVoiceConnection = (path, options = {}) => {
2260
2706
  const ws = new WebSocket(buildWsUrl(path, state.sessionId, state.scenarioId));
2261
2707
  ws.binaryType = "arraybuffer";
2262
2708
  ws.onopen = () => {
2709
+ const wasReconnecting = state.reconnectAttempts > 0;
2263
2710
  state.isConnected = true;
2264
- state.reconnectAttempts = 0;
2265
2711
  flushPendingMessages();
2712
+ if (wasReconnecting) {
2713
+ emitConnection({
2714
+ reconnect: {
2715
+ attempts: state.reconnectAttempts,
2716
+ lastResumedAt: Date.now(),
2717
+ maxAttempts: maxReconnectAttempts,
2718
+ status: "resumed"
2719
+ },
2720
+ type: "connection"
2721
+ });
2722
+ state.reconnectAttempts = 0;
2723
+ }
2266
2724
  listeners.forEach((listener) => listener({
2267
2725
  scenarioId: state.scenarioId ?? undefined,
2268
2726
  sessionId: state.sessionId,
@@ -2292,6 +2750,16 @@ var createVoiceConnection = (path, options = {}) => {
2292
2750
  const reconnectable = shouldReconnect && event.code !== WS_NORMAL_CLOSURE && state.reconnectAttempts < maxReconnectAttempts;
2293
2751
  if (reconnectable) {
2294
2752
  scheduleReconnect();
2753
+ } else if (shouldReconnect && event.code !== WS_NORMAL_CLOSURE) {
2754
+ emitConnection({
2755
+ reconnect: {
2756
+ attempts: state.reconnectAttempts,
2757
+ lastDisconnectAt: Date.now(),
2758
+ maxAttempts: maxReconnectAttempts,
2759
+ status: "exhausted"
2760
+ },
2761
+ type: "connection"
2762
+ });
2295
2763
  }
2296
2764
  };
2297
2765
  state.ws = ws;
@@ -2362,6 +2830,11 @@ var createVoiceConnection = (path, options = {}) => {
2362
2830
  };
2363
2831
 
2364
2832
  // src/client/store.ts
2833
+ var createInitialReconnectState = () => ({
2834
+ attempts: 0,
2835
+ maxAttempts: 0,
2836
+ status: "idle"
2837
+ });
2365
2838
  var createInitialState2 = () => ({
2366
2839
  assistantAudio: [],
2367
2840
  assistantTexts: [],
@@ -2370,6 +2843,7 @@ var createInitialState2 = () => ({
2370
2843
  isConnected: false,
2371
2844
  scenarioId: null,
2372
2845
  partial: "",
2846
+ reconnect: createInitialReconnectState(),
2373
2847
  sessionId: null,
2374
2848
  status: "idle",
2375
2849
  turns: []
@@ -2426,7 +2900,19 @@ var createVoiceStreamStore = () => {
2426
2900
  case "connected":
2427
2901
  state = {
2428
2902
  ...state,
2429
- isConnected: true
2903
+ isConnected: true,
2904
+ reconnect: state.reconnect.status === "reconnecting" ? {
2905
+ ...state.reconnect,
2906
+ lastResumedAt: Date.now(),
2907
+ nextAttemptAt: undefined,
2908
+ status: "resumed"
2909
+ } : state.reconnect
2910
+ };
2911
+ break;
2912
+ case "connection":
2913
+ state = {
2914
+ ...state,
2915
+ reconnect: action.reconnect
2430
2916
  };
2431
2917
  break;
2432
2918
  case "disconnected":
@@ -2454,6 +2940,26 @@ var createVoiceStreamStore = () => {
2454
2940
  partial: action.transcript.text
2455
2941
  };
2456
2942
  break;
2943
+ case "replay":
2944
+ state = {
2945
+ ...state,
2946
+ assistantTexts: [...action.assistantTexts],
2947
+ call: action.call ?? null,
2948
+ error: null,
2949
+ isConnected: action.status === "active",
2950
+ partial: action.partial,
2951
+ reconnect: state.reconnect.status === "reconnecting" ? {
2952
+ ...state.reconnect,
2953
+ lastResumedAt: Date.now(),
2954
+ nextAttemptAt: undefined,
2955
+ status: "resumed"
2956
+ } : state.reconnect,
2957
+ scenarioId: action.scenarioId ?? state.scenarioId,
2958
+ sessionId: action.sessionId,
2959
+ status: action.status,
2960
+ turns: [...action.turns]
2961
+ };
2962
+ break;
2457
2963
  case "session":
2458
2964
  state = {
2459
2965
  ...state,
@@ -2491,20 +2997,50 @@ var createVoiceStreamStore = () => {
2491
2997
  var createVoiceStream = (path, options = {}) => {
2492
2998
  const connection = createVoiceConnection(path, options);
2493
2999
  const store = createVoiceStreamStore();
3000
+ const browserMediaReporter = options.browserMedia && typeof window !== "undefined" ? createVoiceBrowserMediaReporter({
3001
+ ...options.browserMedia,
3002
+ getScenarioId: () => options.browserMedia ? options.browserMedia.getScenarioId?.() ?? connection.getScenarioId() : connection.getScenarioId(),
3003
+ getSessionId: () => options.browserMedia ? options.browserMedia.getSessionId?.() ?? connection.getSessionId() : connection.getSessionId()
3004
+ }) : null;
2494
3005
  const subscribers = new Set;
2495
3006
  const start = (input) => Promise.resolve().then(() => {
2496
3007
  if (!input?.sessionId && !input?.scenarioId) {
2497
3008
  return;
2498
3009
  }
2499
3010
  connection.start(input);
3011
+ browserMediaReporter?.start();
2500
3012
  });
2501
3013
  const notify = () => {
2502
3014
  subscribers.forEach((subscriber) => subscriber());
2503
3015
  };
3016
+ const reportReconnect = () => {
3017
+ if (!options.reconnectReportPath || typeof fetch === "undefined") {
3018
+ return;
3019
+ }
3020
+ const snapshot = store.getSnapshot();
3021
+ const body = JSON.stringify({
3022
+ at: Date.now(),
3023
+ reconnect: snapshot.reconnect,
3024
+ scenarioId: snapshot.scenarioId,
3025
+ sessionId: connection.getSessionId(),
3026
+ turnIds: snapshot.turns.map((turn) => turn.id)
3027
+ });
3028
+ fetch(options.reconnectReportPath, {
3029
+ body,
3030
+ headers: {
3031
+ "Content-Type": "application/json"
3032
+ },
3033
+ keepalive: true,
3034
+ method: "POST"
3035
+ }).catch(() => {});
3036
+ };
2504
3037
  const unsubscribeConnection = connection.subscribe((message) => {
2505
3038
  const action = serverMessageToAction(message);
2506
3039
  if (action) {
2507
3040
  store.dispatch(action);
3041
+ if (message.type === "connection") {
3042
+ reportReconnect();
3043
+ }
2508
3044
  notify();
2509
3045
  }
2510
3046
  });
@@ -2514,6 +3050,7 @@ var createVoiceStream = (path, options = {}) => {
2514
3050
  },
2515
3051
  close() {
2516
3052
  unsubscribeConnection();
3053
+ browserMediaReporter?.close();
2517
3054
  connection.close();
2518
3055
  store.dispatch({ type: "disconnected" });
2519
3056
  notify();
@@ -2540,6 +3077,9 @@ var createVoiceStream = (path, options = {}) => {
2540
3077
  get partial() {
2541
3078
  return store.getSnapshot().partial;
2542
3079
  },
3080
+ get reconnect() {
3081
+ return store.getSnapshot().reconnect;
3082
+ },
2543
3083
  get sessionId() {
2544
3084
  return connection.getSessionId();
2545
3085
  },
@@ -2895,6 +3435,7 @@ var createInitialState3 = (stream) => ({
2895
3435
  isConnected: stream.isConnected,
2896
3436
  isRecording: false,
2897
3437
  partial: stream.partial,
3438
+ reconnect: stream.reconnect,
2898
3439
  recordingError: null,
2899
3440
  sessionId: stream.sessionId,
2900
3441
  scenarioId: stream.scenarioId,
@@ -2924,6 +3465,7 @@ var createVoiceController = (path, options = {}) => {
2924
3465
  error: stream.error,
2925
3466
  isConnected: stream.isConnected,
2926
3467
  partial: stream.partial,
3468
+ reconnect: stream.reconnect,
2927
3469
  sessionId: stream.sessionId,
2928
3470
  scenarioId: stream.scenarioId,
2929
3471
  status: stream.status,
@@ -2948,7 +3490,13 @@ var createVoiceController = (path, options = {}) => {
2948
3490
  capture = createMicrophoneCapture({
2949
3491
  channelCount: options.capture?.channelCount ?? preset.capture.channelCount,
2950
3492
  onLevel: options.capture?.onLevel,
2951
- onAudio: (audio) => stream.sendAudio(audio),
3493
+ onAudio: (audio) => {
3494
+ if (options.capture?.onAudio) {
3495
+ options.capture.onAudio(audio, stream.sendAudio);
3496
+ return;
3497
+ }
3498
+ stream.sendAudio(audio);
3499
+ },
2952
3500
  sampleRateHz: options.capture?.sampleRateHz ?? preset.capture.sampleRateHz
2953
3501
  });
2954
3502
  return capture;
@@ -3018,6 +3566,9 @@ var createVoiceController = (path, options = {}) => {
3018
3566
  get recordingError() {
3019
3567
  return state.recordingError;
3020
3568
  },
3569
+ get reconnect() {
3570
+ return state.reconnect;
3571
+ },
3021
3572
  sendAudio: (audio) => stream.sendAudio(audio),
3022
3573
  get sessionId() {
3023
3574
  return state.sessionId;
@@ -3063,11 +3614,26 @@ var DEFAULT_INTERRUPT_THRESHOLD = 0.08;
3063
3614
  var shouldInterruptForLevel = (level, options = {}) => (options.enabled ?? true) && level >= (options.interruptThreshold ?? DEFAULT_INTERRUPT_THRESHOLD);
3064
3615
  var bindVoiceBargeIn = (controller, player, options = {}) => {
3065
3616
  let lastPartial = controller.partial;
3066
- const interruptIfPlaying = () => {
3617
+ const interruptIfPlaying = (reason) => {
3067
3618
  if (!player.isPlaying || options.enabled === false) {
3619
+ options.monitor?.recordSkipped({
3620
+ reason,
3621
+ sessionId: controller.sessionId
3622
+ });
3068
3623
  return;
3069
3624
  }
3070
- player.interrupt();
3625
+ options.monitor?.recordRequested({
3626
+ reason,
3627
+ sessionId: controller.sessionId
3628
+ });
3629
+ player.interrupt().then(() => {
3630
+ options.monitor?.recordStopped({
3631
+ latencyMs: player.lastInterruptLatencyMs,
3632
+ playbackStopLatencyMs: player.lastPlaybackStopLatencyMs,
3633
+ reason,
3634
+ sessionId: controller.sessionId
3635
+ });
3636
+ });
3071
3637
  };
3072
3638
  const unsubscribe = controller.subscribe(() => {
3073
3639
  if (options.interruptOnPartial === false) {
@@ -3075,7 +3641,7 @@ var bindVoiceBargeIn = (controller, player, options = {}) => {
3075
3641
  return;
3076
3642
  }
3077
3643
  if (!lastPartial && controller.partial) {
3078
- interruptIfPlaying();
3644
+ interruptIfPlaying("partial-transcript");
3079
3645
  }
3080
3646
  lastPartial = controller.partial;
3081
3647
  });
@@ -3085,11 +3651,11 @@ var bindVoiceBargeIn = (controller, player, options = {}) => {
3085
3651
  },
3086
3652
  handleLevel: (level) => {
3087
3653
  if (shouldInterruptForLevel(level, options)) {
3088
- interruptIfPlaying();
3654
+ interruptIfPlaying("input-level");
3089
3655
  }
3090
3656
  },
3091
3657
  sendAudio: (audio) => {
3092
- interruptIfPlaying();
3658
+ interruptIfPlaying("manual-audio");
3093
3659
  controller.sendAudio(audio);
3094
3660
  }
3095
3661
  };
@@ -3119,7 +3685,17 @@ var createVoiceDuplexController = (path, options = {}) => {
3119
3685
  audioPlayer,
3120
3686
  close,
3121
3687
  interruptAssistant: async () => {
3688
+ options.bargeIn?.monitor?.recordRequested({
3689
+ reason: "manual-interrupt",
3690
+ sessionId: controller.sessionId
3691
+ });
3122
3692
  await audioPlayer.interrupt();
3693
+ options.bargeIn?.monitor?.recordStopped({
3694
+ latencyMs: audioPlayer.lastInterruptLatencyMs,
3695
+ playbackStopLatencyMs: audioPlayer.lastPlaybackStopLatencyMs,
3696
+ reason: "manual-interrupt",
3697
+ sessionId: controller.sessionId
3698
+ });
3123
3699
  },
3124
3700
  sendAudio: (audio) => {
3125
3701
  bargeInBinding?.sendAudio(audio);
@@ -3510,7 +4086,235 @@ var loadVoiceTestFixtures = async (fixtureDirectory) => {
3510
4086
  }
3511
4087
  return fixtures;
3512
4088
  };
4089
+ // src/testing/ioProviderSimulator.ts
4090
+ var defaultFailureMessage = (input) => `Simulated ${input.provider} ${input.kind.toUpperCase()} ${input.operation} failure.`;
4091
+ var resolveRecoveryElapsedMs = (value, provider) => {
4092
+ if (typeof value === "number") {
4093
+ return value;
4094
+ }
4095
+ return value?.[provider] ?? 25;
4096
+ };
4097
+ var createHealth = (input) => ({
4098
+ consecutiveFailures: input.status === "healthy" ? 0 : 1,
4099
+ lastFailureAt: input.status === "healthy" ? undefined : input.now,
4100
+ provider: input.provider,
4101
+ status: input.status,
4102
+ suppressedUntil: input.suppressedUntil
4103
+ });
4104
+ var resolveFallback = async (options, provider) => {
4105
+ const configured = typeof options.fallback === "function" ? await options.fallback(provider) : options.fallback;
4106
+ return (configured ?? options.providers).find((candidate) => candidate !== provider);
4107
+ };
4108
+ var createVoiceIOProviderFailureSimulator = (options) => {
4109
+ if (options.providers.length === 0) {
4110
+ throw new Error("At least one provider is required.");
4111
+ }
4112
+ const now = options.now ?? Date.now;
4113
+ const operation = options.operation ?? "open";
4114
+ const cooldownMs = Math.max(0, options.cooldownMs ?? 30000);
4115
+ const emit = async (event, input) => {
4116
+ await options.onProviderEvent?.(event, input);
4117
+ };
4118
+ const run = async (provider, mode) => {
4119
+ if (!options.providers.includes(provider)) {
4120
+ throw new Error(`${provider} is not configured for simulation.`);
4121
+ }
4122
+ const startedAt = now();
4123
+ const sessionId = options.sessionId?.({ mode, now: startedAt, provider }) ?? `${options.kind}-provider-sim-${startedAt}`;
4124
+ if (mode === "recovery") {
4125
+ await emit({
4126
+ at: startedAt,
4127
+ attempt: 0,
4128
+ elapsedMs: resolveRecoveryElapsedMs(options.recoveryElapsedMs, provider),
4129
+ kind: options.kind,
4130
+ latencyBudgetMs: options.latencyBudgets?.[provider],
4131
+ operation,
4132
+ provider,
4133
+ providerHealth: createHealth({
4134
+ now: startedAt,
4135
+ provider,
4136
+ status: "healthy"
4137
+ }),
4138
+ selectedProvider: provider,
4139
+ status: "success"
4140
+ }, { mode, provider, sessionId });
4141
+ return {
4142
+ mode,
4143
+ provider,
4144
+ sessionId,
4145
+ status: "simulated"
4146
+ };
4147
+ }
4148
+ const fallbackProvider = await resolveFallback(options, provider);
4149
+ const suppressedUntil = startedAt + cooldownMs;
4150
+ await emit({
4151
+ at: startedAt,
4152
+ attempt: 0,
4153
+ elapsedMs: options.failureElapsedMs ?? 10,
4154
+ error: (options.failureMessage ?? defaultFailureMessage)({
4155
+ kind: options.kind,
4156
+ operation,
4157
+ provider
4158
+ }),
4159
+ fallbackProvider,
4160
+ kind: options.kind,
4161
+ latencyBudgetMs: options.latencyBudgets?.[provider],
4162
+ operation,
4163
+ provider,
4164
+ providerHealth: createHealth({
4165
+ now: startedAt,
4166
+ provider,
4167
+ status: "suppressed",
4168
+ suppressedUntil
4169
+ }),
4170
+ selectedProvider: provider,
4171
+ status: "error",
4172
+ suppressedUntil
4173
+ }, { mode, provider, sessionId });
4174
+ if (fallbackProvider) {
4175
+ await emit({
4176
+ at: startedAt + 1,
4177
+ attempt: 1,
4178
+ elapsedMs: resolveRecoveryElapsedMs(options.recoveryElapsedMs, fallbackProvider),
4179
+ fallbackProvider,
4180
+ kind: options.kind,
4181
+ latencyBudgetMs: options.latencyBudgets?.[fallbackProvider],
4182
+ operation,
4183
+ provider: fallbackProvider,
4184
+ providerHealth: createHealth({
4185
+ now: startedAt + 1,
4186
+ provider: fallbackProvider,
4187
+ status: "healthy"
4188
+ }),
4189
+ selectedProvider: provider,
4190
+ status: "fallback"
4191
+ }, { mode, provider, sessionId });
4192
+ }
4193
+ return {
4194
+ fallbackProvider,
4195
+ mode,
4196
+ provider,
4197
+ sessionId,
4198
+ status: "simulated",
4199
+ suppressedUntil
4200
+ };
4201
+ };
4202
+ return {
4203
+ run
4204
+ };
4205
+ };
3513
4206
  // src/modelAdapters.ts
4207
+ var isVoiceProviderRoutingPolicyPreset = (value) => value === "balanced" || value === "cost-cap" || value === "cost-first" || value === "latency-first" || value === "quality-first";
4208
+ var resolveVoiceProviderRoutingPolicyPreset = (preset, options = {}) => {
4209
+ switch (preset) {
4210
+ case "balanced":
4211
+ return {
4212
+ fallbackMode: "provider-error",
4213
+ strategy: "balanced",
4214
+ weights: {
4215
+ cost: 1,
4216
+ latencyMs: 0.005,
4217
+ priority: 1,
4218
+ quality: 10,
4219
+ ...options.weights
4220
+ },
4221
+ ...options
4222
+ };
4223
+ case "cost-cap":
4224
+ return {
4225
+ fallbackMode: "provider-error",
4226
+ strategy: "prefer-cheapest",
4227
+ ...options
4228
+ };
4229
+ case "cost-first":
4230
+ return {
4231
+ fallbackMode: "provider-error",
4232
+ strategy: "prefer-cheapest",
4233
+ ...options
4234
+ };
4235
+ case "latency-first":
4236
+ return {
4237
+ fallbackMode: "provider-error",
4238
+ strategy: "prefer-fastest",
4239
+ ...options
4240
+ };
4241
+ case "quality-first":
4242
+ return {
4243
+ fallbackMode: "provider-error",
4244
+ strategy: "quality-first",
4245
+ ...options
4246
+ };
4247
+ }
4248
+ };
4249
+ var resolveVoiceProviderRoutingPolicy = (policy) => {
4250
+ if (!policy) {
4251
+ return;
4252
+ }
4253
+ if (typeof policy === "string") {
4254
+ return isVoiceProviderRoutingPolicyPreset(policy) ? resolveVoiceProviderRoutingPolicyPreset(policy) : {
4255
+ strategy: policy
4256
+ };
4257
+ }
4258
+ return policy;
4259
+ };
4260
+ var mergeDefinedProviderPolicyFields = (base, surface) => {
4261
+ const next = {
4262
+ ...base ?? {}
4263
+ };
4264
+ if (surface.allowProviders !== undefined) {
4265
+ next.allowProviders = surface.allowProviders;
4266
+ }
4267
+ if (surface.fallbackMode !== undefined) {
4268
+ next.fallbackMode = surface.fallbackMode;
4269
+ }
4270
+ if (surface.maxCost !== undefined) {
4271
+ next.maxCost = surface.maxCost;
4272
+ }
4273
+ if (surface.maxLatencyMs !== undefined) {
4274
+ next.maxLatencyMs = surface.maxLatencyMs;
4275
+ }
4276
+ if (surface.minQuality !== undefined) {
4277
+ next.minQuality = surface.minQuality;
4278
+ }
4279
+ if (surface.strategy !== undefined) {
4280
+ next.strategy = surface.strategy;
4281
+ }
4282
+ if (surface.weights !== undefined) {
4283
+ next.weights = {
4284
+ ...base?.weights ?? {},
4285
+ ...surface.weights
4286
+ };
4287
+ }
4288
+ return next;
4289
+ };
4290
+ var createVoiceProviderOrchestrationProfile = (options) => {
4291
+ const surfaceNames = Object.keys(options.surfaces);
4292
+ const defaultSurface = options.defaultSurface ?? surfaceNames[0];
4293
+ if (!defaultSurface || !options.surfaces[defaultSurface]) {
4294
+ throw new Error("Voice provider orchestration profile has no surfaces.");
4295
+ }
4296
+ return {
4297
+ defaultSurface,
4298
+ id: options.id,
4299
+ resolve: (surface = defaultSurface) => {
4300
+ const config = options.surfaces[surface];
4301
+ if (!config) {
4302
+ throw new Error(`Voice provider orchestration profile ${options.id} has no surface "${surface}".`);
4303
+ }
4304
+ const policy = mergeDefinedProviderPolicyFields(resolveVoiceProviderRoutingPolicy(config.policy), config);
4305
+ return {
4306
+ allowProviders: config.allowProviders,
4307
+ fallback: config.fallback,
4308
+ fallbackMode: config.fallbackMode,
4309
+ policy,
4310
+ providerHealth: config.providerHealth,
4311
+ providerProfiles: config.providerProfiles,
4312
+ timeoutMs: config.timeoutMs
4313
+ };
4314
+ },
4315
+ surfaces: options.surfaces
4316
+ };
4317
+ };
3514
4318
  var OUTPUT_SCHEMA = {
3515
4319
  additionalProperties: false,
3516
4320
  properties: {
@@ -3678,19 +4482,23 @@ var createJSONVoiceAssistantModel = (options) => ({
3678
4482
  var createVoiceProviderRouter = (options) => {
3679
4483
  const providerIds = Object.keys(options.providers);
3680
4484
  const firstProvider = providerIds[0];
3681
- const policy = typeof options.policy === "string" ? {
3682
- strategy: options.policy
3683
- } : options.policy;
4485
+ const orchestrationSurface = options.orchestrationProfile?.resolve(options.orchestrationSurface);
4486
+ const policy = resolveVoiceProviderRoutingPolicy(options.policy) ?? resolveVoiceProviderRoutingPolicy(orchestrationSurface?.policy);
3684
4487
  const strategy = policy?.strategy ?? "prefer-selected";
3685
- const fallbackMode = policy?.fallbackMode ?? options.fallbackMode ?? "provider-error";
3686
- const healthOptions = typeof options.providerHealth === "object" ? options.providerHealth : options.providerHealth ? {} : undefined;
4488
+ const fallbackMode = policy?.fallbackMode ?? options.fallbackMode ?? orchestrationSurface?.fallbackMode ?? "provider-error";
4489
+ const providerProfiles = {
4490
+ ...orchestrationSurface?.providerProfiles ?? {},
4491
+ ...options.providerProfiles ?? {}
4492
+ };
4493
+ const providerHealthOption = options.providerHealth ?? orchestrationSurface?.providerHealth;
4494
+ const healthOptions = typeof providerHealthOption === "object" ? providerHealthOption : providerHealthOption ? {} : undefined;
3687
4495
  const healthState = new Map;
3688
4496
  const now = () => healthOptions?.now?.() ?? Date.now();
3689
4497
  const failureThreshold = Math.max(1, healthOptions?.failureThreshold ?? 1);
3690
4498
  const cooldownMs = Math.max(0, healthOptions?.cooldownMs ?? 30000);
3691
4499
  const rateLimitCooldownMs = Math.max(0, healthOptions?.rateLimitCooldownMs ?? 60000);
3692
4500
  const getProviderTimeoutMs = (provider) => {
3693
- const timeoutMs = options.providerProfiles?.[provider]?.timeoutMs ?? options.timeoutMs;
4501
+ const timeoutMs = providerProfiles[provider]?.timeoutMs ?? options.timeoutMs ?? orchestrationSurface?.timeoutMs;
3694
4502
  return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : undefined;
3695
4503
  };
3696
4504
  const getHealth = (provider) => {
@@ -3756,17 +4564,44 @@ var createVoiceProviderRouter = (options) => {
3756
4564
  return cloneHealth(provider);
3757
4565
  };
3758
4566
  const resolveAllowedProviders = async (input) => {
3759
- const allowProviders = policy?.allowProviders ?? options.allowProviders;
4567
+ const allowProviders = policy?.allowProviders ?? options.allowProviders ?? orchestrationSurface?.allowProviders;
3760
4568
  const allowed = typeof allowProviders === "function" ? await allowProviders(input) : allowProviders;
3761
4569
  return new Set(allowed ?? providerIds);
3762
4570
  };
4571
+ const passesBudgetFilters = (provider) => {
4572
+ const profile = providerProfiles[provider];
4573
+ if (typeof policy?.maxCost === "number" && typeof profile?.cost === "number" && profile.cost > policy.maxCost) {
4574
+ return false;
4575
+ }
4576
+ if (typeof policy?.maxLatencyMs === "number" && typeof profile?.latencyMs === "number" && profile.latencyMs > policy.maxLatencyMs) {
4577
+ return false;
4578
+ }
4579
+ if (typeof policy?.minQuality === "number" && typeof profile?.quality === "number" && profile.quality < policy.minQuality) {
4580
+ return false;
4581
+ }
4582
+ return true;
4583
+ };
4584
+ const getBalancedScore = (provider) => {
4585
+ const profile = providerProfiles[provider];
4586
+ if (policy?.scoreProvider) {
4587
+ return policy.scoreProvider(provider, profile);
4588
+ }
4589
+ const weights = policy?.weights ?? {};
4590
+ return (profile?.cost ?? Number.MAX_SAFE_INTEGER) * (weights.cost ?? 1) + (profile?.latencyMs ?? Number.MAX_SAFE_INTEGER) * (weights.latencyMs ?? 0.005) + (profile?.priority ?? 0) * (weights.priority ?? 1) - (profile?.quality ?? 0) * (weights.quality ?? 10);
4591
+ };
3763
4592
  const sortProviders = (providers) => {
3764
- if (strategy !== "prefer-cheapest" && strategy !== "prefer-fastest") {
4593
+ if (strategy !== "prefer-cheapest" && strategy !== "prefer-fastest" && strategy !== "quality-first" && strategy !== "balanced") {
3765
4594
  return providers;
3766
4595
  }
3767
4596
  return [...providers].sort((left, right) => {
3768
- const leftProfile = options.providerProfiles?.[left];
3769
- const rightProfile = options.providerProfiles?.[right];
4597
+ const leftProfile = providerProfiles[left];
4598
+ const rightProfile = providerProfiles[right];
4599
+ if (strategy === "quality-first") {
4600
+ return (rightProfile?.quality ?? Number.MIN_SAFE_INTEGER) - (leftProfile?.quality ?? Number.MIN_SAFE_INTEGER) || (leftProfile?.priority ?? Number.MAX_SAFE_INTEGER) - (rightProfile?.priority ?? Number.MAX_SAFE_INTEGER) || (leftProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER) - (rightProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER) || (leftProfile?.cost ?? Number.MAX_SAFE_INTEGER) - (rightProfile?.cost ?? Number.MAX_SAFE_INTEGER);
4601
+ }
4602
+ if (strategy === "balanced") {
4603
+ return getBalancedScore(left) - getBalancedScore(right);
4604
+ }
3770
4605
  const leftValue = strategy === "prefer-cheapest" ? leftProfile?.cost ?? Number.MAX_SAFE_INTEGER : leftProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER;
3771
4606
  const rightValue = strategy === "prefer-cheapest" ? rightProfile?.cost ?? Number.MAX_SAFE_INTEGER : rightProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER;
3772
4607
  return leftValue - rightValue || (leftProfile?.priority ?? Number.MAX_SAFE_INTEGER) - (rightProfile?.priority ?? Number.MAX_SAFE_INTEGER);
@@ -3775,13 +4610,15 @@ var createVoiceProviderRouter = (options) => {
3775
4610
  const resolveOrder = async (input) => {
3776
4611
  const selectedProvider = await options.selectProvider?.(input);
3777
4612
  const allowedProviders = await resolveAllowedProviders(input);
3778
- const fallbackOrder = typeof options.fallback === "function" ? await options.fallback(input) : options.fallback;
3779
- const rankedProviders = sortProviders([
4613
+ const fallbackSource = options.fallback ?? orchestrationSurface?.fallback;
4614
+ const fallbackOrder = typeof fallbackSource === "function" ? await fallbackSource(input) : fallbackSource;
4615
+ const allowedRankedProviders = sortProviders([
3780
4616
  ...fallbackOrder ?? providerIds
3781
4617
  ]).filter((provider) => allowedProviders.has(provider));
4618
+ const rankedProviders = allowedRankedProviders.filter(passesBudgetFilters);
3782
4619
  const healthyRankedProviders = healthOptions ? rankedProviders.filter((provider) => !isSuppressed(provider)) : rankedProviders;
3783
4620
  const candidateRankedProviders = healthyRankedProviders.length ? healthyRankedProviders : rankedProviders;
3784
- const preferred = selectedProvider && allowedProviders.has(selectedProvider) && (!healthOptions || !isSuppressed(selectedProvider)) ? selectedProvider : candidateRankedProviders[0] ?? firstProvider;
4621
+ const preferred = selectedProvider && allowedProviders.has(selectedProvider) && passesBudgetFilters(selectedProvider) && (!healthOptions || !isSuppressed(selectedProvider)) ? selectedProvider : candidateRankedProviders[0] ?? firstProvider;
3785
4622
  const seen = new Set;
3786
4623
  const order = [];
3787
4624
  const candidates = strategy === "ordered" ? candidateRankedProviders : [
@@ -4497,7 +5334,7 @@ var createVoiceMemoryStore = () => {
4497
5334
  };
4498
5335
 
4499
5336
  // src/session.ts
4500
- import { Buffer } from "buffer";
5337
+ import { Buffer as Buffer2 } from "buffer";
4501
5338
 
4502
5339
  // src/handoff.ts
4503
5340
  var toHex = (bytes) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
@@ -4816,6 +5653,12 @@ var DEFAULT_FORMAT = {
4816
5653
  encoding: "pcm_s16le",
4817
5654
  sampleRateHz: 16000
4818
5655
  };
5656
+ var DEFAULT_REALTIME_FORMAT = {
5657
+ channels: 1,
5658
+ container: "raw",
5659
+ encoding: "pcm_s16le",
5660
+ sampleRateHz: 24000
5661
+ };
4819
5662
  var toError = (value) => value instanceof Error ? value : new Error(String(value));
4820
5663
  var createEmptyCurrentTurn = () => ({
4821
5664
  finalText: "",
@@ -4828,7 +5671,7 @@ var createEmptyCurrentTurn = () => ({
4828
5671
  transcripts: []
4829
5672
  });
4830
5673
  var cloneTranscript = (transcript) => ({ ...transcript });
4831
- var encodeBase64 = (chunk) => Buffer.from(chunk).toString("base64");
5674
+ var encodeBase64 = (chunk) => Buffer2.from(chunk).toString("base64");
4832
5675
  var countWords2 = (text) => text.trim().split(/\s+/).filter(Boolean).length;
4833
5676
  var normalizeText2 = (text) => text.trim().replace(/\s+/g, " ");
4834
5677
  var getAudioChunkDurationMs = (chunk) => chunk.byteLength / (DEFAULT_FORMAT.sampleRateHz * DEFAULT_FORMAT.channels * 2) * 1000;
@@ -4999,7 +5842,7 @@ var createVoiceSession = (options) => {
4999
5842
  } : undefined;
5000
5843
  const appendTrace = async (input) => {
5001
5844
  await options.trace?.append({
5002
- at: Date.now(),
5845
+ at: input.at ?? Date.now(),
5003
5846
  metadata: input.metadata,
5004
5847
  payload: input.payload,
5005
5848
  scenarioId: input.session?.scenarioId ?? options.scenarioId,
@@ -5008,6 +5851,13 @@ var createVoiceSession = (options) => {
5008
5851
  type: input.type
5009
5852
  });
5010
5853
  };
5854
+ const appendTurnLatencyStage = async (input) => appendTrace({
5855
+ at: input.at,
5856
+ payload: { stage: input.stage },
5857
+ session: input.session,
5858
+ turnId: input.turnId,
5859
+ type: "turn_latency.stage"
5860
+ });
5011
5861
  const phraseHints = options.phraseHints ?? [];
5012
5862
  const lexicon = options.lexicon ?? [];
5013
5863
  let socket = options.socket;
@@ -5086,6 +5936,18 @@ var createVoiceSession = (options) => {
5086
5936
  type: "call_lifecycle"
5087
5937
  });
5088
5938
  };
5939
+ const sendReplay = async (session) => {
5940
+ await send({
5941
+ assistantTexts: session.turns.flatMap((turn) => turn.assistantText ? [turn.assistantText] : []),
5942
+ call: session.call,
5943
+ partial: session.currentTurn.partialText,
5944
+ scenarioId: session.scenarioId,
5945
+ sessionId: options.id,
5946
+ status: session.status,
5947
+ turns: session.turns,
5948
+ type: "replay"
5949
+ });
5950
+ };
5089
5951
  const runHandoff = async (input) => {
5090
5952
  const queuedDelivery = options.handoff?.deliveryQueue ? createVoiceHandoffDeliveryRecord({
5091
5953
  action: input.action,
@@ -5189,6 +6051,23 @@ var createVoiceSession = (options) => {
5189
6051
  });
5190
6052
  }
5191
6053
  };
6054
+ const sendAssistantAudio = async (chunk, input) => {
6055
+ const normalizedChunk = chunk instanceof Uint8Array ? new Uint8Array(chunk) : chunk instanceof ArrayBuffer ? new Uint8Array(chunk.slice(0)) : new Uint8Array(chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength));
6056
+ await send({
6057
+ chunkBase64: encodeBase64(normalizedChunk),
6058
+ format: input.format,
6059
+ receivedAt: input.receivedAt,
6060
+ turnId: activeTTSTurnId,
6061
+ type: "audio"
6062
+ });
6063
+ if (activeTTSTurnId) {
6064
+ await appendTurnLatencyStage({
6065
+ at: input.receivedAt,
6066
+ stage: "assistant_audio_received",
6067
+ turnId: activeTTSTurnId
6068
+ });
6069
+ }
6070
+ };
5192
6071
  const scheduleTurnCommit = (delayMs, reason, reset = true) => {
5193
6072
  if (!reset && silenceTimer) {
5194
6073
  return;
@@ -5890,8 +6769,12 @@ var createVoiceSession = (options) => {
5890
6769
  if (sttSession) {
5891
6770
  return sttSession;
5892
6771
  }
5893
- const openedSession = await options.stt.open({
5894
- format: DEFAULT_FORMAT,
6772
+ const inputAdapter = options.realtime ?? options.stt;
6773
+ if (!inputAdapter) {
6774
+ throw new Error("Voice session requires either an stt or realtime adapter.");
6775
+ }
6776
+ const openedSession = await inputAdapter.open({
6777
+ format: options.realtime ? options.realtimeInputFormat ?? DEFAULT_REALTIME_FORMAT : DEFAULT_FORMAT,
5895
6778
  languageStrategy: options.languageStrategy,
5896
6779
  lexicon,
5897
6780
  phraseHints,
@@ -5926,6 +6809,16 @@ var createVoiceSession = (options) => {
5926
6809
  openedSession.on("close", (event) => {
5927
6810
  runAdapterEvent("adapter.close", () => handleClose(event));
5928
6811
  });
6812
+ if (options.realtime) {
6813
+ openedSession.on("audio", ({ chunk, format, receivedAt }) => {
6814
+ runAdapterEvent("adapter.audio", async () => {
6815
+ await sendAssistantAudio(chunk, {
6816
+ format,
6817
+ receivedAt
6818
+ });
6819
+ });
6820
+ });
6821
+ }
5929
6822
  return openedSession;
5930
6823
  };
5931
6824
  const ensureTTSSession = async () => {
@@ -5950,13 +6843,9 @@ var createVoiceSession = (options) => {
5950
6843
  if (ttsSession !== openedSession) {
5951
6844
  return;
5952
6845
  }
5953
- const normalizedChunk = chunk instanceof Uint8Array ? new Uint8Array(chunk) : chunk instanceof ArrayBuffer ? new Uint8Array(chunk.slice(0)) : new Uint8Array(chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength));
5954
- await send({
5955
- chunkBase64: encodeBase64(normalizedChunk),
6846
+ await sendAssistantAudio(chunk, {
5956
6847
  format,
5957
- receivedAt,
5958
- turnId: activeTTSTurnId,
5959
- type: "audio"
6848
+ receivedAt
5960
6849
  });
5961
6850
  });
5962
6851
  });
@@ -6000,9 +6889,32 @@ var createVoiceSession = (options) => {
6000
6889
  });
6001
6890
  };
6002
6891
  const completeTurn = async (session, turn) => {
6892
+ const liveOpsControl = await options.liveOps?.getControl(options.id);
6893
+ if (liveOpsControl?.assistantPaused || liveOpsControl?.operatorTakeover) {
6894
+ await appendTrace({
6895
+ metadata: {
6896
+ source: "voice-live-ops"
6897
+ },
6898
+ payload: {
6899
+ action: "turn.skipped",
6900
+ control: liveOpsControl,
6901
+ reason: liveOpsControl.operatorTakeover ? "operator-takeover" : "assistant-paused",
6902
+ status: "skipped"
6903
+ },
6904
+ session,
6905
+ turnId: turn.id,
6906
+ type: "operator.action"
6907
+ });
6908
+ return;
6909
+ }
6910
+ const injectedInstruction = liveOpsControl?.injectedInstruction?.trim();
6003
6911
  const committedOutput = await options.route.onTurn({
6004
6912
  api,
6005
6913
  context: options.context,
6914
+ liveOps: liveOpsControl ? {
6915
+ control: liveOpsControl,
6916
+ injectedInstruction
6917
+ } : undefined,
6006
6918
  session,
6007
6919
  turn
6008
6920
  });
@@ -6016,6 +6928,7 @@ var createVoiceSession = (options) => {
6016
6928
  voicemail: committedOutput?.voicemail
6017
6929
  };
6018
6930
  if (output?.assistantText) {
6931
+ const assistantTextStartedAt = Date.now();
6019
6932
  await writeSession((currentSession) => {
6020
6933
  setTurnResult(currentSession, turn.id, {
6021
6934
  assistantText: output.assistantText
@@ -6026,10 +6939,17 @@ var createVoiceSession = (options) => {
6026
6939
  turnId: turn.id,
6027
6940
  type: "assistant"
6028
6941
  });
6942
+ await appendTurnLatencyStage({
6943
+ at: assistantTextStartedAt,
6944
+ session,
6945
+ stage: "assistant_text_started",
6946
+ turnId: turn.id
6947
+ });
6029
6948
  await appendTrace({
6030
6949
  payload: {
6031
6950
  text: output.assistantText,
6032
- ttsConfigured: Boolean(options.tts)
6951
+ ttsConfigured: Boolean(options.tts),
6952
+ realtimeConfigured: Boolean(options.realtime)
6033
6953
  },
6034
6954
  session,
6035
6955
  turnId: turn.id,
@@ -6040,7 +6960,18 @@ var createVoiceSession = (options) => {
6040
6960
  if (activeTTSSession) {
6041
6961
  const ttsStartedAt = Date.now();
6042
6962
  activeTTSTurnId = turn.id;
6963
+ await appendTurnLatencyStage({
6964
+ at: ttsStartedAt,
6965
+ session,
6966
+ stage: "tts_send_started",
6967
+ turnId: turn.id
6968
+ });
6043
6969
  await activeTTSSession.send(output.assistantText);
6970
+ await appendTurnLatencyStage({
6971
+ session,
6972
+ stage: "tts_send_completed",
6973
+ turnId: turn.id
6974
+ });
6044
6975
  await appendTrace({
6045
6976
  payload: {
6046
6977
  elapsedMs: Date.now() - ttsStartedAt,
@@ -6050,17 +6981,43 @@ var createVoiceSession = (options) => {
6050
6981
  turnId: turn.id,
6051
6982
  type: "turn.assistant"
6052
6983
  });
6053
- }
6054
- } catch (error) {
6055
- logger.warn("voice tts send failed", {
6056
- error: toError(error).message,
6057
- sessionId: options.id,
6058
- turnId: turn.id
6984
+ } else if (options.realtime) {
6985
+ const activeRealtimeSession = await ensureAdapter();
6986
+ const realtimeStartedAt = Date.now();
6987
+ activeTTSTurnId = turn.id;
6988
+ await appendTurnLatencyStage({
6989
+ at: realtimeStartedAt,
6990
+ session,
6991
+ stage: "tts_send_started",
6992
+ turnId: turn.id
6993
+ });
6994
+ await activeRealtimeSession.send(output.assistantText);
6995
+ await appendTurnLatencyStage({
6996
+ session,
6997
+ stage: "tts_send_completed",
6998
+ turnId: turn.id
6999
+ });
7000
+ await appendTrace({
7001
+ payload: {
7002
+ elapsedMs: Date.now() - realtimeStartedAt,
7003
+ mode: "realtime",
7004
+ status: "sent"
7005
+ },
7006
+ session,
7007
+ turnId: turn.id,
7008
+ type: "turn.assistant"
7009
+ });
7010
+ }
7011
+ } catch (error) {
7012
+ logger.warn("voice assistant audio send failed", {
7013
+ error: toError(error).message,
7014
+ sessionId: options.id,
7015
+ turnId: turn.id
6059
7016
  });
6060
7017
  await appendTrace({
6061
7018
  payload: {
6062
7019
  error: toError(error).message,
6063
- status: "tts-send-failed"
7020
+ status: options.realtime ? "realtime-send-failed" : "tts-send-failed"
6064
7021
  },
6065
7022
  session,
6066
7023
  turnId: turn.id,
@@ -6237,11 +7194,35 @@ var createVoiceSession = (options) => {
6237
7194
  turnId: turn.id,
6238
7195
  type: "turn.cost"
6239
7196
  });
7197
+ const firstTranscriptAt = turn.transcripts.map((transcript) => transcript.endedAtMs ?? transcript.startedAtMs).filter((value) => typeof value === "number").sort((left, right) => left - right)[0];
7198
+ const finalTranscriptAt = turn.transcripts.filter((transcript) => transcript.isFinal).map((transcript) => transcript.endedAtMs ?? transcript.startedAtMs).filter((value) => typeof value === "number").sort((left, right) => left - right)[0];
7199
+ if (firstTranscriptAt !== undefined) {
7200
+ await appendTurnLatencyStage({
7201
+ at: firstTranscriptAt,
7202
+ session: updatedSession,
7203
+ stage: "speech_detected",
7204
+ turnId: turn.id
7205
+ });
7206
+ }
7207
+ if (finalTranscriptAt !== undefined) {
7208
+ await appendTurnLatencyStage({
7209
+ at: finalTranscriptAt,
7210
+ session: updatedSession,
7211
+ stage: "final_transcript",
7212
+ turnId: turn.id
7213
+ });
7214
+ }
7215
+ await appendTurnLatencyStage({
7216
+ at: turn.committedAt,
7217
+ session: updatedSession,
7218
+ stage: "turn_committed",
7219
+ turnId: turn.id
7220
+ });
6240
7221
  await send({
6241
7222
  turn,
6242
7223
  type: "turn"
6243
7224
  });
6244
- if (options.sttLifecycle === "turn-scoped") {
7225
+ if (options.stt && options.sttLifecycle === "turn-scoped") {
6245
7226
  await closeAdapter("turn-commit");
6246
7227
  }
6247
7228
  await completeTurn(updatedSession, turn);
@@ -6304,6 +7285,7 @@ var createVoiceSession = (options) => {
6304
7285
  scenarioId: session.scenarioId,
6305
7286
  type: "session"
6306
7287
  });
7288
+ await sendReplay(session);
6307
7289
  if (shouldFireOnSession) {
6308
7290
  await options.route.onCallStart?.({
6309
7291
  api,
@@ -7237,16 +8219,16 @@ var renderVoiceCallReviewHTML = (artifact) => {
7237
8219
  </html>`;
7238
8220
  };
7239
8221
  // src/testing/sessionBenchmark.ts
7240
- var average3 = (values) => values.length > 0 ? values.reduce((sum, value) => sum + value, 0) / values.length : 0;
8222
+ var average4 = (values) => values.length > 0 ? values.reduce((sum, value) => sum + value, 0) / values.length : 0;
7241
8223
  var normalizeTurnText = (value) => value.toLowerCase().replace(/[^\p{L}\p{N}\s']/gu, " ").replace(/\s+/g, " ").trim();
7242
8224
  var countPassedTurns = (turnResults) => turnResults.reduce((count, result) => count + (result.passes ? 1 : 0), 0);
7243
8225
  var calculateTurnPassRate = (turnResults) => turnResults.length > 0 ? countPassedTurns(turnResults) / turnResults.length : 0;
7244
8226
  var summarizeScenarioCosts = (turnResults) => {
7245
8227
  const costEstimates = turnResults.map((turn) => turn.quality?.cost).filter((value) => value !== undefined);
7246
8228
  return {
7247
- averageRelativeCostUnits: roundMetric5(average3(costEstimates.map((estimate) => estimate.estimatedRelativeCostUnits))),
7248
- fallbackReplayAudioMs: roundMetric5(average3(costEstimates.map((estimate) => estimate.fallbackReplayAudioMs)), 2),
7249
- primaryAudioMs: roundMetric5(average3(costEstimates.map((estimate) => estimate.primaryAudioMs)), 2)
8229
+ averageRelativeCostUnits: roundMetric5(average4(costEstimates.map((estimate) => estimate.estimatedRelativeCostUnits))),
8230
+ fallbackReplayAudioMs: roundMetric5(average4(costEstimates.map((estimate) => estimate.fallbackReplayAudioMs)), 2),
8231
+ primaryAudioMs: roundMetric5(average4(costEstimates.map((estimate) => estimate.primaryAudioMs)), 2)
7250
8232
  };
7251
8233
  };
7252
8234
  var roundMetric5 = (value, digits = 4) => {
@@ -7565,13 +8547,13 @@ var summarizeVoiceSessionBenchmark = (adapterId, scenarios) => {
7565
8547
  const turnAccuracies = scenarios.flatMap((scenario) => scenario.turnResults.map((turn) => turn.accuracy?.wordErrorRate).filter((value) => typeof value === "number"));
7566
8548
  return {
7567
8549
  adapterId,
7568
- averageElapsedMs: roundMetric5(average3(scenarios.map((scenario) => scenario.elapsedMs)), 2),
7569
- averageFallbackReplayAudioMs: roundMetric5(average3(scenarios.map((scenario) => scenario.fallbackReplayAudioMs)), 2),
7570
- averagePrimaryAudioMs: roundMetric5(average3(scenarios.map((scenario) => scenario.primaryAudioMs)), 2),
7571
- averageReconnectCount: roundMetric5(average3(scenarios.map((scenario) => scenario.reconnectCount))),
7572
- averageRelativeCostUnits: roundMetric5(average3(scenarios.map((scenario) => scenario.averageRelativeCostUnits))),
7573
- averageTurnPassRate: roundMetric5(average3(scenarios.map((scenario) => scenario.turnPassRate))),
7574
- averageWordErrorRate: roundMetric5(average3(turnAccuracies)),
8550
+ averageElapsedMs: roundMetric5(average4(scenarios.map((scenario) => scenario.elapsedMs)), 2),
8551
+ averageFallbackReplayAudioMs: roundMetric5(average4(scenarios.map((scenario) => scenario.fallbackReplayAudioMs)), 2),
8552
+ averagePrimaryAudioMs: roundMetric5(average4(scenarios.map((scenario) => scenario.primaryAudioMs)), 2),
8553
+ averageReconnectCount: roundMetric5(average4(scenarios.map((scenario) => scenario.reconnectCount))),
8554
+ averageRelativeCostUnits: roundMetric5(average4(scenarios.map((scenario) => scenario.averageRelativeCostUnits))),
8555
+ averageTurnPassRate: roundMetric5(average4(scenarios.map((scenario) => scenario.turnPassRate))),
8556
+ averageWordErrorRate: roundMetric5(average4(turnAccuracies)),
7575
8557
  duplicateTurnRate: roundMetric5(scenarios.length > 0 ? scenarios.filter((scenario) => scenario.duplicateTurnCount > 0).length / scenarios.length : 0),
7576
8558
  passCount,
7577
8559
  passRate: roundMetric5(scenarios.length > 0 ? passCount / scenarios.length : 0),
@@ -7597,13 +8579,13 @@ var summarizeVoiceSessionBenchmarkSeries = (input) => {
7597
8579
  const passCount = results.filter((scenario) => scenario.passes).length;
7598
8580
  const sample = results[0];
7599
8581
  return {
7600
- averageElapsedMs: roundMetric5(average3(results.map((scenario) => scenario.elapsedMs)), 2),
7601
- averageFallbackReplayAudioMs: roundMetric5(average3(results.map((scenario) => scenario.fallbackReplayAudioMs)), 2),
7602
- averagePrimaryAudioMs: roundMetric5(average3(results.map((scenario) => scenario.primaryAudioMs)), 2),
7603
- averageReconnectCount: roundMetric5(average3(results.map((scenario) => scenario.reconnectCount))),
7604
- averageRelativeCostUnits: roundMetric5(average3(results.map((scenario) => scenario.averageRelativeCostUnits))),
7605
- averageTurnPassRate: roundMetric5(average3(results.map((scenario) => scenario.turnPassRate))),
7606
- averageWordErrorRate: roundMetric5(average3(wordErrorRates)),
8582
+ averageElapsedMs: roundMetric5(average4(results.map((scenario) => scenario.elapsedMs)), 2),
8583
+ averageFallbackReplayAudioMs: roundMetric5(average4(results.map((scenario) => scenario.fallbackReplayAudioMs)), 2),
8584
+ averagePrimaryAudioMs: roundMetric5(average4(results.map((scenario) => scenario.primaryAudioMs)), 2),
8585
+ averageReconnectCount: roundMetric5(average4(results.map((scenario) => scenario.reconnectCount))),
8586
+ averageRelativeCostUnits: roundMetric5(average4(results.map((scenario) => scenario.averageRelativeCostUnits))),
8587
+ averageTurnPassRate: roundMetric5(average4(results.map((scenario) => scenario.turnPassRate))),
8588
+ averageWordErrorRate: roundMetric5(average4(wordErrorRates)),
7607
8589
  bestWordErrorRate: roundMetric5(wordErrorRates.length > 0 ? Math.min(...wordErrorRates) : 0),
7608
8590
  fixtureId,
7609
8591
  passCount,
@@ -7626,18 +8608,18 @@ var summarizeVoiceSessionBenchmarkSeries = (input) => {
7626
8608
  scenarios: scenarioAggregates,
7627
8609
  summary: {
7628
8610
  adapterId: input.adapterId,
7629
- averageElapsedMs: roundMetric5(average3(scenarioAggregates.map((scenario) => scenario.averageElapsedMs)), 2),
7630
- averageFallbackReplayAudioMs: roundMetric5(average3(scenarioAggregates.map((scenario) => scenario.averageFallbackReplayAudioMs)), 2),
7631
- averagePassRate: roundMetric5(average3(scenarioAggregates.map((scenario) => scenario.passRate))),
7632
- averagePrimaryAudioMs: roundMetric5(average3(scenarioAggregates.map((scenario) => scenario.averagePrimaryAudioMs)), 2),
7633
- averageReconnectCount: roundMetric5(average3(scenarioAggregates.map((scenario) => scenario.averageReconnectCount))),
7634
- averageRelativeCostUnits: roundMetric5(average3(scenarioAggregates.map((scenario) => scenario.averageRelativeCostUnits))),
7635
- averageTurnPassRate: roundMetric5(average3(scenarioAggregates.map((scenario) => scenario.averageTurnPassRate))),
7636
- averageWordErrorRate: roundMetric5(average3(scenarioAggregates.map((scenario) => scenario.averageWordErrorRate))),
8611
+ averageElapsedMs: roundMetric5(average4(scenarioAggregates.map((scenario) => scenario.averageElapsedMs)), 2),
8612
+ averageFallbackReplayAudioMs: roundMetric5(average4(scenarioAggregates.map((scenario) => scenario.averageFallbackReplayAudioMs)), 2),
8613
+ averagePassRate: roundMetric5(average4(scenarioAggregates.map((scenario) => scenario.passRate))),
8614
+ averagePrimaryAudioMs: roundMetric5(average4(scenarioAggregates.map((scenario) => scenario.averagePrimaryAudioMs)), 2),
8615
+ averageReconnectCount: roundMetric5(average4(scenarioAggregates.map((scenario) => scenario.averageReconnectCount))),
8616
+ averageRelativeCostUnits: roundMetric5(average4(scenarioAggregates.map((scenario) => scenario.averageRelativeCostUnits))),
8617
+ averageTurnPassRate: roundMetric5(average4(scenarioAggregates.map((scenario) => scenario.averageTurnPassRate))),
8618
+ averageWordErrorRate: roundMetric5(average4(scenarioAggregates.map((scenario) => scenario.averageWordErrorRate))),
7637
8619
  flakyScenarioCount: scenarioAggregates.filter((scenario) => scenario.passRate > 0 && scenario.passRate < 1).length,
7638
8620
  generatedRunCount: input.reports.length,
7639
- reconnectCoverageRate: roundMetric5(average3(reconnectCoverageRates)),
7640
- reconnectSuccessRate: roundMetric5(average3(reconnectRates)),
8621
+ reconnectCoverageRate: roundMetric5(average4(reconnectCoverageRates)),
8622
+ reconnectSuccessRate: roundMetric5(average4(reconnectRates)),
7641
8623
  scenarioCount: scenarioAggregates.length,
7642
8624
  stableScenarioCount: scenarioAggregates.filter((scenario) => scenario.passRate === 1).length,
7643
8625
  totalPassCount,
@@ -7680,10 +8662,981 @@ var runVoiceSessionBenchmarkSeries = async (input) => {
7680
8662
  });
7681
8663
  };
7682
8664
  // src/telephony/twilio.ts
7683
- import { Buffer as Buffer2 } from "buffer";
8665
+ import { Buffer as Buffer3 } from "buffer";
8666
+ import { Elysia as Elysia2 } from "elysia";
8667
+
8668
+ // src/telephonyOutcome.ts
8669
+ import { Elysia } from "elysia";
8670
+ var DEFAULT_COMPLETED_STATUSES = [
8671
+ "answered",
8672
+ "completed",
8673
+ "complete",
8674
+ "connected",
8675
+ "in-progress",
8676
+ "live"
8677
+ ];
8678
+ var DEFAULT_NO_ANSWER_STATUSES = [
8679
+ "busy",
8680
+ "canceled",
8681
+ "cancelled",
8682
+ "failed",
8683
+ "no-answer",
8684
+ "no_answer",
8685
+ "not-answered",
8686
+ "ring-no-answer",
8687
+ "timeout",
8688
+ "unanswered"
8689
+ ];
8690
+ var DEFAULT_VOICEMAIL_STATUSES = [
8691
+ "answering-machine",
8692
+ "machine",
8693
+ "voicemail",
8694
+ "voice-mail"
8695
+ ];
8696
+ var DEFAULT_TRANSFER_STATUSES = ["bridged", "forwarded", "transferred"];
8697
+ var DEFAULT_ESCALATION_STATUSES = ["escalated", "human-required", "operator"];
8698
+ var DEFAULT_FAILED_STATUSES = ["busy", "failed", "no-answer"];
8699
+ var DEFAULT_MACHINE_VOICEMAIL_VALUES = [
8700
+ "answering-machine",
8701
+ "fax",
8702
+ "machine",
8703
+ "machine-end-beep",
8704
+ "machine-end-other",
8705
+ "machine-start",
8706
+ "voicemail"
8707
+ ];
8708
+ var DEFAULT_NO_ANSWER_SIP_CODES = [408, 480, 486, 487, 603];
8709
+ var isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
8710
+ var uniqueSorted = (values) => Array.from(new Set(values)).sort();
8711
+ var findMissing = (values, required) => {
8712
+ if (!required?.length) {
8713
+ return [];
8714
+ }
8715
+ const valueSet = new Set(values);
8716
+ return required.filter((value) => !valueSet.has(value));
8717
+ };
8718
+
8719
+ class VoiceTelephonyWebhookVerificationError extends Error {
8720
+ result;
8721
+ constructor(result) {
8722
+ super(result.ok ? "telephony webhook verified" : result.reason);
8723
+ this.name = "VoiceTelephonyWebhookVerificationError";
8724
+ this.result = result;
8725
+ }
8726
+ }
8727
+ var createMemoryVoiceTelephonyWebhookIdempotencyStore = () => {
8728
+ const decisions = new Map;
8729
+ return {
8730
+ get: (key) => decisions.get(key),
8731
+ set: (key, decision) => {
8732
+ decisions.set(key, decision);
8733
+ }
8734
+ };
8735
+ };
8736
+ var isTelephonyWebhookProvider = (value) => value === "generic" || value === "plivo" || value === "telnyx" || value === "twilio";
8737
+ var isTelephonyOutcomeAction = (value) => value === "complete" || value === "escalate" || value === "ignore" || value === "no-answer" || value === "transfer" || value === "voicemail";
8738
+ var isCallDisposition = (value) => value === "completed" || value === "escalated" || value === "failed" || value === "no-answer" || value === "transferred" || value === "voicemail";
8739
+ var evaluateVoiceTelephonyWebhookNormalizationEvidence = (input = {}) => {
8740
+ const issues = [];
8741
+ const decisions = input.decisions ?? [];
8742
+ const verificationAttempts = input.verificationAttempts ?? [];
8743
+ const actions = uniqueSorted(decisions.map((decision) => decision.decision?.action ?? decision.action).filter(isTelephonyOutcomeAction));
8744
+ const dispositions = uniqueSorted(decisions.map((decision) => decision.decision?.disposition ?? decision.disposition).filter(isCallDisposition));
8745
+ const providers = uniqueSorted(decisions.map((decision) => decision.provider ?? decision.event?.provider).filter(isTelephonyWebhookProvider));
8746
+ const sources = uniqueSorted(decisions.map((decision) => decision.decision?.source ?? decision.source).filter((source) => typeof source === "string"));
8747
+ const applied = decisions.filter((decision) => decision.applied === true).length;
8748
+ const duplicateDecisions = decisions.filter((decision) => decision.duplicate === true);
8749
+ const duplicateProviders = uniqueSorted(duplicateDecisions.map((decision) => decision.provider ?? decision.event?.provider).filter(isTelephonyWebhookProvider));
8750
+ const duplicateIdempotencyKeys = new Set(duplicateDecisions.map((decision) => decision.idempotencyKey).filter((key) => typeof key === "string" && key.length > 0)).size;
8751
+ const duplicateCampaignOutcomesApplied = duplicateDecisions.filter((decision) => isRecord(decision.campaignOutcome) && decision.campaignOutcome.applied === true).length;
8752
+ const duplicateOutcomeReasons = uniqueSorted(duplicateDecisions.map((decision) => isRecord(decision.campaignOutcome) ? decision.campaignOutcome.reason : undefined).filter((reason) => typeof reason === "string"));
8753
+ const routeResults = decisions.filter((decision) => isRecord(decision.routeResult)).length;
8754
+ const missingSessionIds = decisions.filter((decision) => !decision.sessionId).length;
8755
+ const rejectedVerificationAttempts = verificationAttempts.filter((attempt) => attempt.rejected === true || attempt.status === 401 || attempt.verification?.ok === false && attempt.verification.reason === "invalid-signature");
8756
+ const rejectedVerificationProviders = uniqueSorted(rejectedVerificationAttempts.map((attempt) => attempt.provider).filter(isTelephonyWebhookProvider));
8757
+ const replayRejectedVerificationAttempts = rejectedVerificationAttempts.filter((attempt) => attempt.replayRejected === true);
8758
+ const replayRejectedVerificationProviders = uniqueSorted(replayRejectedVerificationAttempts.map((attempt) => attempt.provider).filter(isTelephonyWebhookProvider));
8759
+ const rejectedVerificationSideEffects = rejectedVerificationAttempts.reduce((total, attempt) => total + Math.max(0, attempt.sideEffects ?? 0), 0);
8760
+ if (input.minDecisions !== undefined && decisions.length < input.minDecisions) {
8761
+ issues.push(`Expected at least ${String(input.minDecisions)} telephony webhook decision(s), found ${String(decisions.length)}.`);
8762
+ }
8763
+ if (input.minApplied !== undefined && applied < input.minApplied) {
8764
+ issues.push(`Expected at least ${String(input.minApplied)} applied telephony webhook decision(s), found ${String(applied)}.`);
8765
+ }
8766
+ if (input.minDuplicates !== undefined && duplicateDecisions.length < input.minDuplicates) {
8767
+ issues.push(`Expected at least ${String(input.minDuplicates)} duplicate telephony webhook decision(s), found ${String(duplicateDecisions.length)}.`);
8768
+ }
8769
+ if (input.minDuplicateIdempotencyKeys !== undefined && duplicateIdempotencyKeys < input.minDuplicateIdempotencyKeys) {
8770
+ issues.push(`Expected at least ${String(input.minDuplicateIdempotencyKeys)} duplicate telephony webhook idempotency key(s), found ${String(duplicateIdempotencyKeys)}.`);
8771
+ }
8772
+ if (input.maxDuplicateCampaignOutcomesApplied !== undefined && duplicateCampaignOutcomesApplied > input.maxDuplicateCampaignOutcomesApplied) {
8773
+ issues.push(`Expected at most ${String(input.maxDuplicateCampaignOutcomesApplied)} duplicate telephony webhook campaign outcome application(s), found ${String(duplicateCampaignOutcomesApplied)}.`);
8774
+ }
8775
+ if (input.minRejectedVerificationAttempts !== undefined && rejectedVerificationAttempts.length < input.minRejectedVerificationAttempts) {
8776
+ issues.push(`Expected at least ${String(input.minRejectedVerificationAttempts)} rejected telephony webhook verification attempt(s), found ${String(rejectedVerificationAttempts.length)}.`);
8777
+ }
8778
+ if (input.maxRejectedVerificationSideEffects !== undefined && rejectedVerificationSideEffects > input.maxRejectedVerificationSideEffects) {
8779
+ issues.push(`Expected at most ${String(input.maxRejectedVerificationSideEffects)} rejected telephony webhook side effect(s), found ${String(rejectedVerificationSideEffects)}.`);
8780
+ }
8781
+ if (input.minReplayRejectedVerificationAttempts !== undefined && replayRejectedVerificationAttempts.length < input.minReplayRejectedVerificationAttempts) {
8782
+ issues.push(`Expected at least ${String(input.minReplayRejectedVerificationAttempts)} replay-rejected telephony webhook verification attempt(s), found ${String(replayRejectedVerificationAttempts.length)}.`);
8783
+ }
8784
+ if (input.maxMissingSessionIds !== undefined && missingSessionIds > input.maxMissingSessionIds) {
8785
+ issues.push(`Expected at most ${String(input.maxMissingSessionIds)} telephony webhook decision(s) without sessionId, found ${String(missingSessionIds)}.`);
8786
+ }
8787
+ if (input.requireRouteResults && routeResults < decisions.length) {
8788
+ issues.push(`Expected every telephony webhook decision to include a route result, found ${String(routeResults)} of ${String(decisions.length)}.`);
8789
+ }
8790
+ for (const provider of findMissing(providers, input.requiredProviders)) {
8791
+ issues.push(`Missing telephony webhook provider: ${provider}.`);
8792
+ }
8793
+ for (const provider of findMissing(duplicateProviders, input.requiredDuplicateProviders)) {
8794
+ issues.push(`Missing duplicate telephony webhook provider: ${provider}.`);
8795
+ }
8796
+ for (const provider of findMissing(rejectedVerificationProviders, input.requiredRejectedVerificationProviders)) {
8797
+ issues.push(`Missing rejected telephony webhook verification provider: ${provider}.`);
8798
+ }
8799
+ for (const provider of findMissing(replayRejectedVerificationProviders, input.requiredReplayRejectedVerificationProviders)) {
8800
+ issues.push(`Missing replay-rejected telephony webhook verification provider: ${provider}.`);
8801
+ }
8802
+ for (const action of findMissing(actions, input.requiredActions)) {
8803
+ issues.push(`Missing telephony webhook action: ${action}.`);
8804
+ }
8805
+ for (const disposition of findMissing(dispositions, input.requiredDispositions)) {
8806
+ issues.push(`Missing telephony webhook disposition: ${disposition}.`);
8807
+ }
8808
+ return {
8809
+ actions,
8810
+ applied,
8811
+ decisions: decisions.length,
8812
+ dispositions,
8813
+ duplicateCampaignOutcomesApplied,
8814
+ duplicateIdempotencyKeys,
8815
+ duplicateOutcomeReasons,
8816
+ duplicateProviders,
8817
+ duplicates: duplicateDecisions.length,
8818
+ issues,
8819
+ missingSessionIds,
8820
+ ok: issues.length === 0,
8821
+ providers,
8822
+ rejectedVerificationAttempts: rejectedVerificationAttempts.length,
8823
+ rejectedVerificationProviders,
8824
+ rejectedVerificationSideEffects,
8825
+ replayRejectedVerificationAttempts: replayRejectedVerificationAttempts.length,
8826
+ replayRejectedVerificationProviders,
8827
+ routeResults,
8828
+ sources,
8829
+ verificationAttempts: verificationAttempts.length
8830
+ };
8831
+ };
8832
+ var assertVoiceTelephonyWebhookNormalizationEvidence = (input = {}) => {
8833
+ const assertion = evaluateVoiceTelephonyWebhookNormalizationEvidence(input);
8834
+ if (!assertion.ok) {
8835
+ throw new Error(`Voice telephony webhook normalization evidence assertion failed: ${assertion.issues.join(" ")}`);
8836
+ }
8837
+ return assertion;
8838
+ };
8839
+ var normalizeToken = (value) => typeof value === "string" ? value.trim().toLowerCase().replace(/\s+/g, "-").replace(/_+/g, "-") : undefined;
8840
+ var firstString = (source, keys) => {
8841
+ for (const key of keys) {
8842
+ const value = source[key];
8843
+ if (typeof value === "string" && value.trim()) {
8844
+ return value.trim();
8845
+ }
8846
+ if (typeof value === "number" && Number.isFinite(value)) {
8847
+ return String(value);
8848
+ }
8849
+ }
8850
+ };
8851
+ var firstNumber = (source, keys) => {
8852
+ for (const key of keys) {
8853
+ const value = source[key];
8854
+ if (typeof value === "number" && Number.isFinite(value)) {
8855
+ return value;
8856
+ }
8857
+ if (typeof value === "string" && value.trim()) {
8858
+ const parsed = Number(value);
8859
+ if (Number.isFinite(parsed)) {
8860
+ return parsed;
8861
+ }
8862
+ }
8863
+ }
8864
+ };
8865
+ var parseMaybeJSON = (value) => {
8866
+ try {
8867
+ return JSON.parse(value);
8868
+ } catch {
8869
+ return;
8870
+ }
8871
+ };
8872
+ var flattenPayload = (value) => {
8873
+ if (!isRecord(value)) {
8874
+ return {};
8875
+ }
8876
+ const data = isRecord(value.data) ? value.data : undefined;
8877
+ const payload = isRecord(value.payload) ? value.payload : undefined;
8878
+ const event = isRecord(value.event) ? value.event : undefined;
8879
+ return {
8880
+ ...value,
8881
+ ...payload,
8882
+ ...event,
8883
+ ...data,
8884
+ ...isRecord(data?.payload) ? data.payload : undefined
8885
+ };
8886
+ };
8887
+ var toBase64 = (bytes) => Buffer.from(new Uint8Array(bytes)).toString("base64");
8888
+ var timingSafeEqual = (left, right) => {
8889
+ const encoder = new TextEncoder;
8890
+ const leftBytes = encoder.encode(left);
8891
+ const rightBytes = encoder.encode(right);
8892
+ if (leftBytes.length !== rightBytes.length) {
8893
+ return false;
8894
+ }
8895
+ let diff = 0;
8896
+ for (let index = 0;index < leftBytes.length; index += 1) {
8897
+ diff |= leftBytes[index] ^ rightBytes[index];
8898
+ }
8899
+ return diff === 0;
8900
+ };
8901
+ var signHmacSHA1Base64 = async (secret, payload) => {
8902
+ const encoder = new TextEncoder;
8903
+ const key = await crypto.subtle.importKey("raw", encoder.encode(secret), {
8904
+ hash: "SHA-1",
8905
+ name: "HMAC"
8906
+ }, false, ["sign"]);
8907
+ const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
8908
+ return toBase64(signature);
8909
+ };
8910
+ var sortedParamsForSignature = (body) => Object.entries(flattenPayload(body)).filter(([, value]) => value !== undefined && value !== null).sort(([left], [right]) => left.localeCompare(right)).map(([key, value]) => `${key}${String(value)}`).join("");
8911
+ var normalizeList = (values, fallback) => new Set((values ?? fallback).map(normalizeToken).filter(Boolean));
8912
+ var metadataValue = (metadata, keys) => {
8913
+ for (const key of keys) {
8914
+ const value = metadata?.[key];
8915
+ if (typeof value === "string" && value.trim()) {
8916
+ return value.trim();
8917
+ }
8918
+ }
8919
+ };
8920
+ var resolveTransferTarget = (event, policy) => {
8921
+ if (typeof event.target === "string" && event.target.trim()) {
8922
+ return event.target.trim();
8923
+ }
8924
+ const metadataTarget = metadataValue(event.metadata, [
8925
+ "transferTarget",
8926
+ "target",
8927
+ "queue",
8928
+ "department"
8929
+ ]);
8930
+ if (metadataTarget) {
8931
+ return metadataTarget;
8932
+ }
8933
+ if (typeof policy.transferTarget === "function") {
8934
+ const target = policy.transferTarget(event);
8935
+ return typeof target === "string" && target.trim() ? target.trim() : undefined;
8936
+ }
8937
+ return typeof policy.transferTarget === "string" && policy.transferTarget.trim() ? policy.transferTarget.trim() : undefined;
8938
+ };
8939
+ var mergeMetadata = (event, policy) => ({
8940
+ ...policy.includeProviderPayload ? {
8941
+ answeredBy: event.answeredBy,
8942
+ durationMs: event.durationMs,
8943
+ provider: event.provider,
8944
+ reason: event.reason,
8945
+ sipCode: event.sipCode,
8946
+ status: event.status
8947
+ } : undefined,
8948
+ ...policy.metadata,
8949
+ ...event.metadata
8950
+ });
8951
+ var withDecisionDefaults = (decision, input) => {
8952
+ if (typeof decision === "string") {
8953
+ return buildDecision(decision, input);
8954
+ }
8955
+ return {
8956
+ ...buildDecision(decision.action, input),
8957
+ ...decision,
8958
+ confidence: decision.confidence ?? "high",
8959
+ metadata: {
8960
+ ...mergeMetadata(input.event, input.policy),
8961
+ ...decision.metadata
8962
+ },
8963
+ source: decision.source ?? input.source,
8964
+ target: decision.target ?? (decision.action === "transfer" ? resolveTransferTarget(input.event, input.policy) : undefined)
8965
+ };
8966
+ };
8967
+ var dispositionForAction = (action) => {
8968
+ switch (action) {
8969
+ case "complete":
8970
+ return "completed";
8971
+ case "escalate":
8972
+ return "escalated";
8973
+ case "no-answer":
8974
+ return "no-answer";
8975
+ case "transfer":
8976
+ return "transferred";
8977
+ case "voicemail":
8978
+ return "voicemail";
8979
+ default:
8980
+ return;
8981
+ }
8982
+ };
8983
+ var buildDecision = (action, input) => ({
8984
+ action,
8985
+ confidence: action === "ignore" ? "low" : "high",
8986
+ disposition: dispositionForAction(action),
8987
+ metadata: mergeMetadata(input.event, input.policy),
8988
+ reason: input.event.reason,
8989
+ source: input.source,
8990
+ target: action === "transfer" ? resolveTransferTarget(input.event, input.policy) : undefined
8991
+ });
8992
+ var createVoiceTelephonyOutcomePolicy = (policy = {}) => ({
8993
+ completedStatuses: policy.completedStatuses ?? DEFAULT_COMPLETED_STATUSES,
8994
+ escalationStatuses: policy.escalationStatuses ?? DEFAULT_ESCALATION_STATUSES,
8995
+ failedAsNoAnswer: policy.failedAsNoAnswer ?? true,
8996
+ failedStatuses: policy.failedStatuses ?? DEFAULT_FAILED_STATUSES,
8997
+ includeProviderPayload: policy.includeProviderPayload ?? true,
8998
+ machineDetectionVoicemailValues: policy.machineDetectionVoicemailValues ?? DEFAULT_MACHINE_VOICEMAIL_VALUES,
8999
+ metadata: policy.metadata,
9000
+ minAnsweredDurationMs: policy.minAnsweredDurationMs,
9001
+ noAnswerOnZeroDuration: policy.noAnswerOnZeroDuration ?? true,
9002
+ noAnswerSipCodes: policy.noAnswerSipCodes ?? DEFAULT_NO_ANSWER_SIP_CODES,
9003
+ noAnswerStatuses: policy.noAnswerStatuses ?? DEFAULT_NO_ANSWER_STATUSES,
9004
+ statusMap: policy.statusMap,
9005
+ transferStatuses: policy.transferStatuses ?? DEFAULT_TRANSFER_STATUSES,
9006
+ transferTarget: policy.transferTarget,
9007
+ voicemailStatuses: policy.voicemailStatuses ?? DEFAULT_VOICEMAIL_STATUSES
9008
+ });
9009
+ var resolveVoiceTelephonyOutcome = (event, policyInput = {}) => {
9010
+ const policy = createVoiceTelephonyOutcomePolicy(policyInput);
9011
+ const status = normalizeToken(event.status);
9012
+ const provider = normalizeToken(event.provider);
9013
+ const answeredBy = normalizeToken(event.answeredBy);
9014
+ const target = resolveTransferTarget(event, policy);
9015
+ if (status) {
9016
+ const mapped = policy.statusMap?.[status] ?? (provider ? policy.statusMap?.[`${provider}:${status}`] : undefined);
9017
+ if (mapped) {
9018
+ return withDecisionDefaults(mapped, {
9019
+ event,
9020
+ policy,
9021
+ source: "policy"
9022
+ });
9023
+ }
9024
+ }
9025
+ if (answeredBy && normalizeList(policy.machineDetectionVoicemailValues, []).has(answeredBy)) {
9026
+ return buildDecision("voicemail", { event, policy, source: "answered-by" });
9027
+ }
9028
+ if (typeof event.sipCode === "number" && policy.noAnswerSipCodes.includes(event.sipCode)) {
9029
+ return buildDecision("no-answer", { event, policy, source: "sip" });
9030
+ }
9031
+ if (target && status && normalizeList(policy.transferStatuses, []).has(status)) {
9032
+ return buildDecision("transfer", { event, policy, source: "status" });
9033
+ }
9034
+ if (status && normalizeList(policy.voicemailStatuses, []).has(status)) {
9035
+ return buildDecision("voicemail", { event, policy, source: "status" });
9036
+ }
9037
+ if (status && normalizeList(policy.escalationStatuses, []).has(status)) {
9038
+ return buildDecision("escalate", { event, policy, source: "status" });
9039
+ }
9040
+ if (status && (policy.failedAsNoAnswer ? normalizeList(policy.noAnswerStatuses, []).has(status) || normalizeList(policy.failedStatuses, []).has(status) : normalizeList(policy.noAnswerStatuses, []).has(status))) {
9041
+ return buildDecision("no-answer", { event, policy, source: "status" });
9042
+ }
9043
+ if (policy.noAnswerOnZeroDuration && typeof event.durationMs === "number" && event.durationMs <= 0) {
9044
+ return buildDecision("no-answer", { event, policy, source: "duration" });
9045
+ }
9046
+ if (typeof policy.minAnsweredDurationMs === "number" && typeof event.durationMs === "number" && event.durationMs < policy.minAnsweredDurationMs) {
9047
+ return {
9048
+ ...buildDecision("no-answer", { event, policy, source: "duration" }),
9049
+ confidence: "medium"
9050
+ };
9051
+ }
9052
+ if (status && normalizeList(policy.completedStatuses, []).has(status)) {
9053
+ return buildDecision("complete", { event, policy, source: "status" });
9054
+ }
9055
+ if (target) {
9056
+ return {
9057
+ ...buildDecision("transfer", { event, policy, source: "explicit-target" }),
9058
+ confidence: "medium"
9059
+ };
9060
+ }
9061
+ return buildDecision("ignore", { event, policy, source: "status" });
9062
+ };
9063
+ var voiceTelephonyOutcomeToRouteResult = (decision, result) => {
9064
+ switch (decision.action) {
9065
+ case "complete":
9066
+ return { complete: true, result };
9067
+ case "escalate":
9068
+ return {
9069
+ escalate: {
9070
+ metadata: decision.metadata,
9071
+ reason: decision.reason ?? "telephony-escalation"
9072
+ },
9073
+ result
9074
+ };
9075
+ case "no-answer":
9076
+ return {
9077
+ noAnswer: {
9078
+ metadata: decision.metadata
9079
+ },
9080
+ result
9081
+ };
9082
+ case "transfer":
9083
+ if (!decision.target) {
9084
+ return { result };
9085
+ }
9086
+ return {
9087
+ result,
9088
+ transfer: {
9089
+ metadata: decision.metadata,
9090
+ reason: decision.reason,
9091
+ target: decision.target
9092
+ }
9093
+ };
9094
+ case "voicemail":
9095
+ return {
9096
+ result,
9097
+ voicemail: {
9098
+ metadata: decision.metadata
9099
+ }
9100
+ };
9101
+ default:
9102
+ return { result };
9103
+ }
9104
+ };
9105
+ var applyVoiceTelephonyOutcome = async (api, decision, result) => {
9106
+ switch (decision.action) {
9107
+ case "complete":
9108
+ await api.complete(result);
9109
+ break;
9110
+ case "escalate":
9111
+ await api.escalate({
9112
+ metadata: decision.metadata,
9113
+ reason: decision.reason ?? "telephony-escalation",
9114
+ result
9115
+ });
9116
+ break;
9117
+ case "no-answer":
9118
+ await api.markNoAnswer({
9119
+ metadata: decision.metadata,
9120
+ result
9121
+ });
9122
+ break;
9123
+ case "transfer":
9124
+ if (!decision.target) {
9125
+ return;
9126
+ }
9127
+ await api.transfer({
9128
+ metadata: decision.metadata,
9129
+ reason: decision.reason,
9130
+ result,
9131
+ target: decision.target
9132
+ });
9133
+ break;
9134
+ case "voicemail":
9135
+ await api.markVoicemail({
9136
+ metadata: decision.metadata,
9137
+ result
9138
+ });
9139
+ break;
9140
+ default:
9141
+ break;
9142
+ }
9143
+ };
9144
+ var parseRequestBodyText = (input) => {
9145
+ const { contentType, text } = input;
9146
+ if (!text) {
9147
+ return {};
9148
+ }
9149
+ if (contentType.includes("application/json")) {
9150
+ return parseMaybeJSON(text) ?? {};
9151
+ }
9152
+ if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
9153
+ return Object.fromEntries(new URLSearchParams(text));
9154
+ }
9155
+ return parseMaybeJSON(text) ?? Object.fromEntries(new URLSearchParams(text));
9156
+ };
9157
+ var readRequestBody = async (request) => {
9158
+ const contentType = request.headers.get("content-type") ?? "";
9159
+ const text = await request.text();
9160
+ return {
9161
+ body: parseRequestBodyText({ contentType, text }),
9162
+ rawBody: text
9163
+ };
9164
+ };
9165
+ var signVoiceTwilioWebhook = async (input) => signHmacSHA1Base64(input.authToken, `${input.url}${sortedParamsForSignature(input.body ?? {})}`);
9166
+ var verifyVoiceTwilioWebhookSignature = async (input) => {
9167
+ if (!input.authToken) {
9168
+ return { ok: false, reason: "missing-secret" };
9169
+ }
9170
+ const signature = input.headers.get("x-twilio-signature");
9171
+ if (!signature) {
9172
+ return { ok: false, reason: "missing-signature" };
9173
+ }
9174
+ const expected = await signVoiceTwilioWebhook({
9175
+ authToken: input.authToken,
9176
+ body: input.body,
9177
+ url: input.url
9178
+ });
9179
+ return timingSafeEqual(signature, expected) ? { ok: true } : { ok: false, reason: "invalid-signature" };
9180
+ };
9181
+ var resolveVerificationUrl = (option, input) => typeof option === "function" ? option(input) : option ?? input.request.url;
9182
+ var verifyVoiceTelephonyWebhook = async (input) => {
9183
+ if (input.options.verify) {
9184
+ return input.options.verify({
9185
+ body: input.body,
9186
+ headers: input.request.headers,
9187
+ provider: input.provider,
9188
+ query: input.query,
9189
+ rawBody: input.rawBody,
9190
+ request: input.request
9191
+ });
9192
+ }
9193
+ if (!input.options.signingSecret) {
9194
+ return input.options.requireVerification ? { ok: false, reason: "missing-secret" } : { ok: true };
9195
+ }
9196
+ if (input.provider !== "twilio") {
9197
+ return { ok: false, reason: "unsupported-provider" };
9198
+ }
9199
+ return verifyVoiceTwilioWebhookSignature({
9200
+ authToken: input.options.signingSecret,
9201
+ body: input.body,
9202
+ headers: input.request.headers,
9203
+ url: resolveVerificationUrl(input.options.verificationUrl, {
9204
+ query: input.query,
9205
+ request: input.request
9206
+ })
9207
+ });
9208
+ };
9209
+ var durationMsFromSeconds = (value) => typeof value === "number" ? value * 1000 : undefined;
9210
+ var parseVoiceTelephonyWebhookEvent = (input) => {
9211
+ const payload = flattenPayload(input.body);
9212
+ const provider = firstString(payload, ["provider", "Provider"]) ?? input.provider;
9213
+ const status = firstString(payload, [
9214
+ "CallStatus",
9215
+ "call_status",
9216
+ "callStatus",
9217
+ "DialCallStatus",
9218
+ "dial_call_status",
9219
+ "status",
9220
+ "event_type",
9221
+ "type"
9222
+ ]);
9223
+ const durationMs = firstNumber(payload, ["durationMs", "duration_ms"]) ?? durationMsFromSeconds(firstNumber(payload, [
9224
+ "CallDuration",
9225
+ "call_duration",
9226
+ "callDuration",
9227
+ "DialCallDuration",
9228
+ "dial_call_duration",
9229
+ "duration"
9230
+ ]));
9231
+ const sipCode = firstNumber(payload, [
9232
+ "SipResponseCode",
9233
+ "sip_response_code",
9234
+ "sipCode",
9235
+ "sip_code",
9236
+ "hangupCauseCode"
9237
+ ]);
9238
+ const from = firstString(payload, ["From", "from", "caller_id", "callerId"]);
9239
+ const to = firstString(payload, ["To", "to", "called_number", "calledNumber"]);
9240
+ const target = firstString(payload, [
9241
+ "transferTarget",
9242
+ "TransferTarget",
9243
+ "target",
9244
+ "queue",
9245
+ "department"
9246
+ ]);
9247
+ return {
9248
+ answeredBy: firstString(payload, [
9249
+ "AnsweredBy",
9250
+ "answered_by",
9251
+ "answeredBy",
9252
+ "machineDetection",
9253
+ "machine_detection"
9254
+ ]),
9255
+ durationMs,
9256
+ from,
9257
+ metadata: {
9258
+ ...input.query,
9259
+ ...payload
9260
+ },
9261
+ provider,
9262
+ reason: firstString(payload, [
9263
+ "Reason",
9264
+ "reason",
9265
+ "HangupCause",
9266
+ "hangup_cause",
9267
+ "hangupCause"
9268
+ ]),
9269
+ sipCode,
9270
+ status,
9271
+ target,
9272
+ to
9273
+ };
9274
+ };
9275
+ var defaultSessionId = (input) => {
9276
+ const payload = flattenPayload(input.body);
9277
+ const metadataSessionId = input.event.metadata?.sessionId;
9278
+ return firstString(input.query, ["sessionId", "session_id"]) ?? firstString(payload, [
9279
+ "sessionId",
9280
+ "session_id",
9281
+ "SessionId",
9282
+ "CallSid",
9283
+ "call_sid",
9284
+ "callSid",
9285
+ "CallUUID",
9286
+ "call_uuid",
9287
+ "callControlId",
9288
+ "call_control_id"
9289
+ ]) ?? (typeof metadataSessionId === "string" ? metadataSessionId : undefined);
9290
+ };
9291
+ var defaultIdempotencyKey = (input) => {
9292
+ const payload = flattenPayload(input.body);
9293
+ const eventId = firstString(payload, [
9294
+ "id",
9295
+ "event_id",
9296
+ "eventId",
9297
+ "EventSid",
9298
+ "event_sid",
9299
+ "MessageSid",
9300
+ "message_sid",
9301
+ "CallSid",
9302
+ "call_sid",
9303
+ "CallUUID",
9304
+ "call_uuid",
9305
+ "callControlId",
9306
+ "call_control_id"
9307
+ ]);
9308
+ const status = normalizeToken(input.event.status) ?? "unknown";
9309
+ if (eventId) {
9310
+ return `${input.provider}:${eventId}:${status}`;
9311
+ }
9312
+ if (input.sessionId) {
9313
+ return `${input.provider}:${input.sessionId}:${status}`;
9314
+ }
9315
+ };
9316
+ var createVoiceTelephonyWebhookHandler = (options = {}) => async (input) => {
9317
+ const provider = options.provider ?? "generic";
9318
+ const query = input.query ?? {};
9319
+ const { body, rawBody } = await readRequestBody(input.request);
9320
+ const verification = await verifyVoiceTelephonyWebhook({
9321
+ body,
9322
+ options,
9323
+ provider,
9324
+ query,
9325
+ rawBody,
9326
+ request: input.request
9327
+ });
9328
+ if (!verification.ok) {
9329
+ throw new VoiceTelephonyWebhookVerificationError(verification);
9330
+ }
9331
+ const event = options.parse ? await options.parse({
9332
+ body,
9333
+ headers: input.request.headers,
9334
+ provider,
9335
+ query,
9336
+ request: input.request
9337
+ }) : parseVoiceTelephonyWebhookEvent({
9338
+ body,
9339
+ headers: input.request.headers,
9340
+ provider,
9341
+ query,
9342
+ request: input.request
9343
+ });
9344
+ const sessionId = await (options.resolveSessionId?.({
9345
+ body,
9346
+ event,
9347
+ query,
9348
+ request: input.request
9349
+ }) ?? defaultSessionId({ body, event, query }));
9350
+ const idempotencyEnabled = options.idempotency?.enabled !== false;
9351
+ const idempotencyKey = idempotencyEnabled ? await (options.idempotency?.key?.({
9352
+ body,
9353
+ event,
9354
+ provider,
9355
+ query,
9356
+ request: input.request,
9357
+ sessionId
9358
+ }) ?? defaultIdempotencyKey({ body, event, provider, sessionId })) : undefined;
9359
+ const idempotencyStore = options.idempotency?.store;
9360
+ if (idempotencyKey && idempotencyStore) {
9361
+ const existing = await idempotencyStore.get(idempotencyKey);
9362
+ if (existing) {
9363
+ const duplicateDecision = {
9364
+ ...existing,
9365
+ duplicate: true
9366
+ };
9367
+ await options.onDecision?.({
9368
+ ...duplicateDecision,
9369
+ context: options.context,
9370
+ request: input.request
9371
+ });
9372
+ return duplicateDecision;
9373
+ }
9374
+ }
9375
+ const decision = resolveVoiceTelephonyOutcome(event, options.policy);
9376
+ const resultResolver = options.result;
9377
+ const result = typeof resultResolver === "function" ? await resultResolver({
9378
+ decision,
9379
+ event,
9380
+ sessionId
9381
+ }) : resultResolver;
9382
+ const routeResult = voiceTelephonyOutcomeToRouteResult(decision, result);
9383
+ const shouldApply = typeof options.apply === "function" ? options.apply({
9384
+ applied: false,
9385
+ decision,
9386
+ event,
9387
+ routeResult,
9388
+ sessionId
9389
+ }) : options.apply === true;
9390
+ let applied = false;
9391
+ if (shouldApply && decision.action !== "ignore" && options.getSessionHandle) {
9392
+ const api = await options.getSessionHandle({
9393
+ context: options.context,
9394
+ decision,
9395
+ event,
9396
+ request: input.request,
9397
+ sessionId
9398
+ });
9399
+ if (api) {
9400
+ await applyVoiceTelephonyOutcome(api, decision, result);
9401
+ applied = true;
9402
+ }
9403
+ }
9404
+ const webhookDecision = {
9405
+ applied,
9406
+ decision,
9407
+ event,
9408
+ idempotencyKey,
9409
+ routeResult,
9410
+ sessionId
9411
+ };
9412
+ if (idempotencyKey && idempotencyStore) {
9413
+ const now = Date.now();
9414
+ await idempotencyStore.set(idempotencyKey, {
9415
+ ...webhookDecision,
9416
+ createdAt: now,
9417
+ updatedAt: now
9418
+ });
9419
+ }
9420
+ await options.onDecision?.({
9421
+ ...webhookDecision,
9422
+ context: options.context,
9423
+ request: input.request
9424
+ });
9425
+ return webhookDecision;
9426
+ };
9427
+ var createVoiceTelephonyWebhookRoutes = (options = {}) => {
9428
+ const path = options.path ?? "/api/voice/telephony/webhook";
9429
+ const handler = createVoiceTelephonyWebhookHandler(options);
9430
+ return new Elysia({
9431
+ name: options.name ?? "absolutejs-voice-telephony-webhooks"
9432
+ }).post(path, async ({ query, request }) => {
9433
+ try {
9434
+ return await handler({ query, request });
9435
+ } catch (error) {
9436
+ if (error instanceof VoiceTelephonyWebhookVerificationError) {
9437
+ return new Response(JSON.stringify({ verification: error.result }), {
9438
+ headers: {
9439
+ "content-type": "application/json"
9440
+ },
9441
+ status: 401
9442
+ });
9443
+ }
9444
+ throw error;
9445
+ }
9446
+ }, {
9447
+ parse: "none"
9448
+ });
9449
+ };
9450
+
9451
+ // src/telephony/twilio.ts
7684
9452
  var TWILIO_MULAW_SAMPLE_RATE = 8000;
7685
9453
  var VOICE_PCM_SAMPLE_RATE = 16000;
7686
9454
  var escapeXml2 = (value) => value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("'", "&apos;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
9455
+ var resolveRequestOrigin = (request) => {
9456
+ const url = new URL(request.url);
9457
+ const forwardedHost = request.headers.get("x-forwarded-host");
9458
+ const forwardedProto = request.headers.get("x-forwarded-proto");
9459
+ const host = forwardedHost ?? request.headers.get("host") ?? url.host;
9460
+ const protocol = forwardedProto ?? url.protocol.replace(":", "");
9461
+ return `${protocol}://${host}`;
9462
+ };
9463
+ var resolveTwilioStreamUrl = async (options, input) => {
9464
+ if (typeof options.twiml?.streamUrl === "function") {
9465
+ return options.twiml.streamUrl(input);
9466
+ }
9467
+ if (typeof options.twiml?.streamUrl === "string") {
9468
+ return options.twiml.streamUrl;
9469
+ }
9470
+ const origin = resolveRequestOrigin(input.request);
9471
+ const wsOrigin = origin.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
9472
+ return `${wsOrigin}${input.streamPath}`;
9473
+ };
9474
+ var resolveTwilioStreamParameters = async (parameters, input) => {
9475
+ if (typeof parameters === "function") {
9476
+ return parameters(input);
9477
+ }
9478
+ return parameters;
9479
+ };
9480
+ var joinUrlPath = (origin, path) => `${origin.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
9481
+ var escapeHtml2 = (value) => value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("'", "&#39;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
9482
+ var getWebhookVerificationUrl = (webhook, input) => {
9483
+ if (!webhook?.verificationUrl) {
9484
+ return;
9485
+ }
9486
+ if (typeof webhook.verificationUrl === "function") {
9487
+ return webhook.verificationUrl(input);
9488
+ }
9489
+ return webhook.verificationUrl;
9490
+ };
9491
+ var buildTwilioVoiceSetupStatus = async (options, input) => {
9492
+ const origin = resolveRequestOrigin(input.request);
9493
+ const stream = await resolveTwilioStreamUrl(options, input);
9494
+ const twiml = joinUrlPath(origin, input.twimlPath);
9495
+ const webhook = joinUrlPath(origin, input.webhookPath);
9496
+ const verificationUrl = getWebhookVerificationUrl(options.webhook, input);
9497
+ const missing = Object.entries(options.setup?.requiredEnv ?? {}).filter((entry) => !entry[1]).map(([name]) => name);
9498
+ const signingConfigured = Boolean(options.webhook?.signingSecret || options.webhook?.verify);
9499
+ const warnings = [
9500
+ ...stream.startsWith("wss://") ? [] : ["Twilio media streams should use wss:// in production."],
9501
+ ...signingConfigured ? [] : ["Webhook signature verification is not configured."],
9502
+ ...verificationUrl || !signingConfigured ? [] : ["Webhook signing is configured without an explicit verification URL."]
9503
+ ];
9504
+ return {
9505
+ generatedAt: Date.now(),
9506
+ missing,
9507
+ provider: "twilio",
9508
+ ready: missing.length === 0 && signingConfigured && warnings.length === 0,
9509
+ signing: {
9510
+ configured: signingConfigured,
9511
+ mode: options.webhook?.verify ? "custom" : options.webhook?.signingSecret ? "twilio-signature" : "none",
9512
+ verificationUrl
9513
+ },
9514
+ urls: {
9515
+ stream,
9516
+ twiml,
9517
+ webhook
9518
+ },
9519
+ warnings
9520
+ };
9521
+ };
9522
+ var renderTwilioVoiceSetupHTML = (status, title) => `<main style="font-family: ui-sans-serif, system-ui; max-width: 860px; margin: 40px auto; padding: 0 20px;">
9523
+ <p style="letter-spacing: .12em; text-transform: uppercase; color: #52606d;">Twilio setup</p>
9524
+ <h1>${escapeHtml2(title)}</h1>
9525
+ <p><strong>Status:</strong> ${status.ready ? "Ready" : "Needs attention"}</p>
9526
+ <section>
9527
+ <h2>URLs</h2>
9528
+ <ul>
9529
+ <li><strong>TwiML:</strong> <code>${escapeHtml2(status.urls.twiml)}</code></li>
9530
+ <li><strong>Media stream:</strong> <code>${escapeHtml2(status.urls.stream)}</code></li>
9531
+ <li><strong>Status webhook:</strong> <code>${escapeHtml2(status.urls.webhook)}</code></li>
9532
+ </ul>
9533
+ </section>
9534
+ <section>
9535
+ <h2>Signing</h2>
9536
+ <p>Mode: <code>${status.signing.mode}</code></p>
9537
+ ${status.signing.verificationUrl ? `<p>Verification URL: <code>${escapeHtml2(status.signing.verificationUrl)}</code></p>` : ""}
9538
+ </section>
9539
+ ${status.missing.length ? `<section><h2>Missing env</h2><ul>${status.missing.map((name) => `<li><code>${escapeHtml2(name)}</code></li>`).join("")}</ul></section>` : ""}
9540
+ ${status.warnings.length ? `<section><h2>Warnings</h2><ul>${status.warnings.map((warning) => `<li>${escapeHtml2(warning)}</li>`).join("")}</ul></section>` : ""}
9541
+ </main>`;
9542
+ var extractTwilioStreamUrl = (twiml) => twiml.match(/<Stream\b[^>]*\surl="([^"]+)"/i)?.[1]?.replaceAll("&amp;", "&");
9543
+ var createSmokeCheck = (name, status, message, details) => ({
9544
+ details,
9545
+ message,
9546
+ name,
9547
+ status
9548
+ });
9549
+ var renderTwilioVoiceSmokeHTML = (report, title) => `<main style="font-family: ui-sans-serif, system-ui; max-width: 860px; margin: 40px auto; padding: 0 20px;">
9550
+ <p style="letter-spacing: .12em; text-transform: uppercase; color: #52606d;">Twilio smoke test</p>
9551
+ <h1>${escapeHtml2(title)}</h1>
9552
+ <p><strong>Status:</strong> ${report.pass ? "Pass" : "Fail"}</p>
9553
+ <section>
9554
+ <h2>Checks</h2>
9555
+ <ul>
9556
+ ${report.checks.map((check) => `<li><strong>${escapeHtml2(check.name)}</strong>: ${escapeHtml2(check.status)}${check.message ? ` - ${escapeHtml2(check.message)}` : ""}</li>`).join("")}
9557
+ </ul>
9558
+ </section>
9559
+ <section>
9560
+ <h2>Observed URLs</h2>
9561
+ <ul>
9562
+ <li><strong>TwiML:</strong> <code>${escapeHtml2(report.setup.urls.twiml)}</code></li>
9563
+ <li><strong>Stream:</strong> <code>${escapeHtml2(report.twiml?.streamUrl ?? report.setup.urls.stream)}</code></li>
9564
+ <li><strong>Webhook:</strong> <code>${escapeHtml2(report.setup.urls.webhook)}</code></li>
9565
+ </ul>
9566
+ </section>
9567
+ </main>`;
9568
+ var runTwilioVoiceSmokeTest = async (input) => {
9569
+ const setup = await buildTwilioVoiceSetupStatus(input.options, input);
9570
+ const checks = [];
9571
+ const twimlUrl = new URL(setup.urls.twiml);
9572
+ twimlUrl.searchParams.set("scenarioId", input.options.smoke?.scenarioId ?? "smoke");
9573
+ twimlUrl.searchParams.set("sessionId", input.options.smoke?.sessionId ?? "smoke-session");
9574
+ const twimlResponse = await input.app.handle(new Request(twimlUrl, {
9575
+ headers: input.request.headers
9576
+ }));
9577
+ const twiml = await twimlResponse.text();
9578
+ const streamUrl = extractTwilioStreamUrl(twiml);
9579
+ checks.push(createSmokeCheck("twiml", twimlResponse.ok && Boolean(streamUrl) ? "pass" : "fail", streamUrl ? "TwiML includes a media stream URL." : 'TwiML is missing <Stream url="...">.', {
9580
+ status: twimlResponse.status,
9581
+ streamUrl
9582
+ }));
9583
+ checks.push(createSmokeCheck("stream-url", streamUrl?.startsWith("wss://") ? "pass" : "fail", streamUrl?.startsWith("wss://") ? "Media stream URL uses wss://." : "Media stream URL should use wss:// for Twilio.", {
9584
+ streamUrl
9585
+ }));
9586
+ const webhookBody = {
9587
+ CallSid: input.options.smoke?.callSid ?? "CA_SMOKE_TEST",
9588
+ CallStatus: input.options.smoke?.status ?? "busy",
9589
+ SipResponseCode: String(input.options.smoke?.sipCode ?? 486)
9590
+ };
9591
+ const webhookHeaders = new Headers({
9592
+ "content-type": "application/x-www-form-urlencoded"
9593
+ });
9594
+ const verificationUrl = setup.signing.verificationUrl ?? setup.urls.webhook;
9595
+ if (input.options.webhook?.signingSecret) {
9596
+ webhookHeaders.set("x-twilio-signature", await signVoiceTwilioWebhook({
9597
+ authToken: input.options.webhook.signingSecret,
9598
+ body: webhookBody,
9599
+ url: verificationUrl
9600
+ }));
9601
+ }
9602
+ const webhookResponse = await input.app.handle(new Request(setup.urls.webhook, {
9603
+ body: new URLSearchParams(webhookBody),
9604
+ headers: webhookHeaders,
9605
+ method: "POST"
9606
+ }));
9607
+ const webhookText = await webhookResponse.text();
9608
+ const webhookPayload = (() => {
9609
+ try {
9610
+ return JSON.parse(webhookText);
9611
+ } catch {
9612
+ return webhookText;
9613
+ }
9614
+ })();
9615
+ checks.push(createSmokeCheck("webhook", webhookResponse.ok ? "pass" : "fail", webhookResponse.ok ? "Synthetic Twilio status callback was accepted." : "Synthetic Twilio status callback failed.", {
9616
+ status: webhookResponse.status
9617
+ }));
9618
+ for (const warning of setup.warnings) {
9619
+ checks.push(createSmokeCheck("setup-warning", "warn", warning));
9620
+ }
9621
+ for (const name of setup.missing) {
9622
+ checks.push(createSmokeCheck("missing-env", "fail", `${name} is missing.`));
9623
+ }
9624
+ return {
9625
+ checks,
9626
+ generatedAt: Date.now(),
9627
+ pass: checks.every((check) => check.status !== "fail"),
9628
+ provider: "twilio",
9629
+ setup,
9630
+ twiml: {
9631
+ status: twimlResponse.status,
9632
+ streamUrl
9633
+ },
9634
+ webhook: {
9635
+ body: webhookPayload,
9636
+ status: webhookResponse.status
9637
+ }
9638
+ };
9639
+ };
7687
9640
  var normalizeOnTurn = (handler) => {
7688
9641
  if (handler.length > 1) {
7689
9642
  const directHandler = handler;
@@ -7785,7 +9738,7 @@ var bytesToInt16Array = (bytes) => {
7785
9738
  return output;
7786
9739
  };
7787
9740
  var decodeTwilioMulawBase64 = (payload) => {
7788
- const bytes = Uint8Array.from(Buffer2.from(payload, "base64"));
9741
+ const bytes = Uint8Array.from(Buffer3.from(payload, "base64"));
7789
9742
  const samples = new Int16Array(bytes.length);
7790
9743
  for (let index = 0;index < bytes.length; index += 1) {
7791
9744
  samples[index] = decodeMulawSample(bytes[index] ?? 0);
@@ -7797,7 +9750,7 @@ var encodeTwilioMulawBase64 = (samples) => {
7797
9750
  for (let index = 0;index < samples.length; index += 1) {
7798
9751
  bytes[index] = encodeMulawSample(samples[index] ?? 0);
7799
9752
  }
7800
- return Buffer2.from(bytes).toString("base64");
9753
+ return Buffer3.from(bytes).toString("base64");
7801
9754
  };
7802
9755
  var transcodeTwilioInboundPayloadToPCM16 = (payload) => {
7803
9756
  const narrowband = decodeTwilioMulawBase64(payload);
@@ -7806,7 +9759,7 @@ var transcodeTwilioInboundPayloadToPCM16 = (payload) => {
7806
9759
  };
7807
9760
  var transcodePCMToTwilioOutboundPayload = (chunk, format) => {
7808
9761
  if (format.container === "raw" && format.encoding === "mulaw" && format.channels === 1 && format.sampleRateHz === TWILIO_MULAW_SAMPLE_RATE) {
7809
- return Buffer2.from(chunk).toString("base64");
9762
+ return Buffer3.from(chunk).toString("base64");
7810
9763
  }
7811
9764
  if (format.encoding !== "pcm_s16le") {
7812
9765
  throw new Error(`Unsupported outbound telephony audio format: ${format.container}/${format.encoding}`);
@@ -7847,7 +9800,7 @@ var createTwilioSocketAdapter = (socket, getState) => ({
7847
9800
  return;
7848
9801
  }
7849
9802
  if (message.type === "audio") {
7850
- const payload = transcodePCMToTwilioOutboundPayload(Uint8Array.from(Buffer2.from(message.chunkBase64, "base64")), message.format);
9803
+ const payload = transcodePCMToTwilioOutboundPayload(Uint8Array.from(Buffer3.from(message.chunkBase64, "base64")), message.format);
7851
9804
  state.hasOutboundAudioSinceLastInbound = true;
7852
9805
  state.reviewRecorder?.recordTwilioOutbound({
7853
9806
  bytes: payload.length,
@@ -8060,6 +10013,148 @@ var createTwilioMediaStreamBridge = (socket, options) => {
8060
10013
  }
8061
10014
  };
8062
10015
  };
10016
+ var createTwilioVoiceRoutes = (options) => {
10017
+ const streamPath = options.streamPath ?? "/api/voice/twilio/stream";
10018
+ const twimlPath = options.twiml?.path ?? "/api/voice/twilio";
10019
+ const webhookPath = options.webhook?.path ?? "/api/voice/twilio/webhook";
10020
+ const setupPath = options.setup?.path === false ? false : options.setup?.path ?? "/api/voice/twilio/setup";
10021
+ const smokePath = options.smoke?.path === false ? false : options.smoke?.path ?? "/api/voice/twilio/smoke";
10022
+ const bridges = new WeakMap;
10023
+ const webhookPolicy = options.webhook?.policy ?? options.outcomePolicy ?? createVoiceTelephonyOutcomePolicy();
10024
+ const app = new Elysia2({
10025
+ name: options.name ?? "absolutejs-voice-twilio"
10026
+ }).get(twimlPath, async ({ query, request }) => {
10027
+ const streamUrl = await resolveTwilioStreamUrl(options, {
10028
+ query,
10029
+ request,
10030
+ streamPath
10031
+ });
10032
+ const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
10033
+ query,
10034
+ request
10035
+ });
10036
+ return new Response(createTwilioVoiceResponse({
10037
+ parameters,
10038
+ streamName: options.twiml?.streamName,
10039
+ streamUrl,
10040
+ track: options.twiml?.track
10041
+ }), {
10042
+ headers: {
10043
+ "content-type": "text/xml; charset=utf-8"
10044
+ }
10045
+ });
10046
+ }).post(twimlPath, async ({ query, request }) => {
10047
+ const streamUrl = await resolveTwilioStreamUrl(options, {
10048
+ query,
10049
+ request,
10050
+ streamPath
10051
+ });
10052
+ const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
10053
+ query,
10054
+ request
10055
+ });
10056
+ return new Response(createTwilioVoiceResponse({
10057
+ parameters,
10058
+ streamName: options.twiml?.streamName,
10059
+ streamUrl,
10060
+ track: options.twiml?.track
10061
+ }), {
10062
+ headers: {
10063
+ "content-type": "text/xml; charset=utf-8"
10064
+ }
10065
+ });
10066
+ }).ws(streamPath, {
10067
+ close: async (ws, _code, reason) => {
10068
+ const bridge = bridges.get(ws);
10069
+ bridges.delete(ws);
10070
+ await bridge?.close(reason);
10071
+ },
10072
+ message: async (ws, raw) => {
10073
+ let bridge = bridges.get(ws);
10074
+ if (!bridge) {
10075
+ bridge = createTwilioMediaStreamBridge({
10076
+ close: (code, reason) => {
10077
+ ws.close(code, reason);
10078
+ },
10079
+ send: (data) => {
10080
+ ws.send(data);
10081
+ }
10082
+ }, options);
10083
+ bridges.set(ws, bridge);
10084
+ }
10085
+ await bridge.handleMessage(raw);
10086
+ }
10087
+ }).use(createVoiceTelephonyWebhookRoutes({
10088
+ ...options.webhook ?? {},
10089
+ context: options.context,
10090
+ path: webhookPath,
10091
+ policy: webhookPolicy,
10092
+ provider: "twilio"
10093
+ }));
10094
+ if (!setupPath) {
10095
+ if (!smokePath) {
10096
+ return app;
10097
+ }
10098
+ return app.get(smokePath, async ({ query, request }) => {
10099
+ const report = await runTwilioVoiceSmokeTest({
10100
+ app,
10101
+ options,
10102
+ query,
10103
+ request,
10104
+ streamPath,
10105
+ twimlPath,
10106
+ webhookPath
10107
+ });
10108
+ if (query.format === "html") {
10109
+ return new Response(renderTwilioVoiceSmokeHTML(report, options.smoke?.title ?? "AbsoluteJS Twilio Voice Smoke Test"), {
10110
+ headers: {
10111
+ "content-type": "text/html; charset=utf-8"
10112
+ }
10113
+ });
10114
+ }
10115
+ return report;
10116
+ });
10117
+ }
10118
+ const withSetup = app.get(setupPath, async ({ query, request }) => {
10119
+ const status = await buildTwilioVoiceSetupStatus(options, {
10120
+ query,
10121
+ request,
10122
+ streamPath,
10123
+ twimlPath,
10124
+ webhookPath
10125
+ });
10126
+ if (query.format === "html") {
10127
+ return new Response(renderTwilioVoiceSetupHTML(status, options.setup?.title ?? "AbsoluteJS Twilio Voice Setup"), {
10128
+ headers: {
10129
+ "content-type": "text/html; charset=utf-8"
10130
+ }
10131
+ });
10132
+ }
10133
+ return status;
10134
+ });
10135
+ if (!smokePath) {
10136
+ return withSetup;
10137
+ }
10138
+ return withSetup.get(smokePath, async ({ query, request }) => {
10139
+ const report = await runTwilioVoiceSmokeTest({
10140
+ app,
10141
+ options,
10142
+ query,
10143
+ request,
10144
+ streamPath,
10145
+ twimlPath,
10146
+ webhookPath
10147
+ });
10148
+ if (query.format === "html") {
10149
+ return new Response(renderTwilioVoiceSmokeHTML(report, options.smoke?.title ?? "AbsoluteJS Twilio Voice Smoke Test"), {
10150
+ headers: {
10151
+ "content-type": "text/html; charset=utf-8"
10152
+ }
10153
+ });
10154
+ }
10155
+ return report;
10156
+ });
10157
+ };
8063
10158
 
8064
10159
  // src/testing/telephony.ts
8065
10160
  var DEFAULT_PCM16_FORMAT = {
@@ -8325,7 +10420,7 @@ var runVoiceTelephonyBenchmark = async (scenarios = getDefaultVoiceTelephonyBenc
8325
10420
  };
8326
10421
  };
8327
10422
  // src/testing/tts.ts
8328
- var DEFAULT_REALTIME_FORMAT = {
10423
+ var DEFAULT_REALTIME_FORMAT2 = {
8329
10424
  channels: 1,
8330
10425
  container: "raw",
8331
10426
  encoding: "pcm_s16le",
@@ -8384,7 +10479,7 @@ var runTTSAdapterFixture = async (adapter, fixture, options = {}) => {
8384
10479
  let audioDurationMs = 0;
8385
10480
  let audioChunkCount = 0;
8386
10481
  const session = adapter.kind === "realtime" ? await adapter.open({
8387
- format: options.realtimeFormat ?? DEFAULT_REALTIME_FORMAT,
10482
+ format: options.realtimeFormat ?? DEFAULT_REALTIME_FORMAT2,
8388
10483
  sessionId: `tts-benchmark:${fixture.id}`,
8389
10484
  ...openOptions ?? {}
8390
10485
  }) : await adapter.open({
@@ -8551,6 +10646,7 @@ export {
8551
10646
  getDefaultTTSBenchmarkFixtures,
8552
10647
  evaluateSTTBenchmarkAcceptance,
8553
10648
  createVoiceProviderFailureSimulator,
10649
+ createVoiceIOProviderFailureSimulator,
8554
10650
  createVoiceCallReviewRecorder,
8555
10651
  createVoiceCallReviewFromLiveTelephonyReport,
8556
10652
  createTelephonyVoiceTestFixtures,