getpatter 0.4.3 → 0.4.4
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/dist/{chunk-35EVXMGB.mjs → chunk-O3RQG3NL.mjs} +453 -100
- package/dist/cli.js +92 -5
- package/dist/index.d.mts +245 -64
- package/dist/index.d.ts +245 -64
- package/dist/index.js +636 -147
- package/dist/index.mjs +183 -47
- package/dist/{test-mode-RH65MMSP.mjs → test-mode-ASSLSQU2.mjs} +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -314,20 +314,36 @@ var init_deepgram_stt = __esm({
|
|
|
314
314
|
init_logger();
|
|
315
315
|
DEEPGRAM_WS_URL = "wss://api.deepgram.com/v1/listen";
|
|
316
316
|
DeepgramSTT = class _DeepgramSTT {
|
|
317
|
-
constructor(apiKey, language = "en", model = "nova-3", encoding = "linear16", sampleRate = 16e3) {
|
|
318
|
-
this.apiKey = apiKey;
|
|
319
|
-
this.language = language;
|
|
320
|
-
this.model = model;
|
|
321
|
-
this.encoding = encoding;
|
|
322
|
-
this.sampleRate = sampleRate;
|
|
323
|
-
}
|
|
324
317
|
ws = null;
|
|
325
318
|
callbacks = [];
|
|
326
319
|
/** Request ID from Deepgram — used to query actual cost post-call. */
|
|
327
320
|
requestId = "";
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
321
|
+
apiKey;
|
|
322
|
+
language;
|
|
323
|
+
model;
|
|
324
|
+
encoding;
|
|
325
|
+
sampleRate;
|
|
326
|
+
endpointingMs;
|
|
327
|
+
utteranceEndMs;
|
|
328
|
+
smartFormat;
|
|
329
|
+
interimResults;
|
|
330
|
+
vadEvents;
|
|
331
|
+
constructor(apiKey, languageOrOptions, model, encoding, sampleRate, options) {
|
|
332
|
+
this.apiKey = apiKey;
|
|
333
|
+
const opts = typeof languageOrOptions === "object" && languageOrOptions !== null ? languageOrOptions : options ?? {};
|
|
334
|
+
this.language = (typeof languageOrOptions === "string" ? languageOrOptions : opts.language) ?? "en";
|
|
335
|
+
this.model = model ?? opts.model ?? "nova-3";
|
|
336
|
+
this.encoding = encoding ?? opts.encoding ?? "linear16";
|
|
337
|
+
this.sampleRate = sampleRate ?? opts.sampleRate ?? 16e3;
|
|
338
|
+
this.endpointingMs = opts.endpointingMs ?? 150;
|
|
339
|
+
this.utteranceEndMs = opts.utteranceEndMs === null ? null : opts.utteranceEndMs ?? 1e3;
|
|
340
|
+
this.smartFormat = opts.smartFormat ?? true;
|
|
341
|
+
this.interimResults = opts.interimResults ?? true;
|
|
342
|
+
this.vadEvents = opts.vadEvents ?? true;
|
|
343
|
+
}
|
|
344
|
+
/** Factory for Twilio calls — mulaw 8 kHz. Forwards tuning options through. */
|
|
345
|
+
static forTwilio(apiKey, language = "en", model = "nova-3", options = {}) {
|
|
346
|
+
return new _DeepgramSTT(apiKey, language, model, "mulaw", 8e3, options);
|
|
331
347
|
}
|
|
332
348
|
async connect() {
|
|
333
349
|
const params = new URLSearchParams({
|
|
@@ -336,12 +352,15 @@ var init_deepgram_stt = __esm({
|
|
|
336
352
|
encoding: this.encoding,
|
|
337
353
|
sample_rate: String(this.sampleRate),
|
|
338
354
|
channels: "1",
|
|
339
|
-
interim_results: "true",
|
|
340
|
-
endpointing:
|
|
341
|
-
smart_format: "true",
|
|
342
|
-
vad_events: "true",
|
|
355
|
+
interim_results: this.interimResults ? "true" : "false",
|
|
356
|
+
endpointing: String(this.endpointingMs),
|
|
357
|
+
smart_format: this.smartFormat ? "true" : "false",
|
|
358
|
+
vad_events: this.vadEvents ? "true" : "false",
|
|
343
359
|
no_delay: "true"
|
|
344
360
|
});
|
|
361
|
+
if (this.utteranceEndMs !== null) {
|
|
362
|
+
params.set("utterance_end_ms", String(Math.max(this.utteranceEndMs, 1e3)));
|
|
363
|
+
}
|
|
345
364
|
const url = `${DEEPGRAM_WS_URL}?${params.toString()}`;
|
|
346
365
|
this.ws = new import_ws4.default(url, {
|
|
347
366
|
headers: { Authorization: `Token ${this.apiKey}` }
|
|
@@ -376,7 +395,7 @@ var init_deepgram_stt = __esm({
|
|
|
376
395
|
if (!text) return;
|
|
377
396
|
const transcript = {
|
|
378
397
|
text,
|
|
379
|
-
isFinal: Boolean(data.is_final)
|
|
398
|
+
isFinal: Boolean(data.is_final) || Boolean(data.speech_final),
|
|
380
399
|
confidence: best.confidence ?? 0
|
|
381
400
|
};
|
|
382
401
|
for (const cb of this.callbacks) {
|
|
@@ -616,9 +635,15 @@ var init_store = __esm({
|
|
|
616
635
|
maxCalls;
|
|
617
636
|
calls = [];
|
|
618
637
|
activeCalls = /* @__PURE__ */ new Map();
|
|
619
|
-
|
|
638
|
+
/**
|
|
639
|
+
* Accepts either a numeric ``maxCalls`` (legacy positional — matches the
|
|
640
|
+
* original TS API) or an options object ``{ maxCalls }`` to align with the
|
|
641
|
+
* Python SDK's keyword-argument style. Plain literals also work:
|
|
642
|
+
* ``new MetricsStore()`` / ``new MetricsStore(100)`` / ``new MetricsStore({ maxCalls: 100 })``.
|
|
643
|
+
*/
|
|
644
|
+
constructor(maxCallsOrOpts = 500) {
|
|
620
645
|
super();
|
|
621
|
-
this.maxCalls = maxCalls;
|
|
646
|
+
this.maxCalls = typeof maxCallsOrOpts === "number" ? maxCallsOrOpts : maxCallsOrOpts.maxCalls ?? 500;
|
|
622
647
|
}
|
|
623
648
|
publish(eventType, data) {
|
|
624
649
|
this.emit("sse", { type: eventType, data });
|
|
@@ -626,22 +651,100 @@ var init_store = __esm({
|
|
|
626
651
|
recordCallStart(data) {
|
|
627
652
|
const callId = data.call_id || "";
|
|
628
653
|
if (!callId) return;
|
|
654
|
+
const existing = this.activeCalls.get(callId);
|
|
655
|
+
if (existing) {
|
|
656
|
+
existing.caller = data.caller || existing.caller;
|
|
657
|
+
existing.callee = data.callee || existing.callee;
|
|
658
|
+
existing.direction = data.direction || existing.direction;
|
|
659
|
+
existing.status = "in-progress";
|
|
660
|
+
existing.turns = existing.turns || [];
|
|
661
|
+
} else {
|
|
662
|
+
const record = {
|
|
663
|
+
call_id: callId,
|
|
664
|
+
caller: data.caller || "",
|
|
665
|
+
callee: data.callee || "",
|
|
666
|
+
direction: data.direction || "inbound",
|
|
667
|
+
started_at: Date.now() / 1e3,
|
|
668
|
+
status: "in-progress",
|
|
669
|
+
turns: []
|
|
670
|
+
};
|
|
671
|
+
this.activeCalls.set(callId, record);
|
|
672
|
+
}
|
|
673
|
+
this.publish("call_start", {
|
|
674
|
+
call_id: callId,
|
|
675
|
+
caller: data.caller || "",
|
|
676
|
+
callee: data.callee || "",
|
|
677
|
+
direction: data.direction || "inbound"
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Pre-register an outbound call before any webhook fires. Lets the
|
|
682
|
+
* dashboard surface attempts that never reach media (no-answer, busy,
|
|
683
|
+
* carrier-rejected). Mirrors the Python ``record_call_initiated``.
|
|
684
|
+
*/
|
|
685
|
+
recordCallInitiated(data) {
|
|
686
|
+
const callId = data.call_id || "";
|
|
687
|
+
if (!callId) return;
|
|
688
|
+
if (this.activeCalls.has(callId)) return;
|
|
629
689
|
const record = {
|
|
630
690
|
call_id: callId,
|
|
631
691
|
caller: data.caller || "",
|
|
632
692
|
callee: data.callee || "",
|
|
633
|
-
direction: data.direction || "
|
|
693
|
+
direction: data.direction || "outbound",
|
|
634
694
|
started_at: Date.now() / 1e3,
|
|
695
|
+
status: "initiated",
|
|
635
696
|
turns: []
|
|
636
697
|
};
|
|
637
698
|
this.activeCalls.set(callId, record);
|
|
638
|
-
this.publish("
|
|
699
|
+
this.publish("call_initiated", {
|
|
639
700
|
call_id: callId,
|
|
640
701
|
caller: record.caller,
|
|
641
702
|
callee: record.callee,
|
|
642
|
-
direction: record.direction
|
|
703
|
+
direction: record.direction,
|
|
704
|
+
status: record.status
|
|
643
705
|
});
|
|
644
706
|
}
|
|
707
|
+
/**
|
|
708
|
+
* Update the status of an active or completed call. Terminal states
|
|
709
|
+
* (completed, no-answer, busy, failed, canceled, webhook_error) move the
|
|
710
|
+
* row from active to completed so the UI freezes the live duration timer.
|
|
711
|
+
*/
|
|
712
|
+
updateCallStatus(callId, status, extra = {}) {
|
|
713
|
+
if (!callId || !status) return;
|
|
714
|
+
const TERMINAL = /* @__PURE__ */ new Set(["completed", "no-answer", "busy", "failed", "canceled", "webhook_error"]);
|
|
715
|
+
const active = this.activeCalls.get(callId);
|
|
716
|
+
if (active) {
|
|
717
|
+
active.status = status;
|
|
718
|
+
Object.assign(active, extra);
|
|
719
|
+
if (TERMINAL.has(status)) {
|
|
720
|
+
const entry = {
|
|
721
|
+
call_id: callId,
|
|
722
|
+
caller: active.caller || "",
|
|
723
|
+
callee: active.callee || "",
|
|
724
|
+
direction: active.direction || "outbound",
|
|
725
|
+
started_at: active.started_at || 0,
|
|
726
|
+
ended_at: Date.now() / 1e3,
|
|
727
|
+
status,
|
|
728
|
+
metrics: null,
|
|
729
|
+
...extra
|
|
730
|
+
};
|
|
731
|
+
this.activeCalls.delete(callId);
|
|
732
|
+
this.calls.push(entry);
|
|
733
|
+
if (this.calls.length > this.maxCalls) {
|
|
734
|
+
this.calls = this.calls.slice(-this.maxCalls);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
} else {
|
|
738
|
+
for (let i = this.calls.length - 1; i >= 0; i--) {
|
|
739
|
+
if (this.calls[i].call_id === callId) {
|
|
740
|
+
this.calls[i].status = status;
|
|
741
|
+
Object.assign(this.calls[i], extra);
|
|
742
|
+
break;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
this.publish("call_status", { call_id: callId, status, ...extra });
|
|
747
|
+
}
|
|
645
748
|
recordTurn(data) {
|
|
646
749
|
const callId = data.call_id || "";
|
|
647
750
|
const turn = data.turn;
|
|
@@ -658,6 +761,8 @@ var init_store = __esm({
|
|
|
658
761
|
if (!callId) return;
|
|
659
762
|
const active = this.activeCalls.get(callId);
|
|
660
763
|
this.activeCalls.delete(callId);
|
|
764
|
+
const activeStatus = active?.status;
|
|
765
|
+
const resolvedStatus = activeStatus && activeStatus !== "in-progress" ? activeStatus : "completed";
|
|
661
766
|
const entry = {
|
|
662
767
|
call_id: callId,
|
|
663
768
|
caller: data.caller || active?.caller || "",
|
|
@@ -666,6 +771,7 @@ var init_store = __esm({
|
|
|
666
771
|
started_at: active?.started_at || 0,
|
|
667
772
|
ended_at: Date.now() / 1e3,
|
|
668
773
|
transcript: data.transcript || [],
|
|
774
|
+
status: resolvedStatus,
|
|
669
775
|
metrics: metrics ?? null
|
|
670
776
|
};
|
|
671
777
|
this.calls.push(entry);
|
|
@@ -1867,18 +1973,73 @@ var init_remote_message = __esm({
|
|
|
1867
1973
|
});
|
|
1868
1974
|
|
|
1869
1975
|
// src/providers/elevenlabs-tts.ts
|
|
1870
|
-
|
|
1976
|
+
function resolveVoiceId(voice) {
|
|
1977
|
+
if (!voice) return voice;
|
|
1978
|
+
if (VOICE_ID_PATTERN.test(voice)) return voice;
|
|
1979
|
+
return ELEVENLABS_VOICE_ID_BY_NAME[voice.toLowerCase()] ?? voice;
|
|
1980
|
+
}
|
|
1981
|
+
var ELEVENLABS_BASE_URL, ELEVENLABS_VOICE_ID_BY_NAME, VOICE_ID_PATTERN, ElevenLabsTTS;
|
|
1871
1982
|
var init_elevenlabs_tts = __esm({
|
|
1872
1983
|
"src/providers/elevenlabs-tts.ts"() {
|
|
1873
1984
|
"use strict";
|
|
1874
1985
|
ELEVENLABS_BASE_URL = "https://api.elevenlabs.io/v1";
|
|
1986
|
+
ELEVENLABS_VOICE_ID_BY_NAME = {
|
|
1987
|
+
rachel: "21m00Tcm4TlvDq8ikWAM",
|
|
1988
|
+
drew: "29vD33N1CtxCmqQRPOHJ",
|
|
1989
|
+
clyde: "2EiwWnXFnvU5JabPnv8n",
|
|
1990
|
+
paul: "5Q0t7uMcjvnagumLfvZi",
|
|
1991
|
+
domi: "AZnzlk1XvdvUeBnXmlld",
|
|
1992
|
+
dave: "CYw3kZ02Hs0563khs1Fj",
|
|
1993
|
+
fin: "D38z5RcWu1voky8WS1ja",
|
|
1994
|
+
bella: "EXAVITQu4vr4xnSDxMaL",
|
|
1995
|
+
antoni: "ErXwobaYiN019PkySvjV",
|
|
1996
|
+
thomas: "GBv7mTt0atIp3Br8iCZE",
|
|
1997
|
+
charlie: "IKne3meq5aSn9XLyUdCD",
|
|
1998
|
+
george: "JBFqnCBsd6RMkjVDRZzb",
|
|
1999
|
+
emily: "LcfcDJNUP1GQjkzn1xUU",
|
|
2000
|
+
elli: "MF3mGyEYCl7XYWbV9V6O",
|
|
2001
|
+
callum: "N2lVS1w4EtoT3dr4eOWO",
|
|
2002
|
+
patrick: "ODq5zmih8GrVes37Dizd",
|
|
2003
|
+
harry: "SOYHLrjzK2X1ezoPC6cr",
|
|
2004
|
+
liam: "TX3LPaxmHKxFdv7VOQHJ",
|
|
2005
|
+
dorothy: "ThT5KcBeYPX3keUQqHPh",
|
|
2006
|
+
josh: "TxGEqnHWrfWFTfGW9XjX",
|
|
2007
|
+
arnold: "VR6AewLTigWG4xSOukaG",
|
|
2008
|
+
charlotte: "XB0fDUnXU5powFXDhCwa",
|
|
2009
|
+
matilda: "XrExE9yKIg1WjnnlVkGX",
|
|
2010
|
+
matthew: "Yko7PKHZNXotIFUBG7I9",
|
|
2011
|
+
james: "ZQe5CZNOzWyzPSCn5a3c",
|
|
2012
|
+
joseph: "Zlb1dXrM653N07WRdFW3",
|
|
2013
|
+
jeremy: "bVMeCyTHy58xNoL34h3p",
|
|
2014
|
+
michael: "flq6f7yk4E4fJM5XTYuZ",
|
|
2015
|
+
ethan: "g5CIjZEefAph4nQFvHAz",
|
|
2016
|
+
gigi: "jBpfuIE2acCO8z3wKNLl",
|
|
2017
|
+
freya: "jsCqWAovK2LkecY7zXl4",
|
|
2018
|
+
brian: "nPczCjzI2devNBz1zQrb",
|
|
2019
|
+
grace: "oWAxZDx7w5VEj9dCyTzz",
|
|
2020
|
+
daniel: "onwK4e9ZLuTAKqWW03F9",
|
|
2021
|
+
lily: "pFZP5JQG7iQjIQuC4Bku",
|
|
2022
|
+
serena: "pMsXgVXv3BLzUgSXRplE",
|
|
2023
|
+
adam: "pNInz6obpgDQGcFmaJgB",
|
|
2024
|
+
nicole: "piTKgcLEGmPE4e6mEKli",
|
|
2025
|
+
bill: "pqHfZKP75CvOlQylNhV4",
|
|
2026
|
+
jessie: "t0jbNlBVZ17f02VDIeMI",
|
|
2027
|
+
ryan: "wViXBPUzp2ZZixB1xQuM",
|
|
2028
|
+
sam: "yoZ06aMxZJJ28mfd3POQ",
|
|
2029
|
+
glinda: "z9fAnlkpzviPz146aGWa",
|
|
2030
|
+
giovanni: "zcAOhNBS3c14rBihAFp1",
|
|
2031
|
+
mimi: "zrHiDhphv9ZnVXBqCLjz",
|
|
2032
|
+
alloy: "21m00Tcm4TlvDq8ikWAM"
|
|
2033
|
+
};
|
|
2034
|
+
VOICE_ID_PATTERN = /^[A-Za-z0-9]{20}$/;
|
|
1875
2035
|
ElevenLabsTTS = class {
|
|
1876
2036
|
constructor(apiKey, voiceId = "21m00Tcm4TlvDq8ikWAM", modelId = "eleven_turbo_v2_5", outputFormat = "pcm_16000") {
|
|
1877
2037
|
this.apiKey = apiKey;
|
|
1878
|
-
this.voiceId = voiceId;
|
|
1879
2038
|
this.modelId = modelId;
|
|
1880
2039
|
this.outputFormat = outputFormat;
|
|
2040
|
+
this.voiceId = resolveVoiceId(voiceId);
|
|
1881
2041
|
}
|
|
2042
|
+
voiceId;
|
|
1882
2043
|
/**
|
|
1883
2044
|
* Synthesise text to speech and return the full audio as a single Buffer.
|
|
1884
2045
|
*
|
|
@@ -1963,6 +2124,11 @@ var init_openai_tts = __esm({
|
|
|
1963
2124
|
*
|
|
1964
2125
|
* OpenAI returns 24 kHz PCM16; each chunk is resampled to 16 kHz before
|
|
1965
2126
|
* yielding so the output is ready for telephony pipelines.
|
|
2127
|
+
*
|
|
2128
|
+
* The resampler carries state (buffered samples + odd trailing byte)
|
|
2129
|
+
* between chunks — without that state cross-chunk sample alignment drifts
|
|
2130
|
+
* and the caller hears pops / dropped audio (BUG #23, mirror of the
|
|
2131
|
+
* Python `audioop.ratecv` fix).
|
|
1966
2132
|
*/
|
|
1967
2133
|
async *synthesizeStream(text) {
|
|
1968
2134
|
const response = await fetch(OPENAI_TTS_URL, {
|
|
@@ -1986,14 +2152,23 @@ var init_openai_tts = __esm({
|
|
|
1986
2152
|
if (!response.body) {
|
|
1987
2153
|
throw new Error("OpenAI TTS: no response body");
|
|
1988
2154
|
}
|
|
2155
|
+
const ctx = { carryByte: null, leftover: [] };
|
|
1989
2156
|
const reader = response.body.getReader();
|
|
1990
2157
|
try {
|
|
1991
2158
|
while (true) {
|
|
1992
2159
|
const { done, value } = await reader.read();
|
|
1993
2160
|
if (done) break;
|
|
1994
2161
|
if (value && value.length > 0) {
|
|
1995
|
-
|
|
2162
|
+
const out = _OpenAITTS.resampleStreaming(Buffer.from(value), ctx);
|
|
2163
|
+
if (out.length > 0) yield out;
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
if (ctx.leftover.length > 0) {
|
|
2167
|
+
const tail = Buffer.alloc(ctx.leftover.length * 2);
|
|
2168
|
+
for (let i = 0; i < ctx.leftover.length; i++) {
|
|
2169
|
+
tail.writeInt16LE(ctx.leftover[i], i * 2);
|
|
1996
2170
|
}
|
|
2171
|
+
yield tail;
|
|
1997
2172
|
}
|
|
1998
2173
|
} finally {
|
|
1999
2174
|
if (typeof reader.cancel === "function") await reader.cancel().catch(() => {
|
|
@@ -2002,35 +2177,53 @@ var init_openai_tts = __esm({
|
|
|
2002
2177
|
}
|
|
2003
2178
|
}
|
|
2004
2179
|
/**
|
|
2005
|
-
*
|
|
2006
|
-
*
|
|
2007
|
-
* For each group of 3 input samples the first is kept as-is and the second
|
|
2008
|
-
* output sample is the average of input samples 2 and 3. This matches the
|
|
2009
|
-
* Python SDK implementation.
|
|
2180
|
+
* Streaming 24 kHz → 16 kHz resampler (PCM16-LE). Maintains cross-chunk
|
|
2181
|
+
* state so the 3:2 pattern doesn't reset at every network read.
|
|
2010
2182
|
*/
|
|
2011
|
-
static
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2183
|
+
static resampleStreaming(audio, ctx) {
|
|
2184
|
+
let buf;
|
|
2185
|
+
if (ctx.carryByte !== null) {
|
|
2186
|
+
buf = Buffer.concat([Buffer.from([ctx.carryByte]), audio]);
|
|
2187
|
+
ctx.carryByte = null;
|
|
2188
|
+
} else {
|
|
2189
|
+
buf = audio;
|
|
2190
|
+
}
|
|
2191
|
+
if (buf.length % 2 === 1) {
|
|
2192
|
+
ctx.carryByte = buf[buf.length - 1];
|
|
2193
|
+
buf = buf.subarray(0, buf.length - 1);
|
|
2194
|
+
}
|
|
2195
|
+
if (buf.length === 0 && ctx.leftover.length === 0) {
|
|
2196
|
+
return Buffer.alloc(0);
|
|
2197
|
+
}
|
|
2198
|
+
const sampleCount = buf.length / 2;
|
|
2199
|
+
const samples = ctx.leftover.slice();
|
|
2200
|
+
for (let i2 = 0; i2 < sampleCount; i2++) {
|
|
2201
|
+
samples.push(buf.readInt16LE(i2 * 2));
|
|
2028
2202
|
}
|
|
2029
|
-
const out =
|
|
2030
|
-
|
|
2031
|
-
|
|
2203
|
+
const out = [];
|
|
2204
|
+
let i = 0;
|
|
2205
|
+
while (i + 2 < samples.length) {
|
|
2206
|
+
out.push(samples[i]);
|
|
2207
|
+
out.push(Math.trunc((samples[i + 1] + samples[i + 2]) / 2));
|
|
2208
|
+
i += 3;
|
|
2032
2209
|
}
|
|
2033
|
-
|
|
2210
|
+
ctx.leftover = samples.slice(i);
|
|
2211
|
+
const buffer = Buffer.alloc(out.length * 2);
|
|
2212
|
+
for (let j = 0; j < out.length; j++) {
|
|
2213
|
+
buffer.writeInt16LE(out[j], j * 2);
|
|
2214
|
+
}
|
|
2215
|
+
return buffer;
|
|
2216
|
+
}
|
|
2217
|
+
/** @deprecated use {@link resampleStreaming} with persistent state. */
|
|
2218
|
+
static resample24kTo16k(audio) {
|
|
2219
|
+
const ctx = { carryByte: null, leftover: [] };
|
|
2220
|
+
const out = _OpenAITTS.resampleStreaming(audio, ctx);
|
|
2221
|
+
if (ctx.leftover.length === 0) return out;
|
|
2222
|
+
const tail = Buffer.alloc(ctx.leftover.length * 2);
|
|
2223
|
+
for (let i = 0; i < ctx.leftover.length; i++) {
|
|
2224
|
+
tail.writeInt16LE(ctx.leftover[i], i * 2);
|
|
2225
|
+
}
|
|
2226
|
+
return Buffer.concat([out, tail]);
|
|
2034
2227
|
}
|
|
2035
2228
|
};
|
|
2036
2229
|
}
|
|
@@ -2972,6 +3165,9 @@ var init_stream_handler = __esm({
|
|
|
2972
3165
|
maxDurationTimer = null;
|
|
2973
3166
|
transcriptProcessing = false;
|
|
2974
3167
|
transcriptQueue = [];
|
|
3168
|
+
// BUG #22 throttle state — mirror Python impl.
|
|
3169
|
+
lastCommitText = "";
|
|
3170
|
+
lastCommitAt = 0;
|
|
2975
3171
|
history;
|
|
2976
3172
|
metricsAcc;
|
|
2977
3173
|
constructor(deps, ws, caller, callee) {
|
|
@@ -3082,15 +3278,23 @@ var init_stream_handler = __esm({
|
|
|
3082
3278
|
this.streamSid = sid;
|
|
3083
3279
|
}
|
|
3084
3280
|
/** Handle an incoming audio chunk (already decoded from base64). */
|
|
3085
|
-
handleAudio(audioBuffer) {
|
|
3281
|
+
async handleAudio(audioBuffer) {
|
|
3086
3282
|
const provider = this.deps.agent.provider ?? "openai_realtime";
|
|
3087
|
-
if (provider === "pipeline" && this.stt
|
|
3088
|
-
if (this.deps.
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3283
|
+
if (provider === "pipeline" && this.stt) {
|
|
3284
|
+
if (this.isSpeaking && (this.deps.agent.bargeInThresholdMs ?? 300) === 0) {
|
|
3285
|
+
return;
|
|
3286
|
+
}
|
|
3287
|
+
const pcm8k = mulawToPcm16(audioBuffer);
|
|
3288
|
+
const pcm16k = resample8kTo16k(pcm8k);
|
|
3289
|
+
const hooks = this.deps.agent.hooks;
|
|
3290
|
+
if (hooks) {
|
|
3291
|
+
const hookExecutor = new PipelineHookExecutor(hooks);
|
|
3292
|
+
const hookCtx = this.buildHookContext();
|
|
3293
|
+
const processed = await hookExecutor.runBeforeSendToStt(pcm16k, hookCtx);
|
|
3294
|
+
if (processed === null) return;
|
|
3295
|
+
this.stt.sendAudio(processed);
|
|
3092
3296
|
} else {
|
|
3093
|
-
this.stt.sendAudio(
|
|
3297
|
+
this.stt.sendAudio(pcm16k);
|
|
3094
3298
|
}
|
|
3095
3299
|
} else if (this.adapter) {
|
|
3096
3300
|
if (this.adapter instanceof ElevenLabsConvAIAdapter && this.deps.bridge.telephonyProvider === "twilio") {
|
|
@@ -3172,8 +3376,7 @@ var init_stream_handler = __esm({
|
|
|
3172
3376
|
this.tts = new OpenAITTS(this.deps.agent.tts.apiKey, this.deps.agent.tts.voice ?? "alloy");
|
|
3173
3377
|
}
|
|
3174
3378
|
} else if (this.deps.agent.elevenlabsKey) {
|
|
3175
|
-
|
|
3176
|
-
this.tts = new ElevenLabsTTS(this.deps.agent.elevenlabsKey, voiceId);
|
|
3379
|
+
this.tts = new ElevenLabsTTS(this.deps.agent.elevenlabsKey, this.deps.agent.voice || "rachel");
|
|
3177
3380
|
}
|
|
3178
3381
|
if (!this.stt) {
|
|
3179
3382
|
getLogger().info(`Pipeline mode (${label}): no STT configured`);
|
|
@@ -3285,7 +3488,59 @@ var init_stream_handler = __esm({
|
|
|
3285
3488
|
}
|
|
3286
3489
|
}
|
|
3287
3490
|
async processTranscript(transcript) {
|
|
3491
|
+
if (transcript.text && this.isSpeaking) {
|
|
3492
|
+
getLogger().info(
|
|
3493
|
+
`Barge-in: caller spoke over agent (${sanitizeLogValue(transcript.text.slice(0, 40))})`
|
|
3494
|
+
);
|
|
3495
|
+
this.isSpeaking = false;
|
|
3496
|
+
try {
|
|
3497
|
+
this.deps.bridge.sendClear(this.ws, this.streamSid);
|
|
3498
|
+
} catch (err) {
|
|
3499
|
+
getLogger().debug(`sendClear during barge-in failed: ${String(err)}`);
|
|
3500
|
+
}
|
|
3501
|
+
this.metricsAcc.recordTurnInterrupted();
|
|
3502
|
+
}
|
|
3288
3503
|
if (!transcript.isFinal || !transcript.text) return;
|
|
3504
|
+
const now = Date.now();
|
|
3505
|
+
const normalised = transcript.text.trim().toLowerCase();
|
|
3506
|
+
const stripped = normalised.replace(/[.,!?;: ]+$/, "").trim();
|
|
3507
|
+
const sinceLastMs = now - this.lastCommitAt;
|
|
3508
|
+
const HALLUCINATIONS = /* @__PURE__ */ new Set([
|
|
3509
|
+
"you",
|
|
3510
|
+
"thank you",
|
|
3511
|
+
"thanks",
|
|
3512
|
+
"yeah",
|
|
3513
|
+
"yes",
|
|
3514
|
+
"no",
|
|
3515
|
+
"okay",
|
|
3516
|
+
"ok",
|
|
3517
|
+
"uh",
|
|
3518
|
+
"um",
|
|
3519
|
+
"mmm",
|
|
3520
|
+
"hmm",
|
|
3521
|
+
".",
|
|
3522
|
+
"bye",
|
|
3523
|
+
"right",
|
|
3524
|
+
"cool"
|
|
3525
|
+
]);
|
|
3526
|
+
if (HALLUCINATIONS.has(stripped) || stripped === "") {
|
|
3527
|
+
getLogger().info(`Dropped likely STT hallucination: ${sanitizeLogValue(normalised.slice(0, 40))}`);
|
|
3528
|
+
return;
|
|
3529
|
+
}
|
|
3530
|
+
if (sinceLastMs < 2e3 && normalised === this.lastCommitText) {
|
|
3531
|
+
getLogger().info(
|
|
3532
|
+
`Dropped duplicate final transcript (${(sinceLastMs / 1e3).toFixed(1)}s since last): ${sanitizeLogValue(normalised.slice(0, 40))}`
|
|
3533
|
+
);
|
|
3534
|
+
return;
|
|
3535
|
+
}
|
|
3536
|
+
if (sinceLastMs < 500) {
|
|
3537
|
+
getLogger().info(
|
|
3538
|
+
`Dropped back-to-back final transcript (${(sinceLastMs / 1e3).toFixed(2)}s since last): ${sanitizeLogValue(normalised.slice(0, 40))}`
|
|
3539
|
+
);
|
|
3540
|
+
return;
|
|
3541
|
+
}
|
|
3542
|
+
this.lastCommitText = normalised;
|
|
3543
|
+
this.lastCommitAt = now;
|
|
3289
3544
|
const label = this.deps.bridge.label;
|
|
3290
3545
|
getLogger().info(`User (${label} pipeline): ${sanitizeLogValue(transcript.text)}`);
|
|
3291
3546
|
this.metricsAcc.startTurn();
|
|
@@ -3770,6 +4025,25 @@ function buildAIAdapter(config, agent, resolvedPrompt) {
|
|
|
3770
4025
|
tools
|
|
3771
4026
|
);
|
|
3772
4027
|
}
|
|
4028
|
+
function extractDeepgramOptions(options) {
|
|
4029
|
+
if (!options) return {};
|
|
4030
|
+
const get = (snake, camel) => options[snake] ?? options[camel];
|
|
4031
|
+
const out = {};
|
|
4032
|
+
const model = get("model", "model");
|
|
4033
|
+
if (typeof model === "string") out.model = model;
|
|
4034
|
+
const endpointing = get("endpointing_ms", "endpointingMs");
|
|
4035
|
+
if (typeof endpointing === "number") out.endpointingMs = endpointing;
|
|
4036
|
+
const utteranceEnd = get("utterance_end_ms", "utteranceEndMs");
|
|
4037
|
+
if (utteranceEnd === null) out.utteranceEndMs = null;
|
|
4038
|
+
else if (typeof utteranceEnd === "number") out.utteranceEndMs = utteranceEnd;
|
|
4039
|
+
const smart = get("smart_format", "smartFormat");
|
|
4040
|
+
if (typeof smart === "boolean") out.smartFormat = smart;
|
|
4041
|
+
const interim = get("interim_results", "interimResults");
|
|
4042
|
+
if (typeof interim === "boolean") out.interimResults = interim;
|
|
4043
|
+
const vad = get("vad_events", "vadEvents");
|
|
4044
|
+
if (typeof vad === "boolean") out.vadEvents = vad;
|
|
4045
|
+
return out;
|
|
4046
|
+
}
|
|
3773
4047
|
function isValidTelnyxTransferTarget(target) {
|
|
3774
4048
|
if (typeof target !== "string" || !target) return false;
|
|
3775
4049
|
if (/^\+[1-9]\d{6,14}$/.test(target)) return true;
|
|
@@ -3875,13 +4149,21 @@ var init_server = __esm({
|
|
|
3875
4149
|
}
|
|
3876
4150
|
}
|
|
3877
4151
|
createStt(agent) {
|
|
4152
|
+
const isPipeline = agent.provider === "pipeline";
|
|
3878
4153
|
if (agent.stt) {
|
|
3879
4154
|
if (agent.stt.provider === "deepgram") {
|
|
3880
|
-
|
|
4155
|
+
const dgOptions = extractDeepgramOptions(agent.stt.options);
|
|
4156
|
+
if (isPipeline) {
|
|
4157
|
+
return new DeepgramSTT(agent.stt.apiKey, agent.stt.language ?? "en", dgOptions.model, "linear16", 16e3, dgOptions);
|
|
4158
|
+
}
|
|
4159
|
+
return DeepgramSTT.forTwilio(agent.stt.apiKey, agent.stt.language ?? "en", dgOptions.model, dgOptions);
|
|
3881
4160
|
} else if (agent.stt.provider === "whisper") {
|
|
3882
|
-
return WhisperSTT.forTwilio(agent.stt.apiKey, agent.stt.language ?? "en");
|
|
4161
|
+
return isPipeline ? new WhisperSTT(agent.stt.apiKey, "whisper-1", agent.stt.language ?? "en") : WhisperSTT.forTwilio(agent.stt.apiKey, agent.stt.language ?? "en");
|
|
3883
4162
|
}
|
|
3884
4163
|
} else if (agent.deepgramKey) {
|
|
4164
|
+
if (isPipeline) {
|
|
4165
|
+
return new DeepgramSTT(agent.deepgramKey, agent.language ?? "en", "nova-3", "linear16", 16e3);
|
|
4166
|
+
}
|
|
3885
4167
|
return DeepgramSTT.forTwilio(agent.deepgramKey, agent.language ?? "en");
|
|
3886
4168
|
}
|
|
3887
4169
|
return null;
|
|
@@ -3923,12 +4205,12 @@ var init_server = __esm({
|
|
|
3923
4205
|
label = "Telnyx";
|
|
3924
4206
|
telephonyProvider = "telnyx";
|
|
3925
4207
|
sendAudio(ws, audioBase64, _streamSid) {
|
|
3926
|
-
ws.send(JSON.stringify({
|
|
4208
|
+
ws.send(JSON.stringify({ event: "media", media: { payload: audioBase64 } }));
|
|
3927
4209
|
}
|
|
3928
4210
|
sendMark(_ws, _markName, _streamSid) {
|
|
3929
4211
|
}
|
|
3930
4212
|
sendClear(ws, _streamSid) {
|
|
3931
|
-
ws.send(JSON.stringify({
|
|
4213
|
+
ws.send(JSON.stringify({ event: "clear" }));
|
|
3932
4214
|
}
|
|
3933
4215
|
async transferCall(callId, toNumber) {
|
|
3934
4216
|
if (!isValidTelnyxTransferTarget(toNumber)) {
|
|
@@ -4024,7 +4306,15 @@ var init_server = __esm({
|
|
|
4024
4306
|
createStt(agent) {
|
|
4025
4307
|
if (agent.stt) {
|
|
4026
4308
|
if (agent.stt.provider === "deepgram") {
|
|
4027
|
-
|
|
4309
|
+
const dgOptions = extractDeepgramOptions(agent.stt.options);
|
|
4310
|
+
return new DeepgramSTT(
|
|
4311
|
+
agent.stt.apiKey,
|
|
4312
|
+
agent.stt.language ?? "en",
|
|
4313
|
+
dgOptions.model ?? "nova-3",
|
|
4314
|
+
"linear16",
|
|
4315
|
+
16e3,
|
|
4316
|
+
dgOptions
|
|
4317
|
+
);
|
|
4028
4318
|
} else if (agent.stt.provider === "whisper") {
|
|
4029
4319
|
return new WhisperSTT(agent.stt.apiKey, "whisper-1", agent.stt.language ?? "en");
|
|
4030
4320
|
}
|
|
@@ -4076,6 +4366,7 @@ var init_server = __esm({
|
|
|
4076
4366
|
server = null;
|
|
4077
4367
|
wss = null;
|
|
4078
4368
|
twilioTokenWarningLogged = false;
|
|
4369
|
+
telnyxSigWarningLogged = false;
|
|
4079
4370
|
metricsStore;
|
|
4080
4371
|
pricing;
|
|
4081
4372
|
remoteHandler = new RemoteMessageHandler();
|
|
@@ -4123,6 +4414,31 @@ var init_server = __esm({
|
|
|
4123
4414
|
mountApi(app, this.metricsStore, this.dashboardToken);
|
|
4124
4415
|
getLogger().info("Dashboard: http://127.0.0.1:" + port + "/");
|
|
4125
4416
|
}
|
|
4417
|
+
app.post("/webhooks/twilio/status", (req, res) => {
|
|
4418
|
+
if (this.config.twilioToken) {
|
|
4419
|
+
const signature = req.headers["x-twilio-signature"] || "";
|
|
4420
|
+
const url = `https://${this.config.webhookUrl}${req.originalUrl}`;
|
|
4421
|
+
const params = req.body ?? {};
|
|
4422
|
+
if (!validateTwilioSignature(url, params, signature, this.config.twilioToken)) {
|
|
4423
|
+
res.status(403).send("Invalid signature");
|
|
4424
|
+
return;
|
|
4425
|
+
}
|
|
4426
|
+
}
|
|
4427
|
+
const body = req.body;
|
|
4428
|
+
const callSid = sanitizeLogValue(body["CallSid"] ?? "");
|
|
4429
|
+
const callStatus = sanitizeLogValue(body["CallStatus"] ?? "");
|
|
4430
|
+
const duration = body["CallDuration"] ?? body["Duration"] ?? "";
|
|
4431
|
+
getLogger().info(
|
|
4432
|
+
`Twilio status ${callStatus} for call ${callSid} (duration=${duration})`
|
|
4433
|
+
);
|
|
4434
|
+
if (callSid && callStatus) {
|
|
4435
|
+
const extra = {};
|
|
4436
|
+
const parsed = parseFloat(duration);
|
|
4437
|
+
if (!Number.isNaN(parsed)) extra.duration_seconds = parsed;
|
|
4438
|
+
this.metricsStore.updateCallStatus(callSid, callStatus, extra);
|
|
4439
|
+
}
|
|
4440
|
+
res.status(204).send();
|
|
4441
|
+
});
|
|
4126
4442
|
app.post("/webhooks/twilio/recording", (req, res) => {
|
|
4127
4443
|
if (this.config.twilioToken) {
|
|
4128
4444
|
const signature = req.headers["x-twilio-signature"] || "";
|
|
@@ -4208,7 +4524,7 @@ var init_server = __esm({
|
|
|
4208
4524
|
const twiml = `<?xml version="1.0" encoding="UTF-8"?><Response><Connect><Stream url="${xmlStreamUrl}"><Parameter name="caller" value="${xmlEscape(caller)}"/><Parameter name="callee" value="${xmlEscape(callee)}"/></Stream></Connect></Response>`;
|
|
4209
4525
|
res.type("text/xml").send(twiml);
|
|
4210
4526
|
});
|
|
4211
|
-
app.post("/webhooks/telnyx/voice", (req, res) => {
|
|
4527
|
+
app.post("/webhooks/telnyx/voice", async (req, res) => {
|
|
4212
4528
|
if (this.config.telnyxPublicKey) {
|
|
4213
4529
|
const rawBody = req.rawBody ?? "";
|
|
4214
4530
|
const signature = req.headers["telnyx-signature-ed25519"] ?? "";
|
|
@@ -4217,7 +4533,8 @@ var init_server = __esm({
|
|
|
4217
4533
|
getLogger().warn("Telnyx webhook rejected: invalid or missing Ed25519 signature");
|
|
4218
4534
|
return res.status(403).send("Invalid signature");
|
|
4219
4535
|
}
|
|
4220
|
-
} else {
|
|
4536
|
+
} else if (!this.telnyxSigWarningLogged) {
|
|
4537
|
+
this.telnyxSigWarningLogged = true;
|
|
4221
4538
|
getLogger().warn("Telnyx webhook signature verification is disabled. Set telnyxPublicKey in LocalOptions for production use.");
|
|
4222
4539
|
}
|
|
4223
4540
|
const body = req.body;
|
|
@@ -4227,41 +4544,77 @@ var init_server = __esm({
|
|
|
4227
4544
|
if (typeof body.data.event_type !== "string" || typeof body.data.payload !== "object" || body.data.payload === null) {
|
|
4228
4545
|
return res.status(400).send("Invalid body");
|
|
4229
4546
|
}
|
|
4230
|
-
const eventType = body
|
|
4547
|
+
const eventType = body.data.event_type ?? "";
|
|
4548
|
+
const payload = body.data.payload ?? {};
|
|
4231
4549
|
if (eventType === "call.dtmf.received") {
|
|
4232
|
-
const digit = String(
|
|
4550
|
+
const digit = String(payload.digit ?? "").trim();
|
|
4233
4551
|
if (digit) {
|
|
4234
4552
|
getLogger().info(`Telnyx DTMF received (webhook): ${sanitizeLogValue(digit)}`);
|
|
4235
4553
|
}
|
|
4236
|
-
return res.
|
|
4554
|
+
return res.status(200).send();
|
|
4237
4555
|
}
|
|
4238
4556
|
if (eventType === "call.recording.saved") {
|
|
4239
|
-
const recordingUrl =
|
|
4557
|
+
const recordingUrl = payload.recording_urls?.mp3 ?? payload.recording_urls?.wav ?? payload.public_recording_urls?.mp3 ?? "";
|
|
4240
4558
|
if (recordingUrl) {
|
|
4241
4559
|
getLogger().info(`Telnyx recording saved (webhook): ${sanitizeLogValue(recordingUrl)}`);
|
|
4242
4560
|
}
|
|
4243
|
-
return res.
|
|
4244
|
-
}
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4248
|
-
|
|
4249
|
-
|
|
4250
|
-
|
|
4251
|
-
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4561
|
+
return res.status(200).send();
|
|
4562
|
+
}
|
|
4563
|
+
const callControlId = payload.call_control_id ?? "";
|
|
4564
|
+
if (!callControlId) {
|
|
4565
|
+
getLogger().warn("Telnyx webhook rejected: missing call_control_id");
|
|
4566
|
+
return res.status(400).send("Invalid webhook payload");
|
|
4567
|
+
}
|
|
4568
|
+
const apiKey = this.config.telnyxKey;
|
|
4569
|
+
if (!apiKey) {
|
|
4570
|
+
getLogger().warn("Telnyx webhook: missing telnyxKey in LocalOptions");
|
|
4571
|
+
return res.status(500).send("Missing Telnyx API key");
|
|
4572
|
+
}
|
|
4573
|
+
const apiBase = "https://api.telnyx.com/v2";
|
|
4574
|
+
const authHeaders = {
|
|
4575
|
+
"Content-Type": "application/json",
|
|
4576
|
+
Authorization: `Bearer ${apiKey}`
|
|
4577
|
+
};
|
|
4578
|
+
try {
|
|
4579
|
+
if (eventType === "call.initiated") {
|
|
4580
|
+
getLogger().info(`Telnyx call.initiated ${callControlId} \u2014 answering`);
|
|
4581
|
+
const resp = await fetch(`${apiBase}/calls/${encodeURIComponent(callControlId)}/actions/answer`, {
|
|
4582
|
+
method: "POST",
|
|
4583
|
+
headers: authHeaders,
|
|
4584
|
+
body: JSON.stringify({}),
|
|
4585
|
+
signal: AbortSignal.timeout(1e4)
|
|
4586
|
+
});
|
|
4587
|
+
if (!resp.ok) {
|
|
4588
|
+
getLogger().warn(`Telnyx answer failed: ${resp.status} ${(await resp.text()).slice(0, 200)}`);
|
|
4589
|
+
}
|
|
4590
|
+
} else if (eventType === "call.answered") {
|
|
4591
|
+
const caller = payload.from ?? "";
|
|
4592
|
+
const callee = payload.to ?? "";
|
|
4593
|
+
const streamUrl = `wss://${this.config.webhookUrl}/ws/stream/${encodeURIComponent(callControlId)}?caller=${encodeURIComponent(caller)}&callee=${encodeURIComponent(callee)}`;
|
|
4594
|
+
getLogger().info(`Telnyx call.answered ${callControlId} \u2014 starting stream`);
|
|
4595
|
+
const resp = await fetch(`${apiBase}/calls/${encodeURIComponent(callControlId)}/actions/streaming_start`, {
|
|
4596
|
+
method: "POST",
|
|
4597
|
+
headers: authHeaders,
|
|
4598
|
+
body: JSON.stringify({
|
|
4256
4599
|
stream_url: streamUrl,
|
|
4257
|
-
stream_track: "both_tracks"
|
|
4258
|
-
|
|
4600
|
+
stream_track: "both_tracks",
|
|
4601
|
+
stream_bidirectional_mode: "rtp",
|
|
4602
|
+
stream_bidirectional_codec: "PCMU",
|
|
4603
|
+
stream_bidirectional_sampling_rate: 8e3,
|
|
4604
|
+
stream_bidirectional_target_legs: "self"
|
|
4605
|
+
}),
|
|
4606
|
+
signal: AbortSignal.timeout(1e4)
|
|
4607
|
+
});
|
|
4608
|
+
if (!resp.ok) {
|
|
4609
|
+
getLogger().warn(`Telnyx streaming_start failed: ${resp.status} ${(await resp.text()).slice(0, 200)}`);
|
|
4259
4610
|
}
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
4611
|
+
} else {
|
|
4612
|
+
getLogger().debug(`Telnyx event ignored: ${eventType}`);
|
|
4613
|
+
}
|
|
4614
|
+
} catch (e) {
|
|
4615
|
+
getLogger().error(`Telnyx webhook handler error: ${String(e)}`);
|
|
4264
4616
|
}
|
|
4617
|
+
return res.status(200).send();
|
|
4265
4618
|
});
|
|
4266
4619
|
this.server = (0, import_http.createServer)(app);
|
|
4267
4620
|
this.wss = new import_ws5.WebSocketServer({ noServer: true });
|
|
@@ -4408,11 +4761,12 @@ Connect AI agents to phone numbers in 4 lines of code
|
|
|
4408
4761
|
getLogger().error("Failed to parse Telnyx WS message:", e);
|
|
4409
4762
|
return;
|
|
4410
4763
|
}
|
|
4411
|
-
const
|
|
4412
|
-
|
|
4413
|
-
|
|
4764
|
+
const event = data.event ?? "";
|
|
4765
|
+
if (event === "connected") return;
|
|
4766
|
+
getLogger().info(`Telnyx event: ${event}`);
|
|
4767
|
+
if (event === "start" && !streamStarted) {
|
|
4414
4768
|
streamStarted = true;
|
|
4415
|
-
const callControlId = data.
|
|
4769
|
+
const callControlId = data.start?.call_control_id ?? "";
|
|
4416
4770
|
if (callControlId) this.activeCallIds.set(ws, callControlId);
|
|
4417
4771
|
await handler.handleCallStart(callControlId);
|
|
4418
4772
|
if (this.recording) {
|
|
@@ -4422,22 +4776,21 @@ Connect AI agents to phone numbers in 4 lines of code
|
|
|
4422
4776
|
getLogger().warn(`Could not start recording: ${String(e)}`);
|
|
4423
4777
|
}
|
|
4424
4778
|
}
|
|
4425
|
-
} else if (
|
|
4426
|
-
const
|
|
4779
|
+
} else if (event === "media") {
|
|
4780
|
+
const track = data.media?.track ?? "inbound";
|
|
4781
|
+
if (track !== "inbound") return;
|
|
4782
|
+
const audioChunk = data.media?.payload ?? "";
|
|
4427
4783
|
if (!audioChunk) return;
|
|
4428
4784
|
handler.handleAudio(Buffer.from(audioChunk, "base64"));
|
|
4429
|
-
} else if (
|
|
4430
|
-
const digit = String(data.
|
|
4785
|
+
} else if (event === "dtmf") {
|
|
4786
|
+
const digit = String(data.dtmf?.digit ?? "").trim();
|
|
4431
4787
|
if (digit) {
|
|
4432
4788
|
getLogger().info(`Telnyx DTMF received: ${digit}`);
|
|
4433
4789
|
await handler.handleDtmf(digit);
|
|
4434
4790
|
}
|
|
4435
|
-
} else if (
|
|
4436
|
-
|
|
4437
|
-
|
|
4438
|
-
getLogger().info(`Telnyx recording saved: ${recordingUrl}`);
|
|
4439
|
-
}
|
|
4440
|
-
} else if (eventType === "stream_stopped") {
|
|
4791
|
+
} else if (event === "error") {
|
|
4792
|
+
getLogger().warn(`Telnyx stream error: ${JSON.stringify(data)}`);
|
|
4793
|
+
} else if (event === "stop") {
|
|
4441
4794
|
await handler.handleStop();
|
|
4442
4795
|
}
|
|
4443
4796
|
} catch (err) {
|
|
@@ -6748,13 +7101,21 @@ var STTConfigImpl = class {
|
|
|
6748
7101
|
provider;
|
|
6749
7102
|
apiKey;
|
|
6750
7103
|
language;
|
|
6751
|
-
|
|
7104
|
+
options;
|
|
7105
|
+
constructor(provider, apiKey, language = "en", options) {
|
|
6752
7106
|
this.provider = provider;
|
|
6753
7107
|
this.apiKey = apiKey;
|
|
6754
7108
|
this.language = language;
|
|
7109
|
+
if (options) this.options = options;
|
|
6755
7110
|
}
|
|
6756
7111
|
toDict() {
|
|
6757
|
-
|
|
7112
|
+
const out = {
|
|
7113
|
+
provider: this.provider,
|
|
7114
|
+
api_key: this.apiKey,
|
|
7115
|
+
language: this.language
|
|
7116
|
+
};
|
|
7117
|
+
if (this.options) out.options = { ...this.options };
|
|
7118
|
+
return out;
|
|
6758
7119
|
}
|
|
6759
7120
|
};
|
|
6760
7121
|
var TTSConfigImpl = class {
|
|
@@ -6771,7 +7132,15 @@ var TTSConfigImpl = class {
|
|
|
6771
7132
|
}
|
|
6772
7133
|
};
|
|
6773
7134
|
function deepgram(opts) {
|
|
6774
|
-
|
|
7135
|
+
const options = {
|
|
7136
|
+
model: opts.model ?? "nova-3",
|
|
7137
|
+
endpointing_ms: opts.endpointingMs ?? 150,
|
|
7138
|
+
utterance_end_ms: opts.utteranceEndMs === null ? null : opts.utteranceEndMs ?? 1e3,
|
|
7139
|
+
smart_format: opts.smartFormat ?? true,
|
|
7140
|
+
interim_results: opts.interimResults ?? true
|
|
7141
|
+
};
|
|
7142
|
+
if (opts.vadEvents !== void 0) options.vad_events = opts.vadEvents;
|
|
7143
|
+
return new STTConfigImpl("deepgram", opts.apiKey, opts.language ?? "en", options);
|
|
6775
7144
|
}
|
|
6776
7145
|
function whisper(opts) {
|
|
6777
7146
|
return new STTConfigImpl("whisper", opts.apiKey, opts.language ?? "en");
|
|
@@ -6782,11 +7151,42 @@ function elevenlabs(opts) {
|
|
|
6782
7151
|
function openaiTts(opts) {
|
|
6783
7152
|
return new TTSConfigImpl("openai", opts.apiKey, opts.voice ?? "alloy");
|
|
6784
7153
|
}
|
|
7154
|
+
function cartesia(opts) {
|
|
7155
|
+
return new TTSConfigImpl(
|
|
7156
|
+
"cartesia",
|
|
7157
|
+
opts.apiKey,
|
|
7158
|
+
opts.voice ?? "f786b574-daa5-4673-aa0c-cbe3e8534c02"
|
|
7159
|
+
);
|
|
7160
|
+
}
|
|
7161
|
+
function rime(opts) {
|
|
7162
|
+
return new TTSConfigImpl("rime", opts.apiKey, opts.voice ?? "astra");
|
|
7163
|
+
}
|
|
7164
|
+
function lmnt(opts) {
|
|
7165
|
+
return new TTSConfigImpl("lmnt", opts.apiKey, opts.voice ?? "leah");
|
|
7166
|
+
}
|
|
6785
7167
|
|
|
6786
7168
|
// src/client.ts
|
|
6787
7169
|
init_server();
|
|
6788
7170
|
var DEFAULT_BACKEND_URL2 = "wss://api.getpatter.com";
|
|
6789
7171
|
var DEFAULT_REST_URL = "https://api.getpatter.com";
|
|
7172
|
+
function sttConfigToDict(cfg) {
|
|
7173
|
+
const out = {
|
|
7174
|
+
provider: cfg.provider,
|
|
7175
|
+
api_key: cfg.apiKey,
|
|
7176
|
+
language: cfg.language
|
|
7177
|
+
};
|
|
7178
|
+
if (cfg.options) out.options = { ...cfg.options };
|
|
7179
|
+
return out;
|
|
7180
|
+
}
|
|
7181
|
+
function ttsConfigToDict(cfg) {
|
|
7182
|
+
const out = {
|
|
7183
|
+
provider: cfg.provider,
|
|
7184
|
+
api_key: cfg.apiKey,
|
|
7185
|
+
voice: cfg.voice
|
|
7186
|
+
};
|
|
7187
|
+
if (cfg.options) out.options = { ...cfg.options };
|
|
7188
|
+
return out;
|
|
7189
|
+
}
|
|
6790
7190
|
var Patter = class {
|
|
6791
7191
|
apiKey;
|
|
6792
7192
|
backendUrl;
|
|
@@ -6797,7 +7197,8 @@ var Patter = class {
|
|
|
6797
7197
|
embeddedServer = null;
|
|
6798
7198
|
tunnelHandle = null;
|
|
6799
7199
|
constructor(options) {
|
|
6800
|
-
|
|
7200
|
+
const isLocal = "mode" in options && options.mode === "local" || !("apiKey" in options) && ("twilioSid" in options && options.twilioSid || "telnyxKey" in options && options.telnyxKey);
|
|
7201
|
+
if (isLocal) {
|
|
6801
7202
|
const local = options;
|
|
6802
7203
|
if (!local.phoneNumber) {
|
|
6803
7204
|
throw new Error("Local mode requires phoneNumber");
|
|
@@ -6957,23 +7358,42 @@ var Patter = class {
|
|
|
6957
7358
|
const telnyxKey = this.localConfig.telnyxKey ?? "";
|
|
6958
7359
|
const connectionId = this.localConfig.telnyxConnectionId ?? "";
|
|
6959
7360
|
const streamUrl = `wss://${webhookUrl}/ws/stream/${encodeURIComponent(localOpts.to)}?caller=${encodeURIComponent(phoneNumber)}&callee=${encodeURIComponent(localOpts.to)}`;
|
|
7361
|
+
const telnyxPayload = {
|
|
7362
|
+
connection_id: connectionId,
|
|
7363
|
+
from: phoneNumber,
|
|
7364
|
+
to: localOpts.to,
|
|
7365
|
+
stream_url: streamUrl,
|
|
7366
|
+
stream_track: "both_tracks"
|
|
7367
|
+
};
|
|
7368
|
+
if (localOpts.ringTimeout !== void 0) {
|
|
7369
|
+
telnyxPayload.timeout_secs = Math.max(1, Math.floor(localOpts.ringTimeout));
|
|
7370
|
+
}
|
|
6960
7371
|
const response2 = await fetch("https://api.telnyx.com/v2/calls", {
|
|
6961
7372
|
method: "POST",
|
|
6962
7373
|
headers: {
|
|
6963
7374
|
"Content-Type": "application/json",
|
|
6964
7375
|
Authorization: `Bearer ${telnyxKey}`
|
|
6965
7376
|
},
|
|
6966
|
-
body: JSON.stringify(
|
|
6967
|
-
connection_id: connectionId,
|
|
6968
|
-
from: phoneNumber,
|
|
6969
|
-
to: localOpts.to,
|
|
6970
|
-
stream_url: streamUrl,
|
|
6971
|
-
stream_track: "both_tracks"
|
|
6972
|
-
})
|
|
7377
|
+
body: JSON.stringify(telnyxPayload)
|
|
6973
7378
|
});
|
|
6974
7379
|
if (!response2.ok) {
|
|
6975
7380
|
throw new ProvisionError(`Failed to initiate Telnyx call: ${await response2.text()}`);
|
|
6976
7381
|
}
|
|
7382
|
+
if (this.embeddedServer) {
|
|
7383
|
+
try {
|
|
7384
|
+
const body = await response2.clone().json();
|
|
7385
|
+
const callId = body.data?.call_control_id;
|
|
7386
|
+
if (callId) {
|
|
7387
|
+
this.embeddedServer.metricsStore.recordCallInitiated({
|
|
7388
|
+
call_id: callId,
|
|
7389
|
+
caller: phoneNumber,
|
|
7390
|
+
callee: localOpts.to,
|
|
7391
|
+
direction: "outbound"
|
|
7392
|
+
});
|
|
7393
|
+
}
|
|
7394
|
+
} catch {
|
|
7395
|
+
}
|
|
7396
|
+
}
|
|
6977
7397
|
return;
|
|
6978
7398
|
}
|
|
6979
7399
|
const twilioSid = this.localConfig.twilioSid ?? "";
|
|
@@ -6985,13 +7405,19 @@ var Patter = class {
|
|
|
6985
7405
|
From: phoneNumber,
|
|
6986
7406
|
Url: `https://${webhookUrl}/webhooks/twilio/voice`,
|
|
6987
7407
|
StatusCallback: statusCallbackUrl,
|
|
6988
|
-
StatusCallbackMethod: "POST"
|
|
7408
|
+
StatusCallbackMethod: "POST",
|
|
7409
|
+
// Full lifecycle so the dashboard sees ringing/no-answer/busy/failed
|
|
7410
|
+
// transitions even when media never arrives.
|
|
7411
|
+
StatusCallbackEvent: "initiated ringing answered completed"
|
|
6989
7412
|
});
|
|
6990
7413
|
if (localOpts.machineDetection) {
|
|
6991
7414
|
params.append("MachineDetection", "DetectMessageEnd");
|
|
6992
7415
|
params.append("AsyncAmd", "true");
|
|
6993
7416
|
params.append("AsyncAmdStatusCallback", `https://${webhookUrl}/webhooks/twilio/amd`);
|
|
6994
7417
|
}
|
|
7418
|
+
if (localOpts.ringTimeout !== void 0) {
|
|
7419
|
+
params.append("Timeout", String(Math.max(1, Math.floor(localOpts.ringTimeout))));
|
|
7420
|
+
}
|
|
6995
7421
|
if (localOpts.voicemailMessage && this.embeddedServer) {
|
|
6996
7422
|
this.embeddedServer.voicemailMessage = localOpts.voicemailMessage;
|
|
6997
7423
|
}
|
|
@@ -7006,6 +7432,21 @@ var Patter = class {
|
|
|
7006
7432
|
if (!response.ok) {
|
|
7007
7433
|
throw new ProvisionError(`Failed to initiate call: ${await response.text()}`);
|
|
7008
7434
|
}
|
|
7435
|
+
if (this.embeddedServer) {
|
|
7436
|
+
try {
|
|
7437
|
+
const body = await response.clone().json();
|
|
7438
|
+
const callSid = body.sid;
|
|
7439
|
+
if (callSid) {
|
|
7440
|
+
this.embeddedServer.metricsStore.recordCallInitiated({
|
|
7441
|
+
call_id: callSid,
|
|
7442
|
+
caller: phoneNumber,
|
|
7443
|
+
callee: localOpts.to,
|
|
7444
|
+
direction: "outbound"
|
|
7445
|
+
});
|
|
7446
|
+
}
|
|
7447
|
+
} catch {
|
|
7448
|
+
}
|
|
7449
|
+
}
|
|
7009
7450
|
return;
|
|
7010
7451
|
}
|
|
7011
7452
|
const cloudOpts = options;
|
|
@@ -7088,11 +7529,15 @@ var Patter = class {
|
|
|
7088
7529
|
const data = await response.json();
|
|
7089
7530
|
return data.map((c) => ({ id: c.id, direction: c.direction, caller: c.caller, callee: c.callee, startedAt: c.started_at, endedAt: c.ended_at, durationSeconds: c.duration_seconds, status: c.status, transcript: c.transcript }));
|
|
7090
7531
|
}
|
|
7091
|
-
// Provider helpers
|
|
7532
|
+
// Provider helpers — mirror the Python classmethod factories so callers can
|
|
7533
|
+
// write ``Patter.deepgram({ apiKey })`` without importing the top-level.
|
|
7092
7534
|
static deepgram = deepgram;
|
|
7093
7535
|
static whisper = whisper;
|
|
7094
7536
|
static elevenlabs = elevenlabs;
|
|
7095
7537
|
static openaiTts = openaiTts;
|
|
7538
|
+
static cartesia = cartesia;
|
|
7539
|
+
static rime = rime;
|
|
7540
|
+
static lmnt = lmnt;
|
|
7096
7541
|
static guardrail(opts) {
|
|
7097
7542
|
return {
|
|
7098
7543
|
name: opts.name,
|
|
@@ -7158,8 +7603,8 @@ var Patter = class {
|
|
|
7158
7603
|
provider,
|
|
7159
7604
|
provider_credentials: credentials,
|
|
7160
7605
|
country,
|
|
7161
|
-
stt_config: stt?.
|
|
7162
|
-
tts_config: tts?.
|
|
7606
|
+
stt_config: stt ? stt.toDict?.() ?? sttConfigToDict(stt) : null,
|
|
7607
|
+
tts_config: tts ? tts.toDict?.() ?? ttsConfigToDict(tts) : null
|
|
7163
7608
|
})
|
|
7164
7609
|
});
|
|
7165
7610
|
if (response.status === 409) return;
|
|
@@ -7293,6 +7738,37 @@ var FallbackLLMProvider = class {
|
|
|
7293
7738
|
}
|
|
7294
7739
|
}
|
|
7295
7740
|
}
|
|
7741
|
+
/**
|
|
7742
|
+
* Async-friendly disposer. Parity with Python's ``FallbackLLMProvider.aclose()``
|
|
7743
|
+
* — safe to call multiple times, returns a resolved Promise once all probe
|
|
7744
|
+
* timers are cleared. Prefer this in async contexts so awaiting the
|
|
7745
|
+
* shutdown integrates naturally with the owning lifecycle.
|
|
7746
|
+
*/
|
|
7747
|
+
async aclose() {
|
|
7748
|
+
this.destroy();
|
|
7749
|
+
}
|
|
7750
|
+
/**
|
|
7751
|
+
* Explicit-resource-management hook so callers can write
|
|
7752
|
+
* ``await using fallback = new FallbackLLMProvider([...])`` and have
|
|
7753
|
+
* background probe timers cleared automatically when the block exits.
|
|
7754
|
+
* Mirrors Python's ``async with FallbackLLMProvider(...)``.
|
|
7755
|
+
*/
|
|
7756
|
+
async [Symbol.asyncDispose]() {
|
|
7757
|
+
await this.aclose();
|
|
7758
|
+
}
|
|
7759
|
+
/**
|
|
7760
|
+
* Stream only the text deltas, flattening the chunk envelope. Parity with
|
|
7761
|
+
* Python's ``FallbackLLMProvider.complete_stream``. Tool-call and done
|
|
7762
|
+
* markers are filtered out so callers can concatenate the yielded strings
|
|
7763
|
+
* directly.
|
|
7764
|
+
*/
|
|
7765
|
+
async *completeStream(messages, tools) {
|
|
7766
|
+
for await (const chunk of this.stream(messages, tools)) {
|
|
7767
|
+
if (chunk.type === "text") {
|
|
7768
|
+
yield chunk.content ?? "";
|
|
7769
|
+
}
|
|
7770
|
+
}
|
|
7771
|
+
}
|
|
7296
7772
|
// -----------------------------------------------------------------------
|
|
7297
7773
|
// LLMProvider implementation
|
|
7298
7774
|
// -----------------------------------------------------------------------
|
|
@@ -7815,13 +8291,37 @@ function wrapCallback(cb) {
|
|
|
7815
8291
|
}
|
|
7816
8292
|
};
|
|
7817
8293
|
}
|
|
7818
|
-
|
|
7819
|
-
|
|
7820
|
-
|
|
7821
|
-
|
|
7822
|
-
|
|
7823
|
-
|
|
7824
|
-
|
|
8294
|
+
function scheduleCron(cron, callback) {
|
|
8295
|
+
let cancelled = false;
|
|
8296
|
+
let task = null;
|
|
8297
|
+
const jobId = `cron-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
8298
|
+
loadCron().then((cm) => {
|
|
8299
|
+
if (cancelled) return;
|
|
8300
|
+
if (!cm.validate(cron)) {
|
|
8301
|
+
throw new Error(`Invalid cron expression: ${cron}`);
|
|
8302
|
+
}
|
|
8303
|
+
task = cm.schedule(cron, wrapCallback(callback));
|
|
8304
|
+
}).catch((err) => getLogger().error(`scheduleCron failed: ${String(err)}`));
|
|
8305
|
+
return {
|
|
8306
|
+
jobId,
|
|
8307
|
+
cancel() {
|
|
8308
|
+
if (cancelled) return;
|
|
8309
|
+
cancelled = true;
|
|
8310
|
+
if (task) {
|
|
8311
|
+
try {
|
|
8312
|
+
task.stop();
|
|
8313
|
+
} catch {
|
|
8314
|
+
}
|
|
8315
|
+
try {
|
|
8316
|
+
task.destroy?.();
|
|
8317
|
+
} catch {
|
|
8318
|
+
}
|
|
8319
|
+
}
|
|
8320
|
+
},
|
|
8321
|
+
get pending() {
|
|
8322
|
+
return !cancelled;
|
|
8323
|
+
}
|
|
8324
|
+
};
|
|
7825
8325
|
}
|
|
7826
8326
|
function scheduleOnce(at, callback) {
|
|
7827
8327
|
const delayMs = at.getTime() - Date.now();
|
|
@@ -7843,8 +8343,18 @@ function scheduleOnce(at, callback) {
|
|
|
7843
8343
|
}
|
|
7844
8344
|
};
|
|
7845
8345
|
}
|
|
7846
|
-
function scheduleInterval(
|
|
7847
|
-
|
|
8346
|
+
function scheduleInterval(intervalOrOpts, callback) {
|
|
8347
|
+
let intervalMs;
|
|
8348
|
+
if (typeof intervalOrOpts === "number") {
|
|
8349
|
+
intervalMs = intervalOrOpts;
|
|
8350
|
+
} else if (intervalOrOpts.intervalMs !== void 0) {
|
|
8351
|
+
intervalMs = intervalOrOpts.intervalMs;
|
|
8352
|
+
} else if (intervalOrOpts.seconds !== void 0) {
|
|
8353
|
+
intervalMs = intervalOrOpts.seconds * 1e3;
|
|
8354
|
+
} else {
|
|
8355
|
+
throw new Error("scheduleInterval requires seconds or intervalMs");
|
|
8356
|
+
}
|
|
8357
|
+
if (intervalMs <= 0) throw new Error("interval must be positive");
|
|
7848
8358
|
let cancelled = false;
|
|
7849
8359
|
const wrapped = wrapCallback(callback);
|
|
7850
8360
|
const timer = setInterval(() => {
|
|
@@ -7861,27 +8371,6 @@ function scheduleInterval(intervalMs, callback) {
|
|
|
7861
8371
|
}
|
|
7862
8372
|
};
|
|
7863
8373
|
}
|
|
7864
|
-
function makeHandle(jobId, task) {
|
|
7865
|
-
let cancelled = false;
|
|
7866
|
-
return {
|
|
7867
|
-
jobId,
|
|
7868
|
-
cancel() {
|
|
7869
|
-
if (cancelled) return;
|
|
7870
|
-
cancelled = true;
|
|
7871
|
-
try {
|
|
7872
|
-
task.stop();
|
|
7873
|
-
} catch {
|
|
7874
|
-
}
|
|
7875
|
-
try {
|
|
7876
|
-
task.destroy?.();
|
|
7877
|
-
} catch {
|
|
7878
|
-
}
|
|
7879
|
-
},
|
|
7880
|
-
get pending() {
|
|
7881
|
-
return !cancelled;
|
|
7882
|
-
}
|
|
7883
|
-
};
|
|
7884
|
-
}
|
|
7885
8374
|
|
|
7886
8375
|
// src/index.ts
|
|
7887
8376
|
init_deepgram_stt();
|