@absolutejs/voice 0.0.22-beta.321 → 0.0.22-beta.323
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/dist/angular/index.js +233 -7
- package/dist/browserMediaRoutes.d.ts +2 -1
- package/dist/client/htmxBootstrap.js +69 -7
- package/dist/client/index.js +233 -7
- package/dist/index.d.ts +2 -0
- package/dist/index.js +816 -444
- package/dist/react/index.js +233 -7
- package/dist/svelte/index.js +233 -7
- package/dist/telephonyMediaRoutes.d.ts +63 -0
- package/dist/testing/index.js +246 -20
- package/dist/types.d.ts +3 -1
- package/dist/vue/index.js +233 -7
- package/package.json +2 -2
package/dist/react/index.js
CHANGED
|
@@ -5048,7 +5048,65 @@ var stringStat = (stat, key) => {
|
|
|
5048
5048
|
const value = stat[key];
|
|
5049
5049
|
return typeof value === "string" ? value : undefined;
|
|
5050
5050
|
};
|
|
5051
|
+
var statKey = (stat) => String(stat.id ?? stringStat(stat, "ssrc") ?? numericStat(stat, "ssrc") ?? stringStat(stat, "trackIdentifier") ?? stringStat(stat, "mid") ?? "unknown");
|
|
5051
5052
|
var secondsToMs = (value) => value === undefined ? undefined : value * 1000;
|
|
5053
|
+
var DEFAULT_TELEPHONY_FORMAT = {
|
|
5054
|
+
channels: 1,
|
|
5055
|
+
container: "raw",
|
|
5056
|
+
encoding: "mulaw",
|
|
5057
|
+
sampleRateHz: 8000
|
|
5058
|
+
};
|
|
5059
|
+
var bytesToBase64 = (audio) => {
|
|
5060
|
+
const bytes = audio instanceof ArrayBuffer ? new Uint8Array(audio) : new Uint8Array(audio.buffer, audio.byteOffset, audio.byteLength);
|
|
5061
|
+
return Buffer.from(bytes).toString("base64");
|
|
5062
|
+
};
|
|
5063
|
+
var base64ToBytes = (value) => new Uint8Array(Buffer.from(value, "base64"));
|
|
5064
|
+
var unknownRecord = (value) => value && typeof value === "object" ? value : {};
|
|
5065
|
+
var firstString = (records, keys) => {
|
|
5066
|
+
for (const record of records) {
|
|
5067
|
+
for (const key of keys) {
|
|
5068
|
+
const value = record[key];
|
|
5069
|
+
if (typeof value === "string" && value.length > 0) {
|
|
5070
|
+
return value;
|
|
5071
|
+
}
|
|
5072
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
5073
|
+
return String(value);
|
|
5074
|
+
}
|
|
5075
|
+
}
|
|
5076
|
+
}
|
|
5077
|
+
return;
|
|
5078
|
+
};
|
|
5079
|
+
var firstNumber = (records, keys) => {
|
|
5080
|
+
for (const record of records) {
|
|
5081
|
+
for (const key of keys) {
|
|
5082
|
+
const value = record[key];
|
|
5083
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
5084
|
+
return value;
|
|
5085
|
+
}
|
|
5086
|
+
if (typeof value === "string") {
|
|
5087
|
+
const parsed = Number(value);
|
|
5088
|
+
if (Number.isFinite(parsed)) {
|
|
5089
|
+
return parsed;
|
|
5090
|
+
}
|
|
5091
|
+
}
|
|
5092
|
+
}
|
|
5093
|
+
}
|
|
5094
|
+
return;
|
|
5095
|
+
};
|
|
5096
|
+
var telephonyDirection = (track) => {
|
|
5097
|
+
const normalized = track?.toLowerCase();
|
|
5098
|
+
if (!normalized) {
|
|
5099
|
+
return "unknown";
|
|
5100
|
+
}
|
|
5101
|
+
if (normalized.includes("inbound") || normalized.includes("caller") || normalized.includes("in")) {
|
|
5102
|
+
return "inbound";
|
|
5103
|
+
}
|
|
5104
|
+
if (normalized.includes("outbound") || normalized.includes("assistant") || normalized.includes("out")) {
|
|
5105
|
+
return "outbound";
|
|
5106
|
+
}
|
|
5107
|
+
return "unknown";
|
|
5108
|
+
};
|
|
5109
|
+
var telephonyFrameKind = (direction) => direction === "outbound" ? "assistant-audio" : "input-audio";
|
|
5052
5110
|
var normalizeWebRTCStat = (stat) => {
|
|
5053
5111
|
const sample = {};
|
|
5054
5112
|
for (const [key, value] of Object.entries(stat)) {
|
|
@@ -5058,6 +5116,113 @@ var normalizeWebRTCStat = (stat) => {
|
|
|
5058
5116
|
}
|
|
5059
5117
|
return sample;
|
|
5060
5118
|
};
|
|
5119
|
+
var parseTelephonyMediaFrame = (input) => {
|
|
5120
|
+
const envelope = input.envelope;
|
|
5121
|
+
const media = unknownRecord(envelope.media);
|
|
5122
|
+
const payload = firstString([media, envelope], ["payload", "audio", "data"]) ?? firstString([unknownRecord(envelope.message)], ["payload"]);
|
|
5123
|
+
if (!payload) {
|
|
5124
|
+
return;
|
|
5125
|
+
}
|
|
5126
|
+
const carrier = input.carrier ?? firstString([envelope], ["provider"]) ?? "telephony";
|
|
5127
|
+
const streamId = firstString([media, envelope], ["streamSid", "stream_id", "streamId", "streamId", "callSid", "call_id"]);
|
|
5128
|
+
const sequenceNumber = firstString([media, envelope], ["sequenceNumber", "sequence_number", "chunk"]);
|
|
5129
|
+
const track = firstString([media, envelope], ["track", "direction"]);
|
|
5130
|
+
const direction = telephonyDirection(track);
|
|
5131
|
+
const timestamp = firstNumber([media, envelope], ["timestamp", "time", "startedAt"]);
|
|
5132
|
+
return {
|
|
5133
|
+
at: timestamp,
|
|
5134
|
+
audio: base64ToBytes(payload),
|
|
5135
|
+
format: input.format ?? DEFAULT_TELEPHONY_FORMAT,
|
|
5136
|
+
id: [
|
|
5137
|
+
carrier,
|
|
5138
|
+
streamId ?? input.sessionId ?? "stream",
|
|
5139
|
+
sequenceNumber ?? timestamp ?? Date.now()
|
|
5140
|
+
].join(":"),
|
|
5141
|
+
kind: telephonyFrameKind(direction),
|
|
5142
|
+
metadata: {
|
|
5143
|
+
carrier,
|
|
5144
|
+
direction,
|
|
5145
|
+
event: firstString([envelope], ["event", "type"]),
|
|
5146
|
+
sequenceNumber,
|
|
5147
|
+
streamId,
|
|
5148
|
+
track
|
|
5149
|
+
},
|
|
5150
|
+
sessionId: input.sessionId ?? streamId,
|
|
5151
|
+
source: "telephony"
|
|
5152
|
+
};
|
|
5153
|
+
};
|
|
5154
|
+
var serializeTelephonyMediaFrame = (input) => {
|
|
5155
|
+
const carrier = input.carrier ?? input.frame.metadata?.carrier ?? "telephony";
|
|
5156
|
+
const streamId = input.streamId ?? (typeof input.frame.metadata?.streamId === "string" ? input.frame.metadata.streamId : input.frame.sessionId);
|
|
5157
|
+
const sequenceNumber = input.sequenceNumber ?? (typeof input.frame.metadata?.sequenceNumber === "string" || typeof input.frame.metadata?.sequenceNumber === "number" ? input.frame.metadata.sequenceNumber : undefined);
|
|
5158
|
+
const direction = input.frame.kind === "assistant-audio" ? "outbound" : "inbound";
|
|
5159
|
+
const payload = input.frame.audio ? bytesToBase64(input.frame.audio) : "";
|
|
5160
|
+
if (carrier === "twilio") {
|
|
5161
|
+
return {
|
|
5162
|
+
event: "media",
|
|
5163
|
+
sequenceNumber,
|
|
5164
|
+
streamSid: streamId,
|
|
5165
|
+
media: {
|
|
5166
|
+
payload,
|
|
5167
|
+
timestamp: input.frame.at,
|
|
5168
|
+
track: direction
|
|
5169
|
+
}
|
|
5170
|
+
};
|
|
5171
|
+
}
|
|
5172
|
+
if (carrier === "telnyx") {
|
|
5173
|
+
return {
|
|
5174
|
+
event: "media",
|
|
5175
|
+
stream_id: streamId,
|
|
5176
|
+
sequence_number: sequenceNumber,
|
|
5177
|
+
media: {
|
|
5178
|
+
payload,
|
|
5179
|
+
timestamp: input.frame.at,
|
|
5180
|
+
track: direction
|
|
5181
|
+
}
|
|
5182
|
+
};
|
|
5183
|
+
}
|
|
5184
|
+
if (carrier === "plivo") {
|
|
5185
|
+
return {
|
|
5186
|
+
event: "media",
|
|
5187
|
+
streamId,
|
|
5188
|
+
sequenceNumber,
|
|
5189
|
+
media: {
|
|
5190
|
+
payload,
|
|
5191
|
+
timestamp: input.frame.at,
|
|
5192
|
+
track: direction
|
|
5193
|
+
}
|
|
5194
|
+
};
|
|
5195
|
+
}
|
|
5196
|
+
return {
|
|
5197
|
+
event: "media",
|
|
5198
|
+
provider: carrier,
|
|
5199
|
+
sequenceNumber,
|
|
5200
|
+
streamId,
|
|
5201
|
+
media: {
|
|
5202
|
+
payload,
|
|
5203
|
+
timestamp: input.frame.at,
|
|
5204
|
+
track: direction
|
|
5205
|
+
}
|
|
5206
|
+
};
|
|
5207
|
+
};
|
|
5208
|
+
var createTelephonyMediaSerializer = (input) => {
|
|
5209
|
+
const format = input.format ?? DEFAULT_TELEPHONY_FORMAT;
|
|
5210
|
+
return {
|
|
5211
|
+
carrier: input.carrier,
|
|
5212
|
+
format,
|
|
5213
|
+
parse: (envelope) => parseTelephonyMediaFrame({
|
|
5214
|
+
carrier: input.carrier,
|
|
5215
|
+
envelope,
|
|
5216
|
+
format,
|
|
5217
|
+
sessionId: input.sessionId ?? input.streamId
|
|
5218
|
+
}),
|
|
5219
|
+
serialize: (frame) => serializeTelephonyMediaFrame({
|
|
5220
|
+
carrier: input.carrier,
|
|
5221
|
+
frame,
|
|
5222
|
+
streamId: input.streamId
|
|
5223
|
+
})
|
|
5224
|
+
};
|
|
5225
|
+
};
|
|
5061
5226
|
var buildMediaResamplingPlan = (input) => {
|
|
5062
5227
|
const required = !formatMatches(input.inputFormat, input.outputFormat);
|
|
5063
5228
|
return {
|
|
@@ -5294,12 +5459,64 @@ var collectMediaWebRTCStats = async (input) => {
|
|
|
5294
5459
|
const report = await input.peerConnection.getStats(input.selector ?? null);
|
|
5295
5460
|
return [...report.values()].map(normalizeWebRTCStat);
|
|
5296
5461
|
};
|
|
5297
|
-
var
|
|
5298
|
-
const stats =
|
|
5299
|
-
|
|
5300
|
-
|
|
5301
|
-
|
|
5462
|
+
var buildMediaWebRTCStreamContinuityReport = (input = {}) => {
|
|
5463
|
+
const stats = input.stats ?? [];
|
|
5464
|
+
const previousStats = input.previousStats ?? [];
|
|
5465
|
+
const issues = [];
|
|
5466
|
+
const previousByKey = new Map(previousStats.map((stat) => [statKey(stat), stat]));
|
|
5467
|
+
const audioRtp = stats.filter((stat) => (stat.type === "inbound-rtp" || stat.type === "outbound-rtp") && stringStat(stat, "kind") !== "video" && stringStat(stat, "mediaType") !== "video");
|
|
5468
|
+
const streams = audioRtp.map((stat) => {
|
|
5469
|
+
const direction = stat.type === "outbound-rtp" ? "outbound" : "inbound";
|
|
5470
|
+
const packetsKey = direction === "outbound" ? "packetsSent" : "packetsReceived";
|
|
5471
|
+
const bytesKey = direction === "outbound" ? "bytesSent" : "bytesReceived";
|
|
5472
|
+
const previous = previousByKey.get(statKey(stat));
|
|
5473
|
+
const currentPackets = numericStat(stat, packetsKey);
|
|
5474
|
+
const previousPackets = previous ? numericStat(previous, packetsKey) : undefined;
|
|
5475
|
+
const currentBytes = numericStat(stat, bytesKey);
|
|
5476
|
+
const previousBytes = previous ? numericStat(previous, bytesKey) : undefined;
|
|
5477
|
+
const timeDeltaMs = stat.timestamp !== undefined && previous?.timestamp !== undefined ? stat.timestamp - previous.timestamp : undefined;
|
|
5478
|
+
return {
|
|
5479
|
+
bytesDelta: currentBytes !== undefined && previousBytes !== undefined ? currentBytes - previousBytes : undefined,
|
|
5480
|
+
currentPackets,
|
|
5481
|
+
direction,
|
|
5482
|
+
id: statKey(stat),
|
|
5483
|
+
packetDelta: currentPackets !== undefined && previousPackets !== undefined ? currentPackets - previousPackets : undefined,
|
|
5484
|
+
previousPackets,
|
|
5485
|
+
timeDeltaMs
|
|
5486
|
+
};
|
|
5302
5487
|
});
|
|
5488
|
+
const inbound = streams.filter((stream) => stream.direction === "inbound");
|
|
5489
|
+
const outbound = streams.filter((stream) => stream.direction === "outbound");
|
|
5490
|
+
const maxObservedGapMs = max(streams.map((stream) => stream.timeDeltaMs).filter((value) => value !== undefined));
|
|
5491
|
+
const stalledInboundStreams = inbound.filter((stream) => input.maxInboundPacketStallMs !== undefined && stream.timeDeltaMs !== undefined && stream.timeDeltaMs >= input.maxInboundPacketStallMs && stream.packetDelta !== undefined && stream.packetDelta <= 0).length;
|
|
5492
|
+
const stalledOutboundStreams = outbound.filter((stream) => input.maxOutboundPacketStallMs !== undefined && stream.timeDeltaMs !== undefined && stream.timeDeltaMs >= input.maxOutboundPacketStallMs && stream.packetDelta !== undefined && stream.packetDelta <= 0).length;
|
|
5493
|
+
if (input.requireInboundAudio && inbound.length === 0) {
|
|
5494
|
+
pushIssue(issues, "error", "media.webrtc_inbound_audio_missing", "No inbound WebRTC audio RTP stream was observed.");
|
|
5495
|
+
}
|
|
5496
|
+
if (input.requireOutboundAudio && outbound.length === 0) {
|
|
5497
|
+
pushIssue(issues, "error", "media.webrtc_outbound_audio_missing", "No outbound WebRTC audio RTP stream was observed.");
|
|
5498
|
+
}
|
|
5499
|
+
if (input.maxGapMs !== undefined && maxObservedGapMs !== undefined && maxObservedGapMs > input.maxGapMs) {
|
|
5500
|
+
pushIssue(issues, "warning", "media.webrtc_stream_gap", `Observed WebRTC stream sample gap ${String(maxObservedGapMs)}ms above ${String(input.maxGapMs)}ms.`);
|
|
5501
|
+
}
|
|
5502
|
+
if (stalledInboundStreams > 0) {
|
|
5503
|
+
pushIssue(issues, "error", "media.webrtc_inbound_stalled", `${String(stalledInboundStreams)} inbound WebRTC audio stream(s) stopped receiving packets.`);
|
|
5504
|
+
}
|
|
5505
|
+
if (stalledOutboundStreams > 0) {
|
|
5506
|
+
pushIssue(issues, "error", "media.webrtc_outbound_stalled", `${String(stalledOutboundStreams)} outbound WebRTC audio stream(s) stopped sending packets.`);
|
|
5507
|
+
}
|
|
5508
|
+
return {
|
|
5509
|
+
checkedAt: Date.now(),
|
|
5510
|
+
inboundAudioStreams: inbound.length,
|
|
5511
|
+
issues,
|
|
5512
|
+
maxObservedGapMs,
|
|
5513
|
+
outboundAudioStreams: outbound.length,
|
|
5514
|
+
stalledInboundStreams,
|
|
5515
|
+
stalledOutboundStreams,
|
|
5516
|
+
status: issues.some((issue) => issue.severity === "error") ? "fail" : issues.length > 0 ? "warn" : "pass",
|
|
5517
|
+
streams,
|
|
5518
|
+
totalStats: stats.length
|
|
5519
|
+
};
|
|
5303
5520
|
};
|
|
5304
5521
|
var buildMediaPipelineCalibrationReport = (input = {}) => {
|
|
5305
5522
|
const frames = input.frames ?? [];
|
|
@@ -5385,21 +5602,30 @@ var postBrowserMediaReport = async (payload, options) => {
|
|
|
5385
5602
|
};
|
|
5386
5603
|
var createVoiceBrowserMediaReporter = (options) => {
|
|
5387
5604
|
let interval = null;
|
|
5605
|
+
let previousStats = [];
|
|
5388
5606
|
const reportOnce = async () => {
|
|
5389
5607
|
const peerConnection = await resolvePeerConnection(options);
|
|
5390
5608
|
if (!peerConnection) {
|
|
5391
5609
|
return;
|
|
5392
5610
|
}
|
|
5393
|
-
const
|
|
5611
|
+
const stats = await collectMediaWebRTCStats({ peerConnection });
|
|
5612
|
+
const report = buildMediaWebRTCStatsReport({
|
|
5394
5613
|
...options,
|
|
5395
|
-
|
|
5614
|
+
stats
|
|
5615
|
+
});
|
|
5616
|
+
const continuity = options.continuity === false ? undefined : buildMediaWebRTCStreamContinuityReport({
|
|
5617
|
+
...options.continuity,
|
|
5618
|
+
previousStats,
|
|
5619
|
+
stats
|
|
5396
5620
|
});
|
|
5397
5621
|
const payload = {
|
|
5398
5622
|
at: Date.now(),
|
|
5623
|
+
continuity,
|
|
5399
5624
|
report,
|
|
5400
5625
|
scenarioId: options.getScenarioId?.() ?? null,
|
|
5401
5626
|
sessionId: options.getSessionId?.() ?? null
|
|
5402
5627
|
};
|
|
5628
|
+
previousStats = stats;
|
|
5403
5629
|
options.onReport?.(payload);
|
|
5404
5630
|
await postBrowserMediaReport(payload, options);
|
|
5405
5631
|
return payload;
|
package/dist/svelte/index.js
CHANGED
|
@@ -3382,7 +3382,65 @@ var stringStat = (stat, key) => {
|
|
|
3382
3382
|
const value = stat[key];
|
|
3383
3383
|
return typeof value === "string" ? value : undefined;
|
|
3384
3384
|
};
|
|
3385
|
+
var statKey = (stat) => String(stat.id ?? stringStat(stat, "ssrc") ?? numericStat(stat, "ssrc") ?? stringStat(stat, "trackIdentifier") ?? stringStat(stat, "mid") ?? "unknown");
|
|
3385
3386
|
var secondsToMs = (value) => value === undefined ? undefined : value * 1000;
|
|
3387
|
+
var DEFAULT_TELEPHONY_FORMAT = {
|
|
3388
|
+
channels: 1,
|
|
3389
|
+
container: "raw",
|
|
3390
|
+
encoding: "mulaw",
|
|
3391
|
+
sampleRateHz: 8000
|
|
3392
|
+
};
|
|
3393
|
+
var bytesToBase64 = (audio) => {
|
|
3394
|
+
const bytes = audio instanceof ArrayBuffer ? new Uint8Array(audio) : new Uint8Array(audio.buffer, audio.byteOffset, audio.byteLength);
|
|
3395
|
+
return Buffer.from(bytes).toString("base64");
|
|
3396
|
+
};
|
|
3397
|
+
var base64ToBytes = (value) => new Uint8Array(Buffer.from(value, "base64"));
|
|
3398
|
+
var unknownRecord = (value) => value && typeof value === "object" ? value : {};
|
|
3399
|
+
var firstString = (records, keys) => {
|
|
3400
|
+
for (const record of records) {
|
|
3401
|
+
for (const key of keys) {
|
|
3402
|
+
const value = record[key];
|
|
3403
|
+
if (typeof value === "string" && value.length > 0) {
|
|
3404
|
+
return value;
|
|
3405
|
+
}
|
|
3406
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
3407
|
+
return String(value);
|
|
3408
|
+
}
|
|
3409
|
+
}
|
|
3410
|
+
}
|
|
3411
|
+
return;
|
|
3412
|
+
};
|
|
3413
|
+
var firstNumber = (records, keys) => {
|
|
3414
|
+
for (const record of records) {
|
|
3415
|
+
for (const key of keys) {
|
|
3416
|
+
const value = record[key];
|
|
3417
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
3418
|
+
return value;
|
|
3419
|
+
}
|
|
3420
|
+
if (typeof value === "string") {
|
|
3421
|
+
const parsed = Number(value);
|
|
3422
|
+
if (Number.isFinite(parsed)) {
|
|
3423
|
+
return parsed;
|
|
3424
|
+
}
|
|
3425
|
+
}
|
|
3426
|
+
}
|
|
3427
|
+
}
|
|
3428
|
+
return;
|
|
3429
|
+
};
|
|
3430
|
+
var telephonyDirection = (track) => {
|
|
3431
|
+
const normalized = track?.toLowerCase();
|
|
3432
|
+
if (!normalized) {
|
|
3433
|
+
return "unknown";
|
|
3434
|
+
}
|
|
3435
|
+
if (normalized.includes("inbound") || normalized.includes("caller") || normalized.includes("in")) {
|
|
3436
|
+
return "inbound";
|
|
3437
|
+
}
|
|
3438
|
+
if (normalized.includes("outbound") || normalized.includes("assistant") || normalized.includes("out")) {
|
|
3439
|
+
return "outbound";
|
|
3440
|
+
}
|
|
3441
|
+
return "unknown";
|
|
3442
|
+
};
|
|
3443
|
+
var telephonyFrameKind = (direction) => direction === "outbound" ? "assistant-audio" : "input-audio";
|
|
3386
3444
|
var normalizeWebRTCStat = (stat) => {
|
|
3387
3445
|
const sample = {};
|
|
3388
3446
|
for (const [key, value] of Object.entries(stat)) {
|
|
@@ -3392,6 +3450,113 @@ var normalizeWebRTCStat = (stat) => {
|
|
|
3392
3450
|
}
|
|
3393
3451
|
return sample;
|
|
3394
3452
|
};
|
|
3453
|
+
var parseTelephonyMediaFrame = (input) => {
|
|
3454
|
+
const envelope = input.envelope;
|
|
3455
|
+
const media = unknownRecord(envelope.media);
|
|
3456
|
+
const payload = firstString([media, envelope], ["payload", "audio", "data"]) ?? firstString([unknownRecord(envelope.message)], ["payload"]);
|
|
3457
|
+
if (!payload) {
|
|
3458
|
+
return;
|
|
3459
|
+
}
|
|
3460
|
+
const carrier = input.carrier ?? firstString([envelope], ["provider"]) ?? "telephony";
|
|
3461
|
+
const streamId = firstString([media, envelope], ["streamSid", "stream_id", "streamId", "streamId", "callSid", "call_id"]);
|
|
3462
|
+
const sequenceNumber = firstString([media, envelope], ["sequenceNumber", "sequence_number", "chunk"]);
|
|
3463
|
+
const track = firstString([media, envelope], ["track", "direction"]);
|
|
3464
|
+
const direction = telephonyDirection(track);
|
|
3465
|
+
const timestamp = firstNumber([media, envelope], ["timestamp", "time", "startedAt"]);
|
|
3466
|
+
return {
|
|
3467
|
+
at: timestamp,
|
|
3468
|
+
audio: base64ToBytes(payload),
|
|
3469
|
+
format: input.format ?? DEFAULT_TELEPHONY_FORMAT,
|
|
3470
|
+
id: [
|
|
3471
|
+
carrier,
|
|
3472
|
+
streamId ?? input.sessionId ?? "stream",
|
|
3473
|
+
sequenceNumber ?? timestamp ?? Date.now()
|
|
3474
|
+
].join(":"),
|
|
3475
|
+
kind: telephonyFrameKind(direction),
|
|
3476
|
+
metadata: {
|
|
3477
|
+
carrier,
|
|
3478
|
+
direction,
|
|
3479
|
+
event: firstString([envelope], ["event", "type"]),
|
|
3480
|
+
sequenceNumber,
|
|
3481
|
+
streamId,
|
|
3482
|
+
track
|
|
3483
|
+
},
|
|
3484
|
+
sessionId: input.sessionId ?? streamId,
|
|
3485
|
+
source: "telephony"
|
|
3486
|
+
};
|
|
3487
|
+
};
|
|
3488
|
+
var serializeTelephonyMediaFrame = (input) => {
|
|
3489
|
+
const carrier = input.carrier ?? input.frame.metadata?.carrier ?? "telephony";
|
|
3490
|
+
const streamId = input.streamId ?? (typeof input.frame.metadata?.streamId === "string" ? input.frame.metadata.streamId : input.frame.sessionId);
|
|
3491
|
+
const sequenceNumber = input.sequenceNumber ?? (typeof input.frame.metadata?.sequenceNumber === "string" || typeof input.frame.metadata?.sequenceNumber === "number" ? input.frame.metadata.sequenceNumber : undefined);
|
|
3492
|
+
const direction = input.frame.kind === "assistant-audio" ? "outbound" : "inbound";
|
|
3493
|
+
const payload = input.frame.audio ? bytesToBase64(input.frame.audio) : "";
|
|
3494
|
+
if (carrier === "twilio") {
|
|
3495
|
+
return {
|
|
3496
|
+
event: "media",
|
|
3497
|
+
sequenceNumber,
|
|
3498
|
+
streamSid: streamId,
|
|
3499
|
+
media: {
|
|
3500
|
+
payload,
|
|
3501
|
+
timestamp: input.frame.at,
|
|
3502
|
+
track: direction
|
|
3503
|
+
}
|
|
3504
|
+
};
|
|
3505
|
+
}
|
|
3506
|
+
if (carrier === "telnyx") {
|
|
3507
|
+
return {
|
|
3508
|
+
event: "media",
|
|
3509
|
+
stream_id: streamId,
|
|
3510
|
+
sequence_number: sequenceNumber,
|
|
3511
|
+
media: {
|
|
3512
|
+
payload,
|
|
3513
|
+
timestamp: input.frame.at,
|
|
3514
|
+
track: direction
|
|
3515
|
+
}
|
|
3516
|
+
};
|
|
3517
|
+
}
|
|
3518
|
+
if (carrier === "plivo") {
|
|
3519
|
+
return {
|
|
3520
|
+
event: "media",
|
|
3521
|
+
streamId,
|
|
3522
|
+
sequenceNumber,
|
|
3523
|
+
media: {
|
|
3524
|
+
payload,
|
|
3525
|
+
timestamp: input.frame.at,
|
|
3526
|
+
track: direction
|
|
3527
|
+
}
|
|
3528
|
+
};
|
|
3529
|
+
}
|
|
3530
|
+
return {
|
|
3531
|
+
event: "media",
|
|
3532
|
+
provider: carrier,
|
|
3533
|
+
sequenceNumber,
|
|
3534
|
+
streamId,
|
|
3535
|
+
media: {
|
|
3536
|
+
payload,
|
|
3537
|
+
timestamp: input.frame.at,
|
|
3538
|
+
track: direction
|
|
3539
|
+
}
|
|
3540
|
+
};
|
|
3541
|
+
};
|
|
3542
|
+
var createTelephonyMediaSerializer = (input) => {
|
|
3543
|
+
const format = input.format ?? DEFAULT_TELEPHONY_FORMAT;
|
|
3544
|
+
return {
|
|
3545
|
+
carrier: input.carrier,
|
|
3546
|
+
format,
|
|
3547
|
+
parse: (envelope) => parseTelephonyMediaFrame({
|
|
3548
|
+
carrier: input.carrier,
|
|
3549
|
+
envelope,
|
|
3550
|
+
format,
|
|
3551
|
+
sessionId: input.sessionId ?? input.streamId
|
|
3552
|
+
}),
|
|
3553
|
+
serialize: (frame) => serializeTelephonyMediaFrame({
|
|
3554
|
+
carrier: input.carrier,
|
|
3555
|
+
frame,
|
|
3556
|
+
streamId: input.streamId
|
|
3557
|
+
})
|
|
3558
|
+
};
|
|
3559
|
+
};
|
|
3395
3560
|
var buildMediaResamplingPlan = (input) => {
|
|
3396
3561
|
const required = !formatMatches(input.inputFormat, input.outputFormat);
|
|
3397
3562
|
return {
|
|
@@ -3628,12 +3793,64 @@ var collectMediaWebRTCStats = async (input) => {
|
|
|
3628
3793
|
const report = await input.peerConnection.getStats(input.selector ?? null);
|
|
3629
3794
|
return [...report.values()].map(normalizeWebRTCStat);
|
|
3630
3795
|
};
|
|
3631
|
-
var
|
|
3632
|
-
const stats =
|
|
3633
|
-
|
|
3634
|
-
|
|
3635
|
-
|
|
3796
|
+
var buildMediaWebRTCStreamContinuityReport = (input = {}) => {
|
|
3797
|
+
const stats = input.stats ?? [];
|
|
3798
|
+
const previousStats = input.previousStats ?? [];
|
|
3799
|
+
const issues = [];
|
|
3800
|
+
const previousByKey = new Map(previousStats.map((stat) => [statKey(stat), stat]));
|
|
3801
|
+
const audioRtp = stats.filter((stat) => (stat.type === "inbound-rtp" || stat.type === "outbound-rtp") && stringStat(stat, "kind") !== "video" && stringStat(stat, "mediaType") !== "video");
|
|
3802
|
+
const streams = audioRtp.map((stat) => {
|
|
3803
|
+
const direction = stat.type === "outbound-rtp" ? "outbound" : "inbound";
|
|
3804
|
+
const packetsKey = direction === "outbound" ? "packetsSent" : "packetsReceived";
|
|
3805
|
+
const bytesKey = direction === "outbound" ? "bytesSent" : "bytesReceived";
|
|
3806
|
+
const previous = previousByKey.get(statKey(stat));
|
|
3807
|
+
const currentPackets = numericStat(stat, packetsKey);
|
|
3808
|
+
const previousPackets = previous ? numericStat(previous, packetsKey) : undefined;
|
|
3809
|
+
const currentBytes = numericStat(stat, bytesKey);
|
|
3810
|
+
const previousBytes = previous ? numericStat(previous, bytesKey) : undefined;
|
|
3811
|
+
const timeDeltaMs = stat.timestamp !== undefined && previous?.timestamp !== undefined ? stat.timestamp - previous.timestamp : undefined;
|
|
3812
|
+
return {
|
|
3813
|
+
bytesDelta: currentBytes !== undefined && previousBytes !== undefined ? currentBytes - previousBytes : undefined,
|
|
3814
|
+
currentPackets,
|
|
3815
|
+
direction,
|
|
3816
|
+
id: statKey(stat),
|
|
3817
|
+
packetDelta: currentPackets !== undefined && previousPackets !== undefined ? currentPackets - previousPackets : undefined,
|
|
3818
|
+
previousPackets,
|
|
3819
|
+
timeDeltaMs
|
|
3820
|
+
};
|
|
3636
3821
|
});
|
|
3822
|
+
const inbound = streams.filter((stream) => stream.direction === "inbound");
|
|
3823
|
+
const outbound = streams.filter((stream) => stream.direction === "outbound");
|
|
3824
|
+
const maxObservedGapMs = max(streams.map((stream) => stream.timeDeltaMs).filter((value) => value !== undefined));
|
|
3825
|
+
const stalledInboundStreams = inbound.filter((stream) => input.maxInboundPacketStallMs !== undefined && stream.timeDeltaMs !== undefined && stream.timeDeltaMs >= input.maxInboundPacketStallMs && stream.packetDelta !== undefined && stream.packetDelta <= 0).length;
|
|
3826
|
+
const stalledOutboundStreams = outbound.filter((stream) => input.maxOutboundPacketStallMs !== undefined && stream.timeDeltaMs !== undefined && stream.timeDeltaMs >= input.maxOutboundPacketStallMs && stream.packetDelta !== undefined && stream.packetDelta <= 0).length;
|
|
3827
|
+
if (input.requireInboundAudio && inbound.length === 0) {
|
|
3828
|
+
pushIssue(issues, "error", "media.webrtc_inbound_audio_missing", "No inbound WebRTC audio RTP stream was observed.");
|
|
3829
|
+
}
|
|
3830
|
+
if (input.requireOutboundAudio && outbound.length === 0) {
|
|
3831
|
+
pushIssue(issues, "error", "media.webrtc_outbound_audio_missing", "No outbound WebRTC audio RTP stream was observed.");
|
|
3832
|
+
}
|
|
3833
|
+
if (input.maxGapMs !== undefined && maxObservedGapMs !== undefined && maxObservedGapMs > input.maxGapMs) {
|
|
3834
|
+
pushIssue(issues, "warning", "media.webrtc_stream_gap", `Observed WebRTC stream sample gap ${String(maxObservedGapMs)}ms above ${String(input.maxGapMs)}ms.`);
|
|
3835
|
+
}
|
|
3836
|
+
if (stalledInboundStreams > 0) {
|
|
3837
|
+
pushIssue(issues, "error", "media.webrtc_inbound_stalled", `${String(stalledInboundStreams)} inbound WebRTC audio stream(s) stopped receiving packets.`);
|
|
3838
|
+
}
|
|
3839
|
+
if (stalledOutboundStreams > 0) {
|
|
3840
|
+
pushIssue(issues, "error", "media.webrtc_outbound_stalled", `${String(stalledOutboundStreams)} outbound WebRTC audio stream(s) stopped sending packets.`);
|
|
3841
|
+
}
|
|
3842
|
+
return {
|
|
3843
|
+
checkedAt: Date.now(),
|
|
3844
|
+
inboundAudioStreams: inbound.length,
|
|
3845
|
+
issues,
|
|
3846
|
+
maxObservedGapMs,
|
|
3847
|
+
outboundAudioStreams: outbound.length,
|
|
3848
|
+
stalledInboundStreams,
|
|
3849
|
+
stalledOutboundStreams,
|
|
3850
|
+
status: issues.some((issue) => issue.severity === "error") ? "fail" : issues.length > 0 ? "warn" : "pass",
|
|
3851
|
+
streams,
|
|
3852
|
+
totalStats: stats.length
|
|
3853
|
+
};
|
|
3637
3854
|
};
|
|
3638
3855
|
var buildMediaPipelineCalibrationReport = (input = {}) => {
|
|
3639
3856
|
const frames = input.frames ?? [];
|
|
@@ -3719,21 +3936,30 @@ var postBrowserMediaReport = async (payload, options) => {
|
|
|
3719
3936
|
};
|
|
3720
3937
|
var createVoiceBrowserMediaReporter = (options) => {
|
|
3721
3938
|
let interval = null;
|
|
3939
|
+
let previousStats = [];
|
|
3722
3940
|
const reportOnce = async () => {
|
|
3723
3941
|
const peerConnection = await resolvePeerConnection(options);
|
|
3724
3942
|
if (!peerConnection) {
|
|
3725
3943
|
return;
|
|
3726
3944
|
}
|
|
3727
|
-
const
|
|
3945
|
+
const stats = await collectMediaWebRTCStats({ peerConnection });
|
|
3946
|
+
const report = buildMediaWebRTCStatsReport({
|
|
3728
3947
|
...options,
|
|
3729
|
-
|
|
3948
|
+
stats
|
|
3949
|
+
});
|
|
3950
|
+
const continuity = options.continuity === false ? undefined : buildMediaWebRTCStreamContinuityReport({
|
|
3951
|
+
...options.continuity,
|
|
3952
|
+
previousStats,
|
|
3953
|
+
stats
|
|
3730
3954
|
});
|
|
3731
3955
|
const payload = {
|
|
3732
3956
|
at: Date.now(),
|
|
3957
|
+
continuity,
|
|
3733
3958
|
report,
|
|
3734
3959
|
scenarioId: options.getScenarioId?.() ?? null,
|
|
3735
3960
|
sessionId: options.getSessionId?.() ?? null
|
|
3736
3961
|
};
|
|
3962
|
+
previousStats = stats;
|
|
3737
3963
|
options.onReport?.(payload);
|
|
3738
3964
|
await postBrowserMediaReport(payload, options);
|
|
3739
3965
|
return payload;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Elysia } from 'elysia';
|
|
2
|
+
import type { MediaFrame, MediaTelephonyCarrier, MediaTelephonyEnvelope } from '@absolutejs/media';
|
|
3
|
+
export type VoiceTelephonyMediaStatus = 'fail' | 'pass';
|
|
4
|
+
export type VoiceTelephonyMediaCarrierInput = {
|
|
5
|
+
carrier: MediaTelephonyCarrier;
|
|
6
|
+
envelope?: MediaTelephonyEnvelope;
|
|
7
|
+
};
|
|
8
|
+
export type VoiceTelephonyMediaCarrierReport = {
|
|
9
|
+
audioBytes: number;
|
|
10
|
+
carrier: MediaTelephonyCarrier;
|
|
11
|
+
frame?: MediaFrame;
|
|
12
|
+
issues: string[];
|
|
13
|
+
serialized?: MediaTelephonyEnvelope;
|
|
14
|
+
status: VoiceTelephonyMediaStatus;
|
|
15
|
+
};
|
|
16
|
+
export type VoiceTelephonyMediaReport = {
|
|
17
|
+
carriers: readonly VoiceTelephonyMediaCarrierReport[];
|
|
18
|
+
checkedAt: number;
|
|
19
|
+
issues: string[];
|
|
20
|
+
status: VoiceTelephonyMediaStatus;
|
|
21
|
+
};
|
|
22
|
+
export type VoiceTelephonyMediaRoutesOptions = {
|
|
23
|
+
carriers?: readonly VoiceTelephonyMediaCarrierInput[];
|
|
24
|
+
headers?: HeadersInit;
|
|
25
|
+
htmlPath?: false | string;
|
|
26
|
+
name?: string;
|
|
27
|
+
path?: string;
|
|
28
|
+
title?: string;
|
|
29
|
+
};
|
|
30
|
+
export declare const buildVoiceTelephonyMediaReport: (input?: {
|
|
31
|
+
carriers?: readonly VoiceTelephonyMediaCarrierInput[];
|
|
32
|
+
}) => VoiceTelephonyMediaReport;
|
|
33
|
+
export declare const renderVoiceTelephonyMediaHTML: (report: VoiceTelephonyMediaReport, options?: {
|
|
34
|
+
title?: string;
|
|
35
|
+
}) => string;
|
|
36
|
+
export declare const createVoiceTelephonyMediaRoutes: (options?: VoiceTelephonyMediaRoutesOptions) => Elysia<"", {
|
|
37
|
+
decorator: {};
|
|
38
|
+
store: {};
|
|
39
|
+
derive: {};
|
|
40
|
+
resolve: {};
|
|
41
|
+
}, {
|
|
42
|
+
typebox: {};
|
|
43
|
+
error: {};
|
|
44
|
+
}, {
|
|
45
|
+
schema: {};
|
|
46
|
+
standaloneSchema: {};
|
|
47
|
+
macro: {};
|
|
48
|
+
macroFn: {};
|
|
49
|
+
parser: {};
|
|
50
|
+
response: {};
|
|
51
|
+
}, {}, {
|
|
52
|
+
derive: {};
|
|
53
|
+
resolve: {};
|
|
54
|
+
schema: {};
|
|
55
|
+
standaloneSchema: {};
|
|
56
|
+
response: {};
|
|
57
|
+
}, {
|
|
58
|
+
derive: {};
|
|
59
|
+
resolve: {};
|
|
60
|
+
schema: {};
|
|
61
|
+
standaloneSchema: {};
|
|
62
|
+
response: {};
|
|
63
|
+
}>;
|