@kognitivedev/telephony 0.2.29
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/.turbo/turbo-build.log +2 -0
- package/.turbo/turbo-test.log +13 -0
- package/CHANGELOG.md +10 -0
- package/README.md +98 -0
- package/dist/audio.d.ts +4 -0
- package/dist/audio.js +55 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +24 -0
- package/dist/registry.d.ts +15 -0
- package/dist/registry.js +45 -0
- package/dist/rtp.d.ts +28 -0
- package/dist/rtp.js +88 -0
- package/dist/twilio.d.ts +24 -0
- package/dist/twilio.js +136 -0
- package/dist/types.d.ts +242 -0
- package/dist/types.js +2 -0
- package/package.json +56 -0
- package/src/__tests__/audio.test.ts +58 -0
- package/src/__tests__/registry.test.ts +67 -0
- package/src/__tests__/rtp.test.ts +42 -0
- package/src/__tests__/twilio.test.ts +110 -0
- package/src/audio.ts +63 -0
- package/src/index.ts +22 -0
- package/src/registry.ts +56 -0
- package/src/rtp.ts +107 -0
- package/src/twilio.ts +208 -0
- package/src/types.ts +326 -0
- package/tsconfig.json +18 -0
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
export type TelephonyProvider = "twilio" | "telnyx" | "cm_com" | "didww" | "sinch" | "bird" | "sip_custom";
|
|
2
|
+
export type TelephonyConnectionMode = "provider_managed" | "byoc_trunk" | "sip_uri" | "pbx_extension";
|
|
3
|
+
export type TelephonyTransport = "udp" | "tcp" | "tls";
|
|
4
|
+
export type TelephonyMediaEncryption = "none" | "srtp";
|
|
5
|
+
export type TelephonyRegion = "eu" | "de" | "nl" | "uk" | "fr" | "tr" | "us" | "global" | string;
|
|
6
|
+
export type TelephonyCallDirection = "inbound" | "outbound";
|
|
7
|
+
export type TelephonyCallStatus = "queued" | "ringing" | "created" | "active" | "completed" | "failed" | "busy" | "no-answer" | "cancelled" | "unknown";
|
|
8
|
+
export interface TelephonyNumberCapabilities {
|
|
9
|
+
voice?: boolean;
|
|
10
|
+
sms?: boolean;
|
|
11
|
+
mms?: boolean;
|
|
12
|
+
sip?: boolean;
|
|
13
|
+
}
|
|
14
|
+
export interface TelephonyProviderCapabilities {
|
|
15
|
+
inboundPstn?: boolean;
|
|
16
|
+
outboundPstn?: boolean;
|
|
17
|
+
inboundSip?: boolean;
|
|
18
|
+
outboundSip?: boolean;
|
|
19
|
+
sipRefer?: boolean;
|
|
20
|
+
bridgeTransfer?: boolean;
|
|
21
|
+
blindTransfer?: boolean;
|
|
22
|
+
attendedTransfer?: boolean;
|
|
23
|
+
warmTransfer?: boolean;
|
|
24
|
+
hold?: boolean;
|
|
25
|
+
resume?: boolean;
|
|
26
|
+
conference?: boolean;
|
|
27
|
+
dtmf?: boolean;
|
|
28
|
+
recording?: boolean;
|
|
29
|
+
statusCallbacks?: boolean;
|
|
30
|
+
regions?: TelephonyRegion[];
|
|
31
|
+
transports?: TelephonyTransport[];
|
|
32
|
+
mediaEncryption?: TelephonyMediaEncryption[];
|
|
33
|
+
codecs?: string[];
|
|
34
|
+
}
|
|
35
|
+
export interface TelephonyConnection {
|
|
36
|
+
id: string;
|
|
37
|
+
provider: TelephonyProvider;
|
|
38
|
+
name: string;
|
|
39
|
+
mode: TelephonyConnectionMode;
|
|
40
|
+
status?: "draft" | "active" | "disabled" | "error";
|
|
41
|
+
inboundUri?: string;
|
|
42
|
+
outboundProxy?: string;
|
|
43
|
+
authMode?: "ip_acl" | "credentials" | "none";
|
|
44
|
+
username?: string;
|
|
45
|
+
secretRef?: string;
|
|
46
|
+
allowedIps?: string[];
|
|
47
|
+
transport?: TelephonyTransport;
|
|
48
|
+
mediaEncryption?: TelephonyMediaEncryption;
|
|
49
|
+
codecs?: string[];
|
|
50
|
+
region?: TelephonyRegion;
|
|
51
|
+
capabilities?: TelephonyProviderCapabilities;
|
|
52
|
+
metadata?: Record<string, unknown>;
|
|
53
|
+
}
|
|
54
|
+
export type TelephonyDestination = {
|
|
55
|
+
type: "phone_number";
|
|
56
|
+
phoneNumber: string;
|
|
57
|
+
connectionId?: string;
|
|
58
|
+
callerId?: string;
|
|
59
|
+
metadata?: Record<string, unknown>;
|
|
60
|
+
} | {
|
|
61
|
+
type: "sip_uri";
|
|
62
|
+
uri: string;
|
|
63
|
+
connectionId?: string;
|
|
64
|
+
transport?: TelephonyTransport;
|
|
65
|
+
metadata?: Record<string, unknown>;
|
|
66
|
+
} | {
|
|
67
|
+
type: "extension";
|
|
68
|
+
extension: string;
|
|
69
|
+
connectionId: string;
|
|
70
|
+
metadata?: Record<string, unknown>;
|
|
71
|
+
} | {
|
|
72
|
+
type: "queue";
|
|
73
|
+
queueId: string;
|
|
74
|
+
metadata?: Record<string, unknown>;
|
|
75
|
+
} | {
|
|
76
|
+
type: "browser_queue";
|
|
77
|
+
queueId?: string;
|
|
78
|
+
label?: string;
|
|
79
|
+
metadata?: Record<string, unknown>;
|
|
80
|
+
};
|
|
81
|
+
export type TelephonyTransferMode = "blind" | "attended" | "warm";
|
|
82
|
+
export type TelephonyTransferFallback = "return_to_ai" | "alternate_destination" | "voicemail" | "hangup";
|
|
83
|
+
export interface TelephonyTransferPolicy {
|
|
84
|
+
mode?: TelephonyTransferMode;
|
|
85
|
+
timeoutSeconds?: number;
|
|
86
|
+
fallback?: TelephonyTransferFallback;
|
|
87
|
+
fallbackDestination?: TelephonyDestination;
|
|
88
|
+
announceBeforeTransfer?: boolean;
|
|
89
|
+
allowSipRefer?: boolean;
|
|
90
|
+
}
|
|
91
|
+
export type TelephonyCallControlAction = "answer" | "reject" | "originate" | "bridge" | "transfer" | "hold" | "resume" | "conference" | "send_dtmf" | "hangup";
|
|
92
|
+
export interface TelephonyPhoneNumber {
|
|
93
|
+
id?: string;
|
|
94
|
+
provider: TelephonyProvider;
|
|
95
|
+
phoneNumber: string;
|
|
96
|
+
label?: string | null;
|
|
97
|
+
providerNumberId?: string | null;
|
|
98
|
+
capabilities?: TelephonyNumberCapabilities;
|
|
99
|
+
inboundEnabled?: boolean;
|
|
100
|
+
outboundEnabled?: boolean;
|
|
101
|
+
metadata?: Record<string, unknown>;
|
|
102
|
+
}
|
|
103
|
+
export interface TelephonyInboundCallInput {
|
|
104
|
+
provider: TelephonyProvider;
|
|
105
|
+
providerCallId: string;
|
|
106
|
+
from: string;
|
|
107
|
+
to: string;
|
|
108
|
+
accountId?: string;
|
|
109
|
+
direction?: "inbound" | "outbound" | "unknown";
|
|
110
|
+
raw?: Record<string, unknown>;
|
|
111
|
+
}
|
|
112
|
+
export interface TelephonyOutboundCallInput {
|
|
113
|
+
from: string;
|
|
114
|
+
to: string;
|
|
115
|
+
answerUrl: string;
|
|
116
|
+
statusCallbackUrl?: string;
|
|
117
|
+
providerCallId?: string;
|
|
118
|
+
metadata?: Record<string, unknown>;
|
|
119
|
+
}
|
|
120
|
+
export interface TelephonyOutboundCallResult {
|
|
121
|
+
provider: TelephonyProvider;
|
|
122
|
+
providerCallId: string;
|
|
123
|
+
status: TelephonyCallStatus;
|
|
124
|
+
from: string;
|
|
125
|
+
to: string;
|
|
126
|
+
raw?: unknown;
|
|
127
|
+
}
|
|
128
|
+
export interface TelephonyCallControlResult {
|
|
129
|
+
provider: TelephonyProvider;
|
|
130
|
+
providerCallId: string;
|
|
131
|
+
status: TelephonyCallStatus;
|
|
132
|
+
raw?: unknown;
|
|
133
|
+
}
|
|
134
|
+
export interface TelephonyBaseCallControlInput {
|
|
135
|
+
providerCallId: string;
|
|
136
|
+
connectionId?: string;
|
|
137
|
+
metadata?: Record<string, unknown>;
|
|
138
|
+
}
|
|
139
|
+
export interface TelephonyAnswerCallInput extends TelephonyBaseCallControlInput {
|
|
140
|
+
}
|
|
141
|
+
export interface TelephonyRejectCallInput extends TelephonyBaseCallControlInput {
|
|
142
|
+
reason?: string;
|
|
143
|
+
}
|
|
144
|
+
export interface TelephonyOriginateCallInput {
|
|
145
|
+
from?: TelephonyDestination;
|
|
146
|
+
to: TelephonyDestination;
|
|
147
|
+
connectionId?: string;
|
|
148
|
+
answerUrl?: string;
|
|
149
|
+
statusCallbackUrl?: string;
|
|
150
|
+
metadata?: Record<string, unknown>;
|
|
151
|
+
}
|
|
152
|
+
export interface TelephonyOriginateCallResult {
|
|
153
|
+
provider: TelephonyProvider;
|
|
154
|
+
providerCallId: string;
|
|
155
|
+
status: TelephonyCallStatus;
|
|
156
|
+
from?: TelephonyDestination;
|
|
157
|
+
to: TelephonyDestination;
|
|
158
|
+
raw?: unknown;
|
|
159
|
+
}
|
|
160
|
+
export interface TelephonyBridgeCallInput extends TelephonyBaseCallControlInput {
|
|
161
|
+
target: TelephonyDestination | {
|
|
162
|
+
providerCallId: string;
|
|
163
|
+
};
|
|
164
|
+
policy?: TelephonyTransferPolicy;
|
|
165
|
+
}
|
|
166
|
+
export interface TelephonyTransferCallInput extends TelephonyBaseCallControlInput {
|
|
167
|
+
destination: TelephonyDestination;
|
|
168
|
+
mode?: TelephonyTransferMode;
|
|
169
|
+
reason?: string;
|
|
170
|
+
policy?: TelephonyTransferPolicy;
|
|
171
|
+
}
|
|
172
|
+
export interface TelephonyTransferCallResult extends TelephonyCallControlResult {
|
|
173
|
+
destination: TelephonyDestination;
|
|
174
|
+
mode: TelephonyTransferMode;
|
|
175
|
+
transferId?: string;
|
|
176
|
+
}
|
|
177
|
+
export interface TelephonyHoldCallInput extends TelephonyBaseCallControlInput {
|
|
178
|
+
reason?: string;
|
|
179
|
+
}
|
|
180
|
+
export interface TelephonyResumeCallInput extends TelephonyBaseCallControlInput {
|
|
181
|
+
reason?: string;
|
|
182
|
+
}
|
|
183
|
+
export interface TelephonyHangUpCallInput extends TelephonyBaseCallControlInput {
|
|
184
|
+
reason?: string;
|
|
185
|
+
}
|
|
186
|
+
export interface TelephonySendDtmfInput extends TelephonyBaseCallControlInput {
|
|
187
|
+
digits: string;
|
|
188
|
+
}
|
|
189
|
+
export type TelephonyCallEvent = {
|
|
190
|
+
type: "call.created" | "call.ringing" | "call.answered" | "call.ended";
|
|
191
|
+
provider: TelephonyProvider;
|
|
192
|
+
providerCallId: string;
|
|
193
|
+
connectionId?: string;
|
|
194
|
+
at?: string;
|
|
195
|
+
metadata?: Record<string, unknown>;
|
|
196
|
+
} | {
|
|
197
|
+
type: "call.transfer.started" | "call.transfer.completed" | "call.transfer.failed";
|
|
198
|
+
provider: TelephonyProvider;
|
|
199
|
+
providerCallId: string;
|
|
200
|
+
transferId?: string;
|
|
201
|
+
destination?: TelephonyDestination;
|
|
202
|
+
reason?: string;
|
|
203
|
+
at?: string;
|
|
204
|
+
metadata?: Record<string, unknown>;
|
|
205
|
+
};
|
|
206
|
+
export interface ResolvedTelephonyConnection {
|
|
207
|
+
connection: TelephonyConnection;
|
|
208
|
+
provider: TelephonyProvider;
|
|
209
|
+
capabilities: TelephonyProviderCapabilities;
|
|
210
|
+
metadata?: Record<string, unknown>;
|
|
211
|
+
}
|
|
212
|
+
export interface TelephonyConnectionResolver {
|
|
213
|
+
resolveConnection(input: {
|
|
214
|
+
projectId: string;
|
|
215
|
+
accountId?: string;
|
|
216
|
+
agentId?: string;
|
|
217
|
+
destination?: TelephonyDestination;
|
|
218
|
+
channel?: "phone" | "sip" | "outbound";
|
|
219
|
+
metadata?: Record<string, unknown>;
|
|
220
|
+
}): Promise<ResolvedTelephonyConnection>;
|
|
221
|
+
}
|
|
222
|
+
export interface TelephonyProviderAdapter {
|
|
223
|
+
provider: TelephonyProvider;
|
|
224
|
+
createOutboundCall(input: TelephonyOutboundCallInput): Promise<TelephonyOutboundCallResult>;
|
|
225
|
+
}
|
|
226
|
+
export interface TelephonyCallControlAdapter {
|
|
227
|
+
provider: TelephonyProvider;
|
|
228
|
+
capabilities?(): TelephonyProviderCapabilities;
|
|
229
|
+
answerCall?(input: TelephonyAnswerCallInput): Promise<TelephonyCallControlResult>;
|
|
230
|
+
rejectCall?(input: TelephonyRejectCallInput): Promise<TelephonyCallControlResult>;
|
|
231
|
+
originateCall?(input: TelephonyOriginateCallInput): Promise<TelephonyOriginateCallResult>;
|
|
232
|
+
bridgeCall?(input: TelephonyBridgeCallInput): Promise<TelephonyCallControlResult>;
|
|
233
|
+
transferCall?(input: TelephonyTransferCallInput): Promise<TelephonyTransferCallResult>;
|
|
234
|
+
holdCall?(input: TelephonyHoldCallInput): Promise<TelephonyCallControlResult>;
|
|
235
|
+
resumeCall?(input: TelephonyResumeCallInput): Promise<TelephonyCallControlResult>;
|
|
236
|
+
sendDtmf?(input: TelephonySendDtmfInput): Promise<TelephonyCallControlResult>;
|
|
237
|
+
hangUpCall(input: {
|
|
238
|
+
providerCallId: string;
|
|
239
|
+
reason?: string;
|
|
240
|
+
metadata?: Record<string, unknown>;
|
|
241
|
+
}): Promise<TelephonyCallControlResult>;
|
|
242
|
+
}
|
package/dist/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kognitivedev/telephony",
|
|
3
|
+
"version": "0.2.29",
|
|
4
|
+
"main": "dist/index.js",
|
|
5
|
+
"types": "dist/index.d.ts",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"default": "./dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"./audio": {
|
|
12
|
+
"types": "./dist/audio.d.ts",
|
|
13
|
+
"default": "./dist/audio.js"
|
|
14
|
+
},
|
|
15
|
+
"./rtp": {
|
|
16
|
+
"types": "./dist/rtp.d.ts",
|
|
17
|
+
"default": "./dist/rtp.js"
|
|
18
|
+
},
|
|
19
|
+
"./twilio": {
|
|
20
|
+
"types": "./dist/twilio.d.ts",
|
|
21
|
+
"default": "./dist/twilio.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsc",
|
|
29
|
+
"dev": "tsc -w --noCheck",
|
|
30
|
+
"test": "vitest run",
|
|
31
|
+
"prepublishOnly": "npm run build"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@kognitivedev/voice": "^0.2.29"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/node": "^20.0.0",
|
|
38
|
+
"typescript": "^5.0.0",
|
|
39
|
+
"vitest": "^3.0.0"
|
|
40
|
+
},
|
|
41
|
+
"description": "Telephony call-control adapters for Kognitive voice agents",
|
|
42
|
+
"keywords": [
|
|
43
|
+
"kognitive",
|
|
44
|
+
"telephony",
|
|
45
|
+
"twilio",
|
|
46
|
+
"voice",
|
|
47
|
+
"calls"
|
|
48
|
+
],
|
|
49
|
+
"license": "MIT",
|
|
50
|
+
"repository": {
|
|
51
|
+
"type": "git",
|
|
52
|
+
"url": "https://github.com/kognitivedev/kognitive",
|
|
53
|
+
"directory": "packages/telephony"
|
|
54
|
+
},
|
|
55
|
+
"homepage": "https://kognitive.dev"
|
|
56
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { resamplePcm16WindowedSinc } from "../audio";
|
|
3
|
+
|
|
4
|
+
function sinePcm16(sampleRate: number, frequency: number, durationMs: number, amplitude = 12000) {
|
|
5
|
+
const length = Math.round((sampleRate * durationMs) / 1000);
|
|
6
|
+
const samples = new Int16Array(length);
|
|
7
|
+
for (let index = 0; index < length; index += 1) {
|
|
8
|
+
samples[index] = Math.round(Math.sin((2 * Math.PI * frequency * index) / sampleRate) * amplitude);
|
|
9
|
+
}
|
|
10
|
+
return samples;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function rms(samples: Int16Array) {
|
|
14
|
+
if (samples.length === 0) return 0;
|
|
15
|
+
let sum = 0;
|
|
16
|
+
for (const sample of samples) sum += sample * sample;
|
|
17
|
+
return Math.sqrt(sum / samples.length);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function resamplePcm16Linear(samples: Int16Array, sourceRate: number, targetRate: number) {
|
|
21
|
+
const outputLength = Math.max(1, Math.round((samples.length * targetRate) / sourceRate));
|
|
22
|
+
const output = new Int16Array(outputLength);
|
|
23
|
+
const ratio = sourceRate / targetRate;
|
|
24
|
+
for (let index = 0; index < outputLength; index += 1) {
|
|
25
|
+
const position = index * ratio;
|
|
26
|
+
const leftIndex = Math.floor(position);
|
|
27
|
+
const rightIndex = Math.min(leftIndex + 1, samples.length - 1);
|
|
28
|
+
const fraction = position - leftIndex;
|
|
29
|
+
const left = samples[leftIndex] ?? 0;
|
|
30
|
+
const right = samples[rightIndex] ?? left;
|
|
31
|
+
output[index] = Math.round(left + ((right - left) * fraction));
|
|
32
|
+
}
|
|
33
|
+
return output;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("PCM audio helpers", () => {
|
|
37
|
+
it("resamples PCM to the requested duration", () => {
|
|
38
|
+
const source = new Int16Array(480);
|
|
39
|
+
expect(resamplePcm16WindowedSinc(source, 24000, 8000)).toHaveLength(160);
|
|
40
|
+
expect(resamplePcm16WindowedSinc(source, 24000, 16000)).toHaveLength(320);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("attenuates out-of-band aliases better than linear interpolation when downsampling", () => {
|
|
44
|
+
const outOfBand = sinePcm16(24000, 5200, 120);
|
|
45
|
+
const linear = resamplePcm16Linear(outOfBand, 24000, 8000);
|
|
46
|
+
const sinc = resamplePcm16WindowedSinc(outOfBand, 24000, 8000);
|
|
47
|
+
|
|
48
|
+
expect(rms(sinc)).toBeLessThan(rms(linear) * 0.4);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("preserves in-band speech frequencies while downsampling", () => {
|
|
52
|
+
const speechTone = sinePcm16(24000, 1000, 120);
|
|
53
|
+
const sinc = resamplePcm16WindowedSinc(speechTone, 24000, 8000);
|
|
54
|
+
|
|
55
|
+
expect(rms(sinc)).toBeGreaterThan(6000);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createTelephonyAdapterRegistry } from "../registry";
|
|
3
|
+
|
|
4
|
+
describe("@kognitivedev/telephony adapter registry", () => {
|
|
5
|
+
it("registers provider and call-control adapters independently", async () => {
|
|
6
|
+
const registry = createTelephonyAdapterRegistry({
|
|
7
|
+
providers: [{
|
|
8
|
+
provider: "telnyx",
|
|
9
|
+
async createOutboundCall(input) {
|
|
10
|
+
return {
|
|
11
|
+
provider: "telnyx",
|
|
12
|
+
providerCallId: "call_1",
|
|
13
|
+
status: "queued",
|
|
14
|
+
from: input.from,
|
|
15
|
+
to: input.to,
|
|
16
|
+
};
|
|
17
|
+
},
|
|
18
|
+
}],
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
registry.registerCallControl({
|
|
22
|
+
provider: "telnyx",
|
|
23
|
+
capabilities: () => ({ inboundSip: true, outboundSip: true, bridgeTransfer: true }),
|
|
24
|
+
async transferCall(input) {
|
|
25
|
+
return {
|
|
26
|
+
provider: "telnyx",
|
|
27
|
+
providerCallId: input.providerCallId,
|
|
28
|
+
status: "active",
|
|
29
|
+
destination: input.destination,
|
|
30
|
+
mode: input.mode ?? "blind",
|
|
31
|
+
transferId: "transfer_1",
|
|
32
|
+
};
|
|
33
|
+
},
|
|
34
|
+
async hangUpCall(input) {
|
|
35
|
+
return {
|
|
36
|
+
provider: "telnyx",
|
|
37
|
+
providerCallId: input.providerCallId,
|
|
38
|
+
status: "completed",
|
|
39
|
+
};
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
expect(registry.listProviders()).toEqual(["telnyx"]);
|
|
44
|
+
expect(registry.listCallControlProviders()).toEqual(["telnyx"]);
|
|
45
|
+
expect(registry.requireCallControl("telnyx").capabilities?.()).toEqual({
|
|
46
|
+
inboundSip: true,
|
|
47
|
+
outboundSip: true,
|
|
48
|
+
bridgeTransfer: true,
|
|
49
|
+
});
|
|
50
|
+
await expect(registry.requireCallControl("telnyx").transferCall?.({
|
|
51
|
+
providerCallId: "call_1",
|
|
52
|
+
destination: { type: "sip_uri", uri: "sip:support@example.com" },
|
|
53
|
+
})).resolves.toMatchObject({
|
|
54
|
+
provider: "telnyx",
|
|
55
|
+
providerCallId: "call_1",
|
|
56
|
+
mode: "blind",
|
|
57
|
+
transferId: "transfer_1",
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("throws clear errors for missing adapters", () => {
|
|
62
|
+
const registry = createTelephonyAdapterRegistry();
|
|
63
|
+
|
|
64
|
+
expect(() => registry.requireProvider("cm_com")).toThrow('Telephony provider "cm_com" is not registered');
|
|
65
|
+
expect(() => registry.requireCallControl("cm_com")).toThrow('Telephony call-control provider "cm_com" is not registered');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
nextRtpSequenceNumber,
|
|
4
|
+
nextRtpTimestamp,
|
|
5
|
+
parseRtpPacket,
|
|
6
|
+
RTP_PAYLOAD_TYPE_PCMU,
|
|
7
|
+
serializeRtpPacket,
|
|
8
|
+
} from "../rtp";
|
|
9
|
+
|
|
10
|
+
describe("@kognitivedev/telephony RTP helpers", () => {
|
|
11
|
+
it("serializes and parses PCMU RTP packets", () => {
|
|
12
|
+
const payload = Buffer.from([1, 2, 3, 4]);
|
|
13
|
+
const packet = serializeRtpPacket({
|
|
14
|
+
marker: true,
|
|
15
|
+
payloadType: RTP_PAYLOAD_TYPE_PCMU,
|
|
16
|
+
sequenceNumber: 65535,
|
|
17
|
+
timestamp: 123456,
|
|
18
|
+
ssrc: 42,
|
|
19
|
+
payload,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
expect(parseRtpPacket(packet)).toMatchObject({
|
|
23
|
+
version: 2,
|
|
24
|
+
marker: true,
|
|
25
|
+
payloadType: 0,
|
|
26
|
+
sequenceNumber: 65535,
|
|
27
|
+
timestamp: 123456,
|
|
28
|
+
ssrc: 42,
|
|
29
|
+
payload,
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("wraps RTP sequence numbers and timestamps", () => {
|
|
34
|
+
expect(nextRtpSequenceNumber(65535)).toBe(0);
|
|
35
|
+
expect(nextRtpTimestamp(0xffffffff, 160)).toBe(159);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("rejects malformed packets", () => {
|
|
39
|
+
expect(() => parseRtpPacket(Buffer.alloc(2))).toThrow("too short");
|
|
40
|
+
expect(() => parseRtpPacket(Buffer.from([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]))).toThrow("Unsupported RTP version");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { completeTwilioCall, createTwilioOutboundCall, createTwilioPhoneControlAdapter, createTwilioTelephonyProvider } from "../twilio";
|
|
3
|
+
|
|
4
|
+
describe("@kognitivedev/telephony twilio", () => {
|
|
5
|
+
it("creates outbound calls through Twilio REST", async () => {
|
|
6
|
+
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
|
7
|
+
const fetchImpl = async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
8
|
+
calls.push({ url: String(input), init });
|
|
9
|
+
return new Response(JSON.stringify({ sid: "CA123", status: "queued" }), {
|
|
10
|
+
status: 201,
|
|
11
|
+
headers: { "Content-Type": "application/json" },
|
|
12
|
+
});
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const result = await createTwilioOutboundCall({
|
|
16
|
+
accountSid: "AC123",
|
|
17
|
+
authToken: "secret",
|
|
18
|
+
from: "+15550001111",
|
|
19
|
+
to: "+15550002222",
|
|
20
|
+
answerUrl: "https://example.com/answer",
|
|
21
|
+
statusCallbackUrl: "https://example.com/status",
|
|
22
|
+
fetch: fetchImpl as typeof fetch,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
expect(result).toMatchObject({
|
|
26
|
+
provider: "twilio",
|
|
27
|
+
providerCallId: "CA123",
|
|
28
|
+
status: "queued",
|
|
29
|
+
from: "+15550001111",
|
|
30
|
+
to: "+15550002222",
|
|
31
|
+
});
|
|
32
|
+
expect(calls[0].url).toBe("https://api.twilio.com/2010-04-01/Accounts/AC123/Calls.json");
|
|
33
|
+
expect(new Headers(calls[0].init?.headers).get("Authorization")).toMatch(/^Basic /);
|
|
34
|
+
const form = new URLSearchParams(String(calls[0].init?.body));
|
|
35
|
+
expect(form.get("Url")).toBe("https://example.com/answer");
|
|
36
|
+
expect(form.get("StatusCallback")).toBe("https://example.com/status");
|
|
37
|
+
expect(form.getAll("StatusCallbackEvent")).toEqual(["initiated", "ringing", "answered", "completed"]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("exposes a provider adapter", async () => {
|
|
41
|
+
const provider = createTwilioTelephonyProvider({
|
|
42
|
+
accountSid: "AC123",
|
|
43
|
+
authToken: "secret",
|
|
44
|
+
fetch: async () => new Response(JSON.stringify({ sid: "CA456", status: "in-progress" }), {
|
|
45
|
+
status: 201,
|
|
46
|
+
headers: { "Content-Type": "application/json" },
|
|
47
|
+
}),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
await expect(provider.createOutboundCall({
|
|
51
|
+
from: "+15550001111",
|
|
52
|
+
to: "+15550002222",
|
|
53
|
+
answerUrl: "https://example.com/answer",
|
|
54
|
+
})).resolves.toMatchObject({ providerCallId: "CA456", status: "active" });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("completes active Twilio calls through REST", async () => {
|
|
58
|
+
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
|
59
|
+
const fetchImpl = async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
60
|
+
calls.push({ url: String(input), init });
|
|
61
|
+
return new Response(JSON.stringify({ sid: "CA789", status: "completed" }), {
|
|
62
|
+
status: 200,
|
|
63
|
+
headers: { "Content-Type": "application/json" },
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
await expect(completeTwilioCall({
|
|
68
|
+
accountSid: "AC123",
|
|
69
|
+
authToken: "secret",
|
|
70
|
+
providerCallId: "CA789",
|
|
71
|
+
fetch: fetchImpl as typeof fetch,
|
|
72
|
+
})).resolves.toMatchObject({
|
|
73
|
+
provider: "twilio",
|
|
74
|
+
providerCallId: "CA789",
|
|
75
|
+
status: "completed",
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(calls[0].url).toBe("https://api.twilio.com/2010-04-01/Accounts/AC123/Calls/CA789.json");
|
|
79
|
+
expect(new Headers(calls[0].init?.headers).get("Authorization")).toMatch(/^Basic /);
|
|
80
|
+
const form = new URLSearchParams(String(calls[0].init?.body));
|
|
81
|
+
expect(form.get("Status")).toBe("completed");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("exposes a phone control adapter for hangup", async () => {
|
|
85
|
+
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
|
86
|
+
const adapter = createTwilioPhoneControlAdapter({
|
|
87
|
+
accountSid: "AC123",
|
|
88
|
+
authToken: "secret",
|
|
89
|
+
fetch: async (input, init) => {
|
|
90
|
+
calls.push({ url: String(input), init });
|
|
91
|
+
return new Response(JSON.stringify({ sid: "CA999", status: "completed" }), {
|
|
92
|
+
status: 200,
|
|
93
|
+
headers: { "Content-Type": "application/json" },
|
|
94
|
+
});
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
await expect(adapter.hangUpCall({
|
|
99
|
+
providerCallId: "CA999",
|
|
100
|
+
reason: "done",
|
|
101
|
+
})).resolves.toMatchObject({
|
|
102
|
+
provider: "twilio",
|
|
103
|
+
providerCallId: "CA999",
|
|
104
|
+
status: "completed",
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(calls[0].url).toBe("https://api.twilio.com/2010-04-01/Accounts/AC123/Calls/CA999.json");
|
|
108
|
+
expect(new URLSearchParams(String(calls[0].init?.body)).get("Status")).toBe("completed");
|
|
109
|
+
});
|
|
110
|
+
});
|
package/src/audio.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export interface Pcm16ResampleOptions {
|
|
2
|
+
filterRadius?: number;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
const DEFAULT_FILTER_RADIUS = 18;
|
|
6
|
+
const DOWNSAMPLE_CUTOFF_HEADROOM = 0.94;
|
|
7
|
+
|
|
8
|
+
function sinc(value: number) {
|
|
9
|
+
if (Math.abs(value) < 1e-8) return 1;
|
|
10
|
+
const radians = Math.PI * value;
|
|
11
|
+
return Math.sin(radians) / radians;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function blackmanWindow(distance: number, width: number) {
|
|
15
|
+
if (width <= 0 || distance > width) return 0;
|
|
16
|
+
const ratio = distance / width;
|
|
17
|
+
return 0.42 + (0.5 * Math.cos(Math.PI * ratio)) + (0.08 * Math.cos(2 * Math.PI * ratio));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function clampPcm16(value: number) {
|
|
21
|
+
if (value > 32767) return 32767;
|
|
22
|
+
if (value < -32768) return -32768;
|
|
23
|
+
return Math.round(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function resamplePcm16WindowedSinc(
|
|
27
|
+
samples: Int16Array,
|
|
28
|
+
sourceRate: number,
|
|
29
|
+
targetRate: number,
|
|
30
|
+
options: Pcm16ResampleOptions = {},
|
|
31
|
+
) {
|
|
32
|
+
if (sourceRate <= 0 || targetRate <= 0) throw new Error("Sample rates must be positive");
|
|
33
|
+
if (samples.length === 0 || sourceRate === targetRate) return new Int16Array(samples);
|
|
34
|
+
|
|
35
|
+
const outputLength = Math.max(1, Math.round((samples.length * targetRate) / sourceRate));
|
|
36
|
+
const output = new Int16Array(outputLength);
|
|
37
|
+
const sourcePerTarget = sourceRate / targetRate;
|
|
38
|
+
const cutoff = Math.min(1, targetRate / sourceRate) * DOWNSAMPLE_CUTOFF_HEADROOM;
|
|
39
|
+
const filterRadius = Math.max(4, Math.round(options.filterRadius ?? DEFAULT_FILTER_RADIUS));
|
|
40
|
+
const kernelWidth = filterRadius / cutoff;
|
|
41
|
+
|
|
42
|
+
for (let outputIndex = 0; outputIndex < outputLength; outputIndex += 1) {
|
|
43
|
+
const sourcePosition = outputIndex * sourcePerTarget;
|
|
44
|
+
const firstInputIndex = Math.max(0, Math.ceil(sourcePosition - kernelWidth));
|
|
45
|
+
const lastInputIndex = Math.min(samples.length - 1, Math.floor(sourcePosition + kernelWidth));
|
|
46
|
+
|
|
47
|
+
let weightedSum = 0;
|
|
48
|
+
let weightTotal = 0;
|
|
49
|
+
for (let inputIndex = firstInputIndex; inputIndex <= lastInputIndex; inputIndex += 1) {
|
|
50
|
+
const distance = sourcePosition - inputIndex;
|
|
51
|
+
const window = blackmanWindow(Math.abs(distance), kernelWidth);
|
|
52
|
+
if (window === 0) continue;
|
|
53
|
+
const weight = cutoff * sinc(cutoff * distance) * window;
|
|
54
|
+
weightedSum += (samples[inputIndex] ?? 0) * weight;
|
|
55
|
+
weightTotal += weight;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
output[outputIndex] = clampPcm16(weightTotal === 0 ? 0 : weightedSum / weightTotal);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return output;
|
|
62
|
+
}
|
|
63
|
+
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export * from "./types";
|
|
2
|
+
export * from "./audio";
|
|
3
|
+
export * from "./rtp";
|
|
4
|
+
export * from "./twilio";
|
|
5
|
+
export * from "./registry";
|
|
6
|
+
export {
|
|
7
|
+
createVoiceTelephonyService,
|
|
8
|
+
type CreateVoiceTelephonySessionRequest,
|
|
9
|
+
type EndVoiceTelephonySessionOptions,
|
|
10
|
+
type UpdateVoiceTelephonySessionOptions,
|
|
11
|
+
type VoiceCallContext,
|
|
12
|
+
type VoiceCallDirection,
|
|
13
|
+
type VoiceCallProvider,
|
|
14
|
+
type VoiceCallTransport,
|
|
15
|
+
type VoiceTelephonyAgentResolutionContext,
|
|
16
|
+
type VoiceTelephonyAgentResolver,
|
|
17
|
+
type VoiceTelephonyAudioFrame,
|
|
18
|
+
type VoiceTelephonyProviderEvent,
|
|
19
|
+
type VoiceTelephonyService,
|
|
20
|
+
type VoiceTelephonyServiceConfig,
|
|
21
|
+
type VoiceTelephonySessionRecord,
|
|
22
|
+
} from "@kognitivedev/voice/telephony";
|